This commit is contained in:
kst
2026-08-20 00:16:20 +09:00
parent 72a5fdc453
commit efbc533bc1
10 changed files with 1345 additions and 116 deletions
-7
View File
@@ -22,7 +22,6 @@ export interface AppConfig {
maxRetainedProcessOutputBytes: number;
processRetentionMs: number;
maxProcesses: number;
sessionTtlMs: number;
maxFileChunkBytes: number;
maxEditFileBytes: number;
}
@@ -178,12 +177,6 @@ export function loadConfig(
"MCP_MAX_PROCESSES",
1,
),
sessionTtlMs: parseInteger(
env.MCP_SESSION_TTL_MS,
24 * 60 * 60 * 1000,
"MCP_SESSION_TTL_MS",
60_000,
),
maxFileChunkBytes: parseInteger(
env.MCP_MAX_FILE_CHUNK_BYTES,
1024 * 1024,
+118 -21
View File
@@ -1,5 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { isUtf8 } from "node:buffer";
import { constants, createReadStream } from "node:fs";
import {
appendFile,
chmod,
@@ -71,8 +72,89 @@ function encodeContent(data: Buffer, encoding: FileContentEncoding): string {
return encoding === "base64" ? data.toString("base64") : data.toString("utf8");
}
function decodeBase64(data: string): Buffer {
if (data.length === 0) {
return Buffer.alloc(0);
}
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data)) {
throw new Error("Invalid base64 content");
}
const content = data.replace(/=+$/, "");
const suppliedPadding = data.length - content.length;
if (content.length % 4 === 1) {
throw new Error("Invalid base64 content length");
}
const requiredPadding = (4 - (content.length % 4)) % 4;
if (suppliedPadding > 0 && suppliedPadding !== requiredPadding) {
throw new Error("Invalid base64 padding");
}
const canonical = `${content}${"=".repeat(requiredPadding)}`;
const decoded = Buffer.from(canonical, "base64");
if (decoded.toString("base64").replace(/=+$/, "") !== content) {
throw new Error("Invalid base64 content");
}
return decoded;
}
function decodeContent(data: string, encoding: FileContentEncoding): Buffer {
return Buffer.from(data, encoding === "base64" ? "base64" : "utf8");
return encoding === "base64" ? decodeBase64(data) : Buffer.from(data, "utf8");
}
function utf8SequenceLength(firstByte: number): number {
if (firstByte <= 0x7f) {
return 1;
}
if (firstByte >= 0xc2 && firstByte <= 0xdf) {
return 2;
}
if (firstByte >= 0xe0 && firstByte <= 0xef) {
return 3;
}
if (firstByte >= 0xf0 && firstByte <= 0xf4) {
return 4;
}
return 0;
}
function utf8ChunkLength(
buffer: Buffer,
requestedBytes: number,
absoluteOffset: number,
reachesEndOfFile: boolean,
): number {
let cursor = 0;
let lastBoundary = 0;
while (cursor < buffer.length) {
if (cursor >= requestedBytes && lastBoundary > 0) {
return lastBoundary;
}
const sequenceLength = utf8SequenceLength(buffer[cursor]!);
if (sequenceLength === 0) {
throw new Error(
`Invalid UTF-8 at byte offset ${absoluteOffset + cursor}; use encoding=base64`,
);
}
const nextBoundary = cursor + sequenceLength;
if (nextBoundary > buffer.length) {
if (reachesEndOfFile) {
throw new Error(
`Truncated UTF-8 at byte offset ${absoluteOffset + cursor}; use encoding=base64`,
);
}
break;
}
if (!isUtf8(buffer.subarray(cursor, nextBoundary))) {
throw new Error(
`Invalid UTF-8 at byte offset ${absoluteOffset + cursor}; use encoding=base64`,
);
}
if (nextBoundary > requestedBytes) {
return lastBoundary === 0 ? nextBoundary : lastBoundary;
}
lastBoundary = nextBoundary;
cursor = nextBoundary;
}
return lastBoundary;
}
export class FileService {
@@ -180,14 +262,24 @@ export class FileService {
throw new Error(`${resolvedPath} is not a regular file`);
}
const safeOffset = Math.max(0, Math.min(offset, info.size));
const byteCount = Math.max(
1,
Math.min(maxBytes, this.#options.maxChunkBytes, info.size - safeOffset),
);
const availableBytes = info.size - safeOffset;
const requestedBytes = Math.min(maxBytes, this.#options.maxChunkBytes, availableBytes);
const probeBytes = encoding === "utf8"
? Math.min(this.#options.maxChunkBytes, availableBytes, requestedBytes + 3)
: requestedBytes;
const handle = await open(resolvedPath, "r");
try {
const buffer = Buffer.alloc(byteCount);
const { bytesRead } = await handle.read(buffer, 0, byteCount, safeOffset);
const buffer = Buffer.alloc(probeBytes);
const readResult = await handle.read(buffer, 0, probeBytes, safeOffset);
let bytesRead = readResult.bytesRead;
if (encoding === "utf8" && bytesRead > 0) {
bytesRead = utf8ChunkLength(
buffer.subarray(0, bytesRead),
requestedBytes,
safeOffset,
safeOffset + readResult.bytesRead >= info.size,
);
}
const data = buffer.subarray(0, bytesRead);
const nextOffset = safeOffset + bytesRead;
return {
@@ -215,15 +307,18 @@ export class FileService {
fileMode?: number,
): Promise<Record<string, unknown>> {
const resolvedPath = this.resolve(inputPath, cwd);
const data = decodeContent(content, encoding);
if (createParents) {
await mkdir(path.dirname(resolvedPath), { recursive: true });
}
const data = decodeContent(content, encoding);
if (mode === "append") {
await appendFile(resolvedPath, data, fileMode === undefined ? undefined : { mode: fileMode });
} else {
await writeFile(resolvedPath, data, fileMode === undefined ? undefined : { mode: fileMode });
}
if (fileMode !== undefined) {
await chmod(resolvedPath, fileMode);
}
const info = await stat(resolvedPath);
return {
path: resolvedPath,
@@ -242,7 +337,7 @@ export class FileService {
createParents: boolean,
): Promise<Record<string, unknown>> {
const resolvedPath = this.resolve(inputPath, cwd);
const data = Buffer.from(dataBase64, "base64");
const data = decodeBase64(dataBase64);
if (data.length > this.#options.maxChunkBytes) {
throw new Error(
`Upload chunk is ${data.length} bytes; maximum is ${this.#options.maxChunkBytes}`,
@@ -433,20 +528,22 @@ export class FileService {
if (!recursive) {
throw new Error("recursive=true is required to copy a directory");
}
await cp(source, destination, { recursive: true, force });
} else {
if (!force) {
try {
await lstat(destination);
throw new Error(`Destination already exists: ${destination}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
if (!force) {
try {
await lstat(destination);
throw new Error(`Destination already exists: ${destination}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}
if (sourceInfo.isDirectory()) {
await cp(source, destination, { recursive: true, force, errorOnExist: !force });
} else {
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(source, destination);
await copyFile(source, destination, force ? 0 : constants.COPYFILE_EXCL);
}
return { source, destination, copied: true };
}
+1 -1
View File
@@ -92,7 +92,7 @@ export function registerFileTools(
{
title: "Read file",
description:
"Read a bounded chunk of any host file as UTF-8 text or base64. Continue with nextOffset until eof=true.",
"Read a bounded chunk of any host file as UTF-8 text or base64. UTF-8 reads preserve character boundaries and may exceed maxBytes by up to three bytes only when one complete character would otherwise not fit. Continue with nextOffset until eof=true.",
inputSchema: {
path: pathSchema,
cwd: cwdSchema,
+93 -76
View File
@@ -3,7 +3,6 @@ import type { Server as HttpServer } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import express, { type Request, type Response } from "express";
import { createBearerAuth, createHostValidation } from "./auth.js";
@@ -12,10 +11,8 @@ import { errorMessage } from "./errors.js";
import { createMcpServer, type McpServices } from "./mcp-server.js";
import { OAUTH_SCOPES, RemoteDevOAuthProvider } from "./oauth.js";
interface ActiveSession {
transport: StreamableHTTPServerTransport;
interface ActiveRequest {
server: ReturnType<typeof createMcpServer>;
lastUsedAt: number;
}
export interface RunningHttpServer {
@@ -31,6 +28,26 @@ function rpcError(response: Response, status: number, message: string): void {
});
}
function rpcMethod(body: unknown): string | undefined {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return undefined;
}
const method = (body as { method?: unknown }).method;
return typeof method === "string" ? method : undefined;
}
function rpcToolName(body: unknown): string | undefined {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return undefined;
}
const params = (body as { params?: unknown }).params;
if (!params || typeof params !== "object" || Array.isArray(params)) {
return undefined;
}
const name = (params as { name?: unknown }).name;
return typeof name === "string" ? name : undefined;
}
export async function startHttpServer(
config: AppConfig,
services: McpServices,
@@ -38,10 +55,46 @@ export async function startHttpServer(
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", 1);
app.use((request, response, next) => {
if (request.path !== config.endpoint) {
next();
return;
}
const requestId = randomUUID();
const startedAt = performance.now();
let logged = false;
response.set("X-Request-Id", requestId);
const logCompletion = (outcome: "completed" | "aborted") => {
if (logged) {
return;
}
logged = true;
console.log(
JSON.stringify({
event: "mcp_request",
requestId,
httpMethod: request.method,
rpcMethod: rpcMethod(request.body),
toolName: rpcToolName(request.body),
status: response.statusCode,
outcome,
durationMs: Math.round((performance.now() - startedAt) * 10) / 10,
}),
);
};
response.once("finish", () => logCompletion("completed"));
response.once("close", () => {
if (!response.writableEnded) {
logCompletion("aborted");
}
});
next();
});
app.use(express.json({ limit: config.maxRequestBody }));
app.use(createHostValidation(config));
const sessions = new Map<string, ActiveSession>();
const activeRequests = new Set<ActiveRequest>();
let activeMcpRequests = 0;
const oauthProvider = config.oauthEnabled ? new RemoteDevOAuthProvider(config) : undefined;
if (oauthProvider) {
app.get("/.well-known/oauth-protected-resource", (_request, response) => {
@@ -71,7 +124,9 @@ export async function startHttpServer(
status: "ok",
service: "cokacremote",
version: "0.1.0",
activeMcpSessions: sessions.size,
transportMode: "stateless-json",
activeMcpSessions: 0,
activeMcpRequests,
managedProcesses: services.processManager.list().length,
unrestrictedHostAccess: true,
oauthEnabled: config.oauthEnabled,
@@ -79,40 +134,29 @@ export async function startHttpServer(
});
const postHandler = async (request: Request, response: Response): Promise<void> => {
const sessionId = request.header("mcp-session-id");
try {
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
rpcError(response, 404, "Unknown or expired MCP session");
return;
}
session.lastUsedAt = Date.now();
await session.transport.handleRequest(request, response, request.body);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
const server = createMcpServer(config, services);
const activeRequest = { server };
activeRequests.add(activeRequest);
activeMcpRequests += 1;
let closed = false;
const closeRequest = async (): Promise<void> => {
if (closed) {
return;
}
if (!isInitializeRequest(request.body)) {
rpcError(response, 400, "An initialize request or valid MCP session ID is required");
return;
}
let activeSession: ActiveSession;
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (initializedSessionId) => {
activeSession.lastUsedAt = Date.now();
sessions.set(initializedSessionId, activeSession);
},
closed = true;
activeRequests.delete(activeRequest);
activeMcpRequests = Math.max(0, activeMcpRequests - 1);
await server.close().catch((error) => {
console.error("Failed to close MCP request:", errorMessage(error));
});
const server = createMcpServer(config, services);
activeSession = { transport, server, lastUsedAt: Date.now() };
transport.onclose = () => {
const closedSessionId = transport.sessionId;
if (closedSessionId) {
sessions.delete(closedSessionId);
}
};
};
response.once("finish", () => void closeRequest());
response.once("close", () => void closeRequest());
try {
transport.onerror = (error) => {
console.error("MCP transport error:", errorMessage(error));
};
@@ -123,40 +167,20 @@ export async function startHttpServer(
if (!response.headersSent) {
rpcError(response, 500, "Internal MCP server error");
}
await closeRequest();
}
};
const sessionHandler = async (request: Request, response: Response): Promise<void> => {
const sessionId = request.header("mcp-session-id");
if (!sessionId) {
rpcError(response, 400, "MCP-Session-Id header is required");
return;
}
const session = sessions.get(sessionId);
if (!session) {
rpcError(response, 404, "Unknown or expired MCP session");
return;
}
session.lastUsedAt = Date.now();
try {
await session.transport.handleRequest(request, response);
} catch (error) {
console.error(`MCP ${request.method} failed:`, errorMessage(error));
if (!response.headersSent) {
rpcError(response, 500, "Internal MCP server error");
}
}
const methodNotAllowed = (_request: Request, response: Response): void => {
response.set("Allow", "POST");
rpcError(response, 405, "Stateless MCP accepts POST requests only");
};
app.post(config.endpoint, authenticate, (request, response) => {
void postHandler(request, response);
});
app.get(config.endpoint, authenticate, (request, response) => {
void sessionHandler(request, response);
});
app.delete(config.endpoint, authenticate, (request, response) => {
void sessionHandler(request, response);
});
app.get(config.endpoint, authenticate, methodNotAllowed);
app.delete(config.endpoint, authenticate, methodNotAllowed);
app.use(
(
@@ -172,17 +196,8 @@ export async function startHttpServer(
);
const cleanupInterval = setInterval(() => {
const cutoff = Date.now() - config.sessionTtlMs;
for (const [sessionId, session] of sessions) {
if (session.lastUsedAt < cutoff) {
sessions.delete(sessionId);
void session.server.close().catch((error) => {
console.error(`Failed to close expired session ${sessionId}:`, errorMessage(error));
});
}
}
services.processManager.prune();
}, Math.min(config.sessionTtlMs, 60_000));
}, Math.min(config.processRetentionMs, 60_000));
cleanupInterval.unref();
const httpServer = await new Promise<HttpServer>((resolve, reject) => {
@@ -192,9 +207,10 @@ export async function startHttpServer(
const close = async (): Promise<void> => {
clearInterval(cleanupInterval);
const activeSessions = [...sessions.values()];
sessions.clear();
await Promise.allSettled(activeSessions.map((session) => session.server.close()));
const requests = [...activeRequests];
activeRequests.clear();
activeMcpRequests = 0;
await Promise.allSettled(requests.map((request) => request.server.close()));
await services.processManager.shutdown();
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
@@ -205,6 +221,7 @@ export async function startHttpServer(
}
});
});
await new Promise<void>((resolve) => setImmediate(resolve));
};
return { httpServer, close };