Skip to content

Commit 94bc1ae

Browse files
committed
Merge v3.8.0: first-party MCP server, preprint search, CI/lint hardening (deploy)
2 parents 940f4ac + 0d5fade commit 94bc1ae

20 files changed

Lines changed: 6201 additions & 888 deletions

.eslintrc.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "next/core-web-vitals",
3+
"rules": {
4+
"react-hooks/exhaustive-deps": "warn",
5+
"react/no-unescaped-entities": "off",
6+
"@next/next/no-img-element": "off"
7+
}
8+
}

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ jobs:
2121

2222
- run: npm run typecheck
2323

24+
- run: npm run lint
25+
2426
- run: npm run build
2527
env:
2628
# Build succeeds without real keys; routes are lazy at runtime.

.github/workflows/ingest-journals.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,7 @@ jobs:
5757
git config user.name "medcore-ingest-bot"
5858
git config user.email "actions@github.com"
5959
git add lib/journals/generated.ts
60-
git commit -m "chore(journals): refresh ingested dataset [skip ci]
61-
62-
Auto-generated by scripts/ingest-journals.mjs (OpenAlex + DOAJ).
63-
https://claude.ai/code/session_018mZwcEhwHFvtusHwG5xVFt"
60+
git commit \
61+
-m "chore(journals): refresh ingested dataset [skip ci]" \
62+
-m "Auto-generated by scripts/ingest-journals.mjs (OpenAlex + DOAJ)."
6463
git push origin HEAD:main

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ A free, no-login, reporting-guideline-driven workspace for building the core of
77
**Live app:** https://medcore-research-builder.vercel.app
88
**Repository:** https://github.com/Abdulsalam3302/medcore-research-builder
99

10+
## What’s new in v3.8
11+
12+
- **First-party MCP server** — MedCore's engines (journal finder, design
13+
registry, reference verification, coherence checks, preprint search) are now
14+
exposed to any Model Context Protocol client at `/api/mcp`
15+
(Streamable HTTP, stateless). See [`docs/MCP_SERVER.md`](docs/MCP_SERVER.md).
16+
```bash
17+
claude mcp add --transport http medcore https://medcore-research-builder.vercel.app/api/mcp
18+
```
19+
- **Preprint search API**`/api/preprints/search` queries bioRxiv/medRxiv and
20+
other preprints via Europe PMC's `SRC:PPR` source (free, keyless), with
21+
explicit *not peer reviewed* labelling.
22+
- **Lint gate**`npm run lint` (next/core-web-vitals) now passes clean and
23+
runs in CI; React hook dependency hazards fixed.
24+
- **Sync you can trust** — cloud-sync failures and local-storage save failures
25+
now surface as visible alerts (including on mobile) instead of failing silently.
26+
1027
## What’s new in v3
1128

1229
- **Journal Finder** — a deep journal-suggestion engine over WoS SCIE/ESCI,

app/api/mcp/route.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* MedCore MCP endpoint — Model Context Protocol over Streamable HTTP
3+
* (stateless mode, JSON responses).
4+
*
5+
* Connect from any MCP client, e.g. Claude Code:
6+
* claude mcp add --transport http medcore https://medcore-research-builder.vercel.app/api/mcp
7+
*
8+
* Tool logic lives in lib/mcp/server.ts; this file is only the JSON-RPC 2.0
9+
* transport: initialize / ping / tools/list / tools/call, notifications get
10+
* 202, batches and SSE streams are not used (every call here is a single
11+
* request/response).
12+
*/
13+
14+
import { NextResponse } from "next/server";
15+
import { enforceRateLimit, safeJson } from "../_utils";
16+
import {
17+
MCP_PROTOCOL_VERSION,
18+
MCP_TOOLS,
19+
SERVER_INFO,
20+
SERVER_INSTRUCTIONS,
21+
callMcpTool,
22+
} from "@/lib/mcp/server";
23+
24+
export const runtime = "nodejs";
25+
export const maxDuration = 60;
26+
27+
type JsonRpcRequest = {
28+
jsonrpc?: string;
29+
id?: string | number | null;
30+
method?: string;
31+
params?: Record<string, unknown>;
32+
};
33+
34+
function rpcResult(id: string | number | null, result: unknown) {
35+
return NextResponse.json({ jsonrpc: "2.0", id, result });
36+
}
37+
38+
function rpcError(id: string | number | null, code: number, message: string, status = 200) {
39+
return NextResponse.json({ jsonrpc: "2.0", id, error: { code, message } }, { status });
40+
}
41+
42+
export async function POST(req: Request) {
43+
let body: JsonRpcRequest;
44+
try {
45+
body = await safeJson<JsonRpcRequest>(req, "llm");
46+
} catch {
47+
return rpcError(null, -32700, "Parse error: body must be a single JSON-RPC 2.0 message.", 400);
48+
}
49+
if (Array.isArray(body)) {
50+
return rpcError(null, -32600, "Batch requests are not supported.", 400);
51+
}
52+
const method = typeof body?.method === "string" ? body.method : "";
53+
const hasId = body && "id" in body && body.id !== undefined && body.id !== null;
54+
const id = hasId ? (body.id as string | number) : null;
55+
const params = (body?.params || {}) as Record<string, unknown>;
56+
57+
// Notifications (initialized, cancelled, …) need no response body.
58+
if (!hasId) {
59+
return new NextResponse(null, { status: 202 });
60+
}
61+
62+
switch (method) {
63+
case "initialize": {
64+
const requested = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
65+
return rpcResult(id, {
66+
protocolVersion: requested === "2024-11-05" ? requested : MCP_PROTOCOL_VERSION,
67+
capabilities: { tools: { listChanged: false } },
68+
serverInfo: SERVER_INFO,
69+
instructions: SERVER_INSTRUCTIONS,
70+
});
71+
}
72+
case "ping":
73+
return rpcResult(id, {});
74+
case "tools/list":
75+
return rpcResult(id, { tools: MCP_TOOLS });
76+
case "tools/call": {
77+
const name = typeof params.name === "string" ? params.name : "";
78+
if (!name) return rpcError(id, -32602, "'name' is required.");
79+
// Reference verification fans out to several upstream APIs — hold it to
80+
// the stricter verify budget; everything else shares the search tier.
81+
const tier = name === "verify_references" ? "verify" : "search";
82+
const limited = await enforceRateLimit(req, tier);
83+
if (limited) {
84+
return rpcError(id, -32000, "Rate limit exceeded — please wait and try again.", 429);
85+
}
86+
const args = (params.arguments || {}) as Record<string, unknown>;
87+
const result = await callMcpTool(name, args);
88+
return rpcResult(id, result);
89+
}
90+
case "resources/list":
91+
return rpcResult(id, { resources: [] });
92+
case "prompts/list":
93+
return rpcResult(id, { prompts: [] });
94+
default:
95+
return rpcError(id, -32601, `Method '${method}' not found.`);
96+
}
97+
}
98+
99+
// Stateless server: no server-initiated SSE stream, no session to delete.
100+
export async function GET() {
101+
return NextResponse.json(
102+
{ error: "This MCP endpoint is stateless — POST JSON-RPC messages instead." },
103+
{ status: 405, headers: { Allow: "POST" } },
104+
);
105+
}
106+
107+
export async function DELETE() {
108+
return new NextResponse(null, { status: 405, headers: { Allow: "POST" } });
109+
}

app/api/preprints/search/route.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { bad, handleError, ok, enforceRateLimit } from "../../_utils";
2+
import { europepmcSearch } from "@/lib/scholarly/europepmc";
3+
4+
export const runtime = "nodejs";
5+
6+
/**
7+
* Preprint-only search (bioRxiv, medRxiv, Research Square, …) via Europe PMC's
8+
* PPR source. Free, keyless, and clearly labelled: preprints are not peer
9+
* reviewed, so the UI must keep the isPreprint flag visible.
10+
*/
11+
export async function GET(req: Request) {
12+
try {
13+
const limited = await enforceRateLimit(req, "search");
14+
if (limited) return limited;
15+
const u = new URL(req.url);
16+
const q = u.searchParams.get("query") || u.searchParams.get("q") || "";
17+
if (!q) return bad("query is required");
18+
const pageSize = Number(u.searchParams.get("page_size") || "25");
19+
const results = await europepmcSearch({
20+
query: `(${q}) AND SRC:PPR`,
21+
pageSize,
22+
includePreprints: false, // SRC:PPR already scopes the query to preprints
23+
});
24+
return ok({ results });
25+
} catch (e) {
26+
return handleError(e);
27+
}
28+
}

app/error.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default function Error({
1515

1616
return (
1717
<div className="min-h-screen flex items-center justify-center bg-med-bg p-6">
18-
<div className="card-elevated max-w-lg w-full p-6 text-center">
18+
<div role="alert" aria-live="assertive" className="card-elevated max-w-lg w-full p-6 text-center">
1919
<h1 className="display-title text-xl">Something went wrong</h1>
2020
<p className="muted mt-2 text-sm">
2121
An unexpected error occurred. Your draft in browser storage is usually safe — try

components/PlotlyPreview.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function PlotlyPreview({
6363
return;
6464
}
6565
setLoading(true);
66+
const node = ref.current;
6667
ensurePlotly()
6768
.then(() => {
6869
if (cancelled || !ref.current || !window.Plotly) return;
@@ -87,9 +88,9 @@ export function PlotlyPreview({
8788
});
8889
return () => {
8990
cancelled = true;
90-
if (ref.current && window.Plotly) {
91+
if (node && window.Plotly) {
9192
try {
92-
window.Plotly.purge(ref.current);
93+
window.Plotly.purge(node);
9394
} catch {
9495
/* noop */
9596
}

components/ReferenceSafetyPanel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ function scoreBadgeKind(score: number): "good" | "warn" | "bad" {
6262
}
6363

6464
export function ReferenceSafetyPanel({ project }: { project: ProjectState }) {
65-
const verifications = project.references?.verifications || [];
65+
const verifications = useMemo(
66+
() => project.references?.verifications || [],
67+
[project.references?.verifications]
68+
);
6669

6770
const sectionsText = useMemo(() => {
6871
const s = project.sections || ({} as ProjectState["sections"]);

components/ResearchLaunch.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function ResearchLaunch({
4545
update: Update;
4646
onJump?: (k: string) => void;
4747
}) {
48-
const answers = project.researchLaunch || {};
48+
const answers = useMemo(() => project.researchLaunch || {}, [project.researchLaunch]);
4949
const summary = useMemo(() => scoreLaunch(answers), [answers]);
5050
const [baseline, setBaseline] = useState<{ score: number; capturedAt: string } | null>(
5151
null,

0 commit comments

Comments
 (0)