@pipeworx/mcp-openalex 0.1.1 → 0.1.3
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 +3 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +403 -2
- package/src/server.ts +5 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
OpenAlex is a free, open replacement for Microsoft Academic Graph (which shut down in 2022). 240M+ scholarly works with structured data on authors, institutions, concepts, citations, and venues. Open-source data model, generous API. Free, no auth (polite User-Agent + email recommended).
|
|
4
4
|
|
|
5
|
-
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1856+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
6
6
|
|
|
7
7
|
## Why this matters for AI agents
|
|
8
8
|
|
|
@@ -13,6 +13,7 @@ Common flows:
|
|
|
13
13
|
- **Work lookup.** Find a paper by DOI, title, or OpenAlex ID; get full structured record.
|
|
14
14
|
- **Author / institution.** Search Yale's CS department's papers in 2024.
|
|
15
15
|
- **Concept browsing.** Papers tagged with "transformer architecture" or "CRISPR Cas9."
|
|
16
|
+
- **Journal publishing model.** Is a journal subscription, hybrid or open access? Look up by ISSN (print or electronic).
|
|
16
17
|
- **Citation graph.** "Who cites paper X?" or "What does paper X cite?"
|
|
17
18
|
|
|
18
19
|
Citable URI: `pipeworx://openalex/work/{work_id}`.
|
|
@@ -88,7 +89,7 @@ directly, instead of just this one's:
|
|
|
88
89
|
}
|
|
89
90
|
```
|
|
90
91
|
|
|
91
|
-
Both URLs reach the same gateway and the same
|
|
92
|
+
Both URLs reach the same gateway and the same 1856+ data sources. The
|
|
92
93
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
93
94
|
reaches all of them from either one.
|
|
94
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-openalex",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "OpenAlex MCP — wraps the OpenAlex API (scholarly works, free, no auth)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@cloudflare/workers-types": "^4.20260405.1"
|
|
27
27
|
},
|
|
28
28
|
"pipeworx": {
|
|
29
|
-
"sourceHash": "v1-
|
|
30
|
-
"sourceCommit": "
|
|
29
|
+
"sourceHash": "v1-fea2775573e101baa0697fbdc5db229d0a226c6dcc6923c85474ea8020edeec3",
|
|
30
|
+
"sourceCommit": "f54ad77e789f27fe393978f9c080b5b18c87bb4f"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.pipeworx-io/openalex",
|
|
4
4
|
"title": "Openalex",
|
|
5
5
|
"description": "OpenAlex MCP — wraps the OpenAlex API (scholarly works, free, no auth)",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.3",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/openalex",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-openalex",
|
package/src/index.ts
CHANGED
|
@@ -295,12 +295,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
|
|
|
295
295
|
// internal-host-class.ts; no-op for every third-party upstream, which is why
|
|
296
296
|
// this touches 481 packs' error text and changes none of it.
|
|
297
297
|
return markInternalOrigin(
|
|
298
|
-
`${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
|
|
298
|
+
`${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
|
|
299
299
|
res.url,
|
|
300
300
|
res.status,
|
|
301
301
|
);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Facts about a FAILED response that live in its headers, not its body, appended
|
|
306
|
+
* so the gateway's classifier (which only ever sees this string) can tell a limit
|
|
307
|
+
* from a wall from a refusal (fleet #3477). Both are standard, both are absent on
|
|
308
|
+
* almost every response, and neither is invented: `Retry-After` is the server
|
|
309
|
+
* saying "come back later" (the one thing that makes a 403 a throttle), and
|
|
310
|
+
* Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
|
|
311
|
+
*/
|
|
312
|
+
function responseHints(res: Response): string {
|
|
313
|
+
const hints: string[] = [];
|
|
314
|
+
const retryAfter = res.headers.get('retry-after');
|
|
315
|
+
if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
|
|
316
|
+
if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
|
|
317
|
+
return hints.length ? ` (${hints.join('; ')})` : '';
|
|
318
|
+
}
|
|
319
|
+
|
|
304
320
|
/**
|
|
305
321
|
* Just the upstream's own explanation — no name, no status.
|
|
306
322
|
*
|
|
@@ -452,7 +468,42 @@ async function fetchWithTimeout(
|
|
|
452
468
|
),
|
|
453
469
|
);
|
|
454
470
|
}
|
|
455
|
-
|
|
471
|
+
// Fleet #2382. Everything that isn't a timeout/abort here is a genuine
|
|
472
|
+
// NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
|
|
473
|
+
// Cloudflare's own "Network connection lost." — meaning `fetch()` itself
|
|
474
|
+
// threw and no HTTP response of any kind was ever received. Until this fix
|
|
475
|
+
// that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
|
|
476
|
+
// (or the Workers-runtime equivalent) names no upstream, carries no class
|
|
477
|
+
// token, and reads exactly like a defect in OUR code — because it says
|
|
478
|
+
// nothing about the call at all. It landed in `error`, the tier that means
|
|
479
|
+
// "Pipeworx has a defect", for every one of the (at the time of writing)
|
|
480
|
+
// ~470 packs that call this helper directly with no wrapper of their own.
|
|
481
|
+
//
|
|
482
|
+
// `dexscreener` hit this independently (fleet #1579) and fixed it with a
|
|
483
|
+
// bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
|
|
484
|
+
// but only covers one pack; every other caller of this shared helper still
|
|
485
|
+
// leaked the raw exception. Moving the same fix HERE — the one place that
|
|
486
|
+
// already carries the timeout case — covers every pack that uses
|
|
487
|
+
// `fetchWithTimeout` without a wrapper, for free, and without widening
|
|
488
|
+
// `classifyToolError`'s regex list: the fix is giving the message a proper
|
|
489
|
+
// `upstream_down:` token at the point the two facts (no response was ever
|
|
490
|
+
// received, and which host we were trying to reach) are actually in hand,
|
|
491
|
+
// not teaching the classifier to guess from prose after the fact.
|
|
492
|
+
//
|
|
493
|
+
// Safe on the same grounds as the timeout branch above: no argument a
|
|
494
|
+
// caller passes can make `fetch()` itself throw a connection-level error,
|
|
495
|
+
// so this is always an availability failure, never a caller mistake. Same
|
|
496
|
+
// `markInternalOrigin` treatment — an origin we run that never answered is
|
|
497
|
+
// still ours, not a third party's outage.
|
|
498
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
499
|
+
throw new Error(
|
|
500
|
+
markInternalOrigin(
|
|
501
|
+
`upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
|
|
502
|
+
`No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
|
|
503
|
+
'are valid — do not re-check them on the strength of this error. Retry shortly.',
|
|
504
|
+
url,
|
|
505
|
+
),
|
|
506
|
+
);
|
|
456
507
|
}
|
|
457
508
|
}
|
|
458
509
|
|
|
@@ -635,6 +686,254 @@ function pickMessage(node: unknown, depth: number): string | null {
|
|
|
635
686
|
function collapse(s: string): string {
|
|
636
687
|
return s.replace(/\s+/g, ' ').trim();
|
|
637
688
|
}
|
|
689
|
+
|
|
690
|
+
// ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
|
|
691
|
+
//
|
|
692
|
+
// `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
|
|
693
|
+
// multi-megabyte upstream body is held TWICE at peak: once as the raw string
|
|
694
|
+
// and once as the object graph (which for an array of small objects runs
|
|
695
|
+
// several times the byte size). Nothing in `workers/gateway` can see that
|
|
696
|
+
// peak — it happens inside the pack, before any gateway code holds a payload —
|
|
697
|
+
// which is why the gateway-side payload caps of #3293 and #3320 did not move
|
|
698
|
+
// the 503/1102 rate at all.
|
|
699
|
+
//
|
|
700
|
+
// Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
|
|
701
|
+
// statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
|
|
702
|
+
// gnomad_gene BRCA1 2,732,706 B
|
|
703
|
+
// Five of those in flight at the quality runner's width is ~40 MB of parse
|
|
704
|
+
// peak against a 128 MB isolate; Cloudflare kills the isolate and every
|
|
705
|
+
// request in flight returns 503/1102, which is why the victims look random.
|
|
706
|
+
//
|
|
707
|
+
// `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
|
|
708
|
+
// returns one enormous top-level JSON array with no server-side paging. It
|
|
709
|
+
// parses elements off the stream and DISCARDS the consumed bytes, so peak
|
|
710
|
+
// retained memory is one element plus one chunk rather than the whole body,
|
|
711
|
+
// and it cancels the body as soon as it has what it was asked for.
|
|
712
|
+
|
|
713
|
+
/** Default ceiling for a single upstream JSON body read into an isolate.
|
|
714
|
+
* Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
|
|
715
|
+
* below the point where a handful of concurrent reads exhausts 128 MB. */
|
|
716
|
+
const DEFAULT_MAX_JSON_BYTES = 1_500_000;
|
|
717
|
+
|
|
718
|
+
interface JsonArrayPrefix<T> {
|
|
719
|
+
/** The decoded elements, at most `maxItems` of them. */
|
|
720
|
+
items: T[];
|
|
721
|
+
/** True when the upstream array had more elements than we read. */
|
|
722
|
+
truncated: boolean;
|
|
723
|
+
/** Bytes actually pulled off the socket — the number that bounds memory. */
|
|
724
|
+
bytesRead: number;
|
|
725
|
+
/** `Content-Length` if the upstream declared one, else null. */
|
|
726
|
+
declaredBytes: number | null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Read the FIRST `maxItems` elements of a top-level JSON array off a response
|
|
731
|
+
* stream without ever materialising the whole body.
|
|
732
|
+
*
|
|
733
|
+
* Use this instead of `parseJson` when the upstream returns one big array and
|
|
734
|
+
* offers no `limit`/`offset` of its own. Peak retained memory is one element
|
|
735
|
+
* plus the current chunk; the body is cancelled as soon as `maxItems` is
|
|
736
|
+
* reached, so the remaining megabytes are never transferred at all.
|
|
737
|
+
*
|
|
738
|
+
* const { items, truncated } = await readJsonArrayPrefix<Cube>(
|
|
739
|
+
* res, 'StatCan WDS', { maxItems: limit + offset });
|
|
740
|
+
*
|
|
741
|
+
* `maxBytes` is a second, independent backstop for a pathological element (a
|
|
742
|
+
* single 50 MB object): hitting it stops the read and sets `truncated`, rather
|
|
743
|
+
* than throwing, because a partial answer plus an explicit truncated flag is
|
|
744
|
+
* more useful to a caller than an error.
|
|
745
|
+
*/
|
|
746
|
+
async function readJsonArrayPrefix<T>(
|
|
747
|
+
res: Response,
|
|
748
|
+
name: string,
|
|
749
|
+
opts: { maxItems: number; maxBytes?: number },
|
|
750
|
+
): Promise<JsonArrayPrefix<T>> {
|
|
751
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
752
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
753
|
+
const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
754
|
+
|
|
755
|
+
// No streaming body available (a mock, or an already-buffered response):
|
|
756
|
+
// fall back to the whole-text path so behaviour stays correct, just unbounded.
|
|
757
|
+
if (!res.body) {
|
|
758
|
+
const all = await parseJson<T[]>(res, name);
|
|
759
|
+
if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
|
|
760
|
+
return {
|
|
761
|
+
items: all.slice(0, opts.maxItems),
|
|
762
|
+
truncated: all.length > opts.maxItems,
|
|
763
|
+
bytesRead: declaredBytes ?? 0,
|
|
764
|
+
declaredBytes,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const reader = res.body.getReader();
|
|
769
|
+
const decoder = new TextDecoder();
|
|
770
|
+
const items: T[] = [];
|
|
771
|
+
|
|
772
|
+
let buf = '';
|
|
773
|
+
let scan = 0;
|
|
774
|
+
let bytesRead = 0;
|
|
775
|
+
let started = false; // consumed the opening '['
|
|
776
|
+
let arrayEnded = false;
|
|
777
|
+
let depth = 0;
|
|
778
|
+
let inStr = false;
|
|
779
|
+
let esc = false;
|
|
780
|
+
let elemStart = -1;
|
|
781
|
+
let truncated = false;
|
|
782
|
+
|
|
783
|
+
const push = (text: string) => {
|
|
784
|
+
const t = text.trim();
|
|
785
|
+
if (!t) return;
|
|
786
|
+
items.push(JSON.parse(t) as T);
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
outer: while (items.length < opts.maxItems && !arrayEnded) {
|
|
791
|
+
const { value, done } = await reader.read();
|
|
792
|
+
if (done) break;
|
|
793
|
+
bytesRead += value.byteLength;
|
|
794
|
+
buf += decoder.decode(value, { stream: true });
|
|
795
|
+
if (bytesRead > maxBytes) { truncated = true; break; }
|
|
796
|
+
|
|
797
|
+
for (; scan < buf.length; scan++) {
|
|
798
|
+
const ch = buf[scan];
|
|
799
|
+
|
|
800
|
+
if (inStr) {
|
|
801
|
+
if (esc) { esc = false; continue; }
|
|
802
|
+
if (ch === '\\') { esc = true; continue; }
|
|
803
|
+
if (ch === '"') inStr = false;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (!started) {
|
|
808
|
+
if (ch === '[') { started = true; continue; }
|
|
809
|
+
if (ch.trim() === '') continue;
|
|
810
|
+
throw new Error(
|
|
811
|
+
`upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
|
|
812
|
+
'This reader is only for upstreams that answer with one top-level array.',
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
|
|
817
|
+
|
|
818
|
+
if (ch === '{' || ch === '[') {
|
|
819
|
+
if (depth === 0 && elemStart < 0) elemStart = scan;
|
|
820
|
+
depth++;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (ch === '}' || ch === ']') {
|
|
825
|
+
if (depth > 0) {
|
|
826
|
+
depth--;
|
|
827
|
+
if (depth === 0 && elemStart >= 0) {
|
|
828
|
+
push(buf.slice(elemStart, scan + 1));
|
|
829
|
+
elemStart = -1;
|
|
830
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
831
|
+
}
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
// depth 0 and a closing bracket: the end of the top-level array.
|
|
835
|
+
if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
|
|
836
|
+
arrayEnded = true;
|
|
837
|
+
break outer;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (ch === ',' && depth === 0) {
|
|
841
|
+
if (elemStart >= 0) {
|
|
842
|
+
push(buf.slice(elemStart, scan));
|
|
843
|
+
elemStart = -1;
|
|
844
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
845
|
+
}
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// A bare scalar element (number / true / false / null).
|
|
850
|
+
if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Drop everything already consumed. THIS is what bounds memory: without
|
|
854
|
+
// it `buf` grows to the full body and the reader is streaming in name only.
|
|
855
|
+
if (elemStart >= 0) {
|
|
856
|
+
buf = buf.slice(elemStart);
|
|
857
|
+
scan -= elemStart;
|
|
858
|
+
elemStart = 0;
|
|
859
|
+
} else {
|
|
860
|
+
buf = buf.slice(scan);
|
|
861
|
+
scan = 0;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
} finally {
|
|
865
|
+
// Cancel rather than drain: the point is that the remaining megabytes are
|
|
866
|
+
// never transferred. A cancel on an already-finished body is a no-op.
|
|
867
|
+
await reader.cancel().catch(() => {});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { items, truncated, bytesRead, declaredBytes };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* `parseJson`, with a hard ceiling on how many bytes are read.
|
|
875
|
+
*
|
|
876
|
+
* For an upstream whose body is not a single array (so `readJsonArrayPrefix`
|
|
877
|
+
* does not apply) but which can still answer with megabytes. Unlike the array
|
|
878
|
+
* reader this one THROWS when the cap is hit, because a truncated prefix of an
|
|
879
|
+
* arbitrary JSON document is not parseable and there is no partial answer to
|
|
880
|
+
* hand back. A declared `Content-Length` over the cap is refused before a
|
|
881
|
+
* single chunk is read.
|
|
882
|
+
*/
|
|
883
|
+
async function parseJsonBounded<T>(
|
|
884
|
+
res: Response,
|
|
885
|
+
name: string,
|
|
886
|
+
maxBytes: number = DEFAULT_MAX_JSON_BYTES,
|
|
887
|
+
): Promise<T> {
|
|
888
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
889
|
+
const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
890
|
+
|
|
891
|
+
const tooBig = (n: number) =>
|
|
892
|
+
new Error(
|
|
893
|
+
`upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
|
|
894
|
+
'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
|
|
895
|
+
'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
|
|
896
|
+
'a specific id, or a lower limit) and retry.',
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
if (declared !== null && declared > maxBytes) {
|
|
900
|
+
await res.body?.cancel().catch(() => {});
|
|
901
|
+
throw tooBig(declared);
|
|
902
|
+
}
|
|
903
|
+
if (!res.body) return parseJson<T>(res, name);
|
|
904
|
+
|
|
905
|
+
const reader = res.body.getReader();
|
|
906
|
+
const decoder = new TextDecoder();
|
|
907
|
+
let raw = '';
|
|
908
|
+
let bytesRead = 0;
|
|
909
|
+
try {
|
|
910
|
+
for (;;) {
|
|
911
|
+
const { value, done } = await reader.read();
|
|
912
|
+
if (done) break;
|
|
913
|
+
bytesRead += value.byteLength;
|
|
914
|
+
if (bytesRead > maxBytes) throw tooBig(bytesRead);
|
|
915
|
+
raw += decoder.decode(value, { stream: true });
|
|
916
|
+
}
|
|
917
|
+
raw += decoder.decode();
|
|
918
|
+
} finally {
|
|
919
|
+
await reader.cancel().catch(() => {});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!raw.trim()) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
`upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
|
|
925
|
+
'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
return JSON.parse(raw) as T;
|
|
930
|
+
} catch {
|
|
931
|
+
throw new Error(
|
|
932
|
+
`upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
|
|
933
|
+
`It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
638
937
|
/**
|
|
639
938
|
* OpenAlex MCP — wraps the OpenAlex API (scholarly works, free, no auth)
|
|
640
939
|
*
|
|
@@ -892,6 +1191,20 @@ const tools: McpToolExport['tools'] = [
|
|
|
892
1191
|
required: ['id'],
|
|
893
1192
|
},
|
|
894
1193
|
},
|
|
1194
|
+
{
|
|
1195
|
+
name: 'get_journal_publishing_model',
|
|
1196
|
+
description:
|
|
1197
|
+
'Is a journal subscription, hybrid or fully open access? Look up a journal by ISSN (print or electronic, e.g. "0025-1461" or "1864-1547") and get its publisher, article-processing charge, whether the Directory of Open Access Journals lists it, and the open-access status mix of its recent articles (gold, diamond, hybrid, bronze, green, closed), with a derived model label: open_access, hybrid or subscription. The label is inferred from article-level evidence, not declared by the publisher.',
|
|
1198
|
+
inputSchema: {
|
|
1199
|
+
type: 'object',
|
|
1200
|
+
properties: {
|
|
1201
|
+
issn: { type: 'string', description: 'Journal ISSN, print or electronic, e.g. "1864-1547"' },
|
|
1202
|
+
years: { type: 'number', description: 'Look-back window in years for the article mix (1-15, default 5)' },
|
|
1203
|
+
_apiKey: { type: 'string', description: 'OpenAlex API key. Optional — Pipeworx supplies one; pass your own to bill your account instead.' },
|
|
1204
|
+
},
|
|
1205
|
+
required: ['issn'],
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
895
1208
|
];
|
|
896
1209
|
|
|
897
1210
|
// ── Tool Implementations ──────────────────────────────────────────────
|
|
@@ -1017,6 +1330,92 @@ async function getWork(id: string, apiKey?: string) {
|
|
|
1017
1330
|
};
|
|
1018
1331
|
}
|
|
1019
1332
|
|
|
1333
|
+
async function getJournalPublishingModel(issnRaw: string, years: number, apiKey?: string) {
|
|
1334
|
+
const issn = String(issnRaw ?? '').trim().toUpperCase().replace(/^ISSN\s*/, '');
|
|
1335
|
+
if (!/^\d{4}-?\d{3}[\dX]$/.test(issn)) {
|
|
1336
|
+
throw new Error(`"issn" must look like "1864-1547" (8 digits, optional hyphen, last may be X). Got "${issnRaw}".`);
|
|
1337
|
+
}
|
|
1338
|
+
const canon = issn.includes('-') ? issn : `${issn.slice(0, 4)}-${issn.slice(4)}`;
|
|
1339
|
+
const win = Math.min(15, Math.max(1, Math.round(years)));
|
|
1340
|
+
|
|
1341
|
+
const sres = await openAlexFetch(`/sources/issn:${canon}`, new URLSearchParams(), apiKey);
|
|
1342
|
+
if (sres.status === 404) throw new Error(`No journal found for ISSN ${canon}. Check the number, or try the other (print/electronic) ISSN.`);
|
|
1343
|
+
if (!sres.ok) throw await httpError(sres, 'OpenAlex source lookup error');
|
|
1344
|
+
const src = (await sres.json()) as Record<string, any>;
|
|
1345
|
+
const sid = String(src.id ?? '').split('/').pop();
|
|
1346
|
+
|
|
1347
|
+
const toYear = new Date().getUTCFullYear();
|
|
1348
|
+
const gres = await openAlexFetch(
|
|
1349
|
+
'/works',
|
|
1350
|
+
new URLSearchParams({
|
|
1351
|
+
filter: `primary_location.source.id:${sid},publication_year:${toYear - win + 1}-${toYear}`,
|
|
1352
|
+
group_by: 'open_access.oa_status',
|
|
1353
|
+
}),
|
|
1354
|
+
apiKey,
|
|
1355
|
+
);
|
|
1356
|
+
if (!gres.ok) throw await httpError(gres, 'OpenAlex works status mix error');
|
|
1357
|
+
const gdata = (await gres.json()) as { group_by?: Array<{ key: string; count: number }> };
|
|
1358
|
+
const mix: Record<string, number> = {};
|
|
1359
|
+
let total = 0;
|
|
1360
|
+
for (const g of gdata.group_by ?? []) {
|
|
1361
|
+
mix[g.key] = g.count;
|
|
1362
|
+
total += g.count;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// DOAJ is the authority for "fully open access". It is consulted best-effort:
|
|
1366
|
+
// an outage is reported as 'unavailable', never as 'not_listed'.
|
|
1367
|
+
let doaj: 'listed' | 'not_listed' | 'unavailable' = 'unavailable';
|
|
1368
|
+
try {
|
|
1369
|
+
const d = await pwFetch(`https://doaj.org/api/v3/search/journals/${encodeURIComponent('issn:' + canon)}?pageSize=1`, { headers: { Accept: 'application/json' } });
|
|
1370
|
+
if (d.ok) {
|
|
1371
|
+
const dj = (await d.json()) as { total?: number };
|
|
1372
|
+
doaj = (dj.total ?? 0) > 0 ? 'listed' : 'not_listed';
|
|
1373
|
+
}
|
|
1374
|
+
} catch {
|
|
1375
|
+
/* leave unavailable */
|
|
1376
|
+
}
|
|
1377
|
+
const inDoaj = doaj === 'listed' || src.is_in_doaj === true;
|
|
1378
|
+
|
|
1379
|
+
const notListed = doaj === 'unavailable' ? 'Not flagged as DOAJ-listed by OpenAlex (the DOAJ directory itself was unreachable)' : 'Not DOAJ-listed';
|
|
1380
|
+
const openish = (mix.gold ?? 0) + (mix.diamond ?? 0);
|
|
1381
|
+
const hybrid = mix.hybrid ?? 0;
|
|
1382
|
+
let model: 'open_access' | 'hybrid' | 'subscription' | 'unknown';
|
|
1383
|
+
let basis: string;
|
|
1384
|
+
if (inDoaj || src.is_oa === true) {
|
|
1385
|
+
model = 'open_access';
|
|
1386
|
+
basis = inDoaj ? 'Listed in the Directory of Open Access Journals.' : 'Publisher-level open-access flag set in OpenAlex.';
|
|
1387
|
+
} else if (total === 0) {
|
|
1388
|
+
model = 'unknown';
|
|
1389
|
+
basis = `No articles indexed in the last ${win} years to judge from.`;
|
|
1390
|
+
} else if (hybrid + openish > 0) {
|
|
1391
|
+
model = 'hybrid';
|
|
1392
|
+
basis = `${notListed}, but ${hybrid + openish} of ${total} articles in the last ${win} years were openly available through the publisher.`;
|
|
1393
|
+
} else {
|
|
1394
|
+
model = 'subscription';
|
|
1395
|
+
basis = `${notListed} and none of ${total} articles in the last ${win} years were open at the publisher (only closed, bronze or green copies).`;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
return {
|
|
1399
|
+
issn: canon,
|
|
1400
|
+
journal: src.display_name ?? null,
|
|
1401
|
+
issn_l: src.issn_l ?? null,
|
|
1402
|
+
issns: src.issn ?? [],
|
|
1403
|
+
publisher: src.host_organization_name ?? null,
|
|
1404
|
+
publishing_model: model,
|
|
1405
|
+
basis,
|
|
1406
|
+
doaj_listed: doaj,
|
|
1407
|
+
openalex_flag_is_oa: src.is_oa ?? null,
|
|
1408
|
+
apc: { usd: src.apc_usd ?? null, listed: src.apc_prices ?? [] },
|
|
1409
|
+
articles_window: `${toYear - win + 1}-${toYear}`,
|
|
1410
|
+
articles_total: total,
|
|
1411
|
+
articles_by_oa_status: mix,
|
|
1412
|
+
homepage_url: src.homepage_url ?? null,
|
|
1413
|
+
data_as_of: src.updated_date ?? null,
|
|
1414
|
+
source: 'OpenAlex, sources and works open-access status; DOAJ, Directory of Open Access Journals',
|
|
1415
|
+
caveat: 'Model label is inferred from article-level open-access status and DOAJ listing; the publisher\'s own current policy page is authoritative.',
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1020
1419
|
// ── Dispatcher ────────────────────────────────────────────────────────
|
|
1021
1420
|
|
|
1022
1421
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
@@ -1032,6 +1431,8 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
1032
1431
|
return getConcept(args.query as string, apiKey);
|
|
1033
1432
|
case 'get_scholarly_work':
|
|
1034
1433
|
return getWork(args.id as string, apiKey);
|
|
1434
|
+
case 'get_journal_publishing_model':
|
|
1435
|
+
return getJournalPublishingModel(args.issn as string, (args.years as number) ?? 5, apiKey);
|
|
1035
1436
|
default:
|
|
1036
1437
|
throw new Error(`Unknown tool: ${name}`);
|
|
1037
1438
|
}
|
package/src/server.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-openalex', version: '0.1.
|
|
13
|
+
{ name: '@pipeworx/mcp-openalex', version: '0.1.3' },
|
|
13
14
|
{ capabilities: { tools: {} } },
|
|
14
15
|
);
|
|
15
16
|
|
|
@@ -24,7 +25,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
24
25
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
26
|
const { name, arguments: args } = request.params;
|
|
26
27
|
try {
|
|
27
|
-
const
|
|
28
|
+
const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
|
|
29
|
+
|
|
30
|
+
const result = await pack.callTool(name, callArgs);
|
|
28
31
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
32
|
} catch (err) {
|
|
30
33
|
return {
|