Skip to content

Commit 39f464f

Browse files
yyx990803claude
andcommitted
feat: add query code mode tool to MCP server
- Export handleRequest with McpContext (pages + auth token) - Add query tool: executes JS against read-only PlatformClient SDK - Fix loadDocs OOM: replace readdirSync({recursive}) with findMdFiles that skips node_modules (prevents OOM in fresh worktrees) - Tests: remove inline handleRequest copy, add 5 query tool tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9df48b6 commit 39f464f

2 files changed

Lines changed: 315 additions & 132 deletions

File tree

‎packages/void/src/cli/mcp.ts‎

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createInterface } from "node:readline";
22
import { existsSync, readdirSync, readFileSync } from "node:fs";
33
import { dirname, join } from "node:path";
44
import { fileURLToPath } from "node:url";
5+
import { getToken } from "./auth";
6+
import { PlatformClient } from "./client";
57

68
// --- Types ---
79

@@ -56,12 +58,24 @@ function resolveDocsDir(): string {
5658
throw new Error(`Void docs directory not found. Expected bundled docs at: ${bundledDocsDir}`);
5759
}
5860

61+
function findMdFiles(dir: string, prefix = ""): string[] {
62+
const results: string[] = [];
63+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
64+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
65+
const rel = prefix ? prefix + "/" + entry.name : entry.name;
66+
if (entry.isDirectory()) {
67+
results.push(...findMdFiles(join(dir, entry.name), rel));
68+
} else if (entry.name.endsWith(".md")) {
69+
results.push(rel);
70+
}
71+
}
72+
return results;
73+
}
74+
5975
function loadDocs(): DocPage[] {
6076
const docsDir = resolveDocsDir();
6177

62-
const files = readdirSync(docsDir, { recursive: true })
63-
.filter((f): f is string => typeof f === "string" && f.endsWith(".md") && !f.startsWith("."))
64-
.sort();
78+
const files = findMdFiles(docsDir).sort();
6579

6680
return files.map((file) => {
6781
let content = readFileSync(join(docsDir, file), "utf-8");
@@ -136,6 +150,23 @@ export function searchDocs(pages: DocPage[], query: string): SearchResult[] {
136150
return scored.slice(0, 5);
137151
}
138152

153+
// --- Query SDK ---
154+
155+
function createQuerySdk(token: string) {
156+
const client = new PlatformClient(token);
157+
return {
158+
me: () => client.whoami(),
159+
projects: () => client.listProjects(),
160+
deployments: (projectId: string) => client.listDeployments(projectId),
161+
logs: (projectId: string, opts?: { since?: string; limit?: number }) =>
162+
client.getLogs(projectId, opts?.since ?? "1h", opts?.limit ?? 200),
163+
secrets: (projectId: string) => client.listSecrets(projectId),
164+
domains: (projectId: string) => client.listDomains(projectId),
165+
domainStatus: (projectId: string, domainId: string) =>
166+
client.getDomainStatus(projectId, domainId),
167+
};
168+
}
169+
139170
// --- Tool definitions ---
140171

141172
const TOOLS = [
@@ -173,11 +204,46 @@ const TOOLS = [
173204
required: ["query"],
174205
},
175206
},
207+
{
208+
name: "query",
209+
description: `Execute JavaScript to query the Void platform API.
210+
211+
Available methods on the "void_" object:
212+
void_.me() → { login: string, plan: string }
213+
void_.projects() → { id, slug, type, created_at, custom_domain }[]
214+
void_.deployments(projectId) → { id, status, source, commit, created_at, deployed_at }[]
215+
void_.logs(projectId, opts?) → { timestamp, outcome, logs, exceptions, url, method, status }[]
216+
opts: { since?: string (default "1h"), limit?: number (default 200) }
217+
void_.secrets(projectId) → { name }[]
218+
void_.domains(projectId) → { id, hostname, status, cname_target }[]
219+
void_.domainStatus(projectId, domainId) → { hostname, status, ssl_status, verification_errors }
220+
221+
Write an async arrow function in JavaScript. The SDK is available as "void_".
222+
Example: async () => { const ps = await void_.projects(); return ps.map(p => p.slug); }`,
223+
inputSchema: {
224+
type: "object" as const,
225+
properties: {
226+
code: {
227+
type: "string",
228+
description: "JavaScript async arrow function to execute",
229+
},
230+
},
231+
required: ["code"],
232+
},
233+
},
176234
];
177235

178236
// --- JSON-RPC dispatch ---
179237

180-
function handleRequest(pages: DocPage[], req: JsonRpcRequest): object | null {
238+
export interface McpContext {
239+
pages: DocPage[];
240+
token: string | null;
241+
}
242+
243+
export function handleRequest(
244+
ctx: McpContext,
245+
req: JsonRpcRequest,
246+
): object | null | Promise<object | null> {
181247
const { method, id, params } = req;
182248

183249
if (method === "initialize") {
@@ -213,7 +279,7 @@ function handleRequest(pages: DocPage[], req: JsonRpcRequest): object | null {
213279
>;
214280

215281
if (toolName === "list_pages") {
216-
const list = pages.map((p) => ({ slug: p.slug, title: p.title }));
282+
const list = ctx.pages.map((p) => ({ slug: p.slug, title: p.title }));
217283
return {
218284
jsonrpc: "2.0",
219285
id,
@@ -223,7 +289,7 @@ function handleRequest(pages: DocPage[], req: JsonRpcRequest): object | null {
223289

224290
if (toolName === "get_page") {
225291
const slug = toolArgs.slug as string;
226-
const page = pages.find((p) => p.slug === slug);
292+
const page = ctx.pages.find((p) => p.slug === slug);
227293
if (!page) {
228294
return {
229295
jsonrpc: "2.0",
@@ -240,14 +306,67 @@ function handleRequest(pages: DocPage[], req: JsonRpcRequest): object | null {
240306

241307
if (toolName === "search_docs") {
242308
const query = toolArgs.query as string;
243-
const results = searchDocs(pages, query);
309+
const results = searchDocs(ctx.pages, query);
244310
return {
245311
jsonrpc: "2.0",
246312
id,
247313
result: { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] },
248314
};
249315
}
250316

317+
if (toolName === "query") {
318+
const code = toolArgs.code as string;
319+
320+
if (!ctx.token) {
321+
return {
322+
jsonrpc: "2.0",
323+
id,
324+
result: {
325+
content: [{ type: "text", text: "Not logged in. Run `void auth login` first." }],
326+
isError: true,
327+
},
328+
};
329+
}
330+
331+
const sdk = createQuerySdk(ctx.token);
332+
const AsyncFunction = async function () {}.constructor as new (
333+
...args: string[]
334+
) => (...args: unknown[]) => Promise<unknown>;
335+
336+
return (async () => {
337+
try {
338+
const fn = new AsyncFunction("void_", `return await (${code})()`);
339+
const result = await Promise.race([
340+
fn(sdk),
341+
new Promise((_, reject) =>
342+
setTimeout(() => reject(new Error("Query timed out after 10 seconds")), 10_000),
343+
),
344+
]);
345+
return {
346+
jsonrpc: "2.0",
347+
id,
348+
result: {
349+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
350+
},
351+
};
352+
} catch (err) {
353+
return {
354+
jsonrpc: "2.0",
355+
id,
356+
result: {
357+
content: [
358+
{
359+
type: "text",
360+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
361+
},
362+
],
363+
isError: true,
364+
},
365+
};
366+
}
367+
})();
368+
}
369+
251370
return {
252371
jsonrpc: "2.0",
253372
id,
@@ -266,10 +385,12 @@ function handleRequest(pages: DocPage[], req: JsonRpcRequest): object | null {
266385

267386
export function runMcp(): void {
268387
const pages = loadDocs();
388+
const token = getToken();
389+
const ctx: McpContext = { pages, token };
269390

270391
const rl = createInterface({ input: process.stdin });
271392

272-
rl.on("line", (line) => {
393+
rl.on("line", async (line) => {
273394
let req: JsonRpcRequest;
274395
try {
275396
req = JSON.parse(line);
@@ -284,7 +405,7 @@ export function runMcp(): void {
284405
return;
285406
}
286407

287-
const response = handleRequest(pages, req);
408+
const response = await handleRequest(ctx, req);
288409
if (response !== null) {
289410
process.stdout.write(JSON.stringify(response) + "\n");
290411
}

0 commit comments

Comments
 (0)