Skip to content

Commit 7620e20

Browse files
committed
added mcp support
1 parent 7e50096 commit 7620e20

6 files changed

Lines changed: 250 additions & 2 deletions

File tree

src/app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { invokeRouter } from "./routes/invoke.js";
66
import { httpLoggerOptions } from "./utils/logger.js";
77
import { register, httpRequestDuration, httpRequestsTotal } from "./utils/metrics.js";
88
import { deployQueue } from "./deploy/queue.js";
9+
import { execRouter } from "./routes/exec.js";
10+
import { mcpRouter } from "./mcp/routes.js";
11+
import { startSessionReaper } from "./exec/session.js";
912

1013
export const app = express();
1114

@@ -37,6 +40,8 @@ app.use((req, res, next) => {
3740
app.use(express.json());
3841
app.use("/deploy", deployRouter);
3942
app.use("/f", invokeRouter);
43+
app.use("/exec", execRouter)
44+
app.use('/mcp', mcpRouter);
4045

4146
app.get("/metrics", async (_req, res) => {
4247
res.set("Content-Type", register.contentType);
@@ -57,3 +62,5 @@ app.get("/ready", (_req, res) => {
5762
.status(healthy ? 200 : 503)
5863
.json({ status: healthy ? "ready" : "not_ready", checks });
5964
});
65+
66+
startSessionReaper()

src/exec/gateway.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { readVsockResponse } from "../runtime/protocol.js";
33
import { getVmSocket } from "../runtime/transport.js";
44
import { getSession, createSession, touchSession } from "./session.js";
55
import { gatewayLogger } from "../utils/logger.js";
6+
import {
7+
execMessageTotal,
8+
execMessageDurationSeconds,
9+
execProcessExitCode,
10+
execWorkspaceBytesWritten
11+
} from "../utils/metrics.js";
612
import crypto from "crypto";
713
import type { Vm } from "../types/types.js";
814

@@ -61,6 +67,26 @@ export async function sendSessionMessage(
6167
"message sent to VM"
6268
);
6369

64-
const result = await readVsockResponse(socket, timeout, onStream);
65-
return { ...result, messageId: id };
70+
const startTime = process.hrtime.bigint();
71+
let status = "success";
72+
let result;
73+
74+
try {
75+
result = await readVsockResponse(socket, timeout, onStream);
76+
77+
if (message.type === "execute" && result.data?.exitCode !== undefined) {
78+
execProcessExitCode.inc({ command: message.command, exit_code: result.data.exitCode.toString() });
79+
} else if (message.type === "write_file" && result.data?.bytesWritten) {
80+
execWorkspaceBytesWritten.inc(result.data.bytesWritten);
81+
}
82+
83+
return { ...result, messageId: id };
84+
} catch (err) {
85+
status = "error";
86+
throw err;
87+
} finally {
88+
const duration = Number(process.hrtime.bigint() - startTime) / 1_000_000_000;
89+
execMessageDurationSeconds.observe({ type: message.type }, duration);
90+
execMessageTotal.inc({ type: message.type, status });
91+
}
6692
}

src/exec/session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { runtimeStore } from "../runtime/store.js";
22
import { cleanupVm } from "../runtime/cleanup.js";
33
import { sessionLogger } from "../utils/logger.js";
4+
import { execSessionsActive, execSessionDurationSeconds } from "../utils/metrics.js";
45

56
export interface Session {
67
sessionId: string;
@@ -23,6 +24,7 @@ export function createSession(sessionId: string): Session {
2324
state: "creating",
2425
};
2526
sessions.set(sessionId, session);
27+
execSessionsActive.inc();
2628
return session;
2729
}
2830

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

4850
sessions.delete(sessionId);
51+
execSessionsActive.dec();
52+
execSessionDurationSeconds.observe((Date.now() - session.createdAt) / 1000);
4953
sessionLogger.info({ sessionId }, "session destroyed");
5054
return true;
5155
}

src/mcp/routes.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Router } from "express";
2+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
3+
import { createMcpServer } from "./server.js";
4+
5+
export const mcpRouter = Router();
6+
7+
mcpRouter.use((req, res, next) => {
8+
const authToken = process.env.MCP_AUTH_TOKEN;
9+
10+
if (!authToken) {
11+
res.status(503).json({ error: "MCP_AUTH_TOKEN not configured" });
12+
return;
13+
}
14+
15+
const authHeader = req.headers.authorization;
16+
if (!authHeader || authHeader !== `Bearer ${authToken}`) {
17+
res.status(401).json({ error: "Unauthorized" });
18+
return;
19+
}
20+
21+
next();
22+
});
23+
24+
const transports = new Map<string, SSEServerTransport>();
25+
26+
mcpRouter.get("/", async (req, res) => {
27+
const mcpSessionId = req.id as string;
28+
29+
const transport = new SSEServerTransport(`/mcp/messages?mcpSessionId=${mcpSessionId}`, res);
30+
transports.set(mcpSessionId, transport);
31+
32+
const server = createMcpServer();
33+
await server.connect(transport);
34+
35+
req.on("close", () => {
36+
transports.delete(mcpSessionId);
37+
server.close().catch(console.error);
38+
});
39+
});
40+
41+
mcpRouter.post("/messages", async (req, res) => {
42+
const mcpSessionId = req.query.mcpSessionId as string;
43+
const transport = transports.get(mcpSessionId);
44+
45+
if (!transport) {
46+
res.status(404).json({ error: "Session not found or disconnected" });
47+
return;
48+
}
49+
50+
await transport.handlePostMessage(req, res);
51+
});

src/mcp/server.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { z } from "zod";
3+
import { sendSessionMessage } from "../exec/gateway.js";
4+
import { destroySession } from "../exec/session.js";
5+
6+
7+
export function createMcpServer(): McpServer {
8+
const server = new McpServer({
9+
name: "firecracker-sandbox",
10+
version: "1.0.0",
11+
});
12+
13+
server.tool(
14+
"execute",
15+
"Execute a command inside an isolated Firecracker microVM. " +
16+
"The workspace persists across calls within the same sessionId.",
17+
{
18+
sessionId: z.string().describe("Session identifier for workspace persistence"),
19+
command: z.string().describe("Command to run: node, python3, bash, etc."),
20+
args: z.array(z.string()).optional().describe("Command arguments"),
21+
cwd: z.string().optional().describe("Working directory relative to /workspace"),
22+
timeout: z.number().optional().describe("Timeout in milliseconds (default 30000)"),
23+
},
24+
async ({ sessionId, command, args, cwd, timeout }) => {
25+
const parts: string[] = [];
26+
27+
const result = await sendSessionMessage(
28+
sessionId,
29+
{ type: "execute", command, args, cwd, timeout },
30+
(chunk) => {
31+
parts.push(`[${chunk.stream}] ${chunk.data}`);
32+
},
33+
);
34+
35+
const exitCode = result.data?.exitCode ?? -1;
36+
parts.push(`\n--- exit code: ${exitCode} ---`);
37+
38+
return {
39+
content: [{ type: "text", text: parts.join("") }],
40+
isError: exitCode !== 0,
41+
};
42+
}
43+
);
44+
45+
server.tool(
46+
"write_file",
47+
"Write a file to the session workspace.",
48+
{
49+
sessionId: z.string(),
50+
path: z.string().describe("File path relative to /workspace"),
51+
content: z.string().describe("File content (will be base64-encoded automatically)"),
52+
},
53+
async ({ sessionId, path, content }) => {
54+
const encoded = Buffer.from(content).toString("base64");
55+
const result = await sendSessionMessage(sessionId, {
56+
type: "write_file", path, content: encoded,
57+
});
58+
return {
59+
content: [{ type: "text", text: `Wrote ${result.data?.bytesWritten} bytes to ${path}` }],
60+
};
61+
}
62+
);
63+
64+
server.tool(
65+
"read_file",
66+
"Read a file from the session workspace.",
67+
{
68+
sessionId: z.string(),
69+
path: z.string().describe("File path relative to /workspace"),
70+
},
71+
async ({ sessionId, path }) => {
72+
const result = await sendSessionMessage(sessionId, {
73+
type: "read_file", path,
74+
});
75+
const content = Buffer.from(result.data?.content || "", "base64").toString("utf-8");
76+
return {
77+
content: [{ type: "text", text: content }],
78+
};
79+
}
80+
);
81+
82+
server.tool(
83+
"list_files",
84+
"List files in the session workspace.",
85+
{
86+
sessionId: z.string(),
87+
path: z.string().optional().describe("Directory path relative to /workspace"),
88+
recursive: z.boolean().optional().describe("List recursively"),
89+
},
90+
async ({ sessionId, path, recursive }) => {
91+
const result = await sendSessionMessage(sessionId, {
92+
type: "list_files", path, recursive,
93+
});
94+
const listing = (result.data?.files || [])
95+
.map((f: any) => `${f.type === "dir" ? "📁" : "📄"} ${f.path} (${f.size}b)`)
96+
.join("\n");
97+
return {
98+
content: [{ type: "text", text: listing || "(empty)" }],
99+
};
100+
}
101+
);
102+
103+
server.tool(
104+
"reset_session",
105+
"Destroy a session and its VM. The workspace is lost.",
106+
{ sessionId: z.string() },
107+
async ({ sessionId }) => {
108+
const destroyed = await destroySession(sessionId);
109+
return {
110+
content: [{
111+
type: "text",
112+
text: destroyed ? "Session destroyed." : "No active session found.",
113+
}],
114+
};
115+
}
116+
);
117+
118+
return server;
119+
}

src/utils/metrics.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,44 @@ export const schedulerQueueWaitTime = new Histogram({
165165
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
166166
registers: [register],
167167
});
168+
169+
export const execSessionsActive = new Gauge({
170+
name: "exec_sessions_active",
171+
help: "Active sessions",
172+
registers: [register],
173+
});
174+
175+
export const execSessionDurationSeconds = new Histogram({
176+
name: "exec_session_duration_seconds",
177+
help: "Session lifetime",
178+
buckets: [1, 5, 30, 60, 300, 900, 1800, 3600],
179+
registers: [register],
180+
});
181+
182+
export const execMessageTotal = new Counter({
183+
name: "exec_message_total",
184+
help: "Messages by type + status",
185+
labelNames: ["type", "status"],
186+
registers: [register],
187+
});
188+
189+
export const execMessageDurationSeconds = new Histogram({
190+
name: "exec_message_duration_seconds",
191+
help: "Message round-trip time",
192+
labelNames: ["type"],
193+
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60],
194+
registers: [register],
195+
});
196+
197+
export const execProcessExitCode = new Counter({
198+
name: "exec_process_exit_code",
199+
help: "Exit codes by command",
200+
labelNames: ["command", "exit_code"],
201+
registers: [register],
202+
});
203+
204+
export const execWorkspaceBytesWritten = new Counter({
205+
name: "exec_workspace_bytes_written",
206+
help: "Bytes written to workspaces",
207+
registers: [register],
208+
});

0 commit comments

Comments
 (0)