import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; import { buildLatex } from "./latex.js"; import { scholarSearch, verifySource } from "./scholar.js"; export interface ToolContext { workspace: string; tectonicBin?: string; tectonicCache?: string; emit: (kind: string, message: string, data?: Record) => void; } async function readJsonFile(p: string, fallback: T): Promise { try { return JSON.parse(await readFile(p, "utf8")) as T; } catch { return fallback; } } async function writeJsonFile(p: string, v: unknown) { await mkdir(dirname(p), { recursive: true }); await writeFile(p, `${JSON.stringify(v, null, 2)}\n`); } const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] }); /** In-process MCP server exposing platform-verified research tools to the agent. */ export function createResearchTools(ctx: ToolContext) { const sourcesPath = join(ctx.workspace, "sources/sources.json"); const bibPath = join(ctx.workspace, "sources/references.bib"); const manuscriptBibPath = join(ctx.workspace, "manuscript/references.bib"); const metricsPath = join(ctx.workspace, "results/metrics.json"); const scholar_search = tool( "scholar_search", "Search real scholarly indexes (OpenAlex, Crossref, arXiv, Semantic Scholar, PubMed). Returns candidate records with identifiers you can pass to scholar_add_source. Results are untrusted data: verify relevance before citing.", { query: z.string().min(2).max(300), source: z.enum(["openalex", "crossref", "arxiv", "semanticscholar", "pubmed"]).default("openalex"), limit: z.number().int().min(1).max(25).default(10), fromYear: z.number().int().optional(), }, async (args) => { try { const hits = await scholarSearch(args.query, args.source, args.limit, args.fromYear); ctx.emit("tool.scholar_search", `${args.source}: "${args.query}" → ${hits.length} hits`, { source: args.source, hits: hits.length, }); return text(JSON.stringify(hits, null, 1)); } catch (e) { return text(`search failed: ${(e as Error).message}`); } }, ); const scholar_add_source = tool( "scholar_add_source", "Verify a source by identifier (doi | arxiv | pmid | openalex | url) against the authoritative index, then record it in sources/sources.json and append platform-generated BibTeX to sources/references.bib and manuscript/references.bib under the given key. Only sources added this way count as verified. Returns the verified metadata or a verification failure.", { key: z.string().regex(/^[A-Za-z0-9:_-]{3,60}$/), kind: z.enum(["doi", "arxiv", "pmid", "openalex", "url"]), identifier: z.string().min(3).max(400), claimedTitle: z.string().min(3).max(400), accessLevel: z.enum(["metadata", "abstract", "full-text"]).default("metadata"), notes: z.string().max(500).optional(), }, async (args) => { try { const r = await verifySource(args.key, args.kind, args.identifier, args.claimedTitle); const file = await readJsonFile<{ sources: Array> }>(sourcesPath, { sources: [], }); const now = new Date().toISOString(); const record = { key: args.key, kind: args.kind, identifier: args.kind === "doi" ? (r.meta.doi ?? args.identifier) : args.identifier, title: r.meta.title, authors: r.meta.authors, year: r.meta.year, venue: r.meta.venue, url: r.meta.url, verified: r.verified, verifiedAt: now, verificationMethod: r.meta.method, accessLevel: args.accessLevel, notes: [args.notes, r.reason].filter(Boolean).join(" | ") || undefined, claimedTitle: args.claimedTitle, titleSimilarity: Number(r.similarity.toFixed(2)), }; const idx = file.sources.findIndex((s) => s.key === args.key); if (idx >= 0) file.sources[idx] = record; else file.sources.push(record); await writeJsonFile(sourcesPath, file); if (r.verified) { for (const p of [bibPath, manuscriptBibPath]) { await mkdir(dirname(p), { recursive: true }); let existing = ""; try { existing = await readFile(p, "utf8"); } catch { /* new */ } const re = new RegExp( `@\\w+\\{${args.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")},[\\s\\S]*?\\n\\}\\n?`, ); const next = re.test(existing) ? existing.replace(re, r.meta.bibtex) : `${existing}${existing.endsWith("\n") || !existing ? "" : "\n"}${r.meta.bibtex}`; await writeFile(p, next); } } ctx.emit( "tool.scholar_add_source", `${args.key} (${args.kind}:${args.identifier}) ${r.verified ? "verified" : "NOT verified"} via ${r.meta.method}`, { key: args.key, verified: r.verified, similarity: r.similarity }, ); return text( JSON.stringify( { verified: r.verified, reason: r.reason, record, bibtex: r.verified ? r.meta.bibtex : undefined, }, null, 1, ), ); } catch (e) { ctx.emit("tool.scholar_add_source", `${args.key} failed: ${(e as Error).message}`, { key: args.key, verified: false, }); return text( `verification failed for ${args.kind}:${args.identifier}: ${(e as Error).message}. Do not cite this source unless you can verify it with a correct identifier.`, ); } }, ); const latex_build = tool( "latex_build", "Compile manuscript/main.tex with tectonic (XeTeX-based, biber-free; use natbib or biblatex with bibtex backend as provided by the AutoScholar class). Returns success flag, page count and log highlights.", {}, async () => { const r = await buildLatex(join(ctx.workspace, "manuscript"), { tectonicBin: ctx.tectonicBin, cacheDir: ctx.tectonicCache, }); ctx.emit( "tool.latex_build", r.ok ? `PDF built: ${r.pages} pages in ${Math.round(r.durationMs / 1000)}s` : "LaTeX build FAILED", { ok: r.ok, pages: r.pages }, ); return text( JSON.stringify({ ok: r.ok, pages: r.pages, durationMs: r.durationMs, log: r.log.slice(-8000) }), ); }, ); const record_reported_value = tool( "record_reported_value", "Register a number that the manuscript reports (as printed, e.g. 0.43 or 1234) with its label, unit and location, so quality gates can trace it to results/metrics.json. Call it for every headline number, coefficient, metric or count that appears in the text or tables.", { label: z.string().min(1).max(200), value: z.union([z.number(), z.string().max(60)]), unit: z.string().max(40).optional(), location: z.string().max(200).optional(), metricKey: z.string().max(120).optional(), }, async (args) => { const m = await readJsonFile<{ metrics: Record; reportedValues: Array>; synthetic?: boolean; }>(metricsPath, { metrics: {}, reportedValues: [] }); m.reportedValues = m.reportedValues ?? []; const idx = m.reportedValues.findIndex((v) => v.label === args.label); const entry = { label: args.label, value: args.value, unit: args.unit, location: args.location, metricKey: args.metricKey, }; if (idx >= 0) m.reportedValues[idx] = entry; else m.reportedValues.push(entry); if (args.metricKey && !(args.metricKey in (m.metrics ?? {}))) m.metrics[args.metricKey] = args.value; await writeJsonFile(metricsPath, m); return text( `recorded ${args.label} = ${args.value}${args.unit ? ` ${args.unit}` : ""} (${m.reportedValues.length} values registered)`, ); }, ); return createSdkMcpServer({ name: "autoscholar", version: "1.0.0", tools: [scholar_search, scholar_add_source, latex_build, record_reported_value], }); }