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
+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);
}
}