semantic-scholar-mcp 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,3 +19,22 @@ node dist/index.js
19
19
  ```
20
20
 
21
21
  Semantic Scholar rate limits anonymous access. The server retries and reports an honest error when the limit is hit.
22
+
23
+ <!-- paywall -->
24
+ ## Premium tools
25
+
26
+ These tools need a license key:
27
+
28
+ * `paper_citations`
29
+ * `paper_field`
30
+
31
+ Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-semantic-scholar-mcp and set it in your MCP client config:
32
+
33
+ ```json
34
+ "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
35
+ ```
36
+
37
+ The key is checked against MCP Marketplace, cached for 24 hours, and keeps
38
+ working offline once one check has succeeded. Every other tool on this server
39
+ stays free.
40
+ <!-- /paywall -->
package/dist/api.js CHANGED
@@ -38,3 +38,163 @@ export async function paperInfo(args) {
38
38
  const d = await get(`${BASE}/paper/${encodeURIComponent(id)}?fields=title,year,abstract,authors,citationCount,referenceCount,venue,externalIds`);
39
39
  return `Title: ${d.title ?? "n/a"} (${d.year ?? "year n/a"})\nVenue: ${d.venue ?? "n/a"}\nAuthors: ${(d.authors ?? []).slice(0, 5).map((a) => a.name).join(", ") || "n/a"}\nCitations: ${d.citationCount ?? 0} | References: ${d.referenceCount ?? 0}${d.externalIds?.DOI ? ` | doi ${d.externalIds.DOI}` : ""}\n\n${(d.abstract ?? "no abstract").slice(0, 500)}`;
40
40
  }
41
+ // ---- muxE tools:
42
+ // searchPapers prints a list and paperInfo prints one paper's title, venue,
43
+ // authors, two counts, a DOI and the first 500 characters of the abstract. The
44
+ // graph API behind it also answers three other questions it is never asked: who
45
+ // is citing this paper, what this paper cites, and how the field itself sees
46
+ // the work. Those are three routes, all verified live against the public API.
47
+ const GRAPH = 'https://api.semanticscholar.org/graph/v1';
48
+ // The existing get already retries a 429 with backoff, so every call here goes
49
+ // through it rather than reaching for fetch directly.
50
+ const CITATION_FIELDS = 'title,year,authors,citationCount,externalIds,venue';
51
+ const RICH_FIELDS = 'title,year,venue,fieldsOfStudy,s2FieldsOfStudy,publicationTypes,influentialCitationCount,openAccessPdf,tldr,isOpenAccess,citationCount,referenceCount,publicationDate,journal';
52
+ const label = (p) => {
53
+ if (!p)
54
+ return 'not reported';
55
+ const t = typeof p.title === 'string' ? p.title.trim() : '';
56
+ if (!t)
57
+ return 'untitled';
58
+ return t.length > 90 ? `${t.slice(0, 90)}...` : t;
59
+ };
60
+ const authorsOf = (p, max = 3) => {
61
+ const names = Array.isArray(p?.authors) ? p.authors.map((a) => a?.name).filter(Boolean) : [];
62
+ if (!names.length)
63
+ return 'not reported';
64
+ const shown = names.slice(0, max).join(', ');
65
+ return names.length > max ? `${shown}, and ${names.length - max} more` : shown;
66
+ };
67
+ const pct = (n, total) => (total > 0 ? `${((n / total) * 100).toFixed(1)}%` : "n/a");
68
+ function requireId(raw) {
69
+ const id = String(raw ?? '').trim();
70
+ if (!id)
71
+ throw new ScholarError('Provide a paper ID, a DOI such as DOI:10.1038/nature12373, or a corpus id');
72
+ if (id.length > 200)
73
+ throw new ScholarError('That paper ID is far too long to be an id');
74
+ return encodeURIComponent(id);
75
+ }
76
+ export async function paper_citations(args) {
77
+ const id = requireId(args?.paperId);
78
+ const limit = Math.max(1, Math.min(50, Math.floor(Number(args?.limit ?? 10) || 10)));
79
+ const d = await get(`${GRAPH}/paper/${id}/citations?fields=${CITATION_FIELDS}&limit=${limit}`);
80
+ const rows = Array.isArray(d?.data) ? d.data : [];
81
+ if (!rows.length)
82
+ return 'Semantic Scholar returned no citing papers for this id.';
83
+ const papers = rows.map((r) => r?.citingPaper).filter(Boolean);
84
+ const years = papers.map((p) => Number(p.year)).filter((y) => Number.isFinite(y) && y > 0);
85
+ // This route returns offset, next and data, but no total, so the count is
86
+ // described as what came back rather than inventing a denominator.
87
+ const lines = [`Papers citing this one, from Semantic Scholar (${papers.length} returned on this page):`];
88
+ if (years.length) {
89
+ const sorted = [...years].sort((a, b) => a - b);
90
+ const byYear = new Map();
91
+ for (const y of years)
92
+ byYear.set(y, (byYear.get(y) ?? 0) + 1);
93
+ const top = [...byYear.entries()].sort((a, b) => b[1] - a[1])[0];
94
+ lines.push(` publication years run ${sorted[0]} to ${sorted[sorted.length - 1]}, and ${top[0]} is the busiest single year at ${top[1]} of ${years.length}.`);
95
+ }
96
+ // Later citing papers are the ones that matter, so they lead.
97
+ const ordered = [...papers].sort((a, b) => (Number(b.year) || 0) - (Number(a.year) || 0));
98
+ for (const p of ordered.slice(0, limit)) {
99
+ const cites = typeof p.citationCount === 'number' ? p.citationCount : 0;
100
+ lines.push(` ${p.year ?? 'year n/a'} — ${label(p)}`);
101
+ lines.push(` ${authorsOf(p)}${p.venue ? ` | ${p.venue}` : ""} | ${cites} citation${cites === 1 ? "" : "s"} of its own${p?.externalIds?.DOI ? ` | doi ${p.externalIds.DOI}` : ""}`);
102
+ }
103
+ if (ordered.length > limit)
104
+ lines.push(` and ${ordered.length - limit} further citing paper(s) not listed.`);
105
+ if (d?.next !== undefined && d.next !== null)
106
+ lines.push(` the index reports more beyond this page, next offset ${d.next}.`);
107
+ lines.push("");
108
+ lines.push(" The existing tools can only tell you how many citations a paper has, never who those citations came from.");
109
+ return lines.join("\n");
110
+ }
111
+ export async function paper_references(args) {
112
+ const id = requireId(args?.paperId);
113
+ const limit = Math.max(1, Math.min(50, Math.floor(Number(args?.limit ?? 10) || 10)));
114
+ const d = await get(`${GRAPH}/paper/${id}/references?fields=${CITATION_FIELDS}&limit=${limit}`);
115
+ const rows = Array.isArray(d?.data) ? d.data : [];
116
+ if (!rows.length)
117
+ return 'Semantic Scholar returned no references for this id.';
118
+ const papers = rows.map((r) => r?.citedPaper).filter(Boolean);
119
+ const unresolved = rows.length - papers.length;
120
+ const years = papers.map((p) => Number(p.year)).filter((y) => Number.isFinite(y) && y > 0);
121
+ const lines = [
122
+ `What this paper cites (${papers.length} reference${papers.length === 1 ? "" : "s"} returned on this page${unresolved ? `, plus ${unresolved} the index holds no record for` : ""}):`,
123
+ ];
124
+ if (years.length) {
125
+ const sorted = [...years].sort((a, b) => a - b);
126
+ lines.push(` they span ${sorted[0]} to ${sorted[sorted.length - 1]}.`);
127
+ }
128
+ // Oldest first: the question behind this is what the work was built on.
129
+ const ordered = [...papers].sort((a, b) => (Number(a.year) || 9999) - (Number(b.year) || 9999));
130
+ for (const p of ordered.slice(0, limit)) {
131
+ lines.push(` ${p.year ?? 'year n/a'} — ${label(p)}`);
132
+ lines.push(` ${authorsOf(p)}${p.venue ? ` | ${p.venue}` : ""}`);
133
+ }
134
+ if (ordered.length > limit)
135
+ lines.push(` and ${ordered.length - limit} further reference(s) not listed.`);
136
+ if (d?.next !== undefined && d.next !== null)
137
+ lines.push(` the index reports more beyond this page, next offset ${d.next}.`);
138
+ lines.push("");
139
+ lines.push(" The existing paperInfo reports a referenceCount as a bare number and cannot show any of the works behind it.");
140
+ return lines.join("\n");
141
+ }
142
+ export async function paper_field(args) {
143
+ const id = requireId(args?.paperId);
144
+ const d = await get(`${GRAPH}/paper/${id}?fields=${RICH_FIELDS}`);
145
+ if (!d || d.title === undefined)
146
+ throw new ScholarError('Semantic Scholar has no record for that id.');
147
+ const lines = [
148
+ `How the index itself reads this paper, beyond its title and abstract:`,
149
+ ` title: ${label(d)}`,
150
+ ` published: ${d.publicationDate ?? d.year ?? 'not reported'}${d.journal?.name ? ` in ${d.journal.name}` : ""}${d.venue ? ` (venue recorded as ${d.venue})` : ""}`,
151
+ ];
152
+ const fos = Array.isArray(d.fieldsOfStudy) ? d.fieldsOfStudy : [];
153
+ if (fos.length)
154
+ lines.push(` fields of study: ${fos.join(", ")}.`);
155
+ const s2 = Array.isArray(d.s2FieldsOfStudy) ? d.s2FieldsOfStudy : [];
156
+ if (s2.length) {
157
+ // The weighted form is what S2 actually uses for ranking, and it is
158
+ // different from the flat category list above.
159
+ const withWeight = s2.filter((f) => typeof f?.source === "string" || typeof f?.score === "number");
160
+ const bySource = new Map();
161
+ for (const f of s2) {
162
+ const src = f?.source ?? "unspecified";
163
+ bySource.set(src, (bySource.get(src) ?? 0) + 1);
164
+ }
165
+ lines.push(` Semantic Scholar's own classification gives ${s2.length} categories, sourced: ${[...bySource.entries()].map(([k, v]) => `${k} (${v})`).join(", ")}.`);
166
+ if (withWeight.length)
167
+ lines.push(` ${withWeight.length} of those carry a source or score, so the classification is partly derived rather than purely declared.`);
168
+ }
169
+ const types = Array.isArray(d.publicationTypes) ? d.publicationTypes : [];
170
+ lines.push(` publication types: ${types.length ? types.join(", ") : "not reported"}.`);
171
+ const infl = typeof d.influentialCitationCount === "number" ? d.influentialCitationCount : null;
172
+ const cites = typeof d.citationCount === "number" ? d.citationCount : null;
173
+ const refs = typeof d.referenceCount === "number" ? d.referenceCount : null;
174
+ if (infl !== null && cites !== null) {
175
+ lines.push(` citations: ${cites} in total, of which ${infl} (${pct(infl, cites)}) are marked influential.`);
176
+ lines.push(infl / cites > 0.3
177
+ ? ` A high influential share means this work is being built on rather than merely cited in passing.`
178
+ : infl / cites < 0.05
179
+ ? ` Very few of these citations are influential, so the work is referenced more than it is relied on.`
180
+ : ` The influential share is ordinary, so this is cited steadily rather than as a foundation.`);
181
+ }
182
+ else {
183
+ lines.push(` citations: ${cites ?? "not reported"}, influential citations: ${infl === null ? "not reported" : infl}.`);
184
+ }
185
+ if (refs !== null && cites !== null) {
186
+ lines.push(` reference list length: ${refs}.`);
187
+ }
188
+ lines.push(` open access: ${d.isOpenAccess === true ? "yes" : d.isOpenAccess === false ? "no" : "not reported"}${d.openAccessPdf?.url ? `, PDF at ${d.openAccessPdf.url}` : d.isOpenAccess === true ? ", but the index records no direct PDF link" : ""}.`);
189
+ const tldr = d.tldr?.text;
190
+ if (typeof tldr === "string" && tldr) {
191
+ lines.push(` index summary: ${tldr.length > 300 ? `${tldr.slice(0, 300)}...` : tldr}`);
192
+ lines.push(" That summary is generated by the index, not written by the authors, so treat it as a reading of the paper rather than a claim from it.");
193
+ }
194
+ else {
195
+ lines.push(" the index holds no generated summary for this paper.");
196
+ }
197
+ lines.push("");
198
+ lines.push(" The existing paperInfo asks for the abstract and two counts and prints neither of these.");
199
+ return lines.join("\n");
200
+ }
@@ -0,0 +1,79 @@
1
+ // Premium tools on this server need a license key bought from MCP Marketplace.
2
+ // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
+ // that is not listed in PREMIUM keeps working without a key.
4
+ const SLUG = "semantic-scholar-mcp";
5
+ // Tools that need a key. Everything else is free.
6
+ const PREMIUM = new Set([
7
+ "paper_citations",
8
+ "paper_field",
9
+ ]);
10
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
11
+ // The license check calls the marketplace's verify endpoint directly instead of
12
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
13
+ // check it makes is rejected by Supabase before it reaches the function. The
14
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
15
+ // own JavaScript and only ever reaches their licence check.
16
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
17
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
18
+ const OK_CACHE_MS = 60 * 60 * 1000;
19
+ const BAD_CACHE_MS = 60 * 1000;
20
+ const seen = new Map();
21
+ const REASONS = {
22
+ missing_key: "no key is set",
23
+ invalid_format: "that key is not a valid MCP Marketplace key",
24
+ not_found: "that key was not found",
25
+ revoked: "that key has been revoked",
26
+ rotated: "that key was rotated, use your new one",
27
+ expired: "that key has expired, renew it",
28
+ rate_limited: "there were too many checks just now, try again shortly",
29
+ network_error: "the license server could not be reached",
30
+ };
31
+ function blocked(tool, reason) {
32
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`;
33
+ return `"${tool}" needs a license key, but ${why}. ` +
34
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
35
+ }
36
+ async function verify(key) {
37
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
38
+ method: "POST",
39
+ headers: {
40
+ apikey: PUBLISHABLE_KEY,
41
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify({ key, slug: SLUG }),
45
+ // an unusable key answers 400 with a JSON body, so read the body either way
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ const data = (await res.json());
49
+ if (typeof data?.valid !== "boolean")
50
+ return { valid: false, reason: "unexpected_response" };
51
+ return data;
52
+ }
53
+ /**
54
+ * Returns null when the caller may run the tool, or a short message telling
55
+ * them how to get a key.
56
+ *
57
+ * A good key is remembered for an hour, so a busy session does not hit the
58
+ * license server on every call and keeps working through a brief outage.
59
+ */
60
+ export async function premiumRequired(tool) {
61
+ if (!PREMIUM.has(tool))
62
+ return null;
63
+ const key = process.env.MCP_LICENSE_KEY;
64
+ if (!key)
65
+ return blocked(tool, "missing_key");
66
+ const hit = seen.get(key);
67
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
68
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
69
+ }
70
+ let result;
71
+ try {
72
+ result = await verify(key);
73
+ }
74
+ catch {
75
+ result = { valid: false, reason: "network_error" };
76
+ }
77
+ seen.set(key, { ...result, at: Date.now() });
78
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid");
79
+ }
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { paperInfo } from "./api.js";
4
- import { searchPapers } from "./api.js";
4
+ import { searchPapers, paper_citations, paper_references, paper_field } from "./api.js";
5
+ import { premiumRequired } from "./license.js";
5
6
  const text = (value) => ({ content: [{ type: "text", text: value }] });
6
7
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
7
8
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -34,5 +35,62 @@ export function createServer() {
34
35
  return textError(error(e));
35
36
  }
36
37
  });
38
+ server.registerTool("paper_citations", {
39
+ title: "Paper citations",
40
+ description: "List the papers that cite a given paper, most recent first, with their year, authors, venue, own citation count and DOI, plus the span of years citing work appears in and the busiest single year. The existing tools report a citation count as a bare number and can never say who the citations came from.",
41
+ inputSchema: z.object({
42
+ paperId: z.string().describe("Paper id, for instance 'DOI:10.1038/nature12373'. Search with search_papers first."),
43
+ limit: z.number().int().min(1).max(50).optional().describe("How many citing papers to list. Default 10."),
44
+ }),
45
+ annotations: READ_ONLY,
46
+ }, async ({ paperId, limit }) => {
47
+ // ---- paywall: paper_citations ----
48
+ const paywallMessage = await premiumRequired("paper_citations");
49
+ if (paywallMessage)
50
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
51
+ // ---- paywall: end ----
52
+ try {
53
+ return text(await paper_citations({ paperId, limit }));
54
+ }
55
+ catch (e) {
56
+ return textError(error(e));
57
+ }
58
+ });
59
+ server.registerTool("paper_references", {
60
+ title: "Paper references",
61
+ description: "List the works a given paper cites, oldest first, with year, authors and venue, and say how many references the index could not resolve. This is the backward view: what a paper was built on. paperInfo reports a referenceCount as a number and cannot show any of the works behind it.",
62
+ inputSchema: z.object({
63
+ paperId: z.string().describe("Paper id, for instance 'DOI:10.1038/nature12373'."),
64
+ limit: z.number().int().min(1).max(50).optional().describe("How many references to list. Default 10."),
65
+ }),
66
+ annotations: READ_ONLY,
67
+ }, async ({ paperId, limit }) => {
68
+ try {
69
+ return text(await paper_references({ paperId, limit }));
70
+ }
71
+ catch (e) {
72
+ return textError(error(e));
73
+ }
74
+ });
75
+ server.registerTool("paper_field", {
76
+ title: "Paper field placement",
77
+ description: "Report how the Semantic Scholar index itself reads a paper: its fields of study and the index's own weighted categories, publication types, total against influential citations with the share and what that ratio means, reference list length, open access status and PDF link, and the index's generated summary with a warning that it is not the authors' own words. paperInfo prints the abstract and two counts and none of this.",
78
+ inputSchema: z.object({
79
+ paperId: z.string().describe("Paper id, for instance 'DOI:10.1038/nature12373'."),
80
+ }),
81
+ annotations: READ_ONLY,
82
+ }, async ({ paperId }) => {
83
+ // ---- paywall: paper_field ----
84
+ const paywallMessage = await premiumRequired("paper_field");
85
+ if (paywallMessage)
86
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
87
+ // ---- paywall: end ----
88
+ try {
89
+ return text(await paper_field({ paperId }));
90
+ }
91
+ catch (e) {
92
+ return textError(error(e));
93
+ }
94
+ });
37
95
  return server;
38
96
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0",
2
+ "version": "1.0.1",
3
3
  "type": "module",
4
4
  "repository": {
5
5
  "type": "git",