spb/autoscholar
Public
1import { mkdir, readFile, writeFile } from "node:fs/promises";2import { dirname, join } from "node:path";3import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";4import { z } from "zod";5import { buildLatex } from "./latex.js";6import { scholarSearch, verifySource } from "./scholar.js";78export interface ToolContext {9 workspace: string;10 tectonicBin?: string;11 tectonicCache?: string;12 emit: (kind: string, message: string, data?: Record<string, unknown>) => void;13}1415async function readJsonFile<T>(p: string, fallback: T): Promise<T> {16 try {17 return JSON.parse(await readFile(p, "utf8")) as T;18 } catch {19 return fallback;20 }21}22async function writeJsonFile(p: string, v: unknown) {23 await mkdir(dirname(p), { recursive: true });24 await writeFile(p, `${JSON.stringify(v, null, 2)}\n`);25}2627const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] });2829/** In-process MCP server exposing platform-verified research tools to the agent. */30export function createResearchTools(ctx: ToolContext) {31 const sourcesPath = join(ctx.workspace, "sources/sources.json");32 const bibPath = join(ctx.workspace, "sources/references.bib");33 const manuscriptBibPath = join(ctx.workspace, "manuscript/references.bib");34 const metricsPath = join(ctx.workspace, "results/metrics.json");3536 const scholar_search = tool(37 "scholar_search",38 "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.",39 {40 query: z.string().min(2).max(300),41 source: z.enum(["openalex", "crossref", "arxiv", "semanticscholar", "pubmed"]).default("openalex"),42 limit: z.number().int().min(1).max(25).default(10),43 fromYear: z.number().int().optional(),44 },45 async (args) => {46 try {47 const hits = await scholarSearch(args.query, args.source, args.limit, args.fromYear);48 ctx.emit("tool.scholar_search", `${args.source}: "${args.query}" → ${hits.length} hits`, {49 source: args.source,50 hits: hits.length,51 });52 return text(JSON.stringify(hits, null, 1));53 } catch (e) {54 return text(`search failed: ${(e as Error).message}`);55 }56 },57 );5859 const scholar_add_source = tool(60 "scholar_add_source",61 "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.",62 {63 key: z.string().regex(/^[A-Za-z0-9:_-]{3,60}$/),64 kind: z.enum(["doi", "arxiv", "pmid", "openalex", "url"]),65 identifier: z.string().min(3).max(400),66 claimedTitle: z.string().min(3).max(400),67 accessLevel: z.enum(["metadata", "abstract", "full-text"]).default("metadata"),68 notes: z.string().max(500).optional(),69 },70 async (args) => {71 try {72 const r = await verifySource(args.key, args.kind, args.identifier, args.claimedTitle);73 const file = await readJsonFile<{ sources: Array<Record<string, unknown>> }>(sourcesPath, {74 sources: [],75 });76 const now = new Date().toISOString();77 const record = {78 key: args.key,79 kind: args.kind,80 identifier: args.kind === "doi" ? (r.meta.doi ?? args.identifier) : args.identifier,81 title: r.meta.title,82 authors: r.meta.authors,83 year: r.meta.year,84 venue: r.meta.venue,85 url: r.meta.url,86 verified: r.verified,87 verifiedAt: now,88 verificationMethod: r.meta.method,89 accessLevel: args.accessLevel,90 notes: [args.notes, r.reason].filter(Boolean).join(" | ") || undefined,91 claimedTitle: args.claimedTitle,92 titleSimilarity: Number(r.similarity.toFixed(2)),93 };94 const idx = file.sources.findIndex((s) => s.key === args.key);95 if (idx >= 0) file.sources[idx] = record;96 else file.sources.push(record);97 await writeJsonFile(sourcesPath, file);98 if (r.verified) {99 for (const p of [bibPath, manuscriptBibPath]) {100 await mkdir(dirname(p), { recursive: true });101 let existing = "";102 try {103 existing = await readFile(p, "utf8");104 } catch {105 /* new */106 }107 const re = new RegExp(108 `@\\w+\\{${args.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")},[\\s\\S]*?\\n\\}\\n?`,109 );110 const next = re.test(existing)111 ? existing.replace(re, r.meta.bibtex)112 : `${existing}${existing.endsWith("\n") || !existing ? "" : "\n"}${r.meta.bibtex}`;113 await writeFile(p, next);114 }115 }116 ctx.emit(117 "tool.scholar_add_source",118 `${args.key} (${args.kind}:${args.identifier}) ${r.verified ? "verified" : "NOT verified"} via ${r.meta.method}`,119 { key: args.key, verified: r.verified, similarity: r.similarity },120 );121 return text(122 JSON.stringify(123 {124 verified: r.verified,125 reason: r.reason,126 record,127 bibtex: r.verified ? r.meta.bibtex : undefined,128 },129 null,130 1,131 ),132 );133 } catch (e) {134 ctx.emit("tool.scholar_add_source", `${args.key} failed: ${(e as Error).message}`, {135 key: args.key,136 verified: false,137 });138 return text(139 `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.`,140 );141 }142 },143 );144145 const latex_build = tool(146 "latex_build",147 "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.",148 {},149 async () => {150 const r = await buildLatex(join(ctx.workspace, "manuscript"), {151 tectonicBin: ctx.tectonicBin,152 cacheDir: ctx.tectonicCache,153 });154 ctx.emit(155 "tool.latex_build",156 r.ok ? `PDF built: ${r.pages} pages in ${Math.round(r.durationMs / 1000)}s` : "LaTeX build FAILED",157 { ok: r.ok, pages: r.pages },158 );159 return text(160 JSON.stringify({ ok: r.ok, pages: r.pages, durationMs: r.durationMs, log: r.log.slice(-8000) }),161 );162 },163 );164165 const record_reported_value = tool(166 "record_reported_value",167 "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.",168 {169 label: z.string().min(1).max(200),170 value: z.union([z.number(), z.string().max(60)]),171 unit: z.string().max(40).optional(),172 location: z.string().max(200).optional(),173 metricKey: z.string().max(120).optional(),174 },175 async (args) => {176 const m = await readJsonFile<{177 metrics: Record<string, unknown>;178 reportedValues: Array<Record<string, unknown>>;179 synthetic?: boolean;180 }>(metricsPath, { metrics: {}, reportedValues: [] });181 m.reportedValues = m.reportedValues ?? [];182 const idx = m.reportedValues.findIndex((v) => v.label === args.label);183 const entry = {184 label: args.label,185 value: args.value,186 unit: args.unit,187 location: args.location,188 metricKey: args.metricKey,189 };190 if (idx >= 0) m.reportedValues[idx] = entry;191 else m.reportedValues.push(entry);192 if (args.metricKey && !(args.metricKey in (m.metrics ?? {}))) m.metrics[args.metricKey] = args.value;193 await writeJsonFile(metricsPath, m);194 await writeJsonFile(join(ctx.workspace, "results/reported-values.json"), {195 reportedValues: m.reportedValues,196 });197 return text(198 `recorded ${args.label} = ${args.value}${args.unit ? ` ${args.unit}` : ""} (${m.reportedValues.length} values registered)`,199 );200 },201 );202203 return createSdkMcpServer({204 name: "autoscholar",205 version: "1.0.0",206 tools: [scholar_search, scholar_add_source, latex_build, record_reported_value],207 });208}209