@cyanheads/openalex-mcp-server 0.7.16 → 0.8.0
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/AGENTS.md +32 -30
- package/CLAUDE.md +32 -30
- package/Dockerfile +88 -32
- package/README.md +6 -2
- package/changelog/0.8.x/0.8.0.md +38 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +23 -6
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +21 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +42 -42
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +35 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +110 -62
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts +12 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +23 -8
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +159 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +480 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- package/dist/services/openalex/openalex-service.d.ts +13 -0
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +46 -34
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/provider-text.d.ts +12 -5
- package/dist/services/openalex/provider-text.d.ts.map +1 -1
- package/dist/services/openalex/provider-text.js +357 -33
- package/dist/services/openalex/provider-text.js.map +1 -1
- package/dist/services/openalex/types.d.ts +1 -1
- package/dist/services/openalex/types.js +1 -1
- package/package.json +8 -7
- package/server.json +3 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Response-size budget for the tools that return OpenAlex entity records
|
|
3
|
+
* (`openalex_search_entities`, `openalex_get_citation_graph`). Holds each surface — serialized
|
|
4
|
+
* `structuredContent` and the summed `content[]` text, enrichment trailer included — to
|
|
5
|
+
* 64,000 bytes: a list page loses whole records from its end, a record too large on its own has
|
|
6
|
+
* its biggest arrays windowed, and every cut is disclosed with the call that continues it. The
|
|
7
|
+
* same pass flags list records carrying exactly 100 authorships, OpenAlex's list-response cap.
|
|
8
|
+
* @module mcp-server/tools/response-budget
|
|
9
|
+
*/
|
|
10
|
+
import { z } from '@cyanheads/mcp-ts-core';
|
|
11
|
+
import type { EntityRecord, EntityType } from '../../services/openalex/types.js';
|
|
12
|
+
/** Per-surface ceiling: serialized `structuredContent`, and summed `content[]` text. */
|
|
13
|
+
export declare const RESPONSE_BUDGET_BYTES = 64000;
|
|
14
|
+
/**
|
|
15
|
+
* Bytes reserved on each surface for the `budget` enrichment field. The service writes it from
|
|
16
|
+
* OpenAlex's rate-limit headers through `ctx.enrich`, so its value is not in the handler's hands
|
|
17
|
+
* when the page is measured. The allowance covers its widest realistic form — every number at
|
|
18
|
+
* full double precision, with a prepaid balance — measured at 176 bytes as a JSON member and 108
|
|
19
|
+
* as a trailer line with its separator.
|
|
20
|
+
*/
|
|
21
|
+
export declare const BUDGET_FIELD_ALLOWANCE_BYTES = 256;
|
|
22
|
+
declare const arrayWindowSchema: z.ZodObject<{
|
|
23
|
+
id: z.ZodString;
|
|
24
|
+
field: z.ZodString;
|
|
25
|
+
offset: z.ZodNumber;
|
|
26
|
+
shown: z.ZodNumber;
|
|
27
|
+
total: z.ZodNumber;
|
|
28
|
+
possibly_capped: z.ZodBoolean;
|
|
29
|
+
next: z.ZodNullable<z.ZodObject<{
|
|
30
|
+
tool: z.ZodLiteral<"openalex_search_entities">;
|
|
31
|
+
arguments: z.ZodObject<{}, z.core.$loose>;
|
|
32
|
+
}, z.core.$strip>>;
|
|
33
|
+
}, z.core.$strip>;
|
|
34
|
+
/** Output field: whole records the budget cut from a list page, and the call returning them. */
|
|
35
|
+
export declare const omittedOutputSchema: z.ZodOptional<z.ZodObject<{
|
|
36
|
+
ids: z.ZodArray<z.ZodString>;
|
|
37
|
+
next: z.ZodObject<{
|
|
38
|
+
tool: z.ZodLiteral<"openalex_search_entities">;
|
|
39
|
+
arguments: z.ZodObject<{}, z.core.$loose>;
|
|
40
|
+
}, z.core.$strip>;
|
|
41
|
+
}, z.core.$strip>>;
|
|
42
|
+
/** Output field: arrays returned in part, or possibly in part, each with its continuation. */
|
|
43
|
+
export declare const windowsOutputSchema: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
44
|
+
id: z.ZodString;
|
|
45
|
+
field: z.ZodString;
|
|
46
|
+
offset: z.ZodNumber;
|
|
47
|
+
shown: z.ZodNumber;
|
|
48
|
+
total: z.ZodNumber;
|
|
49
|
+
possibly_capped: z.ZodBoolean;
|
|
50
|
+
next: z.ZodNullable<z.ZodObject<{
|
|
51
|
+
tool: z.ZodLiteral<"openalex_search_entities">;
|
|
52
|
+
arguments: z.ZodObject<{}, z.core.$loose>;
|
|
53
|
+
}, z.core.$strip>>;
|
|
54
|
+
}, z.core.$strip>>>;
|
|
55
|
+
/** Output field: the least this call can return does not fit the budget on its own. */
|
|
56
|
+
export declare const overBudgetOutputSchema: z.ZodOptional<z.ZodBoolean>;
|
|
57
|
+
type ArrayWindow = z.infer<typeof arrayWindowSchema>;
|
|
58
|
+
type Omitted = NonNullable<z.infer<typeof omittedOutputSchema>>;
|
|
59
|
+
/** The disclosure fields a budgeted page carries, each present only when it has something to say. */
|
|
60
|
+
export interface BudgetDisclosure {
|
|
61
|
+
omitted?: Omitted | undefined;
|
|
62
|
+
over_budget?: boolean | undefined;
|
|
63
|
+
windows?: ArrayWindow[] | undefined;
|
|
64
|
+
}
|
|
65
|
+
/** A record page as both tools return it: upstream meta, records, and budget disclosure. */
|
|
66
|
+
export interface RecordPage extends BudgetDisclosure {
|
|
67
|
+
meta: {
|
|
68
|
+
count: number;
|
|
69
|
+
next_cursor: string | null;
|
|
70
|
+
per_page: number;
|
|
71
|
+
};
|
|
72
|
+
results: readonly EntityRecord[];
|
|
73
|
+
}
|
|
74
|
+
export interface BudgetedPage {
|
|
75
|
+
disclosure: BudgetDisclosure;
|
|
76
|
+
/** Sentences for the tool's `notice`; undefined when nothing was cut, windowed, or flagged. */
|
|
77
|
+
notice: string | undefined;
|
|
78
|
+
results: EntityRecord[];
|
|
79
|
+
}
|
|
80
|
+
export interface SurfaceBytes {
|
|
81
|
+
structured: number;
|
|
82
|
+
text: number;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Bytes of both surfaces for the page with no records in it, given the disclosure and the
|
|
86
|
+
* budget sentences it would carry. The tool supplies it, since only the tool knows its own
|
|
87
|
+
* header, notices, and enrichment.
|
|
88
|
+
*/
|
|
89
|
+
export type FrameMeasure = (disclosure: BudgetDisclosure, budgetNotice: string | undefined) => SurfaceBytes;
|
|
90
|
+
/** Enrichment the handler writes, which the framework renders into the `content[]` trailer. */
|
|
91
|
+
export interface PageEnrichment {
|
|
92
|
+
echo: string;
|
|
93
|
+
notice: string | undefined;
|
|
94
|
+
totalCount: number;
|
|
95
|
+
/** The tool's `enrichmentTrailer.totalCount.label`. */
|
|
96
|
+
totalLabel: string;
|
|
97
|
+
}
|
|
98
|
+
/** `https://openalex.org/W123` → `W123`. Any other form (a keyword URL) passes through whole. */
|
|
99
|
+
export declare function bareId(id: string): string;
|
|
100
|
+
/** Join notice sentences, dropping empty ones; undefined when none remain. */
|
|
101
|
+
export declare function joinNotices(parts: readonly (string | undefined)[]): string | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* `format()` for a record page: a count header, each record, then the budget disclosure.
|
|
104
|
+
* `noun` names what `meta.count` counts ("result(s)", "edge(s)").
|
|
105
|
+
*/
|
|
106
|
+
export declare function formatRecordPage(page: RecordPage, noun: string): {
|
|
107
|
+
type: 'text';
|
|
108
|
+
text: string;
|
|
109
|
+
}[];
|
|
110
|
+
/**
|
|
111
|
+
* The enrichment trailer's bytes, minus the service-written `budget` line. Fields render as the
|
|
112
|
+
* framework renders them for these tools: `echo` under its `Query` label, `totalCount` under
|
|
113
|
+
* `totalLabel`, and `notice` as a block quote, joined after a leading blank line.
|
|
114
|
+
*/
|
|
115
|
+
export declare function trailerTextBytes(enrichment: PageEnrichment): number;
|
|
116
|
+
/**
|
|
117
|
+
* Both surfaces' bytes for a page frame — the page with no records — plus the enrichment the
|
|
118
|
+
* handler writes and the allowance for the service's `budget` field.
|
|
119
|
+
*/
|
|
120
|
+
export declare function measurePageFrame(frame: RecordPage, noun: string, enrichment: PageEnrichment): SurfaceBytes;
|
|
121
|
+
export interface FitRecordsRequest {
|
|
122
|
+
entityType: EntityType;
|
|
123
|
+
measureFrame: FrameMeasure;
|
|
124
|
+
/**
|
|
125
|
+
* `list` pages cut whole records and check the authorship cap; a `lookup` holds one record,
|
|
126
|
+
* which OpenAlex returns uncapped, so it can only be windowed.
|
|
127
|
+
*/
|
|
128
|
+
path: 'list' | 'lookup';
|
|
129
|
+
records: readonly EntityRecord[];
|
|
130
|
+
/** The caller's `select`, repeated in the omitted-records call. */
|
|
131
|
+
select: readonly string[] | undefined;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Fit a page of records to the budget. Keeps the longest prefix whose records, charged
|
|
135
|
+
* `max(JSON bytes, rendered bytes)` each, fit beside the page frame; the rest become `omitted`.
|
|
136
|
+
* When even the first record does not fit alone, its largest arrays are windowed instead.
|
|
137
|
+
* Returns new arrays and, for a windowed record, a new record — never the caller's objects
|
|
138
|
+
* mutated.
|
|
139
|
+
*/
|
|
140
|
+
export declare function fitRecordsToBudget(request: FitRecordsRequest): BudgetedPage;
|
|
141
|
+
export interface FitSliceRequest {
|
|
142
|
+
entityType: EntityType;
|
|
143
|
+
/** Record key of the array to page (a `select` alias already resolved); must hold an array. */
|
|
144
|
+
field: string;
|
|
145
|
+
measureFrame: FrameMeasure;
|
|
146
|
+
/** Requested start index; past the end yields an empty, final window. */
|
|
147
|
+
offset: number;
|
|
148
|
+
/** The record from a one-field projection: `id`, `display_name`, and `field`. */
|
|
149
|
+
record: EntityRecord;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Fit one array window of an `id` lookup to the budget: the elements of `field` from `offset`,
|
|
153
|
+
* as many as fit. A window holds at least one element whenever any remain, so a caller walking
|
|
154
|
+
* the array always advances; when that one element alone does not fit, the response runs over
|
|
155
|
+
* the budget and is flagged `over_budget`.
|
|
156
|
+
*/
|
|
157
|
+
export declare function fitSliceToBudget(request: FitSliceRequest): BudgetedPage;
|
|
158
|
+
export {};
|
|
159
|
+
//# sourceMappingURL=response-budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"response-budget.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/response-budget.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Response-size budget for the tools that return OpenAlex entity records
|
|
3
|
+
* (`openalex_search_entities`, `openalex_get_citation_graph`). Holds each surface — serialized
|
|
4
|
+
* `structuredContent` and the summed `content[]` text, enrichment trailer included — to
|
|
5
|
+
* 64,000 bytes: a list page loses whole records from its end, a record too large on its own has
|
|
6
|
+
* its biggest arrays windowed, and every cut is disclosed with the call that continues it. The
|
|
7
|
+
* same pass flags list records carrying exactly 100 authorships, OpenAlex's list-response cap.
|
|
8
|
+
* @module mcp-server/tools/response-budget
|
|
9
|
+
*/
|
|
10
|
+
import { z } from '@cyanheads/mcp-ts-core';
|
|
11
|
+
import { renderEntityRecord, } from './render-entity-record.js';
|
|
12
|
+
/** Per-surface ceiling: serialized `structuredContent`, and summed `content[]` text. */
|
|
13
|
+
export const RESPONSE_BUDGET_BYTES = 64_000;
|
|
14
|
+
/**
|
|
15
|
+
* The most authorships OpenAlex returns on a record in a list response; the record's `id`
|
|
16
|
+
* lookup returns them all. Exactly this many on a list record may or may not be the whole list.
|
|
17
|
+
*/
|
|
18
|
+
const LIST_AUTHORSHIPS_CAP = 100;
|
|
19
|
+
/**
|
|
20
|
+
* Bytes reserved on each surface for the `budget` enrichment field. The service writes it from
|
|
21
|
+
* OpenAlex's rate-limit headers through `ctx.enrich`, so its value is not in the handler's hands
|
|
22
|
+
* when the page is measured. The allowance covers its widest realistic form — every number at
|
|
23
|
+
* full double precision, with a prepaid balance — measured at 176 bytes as a JSON member and 108
|
|
24
|
+
* as a trailer line with its separator.
|
|
25
|
+
*/
|
|
26
|
+
export const BUDGET_FIELD_ALLOWANCE_BYTES = 256;
|
|
27
|
+
const BUDGET_LABEL = `${RESPONSE_BUDGET_BYTES.toLocaleString('en-US')}-byte`;
|
|
28
|
+
const CONTINUATION_TOOL = 'openalex_search_entities';
|
|
29
|
+
const OPENALEX_ID_PREFIX = 'https://openalex.org/';
|
|
30
|
+
const NATIVE_OPENALEX_ID = /^[A-Z]\d+$/;
|
|
31
|
+
/**
|
|
32
|
+
* The call that continues a cut, shared by `omitted.next` and every `windows[].next`. Its
|
|
33
|
+
* arguments are data to pass back unchanged, so the schema leaves them loosely typed rather than
|
|
34
|
+
* spelling out the search tool's input a second time.
|
|
35
|
+
*/
|
|
36
|
+
const continuationCallSchema = z.object({
|
|
37
|
+
tool: z.literal(CONTINUATION_TOOL).describe('Tool to call.'),
|
|
38
|
+
arguments: z.object({}).loose().describe('Arguments to pass as-is.'),
|
|
39
|
+
});
|
|
40
|
+
const arrayWindowSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
id: z.string().describe('Bare ID of the record.'),
|
|
43
|
+
field: z.string().describe('Array field.'),
|
|
44
|
+
offset: z.number().describe('Index of the first element shown.'),
|
|
45
|
+
shown: z.number().describe('Elements shown.'),
|
|
46
|
+
total: z.number().describe('Array length as returned to this call.'),
|
|
47
|
+
possibly_capped: z
|
|
48
|
+
.boolean()
|
|
49
|
+
.describe("True for exactly 100 authorships on a list record (OpenAlex's cap): the list may be longer."),
|
|
50
|
+
next: continuationCallSchema
|
|
51
|
+
.nullable()
|
|
52
|
+
.describe('`id` + `slice` call for the next elements; null at the array end.'),
|
|
53
|
+
})
|
|
54
|
+
.describe('An array returned in part.');
|
|
55
|
+
/** Output field: whole records the budget cut from a list page, and the call returning them. */
|
|
56
|
+
export const omittedOutputSchema = z
|
|
57
|
+
.object({
|
|
58
|
+
ids: z
|
|
59
|
+
.array(z.string())
|
|
60
|
+
.describe('Bare IDs of the records left out, in page order (a keyword keeps its URL).'),
|
|
61
|
+
next: continuationCallSchema.describe("One call returning those records as a set, in OpenAlex's order, not page order. Budgeted too, so it may carry its own `omitted`."),
|
|
62
|
+
})
|
|
63
|
+
.optional()
|
|
64
|
+
.describe('Present when the response budget cut whole records from the page end; pagination continues after the full upstream page.');
|
|
65
|
+
/** Output field: arrays returned in part, or possibly in part, each with its continuation. */
|
|
66
|
+
export const windowsOutputSchema = z
|
|
67
|
+
.array(arrayWindowSchema)
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('Arrays returned in part — windowed to fit the budget, paged by `slice`, or 100 possibly capped authorships — each with its continuation. A `slice` call always carries its window, even for a whole array; otherwise absent when every array is whole.');
|
|
70
|
+
/** Output field: the least this call can return does not fit the budget on its own. */
|
|
71
|
+
export const overBudgetOutputSchema = z
|
|
72
|
+
.boolean()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("True when even the least this call can return — the first record's non-array fields (its arrays windowed to empty), or one `slice` element — exceeds the budget.");
|
|
75
|
+
const utf8Bytes = (text) => Buffer.byteLength(text, 'utf8');
|
|
76
|
+
/** `https://openalex.org/W123` → `W123`. Any other form (a keyword URL) passes through whole. */
|
|
77
|
+
export function bareId(id) {
|
|
78
|
+
const tail = id.startsWith(OPENALEX_ID_PREFIX) ? id.slice(OPENALEX_ID_PREFIX.length) : id;
|
|
79
|
+
return NATIVE_OPENALEX_ID.test(tail) ? tail : id;
|
|
80
|
+
}
|
|
81
|
+
/** Join notice sentences, dropping empty ones; undefined when none remain. */
|
|
82
|
+
export function joinNotices(parts) {
|
|
83
|
+
return parts.filter(Boolean).join(' ') || undefined;
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Rendering — shared by both tools' format() and by the budget's own measurement
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
const renderCall = (call) => `\`${call.tool}\` \`${JSON.stringify(call.arguments)}\``;
|
|
89
|
+
function renderWindow(window) {
|
|
90
|
+
const capped = window.possibly_capped
|
|
91
|
+
? ` — possibly capped: OpenAlex returns at most ${LIST_AUTHORSHIPS_CAP} authorships on a list record`
|
|
92
|
+
: '';
|
|
93
|
+
const next = window.next ? `next: ${renderCall(window.next)}` : 'end of array';
|
|
94
|
+
return `**Window:** ${window.id} \`${window.field}\` — offset ${window.offset}, ${window.shown} shown of ${window.total}${capped} — ${next}`;
|
|
95
|
+
}
|
|
96
|
+
/** Markdown lines disclosing what the budget cut; empty when the page carries no disclosure. */
|
|
97
|
+
function renderBudgetDisclosure(disclosure) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
if (disclosure.over_budget) {
|
|
100
|
+
lines.push('', `**Over budget:** the least this call can return — the first record's non-array fields, or one \`slice\` element — exceeds the ${BUDGET_LABEL} response budget on its own.`);
|
|
101
|
+
}
|
|
102
|
+
if (disclosure.windows?.length)
|
|
103
|
+
lines.push('', ...disclosure.windows.map(renderWindow));
|
|
104
|
+
if (disclosure.omitted) {
|
|
105
|
+
const { ids, next } = disclosure.omitted;
|
|
106
|
+
lines.push('', `**Omitted by the ${BUDGET_LABEL} response budget (${ids.length}):** ${ids.join(', ')}`, `**Omitted records call:** ${renderCall(next)}`);
|
|
107
|
+
}
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Each windowed array's place in its full array, per record ID, so a window numbers its items
|
|
112
|
+
* at their real index and its line says how much of the array it shows.
|
|
113
|
+
*/
|
|
114
|
+
function windowViewsByRecord(windows) {
|
|
115
|
+
const views = new Map();
|
|
116
|
+
for (const window of windows ?? []) {
|
|
117
|
+
const fields = views.get(window.id) ?? new Map();
|
|
118
|
+
fields.set(window.field, { offset: window.offset, total: window.total });
|
|
119
|
+
views.set(window.id, fields);
|
|
120
|
+
}
|
|
121
|
+
return views;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `format()` for a record page: a count header, each record, then the budget disclosure.
|
|
125
|
+
* `noun` names what `meta.count` counts ("result(s)", "edge(s)").
|
|
126
|
+
*/
|
|
127
|
+
export function formatRecordPage(page, noun) {
|
|
128
|
+
const countLabel = `${page.meta.count} ${noun} — ${page.meta.per_page} per page`;
|
|
129
|
+
const lines = [
|
|
130
|
+
page.meta.next_cursor
|
|
131
|
+
? `**${countLabel}** — next cursor: \`${page.meta.next_cursor}\``
|
|
132
|
+
: `**${countLabel}**`,
|
|
133
|
+
];
|
|
134
|
+
const views = windowViewsByRecord(page.windows);
|
|
135
|
+
for (const record of page.results) {
|
|
136
|
+
lines.push(...renderEntityRecord(record, views.get(bareId(record.id))));
|
|
137
|
+
}
|
|
138
|
+
lines.push(...renderBudgetDisclosure(page));
|
|
139
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Measurement
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
/**
|
|
145
|
+
* Byte-exact copy of the framework's trailer join (`joinTrailerFields` in mcp-ts-core's tool
|
|
146
|
+
* handler factory, which the package does not export — cyanheads/mcp-ts-core#538). A line opening
|
|
147
|
+
* a block quote or list item is followed by a blank line instead of a single newline. The
|
|
148
|
+
* response-budget test compares the modelled trailer with the one `runToolContract` renders.
|
|
149
|
+
*/
|
|
150
|
+
const OPENS_LAZY_CONTAINER = /^ {0,3}(?:>|(?:[-+*]|\d{1,9}[.)])(?: |$))/;
|
|
151
|
+
function joinTrailerFields(fields) {
|
|
152
|
+
let text = '';
|
|
153
|
+
for (const field of fields) {
|
|
154
|
+
if (text.length > 0) {
|
|
155
|
+
text += OPENS_LAZY_CONTAINER.test(text.slice(text.lastIndexOf('\n') + 1)) ? '\n\n' : '\n';
|
|
156
|
+
}
|
|
157
|
+
text += field;
|
|
158
|
+
}
|
|
159
|
+
return text;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The enrichment trailer's bytes, minus the service-written `budget` line. Fields render as the
|
|
163
|
+
* framework renders them for these tools: `echo` under its `Query` label, `totalCount` under
|
|
164
|
+
* `totalLabel`, and `notice` as a block quote, joined after a leading blank line.
|
|
165
|
+
*/
|
|
166
|
+
export function trailerTextBytes(enrichment) {
|
|
167
|
+
const fields = [
|
|
168
|
+
`**Query:** ${enrichment.echo}`,
|
|
169
|
+
`**${enrichment.totalLabel}:** ${enrichment.totalCount}`,
|
|
170
|
+
...(enrichment.notice === undefined ? [] : [`> ${enrichment.notice}`]),
|
|
171
|
+
];
|
|
172
|
+
return utf8Bytes(`\n\n${joinTrailerFields(fields)}`);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Both surfaces' bytes for a page frame — the page with no records — plus the enrichment the
|
|
176
|
+
* handler writes and the allowance for the service's `budget` field.
|
|
177
|
+
*/
|
|
178
|
+
export function measurePageFrame(frame, noun, enrichment) {
|
|
179
|
+
const { echo, notice, totalCount } = enrichment;
|
|
180
|
+
const structured = utf8Bytes(JSON.stringify({ ...frame, echo, totalCount, ...(notice !== undefined && { notice }) }));
|
|
181
|
+
const text = formatRecordPage(frame, noun).reduce((sum, block) => sum + utf8Bytes(block.text), 0);
|
|
182
|
+
return {
|
|
183
|
+
structured: structured + BUDGET_FIELD_ALLOWANCE_BYTES,
|
|
184
|
+
text: text + trailerTextBytes(enrichment) + BUDGET_FIELD_ALLOWANCE_BYTES,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* What one record adds to the busier surface: its JSON array member or its rendered lines,
|
|
189
|
+
* each plus the one separator byte (comma, newline) joining it to its neighbour. Rendered with
|
|
190
|
+
* the renderer `format()` uses, with the window views `format()` will pass.
|
|
191
|
+
*/
|
|
192
|
+
function recordCharge(record, views) {
|
|
193
|
+
const json = utf8Bytes(JSON.stringify(record));
|
|
194
|
+
const text = utf8Bytes(renderEntityRecord(record, views).join('\n'));
|
|
195
|
+
return Math.max(json, text) + 1;
|
|
196
|
+
}
|
|
197
|
+
const frameBytes = (bytes) => Math.max(bytes.structured, bytes.text);
|
|
198
|
+
/**
|
|
199
|
+
* Largest n in [0, limit] with `fits(n)`, given `fits(0)` holds and `fits` stays false once
|
|
200
|
+
* it turns false. Gallops up from 0 before bisecting, so each probe renders about as many
|
|
201
|
+
* elements as the answer holds rather than half the array.
|
|
202
|
+
*/
|
|
203
|
+
function largestFitting(limit, fits) {
|
|
204
|
+
let low = 0;
|
|
205
|
+
let high = 1;
|
|
206
|
+
while (high <= limit && fits(high)) {
|
|
207
|
+
low = high;
|
|
208
|
+
high *= 2;
|
|
209
|
+
}
|
|
210
|
+
high = Math.min(high, limit + 1);
|
|
211
|
+
while (high - low > 1) {
|
|
212
|
+
const mid = Math.floor((low + high) / 2);
|
|
213
|
+
if (fits(mid))
|
|
214
|
+
low = mid;
|
|
215
|
+
else
|
|
216
|
+
high = mid;
|
|
217
|
+
}
|
|
218
|
+
return low;
|
|
219
|
+
}
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Disclosure construction
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
function arrayWindow(entityType, record, field, view) {
|
|
224
|
+
const id = bareId(record.id);
|
|
225
|
+
const end = Math.min(view.offset, view.total) + view.shown;
|
|
226
|
+
return {
|
|
227
|
+
id,
|
|
228
|
+
field,
|
|
229
|
+
offset: view.offset,
|
|
230
|
+
shown: view.shown,
|
|
231
|
+
total: view.total,
|
|
232
|
+
possibly_capped: view.possiblyCapped,
|
|
233
|
+
next: end < view.total || view.possiblyCapped
|
|
234
|
+
? {
|
|
235
|
+
tool: CONTINUATION_TOOL,
|
|
236
|
+
arguments: { entity_type: entityType, id, slice: { field, offset: end } },
|
|
237
|
+
}
|
|
238
|
+
: null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function isCappedListRecord(record, capCheck) {
|
|
242
|
+
return (capCheck &&
|
|
243
|
+
Array.isArray(record.authorships) &&
|
|
244
|
+
record.authorships.length === LIST_AUTHORSHIPS_CAP);
|
|
245
|
+
}
|
|
246
|
+
function capWindow(entityType, record) {
|
|
247
|
+
return arrayWindow(entityType, record, 'authorships', {
|
|
248
|
+
offset: 0,
|
|
249
|
+
shown: LIST_AUTHORSHIPS_CAP,
|
|
250
|
+
total: LIST_AUTHORSHIPS_CAP,
|
|
251
|
+
possiblyCapped: true,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function budgetNotice(parts) {
|
|
255
|
+
const { cappedCount, kept, omitted, overBudget, windowedCount } = parts;
|
|
256
|
+
return joinNotices([
|
|
257
|
+
omitted.length > 0
|
|
258
|
+
? `The ${BUDGET_LABEL} response budget held ${kept.length} of ${kept.length + omitted.length} records; \`omitted\` names the other ${omitted.length} and the openalex_search_entities call that returns them.`
|
|
259
|
+
: undefined,
|
|
260
|
+
windowedCount > 0
|
|
261
|
+
? `${windowedCount} array field(s) were windowed to fit the budget; each \`windows\` entry gives the \`id\` + \`slice\` call that continues it.`
|
|
262
|
+
: undefined,
|
|
263
|
+
overBudget
|
|
264
|
+
? "The first record's non-array fields alone exceed the budget, so this response runs over it."
|
|
265
|
+
: undefined,
|
|
266
|
+
cappedCount > 0
|
|
267
|
+
? `${cappedCount} record(s) carry exactly ${LIST_AUTHORSHIPS_CAP} authorships, the most OpenAlex returns on a list record, so the author list may be longer; the \`windows\` entry gives the \`id\` + \`slice\` call for the rest.`
|
|
268
|
+
: undefined,
|
|
269
|
+
]);
|
|
270
|
+
}
|
|
271
|
+
function assemblePage(parts, entityType, select) {
|
|
272
|
+
const disclosure = {};
|
|
273
|
+
if (parts.omitted.length > 0) {
|
|
274
|
+
const ids = parts.omitted.map((record) => bareId(record.id));
|
|
275
|
+
// Keywords have no `openalex` filter upstream (400); their `id` filter takes the keyword URLs.
|
|
276
|
+
const idFilter = entityType === 'keywords' ? 'id' : 'openalex';
|
|
277
|
+
disclosure.omitted = {
|
|
278
|
+
ids,
|
|
279
|
+
next: {
|
|
280
|
+
tool: CONTINUATION_TOOL,
|
|
281
|
+
arguments: {
|
|
282
|
+
entity_type: entityType,
|
|
283
|
+
filters: { [idFilter]: ids.join('|') },
|
|
284
|
+
per_page: ids.length,
|
|
285
|
+
...(select !== undefined && { select: [...select] }),
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (parts.windows.length > 0)
|
|
291
|
+
disclosure.windows = [...parts.windows];
|
|
292
|
+
if (parts.overBudget)
|
|
293
|
+
disclosure.over_budget = true;
|
|
294
|
+
return { results: [...parts.kept], disclosure, notice: budgetNotice(parts) };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Fit a page of records to the budget. Keeps the longest prefix whose records, charged
|
|
298
|
+
* `max(JSON bytes, rendered bytes)` each, fit beside the page frame; the rest become `omitted`.
|
|
299
|
+
* When even the first record does not fit alone, its largest arrays are windowed instead.
|
|
300
|
+
* Returns new arrays and, for a windowed record, a new record — never the caller's objects
|
|
301
|
+
* mutated.
|
|
302
|
+
*/
|
|
303
|
+
export function fitRecordsToBudget(request) {
|
|
304
|
+
const { entityType, measureFrame, path, records, select } = request;
|
|
305
|
+
const capCheck = path === 'list' && entityType === 'works';
|
|
306
|
+
const charges = records.map((record) => recordCharge(record));
|
|
307
|
+
let recordBytes = charges.reduce((sum, charge) => sum + charge, 0);
|
|
308
|
+
for (let kept = records.length; kept >= 1; kept--) {
|
|
309
|
+
const keptRecords = records.slice(0, kept);
|
|
310
|
+
const capped = keptRecords.filter((record) => isCappedListRecord(record, capCheck));
|
|
311
|
+
const page = assemblePage({
|
|
312
|
+
kept: keptRecords,
|
|
313
|
+
omitted: records.slice(kept),
|
|
314
|
+
windows: capped.map((record) => capWindow(entityType, record)),
|
|
315
|
+
windowedCount: 0,
|
|
316
|
+
cappedCount: capped.length,
|
|
317
|
+
overBudget: false,
|
|
318
|
+
}, entityType, select);
|
|
319
|
+
if (frameBytes(measureFrame(page.disclosure, page.notice)) + recordBytes <=
|
|
320
|
+
RESPONSE_BUDGET_BYTES) {
|
|
321
|
+
return page;
|
|
322
|
+
}
|
|
323
|
+
recordBytes -= charges[kept - 1] ?? 0;
|
|
324
|
+
}
|
|
325
|
+
const [first, ...rest] = records;
|
|
326
|
+
if (!first)
|
|
327
|
+
return { results: [], disclosure: {}, notice: undefined };
|
|
328
|
+
return windowRecord(first, rest, isCappedListRecord(first, capCheck), request);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Fill windowed arrays round-robin: each array in turn takes its next leading element while it
|
|
332
|
+
* fits, so every array shows elements for as long as any of its elements would fit. A record's
|
|
333
|
+
* charge only grows as elements are added, so an array whose next element does not fit never
|
|
334
|
+
* fits again and drops out. Whole rounds are found by bisection and the round that ends a run
|
|
335
|
+
* is played one array at a time, so the result is exactly the one-element-at-a-time fill at a
|
|
336
|
+
* cost of a few renders per array. Returns the count shown per field.
|
|
337
|
+
*/
|
|
338
|
+
function fillRoundRobin(base, arrays, fits) {
|
|
339
|
+
const counts = new Map(arrays.map((array) => [array.field, 0]));
|
|
340
|
+
const shownOf = (array) => counts.get(array.field) ?? 0;
|
|
341
|
+
const build = (extra) => {
|
|
342
|
+
const record = { ...base };
|
|
343
|
+
for (const array of arrays) {
|
|
344
|
+
record[array.field] = array.items.slice(0, shownOf(array) + extra(array));
|
|
345
|
+
}
|
|
346
|
+
return record;
|
|
347
|
+
};
|
|
348
|
+
let active = [...arrays];
|
|
349
|
+
while (active.length > 0) {
|
|
350
|
+
const inPlay = new Set(active);
|
|
351
|
+
const raise = (rounds) => (array) => inPlay.has(array) ? Math.min(rounds, array.items.length - shownOf(array)) : 0;
|
|
352
|
+
const headroom = Math.max(...active.map((array) => array.items.length - shownOf(array)));
|
|
353
|
+
const rounds = largestFitting(headroom, (n) => fits(build(raise(n))));
|
|
354
|
+
for (const array of active)
|
|
355
|
+
counts.set(array.field, shownOf(array) + raise(rounds)(array));
|
|
356
|
+
active = active.filter((array) => {
|
|
357
|
+
if (shownOf(array) >= array.items.length)
|
|
358
|
+
return false;
|
|
359
|
+
const grown = build((other) => (other === array ? 1 : 0));
|
|
360
|
+
if (!fits(grown))
|
|
361
|
+
return false;
|
|
362
|
+
counts.set(array.field, shownOf(array) + 1);
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return counts;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Window the arrays of a record too large to fit whole. Tries windowing the largest array
|
|
370
|
+
* alone, then the two largest, and so on, until the record with those arrays emptied fits;
|
|
371
|
+
* then fills the windowed arrays round-robin. The disclosure is sized at its upper bound while
|
|
372
|
+
* fitting — every count at its array length — so the final, smaller disclosure can only fit too.
|
|
373
|
+
*/
|
|
374
|
+
function windowRecord(record, rest, capped, request) {
|
|
375
|
+
const { entityType, measureFrame, select } = request;
|
|
376
|
+
const arrays = Object.entries(record)
|
|
377
|
+
.filter((entry) => Array.isArray(entry[1]) && entry[1].length > 0)
|
|
378
|
+
.map(([field, items]) => ({ field, items, bytes: utf8Bytes(JSON.stringify(items)) }))
|
|
379
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
380
|
+
/** Window entries for `shown` counts per windowed field, plus the cap entry when it applies. */
|
|
381
|
+
const windowsFor = (shown) => {
|
|
382
|
+
const entries = [];
|
|
383
|
+
for (const [field, items] of Object.entries(record)) {
|
|
384
|
+
if (!Array.isArray(items))
|
|
385
|
+
continue;
|
|
386
|
+
const count = shown.get(field);
|
|
387
|
+
const cappedField = capped && field === 'authorships';
|
|
388
|
+
if (count !== undefined && count < items.length) {
|
|
389
|
+
entries.push(arrayWindow(entityType, record, field, {
|
|
390
|
+
offset: 0,
|
|
391
|
+
shown: count,
|
|
392
|
+
total: items.length,
|
|
393
|
+
possiblyCapped: cappedField,
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
else if (cappedField) {
|
|
397
|
+
entries.push(capWindow(entityType, record));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return entries;
|
|
401
|
+
};
|
|
402
|
+
const partsFor = (kept, windows, overBudget) => ({
|
|
403
|
+
kept: [kept],
|
|
404
|
+
omitted: rest,
|
|
405
|
+
windows,
|
|
406
|
+
windowedCount: windows.filter((w) => w.shown < w.total).length,
|
|
407
|
+
cappedCount: capped ? 1 : 0,
|
|
408
|
+
overBudget,
|
|
409
|
+
});
|
|
410
|
+
for (let count = 1; count <= arrays.length; count++) {
|
|
411
|
+
const windowed = arrays.slice(0, count);
|
|
412
|
+
// Upper bound: every windowed count at its full length, each still continuing.
|
|
413
|
+
const bound = windowed.map((array) => arrayWindow(entityType, record, array.field, {
|
|
414
|
+
offset: 0,
|
|
415
|
+
shown: array.items.length,
|
|
416
|
+
total: array.items.length + 1,
|
|
417
|
+
possiblyCapped: capped && array.field === 'authorships',
|
|
418
|
+
}));
|
|
419
|
+
const boundWindows = [
|
|
420
|
+
...bound,
|
|
421
|
+
...(capped && !windowed.some((a) => a.field === 'authorships')
|
|
422
|
+
? [capWindow(entityType, record)]
|
|
423
|
+
: []),
|
|
424
|
+
];
|
|
425
|
+
const boundPage = assemblePage(partsFor(record, boundWindows, false), entityType, select);
|
|
426
|
+
const available = RESPONSE_BUDGET_BYTES - frameBytes(measureFrame(boundPage.disclosure, boundPage.notice));
|
|
427
|
+
// Charged with the views `format()` passes, so each window's note is paid for.
|
|
428
|
+
const views = new Map(windowed.map((array) => [array.field, { offset: 0, total: array.items.length }]));
|
|
429
|
+
const fits = (candidate) => recordCharge(candidate, views) <= available;
|
|
430
|
+
const emptied = { ...record };
|
|
431
|
+
for (const array of windowed)
|
|
432
|
+
emptied[array.field] = [];
|
|
433
|
+
if (!fits(emptied))
|
|
434
|
+
continue;
|
|
435
|
+
const shown = fillRoundRobin(record, windowed, fits);
|
|
436
|
+
const kept = { ...record };
|
|
437
|
+
for (const array of windowed) {
|
|
438
|
+
kept[array.field] = array.items.slice(0, shown.get(array.field));
|
|
439
|
+
}
|
|
440
|
+
return assemblePage(partsFor(kept, windowsFor(shown), false), entityType, select);
|
|
441
|
+
}
|
|
442
|
+
// The record's non-array fields alone exceed the budget: return them whole, arrays empty.
|
|
443
|
+
const emptied = { ...record };
|
|
444
|
+
for (const array of arrays)
|
|
445
|
+
emptied[array.field] = [];
|
|
446
|
+
const shown = new Map(arrays.map((array) => [array.field, 0]));
|
|
447
|
+
return assemblePage(partsFor(emptied, windowsFor(shown), true), entityType, select);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Fit one array window of an `id` lookup to the budget: the elements of `field` from `offset`,
|
|
451
|
+
* as many as fit. A window holds at least one element whenever any remain, so a caller walking
|
|
452
|
+
* the array always advances; when that one element alone does not fit, the response runs over
|
|
453
|
+
* the budget and is flagged `over_budget`.
|
|
454
|
+
*/
|
|
455
|
+
export function fitSliceToBudget(request) {
|
|
456
|
+
const { entityType, field, measureFrame, offset, record } = request;
|
|
457
|
+
const items = record[field];
|
|
458
|
+
const total = items.length;
|
|
459
|
+
const start = Math.min(offset, total);
|
|
460
|
+
const remaining = total - start;
|
|
461
|
+
const views = new Map([[field, { offset, total }]]);
|
|
462
|
+
const view = (shown) => ({ offset, shown, total, possiblyCapped: false });
|
|
463
|
+
// Upper bound: the whole remainder shown, with a continuation call.
|
|
464
|
+
const bound = arrayWindow(entityType, record, field, { ...view(remaining), total: total + 1 });
|
|
465
|
+
const available = RESPONSE_BUDGET_BYTES - frameBytes(measureFrame({ windows: [bound] }, undefined));
|
|
466
|
+
const fitted = largestFitting(remaining, (n) => recordCharge({ ...record, [field]: items.slice(start, start + n) }, views) <= available);
|
|
467
|
+
const overBudget = remaining > 0 && fitted === 0;
|
|
468
|
+
const shown = overBudget ? 1 : fitted;
|
|
469
|
+
return {
|
|
470
|
+
results: [{ ...record, [field]: items.slice(start, start + shown) }],
|
|
471
|
+
disclosure: {
|
|
472
|
+
windows: [arrayWindow(entityType, record, field, view(shown))],
|
|
473
|
+
...(overBudget && { over_budget: true }),
|
|
474
|
+
},
|
|
475
|
+
notice: overBudget
|
|
476
|
+
? `The element at offset ${start} of \`${field}\` alone exceeds the ${BUDGET_LABEL} response budget, so this response runs over it; ${start + 1 < total ? "the `windows` entry's `next` call continues after it" : 'it is the last element'}.`
|
|
477
|
+
: undefined,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
//# sourceMappingURL=response-budget.js.map
|