From c0b205208d013af1277dd14eb337d87a8599c0de Mon Sep 17 00:00:00 2001 From: nirholas Date: Tue, 31 Mar 2026 10:45:43 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20Dockerfile=20and=20Vercel=20i?= =?UTF-8?q?ntegration=20for=20MCP=20server=20=E2=9C=A8=20Implement=20x402?= =?UTF-8?q?=20payment=20handling=20in=20WebFetchTool=20=F0=9F=93=9D=20Refa?= =?UTF-8?q?ctor=20count=20increment=20logic=20in=20X402=20payment=20tracke?= =?UTF-8?q?r=20=E2=9C=A8=20Introduce=20feature=20flag=20management=20in=20?= =?UTF-8?q?Bun=20build=20=E2=9C=A8=20Create=20macro=20for=20package=20vers?= =?UTF-8?q?ioning=20and=20issue=20reporting=20=E2=9C=A8=20Add=20preload=20?= =?UTF-8?q?script=20for=20Bun=20bundler=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mcp-server/.gitignore | 7 + mcp-server/Dockerfile | 29 ++ mcp-server/api/index.ts | 21 ++ mcp-server/api/vercelApp.ts | 96 ++++++ mcp-server/railway.json | 13 + mcp-server/src/http.ts | 172 ++++++++++ mcp-server/src/index.ts | 554 -------------------------------- src/services/x402/tracker.ts | 2 +- src/shims/bun-bundle.ts | 28 ++ src/shims/macro.ts | 26 ++ src/shims/preload.ts | 6 + src/tools/WebFetchTool/utils.ts | 37 +++ src/types/macro.d.ts | 5 + vercel.json | 20 ++ 14 files changed, 461 insertions(+), 555 deletions(-) create mode 100644 mcp-server/.gitignore create mode 100644 mcp-server/Dockerfile create mode 100644 mcp-server/api/index.ts create mode 100644 mcp-server/api/vercelApp.ts create mode 100644 mcp-server/railway.json create mode 100644 mcp-server/src/http.ts create mode 100644 src/shims/bun-bundle.ts create mode 100644 src/shims/macro.ts create mode 100644 src/shims/preload.ts create mode 100644 src/types/macro.d.ts create mode 100644 vercel.json diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 0000000..0caaefa --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.js +*.d.ts +*.js.map +*.d.ts.map +!api/ diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile new file mode 100644 index 0000000..72ec753 --- /dev/null +++ b/mcp-server/Dockerfile @@ -0,0 +1,29 @@ +FROM node:22-slim AS build +WORKDIR /app + +# Copy full repo (src/ is needed at runtime for the MCP explorer) +COPY . . + +# Build MCP server +WORKDIR /app/mcp-server +RUN npm ci && npm run build + +# --------------------------------------------------------------------------- +FROM node:22-slim +WORKDIR /app + +# Copy built MCP server and source code it explores +COPY --from=build /app/mcp-server/dist /app/mcp-server/dist +COPY --from=build /app/mcp-server/node_modules /app/mcp-server/node_modules +COPY --from=build /app/mcp-server/package.json /app/mcp-server/package.json +COPY --from=build /app/src /app/src +COPY --from=build /app/README.md /app/README.md + +ENV NODE_ENV=production +ENV CLAUDE_CODE_SRC_ROOT=/app/src +ENV PORT=3000 + +EXPOSE 3000 + +WORKDIR /app/mcp-server +CMD ["node", "dist/http.js"] diff --git a/mcp-server/api/index.ts b/mcp-server/api/index.ts new file mode 100644 index 0000000..6903bcd --- /dev/null +++ b/mcp-server/api/index.ts @@ -0,0 +1,21 @@ +/** + * Vercel serverless function — proxies requests to the Express HTTP server. + * + * This file re-exports the Express app as a Vercel serverless handler. + * Vercel automatically routes /api/* to this function. + * + * Deploy: + * cd mcp-server && npx vercel + * + * Environment variables (set in Vercel dashboard): + * CLAUDE_CODE_SRC_ROOT — absolute path where src/ is deployed + * MCP_API_KEY — optional bearer token for auth + * + * NOTE: Vercel serverless functions are stateless, so the Streamable HTTP + * transport (which requires sessions) won't persist across invocations. + * For production use with session-based MCP clients, prefer Railway/Render/VPS. + * The legacy SSE transport and stateless tool calls work fine on Vercel. + */ + +export { app as default } from "./vercelApp.js"; + diff --git a/mcp-server/api/vercelApp.ts b/mcp-server/api/vercelApp.ts new file mode 100644 index 0000000..0a46234 --- /dev/null +++ b/mcp-server/api/vercelApp.ts @@ -0,0 +1,96 @@ +/** + * Express app exported for Vercel serverless. + * Shares the same logic as http.ts but is importable as a module. + */ + +import express from "express"; +import { randomUUID } from "node:crypto"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { createServer, SRC_ROOT } from "../src/server.js"; + +const API_KEY = process.env.MCP_API_KEY; + +export const app = express(); +app.use(express.json()); + +// Auth +app.use((req, res, next) => { + if (!API_KEY || req.path === "/health" || req.path === "/api") return next(); + const auth = req.headers.authorization; + if (!auth || auth !== `Bearer ${API_KEY}`) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); +}); + +// Health +app.get("/health", (_req, res) => { + res.json({ status: "ok", server: "claude-code-explorer", version: "1.1.0", srcRoot: SRC_ROOT }); +}); +app.get("/api", (_req, res) => { + res.json({ status: "ok", server: "claude-code-explorer", version: "1.1.0", srcRoot: SRC_ROOT }); +}); + +// Streamable HTTP +const transports = new Map(); + +app.post("/mcp", async (req, res) => { + const sessionId = (req.headers["mcp-session-id"] as string) ?? undefined; + let transport = sessionId ? transports.get(sessionId) : undefined; + + if (!transport) { + const server = createServer(); + transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); + await server.connect(transport); + transport.onclose = () => { + if (transport!.sessionId) transports.delete(transport!.sessionId); + }; + } + + await transport.handleRequest(req, res, req.body); + + if (transport.sessionId && !transports.has(transport.sessionId)) { + transports.set(transport.sessionId, transport); + } +}); + +app.get("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports.has(sessionId)) { + res.status(400).json({ error: "Invalid or missing session ID" }); + return; + } + await transports.get(sessionId)!.handleRequest(req, res); +}); + +app.delete("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (sessionId && transports.has(sessionId)) { + await transports.get(sessionId)!.close(); + transports.delete(sessionId); + } + res.status(200).json({ ok: true }); +}); + +// Legacy SSE +const sseTransports = new Map(); + +app.get("/sse", async (_req, res) => { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseTransports.set(transport.sessionId, transport); + transport.onclose = () => sseTransports.delete(transport.sessionId); + await server.connect(transport); +}); + +app.post("/messages", async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = sseTransports.get(sessionId); + if (!transport) { + res.status(400).json({ error: "Unknown session" }); + return; + } + await transport.handlePostMessage(req, res, req.body); +}); diff --git a/mcp-server/railway.json b/mcp-server/railway.json new file mode 100644 index 0000000..b153f64 --- /dev/null +++ b/mcp-server/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "mcp-server/Dockerfile" + }, + "deploy": { + "startCommand": "node dist/http.js", + "healthcheckPath": "/health", + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} diff --git a/mcp-server/src/http.ts b/mcp-server/src/http.ts new file mode 100644 index 0000000..e5c67d9 --- /dev/null +++ b/mcp-server/src/http.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * HTTP + SSE entrypoint — for remote hosting (Railway, Render, VPS, etc.) + * + * Supports: + * - Streamable HTTP transport (POST /mcp for JSON-RPC, GET /mcp for SSE) + * - Health check at GET /health + * + * Environment: + * PORT — HTTP port (default: 3000) + * CLAUDE_CODE_SRC_ROOT — Path to Claude Code src/ directory + * MCP_API_KEY — Optional bearer token for authentication + */ + +import express from "express"; +import { randomUUID } from "node:crypto"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { createServer, validateSrcRoot, SRC_ROOT } from "./server.js"; + +const PORT = parseInt(process.env.PORT ?? "3000", 10); +const API_KEY = process.env.MCP_API_KEY; + +// --------------------------------------------------------------------------- +// Auth middleware (optional — only active when MCP_API_KEY is set) +// --------------------------------------------------------------------------- + +function authMiddleware( + req: express.Request, + res: express.Response, + next: express.NextFunction +): void { + if (!API_KEY) return next(); + // Skip auth for health check + if (req.path === "/health") return next(); + + const auth = req.headers.authorization; + if (!auth || auth !== `Bearer ${API_KEY}`) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); +} + +// --------------------------------------------------------------------------- +// Streamable HTTP transport (modern MCP protocol) +// --------------------------------------------------------------------------- + +async function startStreamableHTTP(app: express.Express): Promise { + // Map of session ID -> transport + const transports = new Map(); + + app.post("/mcp", async (req, res) => { + const sessionId = + (req.headers["mcp-session-id"] as string) ?? undefined; + let transport = sessionId ? transports.get(sessionId) : undefined; + + if (!transport) { + // New session + const server = createServer(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + await server.connect(transport); + + // Store session after first request so we can retrieve it later + transport.onclose = () => { + if (transport!.sessionId) { + transports.delete(transport!.sessionId); + } + }; + } + + await transport.handleRequest(req, res, req.body); + + // After handling, store with the now-known session ID + if (transport.sessionId && !transports.has(transport.sessionId)) { + transports.set(transport.sessionId, transport); + } + }); + + // SSE stream endpoint for Streamable HTTP + app.get("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports.has(sessionId)) { + res.status(400).json({ error: "Invalid or missing session ID" }); + return; + } + const transport = transports.get(sessionId)!; + await transport.handleRequest(req, res); + }); + + // DELETE — session cleanup + app.delete("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (sessionId && transports.has(sessionId)) { + const transport = transports.get(sessionId)!; + await transport.close(); + transports.delete(sessionId); + } + res.status(200).json({ ok: true }); + }); +} + +// --------------------------------------------------------------------------- +// Legacy SSE transport (for older clients) +// --------------------------------------------------------------------------- + +async function startLegacySSE(app: express.Express): Promise { + const transports = new Map(); + + app.get("/sse", async (_req, res) => { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + transports.set(transport.sessionId, transport); + transport.onclose = () => { + transports.delete(transport.sessionId); + }; + await server.connect(transport); + }); + + app.post("/messages", async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.get(sessionId); + if (!transport) { + res.status(400).json({ error: "Unknown session" }); + return; + } + await transport.handlePostMessage(req, res, req.body); + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + await validateSrcRoot(); + + const app = express(); + app.use(express.json()); + app.use(authMiddleware); + + // Health check + app.get("/health", (_req, res) => { + res.json({ + status: "ok", + server: "claude-code-explorer", + version: "1.1.0", + srcRoot: SRC_ROOT, + }); + }); + + // Register both transports + await startStreamableHTTP(app); + await startLegacySSE(app); + + app.listen(PORT, () => { + console.log(`Claude Code Explorer MCP (HTTP) listening on port ${PORT}`); + console.log(` Streamable HTTP: POST/GET http://localhost:${PORT}/mcp`); + console.log(` Legacy SSE: GET http://localhost:${PORT}/sse`); + console.log(` Health: GET http://localhost:${PORT}/health`); + console.log(` Source root: ${SRC_ROOT}`); + if (API_KEY) console.log(" Auth: Bearer token required"); + }); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 50c2628..b49f61c 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -22,557 +22,3 @@ main().catch((err) => { console.error("Fatal error:", err); process.exit(1); }); - - - // Try directory first, then .ts / .tsx - const candidates = [ - `commands/${commandName}`, - `commands/${commandName}.ts`, - `commands/${commandName}.tsx`, - ]; - let found: string | null = null; - let isDir = false; - for (const c of candidates) { - const abs = safePath(c); - if (abs && (await dirExists(abs))) { - found = abs; - isDir = true; - break; - } - if (abs && (await fileExists(abs))) { - found = abs; - break; - } - } - if (!found) throw new Error(`Command not found: ${commandName}`); - - if (!isDir) { - const content = await fs.readFile(found, "utf-8"); - return { - content: [{ type: "text" as const, text: content }], - }; - } - - const reqFile = (args as Record)?.fileName as - | string - | undefined; - if (reqFile) { - const filePath = safePath(`commands/${commandName}/${reqFile}`); - if (!filePath || !(await fileExists(filePath))) - throw new Error( - `File not found: commands/${commandName}/${reqFile}` - ); - const content = await fs.readFile(filePath, "utf-8"); - return { content: [{ type: "text" as const, text: content }] }; - } - - // Return directory listing when no specific file requested - const files = await listDir(found); - return { - content: [ - { - type: "text" as const, - text: `Command: ${commandName}\nFiles:\n${files.map((f) => ` ${f}`).join("\n")}`, - }, - ], - }; - } - - // ---- read_source_file ---- - case "read_source_file": { - const relPath = (args as Record)?.path as string; - if (!relPath) throw new Error("path is required"); - const abs = safePath(relPath); - if (!abs || !(await fileExists(abs))) - throw new Error(`File not found: ${relPath}`); - const content = await fs.readFile(abs, "utf-8"); - const lines = content.split("\n"); - const start = ((args as Record)?.startLine as number) ?? 1; - const end = ((args as Record)?.endLine as number) ?? lines.length; - const slice = lines.slice( - Math.max(0, start - 1), - Math.min(lines.length, end) - ); - return { - content: [ - { - type: "text" as const, - text: slice - .map((l: string, i: number) => `${(start + i).toString().padStart(5)} | ${l}`) - .join("\n"), - }, - ], - }; - } - - // ---- search_source ---- - case "search_source": { - const pattern = (args as Record)?.pattern as string; - if (!pattern) throw new Error("pattern is required"); - const filePattern = (args as Record)?.filePattern as - | string - | undefined; - const maxResults = - ((args as Record)?.maxResults as number) ?? 50; - - let regex: RegExp; - try { - regex = new RegExp(pattern, "i"); - } catch { - throw new Error(`Invalid regex pattern: ${pattern}`); - } - - const allFiles = await walkFiles(SRC_ROOT); - const filtered = filePattern - ? allFiles.filter((f) => f.endsWith(filePattern)) - : allFiles; - - const matches: string[] = []; - for (const file of filtered) { - if (matches.length >= maxResults) break; - const abs = path.join(SRC_ROOT, file); - let content: string; - try { - content = await fs.readFile(abs, "utf-8"); - } catch { - continue; - } - const lines = content.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (matches.length >= maxResults) break; - if (regex.test(lines[i]!)) { - matches.push(`${file}:${i + 1}: ${lines[i]!.trim()}`); - } - } - } - - return { - content: [ - { - type: "text" as const, - text: matches.length > 0 - ? `Found ${matches.length} match(es):\n\n${matches.join("\n")}` - : "No matches found.", - }, - ], - }; - } - - // ---- list_directory ---- - case "list_directory": { - const relPath = ((args as Record)?.path as string) ?? ""; - const abs = safePath(relPath); - if (!abs || !(await dirExists(abs))) - throw new Error(`Directory not found: ${relPath}`); - const entries = await listDir(abs); - return { - content: [ - { - type: "text" as const, - text: entries.length > 0 ? entries.join("\n") : "(empty directory)", - }, - ], - }; - } - - // ---- get_architecture ---- - case "get_architecture": { - const topLevel = await listDir(SRC_ROOT); - const tools = await getToolList(); - const commands = await getCommandList(); - - const overview = `# Claude Code Architecture Overview - -## Source Root -${SRC_ROOT} - -## Top-Level Entries -${topLevel.map((e) => `- ${e}`).join("\n")} - -## Agent Tools (${tools.length}) -${tools.map((t) => `- **${t.name}** — ${t.files.length} files: ${t.files.join(", ")}`).join("\n")} - -## Slash Commands (${commands.length}) -${commands.map((c) => `- **${c.name}** ${c.isDirectory ? "(directory)" : "(file)"}${c.files ? ": " + c.files.join(", ") : ""}`).join("\n")} - -## Key Files -- **main.tsx** — CLI entrypoint (Commander.js) -- **QueryEngine.ts** — Core LLM API caller, streaming, tool loops -- **Tool.ts** — Base tool types, schemas, permission model -- **commands.ts** — Command registry and loader -- **tools.ts** — Tool registry and loader -- **context.ts** — System/user context collection - -## Core Subsystems -- **bridge/** — IDE integration (VS Code, JetBrains) -- **coordinator/** — Multi-agent orchestration -- **services/mcp/** — MCP client connections -- **services/api/** — Anthropic API client -- **plugins/** — Plugin system -- **skills/** — Skill system -- **tasks/** — Background task management -- **server/** — Server/remote mode -- **entrypoints/mcp.ts** — Built-in MCP server entrypoint -`; - return { content: [{ type: "text" as const, text: overview }] }; - } - - default: - throw new Error(`Unknown tool: ${name}`); - } -}); - -// ---- Prompts ------------------------------------------------------------- - -server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: [ - { - name: "explain_tool", - description: - "Explain how a specific Claude Code tool works, including its input schema, permissions, and execution flow.", - arguments: [ - { - name: "toolName", - description: - "Tool directory name, e.g. 'BashTool', 'FileEditTool', 'AgentTool'", - required: true, - }, - ], - }, - { - name: "explain_command", - description: - "Explain how a specific Claude Code slash command works.", - arguments: [ - { - name: "commandName", - description: - "Command name, e.g. 'commit', 'review', 'mcp', 'config'", - required: true, - }, - ], - }, - { - name: "architecture_overview", - description: - "Get a guided tour of the Claude Code architecture with explanations of each subsystem.", - }, - { - name: "how_does_it_work", - description: - "Explain how a specific feature or subsystem of Claude Code works. Good for understanding MCP integration, permission model, tool system, etc.", - arguments: [ - { - name: "feature", - description: - "Feature or subsystem to explain, e.g. 'permission system', 'MCP client', 'tool deferred loading', 'query engine', 'bridge/IDE integration'", - required: true, - }, - ], - }, - { - name: "compare_tools", - description: - "Compare two Claude Code tools side by side — their purpose, inputs, permissions, and implementation patterns.", - arguments: [ - { - name: "tool1", - description: "First tool name, e.g. 'FileReadTool'", - required: true, - }, - { - name: "tool2", - description: "Second tool name, e.g. 'FileWriteTool'", - required: true, - }, - ], - }, - ], -})); - -server.setRequestHandler(GetPromptRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - switch (name) { - case "explain_tool": { - const toolName = args?.toolName; - if (!toolName) throw new Error("toolName argument is required"); - const toolDir = safePath(`tools/${toolName}`); - if (!toolDir || !(await dirExists(toolDir))) - throw new Error(`Tool not found: ${toolName}`); - - const files = await listDir(toolDir); - const mainFile = - files.find((f) => f === `${toolName}.ts` || f === `${toolName}.tsx`) ?? - files.find((f) => f.endsWith(".ts") || f.endsWith(".tsx")); - - let source = ""; - if (mainFile) { - const abs = path.join(toolDir, mainFile); - source = await fs.readFile(abs, "utf-8"); - } - - return { - description: `Explanation of the ${toolName} tool`, - messages: [ - { - role: "user" as const, - content: { - type: "text" as const, - text: `Analyze and explain this Claude Code tool implementation. Cover: -1. **Purpose** — What does this tool do? -2. **Input Schema** — What parameters does it accept? -3. **Permissions** — What permission checks are performed? -4. **Execution Flow** — How does it process a request? -5. **Output** — What does it return? -6. **Concurrency/Safety** — Is it read-only? Concurrency-safe? Destructive? - -Files in tools/${toolName}/: ${files.join(", ")} - -Main source (${mainFile ?? "not found"}):\n\`\`\`typescript\n${source}\n\`\`\``, - }, - }, - ], - }; - } - - case "explain_command": { - const commandName = args?.commandName; - if (!commandName) throw new Error("commandName argument is required"); - - const candidates = [ - `commands/${commandName}`, - `commands/${commandName}.ts`, - `commands/${commandName}.tsx`, - ]; - let found: string | null = null; - let isDir = false; - for (const c of candidates) { - const abs = safePath(c); - if (abs && (await dirExists(abs))) { found = abs; isDir = true; break; } - if (abs && (await fileExists(abs))) { found = abs; break; } - } - if (!found) throw new Error(`Command not found: ${commandName}`); - - let source = ""; - let fileList = ""; - if (isDir) { - const files = await listDir(found); - fileList = files.join(", "); - const indexFile = files.find((f) => f === "index.ts" || f === "index.tsx"); - if (indexFile) { - source = await fs.readFile(path.join(found, indexFile), "utf-8"); - } - } else { - source = await fs.readFile(found, "utf-8"); - fileList = path.basename(found); - } - - return { - description: `Explanation of the /${commandName} command`, - messages: [ - { - role: "user" as const, - content: { - type: "text" as const, - text: `Analyze and explain this Claude Code slash command. Cover: -1. **Purpose** — What does /${commandName} do? -2. **Type** — Is it a 'prompt' command (sends to LLM) or 'action' command (executes directly)? -3. **Allowed Tools** — What tools can the LLM use when processing this command? -4. **Arguments** — What arguments does it accept? -5. **Implementation** — How does it work? - -Files: ${fileList} - -Source:\n\`\`\`typescript\n${source}\n\`\`\``, - }, - }, - ], - }; - } - - case "architecture_overview": { - const readmePath = path.resolve(SRC_ROOT, "..", "README.md"); - let readme = ""; - try { readme = await fs.readFile(readmePath, "utf-8"); } catch { /* */ } - - const topLevel = await listDir(SRC_ROOT); - const tools = await getToolList(); - const commands = await getCommandList(); - - return { - description: "Architecture overview of Claude Code", - messages: [ - { - role: "user" as const, - content: { - type: "text" as const, - text: `Give a comprehensive guided tour of the Claude Code architecture. Use the following context: - -## README -${readme} - -## src/ top-level files and directories -${topLevel.join("\n")} - -## Tools (${tools.length}) -${tools.map((t) => `- ${t.name}: ${t.files.join(", ")}`).join("\n")} - -## Commands (${commands.length}) -${commands.map((c) => `- ${c.name} ${c.isDirectory ? "(dir)" : "(file)"}`).join("\n")} - -Explain the overall architecture, how the main subsystems connect, and the request lifecycle from CLI input to tool execution.`, - }, - }, - ], - }; - } - - case "how_does_it_work": { - const feature = args?.feature; - if (!feature) throw new Error("feature argument is required"); - - // Map well-known features to relevant source paths - const featureMap: Record = { - "permission system": ["utils/permissions/", "hooks/toolPermission/", "Tool.ts"], - "permissions": ["utils/permissions/", "hooks/toolPermission/", "Tool.ts"], - "mcp client": ["services/mcp/", "tools/MCPTool/", "tools/ListMcpResourcesTool/", "tools/ReadMcpResourceTool/"], - "mcp": ["services/mcp/", "entrypoints/mcp.ts", "tools/MCPTool/"], - "tool system": ["Tool.ts", "tools.ts", "tools/"], - "tools": ["Tool.ts", "tools.ts"], - "query engine": ["QueryEngine.ts", "query/"], - "bridge": ["bridge/"], - "ide integration": ["bridge/"], - "context": ["context.ts", "context/"], - "commands": ["commands.ts", "types/command.ts"], - "command system": ["commands.ts", "types/command.ts", "commands/"], - "cost tracking": ["cost-tracker.ts", "costHook.ts"], - "plugins": ["plugins/"], - "skills": ["skills/"], - "tasks": ["tasks.ts", "tasks/", "tools/TaskCreateTool/"], - "coordinator": ["coordinator/"], - "multi-agent": ["coordinator/", "tools/AgentTool/"], - "memory": ["memdir/", "commands/memory/"], - "voice": ["voice/"], - "server": ["server/"], - }; - - const paths = featureMap[feature.toLowerCase()] ?? []; - let contextFiles = ""; - for (const p of paths) { - const abs = safePath(p); - if (!abs) continue; - try { - const stat = await fs.stat(abs); - if (stat.isDirectory()) { - const files = await listDir(abs); - contextFiles += `\n### ${p}\nFiles: ${files.join(", ")}\n`; - } else { - const content = await fs.readFile(abs, "utf-8"); - // Only include first 200 lines to keep prompt manageable - const preview = content.split("\n").slice(0, 200).join("\n"); - contextFiles += `\n### ${p} (first 200 lines)\n\`\`\`typescript\n${preview}\n\`\`\`\n`; - } - } catch { /* skip */ } - } - - return { - description: `How ${feature} works in Claude Code`, - messages: [ - { - role: "user" as const, - content: { - type: "text" as const, - text: `Explain how "${feature}" works in the Claude Code CLI. Use these relevant source files as context: -${contextFiles || "(No specific source files mapped for this feature. Use the search_source and read_source_file tools to find relevant code.)"} - -Explain the design, key types/interfaces, data flow, and how it integrates with the rest of the system.`, - }, - }, - ], - }; - } - - case "compare_tools": { - const tool1 = args?.tool1; - const tool2 = args?.tool2; - if (!tool1 || !tool2) throw new Error("Both tool1 and tool2 arguments are required"); - - const sources: string[] = []; - for (const toolName of [tool1, tool2]) { - const toolDir = safePath(`tools/${toolName}`); - if (!toolDir || !(await dirExists(toolDir))) { - sources.push(`// Tool not found: ${toolName}`); - continue; - } - const files = await listDir(toolDir); - const mainFile = - files.find((f) => f === `${toolName}.ts` || f === `${toolName}.tsx`) ?? - files.find((f) => f.endsWith(".ts") || f.endsWith(".tsx")); - if (mainFile) { - const content = await fs.readFile(path.join(toolDir, mainFile), "utf-8"); - sources.push(`// tools/${toolName}/${mainFile}\n${content}`); - } else { - sources.push(`// No main source found for ${toolName}`); - } - } - - return { - description: `Comparison of ${tool1} vs ${tool2}`, - messages: [ - { - role: "user" as const, - content: { - type: "text" as const, - text: `Compare these two Claude Code tools side by side. Analyze: -1. **Purpose** — What each tool does -2. **Input Schemas** — How their parameters differ -3. **Permissions** — Different permission models -4. **Read-only / Destructive** — Safety characteristics -5. **Implementation Patterns** — Similarities and differences in how they're built - -## ${tool1} -\`\`\`typescript -${sources[0]} -\`\`\` - -## ${tool2} -\`\`\`typescript -${sources[1]} -\`\`\``, - }, - }, - ], - }; - } - - default: - throw new Error(`Unknown prompt: ${name}`); - } -}); - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main() { - // Validate source root exists - if (!(await dirExists(SRC_ROOT))) { - console.error(`Error: Claude Code src/ directory not found at ${SRC_ROOT}`); - console.error( - "Set CLAUDE_CODE_SRC_ROOT environment variable to the src/ directory path." - ); - process.exit(1); - } - - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error(`Claude Code Explorer MCP server started (src: ${SRC_ROOT})`); -} - -main().catch((err) => { - console.error("Fatal error:", err); - process.exit(1); -}); - - diff --git a/src/services/x402/tracker.ts b/src/services/x402/tracker.ts index 07f2e26..663c3b4 100644 --- a/src/services/x402/tracker.ts +++ b/src/services/x402/tracker.ts @@ -63,7 +63,7 @@ export function formatX402Cost(): string { if (!byDomain[domain]) { byDomain[domain] = { count: 0, totalUSD: 0 } } - byDomain[domain].count += payment.count ?? 1 + byDomain[domain].count += 1 byDomain[domain].totalUSD += payment.amountUSD } catch { // Skip malformed URLs diff --git a/src/shims/bun-bundle.ts b/src/shims/bun-bundle.ts new file mode 100644 index 0000000..43817a3 --- /dev/null +++ b/src/shims/bun-bundle.ts @@ -0,0 +1,28 @@ +// src/shims/bun-bundle.ts + +// Map of feature flags to their enabled state. +// In production Bun builds, these are compile-time constants. +// For our dev build, we read from env vars with sensible defaults. +const FEATURE_FLAGS: Record = { + PROACTIVE: envBool('CLAUDE_CODE_PROACTIVE', false), + KAIROS: envBool('CLAUDE_CODE_KAIROS', false), + BRIDGE_MODE: envBool('CLAUDE_CODE_BRIDGE_MODE', false), + DAEMON: envBool('CLAUDE_CODE_DAEMON', false), + VOICE_MODE: envBool('CLAUDE_CODE_VOICE_MODE', false), + AGENT_TRIGGERS: envBool('CLAUDE_CODE_AGENT_TRIGGERS', false), + MONITOR_TOOL: envBool('CLAUDE_CODE_MONITOR_TOOL', false), + COORDINATOR_MODE: envBool('CLAUDE_CODE_COORDINATOR_MODE', false), + ABLATION_BASELINE: false, // always off for external builds + DUMP_SYSTEM_PROMPT: envBool('CLAUDE_CODE_DUMP_SYSTEM_PROMPT', false), + BG_SESSIONS: envBool('CLAUDE_CODE_BG_SESSIONS', false), +} + +function envBool(key: string, fallback: boolean): boolean { + const v = process.env[key] + if (v === undefined) return fallback + return v === '1' || v === 'true' +} + +export function feature(name: string): boolean { + return FEATURE_FLAGS[name] ?? false +} diff --git a/src/shims/macro.ts b/src/shims/macro.ts new file mode 100644 index 0000000..88f0b16 --- /dev/null +++ b/src/shims/macro.ts @@ -0,0 +1,26 @@ +// src/shims/macro.ts + +// Read version from package.json at startup +import { readFileSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const pkgPath = resolve(dirname(__filename), '..', '..', 'package.json') +let version = '0.0.0-dev' +try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) + version = pkg.version || version +} catch {} + +const MACRO_OBJ = { + VERSION: version, + PACKAGE_URL: '@anthropic-ai/claude-code', + ISSUES_EXPLAINER: + 'report issues at https://github.com/anthropics/claude-code/issues', +} + +// Install as global +;(globalThis as any).MACRO = MACRO_OBJ + +export default MACRO_OBJ diff --git a/src/shims/preload.ts b/src/shims/preload.ts new file mode 100644 index 0000000..f5a6633 --- /dev/null +++ b/src/shims/preload.ts @@ -0,0 +1,6 @@ +// src/shims/preload.ts +// Must be loaded before any application code. +// Provides runtime equivalents of Bun bundler build-time features. + +import './macro.js' +// bun:bundle is resolved via the build alias, not imported here diff --git a/src/tools/WebFetchTool/utils.ts b/src/tools/WebFetchTool/utils.ts index 7304bdd..9672041 100644 --- a/src/tools/WebFetchTool/utils.ts +++ b/src/tools/WebFetchTool/utils.ts @@ -324,6 +324,43 @@ export async function getWithPermittedRedirects( throw new EgressBlockedError(hostname) } + // Handle HTTP 402 Payment Required via x402 protocol + if ( + axios.isAxiosError(error) && + error.response?.status === 402 + ) { + try { + const { isX402Enabled, handlePaymentRequired, getX402SessionSpentUSD } = + require('../../services/x402/index.js') as typeof import('../../services/x402/index.js') + + const paymentHeader = error.response.headers['x-payment-required'] + if (isX402Enabled() && paymentHeader) { + const result = handlePaymentRequired( + paymentHeader, + getX402SessionSpentUSD(), + ) + + if (result) { + // Retry request with payment header + return await axios.get(url, { + signal, + timeout: FETCH_TIMEOUT_MS, + maxRedirects: 0, + responseType: 'arraybuffer', + maxContentLength: MAX_HTTP_CONTENT_LENGTH, + headers: { + Accept: 'text/markdown, text/html, */*', + 'User-Agent': getWebFetchUserAgent(), + 'x-payment': result.paymentHeader, + }, + }) + } + } + } catch { + // x402 handling failed, fall through to original error + } + } + throw error } } diff --git a/src/types/macro.d.ts b/src/types/macro.d.ts new file mode 100644 index 0000000..04f7203 --- /dev/null +++ b/src/types/macro.d.ts @@ -0,0 +1,5 @@ +declare const MACRO: { + VERSION: string + PACKAGE_URL: string + ISSUES_EXPLAINER: string +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..5565e54 --- /dev/null +++ b/vercel.json @@ -0,0 +1,20 @@ +{ + "version": 2, + "builds": [ + { + "src": "mcp-server/api/index.ts", + "use": "@vercel/node", + "config": { + "includeFiles": ["src/**", "mcp-server/src/**", "mcp-server/dist/**", "README.md"] + } + } + ], + "routes": [ + { "src": "/health", "dest": "mcp-server/api/index.ts" }, + { "src": "/mcp", "dest": "mcp-server/api/index.ts" }, + { "src": "/sse", "dest": "mcp-server/api/index.ts" }, + { "src": "/messages", "dest": "mcp-server/api/index.ts" }, + { "src": "/api", "dest": "mcp-server/api/index.ts" }, + { "src": "/(.*)", "dest": "mcp-server/api/index.ts" } + ] +}