first commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# Network
|
||||||
|
MCP_HOST=0.0.0.0
|
||||||
|
MCP_PORT=3000
|
||||||
|
MCP_ENDPOINT=/mcp
|
||||||
|
MCP_PUBLIC_URL=https://mcp.example.com
|
||||||
|
|
||||||
|
# Authentication. A token is required unless MCP_ALLOW_NO_AUTH=true.
|
||||||
|
MCP_AUTH_TOKEN=replace-with-a-long-random-token
|
||||||
|
MCP_ALLOW_NO_AUTH=false
|
||||||
|
|
||||||
|
# Host execution. No sandbox, approval, command allowlist, or path restriction is applied.
|
||||||
|
MCP_DEFAULT_CWD=/root
|
||||||
|
MCP_DEFAULT_SHELL=/bin/bash
|
||||||
|
|
||||||
|
# Operational limits for transport stability, not permission restrictions.
|
||||||
|
MCP_MAX_REQUEST_BODY=8mb
|
||||||
|
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
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Dependencies and generated output
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.vitest/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Local configuration and runtime state
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
*.pid.lock
|
||||||
|
*oauth-state*.json
|
||||||
|
.cache/
|
||||||
|
.tmp/
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Credentials and private keys
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
.npmrc
|
||||||
|
.ssh/
|
||||||
|
id_rsa
|
||||||
|
id_ed25519
|
||||||
|
|
||||||
|
# Local reference material and troubleshooting captures
|
||||||
|
/codex/
|
||||||
|
/oauth/
|
||||||
|
|
||||||
|
# Editors and operating systems
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 cokacremote contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# cokacremote
|
||||||
|
|
||||||
|
VPS 또는 EC2 인스턴스에서 상시 실행하는 Node.js 원격 개발 MCP 서버입니다. ChatGPT나 다른 MCP 클라이언트가 인스턴스의 셸, 프로세스, 파일 시스템을 직접 사용하도록 구성했습니다.
|
||||||
|
|
||||||
|
이 서버에는 작업공간 샌드박스, 명령 허용목록, 실행 승인, 경로 제한이 없습니다. 도구는 MCP 서버 프로세스의 실제 OS 권한을 그대로 사용합니다. `deploy/remote-dev-mcp.service`는 요구사항에 맞춰 `root`로 실행됩니다.
|
||||||
|
|
||||||
|
## 제공 도구
|
||||||
|
|
||||||
|
### 실행 및 프로세스
|
||||||
|
|
||||||
|
- `exec_command`: 셸 명령, 빌드, 테스트, 패키지 설치, Git, 서비스 관리, 로그 조회
|
||||||
|
- `run_script`: Bash, sh, Node.js, Python 또는 임의 인터프리터로 전체 스크립트 실행
|
||||||
|
- `write_stdin`: 장기 실행 프로세스에 입력을 쓰고 후속 출력 조회
|
||||||
|
- `read_process`: 출력 커서 기반 폴링과 종료 상태 조회
|
||||||
|
- `terminate_process`: 프로세스 그룹에 `SIGINT`, `SIGTERM`, `SIGKILL` 전달
|
||||||
|
- `list_processes`: 실행 중이거나 최근 완료된 세션 조회
|
||||||
|
|
||||||
|
### 파일 시스템
|
||||||
|
|
||||||
|
- `list_directory`, `stat_path`, `read_file`, `write_file`
|
||||||
|
- `replace_in_file`, `apply_patch`
|
||||||
|
- `upload_file`, `download_file`, `hash_file`
|
||||||
|
- `make_directory`, `copy_path`, `move_path`, `remove_path`, `chmod_path`
|
||||||
|
|
||||||
|
상대경로는 `MCP_DEFAULT_CWD`에서 해석되지만 절대경로와 `~/...`도 허용됩니다. 업로드와 다운로드는 `nextOffset`을 사용한 base64 청크 전송 방식입니다.
|
||||||
|
|
||||||
|
## 로컬 실행
|
||||||
|
|
||||||
|
Node.js 22 이상이 필요합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
export MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
|
||||||
|
export MCP_DEFAULT_CWD=/root
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
기본 MCP URL은 `http://0.0.0.0:3000/mcp`, 상태 확인 URL은 `/health`입니다.
|
||||||
|
|
||||||
|
개발 모드에서는 다음 명령을 사용할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MCP_AUTH_TOKEN=development-token npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 인증 방식
|
||||||
|
|
||||||
|
`MCP_AUTH_TOKEN`이 설정되면 모든 MCP 요청에 다음 헤더가 필요합니다.
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <MCP_AUTH_TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
ChatGPT 플러그인 연결용으로 내장 OAuth 2.1 Authorization Server를 활성화할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MCP_OAUTH_ENABLED=true
|
||||||
|
MCP_PUBLIC_URL=https://mcp.example.com
|
||||||
|
MCP_OAUTH_STATE_FILE=/var/lib/remote-dev-mcp/oauth-state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
활성화하면 다음 기능을 제공합니다.
|
||||||
|
|
||||||
|
- RFC 9728 Protected Resource Metadata
|
||||||
|
- RFC 8414 Authorization Server Metadata
|
||||||
|
- Dynamic Client Registration(DCR)
|
||||||
|
- Authorization Code + PKCE(S256)
|
||||||
|
- `resource` audience 검증
|
||||||
|
- 액세스 토큰, 회전형 refresh token, token revocation
|
||||||
|
|
||||||
|
ChatGPT에서 연결을 승인할 때 표시되는 로그인 화면에는 `MCP_AUTH_TOKEN` 값을 입력합니다. 이 값은 승인용 비밀번호 역할도 하며, 기존처럼 정적 Bearer 토큰으로 직접 호출하는 방식도 계속 지원됩니다. 등록 클라이언트와 토큰 해시는 `MCP_OAUTH_STATE_FILE`에 권한 `600`으로 저장됩니다.
|
||||||
|
|
||||||
|
인증을 서버 앞단의 OAuth 프록시나 사설 네트워크에서 처리한다면 다음과 같이 내장 토큰 검사를 끌 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MCP_ALLOW_NO_AUTH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
내장 OAuth 대신 외부 IdP 또는 OAuth 게이트웨이를 사용할 수도 있습니다. 이 경우 Node 서버는 `127.0.0.1`에만 바인딩하고 앞단에서 인증을 처리합니다. ChatGPT에서 익명 MCP로 직접 연결할 경우에는 `MCP_ALLOW_NO_AUTH=true`를 사용할 수 있습니다. 이 경우 URL을 아는 누구나 인스턴스의 전체 권한을 사용할 수 있다는 점은 의도된 동작입니다.
|
||||||
|
|
||||||
|
OpenAI의 현재 원격 MCP 인증 요구사항은 [MCP 서버 인증 문서](https://developers.openai.com/plugins/build/auth)에 정리되어 있습니다.
|
||||||
|
|
||||||
|
## VPS/EC2 배포
|
||||||
|
|
||||||
|
예시는 `/opt/remote-dev-mcp`에 설치하는 경우입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/remote-dev-mcp
|
||||||
|
sudo cp -a package.json package-lock.json tsconfig.json src deploy /opt/remote-dev-mcp/
|
||||||
|
cd /opt/remote-dev-mcp
|
||||||
|
sudo npm ci
|
||||||
|
sudo npm run build
|
||||||
|
sudo npm prune --omit=dev
|
||||||
|
|
||||||
|
sudo cp deploy/remote-dev-mcp.env.example /etc/remote-dev-mcp.env
|
||||||
|
sudo chmod 600 /etc/remote-dev-mcp.env
|
||||||
|
sudo editor /etc/remote-dev-mcp.env
|
||||||
|
|
||||||
|
sudo cp deploy/remote-dev-mcp.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now remote-dev-mcp
|
||||||
|
sudo systemctl status remote-dev-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
`/usr/bin/node`가 실제 Node.js 경로와 다르면 systemd 파일의 `ExecStart`를 수정합니다. `which node`로 확인할 수 있습니다.
|
||||||
|
|
||||||
|
공개 인터넷에서 사용할 때는 HTTPS가 필요합니다. [Nginx 예제](deploy/nginx.remote-dev-mcp.conf)의 도메인과 인증서 경로를 바꾼 뒤 활성화합니다. Streamable HTTP의 SSE 응답을 위해 proxy buffering을 비활성화하고 긴 read timeout을 사용합니다.
|
||||||
|
|
||||||
|
## ChatGPT 연결
|
||||||
|
|
||||||
|
배포 URL이 `https://mcp.example.com/mcp`라고 가정합니다.
|
||||||
|
|
||||||
|
- ChatGPT 인증 연결: `MCP_OAUTH_ENABLED=true`로 배포하고 해당 URL을 연결한 뒤, 승인 화면에 `MCP_AUTH_TOKEN`을 입력합니다.
|
||||||
|
- ChatGPT 익명 개발 연결: 내장 인증을 끄고 해당 URL을 연결합니다.
|
||||||
|
- 외부 인증 연결: 내장 OAuth 대신 OAuth 2.1 게이트웨이나 IdP를 사용할 수 있습니다.
|
||||||
|
- OpenAI Responses API: remote MCP 도구의 서버 URL을 지정하고 서버가 요구하는 인증 토큰을 전달합니다.
|
||||||
|
|
||||||
|
ChatGPT의 최신 연결 경로는 **Settings → Security and login → Developer mode**를 활성화한 뒤 플러그인 추가 화면에서 원격 MCP URL을 등록하는 방식입니다. 자세한 내용은 [OpenAI 원격 MCP 문서](https://developers.openai.com/api/docs/mcp)를 참고합니다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
테스트에는 실제 Streamable HTTP MCP 클라이언트 연결, bearer 인증, 도구 목록, `run_script`, 파일 읽기·쓰기, 장기 프로세스, 청크 전송 및 unified diff 적용이 포함됩니다.
|
||||||
|
|
||||||
|
## 주요 환경 변수
|
||||||
|
|
||||||
|
| 변수 | 기본값 | 설명 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `MCP_HOST` | `0.0.0.0` | HTTP 바인드 주소 |
|
||||||
|
| `MCP_PORT` | `3000` | HTTP 포트 |
|
||||||
|
| `MCP_ENDPOINT` | `/mcp` | Streamable HTTP MCP 경로 |
|
||||||
|
| `MCP_PUBLIC_URL` | 없음 | 외부 HTTPS 기준 URL |
|
||||||
|
| `MCP_AUTH_TOKEN` | 없음 | bearer 토큰 |
|
||||||
|
| `MCP_ALLOW_NO_AUTH` | `false` | 인증 없이 시작 허용 |
|
||||||
|
| `MCP_OAUTH_ENABLED` | `false` | ChatGPT용 내장 OAuth 2.1/DCR 활성화 |
|
||||||
|
| `MCP_OAUTH_ISSUER` | `MCP_PUBLIC_URL` | OAuth issuer URL |
|
||||||
|
| `MCP_OAUTH_RESOURCE` | `<MCP_PUBLIC_URL><MCP_ENDPOINT>` | MCP resource audience |
|
||||||
|
| `MCP_OAUTH_STATE_FILE` | 작업 디렉터리 내부 | 등록 클라이언트와 토큰 해시 저장 파일 |
|
||||||
|
| `MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS` | `3600` | OAuth 액세스 토큰 수명 |
|
||||||
|
| `MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | OAuth refresh token 수명 |
|
||||||
|
| `MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS` | `300` | 일회용 authorization code 수명 |
|
||||||
|
| `MCP_DEFAULT_CWD` | 서버 시작 디렉터리 | 상대경로 기준 |
|
||||||
|
| `MCP_DEFAULT_SHELL` | `$SHELL` 또는 `/bin/bash` | `exec_command` 기본 셸 |
|
||||||
|
| `MCP_MAX_OUTPUT_BYTES` | `1048576` | 한 도구 응답의 최대 출력 |
|
||||||
|
| `MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES` | `4194304` | 프로세스별 보관 출력 |
|
||||||
|
| `MCP_PROCESS_RETENTION_MS` | `3600000` | 완료 프로세스 보관 시간 |
|
||||||
|
| `MCP_SESSION_TTL_MS` | `86400000` | 유휴 MCP 세션 보관 시간 |
|
||||||
|
| `MCP_MAX_FILE_CHUNK_BYTES` | `1048576` | 파일 전송 청크 크기 |
|
||||||
|
|
||||||
|
## 라이선스
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name mcp.example.com;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name mcp.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 8m;
|
||||||
|
|
||||||
|
location = /mcp {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 24h;
|
||||||
|
proxy_send_timeout 24h;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /.well-known/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(authorize|token|register|revoke)$ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://127.0.0.1:3000/health;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
MCP_HOST=127.0.0.1
|
||||||
|
MCP_PORT=3000
|
||||||
|
MCP_ENDPOINT=/mcp
|
||||||
|
MCP_PUBLIC_URL=https://mcp.example.com
|
||||||
|
MCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1,localhost
|
||||||
|
MCP_AUTH_TOKEN=replace-with-a-long-random-token
|
||||||
|
MCP_ALLOW_NO_AUTH=false
|
||||||
|
MCP_OAUTH_ENABLED=true
|
||||||
|
MCP_OAUTH_ISSUER=https://mcp.example.com
|
||||||
|
MCP_OAUTH_RESOURCE=https://mcp.example.com/mcp
|
||||||
|
MCP_OAUTH_STATE_FILE=/var/lib/remote-dev-mcp/oauth-state.json
|
||||||
|
MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600
|
||||||
|
MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||||
|
MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS=300
|
||||||
|
MCP_DEFAULT_CWD=/root
|
||||||
|
MCP_DEFAULT_SHELL=/bin/bash
|
||||||
|
MCP_MAX_REQUEST_BODY=8mb
|
||||||
|
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
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=cokacremote MCP Server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
WorkingDirectory=/opt/remote-dev-mcp
|
||||||
|
EnvironmentFile=/etc/remote-dev-mcp.env
|
||||||
|
ExecStart=/usr/bin/node /opt/remote-dev-mcp/dist/src/server.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=20
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Generated
+3361
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "cokacremote",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Full-access remote development MCP server for VPS and EC2 hosts",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"start": "node dist/src/server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "1.30.0",
|
||||||
|
"express": "5.2.1",
|
||||||
|
"zod": "4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "5.0.6",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"tsx": "4.23.12",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import type { OAuthTokenVerifier } from "@modelcontextprotocol/sdk/server/auth/provider.js";
|
||||||
|
|
||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
|
||||||
|
export function tokensEqual(actual: string, expected: string): boolean {
|
||||||
|
const actualBuffer = Buffer.from(actual);
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
return (
|
||||||
|
actualBuffer.length === expectedBuffer.length &&
|
||||||
|
timingSafeEqual(actualBuffer, expectedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthResourceMetadataUrl(config: AppConfig): string {
|
||||||
|
const resource = new URL(config.oauthResourceUrl!);
|
||||||
|
const suffix = resource.pathname === "/" ? "" : resource.pathname;
|
||||||
|
return new URL(`/.well-known/oauth-protected-resource${suffix}`, resource).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBearerAuth(
|
||||||
|
config: AppConfig,
|
||||||
|
oauthVerifier?: OAuthTokenVerifier,
|
||||||
|
): RequestHandler {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
if (config.allowNoAuth && !config.authToken && !oauthVerifier) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = request.header("authorization");
|
||||||
|
const match = authorization?.match(/^Bearer\s+(.+)$/i);
|
||||||
|
const suppliedToken = match?.[1];
|
||||||
|
if (suppliedToken && config.authToken && tokensEqual(suppliedToken, config.authToken)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suppliedToken && oauthVerifier && config.oauthResourceUrl) {
|
||||||
|
try {
|
||||||
|
const authInfo = await oauthVerifier.verifyAccessToken(suppliedToken);
|
||||||
|
const expectedResource = new URL(config.oauthResourceUrl).href;
|
||||||
|
if (
|
||||||
|
authInfo.expiresAt !== undefined &&
|
||||||
|
authInfo.expiresAt >= Date.now() / 1000 &&
|
||||||
|
authInfo.resource?.href === expectedResource &&
|
||||||
|
authInfo.scopes.includes("mcp:tools")
|
||||||
|
) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Return the same challenge for every invalid token.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = config.oauthEnabled
|
||||||
|
? `Bearer realm="cokacremote", error="invalid_token", scope="mcp:tools", resource_metadata="${oauthResourceMetadataUrl(config)}"`
|
||||||
|
: 'Bearer realm="cokacremote"';
|
||||||
|
response.status(401).set("WWW-Authenticate", challenge).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code: -32001, message: "Unauthorized" },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHostValidation(config: AppConfig): RequestHandler {
|
||||||
|
return (request, response, next) => {
|
||||||
|
if (!config.allowedHosts || config.allowedHosts.length === 0) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawHost = request.header("host");
|
||||||
|
let hostname = "";
|
||||||
|
try {
|
||||||
|
hostname = new URL(`http://${rawHost ?? ""}`).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
// The empty value is rejected below.
|
||||||
|
}
|
||||||
|
if (!config.allowedHosts.includes(hostname)) {
|
||||||
|
response.status(403).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code: -32002, message: "Host header is not allowed" },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
endpoint: string;
|
||||||
|
publicUrl: string | undefined;
|
||||||
|
allowedHosts: string[] | undefined;
|
||||||
|
authToken: string | undefined;
|
||||||
|
allowNoAuth: boolean;
|
||||||
|
oauthEnabled: boolean;
|
||||||
|
oauthIssuerUrl: string | undefined;
|
||||||
|
oauthResourceUrl: string | undefined;
|
||||||
|
oauthStateFile: string;
|
||||||
|
oauthAccessTokenTtlSeconds: number;
|
||||||
|
oauthRefreshTokenTtlSeconds: number;
|
||||||
|
oauthAuthorizationCodeTtlSeconds: number;
|
||||||
|
defaultCwd: string;
|
||||||
|
defaultShell: string;
|
||||||
|
maxRequestBody: string;
|
||||||
|
maxOutputBytes: number;
|
||||||
|
maxRetainedProcessOutputBytes: number;
|
||||||
|
processRetentionMs: number;
|
||||||
|
maxProcesses: number;
|
||||||
|
sessionTtlMs: number;
|
||||||
|
maxFileChunkBytes: number;
|
||||||
|
maxEditFileBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (["1", "true", "yes", "on"].includes(value.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["0", "false", "no", "off"].includes(value.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid boolean value: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInteger(
|
||||||
|
value: string | undefined,
|
||||||
|
fallback: number,
|
||||||
|
name: string,
|
||||||
|
minimum: number,
|
||||||
|
): number {
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < minimum) {
|
||||||
|
throw new Error(`${name} must be an integer greater than or equal to ${minimum}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEndpoint(value: string | undefined): string {
|
||||||
|
const endpoint = value?.trim() || "/mcp";
|
||||||
|
if (!endpoint.startsWith("/")) {
|
||||||
|
throw new Error("MCP_ENDPOINT must start with '/'");
|
||||||
|
}
|
||||||
|
return endpoint.length > 1 ? endpoint.replace(/\/+$/, "") : endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOAuthUrl(value: string | undefined, name: string): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required when MCP_OAUTH_ENABLED=true`);
|
||||||
|
}
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${name} must be an absolute URL`);
|
||||||
|
}
|
||||||
|
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||||
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) {
|
||||||
|
throw new Error(`${name} must use HTTPS (HTTP is allowed only for loopback tests)`);
|
||||||
|
}
|
||||||
|
if (url.search || url.hash) {
|
||||||
|
throw new Error(`${name} must not contain a query string or fragment`);
|
||||||
|
}
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
processCwd = process.cwd(),
|
||||||
|
): AppConfig {
|
||||||
|
const allowNoAuth = parseBoolean(env.MCP_ALLOW_NO_AUTH, false);
|
||||||
|
const authToken = env.MCP_AUTH_TOKEN?.trim() || undefined;
|
||||||
|
const oauthEnabled = parseBoolean(env.MCP_OAUTH_ENABLED, false);
|
||||||
|
if (!allowNoAuth && !authToken) {
|
||||||
|
throw new Error(
|
||||||
|
"MCP_AUTH_TOKEN is required. Set MCP_ALLOW_NO_AUTH=true only when an upstream OAuth gateway or private network authenticates callers.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (oauthEnabled && !authToken) {
|
||||||
|
throw new Error("MCP_AUTH_TOKEN is required as the OAuth authorization access key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCwd = path.resolve(env.MCP_DEFAULT_CWD?.trim() || processCwd);
|
||||||
|
const allowedHosts = env.MCP_ALLOWED_HOSTS?.split(",")
|
||||||
|
.map((host) => host.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const endpoint = normalizeEndpoint(env.MCP_ENDPOINT);
|
||||||
|
const publicUrl = env.MCP_PUBLIC_URL?.trim().replace(/\/+$/, "") || undefined;
|
||||||
|
const oauthIssuerUrl = oauthEnabled
|
||||||
|
? normalizeOAuthUrl(env.MCP_OAUTH_ISSUER?.trim() || publicUrl, "MCP_OAUTH_ISSUER")
|
||||||
|
: undefined;
|
||||||
|
const oauthResourceUrl = oauthEnabled
|
||||||
|
? normalizeOAuthUrl(
|
||||||
|
env.MCP_OAUTH_RESOURCE?.trim() || (publicUrl ? `${publicUrl}${endpoint}` : undefined),
|
||||||
|
"MCP_OAUTH_RESOURCE",
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: env.MCP_HOST?.trim() || "0.0.0.0",
|
||||||
|
port: parseInteger(env.MCP_PORT, 3000, "MCP_PORT", 1),
|
||||||
|
endpoint,
|
||||||
|
publicUrl,
|
||||||
|
allowedHosts: allowedHosts && allowedHosts.length > 0 ? allowedHosts : undefined,
|
||||||
|
authToken,
|
||||||
|
allowNoAuth,
|
||||||
|
oauthEnabled,
|
||||||
|
oauthIssuerUrl,
|
||||||
|
oauthResourceUrl,
|
||||||
|
oauthStateFile: path.resolve(
|
||||||
|
env.MCP_OAUTH_STATE_FILE?.trim() ||
|
||||||
|
path.join(processCwd, ".remote-dev-mcp-oauth-state.json"),
|
||||||
|
),
|
||||||
|
oauthAccessTokenTtlSeconds: parseInteger(
|
||||||
|
env.MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||||
|
60 * 60,
|
||||||
|
"MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS",
|
||||||
|
300,
|
||||||
|
),
|
||||||
|
oauthRefreshTokenTtlSeconds: parseInteger(
|
||||||
|
env.MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS,
|
||||||
|
30 * 24 * 60 * 60,
|
||||||
|
"MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS",
|
||||||
|
3600,
|
||||||
|
),
|
||||||
|
oauthAuthorizationCodeTtlSeconds: parseInteger(
|
||||||
|
env.MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS,
|
||||||
|
5 * 60,
|
||||||
|
"MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS",
|
||||||
|
60,
|
||||||
|
),
|
||||||
|
defaultCwd,
|
||||||
|
defaultShell:
|
||||||
|
env.MCP_DEFAULT_SHELL?.trim() || env.SHELL?.trim() || "/bin/bash",
|
||||||
|
maxRequestBody: env.MCP_MAX_REQUEST_BODY?.trim() || "8mb",
|
||||||
|
maxOutputBytes: parseInteger(
|
||||||
|
env.MCP_MAX_OUTPUT_BYTES,
|
||||||
|
1024 * 1024,
|
||||||
|
"MCP_MAX_OUTPUT_BYTES",
|
||||||
|
16 * 1024,
|
||||||
|
),
|
||||||
|
maxRetainedProcessOutputBytes: parseInteger(
|
||||||
|
env.MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES,
|
||||||
|
4 * 1024 * 1024,
|
||||||
|
"MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES",
|
||||||
|
64 * 1024,
|
||||||
|
),
|
||||||
|
processRetentionMs: parseInteger(
|
||||||
|
env.MCP_PROCESS_RETENTION_MS,
|
||||||
|
60 * 60 * 1000,
|
||||||
|
"MCP_PROCESS_RETENTION_MS",
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
maxProcesses: parseInteger(
|
||||||
|
env.MCP_MAX_PROCESSES,
|
||||||
|
128,
|
||||||
|
"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,
|
||||||
|
"MCP_MAX_FILE_CHUNK_BYTES",
|
||||||
|
4096,
|
||||||
|
),
|
||||||
|
maxEditFileBytes: parseInteger(
|
||||||
|
env.MCP_MAX_EDIT_FILE_BYTES,
|
||||||
|
64 * 1024 * 1024,
|
||||||
|
"MCP_MAX_EDIT_FILE_BYTES",
|
||||||
|
4096,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(error);
|
||||||
|
} catch {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
import { FileService } from "./file-service.js";
|
||||||
|
import { ProcessManager } from "./process-manager.js";
|
||||||
|
import { runScript } from "./script-runner.js";
|
||||||
|
import { runTool } from "./tool-result.js";
|
||||||
|
|
||||||
|
const fullAccessAnnotations = {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function processResult(result: Awaited<ReturnType<ProcessManager["read"]>>): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
completed: !result.running,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerExecTools(
|
||||||
|
server: McpServer,
|
||||||
|
config: AppConfig,
|
||||||
|
processManager: ProcessManager,
|
||||||
|
fileService: FileService,
|
||||||
|
): void {
|
||||||
|
const environmentSchema = z
|
||||||
|
.record(z.string(), z.string())
|
||||||
|
.optional()
|
||||||
|
.describe("Environment variables added to or overriding the server process environment.");
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"exec_command",
|
||||||
|
{
|
||||||
|
title: "Execute command",
|
||||||
|
description:
|
||||||
|
"Run an unrestricted shell command on the host. The command inherits the MCP server's full OS permissions, environment, filesystem, and network access. Returns output immediately when complete or a process session ID when still running.",
|
||||||
|
inputSchema: {
|
||||||
|
cmd: z.string().min(1).describe("Shell command or script to execute."),
|
||||||
|
workdir: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(`Working directory. Relative paths resolve from ${config.defaultCwd}.`),
|
||||||
|
shell: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(`Shell executable. Defaults to ${config.defaultShell}.`),
|
||||||
|
login: z
|
||||||
|
.boolean()
|
||||||
|
.default(true)
|
||||||
|
.describe("Use login-shell semantics (-lc) instead of -c."),
|
||||||
|
env: environmentSchema,
|
||||||
|
stdin: z.string().optional().describe("Initial text written to stdin after spawn."),
|
||||||
|
timeoutMs: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.default(0)
|
||||||
|
.describe("Maximum runtime in milliseconds. Zero means no timeout."),
|
||||||
|
yieldTimeMs: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(30_000)
|
||||||
|
.default(10_000)
|
||||||
|
.describe("How long to wait for output before returning a running session."),
|
||||||
|
maxOutputBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(16 * 1024)
|
||||||
|
.max(config.maxOutputBytes)
|
||||||
|
.default(config.maxOutputBytes)
|
||||||
|
.describe("Maximum output bytes returned by this call."),
|
||||||
|
},
|
||||||
|
annotations: fullAccessAnnotations,
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
cmd,
|
||||||
|
workdir,
|
||||||
|
shell,
|
||||||
|
login,
|
||||||
|
env,
|
||||||
|
stdin,
|
||||||
|
timeoutMs,
|
||||||
|
yieldTimeMs,
|
||||||
|
maxOutputBytes,
|
||||||
|
}) =>
|
||||||
|
runTool(async () => {
|
||||||
|
const cwd = fileService.resolve(".", workdir);
|
||||||
|
const executable = shell || config.defaultShell;
|
||||||
|
const sessionId = processManager.start({
|
||||||
|
executable,
|
||||||
|
args: [login ? "-lc" : "-c", cmd],
|
||||||
|
commandForDisplay: cmd,
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutMs,
|
||||||
|
stdin,
|
||||||
|
});
|
||||||
|
await processManager.waitForExit(sessionId, yieldTimeMs);
|
||||||
|
const result = await processManager.read(sessionId, {
|
||||||
|
maxOutputBytes,
|
||||||
|
});
|
||||||
|
return processResult(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"run_script",
|
||||||
|
{
|
||||||
|
title: "Run script",
|
||||||
|
description:
|
||||||
|
"Write a supplied script to a temporary executable file and run it with Bash, sh, Node.js, Python, or an arbitrary interpreter. Execution is unrestricted and has the MCP server's full host permissions.",
|
||||||
|
inputSchema: {
|
||||||
|
runtime: z
|
||||||
|
.enum(["bash", "sh", "node", "python", "custom"])
|
||||||
|
.default("bash")
|
||||||
|
.describe("Script runtime. Use custom with interpreter for any other runtime."),
|
||||||
|
script: z.string().describe("Complete script source."),
|
||||||
|
workdir: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(`Working directory. Relative paths resolve from ${config.defaultCwd}.`),
|
||||||
|
args: z.array(z.string()).default([]).describe("Arguments passed after the script path."),
|
||||||
|
env: environmentSchema,
|
||||||
|
interpreter: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Interpreter executable override. Required for runtime=custom."),
|
||||||
|
interpreterArgs: z
|
||||||
|
.array(z.string())
|
||||||
|
.default([])
|
||||||
|
.describe("Arguments placed before the temporary script path."),
|
||||||
|
stdin: z.string().optional().describe("Initial text written to the script stdin."),
|
||||||
|
timeoutMs: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.default(0)
|
||||||
|
.describe("Maximum runtime in milliseconds. Zero means no timeout."),
|
||||||
|
yieldTimeMs: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(30_000)
|
||||||
|
.default(10_000),
|
||||||
|
maxOutputBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(16 * 1024)
|
||||||
|
.max(config.maxOutputBytes)
|
||||||
|
.default(config.maxOutputBytes),
|
||||||
|
keepScript: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.describe("Keep the temporary script after the process exits and return its path."),
|
||||||
|
},
|
||||||
|
annotations: fullAccessAnnotations,
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
runtime,
|
||||||
|
script,
|
||||||
|
workdir,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
interpreter,
|
||||||
|
interpreterArgs,
|
||||||
|
stdin,
|
||||||
|
timeoutMs,
|
||||||
|
yieldTimeMs,
|
||||||
|
maxOutputBytes,
|
||||||
|
keepScript,
|
||||||
|
}) =>
|
||||||
|
runTool(async () => {
|
||||||
|
const result = await runScript(processManager, {
|
||||||
|
runtime,
|
||||||
|
script,
|
||||||
|
cwd: fileService.resolve(".", workdir),
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
interpreter,
|
||||||
|
interpreterArgs,
|
||||||
|
stdin,
|
||||||
|
timeoutMs,
|
||||||
|
yieldTimeMs,
|
||||||
|
maxOutputBytes,
|
||||||
|
keepScript,
|
||||||
|
});
|
||||||
|
return processResult(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"write_stdin",
|
||||||
|
{
|
||||||
|
title: "Write to process stdin",
|
||||||
|
description:
|
||||||
|
"Write text to an existing process session, optionally close stdin, then return new output.",
|
||||||
|
inputSchema: {
|
||||||
|
sessionId: z.string().uuid(),
|
||||||
|
chars: z.string().default(""),
|
||||||
|
closeStdin: z.boolean().default(false),
|
||||||
|
afterSeq: z.number().int().min(0).default(0),
|
||||||
|
yieldTimeMs: z.number().int().min(0).max(300_000).default(250),
|
||||||
|
maxOutputBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(16 * 1024)
|
||||||
|
.max(config.maxOutputBytes)
|
||||||
|
.default(config.maxOutputBytes),
|
||||||
|
},
|
||||||
|
annotations: fullAccessAnnotations,
|
||||||
|
},
|
||||||
|
async ({ sessionId, chars, closeStdin, afterSeq, yieldTimeMs, maxOutputBytes }) =>
|
||||||
|
runTool(async () => {
|
||||||
|
await processManager.write(sessionId, chars, closeStdin);
|
||||||
|
if (closeStdin) {
|
||||||
|
await processManager.waitForExit(sessionId, yieldTimeMs);
|
||||||
|
}
|
||||||
|
const result = await processManager.read(sessionId, {
|
||||||
|
afterSeq,
|
||||||
|
waitMs: closeStdin ? 0 : yieldTimeMs,
|
||||||
|
maxOutputBytes,
|
||||||
|
});
|
||||||
|
return processResult(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"read_process",
|
||||||
|
{
|
||||||
|
title: "Read process output",
|
||||||
|
description:
|
||||||
|
"Poll a managed process for output and terminal state. Pass the previous nextSeq as afterSeq to receive only newer output.",
|
||||||
|
inputSchema: {
|
||||||
|
sessionId: z.string().uuid(),
|
||||||
|
afterSeq: z.number().int().min(0).default(0),
|
||||||
|
waitMs: z.number().int().min(0).max(300_000).default(1000),
|
||||||
|
maxOutputBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(16 * 1024)
|
||||||
|
.max(config.maxOutputBytes)
|
||||||
|
.default(config.maxOutputBytes),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ sessionId, afterSeq, waitMs, maxOutputBytes }) =>
|
||||||
|
runTool(async () =>
|
||||||
|
processResult(
|
||||||
|
await processManager.read(sessionId, {
|
||||||
|
afterSeq,
|
||||||
|
waitMs,
|
||||||
|
maxOutputBytes,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"terminate_process",
|
||||||
|
{
|
||||||
|
title: "Terminate process",
|
||||||
|
description:
|
||||||
|
"Send a signal to a managed process tree. SIGTERM escalates to SIGKILL after graceMs if necessary.",
|
||||||
|
inputSchema: {
|
||||||
|
sessionId: z.string().uuid(),
|
||||||
|
signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]).default("SIGTERM"),
|
||||||
|
graceMs: z.number().int().min(0).max(60_000).default(3000),
|
||||||
|
},
|
||||||
|
annotations: fullAccessAnnotations,
|
||||||
|
},
|
||||||
|
async ({ sessionId, signal, graceMs }) =>
|
||||||
|
runTool(async () =>
|
||||||
|
processResult(await processManager.terminate(sessionId, signal, graceMs)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_processes",
|
||||||
|
{
|
||||||
|
title: "List managed processes",
|
||||||
|
description: "List running and recently completed process sessions.",
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => runTool(() => ({ processes: processManager.list() })),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import {
|
||||||
|
appendFile,
|
||||||
|
chmod,
|
||||||
|
copyFile,
|
||||||
|
cp,
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
open,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
readlink,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
|
||||||
|
import { errorMessage } from "./errors.js";
|
||||||
|
import { expandPath } from "./paths.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export type FileContentEncoding = "utf8" | "base64";
|
||||||
|
|
||||||
|
export interface FileServiceOptions {
|
||||||
|
defaultCwd: string;
|
||||||
|
maxChunkBytes: number;
|
||||||
|
maxEditFileBytes: number;
|
||||||
|
maxOutputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDirectoryOptions {
|
||||||
|
recursive?: boolean;
|
||||||
|
maxDepth?: number;
|
||||||
|
maxEntries?: number;
|
||||||
|
includeHidden?: boolean;
|
||||||
|
includeMetadata?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryEntryResult {
|
||||||
|
path: string;
|
||||||
|
relativePath: string;
|
||||||
|
name: string;
|
||||||
|
type: "file" | "directory" | "symlink" | "other";
|
||||||
|
size?: number;
|
||||||
|
mode?: string;
|
||||||
|
modifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeFromStats(stats: Awaited<ReturnType<typeof lstat>>): DirectoryEntryResult["type"] {
|
||||||
|
if (stats.isFile()) {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
return "directory";
|
||||||
|
}
|
||||||
|
if (stats.isSymbolicLink()) {
|
||||||
|
return "symlink";
|
||||||
|
}
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeContent(data: Buffer, encoding: FileContentEncoding): string {
|
||||||
|
return encoding === "base64" ? data.toString("base64") : data.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeContent(data: string, encoding: FileContentEncoding): Buffer {
|
||||||
|
return Buffer.from(data, encoding === "base64" ? "base64" : "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileService {
|
||||||
|
readonly #options: FileServiceOptions;
|
||||||
|
|
||||||
|
constructor(options: FileServiceOptions) {
|
||||||
|
this.#options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(inputPath: string, cwd?: string): string {
|
||||||
|
const base = cwd
|
||||||
|
? expandPath(cwd, this.#options.defaultCwd)
|
||||||
|
: this.#options.defaultCwd;
|
||||||
|
return expandPath(inputPath, base);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInfo(inputPath: string, cwd?: string): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
const info = await lstat(resolvedPath);
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
path: resolvedPath,
|
||||||
|
type: typeFromStats(info),
|
||||||
|
size: info.size,
|
||||||
|
mode: `0${(info.mode & 0o7777).toString(8)}`,
|
||||||
|
uid: info.uid,
|
||||||
|
gid: info.gid,
|
||||||
|
createdAt: info.birthtime.toISOString(),
|
||||||
|
modifiedAt: info.mtime.toISOString(),
|
||||||
|
accessedAt: info.atime.toISOString(),
|
||||||
|
};
|
||||||
|
if (info.isSymbolicLink()) {
|
||||||
|
result.symlinkTarget = await readlink(resolvedPath);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDirectory(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
options: ListDirectoryOptions = {},
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const root = this.resolve(inputPath, cwd);
|
||||||
|
const recursive = options.recursive ?? false;
|
||||||
|
const maxDepth = Math.max(0, Math.min(options.maxDepth ?? 8, 100));
|
||||||
|
const maxEntries = Math.max(1, Math.min(options.maxEntries ?? 1000, 50_000));
|
||||||
|
const includeHidden = options.includeHidden ?? true;
|
||||||
|
const includeMetadata = options.includeMetadata ?? false;
|
||||||
|
const entries: DirectoryEntryResult[] = [];
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
const visit = async (directory: string, depth: number): Promise<void> => {
|
||||||
|
if (truncated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const directoryEntries = await readdir(directory, { withFileTypes: true });
|
||||||
|
directoryEntries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (const entry of directoryEntries) {
|
||||||
|
if (!includeHidden && entry.name.startsWith(".")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entries.length >= maxEntries) {
|
||||||
|
truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const absolutePath = path.join(directory, entry.name);
|
||||||
|
const relativePath = path.relative(root, absolutePath) || entry.name;
|
||||||
|
const info = await lstat(absolutePath);
|
||||||
|
const result: DirectoryEntryResult = {
|
||||||
|
path: absolutePath,
|
||||||
|
relativePath,
|
||||||
|
name: entry.name,
|
||||||
|
type: typeFromStats(info),
|
||||||
|
};
|
||||||
|
if (includeMetadata) {
|
||||||
|
result.size = info.size;
|
||||||
|
result.mode = `0${(info.mode & 0o7777).toString(8)}`;
|
||||||
|
result.modifiedAt = info.mtime.toISOString();
|
||||||
|
}
|
||||||
|
entries.push(result);
|
||||||
|
if (recursive && info.isDirectory() && depth < maxDepth) {
|
||||||
|
await visit(absolutePath, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await visit(root, 0);
|
||||||
|
return {
|
||||||
|
path: root,
|
||||||
|
entries,
|
||||||
|
count: entries.length,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFileChunk(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
offset = 0,
|
||||||
|
maxBytes = 256 * 1024,
|
||||||
|
encoding: FileContentEncoding = "utf8",
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
const info = await stat(resolvedPath);
|
||||||
|
if (!info.isFile()) {
|
||||||
|
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 handle = await open(resolvedPath, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(byteCount);
|
||||||
|
const { bytesRead } = await handle.read(buffer, 0, byteCount, safeOffset);
|
||||||
|
const data = buffer.subarray(0, bytesRead);
|
||||||
|
const nextOffset = safeOffset + bytesRead;
|
||||||
|
return {
|
||||||
|
path: resolvedPath,
|
||||||
|
encoding,
|
||||||
|
content: encodeContent(data, encoding),
|
||||||
|
offset: safeOffset,
|
||||||
|
nextOffset,
|
||||||
|
bytesRead,
|
||||||
|
totalBytes: info.size,
|
||||||
|
eof: nextOffset >= info.size,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFileContent(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
content: string,
|
||||||
|
encoding: FileContentEncoding,
|
||||||
|
mode: "overwrite" | "append",
|
||||||
|
createParents: boolean,
|
||||||
|
fileMode?: number,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
const info = await stat(resolvedPath);
|
||||||
|
return {
|
||||||
|
path: resolvedPath,
|
||||||
|
bytesWritten: data.length,
|
||||||
|
totalBytes: info.size,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadChunk(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
dataBase64: string,
|
||||||
|
offset: number,
|
||||||
|
truncate: boolean,
|
||||||
|
createParents: boolean,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
const data = Buffer.from(dataBase64, "base64");
|
||||||
|
if (data.length > this.#options.maxChunkBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`Upload chunk is ${data.length} bytes; maximum is ${this.#options.maxChunkBytes}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (createParents) {
|
||||||
|
await mkdir(path.dirname(resolvedPath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await open(resolvedPath, "r+");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
handle = await open(resolvedPath, "w+");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (truncate) {
|
||||||
|
await handle.truncate(0);
|
||||||
|
}
|
||||||
|
const safeOffset = Math.max(0, offset);
|
||||||
|
const { bytesWritten } = await handle.write(data, 0, data.length, safeOffset);
|
||||||
|
const info = await handle.stat();
|
||||||
|
return {
|
||||||
|
path: resolvedPath,
|
||||||
|
offset: safeOffset,
|
||||||
|
bytesWritten,
|
||||||
|
nextOffset: safeOffset + bytesWritten,
|
||||||
|
totalBytes: info.size,
|
||||||
|
chunkSha256: createHash("sha256").update(data).digest("hex"),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadChunk(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
offset: number,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const result = await this.readFileChunk(
|
||||||
|
inputPath,
|
||||||
|
cwd,
|
||||||
|
offset,
|
||||||
|
maxBytes,
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
path: result.path,
|
||||||
|
dataBase64: result.content,
|
||||||
|
offset: result.offset,
|
||||||
|
nextOffset: result.nextOffset,
|
||||||
|
bytesRead: result.bytesRead,
|
||||||
|
totalBytes: result.totalBytes,
|
||||||
|
eof: result.eof,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceInFile(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
oldText: string,
|
||||||
|
newText: string,
|
||||||
|
replaceAll: boolean,
|
||||||
|
expectedOccurrences: number | undefined,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (oldText.length === 0) {
|
||||||
|
throw new Error("oldText must not be empty");
|
||||||
|
}
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
const info = await stat(resolvedPath);
|
||||||
|
if (info.size > this.#options.maxEditFileBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`${resolvedPath} is ${info.size} bytes; replace_in_file limit is ${this.#options.maxEditFileBytes}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const original = await readFile(resolvedPath, "utf8");
|
||||||
|
const occurrences = original.split(oldText).length - 1;
|
||||||
|
const expected = expectedOccurrences ?? (replaceAll ? occurrences : 1);
|
||||||
|
if (occurrences !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected ${expected} occurrence(s) of oldText in ${resolvedPath}, found ${occurrences}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updated = replaceAll
|
||||||
|
? original.split(oldText).join(newText)
|
||||||
|
: original.replace(oldText, newText);
|
||||||
|
await writeFile(resolvedPath, updated, "utf8");
|
||||||
|
return {
|
||||||
|
path: resolvedPath,
|
||||||
|
replacements: replaceAll ? occurrences : Math.min(occurrences, 1),
|
||||||
|
previousBytes: Buffer.byteLength(original),
|
||||||
|
currentBytes: Buffer.byteLength(updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyPatch(
|
||||||
|
patchText: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
options: { checkOnly: boolean; reverse: boolean; threeWay: boolean },
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedCwd = this.resolve(".", cwd);
|
||||||
|
const temporaryDirectory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "remote-dev-mcp-patch-"),
|
||||||
|
);
|
||||||
|
const patchPath = path.join(temporaryDirectory, `${randomUUID()}.patch`);
|
||||||
|
await writeFile(patchPath, patchText, "utf8");
|
||||||
|
|
||||||
|
const baseArguments = ["apply", "--unsafe-paths", "--whitespace=nowarn"];
|
||||||
|
if (options.reverse) {
|
||||||
|
baseArguments.push("--reverse");
|
||||||
|
}
|
||||||
|
if (options.threeWay) {
|
||||||
|
baseArguments.push("--3way");
|
||||||
|
}
|
||||||
|
const checkArguments = [...baseArguments, "--check", patchPath];
|
||||||
|
try {
|
||||||
|
const checked = await execFileAsync("git", checkArguments, {
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: this.#options.maxOutputBytes,
|
||||||
|
});
|
||||||
|
if (options.checkOnly) {
|
||||||
|
return {
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
applied: false,
|
||||||
|
checkOnly: true,
|
||||||
|
stdout: checked.stdout,
|
||||||
|
stderr: checked.stderr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const applied = await execFileAsync("git", [...baseArguments, patchPath], {
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: this.#options.maxOutputBytes,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
applied: true,
|
||||||
|
checkOnly: false,
|
||||||
|
stdout: applied.stdout,
|
||||||
|
stderr: applied.stderr,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const execError = error as Error & { stdout?: string; stderr?: string };
|
||||||
|
throw new Error(
|
||||||
|
[errorMessage(execError), execError.stdout, execError.stderr]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async makeDirectory(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
recursive: boolean,
|
||||||
|
mode?: number,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
await mkdir(resolvedPath, {
|
||||||
|
recursive,
|
||||||
|
...(mode === undefined ? {} : { mode }),
|
||||||
|
});
|
||||||
|
return { path: resolvedPath, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyPath(
|
||||||
|
sourcePath: string,
|
||||||
|
destinationPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
recursive: boolean,
|
||||||
|
force: boolean,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const source = this.resolve(sourcePath, cwd);
|
||||||
|
const destination = this.resolve(destinationPath, cwd);
|
||||||
|
if (source === destination) {
|
||||||
|
throw new Error("Source and destination paths must be different");
|
||||||
|
}
|
||||||
|
const sourceInfo = await lstat(source);
|
||||||
|
if (sourceInfo.isDirectory()) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
await copyFile(source, destination);
|
||||||
|
}
|
||||||
|
return { source, destination, copied: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async movePath(
|
||||||
|
sourcePath: string,
|
||||||
|
destinationPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
overwrite: boolean,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const source = this.resolve(sourcePath, cwd);
|
||||||
|
const destination = this.resolve(destinationPath, cwd);
|
||||||
|
if (source === destination) {
|
||||||
|
return { source, destination, moved: false, samePath: true };
|
||||||
|
}
|
||||||
|
if (!overwrite) {
|
||||||
|
try {
|
||||||
|
await lstat(destination);
|
||||||
|
throw new Error(`Destination already exists: ${destination}`);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await rm(destination, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
try {
|
||||||
|
await rename(source, destination);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "EXDEV") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await cp(source, destination, { recursive: true, force: overwrite });
|
||||||
|
await rm(source, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
return { source, destination, moved: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePath(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
recursive: boolean,
|
||||||
|
force: boolean,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
await rm(resolvedPath, { recursive, force });
|
||||||
|
return { path: resolvedPath, removed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMode(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
mode: number,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
await chmod(resolvedPath, mode);
|
||||||
|
return { path: resolvedPath, mode: `0${mode.toString(8)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async hashFile(
|
||||||
|
inputPath: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
algorithm: "sha256" | "sha512" | "md5",
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const resolvedPath = this.resolve(inputPath, cwd);
|
||||||
|
const hash = createHash(algorithm);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const stream = createReadStream(resolvedPath);
|
||||||
|
stream.on("data", (chunk) => hash.update(chunk));
|
||||||
|
stream.on("error", reject);
|
||||||
|
stream.on("end", resolve);
|
||||||
|
});
|
||||||
|
return { path: resolvedPath, algorithm, digest: hash.digest("hex") };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
import { FileService } from "./file-service.js";
|
||||||
|
import { runTool } from "./tool-result.js";
|
||||||
|
|
||||||
|
const readAnnotations = {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeAnnotations = {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cwdSchema = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Base directory used to resolve relative paths.");
|
||||||
|
|
||||||
|
const pathSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("Absolute path, ~/ path, or a path relative to cwd/default cwd.");
|
||||||
|
|
||||||
|
const fileModeSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^(?:0o)?[0-7]{3,4}$/)
|
||||||
|
.optional()
|
||||||
|
.describe("Unix mode written as an octal string, for example 0755.");
|
||||||
|
|
||||||
|
function parseMode(mode: string | undefined): number | undefined {
|
||||||
|
if (mode === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Number.parseInt(mode.replace(/^0o/, ""), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFileTools(
|
||||||
|
server: McpServer,
|
||||||
|
config: AppConfig,
|
||||||
|
files: FileService,
|
||||||
|
): void {
|
||||||
|
server.registerTool(
|
||||||
|
"list_directory",
|
||||||
|
{
|
||||||
|
title: "List directory",
|
||||||
|
description:
|
||||||
|
"List any host directory. Recursive listing does not follow directory symlinks.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
recursive: z.boolean().default(false),
|
||||||
|
maxDepth: z.number().int().min(0).max(100).default(8),
|
||||||
|
maxEntries: z.number().int().min(1).max(50_000).default(1000),
|
||||||
|
includeHidden: z.boolean().default(true),
|
||||||
|
includeMetadata: z.boolean().default(false),
|
||||||
|
},
|
||||||
|
annotations: readAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, recursive, maxDepth, maxEntries, includeHidden, includeMetadata }) =>
|
||||||
|
runTool(() =>
|
||||||
|
files.listDirectory(path, cwd, {
|
||||||
|
recursive,
|
||||||
|
maxDepth,
|
||||||
|
maxEntries,
|
||||||
|
includeHidden,
|
||||||
|
includeMetadata,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"stat_path",
|
||||||
|
{
|
||||||
|
title: "Inspect path",
|
||||||
|
description: "Return metadata for any file, directory, or symbolic link.",
|
||||||
|
inputSchema: { path: pathSchema, cwd: cwdSchema },
|
||||||
|
annotations: readAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd }) => runTool(() => files.getInfo(path, cwd)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"read_file",
|
||||||
|
{
|
||||||
|
title: "Read file",
|
||||||
|
description:
|
||||||
|
"Read a bounded chunk of any host file as UTF-8 text or base64. Continue with nextOffset until eof=true.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
maxBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(config.maxFileChunkBytes)
|
||||||
|
.default(Math.min(256 * 1024, config.maxFileChunkBytes)),
|
||||||
|
encoding: z.enum(["utf8", "base64"]).default("utf8"),
|
||||||
|
},
|
||||||
|
annotations: readAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, offset, maxBytes, encoding }) =>
|
||||||
|
runTool(() => files.readFileChunk(path, cwd, offset, maxBytes, encoding)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"write_file",
|
||||||
|
{
|
||||||
|
title: "Write file",
|
||||||
|
description:
|
||||||
|
"Create, overwrite, or append to any host file using UTF-8 or base64 content.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
content: z.string(),
|
||||||
|
encoding: z.enum(["utf8", "base64"]).default("utf8"),
|
||||||
|
mode: z.enum(["overwrite", "append"]).default("overwrite"),
|
||||||
|
createParents: z.boolean().default(true),
|
||||||
|
fileMode: fileModeSchema,
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, content, encoding, mode, createParents, fileMode }) =>
|
||||||
|
runTool(() =>
|
||||||
|
files.writeFileContent(
|
||||||
|
path,
|
||||||
|
cwd,
|
||||||
|
content,
|
||||||
|
encoding,
|
||||||
|
mode,
|
||||||
|
createParents,
|
||||||
|
parseMode(fileMode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"replace_in_file",
|
||||||
|
{
|
||||||
|
title: "Replace text in file",
|
||||||
|
description:
|
||||||
|
"Perform an exact text replacement in a UTF-8 file. By default exactly one occurrence must exist, preventing ambiguous edits.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
oldText: z.string().min(1),
|
||||||
|
newText: z.string(),
|
||||||
|
replaceAll: z.boolean().default(false),
|
||||||
|
expectedOccurrences: z.number().int().min(0).optional(),
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, oldText, newText, replaceAll, expectedOccurrences }) =>
|
||||||
|
runTool(() =>
|
||||||
|
files.replaceInFile(
|
||||||
|
path,
|
||||||
|
cwd,
|
||||||
|
oldText,
|
||||||
|
newText,
|
||||||
|
replaceAll,
|
||||||
|
expectedOccurrences,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"apply_patch",
|
||||||
|
{
|
||||||
|
title: "Apply unified diff",
|
||||||
|
description:
|
||||||
|
"Validate and apply a standard unified diff with git apply. Paths are unrestricted and --unsafe-paths is enabled.",
|
||||||
|
inputSchema: {
|
||||||
|
patch: z.string().min(1).describe("Standard unified diff text."),
|
||||||
|
cwd: cwdSchema,
|
||||||
|
checkOnly: z.boolean().default(false),
|
||||||
|
reverse: z.boolean().default(false),
|
||||||
|
threeWay: z.boolean().default(false),
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ patch, cwd, checkOnly, reverse, threeWay }) =>
|
||||||
|
runTool(() => files.applyPatch(patch, cwd, { checkOnly, reverse, threeWay })),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"upload_file",
|
||||||
|
{
|
||||||
|
title: "Upload file chunk",
|
||||||
|
description:
|
||||||
|
"Write a base64 file chunk at an exact byte offset. Use truncate=true for the first chunk of a replacement upload, then continue with nextOffset.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
dataBase64: z.string(),
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
truncate: z.boolean().default(false),
|
||||||
|
createParents: z.boolean().default(true),
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, dataBase64, offset, truncate, createParents }) =>
|
||||||
|
runTool(() =>
|
||||||
|
files.uploadChunk(path, cwd, dataBase64, offset, truncate, createParents),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"download_file",
|
||||||
|
{
|
||||||
|
title: "Download file chunk",
|
||||||
|
description:
|
||||||
|
"Read a file chunk as base64. Continue with nextOffset until eof=true.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
maxBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(config.maxFileChunkBytes)
|
||||||
|
.default(config.maxFileChunkBytes),
|
||||||
|
},
|
||||||
|
annotations: readAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, offset, maxBytes }) =>
|
||||||
|
runTool(() => files.downloadChunk(path, cwd, offset, maxBytes)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"make_directory",
|
||||||
|
{
|
||||||
|
title: "Create directory",
|
||||||
|
description: "Create any host directory.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
recursive: z.boolean().default(true),
|
||||||
|
mode: fileModeSchema,
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, recursive, mode }) =>
|
||||||
|
runTool(() => files.makeDirectory(path, cwd, recursive, parseMode(mode))),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"copy_path",
|
||||||
|
{
|
||||||
|
title: "Copy path",
|
||||||
|
description: "Copy a file or directory anywhere on the host.",
|
||||||
|
inputSchema: {
|
||||||
|
sourcePath: pathSchema,
|
||||||
|
destinationPath: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
recursive: z.boolean().default(true),
|
||||||
|
force: z.boolean().default(true),
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ sourcePath, destinationPath, cwd, recursive, force }) =>
|
||||||
|
runTool(() => files.copyPath(sourcePath, destinationPath, cwd, recursive, force)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"move_path",
|
||||||
|
{
|
||||||
|
title: "Move path",
|
||||||
|
description: "Move or rename a file or directory anywhere on the host.",
|
||||||
|
inputSchema: {
|
||||||
|
sourcePath: pathSchema,
|
||||||
|
destinationPath: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
overwrite: z.boolean().default(false),
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ sourcePath, destinationPath, cwd, overwrite }) =>
|
||||||
|
runTool(() => files.movePath(sourcePath, destinationPath, cwd, overwrite)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"remove_path",
|
||||||
|
{
|
||||||
|
title: "Remove path",
|
||||||
|
description:
|
||||||
|
"Permanently remove any host file or directory. This operation is not restricted to a workspace and does not use trash.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
recursive: z.boolean().default(false),
|
||||||
|
force: z.boolean().default(false),
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, recursive, force }) =>
|
||||||
|
runTool(() => files.removePath(path, cwd, recursive, force)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"chmod_path",
|
||||||
|
{
|
||||||
|
title: "Change path mode",
|
||||||
|
description: "Change Unix permission bits on any host path.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
mode: z.string().regex(/^(?:0o)?[0-7]{3,4}$/),
|
||||||
|
},
|
||||||
|
annotations: writeAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, mode }) =>
|
||||||
|
runTool(() => files.changeMode(path, cwd, parseMode(mode) ?? 0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"hash_file",
|
||||||
|
{
|
||||||
|
title: "Hash file",
|
||||||
|
description: "Calculate a digest for any host file, useful for transfer verification.",
|
||||||
|
inputSchema: {
|
||||||
|
path: pathSchema,
|
||||||
|
cwd: cwdSchema,
|
||||||
|
algorithm: z.enum(["sha256", "sha512", "md5"]).default("sha256"),
|
||||||
|
},
|
||||||
|
annotations: readAnnotations,
|
||||||
|
},
|
||||||
|
async ({ path, cwd, algorithm }) =>
|
||||||
|
runTool(() => files.hashFile(path, cwd, algorithm)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
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";
|
||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
import { errorMessage } from "./errors.js";
|
||||||
|
import { createMcpServer, type McpServices } from "./mcp-server.js";
|
||||||
|
import { OAUTH_SCOPES, RemoteDevOAuthProvider } from "./oauth.js";
|
||||||
|
|
||||||
|
interface ActiveSession {
|
||||||
|
transport: StreamableHTTPServerTransport;
|
||||||
|
server: ReturnType<typeof createMcpServer>;
|
||||||
|
lastUsedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunningHttpServer {
|
||||||
|
httpServer: HttpServer;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcError(response: Response, status: number, message: string): void {
|
||||||
|
response.status(status).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code: -32000, message },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startHttpServer(
|
||||||
|
config: AppConfig,
|
||||||
|
services: McpServices,
|
||||||
|
): Promise<RunningHttpServer> {
|
||||||
|
const app = express();
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
app.use(express.json({ limit: config.maxRequestBody }));
|
||||||
|
app.use(createHostValidation(config));
|
||||||
|
|
||||||
|
const sessions = new Map<string, ActiveSession>();
|
||||||
|
const oauthProvider = config.oauthEnabled ? new RemoteDevOAuthProvider(config) : undefined;
|
||||||
|
if (oauthProvider) {
|
||||||
|
app.get("/.well-known/oauth-protected-resource", (_request, response) => {
|
||||||
|
response.json({
|
||||||
|
resource: oauthProvider.resourceUrl.href,
|
||||||
|
authorization_servers: [oauthProvider.issuerUrl.href],
|
||||||
|
scopes_supported: [...OAUTH_SCOPES],
|
||||||
|
bearer_methods_supported: ["header"],
|
||||||
|
resource_name: "cokacremote",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.use(
|
||||||
|
mcpAuthRouter({
|
||||||
|
provider: oauthProvider,
|
||||||
|
issuerUrl: oauthProvider.issuerUrl,
|
||||||
|
resourceServerUrl: oauthProvider.resourceUrl,
|
||||||
|
scopesSupported: [...OAUTH_SCOPES],
|
||||||
|
resourceName: "cokacremote",
|
||||||
|
clientRegistrationOptions: { clientSecretExpirySeconds: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const authenticate = createBearerAuth(config, oauthProvider);
|
||||||
|
|
||||||
|
app.get("/health", (_request, response) => {
|
||||||
|
response.json({
|
||||||
|
status: "ok",
|
||||||
|
service: "cokacremote",
|
||||||
|
version: "0.1.0",
|
||||||
|
activeMcpSessions: sessions.size,
|
||||||
|
managedProcesses: services.processManager.list().length,
|
||||||
|
unrestrictedHostAccess: true,
|
||||||
|
oauthEnabled: config.oauthEnabled,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const server = createMcpServer(config, services);
|
||||||
|
activeSession = { transport, server, lastUsedAt: Date.now() };
|
||||||
|
transport.onclose = () => {
|
||||||
|
const closedSessionId = transport.sessionId;
|
||||||
|
if (closedSessionId) {
|
||||||
|
sessions.delete(closedSessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
transport.onerror = (error) => {
|
||||||
|
console.error("MCP transport error:", errorMessage(error));
|
||||||
|
};
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(request, response, request.body);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MCP POST failed:", errorMessage(error));
|
||||||
|
if (!response.headersSent) {
|
||||||
|
rpcError(response, 500, "Internal MCP server error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.use(
|
||||||
|
(
|
||||||
|
error: unknown,
|
||||||
|
_request: Request,
|
||||||
|
response: Response,
|
||||||
|
_next: express.NextFunction,
|
||||||
|
) => {
|
||||||
|
if (!response.headersSent) {
|
||||||
|
rpcError(response, 400, `Invalid request body: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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));
|
||||||
|
cleanupInterval.unref();
|
||||||
|
|
||||||
|
const httpServer = await new Promise<HttpServer>((resolve, reject) => {
|
||||||
|
const listeningServer = app.listen(config.port, config.host, () => resolve(listeningServer));
|
||||||
|
listeningServer.once("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = async (): Promise<void> => {
|
||||||
|
clearInterval(cleanupInterval);
|
||||||
|
const activeSessions = [...sessions.values()];
|
||||||
|
sessions.clear();
|
||||||
|
await Promise.allSettled(activeSessions.map((session) => session.server.close()));
|
||||||
|
await services.processManager.shutdown();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
httpServer.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { httpServer, close };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
|
||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
import { registerExecTools } from "./exec-tools.js";
|
||||||
|
import { FileService } from "./file-service.js";
|
||||||
|
import { registerFileTools } from "./file-tools.js";
|
||||||
|
import { ProcessManager } from "./process-manager.js";
|
||||||
|
|
||||||
|
export interface McpServices {
|
||||||
|
processManager: ProcessManager;
|
||||||
|
fileService: FileService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServices(config: AppConfig): McpServices {
|
||||||
|
return {
|
||||||
|
processManager: new ProcessManager({
|
||||||
|
maxRetainedOutputBytes: config.maxRetainedProcessOutputBytes,
|
||||||
|
processRetentionMs: config.processRetentionMs,
|
||||||
|
maxProcesses: config.maxProcesses,
|
||||||
|
defaultMaxOutputBytes: config.maxOutputBytes,
|
||||||
|
}),
|
||||||
|
fileService: new FileService({
|
||||||
|
defaultCwd: config.defaultCwd,
|
||||||
|
maxChunkBytes: config.maxFileChunkBytes,
|
||||||
|
maxEditFileBytes: config.maxEditFileBytes,
|
||||||
|
maxOutputBytes: config.maxOutputBytes,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpServer(config: AppConfig, services: McpServices): McpServer {
|
||||||
|
const server = new McpServer(
|
||||||
|
{
|
||||||
|
name: "cokacremote",
|
||||||
|
version: "0.1.0",
|
||||||
|
...(config.publicUrl ? { websiteUrl: config.publicUrl } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
instructions:
|
||||||
|
"This server is an unrestricted remote development environment. Tools operate directly on the host with the MCP service process's full OS permissions. Use exec_command for shell, build, test, package, Git, service, and log workflows; run_script for complete Bash, Node.js, or Python scripts; and the file tools for direct file operations. Poll long-running commands with read_process or write_stdin.",
|
||||||
|
capabilities: { logging: {} },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
registerExecTools(
|
||||||
|
server,
|
||||||
|
config,
|
||||||
|
services.processManager,
|
||||||
|
services.fileService,
|
||||||
|
);
|
||||||
|
registerFileTools(server, config, services.fileService);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
+513
@@ -0,0 +1,513 @@
|
|||||||
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
|
||||||
|
import {
|
||||||
|
InvalidGrantError,
|
||||||
|
InvalidScopeError,
|
||||||
|
InvalidTargetError,
|
||||||
|
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
||||||
|
import type {
|
||||||
|
AuthorizationParams,
|
||||||
|
OAuthServerProvider,
|
||||||
|
} from "@modelcontextprotocol/sdk/server/auth/provider.js";
|
||||||
|
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||||
|
import type {
|
||||||
|
OAuthClientInformationFull,
|
||||||
|
OAuthTokenRevocationRequest,
|
||||||
|
OAuthTokens,
|
||||||
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
|
import { tokensEqual } from "./auth.js";
|
||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
|
||||||
|
export const OAUTH_SCOPES = ["mcp:tools"] as const;
|
||||||
|
|
||||||
|
interface StoredToken {
|
||||||
|
type: "access" | "refresh";
|
||||||
|
clientId: string;
|
||||||
|
scopes: string[];
|
||||||
|
expiresAt: number;
|
||||||
|
resource: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedOAuthState {
|
||||||
|
version: 1;
|
||||||
|
clients: Record<string, OAuthClientInformationFull>;
|
||||||
|
tokens: Record<string, StoredToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorizationCodeRecord {
|
||||||
|
clientId: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
redirectUri: string;
|
||||||
|
resource: string;
|
||||||
|
scopes: string[];
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshResult =
|
||||||
|
| { status: "invalid" }
|
||||||
|
| { status: "invalid_scope" }
|
||||||
|
| { status: "ok"; tokens: OAuthTokens };
|
||||||
|
|
||||||
|
function emptyState(): PersistedOAuthState {
|
||||||
|
return { version: 1, clients: {}, tokens: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenHash(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomToken(): string {
|
||||||
|
return randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredToken(value: unknown): value is StoredToken {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const token = value as Partial<StoredToken>;
|
||||||
|
return (
|
||||||
|
(token.type === "access" || token.type === "refresh") &&
|
||||||
|
typeof token.clientId === "string" &&
|
||||||
|
Array.isArray(token.scopes) &&
|
||||||
|
token.scopes.every((scope) => typeof scope === "string") &&
|
||||||
|
typeof token.expiresAt === "number" &&
|
||||||
|
typeof token.resource === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseState(value: string): PersistedOAuthState {
|
||||||
|
const parsed = JSON.parse(value) as Partial<PersistedOAuthState>;
|
||||||
|
if (
|
||||||
|
parsed.version !== 1 ||
|
||||||
|
!parsed.clients ||
|
||||||
|
typeof parsed.clients !== "object" ||
|
||||||
|
!parsed.tokens ||
|
||||||
|
typeof parsed.tokens !== "object" ||
|
||||||
|
!Object.values(parsed.tokens).every(isStoredToken)
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid OAuth state file format");
|
||||||
|
}
|
||||||
|
return parsed as PersistedOAuthState;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PersistentOAuthStore implements OAuthRegisteredClientsStore {
|
||||||
|
private state = emptyState();
|
||||||
|
private loadPromise: Promise<void> | undefined;
|
||||||
|
private mutationQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly stateFile: string,
|
||||||
|
private readonly accessTokenTtlSeconds: number,
|
||||||
|
private readonly refreshTokenTtlSeconds: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async ensureLoaded(): Promise<void> {
|
||||||
|
this.loadPromise ??= (async () => {
|
||||||
|
try {
|
||||||
|
this.state = parseState(await readFile(this.stateFile, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
await this.loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpired(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [hash, token] of Object.entries(this.state.tokens)) {
|
||||||
|
if (token.expiresAt <= now) {
|
||||||
|
delete this.state.tokens[hash];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(): Promise<void> {
|
||||||
|
const directory = path.dirname(this.stateFile);
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
await chmod(directory, 0o700);
|
||||||
|
const temporaryFile = `${this.stateFile}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(temporaryFile, `${JSON.stringify(this.state, null, 2)}\n`, {
|
||||||
|
encoding: "utf8",
|
||||||
|
flag: "wx",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
await rename(temporaryFile, this.stateFile);
|
||||||
|
} catch (error) {
|
||||||
|
await unlink(temporaryFile).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mutate<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const pending = this.mutationQueue.then(async () => {
|
||||||
|
this.pruneExpired();
|
||||||
|
const result = await operation();
|
||||||
|
await this.persist();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
this.mutationQueue = pending.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
await this.mutationQueue;
|
||||||
|
return this.state.clients[clientId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerClient(
|
||||||
|
client: Omit<OAuthClientInformationFull, "client_id" | "client_id_issued_at">,
|
||||||
|
): Promise<OAuthClientInformationFull> {
|
||||||
|
const supplied = client as Partial<OAuthClientInformationFull>;
|
||||||
|
const registered: OAuthClientInformationFull = {
|
||||||
|
...client,
|
||||||
|
client_id: supplied.client_id || randomUUID(),
|
||||||
|
client_id_issued_at: supplied.client_id_issued_at || Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
return this.mutate(() => {
|
||||||
|
this.state.clients[registered.client_id] = registered;
|
||||||
|
return registered;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async issueTokenPair(clientId: string, scopes: string[], resource: string): Promise<OAuthTokens> {
|
||||||
|
return this.mutate(() => this.issueTokenPairWithoutPersist(clientId, scopes, resource));
|
||||||
|
}
|
||||||
|
|
||||||
|
private issueTokenPairWithoutPersist(
|
||||||
|
clientId: string,
|
||||||
|
scopes: string[],
|
||||||
|
resource: string,
|
||||||
|
): OAuthTokens {
|
||||||
|
const accessToken = randomToken();
|
||||||
|
const refreshToken = randomToken();
|
||||||
|
const now = Date.now();
|
||||||
|
this.state.tokens[tokenHash(accessToken)] = {
|
||||||
|
type: "access",
|
||||||
|
clientId,
|
||||||
|
scopes,
|
||||||
|
expiresAt: now + this.accessTokenTtlSeconds * 1000,
|
||||||
|
resource,
|
||||||
|
};
|
||||||
|
this.state.tokens[tokenHash(refreshToken)] = {
|
||||||
|
type: "refresh",
|
||||||
|
clientId,
|
||||||
|
scopes,
|
||||||
|
expiresAt: now + this.refreshTokenTtlSeconds * 1000,
|
||||||
|
resource,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
access_token: accessToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: this.accessTokenTtlSeconds,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
scope: scopes.join(" "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateRefreshToken(
|
||||||
|
refreshToken: string,
|
||||||
|
clientId: string,
|
||||||
|
resource: string,
|
||||||
|
requestedScopes: string[] | undefined,
|
||||||
|
): Promise<RefreshResult> {
|
||||||
|
return this.mutate(() => {
|
||||||
|
const hash = tokenHash(refreshToken);
|
||||||
|
const current = this.state.tokens[hash];
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
current.type !== "refresh" ||
|
||||||
|
current.clientId !== clientId ||
|
||||||
|
current.resource !== resource ||
|
||||||
|
current.expiresAt <= Date.now()
|
||||||
|
) {
|
||||||
|
return { status: "invalid" };
|
||||||
|
}
|
||||||
|
const scopes = requestedScopes ?? current.scopes;
|
||||||
|
if (!scopes.every((scope) => current.scopes.includes(scope))) {
|
||||||
|
return { status: "invalid_scope" };
|
||||||
|
}
|
||||||
|
delete this.state.tokens[hash];
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
tokens: this.issueTokenPairWithoutPersist(clientId, scopes, resource),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccessToken(token: string): Promise<StoredToken | undefined> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
await this.mutationQueue;
|
||||||
|
const stored = this.state.tokens[tokenHash(token)];
|
||||||
|
if (!stored || stored.type !== "access" || stored.expiresAt <= Date.now()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(token: string, clientId: string): Promise<void> {
|
||||||
|
await this.mutate(() => {
|
||||||
|
const hash = tokenHash(token);
|
||||||
|
if (this.state.tokens[hash]?.clientId === clientId) {
|
||||||
|
delete this.state.tokens[hash];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenInput(name: string, value: string | undefined): string {
|
||||||
|
return value === undefined
|
||||||
|
? ""
|
||||||
|
: `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthorizationPage(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
params: AuthorizationParams,
|
||||||
|
invalidKey: boolean,
|
||||||
|
): string {
|
||||||
|
const clientName = client.client_name || "ChatGPT MCP client";
|
||||||
|
let redirectHost = params.redirectUri;
|
||||||
|
try {
|
||||||
|
redirectHost = new URL(params.redirectUri).host;
|
||||||
|
} catch {
|
||||||
|
// The SDK already validates this URL before calling the provider.
|
||||||
|
}
|
||||||
|
const fields = [
|
||||||
|
hiddenInput("client_id", client.client_id),
|
||||||
|
hiddenInput("redirect_uri", params.redirectUri),
|
||||||
|
hiddenInput("response_type", "code"),
|
||||||
|
hiddenInput("code_challenge", params.codeChallenge),
|
||||||
|
hiddenInput("code_challenge_method", "S256"),
|
||||||
|
hiddenInput("scope", params.scopes?.join(" ")),
|
||||||
|
hiddenInput("state", params.state),
|
||||||
|
hiddenInput("resource", params.resource?.href),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>cokacremote 승인</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||||
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b1020; color: #e8ecf5; }
|
||||||
|
main { width: min(440px, calc(100vw - 40px)); padding: 28px; border: 1px solid #2a3550; border-radius: 16px; background: #121a2d; box-shadow: 0 20px 70px #0008; }
|
||||||
|
h1 { margin: 0 0 12px; font-size: 22px; }
|
||||||
|
p { color: #b8c1d8; line-height: 1.55; }
|
||||||
|
.warning { padding: 12px; border-radius: 10px; background: #3c2316; color: #ffd8bd; }
|
||||||
|
.error { color: #ff9f9f; font-weight: 650; }
|
||||||
|
label { display: block; margin: 20px 0 8px; font-weight: 650; }
|
||||||
|
input[type=password] { box-sizing: border-box; width: 100%; padding: 12px; border: 1px solid #52617d; border-radius: 9px; background: #0b1020; color: white; font: inherit; }
|
||||||
|
button { width: 100%; margin-top: 16px; padding: 12px; border: 0; border-radius: 9px; background: #5b8cff; color: white; font: inherit; font-weight: 700; cursor: pointer; }
|
||||||
|
small { display: block; margin-top: 14px; color: #8390aa; overflow-wrap: anywhere; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>cokacremote 연결 승인</h1>
|
||||||
|
<p><strong>${escapeHtml(clientName)}</strong>이 이 서버의 MCP 도구 사용 권한을 요청했습니다.</p>
|
||||||
|
<p class="warning">승인하면 ChatGPT가 이 EC2에서 root 권한으로 명령 실행과 파일 변경을 수행할 수 있습니다.</p>
|
||||||
|
${invalidKey ? '<p class="error">인증키가 올바르지 않습니다.</p>' : ""}
|
||||||
|
<form method="post" action="/authorize" autocomplete="off">
|
||||||
|
${fields}
|
||||||
|
<label for="access_key">MCP 인증키</label>
|
||||||
|
<input id="access_key" name="access_key" type="password" required autofocus autocomplete="current-password">
|
||||||
|
<button type="submit">승인하고 ChatGPT로 돌아가기</button>
|
||||||
|
</form>
|
||||||
|
<small>콜백 대상: ${escapeHtml(redirectHost)} · 범위: ${escapeHtml(params.scopes?.join(" ") || OAUTH_SCOPES.join(" "))}</small>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteDevOAuthProvider implements OAuthServerProvider {
|
||||||
|
readonly clientsStore: PersistentOAuthStore;
|
||||||
|
readonly issuerUrl: URL;
|
||||||
|
readonly resourceUrl: URL;
|
||||||
|
private readonly authorizationCodes = new Map<string, AuthorizationCodeRecord>();
|
||||||
|
|
||||||
|
constructor(private readonly config: AppConfig) {
|
||||||
|
if (!config.oauthIssuerUrl || !config.oauthResourceUrl || !config.authToken) {
|
||||||
|
throw new Error("OAuth configuration is incomplete");
|
||||||
|
}
|
||||||
|
this.issuerUrl = new URL(config.oauthIssuerUrl);
|
||||||
|
this.resourceUrl = new URL(config.oauthResourceUrl);
|
||||||
|
this.clientsStore = new PersistentOAuthStore(
|
||||||
|
config.oauthStateFile,
|
||||||
|
config.oauthAccessTokenTtlSeconds,
|
||||||
|
config.oauthRefreshTokenTtlSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateResource(resource: URL | undefined): string {
|
||||||
|
if (!resource || resource.href !== this.resourceUrl.href) {
|
||||||
|
throw new InvalidTargetError(`resource must be ${this.resourceUrl.href}`);
|
||||||
|
}
|
||||||
|
return resource.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateScopes(scopes: string[] | undefined): string[] {
|
||||||
|
const requested = scopes && scopes.length > 0 ? [...new Set(scopes)] : [...OAUTH_SCOPES];
|
||||||
|
if (!requested.every((scope) => OAUTH_SCOPES.includes(scope as (typeof OAUTH_SCOPES)[number]))) {
|
||||||
|
throw new InvalidScopeError("Only the mcp:tools scope is supported");
|
||||||
|
}
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneAuthorizationCodes(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [code, record] of this.authorizationCodes) {
|
||||||
|
if (record.expiresAt <= now) {
|
||||||
|
this.authorizationCodes.delete(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async authorize(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
params: AuthorizationParams,
|
||||||
|
response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const resource = this.validateResource(params.resource);
|
||||||
|
const scopes = this.validateScopes(params.scopes);
|
||||||
|
const redirectOrigin = new URL(params.redirectUri).origin;
|
||||||
|
const request = response.req as Request;
|
||||||
|
const accessKey =
|
||||||
|
request.method === "POST" && typeof request.body?.access_key === "string"
|
||||||
|
? request.body.access_key
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
response.set({
|
||||||
|
"Content-Security-Policy":
|
||||||
|
`default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${redirectOrigin}; base-uri 'none'; frame-ancestors 'none'`,
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessKey || !tokensEqual(accessKey, this.config.authToken!)) {
|
||||||
|
response
|
||||||
|
.status(accessKey ? 401 : 200)
|
||||||
|
.type("html")
|
||||||
|
.send(renderAuthorizationPage(client, { ...params, scopes, resource: new URL(resource) }, Boolean(accessKey)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pruneAuthorizationCodes();
|
||||||
|
const code = randomToken();
|
||||||
|
this.authorizationCodes.set(code, {
|
||||||
|
clientId: client.client_id,
|
||||||
|
codeChallenge: params.codeChallenge,
|
||||||
|
redirectUri: params.redirectUri,
|
||||||
|
resource,
|
||||||
|
scopes,
|
||||||
|
expiresAt: Date.now() + this.config.oauthAuthorizationCodeTtlSeconds * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = new URL(params.redirectUri);
|
||||||
|
target.searchParams.set("code", code);
|
||||||
|
if (params.state !== undefined) {
|
||||||
|
target.searchParams.set("state", params.state);
|
||||||
|
}
|
||||||
|
response.redirect(302, target.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
async challengeForAuthorizationCode(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
authorizationCode: string,
|
||||||
|
): Promise<string> {
|
||||||
|
this.pruneAuthorizationCodes();
|
||||||
|
const record = this.authorizationCodes.get(authorizationCode);
|
||||||
|
if (!record || record.clientId !== client.client_id) {
|
||||||
|
throw new InvalidGrantError("Invalid or expired authorization code");
|
||||||
|
}
|
||||||
|
return record.codeChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchangeAuthorizationCode(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
authorizationCode: string,
|
||||||
|
_codeVerifier?: string,
|
||||||
|
redirectUri?: string,
|
||||||
|
resource?: URL,
|
||||||
|
): Promise<OAuthTokens> {
|
||||||
|
this.pruneAuthorizationCodes();
|
||||||
|
const record = this.authorizationCodes.get(authorizationCode);
|
||||||
|
if (
|
||||||
|
!record ||
|
||||||
|
record.clientId !== client.client_id ||
|
||||||
|
record.redirectUri !== redirectUri ||
|
||||||
|
record.resource !== this.validateResource(resource)
|
||||||
|
) {
|
||||||
|
throw new InvalidGrantError("Invalid authorization code binding");
|
||||||
|
}
|
||||||
|
this.authorizationCodes.delete(authorizationCode);
|
||||||
|
return this.clientsStore.issueTokenPair(client.client_id, record.scopes, record.resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchangeRefreshToken(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
refreshToken: string,
|
||||||
|
scopes?: string[],
|
||||||
|
resource?: URL,
|
||||||
|
): Promise<OAuthTokens> {
|
||||||
|
const resourceValue = this.validateResource(resource);
|
||||||
|
const requestedScopes = scopes ? this.validateScopes(scopes) : undefined;
|
||||||
|
const result = await this.clientsStore.rotateRefreshToken(
|
||||||
|
refreshToken,
|
||||||
|
client.client_id,
|
||||||
|
resourceValue,
|
||||||
|
requestedScopes,
|
||||||
|
);
|
||||||
|
if (result.status === "invalid_scope") {
|
||||||
|
throw new InvalidScopeError("Refresh scope exceeds the original grant");
|
||||||
|
}
|
||||||
|
if (result.status === "invalid") {
|
||||||
|
throw new InvalidGrantError("Invalid or expired refresh token");
|
||||||
|
}
|
||||||
|
return result.tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||||
|
const stored = await this.clientsStore.getAccessToken(token);
|
||||||
|
if (!stored || stored.resource !== this.resourceUrl.href) {
|
||||||
|
throw new InvalidGrantError("Invalid or expired access token");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
clientId: stored.clientId,
|
||||||
|
scopes: stored.scopes,
|
||||||
|
expiresAt: Math.floor(stored.expiresAt / 1000),
|
||||||
|
resource: new URL(stored.resource),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeToken(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
request: OAuthTokenRevocationRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.clientsStore.revoke(request.token, client.client_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function expandPath(input: string, baseDirectory: string): string {
|
||||||
|
const expanded = input === "~"
|
||||||
|
? os.homedir()
|
||||||
|
: input.startsWith("~/")
|
||||||
|
? path.join(os.homedir(), input.slice(2))
|
||||||
|
: input;
|
||||||
|
return path.isAbsolute(expanded)
|
||||||
|
? path.normalize(expanded)
|
||||||
|
: path.resolve(baseDirectory, expanded);
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
|
||||||
|
import { errorMessage } from "./errors.js";
|
||||||
|
|
||||||
|
const OUTPUT_CHUNK_BYTES = 16 * 1024;
|
||||||
|
|
||||||
|
export type ProcessOutputStream = "stdout" | "stderr";
|
||||||
|
|
||||||
|
interface OutputChunk {
|
||||||
|
seq: number;
|
||||||
|
stream: ProcessOutputStream;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagedProcess {
|
||||||
|
sessionId: string;
|
||||||
|
child: ChildProcessWithoutNullStreams;
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt: number | undefined;
|
||||||
|
exitCode: number | null | undefined;
|
||||||
|
signal: NodeJS.Signals | null | undefined;
|
||||||
|
error: string | undefined;
|
||||||
|
timedOut: boolean;
|
||||||
|
chunks: OutputChunk[];
|
||||||
|
retainedBytes: number;
|
||||||
|
totalOutputBytes: number;
|
||||||
|
droppedOutputBytes: number;
|
||||||
|
nextSeq: number;
|
||||||
|
waiters: Set<() => void>;
|
||||||
|
exitWaiters: Set<() => void>;
|
||||||
|
timeoutHandle: NodeJS.Timeout | undefined;
|
||||||
|
cleanup: (() => Promise<void>) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartProcessRequest {
|
||||||
|
executable: string;
|
||||||
|
args: string[];
|
||||||
|
commandForDisplay: string;
|
||||||
|
cwd: string;
|
||||||
|
env?: Record<string, string> | undefined;
|
||||||
|
timeoutMs?: number | undefined;
|
||||||
|
stdin?: string | undefined;
|
||||||
|
cleanup?: (() => Promise<void>) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadProcessRequest {
|
||||||
|
afterSeq?: number | undefined;
|
||||||
|
waitMs?: number | undefined;
|
||||||
|
maxOutputBytes?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessReadResult {
|
||||||
|
sessionId: string;
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
running: boolean;
|
||||||
|
pid: number | undefined;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | undefined;
|
||||||
|
wallTimeMs: number;
|
||||||
|
exitCode: number | null | undefined;
|
||||||
|
signal: NodeJS.Signals | null | undefined;
|
||||||
|
timedOut: boolean;
|
||||||
|
error: string | undefined;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
output: string;
|
||||||
|
nextSeq: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
totalOutputBytes: number;
|
||||||
|
droppedOutputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessManagerOptions {
|
||||||
|
maxRetainedOutputBytes: number;
|
||||||
|
processRetentionMs: number;
|
||||||
|
maxProcesses: number;
|
||||||
|
defaultMaxOutputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProcessManager {
|
||||||
|
readonly #processes = new Map<string, ManagedProcess>();
|
||||||
|
readonly #options: ProcessManagerOptions;
|
||||||
|
|
||||||
|
constructor(options: ProcessManagerOptions) {
|
||||||
|
this.#options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(request: StartProcessRequest): string {
|
||||||
|
this.prune();
|
||||||
|
this.#makeCapacity();
|
||||||
|
|
||||||
|
const child = spawn(request.executable, request.args, {
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: { ...process.env, ...request.env },
|
||||||
|
stdio: "pipe",
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const managed: ManagedProcess = {
|
||||||
|
sessionId,
|
||||||
|
child,
|
||||||
|
command: request.commandForDisplay,
|
||||||
|
cwd: request.cwd,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
endedAt: undefined,
|
||||||
|
exitCode: undefined,
|
||||||
|
signal: undefined,
|
||||||
|
error: undefined,
|
||||||
|
timedOut: false,
|
||||||
|
chunks: [],
|
||||||
|
retainedBytes: 0,
|
||||||
|
totalOutputBytes: 0,
|
||||||
|
droppedOutputBytes: 0,
|
||||||
|
nextSeq: 1,
|
||||||
|
waiters: new Set(),
|
||||||
|
exitWaiters: new Set(),
|
||||||
|
timeoutHandle: undefined,
|
||||||
|
cleanup: request.cleanup,
|
||||||
|
};
|
||||||
|
this.#processes.set(sessionId, managed);
|
||||||
|
|
||||||
|
child.stdout.on("data", (data: Buffer | string) => {
|
||||||
|
this.#appendOutput(managed, "stdout", Buffer.from(data));
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (data: Buffer | string) => {
|
||||||
|
this.#appendOutput(managed, "stderr", Buffer.from(data));
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
managed.error = errorMessage(error);
|
||||||
|
this.#finish(managed, null, null);
|
||||||
|
});
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
this.#finish(managed, code, signal);
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutMs = request.timeoutMs ?? 0;
|
||||||
|
if (timeoutMs > 0) {
|
||||||
|
managed.timeoutHandle = setTimeout(() => {
|
||||||
|
managed.timedOut = true;
|
||||||
|
managed.error ??= `Process exceeded timeout of ${timeoutMs} ms`;
|
||||||
|
this.#signal(managed, "SIGTERM");
|
||||||
|
const forceTimer = setTimeout(() => {
|
||||||
|
if (this.#isRunning(managed)) {
|
||||||
|
this.#signal(managed, "SIGKILL");
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
forceTimer.unref();
|
||||||
|
}, timeoutMs);
|
||||||
|
managed.timeoutHandle.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.stdin !== undefined && request.stdin.length > 0) {
|
||||||
|
child.stdin.write(request.stdin);
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(
|
||||||
|
sessionId: string,
|
||||||
|
request: ReadProcessRequest = {},
|
||||||
|
): Promise<ProcessReadResult> {
|
||||||
|
const managed = this.#require(sessionId);
|
||||||
|
const afterSeq = Math.max(0, request.afterSeq ?? 0);
|
||||||
|
const waitMs = Math.max(0, request.waitMs ?? 0);
|
||||||
|
if (waitMs > 0) {
|
||||||
|
await this.#waitForOutput(managed, afterSeq, waitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxOutputBytes = Math.max(
|
||||||
|
OUTPUT_CHUNK_BYTES,
|
||||||
|
Math.min(
|
||||||
|
request.maxOutputBytes ?? this.#options.defaultMaxOutputBytes,
|
||||||
|
this.#options.defaultMaxOutputBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const eligible = managed.chunks.filter((chunk) => chunk.seq > afterSeq);
|
||||||
|
const selected: OutputChunk[] = [];
|
||||||
|
let selectedBytes = 0;
|
||||||
|
for (const chunk of eligible) {
|
||||||
|
if (selectedBytes + chunk.data.length > maxOutputBytes) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
selected.push(chunk);
|
||||||
|
selectedBytes += chunk.data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = Buffer.concat(
|
||||||
|
selected.filter((chunk) => chunk.stream === "stdout").map((chunk) => chunk.data),
|
||||||
|
).toString("utf8");
|
||||||
|
const stderr = Buffer.concat(
|
||||||
|
selected.filter((chunk) => chunk.stream === "stderr").map((chunk) => chunk.data),
|
||||||
|
).toString("utf8");
|
||||||
|
const output = Buffer.concat(selected.map((chunk) => chunk.data)).toString("utf8");
|
||||||
|
const nextSeq = selected.at(-1)?.seq ?? afterSeq;
|
||||||
|
const now = managed.endedAt ?? Date.now();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
command: managed.command,
|
||||||
|
cwd: managed.cwd,
|
||||||
|
running: this.#isRunning(managed),
|
||||||
|
pid: managed.child.pid,
|
||||||
|
startedAt: new Date(managed.startedAt).toISOString(),
|
||||||
|
endedAt:
|
||||||
|
managed.endedAt === undefined
|
||||||
|
? undefined
|
||||||
|
: new Date(managed.endedAt).toISOString(),
|
||||||
|
wallTimeMs: now - managed.startedAt,
|
||||||
|
exitCode: managed.exitCode,
|
||||||
|
signal: managed.signal,
|
||||||
|
timedOut: managed.timedOut,
|
||||||
|
error: managed.error,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
output,
|
||||||
|
nextSeq,
|
||||||
|
hasMore: eligible.length > selected.length,
|
||||||
|
totalOutputBytes: managed.totalOutputBytes,
|
||||||
|
droppedOutputBytes: managed.droppedOutputBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(
|
||||||
|
sessionId: string,
|
||||||
|
input: string,
|
||||||
|
closeStdin = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const managed = this.#require(sessionId);
|
||||||
|
if (input.length === 0 && !closeStdin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.#isRunning(managed)) {
|
||||||
|
throw new Error(`Process ${sessionId} is not running`);
|
||||||
|
}
|
||||||
|
if (managed.child.stdin.destroyed || !managed.child.stdin.writable) {
|
||||||
|
throw new Error(`stdin is closed for process ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.length > 0) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
managed.child.stdin.write(input, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (closeStdin) {
|
||||||
|
managed.child.stdin.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForExit(sessionId: string, waitMs: number): Promise<void> {
|
||||||
|
const managed = this.#require(sessionId);
|
||||||
|
if (!this.#isRunning(managed) || waitMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
managed.exitWaiters.delete(finish);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, waitMs);
|
||||||
|
managed.exitWaiters.add(finish);
|
||||||
|
if (!this.#isRunning(managed)) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(
|
||||||
|
sessionId: string,
|
||||||
|
signal: NodeJS.Signals = "SIGTERM",
|
||||||
|
graceMs = 3000,
|
||||||
|
): Promise<ProcessReadResult> {
|
||||||
|
const managed = this.#require(sessionId);
|
||||||
|
if (this.#isRunning(managed)) {
|
||||||
|
this.#signal(managed, signal);
|
||||||
|
if (signal !== "SIGKILL" && graceMs > 0) {
|
||||||
|
const forceTimer = setTimeout(() => {
|
||||||
|
if (this.#isRunning(managed)) {
|
||||||
|
this.#signal(managed, "SIGKILL");
|
||||||
|
}
|
||||||
|
}, graceMs);
|
||||||
|
forceTimer.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.read(sessionId, { waitMs: Math.min(graceMs, 1000) });
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): Array<{
|
||||||
|
sessionId: string;
|
||||||
|
pid: number | undefined;
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
running: boolean;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | undefined;
|
||||||
|
exitCode: number | null | undefined;
|
||||||
|
}> {
|
||||||
|
this.prune();
|
||||||
|
return [...this.#processes.values()].map((managed) => ({
|
||||||
|
sessionId: managed.sessionId,
|
||||||
|
pid: managed.child.pid,
|
||||||
|
command: managed.command,
|
||||||
|
cwd: managed.cwd,
|
||||||
|
running: this.#isRunning(managed),
|
||||||
|
startedAt: new Date(managed.startedAt).toISOString(),
|
||||||
|
endedAt:
|
||||||
|
managed.endedAt === undefined
|
||||||
|
? undefined
|
||||||
|
: new Date(managed.endedAt).toISOString(),
|
||||||
|
exitCode: managed.exitCode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
prune(): void {
|
||||||
|
const cutoff = Date.now() - this.#options.processRetentionMs;
|
||||||
|
for (const [sessionId, managed] of this.#processes) {
|
||||||
|
if (managed.endedAt !== undefined && managed.endedAt < cutoff) {
|
||||||
|
this.#processes.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
const running = [...this.#processes.values()].filter((managed) =>
|
||||||
|
this.#isRunning(managed),
|
||||||
|
);
|
||||||
|
for (const managed of running) {
|
||||||
|
this.#signal(managed, "SIGTERM");
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, running.length > 0 ? 500 : 0));
|
||||||
|
for (const managed of running) {
|
||||||
|
if (this.#isRunning(managed)) {
|
||||||
|
this.#signal(managed, "SIGKILL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#makeCapacity(): void {
|
||||||
|
if (this.#processes.size < this.#options.maxProcesses) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const completed = [...this.#processes.values()]
|
||||||
|
.filter((managed) => managed.endedAt !== undefined)
|
||||||
|
.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0));
|
||||||
|
while (
|
||||||
|
this.#processes.size >= this.#options.maxProcesses &&
|
||||||
|
completed.length > 0
|
||||||
|
) {
|
||||||
|
const managed = completed.shift();
|
||||||
|
if (managed) {
|
||||||
|
this.#processes.delete(managed.sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.#processes.size >= this.#options.maxProcesses) {
|
||||||
|
throw new Error(
|
||||||
|
`Maximum managed process count (${this.#options.maxProcesses}) reached`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#require(sessionId: string): ManagedProcess {
|
||||||
|
const managed = this.#processes.get(sessionId);
|
||||||
|
if (!managed) {
|
||||||
|
throw new Error(`Unknown process session: ${sessionId}`);
|
||||||
|
}
|
||||||
|
return managed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#appendOutput(
|
||||||
|
managed: ManagedProcess,
|
||||||
|
stream: ProcessOutputStream,
|
||||||
|
data: Buffer,
|
||||||
|
): void {
|
||||||
|
for (let offset = 0; offset < data.length; offset += OUTPUT_CHUNK_BYTES) {
|
||||||
|
const chunkData = Buffer.from(data.subarray(offset, offset + OUTPUT_CHUNK_BYTES));
|
||||||
|
managed.chunks.push({
|
||||||
|
seq: managed.nextSeq,
|
||||||
|
stream,
|
||||||
|
data: chunkData,
|
||||||
|
});
|
||||||
|
managed.nextSeq += 1;
|
||||||
|
managed.retainedBytes += chunkData.length;
|
||||||
|
managed.totalOutputBytes += chunkData.length;
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
managed.retainedBytes > this.#options.maxRetainedOutputBytes &&
|
||||||
|
managed.chunks.length > 0
|
||||||
|
) {
|
||||||
|
const removed = managed.chunks.shift();
|
||||||
|
if (removed) {
|
||||||
|
managed.retainedBytes -= removed.data.length;
|
||||||
|
managed.droppedOutputBytes += removed.data.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#notify(managed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#finish(
|
||||||
|
managed: ManagedProcess,
|
||||||
|
code: number | null,
|
||||||
|
signal: NodeJS.Signals | null,
|
||||||
|
): void {
|
||||||
|
if (managed.endedAt !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
managed.endedAt = Date.now();
|
||||||
|
managed.exitCode = code;
|
||||||
|
managed.signal = signal;
|
||||||
|
if (managed.timeoutHandle) {
|
||||||
|
clearTimeout(managed.timeoutHandle);
|
||||||
|
managed.timeoutHandle = undefined;
|
||||||
|
}
|
||||||
|
this.#notify(managed);
|
||||||
|
const exitWaiters = [...managed.exitWaiters];
|
||||||
|
managed.exitWaiters.clear();
|
||||||
|
for (const waiter of exitWaiters) {
|
||||||
|
waiter();
|
||||||
|
}
|
||||||
|
if (managed.cleanup) {
|
||||||
|
void managed.cleanup().catch((error) => {
|
||||||
|
managed.error ??= `Cleanup failed: ${errorMessage(error)}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#notify(managed: ManagedProcess): void {
|
||||||
|
const waiters = [...managed.waiters];
|
||||||
|
managed.waiters.clear();
|
||||||
|
for (const waiter of waiters) {
|
||||||
|
waiter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#waitForOutput(
|
||||||
|
managed: ManagedProcess,
|
||||||
|
afterSeq: number,
|
||||||
|
waitMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
if (managed.nextSeq - 1 > afterSeq || !this.#isRunning(managed)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
managed.waiters.delete(finish);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, waitMs);
|
||||||
|
managed.waiters.add(finish);
|
||||||
|
if (managed.nextSeq - 1 > afterSeq || !this.#isRunning(managed)) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#isRunning(managed: ManagedProcess): boolean {
|
||||||
|
return managed.endedAt === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
#signal(managed: ManagedProcess, signal: NodeJS.Signals): void {
|
||||||
|
const pid = managed.child.pid;
|
||||||
|
if (pid === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
process.kill(-pid, signal);
|
||||||
|
} else {
|
||||||
|
managed.child.kill(signal);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== "ESRCH") {
|
||||||
|
managed.error ??= `Failed to signal process: ${errorMessage(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { ProcessReadResult } from "./process-manager.js";
|
||||||
|
import { ProcessManager } from "./process-manager.js";
|
||||||
|
|
||||||
|
export type ScriptRuntime = "bash" | "sh" | "node" | "python" | "custom";
|
||||||
|
|
||||||
|
export interface RunScriptRequest {
|
||||||
|
runtime: ScriptRuntime;
|
||||||
|
script: string;
|
||||||
|
cwd: string;
|
||||||
|
args?: string[] | undefined;
|
||||||
|
env?: Record<string, string> | undefined;
|
||||||
|
interpreter?: string | undefined;
|
||||||
|
interpreterArgs?: string[] | undefined;
|
||||||
|
timeoutMs?: number | undefined;
|
||||||
|
yieldTimeMs?: number | undefined;
|
||||||
|
maxOutputBytes?: number | undefined;
|
||||||
|
stdin?: string | undefined;
|
||||||
|
keepScript?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunScriptResult extends ProcessReadResult {
|
||||||
|
scriptPath: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeDefinition {
|
||||||
|
executable: string;
|
||||||
|
extension: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeDefinition(request: RunScriptRequest): RuntimeDefinition {
|
||||||
|
if (request.runtime === "custom") {
|
||||||
|
if (!request.interpreter?.trim()) {
|
||||||
|
throw new Error("interpreter is required when runtime is custom");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
executable: request.interpreter,
|
||||||
|
extension: ".script",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const definitions: Record<Exclude<ScriptRuntime, "custom">, RuntimeDefinition> = {
|
||||||
|
bash: { executable: request.interpreter || "bash", extension: ".sh" },
|
||||||
|
sh: { executable: request.interpreter || "sh", extension: ".sh" },
|
||||||
|
node: { executable: request.interpreter || process.execPath, extension: ".mjs" },
|
||||||
|
python: { executable: request.interpreter || "python3", extension: ".py" },
|
||||||
|
};
|
||||||
|
return definitions[request.runtime];
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayCommand(executable: string, args: string[]): string {
|
||||||
|
return [executable, ...args].map((value) => JSON.stringify(value)).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScript(
|
||||||
|
processManager: ProcessManager,
|
||||||
|
request: RunScriptRequest,
|
||||||
|
): Promise<RunScriptResult> {
|
||||||
|
const runtime = runtimeDefinition(request);
|
||||||
|
const temporaryDirectory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "remote-dev-mcp-script-"),
|
||||||
|
);
|
||||||
|
const scriptPath = path.join(temporaryDirectory, `script${runtime.extension}`);
|
||||||
|
await writeFile(scriptPath, request.script, { mode: 0o700 });
|
||||||
|
await chmod(scriptPath, 0o700);
|
||||||
|
|
||||||
|
const processArgs = [
|
||||||
|
...(request.interpreterArgs ?? []),
|
||||||
|
scriptPath,
|
||||||
|
...(request.args ?? []),
|
||||||
|
];
|
||||||
|
const keepScript = request.keepScript ?? false;
|
||||||
|
const cleanup = keepScript
|
||||||
|
? undefined
|
||||||
|
: async () => {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
let sessionId: string;
|
||||||
|
try {
|
||||||
|
sessionId = processManager.start({
|
||||||
|
executable: runtime.executable,
|
||||||
|
args: processArgs,
|
||||||
|
commandForDisplay: displayCommand(runtime.executable, processArgs),
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: request.env,
|
||||||
|
timeoutMs: request.timeoutMs,
|
||||||
|
stdin: request.stdin,
|
||||||
|
cleanup,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processManager.waitForExit(sessionId, request.yieldTimeMs ?? 10_000);
|
||||||
|
const result = await processManager.read(sessionId, {
|
||||||
|
maxOutputBytes: request.maxOutputBytes,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
scriptPath: keepScript ? scriptPath : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
import { errorMessage } from "./errors.js";
|
||||||
|
import { startHttpServer } from "./http-server.js";
|
||||||
|
import { createServices } from "./mcp-server.js";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const services = createServices(config);
|
||||||
|
const running = await startHttpServer(config, services);
|
||||||
|
const endpointUrl = config.publicUrl
|
||||||
|
? `${config.publicUrl}${config.endpoint}`
|
||||||
|
: `http://${config.host}:${config.port}${config.endpoint}`;
|
||||||
|
|
||||||
|
console.log(`cokacremote listening at ${endpointUrl}`);
|
||||||
|
console.log(`default cwd: ${config.defaultCwd}`);
|
||||||
|
console.log("execution mode: unrestricted host access");
|
||||||
|
console.log(
|
||||||
|
config.allowNoAuth && !config.authToken
|
||||||
|
? "authentication: disabled"
|
||||||
|
: config.oauthEnabled
|
||||||
|
? "authentication: static bearer + OAuth 2.1 (DCR/PKCE)"
|
||||||
|
: "authentication: bearer token",
|
||||||
|
);
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
if (shuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log(`received ${signal}; shutting down`);
|
||||||
|
try {
|
||||||
|
await running.close();
|
||||||
|
process.exitCode = 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("shutdown failed:", errorMessage(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("cokacremote failed to start:", errorMessage(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
|
import { errorMessage } from "./errors.js";
|
||||||
|
|
||||||
|
export function successResult(data: Record<string, unknown>): CallToolResult {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||||
|
structuredContent: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorResult(error: unknown): CallToolResult {
|
||||||
|
const data = { error: errorMessage(error) };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||||
|
structuredContent: data,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTool(
|
||||||
|
operation: () => Promise<Record<string, unknown>> | Record<string, unknown>,
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
try {
|
||||||
|
return successResult(await operation());
|
||||||
|
} catch (error) {
|
||||||
|
return errorResult(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
describe("loadConfig", () => {
|
||||||
|
it("requires authentication unless explicitly disabled", () => {
|
||||||
|
expect(() => loadConfig({}, "/tmp")).toThrow("MCP_AUTH_TOKEN is required");
|
||||||
|
expect(loadConfig({ MCP_ALLOW_NO_AUTH: "true" }, "/tmp").allowNoAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads full-access host settings", () => {
|
||||||
|
const config = loadConfig(
|
||||||
|
{
|
||||||
|
MCP_AUTH_TOKEN: "secret",
|
||||||
|
MCP_PORT: "4321",
|
||||||
|
MCP_DEFAULT_CWD: "/",
|
||||||
|
MCP_ALLOWED_HOSTS: "mcp.example.com,localhost",
|
||||||
|
},
|
||||||
|
"/tmp",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
port: 4321,
|
||||||
|
defaultCwd: "/",
|
||||||
|
authToken: "secret",
|
||||||
|
allowedHosts: ["mcp.example.com", "localhost"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires public HTTPS metadata when OAuth is enabled", () => {
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_OAUTH_ENABLED: "true" }, "/tmp"),
|
||||||
|
).toThrow("MCP_OAUTH_ISSUER is required");
|
||||||
|
|
||||||
|
const config = loadConfig(
|
||||||
|
{
|
||||||
|
MCP_AUTH_TOKEN: "secret",
|
||||||
|
MCP_OAUTH_ENABLED: "true",
|
||||||
|
MCP_PUBLIC_URL: "https://mcp.example.com",
|
||||||
|
MCP_OAUTH_STATE_FILE: "/tmp/oauth-state.json",
|
||||||
|
},
|
||||||
|
"/tmp",
|
||||||
|
);
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
oauthEnabled: true,
|
||||||
|
oauthIssuerUrl: "https://mcp.example.com/",
|
||||||
|
oauthResourceUrl: "https://mcp.example.com/mcp",
|
||||||
|
oauthStateFile: "/tmp/oauth-state.json",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { FileService } from "../src/file-service.js";
|
||||||
|
|
||||||
|
describe("FileService", () => {
|
||||||
|
let temporaryDirectory: string;
|
||||||
|
let files: FileService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-test-"));
|
||||||
|
files = new FileService({
|
||||||
|
defaultCwd: temporaryDirectory,
|
||||||
|
maxChunkBytes: 1024 * 1024,
|
||||||
|
maxEditFileBytes: 1024 * 1024,
|
||||||
|
maxOutputBytes: 1024 * 1024,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes, reads, lists, and replaces text", async () => {
|
||||||
|
await files.writeFileContent(
|
||||||
|
"src/example.txt",
|
||||||
|
undefined,
|
||||||
|
"alpha beta\n",
|
||||||
|
"utf8",
|
||||||
|
"overwrite",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await files.replaceInFile(
|
||||||
|
"src/example.txt",
|
||||||
|
undefined,
|
||||||
|
"beta",
|
||||||
|
"gamma",
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const read = await files.readFileChunk(
|
||||||
|
"src/example.txt",
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
1024,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const listed = await files.listDirectory(".", undefined, {
|
||||||
|
recursive: true,
|
||||||
|
includeMetadata: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(read.content).toBe("alpha gamma\n");
|
||||||
|
expect(read.eof).toBe(true);
|
||||||
|
expect(listed.entries).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ relativePath: path.join("src", "example.txt") }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads and downloads binary chunks with offsets", async () => {
|
||||||
|
const first = await files.uploadChunk(
|
||||||
|
"artifact.bin",
|
||||||
|
undefined,
|
||||||
|
Buffer.from("hello").toString("base64"),
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await files.uploadChunk(
|
||||||
|
"artifact.bin",
|
||||||
|
undefined,
|
||||||
|
Buffer.from(" world").toString("base64"),
|
||||||
|
first.nextOffset as number,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloaded = await files.downloadChunk(
|
||||||
|
"artifact.bin",
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
1024,
|
||||||
|
);
|
||||||
|
const hashed = await files.hashFile("artifact.bin", undefined, "sha256");
|
||||||
|
|
||||||
|
expect(Buffer.from(downloaded.dataBase64 as string, "base64").toString()).toBe(
|
||||||
|
"hello world",
|
||||||
|
);
|
||||||
|
expect(downloaded.eof).toBe(true);
|
||||||
|
expect(hashed.digest).toBe(
|
||||||
|
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates and applies a unified diff", async () => {
|
||||||
|
await writeFile(path.join(temporaryDirectory, "patch.txt"), "old\n", "utf8");
|
||||||
|
const patchText = [
|
||||||
|
"diff --git a/patch.txt b/patch.txt",
|
||||||
|
"--- a/patch.txt",
|
||||||
|
"+++ b/patch.txt",
|
||||||
|
"@@ -1 +1 @@",
|
||||||
|
"-old",
|
||||||
|
"+new",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const checked = await files.applyPatch(patchText, undefined, {
|
||||||
|
checkOnly: true,
|
||||||
|
reverse: false,
|
||||||
|
threeWay: false,
|
||||||
|
});
|
||||||
|
const applied = await files.applyPatch(patchText, undefined, {
|
||||||
|
checkOnly: false,
|
||||||
|
reverse: false,
|
||||||
|
threeWay: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checked.applied).toBe(false);
|
||||||
|
expect(applied.applied).toBe(true);
|
||||||
|
expect(await readFile(path.join(temporaryDirectory, "patch.txt"), "utf8")).toBe(
|
||||||
|
"new\n",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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, type AppConfig } from "../src/config.js";
|
||||||
|
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
|
||||||
|
import { createServices, type McpServices } from "../src/mcp-server.js";
|
||||||
|
|
||||||
|
describe("remote development MCP server", () => {
|
||||||
|
let temporaryDirectory: string;
|
||||||
|
let config: AppConfig;
|
||||||
|
let services: McpServices;
|
||||||
|
let running: RunningHttpServer;
|
||||||
|
let endpoint: URL;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-http-test-"));
|
||||||
|
config = loadConfig(
|
||||||
|
{
|
||||||
|
MCP_AUTH_TOKEN: "integration-secret",
|
||||||
|
MCP_HOST: "127.0.0.1",
|
||||||
|
MCP_DEFAULT_CWD: temporaryDirectory,
|
||||||
|
MCP_MAX_FILE_CHUNK_BYTES: "65536",
|
||||||
|
},
|
||||||
|
temporaryDirectory,
|
||||||
|
);
|
||||||
|
config.port = 0;
|
||||||
|
services = createServices(config);
|
||||||
|
running = await startHttpServer(config, services);
|
||||||
|
const address = running.httpServer.address() as AddressInfo;
|
||||||
|
endpoint = new URL(`http://127.0.0.1:${address.port}${config.endpoint}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await running.close();
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated MCP initialization", async () => {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "initialize",
|
||||||
|
params: {
|
||||||
|
protocolVersion: "2025-11-25",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "test", version: "1" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists tools and executes script and file workflows", async () => {
|
||||||
|
const client = new Client({ name: "integration-test", version: "1.0.0" });
|
||||||
|
const transport = new StreamableHTTPClientTransport(endpoint, {
|
||||||
|
requestInit: {
|
||||||
|
headers: { Authorization: "Bearer integration-secret" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.connect(transport);
|
||||||
|
try {
|
||||||
|
expect(client.getServerVersion()).toMatchObject({
|
||||||
|
name: "cokacremote",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.tools.map((tool) => tool.name)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"exec_command",
|
||||||
|
"run_script",
|
||||||
|
"write_stdin",
|
||||||
|
"read_file",
|
||||||
|
"write_file",
|
||||||
|
"apply_patch",
|
||||||
|
"upload_file",
|
||||||
|
"download_file",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const scriptResult = await client.callTool({
|
||||||
|
name: "run_script",
|
||||||
|
arguments: {
|
||||||
|
runtime: "node",
|
||||||
|
script: "console.log(6 * 7)",
|
||||||
|
yieldTimeMs: 2000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(scriptResult.isError).not.toBe(true);
|
||||||
|
expect(scriptResult.structuredContent).toMatchObject({
|
||||||
|
completed: true,
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: "42\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeResult = await client.callTool({
|
||||||
|
name: "write_file",
|
||||||
|
arguments: { path: "hello.txt", content: "hello MCP\n" },
|
||||||
|
});
|
||||||
|
expect(writeResult.isError).not.toBe(true);
|
||||||
|
|
||||||
|
const readResult = await client.callTool({
|
||||||
|
name: "read_file",
|
||||||
|
arguments: { path: "hello.txt" },
|
||||||
|
});
|
||||||
|
expect(readResult.structuredContent).toMatchObject({
|
||||||
|
content: "hello MCP\n",
|
||||||
|
eof: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await transport.terminateSession();
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { createServer } 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, type AppConfig } from "../src/config.js";
|
||||||
|
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
|
||||||
|
import { createServices } from "../src/mcp-server.js";
|
||||||
|
|
||||||
|
async function reservePort(): Promise<number> {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
const port = (server.address() as AddressInfo).port;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function form(values: Record<string, string>): URLSearchParams {
|
||||||
|
return new URLSearchParams(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OAuth 2.1 MCP authorization", () => {
|
||||||
|
let temporaryDirectory: string;
|
||||||
|
let stateFile: string;
|
||||||
|
let config: AppConfig;
|
||||||
|
let running: RunningHttpServer;
|
||||||
|
let baseUrl: string;
|
||||||
|
let resourceUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-oauth-test-"));
|
||||||
|
stateFile = path.join(temporaryDirectory, "oauth", "state.json");
|
||||||
|
const port = await reservePort();
|
||||||
|
baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
resourceUrl = `${baseUrl}/mcp`;
|
||||||
|
config = loadConfig(
|
||||||
|
{
|
||||||
|
MCP_AUTH_TOKEN: "oauth-login-secret",
|
||||||
|
MCP_OAUTH_ENABLED: "true",
|
||||||
|
MCP_PUBLIC_URL: baseUrl,
|
||||||
|
MCP_OAUTH_STATE_FILE: stateFile,
|
||||||
|
MCP_HOST: "127.0.0.1",
|
||||||
|
MCP_PORT: String(port),
|
||||||
|
MCP_DEFAULT_CWD: temporaryDirectory,
|
||||||
|
},
|
||||||
|
temporaryDirectory,
|
||||||
|
);
|
||||||
|
running = await startHttpServer(config, createServices(config));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await running.close();
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discovers, authorizes with PKCE, refreshes, revokes, and calls MCP tools", async () => {
|
||||||
|
const unauthenticated = await fetch(resourceUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "initialize",
|
||||||
|
params: {
|
||||||
|
protocolVersion: "2025-11-25",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "oauth-test", version: "1" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(unauthenticated.status).toBe(401);
|
||||||
|
expect(unauthenticated.headers.get("www-authenticate")).toContain(
|
||||||
|
`${baseUrl}/.well-known/oauth-protected-resource/mcp`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const metadataPath of [
|
||||||
|
"/.well-known/oauth-protected-resource",
|
||||||
|
"/.well-known/oauth-protected-resource/mcp",
|
||||||
|
]) {
|
||||||
|
const response = await fetch(`${baseUrl}${metadataPath}`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
resource: resourceUrl,
|
||||||
|
authorization_servers: [`${baseUrl}/`],
|
||||||
|
scopes_supported: ["mcp:tools"],
|
||||||
|
resource_name: "cokacremote",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataResponse = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
|
||||||
|
expect(metadataResponse.status).toBe(200);
|
||||||
|
expect(await metadataResponse.json()).toMatchObject({
|
||||||
|
issuer: `${baseUrl}/`,
|
||||||
|
authorization_endpoint: `${baseUrl}/authorize`,
|
||||||
|
token_endpoint: `${baseUrl}/token`,
|
||||||
|
registration_endpoint: `${baseUrl}/register`,
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
token_endpoint_auth_methods_supported: expect.arrayContaining(["none"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUri = "https://chatgpt.com/connector/oauth/test-callback";
|
||||||
|
const registrationResponse = await fetch(`${baseUrl}/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
redirect_uris: [redirectUri],
|
||||||
|
token_endpoint_auth_method: "none",
|
||||||
|
grant_types: ["authorization_code", "refresh_token"],
|
||||||
|
response_types: ["code"],
|
||||||
|
client_name: "ChatGPT OAuth integration test",
|
||||||
|
scope: "mcp:tools",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(registrationResponse.status).toBe(201);
|
||||||
|
const registered = (await registrationResponse.json()) as { client_id: string };
|
||||||
|
expect(registered.client_id).toBeTruthy();
|
||||||
|
|
||||||
|
const codeVerifier = randomBytes(48).toString("base64url");
|
||||||
|
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
||||||
|
const authorizationValues = {
|
||||||
|
client_id: registered.client_id,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
scope: "mcp:tools",
|
||||||
|
state: "oauth-test-state",
|
||||||
|
resource: resourceUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginPage = await fetch(`${baseUrl}/authorize?${form(authorizationValues)}`, {
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
expect(loginPage.status).toBe(200);
|
||||||
|
expect(loginPage.headers.get("content-security-policy")).toContain(
|
||||||
|
"form-action 'self' https://chatgpt.com",
|
||||||
|
);
|
||||||
|
expect(await loginPage.text()).toContain("MCP 인증키");
|
||||||
|
|
||||||
|
const rejectedLogin = await fetch(`${baseUrl}/authorize`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: form({ ...authorizationValues, access_key: "wrong-key" }),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
expect(rejectedLogin.status).toBe(401);
|
||||||
|
|
||||||
|
const approvedLogin = await fetch(`${baseUrl}/authorize`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: form({ ...authorizationValues, access_key: "oauth-login-secret" }),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
expect(approvedLogin.status).toBe(302);
|
||||||
|
const callback = new URL(approvedLogin.headers.get("location")!);
|
||||||
|
expect(callback.origin + callback.pathname).toBe(redirectUri);
|
||||||
|
expect(callback.searchParams.get("state")).toBe("oauth-test-state");
|
||||||
|
const authorizationCode = callback.searchParams.get("code");
|
||||||
|
expect(authorizationCode).toBeTruthy();
|
||||||
|
|
||||||
|
const tokenResponse = await fetch(`${baseUrl}/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: form({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: registered.client_id,
|
||||||
|
code: authorizationCode!,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
resource: resourceUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(tokenResponse.status).toBe(200);
|
||||||
|
const tokens = (await tokenResponse.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
scope: string;
|
||||||
|
};
|
||||||
|
expect(tokens).toMatchObject({ expires_in: 3600, scope: "mcp:tools" });
|
||||||
|
|
||||||
|
await running.close();
|
||||||
|
running = await startHttpServer(config, createServices(config));
|
||||||
|
|
||||||
|
const client = new Client({ name: "oauth-integration-test", version: "1.0.0" });
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(resourceUrl), {
|
||||||
|
requestInit: { headers: { Authorization: `Bearer ${tokens.access_token}` } },
|
||||||
|
});
|
||||||
|
await client.connect(transport);
|
||||||
|
try {
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.tools.some((tool) => tool.name === "run_script")).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await transport.terminateSession();
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshResponse = await fetch(`${baseUrl}/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: form({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: registered.client_id,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
resource: resourceUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(refreshResponse.status).toBe(200);
|
||||||
|
const refreshed = (await refreshResponse.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
expect(refreshed.access_token).not.toBe(tokens.access_token);
|
||||||
|
expect(refreshed.refresh_token).not.toBe(tokens.refresh_token);
|
||||||
|
|
||||||
|
const revokeResponse = await fetch(`${baseUrl}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: form({ client_id: registered.client_id, token: refreshed.access_token }),
|
||||||
|
});
|
||||||
|
expect(revokeResponse.status).toBe(200);
|
||||||
|
|
||||||
|
const revokedRequest = await fetch(resourceUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${refreshed.access_token}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "initialize", params: {} }),
|
||||||
|
});
|
||||||
|
expect(revokedRequest.status).toBe(401);
|
||||||
|
|
||||||
|
expect((await stat(stateFile)).mode & 0o777).toBe(0o600);
|
||||||
|
const persisted = await readFile(stateFile, "utf8");
|
||||||
|
expect(persisted).not.toContain(tokens.access_token);
|
||||||
|
expect(persisted).not.toContain(tokens.refresh_token);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ProcessManager } from "../src/process-manager.js";
|
||||||
|
|
||||||
|
function createManager(): ProcessManager {
|
||||||
|
return new ProcessManager({
|
||||||
|
maxRetainedOutputBytes: 1024 * 1024,
|
||||||
|
processRetentionMs: 60_000,
|
||||||
|
maxProcesses: 16,
|
||||||
|
defaultMaxOutputBytes: 1024 * 1024,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProcessManager", () => {
|
||||||
|
let manager: ProcessManager | undefined;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await manager?.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures stdout, stderr, and exit state", async () => {
|
||||||
|
manager = createManager();
|
||||||
|
const sessionId = manager.start({
|
||||||
|
executable: "/bin/bash",
|
||||||
|
args: ["-c", "printf stdout; printf stderr >&2"],
|
||||||
|
commandForDisplay: "test output",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.waitForExit(sessionId, 2000);
|
||||||
|
const result = await manager.read(sessionId);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
running: false,
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: "stdout",
|
||||||
|
stderr: "stderr",
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
expect(result.output).toContain("stdout");
|
||||||
|
expect(result.output).toContain("stderr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports interactive stdin and closes cleanly", async () => {
|
||||||
|
manager = createManager();
|
||||||
|
const sessionId = manager.start({
|
||||||
|
executable: "/bin/cat",
|
||||||
|
args: [],
|
||||||
|
commandForDisplay: "cat",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.write(sessionId, "hello\n", true);
|
||||||
|
await manager.waitForExit(sessionId, 2000);
|
||||||
|
const result = await manager.read(sessionId);
|
||||||
|
|
||||||
|
expect(result.running).toBe(false);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout).toBe("hello\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates a command when its timeout expires", async () => {
|
||||||
|
manager = createManager();
|
||||||
|
const sessionId = manager.start({
|
||||||
|
executable: "/bin/bash",
|
||||||
|
args: ["-c", "sleep 10"],
|
||||||
|
commandForDisplay: "sleep 10",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
timeoutMs: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.waitForExit(sessionId, 3000);
|
||||||
|
const result = await manager.read(sessionId);
|
||||||
|
|
||||||
|
expect(result.running).toBe(false);
|
||||||
|
expect(result.timedOut).toBe(true);
|
||||||
|
expect(result.error).toContain("timeout");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||||
|
"exclude": ["dist", "node_modules", "codex"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
exclude: ["codex/**", "node_modules/**", "dist/**"],
|
||||||
|
testTimeout: 10_000,
|
||||||
|
hookTimeout: 10_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user