ad
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user