first commit

This commit is contained in:
kst
2026-08-19 22:52:46 +09:00
commit 8454476c93
30 changed files with 7352 additions and 0 deletions
+23
View File
@@ -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
View File
@@ -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
*~
+21
View File
@@ -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.
+159
View File
@@ -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)
+60
View File
@@ -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;
}
}
+24
View File
@@ -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
+20
View File
@@ -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
+3361
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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,
),
};
}
+13
View File
@@ -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);
}
}
+302
View File
@@ -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() })),
);
}
+526
View File
@@ -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") };
}
}
+339
View File
@@ -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)),
);
}
+211
View File
@@ -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 };
}
+53
View File
@@ -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
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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);
}
}
+13
View File
@@ -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);
}
+499
View File
@@ -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)}`;
}
}
}
}
+107
View File
@@ -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,
};
}
+48
View File
@@ -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;
});
+29
View File
@@ -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);
}
}
+51
View File
@@ -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",
});
});
});
+130
View File
@@ -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",
);
});
});
+124
View File
@@ -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();
}
});
});
+249
View File
@@ -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);
});
});
+79
View File
@@ -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");
});
});
+20
View File
@@ -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"]
}
+10
View File
@@ -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,
},
});