import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import type { SessionManager } from "../runtime/SessionManager.js"; import { ToolError } from "../utils/errors.js"; import { SERVER_INSTRUCTIONS } from "./instructions.js"; import { newId } from "../utils/ids.js"; import type { ExecResult } from "../worker/protocol.js"; import { encodePathForUri, decodePathFromUri } from "../utils/paths.js"; export const SERVER_NAME = "just-bash-mcp"; export const SERVER_VERSION = "0.1.0"; const PathSchema = z.string().min(1); function toolErrorResult(err: unknown) { const message = err instanceof ToolError ? `[${err.code}] ${err.message}` : err instanceof Error ? err.message : String(err); return { isError: true, content: [{ type: "text" as const, text: message }], structuredContent: { error: { code: err instanceof ToolError ? err.code : "INTERNAL", message, }, }, }; } function jsonOk(structured: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(structured) }], structuredContent: structured as Record, }; } export function createServer(sm: SessionManager) { const server = new McpServer( { name: SERVER_NAME, version: SERVER_VERSION }, { instructions: SERVER_INSTRUCTIONS, capabilities: { tools: { listChanged: false }, resources: { listChanged: false }, }, }, ); const cfg = sm.config; server.registerTool( "session_create", { title: "Create a just-bash workspace session", description: "Creates an isolated workspace with an in-memory filesystem. Returns a session_id that must be passed to other tools.", inputSchema: { initial_files: z .record(z.string()) .optional() .describe("Map of absolute path -> utf8 content to seed the workspace"), cwd: z.string().optional(), ttl_seconds: z.number().int().positive().optional(), capabilities: z .object({ network: z.boolean().optional(), python: z.boolean().optional(), javascript: z.boolean().optional(), }) .optional(), }, }, async (args, extra) => { try { const tokenHash = (extra as any)?.authInfo?.tokenHash as string | undefined; const rec = await sm.createSession({ initialFiles: args.initial_files, cwd: args.cwd, ttlSeconds: args.ttl_seconds, capabilities: args.capabilities, ownerTokenHash: tokenHash, }); return jsonOk({ session_id: rec.id, cwd: rec.cwd, expires_at: new Date(rec.expiresAt).toISOString(), capabilities: rec.capabilities, limits: { max_session_fs_bytes: cfg.maxSessionFsBytes, max_single_file_bytes: cfg.maxSingleFileBytes, max_write_batch_bytes: cfg.maxWriteBatchBytes, max_read_bytes_per_call: cfg.maxReadBytesPerCall, default_timeout_ms: cfg.defaultTimeoutMs, max_timeout_ms: cfg.maxTimeoutMs, }, }); } catch (e) { return toolErrorResult(e); } }, ); server.registerTool( "session_reset", { title: "Reset a session", description: "Terminates the existing worker, starts a fresh one with the same session_id. mode=empty starts empty; mode=initial_files restores the files passed to session_create.", inputSchema: { session_id: z.string(), mode: z.enum(["empty", "initial_files"]).default("empty"), }, }, async (args) => { try { const rec = await sm.reset(args.session_id, args.mode); return jsonOk({ session_id: rec.id, reset: true, cwd: rec.cwd, expires_at: new Date(rec.expiresAt).toISOString(), }); } catch (e) { return toolErrorResult(e); } }, ); server.registerTool( "session_delete", { title: "Delete a session", description: "Terminates the worker and removes session metadata. Idempotent.", inputSchema: { session_id: z.string(), }, }, async (args) => { try { const ok = await sm.delete(args.session_id, "user_delete"); return jsonOk({ deleted: ok }); } catch (e) { return toolErrorResult(e); } }, ); server.registerTool( "bash_exec", { title: "Run a command in a session", description: "Runs a script inside an isolated worker. NOTE: files persist across calls, but cwd/env/aliases/functions do NOT persist between bash_exec calls unless passed in again.", inputSchema: { session_id: z.string(), script: z.string(), stdin: z.string().optional(), cwd: z.string().optional(), env: z.record(z.string()).optional(), replace_env: z.boolean().optional(), timeout_ms: z.number().int().positive().optional(), max_stdout_bytes: z.number().int().nonnegative().optional(), max_stderr_bytes: z.number().int().nonnegative().optional(), }, }, async (args) => { try { const rec = sm.require(args.session_id); const timeoutMs = Math.min( Math.max(args.timeout_ms ?? cfg.defaultTimeoutMs, 1), cfg.maxTimeoutMs, ); const hardTimeoutMs = timeoutMs + cfg.watchdogGraceMs; const maxStdoutBytes = Math.min( args.max_stdout_bytes ?? cfg.maxEmbeddedStdoutBytes, cfg.maxEmbeddedStdoutBytes, ); const maxStderrBytes = Math.min( args.max_stderr_bytes ?? cfg.maxEmbeddedStderrBytes, cfg.maxEmbeddedStderrBytes, ); const execId = newId("ex"); const outcome = await sm.runOnSessionQueue(args.session_id, () => rec.worker.exec( { execId, script: args.script, stdin: args.stdin, cwd: args.cwd, env: args.env, replaceEnv: args.replace_env, timeoutMs, maxStdoutBytes, maxStderrBytes, }, hardTimeoutMs, ), ); sm.touch(args.session_id); if (outcome.hardKilled) { sm.invalidate(args.session_id, "hard_timeout"); const body = { exec_id: execId, exit_code: 124, stdout: "", stderr: "execution exceeded hard timeout; session worker was terminated", duration_ms: hardTimeoutMs, timed_out: true, hard_killed: true, session_valid: false, truncated: { stdout: false, stderr: false }, resource_links: [], metadata: { commands: [] }, }; return jsonOk(body); } const res = outcome.result as ExecResult; const resourceLinks = collectResourceLinks(sm, rec.id, execId, res, cfg.maxResourceBytesSession, rec.idleTtlMs); const body = { exec_id: execId, exit_code: res.exitCode, stdout: res.stdout, stderr: res.stderr, duration_ms: res.durationMs, timed_out: res.timedOut, hard_killed: false, session_valid: true, truncated: { stdout: res.truncatedStdout, stderr: res.truncatedStderr }, resource_links: resourceLinks, metadata: { commands: res.commands }, session_fs_bytes: res.sessionFsBytes, }; return { content: [ { type: "text" as const, text: JSON.stringify(body) }, ...resourceLinks.map((rl) => ({ type: "resource_link" as const, uri: rl.uri, name: rl.name, mimeType: rl.mime_type, })), ], structuredContent: body, }; } catch (e) { return toolErrorResult(e); } }, ); server.registerTool( "files_write", { title: "Write files into the session", description: "Batch write files into the session virtual filesystem.", inputSchema: { session_id: z.string(), files: z .array( z.object({ path: PathSchema, content: z.string(), encoding: z.enum(["utf8", "base64"]).default("utf8"), }), ) .min(1), overwrite: z.boolean().default(true), create_dirs: z.boolean().default(true), }, }, async (args) => { try { const rec = sm.require(args.session_id); const result = await rec.worker.filesWrite({ files: args.files.map((f) => ({ path: f.path, content: f.content, encoding: f.encoding, })), overwrite: args.overwrite, createDirs: args.create_dirs, }); sm.touch(args.session_id); return jsonOk({ written: result.written, session_fs_bytes: result.sessionFsBytes, }); } catch (e) { if (e instanceof Error && (e as any).code) { return toolErrorResult(new ToolError((e as any).code as any, e.message)); } return toolErrorResult(e); } }, ); server.registerTool( "files_read", { title: "Read a file from the session", description: "Reads file content with offset-based pagination. Returns base64 for binary.", inputSchema: { session_id: z.string(), path: PathSchema, offset: z.number().int().nonnegative().default(0), max_bytes: z.number().int().positive().optional(), encoding: z.enum(["utf8", "base64", "auto"]).default("auto"), }, }, async (args) => { try { const rec = sm.require(args.session_id); const maxBytes = Math.min( args.max_bytes ?? cfg.maxReadBytesPerCall, cfg.maxReadBytesPerCall, ); const result = await rec.worker.filesRead({ path: args.path, offset: args.offset, maxBytes, encoding: args.encoding, }); sm.touch(args.session_id); return jsonOk({ path: result.path, content: result.content, encoding: result.encoding, bytes_read: result.bytesRead, next_offset: result.nextOffset, }); } catch (e) { if (e instanceof Error && (e as any).code) { return toolErrorResult(new ToolError((e as any).code as any, e.message)); } return toolErrorResult(e); } }, ); server.registerTool( "files_list", { title: "List files/directories in the session", description: "Lists files and directories with optional glob and recursive traversal.", inputSchema: { session_id: z.string(), path: PathSchema, recursive: z.boolean().default(false), glob: z.string().optional(), max_entries: z.number().int().positive().default(200), cursor: z.string().nullable().optional(), }, }, async (args) => { try { const rec = sm.require(args.session_id); const result = await rec.worker.filesList({ path: args.path, recursive: args.recursive, glob: args.glob, maxEntries: args.max_entries, cursor: args.cursor ?? undefined, }); sm.touch(args.session_id); return jsonOk({ entries: result.entries, next_cursor: result.nextCursor, }); } catch (e) { if (e instanceof Error && (e as any).code) { return toolErrorResult(new ToolError((e as any).code as any, e.message)); } return toolErrorResult(e); } }, ); // Dynamic resource reads for justbash:// URIs. server.server.setRequestHandler( ReadResourceRequestSchema, async (req) => { const uri: string = req.params.uri; const parsed = parseResourceUri(uri); if (!parsed) { throw new Error(`Unknown resource URI: ${uri}`); } if (parsed.kind === "exec_stream") { const buf = sm.resourceStore.get(parsed.sessionId, parsed.execId, parsed.stream); if (!buf) { throw new Error(`Resource expired or evicted: ${uri}`); } return { contents: [ { uri, mimeType: "text/plain", text: buf.bytes.toString("utf8"), }, ], }; } if (parsed.kind === "file") { const rec = sm.require(parsed.sessionId); const result = await rec.worker.filesRead({ path: parsed.path, offset: 0, maxBytes: cfg.maxReadBytesPerCall, encoding: "auto", }); return { contents: [ { uri, mimeType: result.encoding === "base64" ? "application/octet-stream" : "text/plain", text: result.content, }, ], }; } throw new Error(`Unknown resource URI: ${uri}`); }, ); return server; } interface ResourceLink { uri: string; name: string; mime_type: string; bytes_available: number; complete: boolean; expires_at: string; } function collectResourceLinks( sm: SessionManager, sessionId: string, execId: string, res: ExecResult, ttlMs: number, sessionTtlMs: number, ): ResourceLink[] { void ttlMs; const links: ResourceLink[] = []; if (res.retainedStdout) { const ok = sm.resourceStore.store( sessionId, execId, "stdout", res.retainedStdout, res.capturedStdoutBytes <= Buffer.byteLength(res.retainedStdout, "utf8"), sessionTtlMs, ); if (ok) { const buf = sm.resourceStore.get(sessionId, execId, "stdout"); if (buf) { links.push({ uri: `justbash://session/${sessionId}/exec/${execId}/stdout`, name: "stdout", mime_type: "text/plain", bytes_available: buf.bytes.length, complete: buf.complete, expires_at: new Date(buf.expiresAt).toISOString(), }); } } } if (res.retainedStderr) { const ok = sm.resourceStore.store( sessionId, execId, "stderr", res.retainedStderr, res.capturedStderrBytes <= Buffer.byteLength(res.retainedStderr, "utf8"), sessionTtlMs, ); if (ok) { const buf = sm.resourceStore.get(sessionId, execId, "stderr"); if (buf) { links.push({ uri: `justbash://session/${sessionId}/exec/${execId}/stderr`, name: "stderr", mime_type: "text/plain", bytes_available: buf.bytes.length, complete: buf.complete, expires_at: new Date(buf.expiresAt).toISOString(), }); } } } return links; } type ParsedResourceUri = | { kind: "exec_stream"; sessionId: string; execId: string; stream: "stdout" | "stderr" } | { kind: "file"; sessionId: string; path: string }; function parseResourceUri(uri: string): ParsedResourceUri | null { const m1 = /^justbash:\/\/session\/([^/]+)\/exec\/([^/]+)\/(stdout|stderr)$/.exec(uri); if (m1) { return { kind: "exec_stream", sessionId: m1[1], execId: m1[2], stream: m1[3] as "stdout" | "stderr", }; } const m2 = /^justbash:\/\/session\/([^/]+)\/file\/(.+)$/.exec(uri); if (m2) { return { kind: "file", sessionId: m2[1], path: decodePathFromUri(m2[2]) }; } return null; } void encodePathForUri;