Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { invokeRouter } from "./routes/invoke.js";
import { httpLoggerOptions } from "./utils/logger.js";
import { register, httpRequestDuration, httpRequestsTotal } from "./utils/metrics.js";
import { deployQueue } from "./deploy/queue.js";
import { execRouter } from "./routes/exec.js";
import { mcpRouter } from "./mcp/routes.js";
import { startSessionReaper } from "./exec/session.js";

export const app = express();

Expand Down Expand Up @@ -37,6 +40,8 @@ app.use((req, res, next) => {
app.use(express.json());
app.use("/deploy", deployRouter);
app.use("/f", invokeRouter);
app.use("/exec", execRouter)
app.use('/mcp', mcpRouter);

app.get("/metrics", async (_req, res) => {
res.set("Content-Type", register.contentType);
Expand All @@ -57,3 +62,5 @@ app.get("/ready", (_req, res) => {
.status(healthy ? 200 : 503)
.json({ status: healthy ? "ready" : "not_ready", checks });
});

startSessionReaper()
30 changes: 28 additions & 2 deletions src/exec/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { readVsockResponse } from "../runtime/protocol.js";
import { getVmSocket } from "../runtime/transport.js";
import { getSession, createSession, touchSession } from "./session.js";
import { gatewayLogger } from "../utils/logger.js";
import {
execMessageTotal,
execMessageDurationSeconds,
execProcessExitCode,
execWorkspaceBytesWritten
} from "../utils/metrics.js";
import crypto from "crypto";
import type { Vm } from "../types/types.js";

Expand Down Expand Up @@ -61,6 +67,26 @@ export async function sendSessionMessage(
"message sent to VM"
);

const result = await readVsockResponse(socket, timeout, onStream);
return { ...result, messageId: id };
const startTime = process.hrtime.bigint();
let status = "success";
let result;

try {
result = await readVsockResponse(socket, timeout, onStream);

if (message.type === "execute" && result.data?.exitCode !== undefined) {
execProcessExitCode.inc({ command: message.command, exit_code: result.data.exitCode.toString() });
} else if (message.type === "write_file" && result.data?.bytesWritten) {
execWorkspaceBytesWritten.inc(result.data.bytesWritten);
}

return { ...result, messageId: id };
} catch (err) {
status = "error";
throw err;
} finally {
const duration = Number(process.hrtime.bigint() - startTime) / 1_000_000_000;
execMessageDurationSeconds.observe({ type: message.type }, duration);
execMessageTotal.inc({ type: message.type, status });
}
}
4 changes: 4 additions & 0 deletions src/exec/session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { runtimeStore } from "../runtime/store.js";
import { cleanupVm } from "../runtime/cleanup.js";
import { sessionLogger } from "../utils/logger.js";
import { execSessionsActive, execSessionDurationSeconds } from "../utils/metrics.js";

export interface Session {
sessionId: string;
Expand All @@ -23,6 +24,7 @@ export function createSession(sessionId: string): Session {
state: "creating",
};
sessions.set(sessionId, session);
execSessionsActive.inc();
return session;
}

Expand All @@ -46,6 +48,8 @@ export async function destroySession(sessionId: string): Promise<boolean> {
}

sessions.delete(sessionId);
execSessionsActive.dec();
execSessionDurationSeconds.observe((Date.now() - session.createdAt) / 1000);
sessionLogger.info({ sessionId }, "session destroyed");
return true;
}
Expand Down
51 changes: 51 additions & 0 deletions src/mcp/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Router } from "express";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";

Check failure on line 2 in src/mcp/routes.ts

View workflow job for this annotation

GitHub Actions / test

src/routes/invoke.test.ts

Error: Cannot find package '@modelcontextprotocol/sdk/server/sse.js' imported from /home/runner/work/serverless-runtime/serverless-runtime/src/mcp/routes.ts ❯ src/mcp/routes.ts:2:1 ❯ src/app.ts:10:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }

Check failure on line 2 in src/mcp/routes.ts

View workflow job for this annotation

GitHub Actions / test

src/routes/deploy.test.ts

Error: Cannot find package '@modelcontextprotocol/sdk/server/sse.js' imported from /home/runner/work/serverless-runtime/serverless-runtime/src/mcp/routes.ts ❯ src/mcp/routes.ts:2:1 ❯ src/app.ts:10:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }

Check failure on line 2 in src/mcp/routes.ts

View workflow job for this annotation

GitHub Actions / test

src/routes/invoke.test.ts

Error: Cannot find package '@modelcontextprotocol/sdk/server/sse.js' imported from /home/runner/work/serverless-runtime/serverless-runtime/src/mcp/routes.ts ❯ src/mcp/routes.ts:2:1 ❯ src/app.ts:10:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }

Check failure on line 2 in src/mcp/routes.ts

View workflow job for this annotation

GitHub Actions / test

src/routes/deploy.test.ts

Error: Cannot find package '@modelcontextprotocol/sdk/server/sse.js' imported from /home/runner/work/serverless-runtime/serverless-runtime/src/mcp/routes.ts ❯ src/mcp/routes.ts:2:1 ❯ src/app.ts:10:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }
import { createMcpServer } from "./server.js";

export const mcpRouter = Router();

mcpRouter.use((req, res, next) => {
const authToken = process.env.MCP_AUTH_TOKEN;

if (!authToken) {
res.status(503).json({ error: "MCP_AUTH_TOKEN not configured" });
return;
}

const authHeader = req.headers.authorization;
if (!authHeader || authHeader !== `Bearer ${authToken}`) {
res.status(401).json({ error: "Unauthorized" });
return;
}

next();
});

const transports = new Map<string, SSEServerTransport>();

mcpRouter.get("/", async (req, res) => {
const mcpSessionId = req.id as string;

const transport = new SSEServerTransport(`/mcp/messages?mcpSessionId=${mcpSessionId}`, res);
transports.set(mcpSessionId, transport);

const server = createMcpServer();
await server.connect(transport);

req.on("close", () => {
transports.delete(mcpSessionId);
server.close().catch(console.error);
});
});

mcpRouter.post("/messages", async (req, res) => {
const mcpSessionId = req.query.mcpSessionId as string;
const transport = transports.get(mcpSessionId);

if (!transport) {
res.status(404).json({ error: "Session not found or disconnected" });
return;
}

await transport.handlePostMessage(req, res);
});
119 changes: 119 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { sendSessionMessage } from "../exec/gateway.js";
import { destroySession } from "../exec/session.js";


export function createMcpServer(): McpServer {
const server = new McpServer({
name: "firecracker-sandbox",
version: "1.0.0",
});

server.tool(
"execute",
"Execute a command inside an isolated Firecracker microVM. " +
"The workspace persists across calls within the same sessionId.",
{
sessionId: z.string().describe("Session identifier for workspace persistence"),
command: z.string().describe("Command to run: node, python3, bash, etc."),
args: z.array(z.string()).optional().describe("Command arguments"),
cwd: z.string().optional().describe("Working directory relative to /workspace"),
timeout: z.number().optional().describe("Timeout in milliseconds (default 30000)"),
},
async ({ sessionId, command, args, cwd, timeout }) => {
const parts: string[] = [];

const result = await sendSessionMessage(
sessionId,
{ type: "execute", command, args, cwd, timeout },
(chunk) => {
parts.push(`[${chunk.stream}] ${chunk.data}`);
},
);

const exitCode = result.data?.exitCode ?? -1;
parts.push(`\n--- exit code: ${exitCode} ---`);

return {
content: [{ type: "text", text: parts.join("") }],
isError: exitCode !== 0,
};
}
);

server.tool(
"write_file",
"Write a file to the session workspace.",
{
sessionId: z.string(),
path: z.string().describe("File path relative to /workspace"),
content: z.string().describe("File content (will be base64-encoded automatically)"),
},
async ({ sessionId, path, content }) => {
const encoded = Buffer.from(content).toString("base64");
const result = await sendSessionMessage(sessionId, {
type: "write_file", path, content: encoded,
});
return {
content: [{ type: "text", text: `Wrote ${result.data?.bytesWritten} bytes to ${path}` }],
};
}
);

server.tool(
"read_file",
"Read a file from the session workspace.",
{
sessionId: z.string(),
path: z.string().describe("File path relative to /workspace"),
},
async ({ sessionId, path }) => {
const result = await sendSessionMessage(sessionId, {
type: "read_file", path,
});
const content = Buffer.from(result.data?.content || "", "base64").toString("utf-8");
return {
content: [{ type: "text", text: content }],
};
}
);

server.tool(
"list_files",
"List files in the session workspace.",
{
sessionId: z.string(),
path: z.string().optional().describe("Directory path relative to /workspace"),
recursive: z.boolean().optional().describe("List recursively"),
},
async ({ sessionId, path, recursive }) => {
const result = await sendSessionMessage(sessionId, {
type: "list_files", path, recursive,
});
const listing = (result.data?.files || [])
.map((f: any) => `${f.type === "dir" ? "📁" : "📄"} ${f.path} (${f.size}b)`)
.join("\n");
return {
content: [{ type: "text", text: listing || "(empty)" }],
};
}
);

server.tool(
"reset_session",
"Destroy a session and its VM. The workspace is lost.",
{ sessionId: z.string() },
async ({ sessionId }) => {
const destroyed = await destroySession(sessionId);
return {
content: [{
type: "text",
text: destroyed ? "Session destroyed." : "No active session found.",
}],
};
}
);

return server;
}
90 changes: 90 additions & 0 deletions src/routes/exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Router } from "express";
import { sendSessionMessage, ensureSession } from "../exec/gateway.js";
import { destroySession, getSession, getAllSessions } from "../exec/session.js";
import { execLogger } from "../utils/logger.js";

export const execRouter = Router();

execRouter.post("/:sessionId/execute", async (req, res) => {
const { sessionId } = req.params;
const { command, args, cwd, env, timeout } = req.body;

if (!command) return res.status(400).json({ error: "command is required" });

const output: { stream: string; data: string; ts: number }[] = [];

try {
const result = await sendSessionMessage(
sessionId,
{ type: "execute", command, args, cwd, env, timeout },
(chunk) => {
output.push({ stream: chunk.stream, data: chunk.data, ts: Date.now() });
},
);

res.json({
exitCode: result.data?.exitCode,
signal: result.data?.signal,
duration: result.data?.duration,
output,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});

execRouter.post("/:sessionId/write", async (req, res) => {
const { sessionId } = req.params;
const { path, content, mode } = req.body;

if (!path || !content) return res.status(400).json({ error: "path and content required" });

try {
const result = await sendSessionMessage(sessionId, {
type: "write_file", path, content, mode,
});
res.json(result.data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});

execRouter.get("/:sessionId/read", async (req, res) => {
const { sessionId } = req.params;
const { path } = req.query;

if (!path) return res.status(400).json({ error: "path query param required" });

try {
const result = await sendSessionMessage(sessionId, {
type: "read_file", path,
});
res.json(result.data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});

execRouter.get("/:sessionId/files", async (req, res) => {
const { sessionId } = req.params;
const { path, recursive } = req.query;

try {
const result = await sendSessionMessage(sessionId, {
type: "list_files", path, recursive: recursive === "true",
});
res.json(result.data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});

execRouter.delete("/:sessionId", async (req, res) => {
const { sessionId } = req.params;
const destroyed = await destroySession(sessionId);
res.json({ destroyed });
});

execRouter.get("/", (_req, res) => {
res.json({ sessions: getAllSessions() });
});
3 changes: 2 additions & 1 deletion src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export const transportLogger = logger.child({ module: "transport" });
export const protocolLogger = logger.child({ module: "protocol" });
export const cleanupLogger = logger.child({ module: "cleanup" });
export const sessionLogger = logger.child({ module: "session" })
export const gatewayLogger = logger.child({module: "gateway"})
export const gatewayLogger = logger.child({ module: "gateway" })
export const execLogger= logger.child({module: "exec" })

export const httpLoggerOptions: Options = {
logger: logger.child({ module: "http" }),
Expand Down
Loading
Loading