@cyanheads/zenodo-mcp-server 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.
Files changed (62) hide show
  1. package/AGENTS.md +30 -22
  2. package/CLAUDE.md +30 -22
  3. package/Dockerfile +86 -40
  4. package/README.md +31 -4
  5. package/changelog/0.1.x/0.1.2.md +33 -0
  6. package/changelog/0.1.x/0.1.3.md +35 -0
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-record.tool.d.ts +106 -2
  10. package/dist/mcp-server/tools/definitions/get-record.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/get-record.tool.js +453 -107
  12. package/dist/mcp-server/tools/definitions/get-record.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/index.d.ts +283 -180
  14. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/list-files.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/list-files.tool.js +28 -22
  17. package/dist/mcp-server/tools/definitions/list-files.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/list-versions.tool.d.ts +1 -1
  19. package/dist/mcp-server/tools/definitions/list-versions.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/list-versions.tool.js +30 -21
  21. package/dist/mcp-server/tools/definitions/list-versions.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/lookup-vocabulary.tool.d.ts +2 -1
  23. package/dist/mcp-server/tools/definitions/lookup-vocabulary.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/lookup-vocabulary.tool.js +41 -24
  25. package/dist/mcp-server/tools/definitions/lookup-vocabulary.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/read-file.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/read-file.tool.js +29 -27
  28. package/dist/mcp-server/tools/definitions/read-file.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-records.tool.d.ts +3 -3
  30. package/dist/mcp-server/tools/definitions/search-records.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/search-records.tool.js +145 -35
  32. package/dist/mcp-server/tools/definitions/search-records.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/paging.d.ts +42 -0
  34. package/dist/mcp-server/tools/paging.d.ts.map +1 -0
  35. package/dist/mcp-server/tools/paging.js +44 -0
  36. package/dist/mcp-server/tools/paging.js.map +1 -0
  37. package/dist/mcp-server/tools/record-miss.d.ts +5 -1
  38. package/dist/mcp-server/tools/record-miss.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/record-miss.js +6 -2
  40. package/dist/mcp-server/tools/record-miss.js.map +1 -1
  41. package/dist/services/zenodo/html-to-text.d.ts +19 -8
  42. package/dist/services/zenodo/html-to-text.d.ts.map +1 -1
  43. package/dist/services/zenodo/html-to-text.js +175 -48
  44. package/dist/services/zenodo/html-to-text.js.map +1 -1
  45. package/dist/services/zenodo/http.d.ts.map +1 -1
  46. package/dist/services/zenodo/http.js +21 -32
  47. package/dist/services/zenodo/http.js.map +1 -1
  48. package/dist/services/zenodo/identifiers.js +1 -1
  49. package/dist/services/zenodo/identifiers.js.map +1 -1
  50. package/dist/services/zenodo/normalize.d.ts.map +1 -1
  51. package/dist/services/zenodo/normalize.js +5 -2
  52. package/dist/services/zenodo/normalize.js.map +1 -1
  53. package/dist/services/zenodo/query-builder.d.ts +84 -8
  54. package/dist/services/zenodo/query-builder.d.ts.map +1 -1
  55. package/dist/services/zenodo/query-builder.js +484 -21
  56. package/dist/services/zenodo/query-builder.js.map +1 -1
  57. package/dist/services/zenodo/text-preview.d.ts +1 -1
  58. package/dist/services/zenodo/text-preview.d.ts.map +1 -1
  59. package/dist/services/zenodo/text-preview.js +38 -38
  60. package/dist/services/zenodo/text-preview.js.map +1 -1
  61. package/package.json +14 -12
  62. package/server.json +16 -24
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * @fileoverview zenodo_get_record — resolves one Zenodo deposit from a record id,
3
3
  * Zenodo or external DOI, or zenodo.org/doi.org URL and returns its full metadata,
4
- * the first 25 files, and optionally a formatted citation. A miss (unknown id,
5
- * deleted record, restricted metadata, DOI not on Zenodo) is a result, not an error.
4
+ * the first 25 files, and optionally a formatted citation. Fields cut at a display
5
+ * cap are listed in `continuations` with a cursor; passing one back returns the
6
+ * next window of that field. A miss (unknown id, deleted record, restricted
7
+ * metadata, DOI not on Zenodo) is a result, not an error.
6
8
  * @module mcp-server/tools/definitions/get-record
7
9
  */
8
10
  import { tool, z } from '@cyanheads/mcp-ts-core';
9
11
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
12
+ import { decodeCursor, encodeCursor } from '@cyanheads/mcp-ts-core/utils';
10
13
  import { parseRecordRef, recordUrl } from '../../../services/zenodo/identifiers.js';
11
14
  import { getZenodoService } from '../../../services/zenodo/zenodo-service.js';
12
15
  import { notOnZenodoMiss, recordMiss, TombstoneSchema } from '../record-miss.js';
13
16
  import { hasLineBreak, inline, quoteBlock } from '../render.js';
14
- import { enumPreprocess } from '../schema-helpers.js';
17
+ import { blankToUndefined, enumPreprocess } from '../schema-helpers.js';
15
18
  const CITATION_STYLES = [
16
19
  'bibtex',
17
20
  'csl-json',
@@ -29,6 +32,29 @@ const COMMUNITIES_CAP = 25;
29
32
  const DESCRIPTION_CAP = 4_000;
30
33
  const ADDITIONAL_DESCRIPTIONS_CAP = 5;
31
34
  const ADDITIONAL_DESCRIPTION_TEXT_CAP = 1_000;
35
+ /** Characters per text window in cursor mode. */
36
+ const TEXT_WINDOW = 8_000;
37
+ /** The list fields a cursor can page, in record order. */
38
+ const LIST_FIELDS = [
39
+ 'additional_descriptions',
40
+ 'creators',
41
+ 'contributors',
42
+ 'keywords',
43
+ 'funding',
44
+ 'related_identifiers',
45
+ 'communities',
46
+ ];
47
+ const FieldSchema = z.enum(['description', ...LIST_FIELDS]);
48
+ const UnitSchema = z.enum(['characters', 'entries']);
49
+ /** The state a cursor carries: framework pagination state bound to one record revision. */
50
+ const CursorStateSchema = z.object({
51
+ offset: z.number().int().min(0),
52
+ limit: z.number().int().min(1),
53
+ recid: z.string(),
54
+ rev: z.number().int().optional(),
55
+ field: FieldSchema,
56
+ index: z.number().int().min(0).optional(),
57
+ });
32
58
  const PersonSchema = z
33
59
  .object({
34
60
  name: z.string().describe('Display name as deposited.'),
@@ -57,6 +83,46 @@ const UsageSchema = z
57
83
  .describe('Unique downloads, as zenodo.org displays them; absent when not reported.'),
58
84
  })
59
85
  .describe('Usage counts.');
86
+ const AdditionalDescriptionSchema = z
87
+ .object({
88
+ type: z.string().optional().describe('Description type, e.g. Notes or Methods.'),
89
+ text: z.string().describe('Plain text, first 1,000 characters.'),
90
+ length: z.number().describe('Full plain-text length in characters.'),
91
+ truncated: z
92
+ .boolean()
93
+ .describe('True when text was cut at 1,000 characters; continuations carries a cursor for the rest.'),
94
+ })
95
+ .describe('One additional description.');
96
+ const FundingSchema = z
97
+ .object({
98
+ funder_id: z.string().optional().describe('Funder ROR id.'),
99
+ funder_name: z.string().optional().describe('Funder name.'),
100
+ award_id: z
101
+ .string()
102
+ .optional()
103
+ .describe('Award id (<funder-ror>::<number>) — feeds zenodo_search_records award.'),
104
+ award_number: z.string().optional().describe('Grant number.'),
105
+ award_acronym: z.string().optional().describe('Award acronym.'),
106
+ award_title: z.string().optional().describe('Award title.'),
107
+ award_program: z.string().optional().describe('Funding program.'),
108
+ award_doi: z.string().optional().describe('Award DOI, e.g. 10.3030/101135562.'),
109
+ award_url: z.string().optional().describe('Award landing page URL.'),
110
+ })
111
+ .describe('One funding entry.');
112
+ const RelatedIdentifierSchema = z
113
+ .object({
114
+ identifier: z.string().describe('The related identifier (DOI, arXiv id, URL, PMID, …).'),
115
+ scheme: z.string().optional().describe('Identifier scheme, e.g. doi, arxiv, url.'),
116
+ relation: z.string().optional().describe('Relation type id, e.g. issupplementto or cites.'),
117
+ resource_type: z.string().optional().describe('Resource type id of the related item.'),
118
+ })
119
+ .describe('One related identifier.');
120
+ const CommunitySchema = z
121
+ .object({
122
+ slug: z.string().describe('Community slug — feeds zenodo_search_records community.'),
123
+ title: z.string().optional().describe('Community title.'),
124
+ })
125
+ .describe('One community.');
60
126
  const RecordSchema = z
61
127
  .object({
62
128
  recid: z.string().describe('Record id of this version.'),
@@ -97,28 +163,25 @@ const RecordSchema = z
97
163
  description: z
98
164
  .string()
99
165
  .optional()
100
- .describe('Description converted from HTML to plain text, capped at 4,000 characters; absent when the record has none.'),
166
+ .describe('Description converted from HTML to plain text, first 4,000 characters; absent when the record has none.'),
101
167
  description_truncated: z
102
168
  .boolean()
103
169
  .optional()
104
- .describe('True when the description was cut at 4,000 characters. Present with description.'),
170
+ .describe('True when the description was cut at 4,000 characters; continuations carries a cursor for the rest. Present with description.'),
105
171
  description_length: z
106
172
  .number()
107
173
  .optional()
108
174
  .describe('Full plain-text length of the description in characters. Present with description.'),
109
175
  additional_descriptions: z
110
- .array(z
111
- .object({
112
- type: z.string().optional().describe('Description type, e.g. Notes or Methods.'),
113
- text: z.string().describe('Plain text, capped at 1,000 characters.'),
114
- })
115
- .describe('One additional description.'))
116
- .describe('Up to 5 additional descriptions.'),
176
+ .array(AdditionalDescriptionSchema)
177
+ .describe('First 5 additional descriptions, in record order (index 0 first).'),
178
+ additional_description_count: z.number().describe('Total additional descriptions.'),
117
179
  creators: z.array(PersonSchema).describe('First 25 creators.'),
118
180
  creator_count: z.number().describe('Total creators.'),
119
181
  contributors: z.array(PersonSchema).describe('First 25 contributors.'),
120
182
  contributor_count: z.number().describe('Total contributors.'),
121
- keywords: z.array(z.string()).describe('Up to 50 subject keywords.'),
183
+ keywords: z.array(z.string()).describe('First 50 subject keywords.'),
184
+ keyword_count: z.number().describe('Total subject keywords.'),
122
185
  rights: z
123
186
  .array(z
124
187
  .object({
@@ -146,52 +209,16 @@ const RecordSchema = z
146
209
  embargo_reason: z.string().optional().describe('Embargo reason, when active.'),
147
210
  })
148
211
  .describe('Access status and embargo.'),
149
- funding: z
150
- .array(z
151
- .object({
152
- funder_id: z.string().optional().describe('Funder ROR id.'),
153
- funder_name: z.string().optional().describe('Funder name.'),
154
- award_id: z
155
- .string()
156
- .optional()
157
- .describe('Award id (<funder-ror>::<number>) — feeds zenodo_search_records award.'),
158
- award_number: z.string().optional().describe('Grant number.'),
159
- award_acronym: z.string().optional().describe('Award acronym.'),
160
- award_title: z.string().optional().describe('Award title.'),
161
- award_program: z.string().optional().describe('Funding program.'),
162
- award_doi: z.string().optional().describe('Award DOI, e.g. 10.3030/101135562.'),
163
- award_url: z.string().optional().describe('Award landing page URL.'),
164
- })
165
- .describe('One funding entry.'))
166
- .describe('Up to 50 funding entries (funders and grants).'),
167
- related_identifiers: z
168
- .array(z
169
- .object({
170
- identifier: z
171
- .string()
172
- .describe('The related identifier (DOI, arXiv id, URL, PMID, …).'),
173
- scheme: z.string().optional().describe('Identifier scheme, e.g. doi, arxiv, url.'),
174
- relation: z
175
- .string()
176
- .optional()
177
- .describe('Relation type id, e.g. issupplementto or cites.'),
178
- resource_type: z.string().optional().describe('Resource type id of the related item.'),
179
- })
180
- .describe('One related identifier.'))
181
- .describe('First 50 related identifiers.'),
212
+ funding: z.array(FundingSchema).describe('First 50 funding entries (funders and grants).'),
213
+ funding_count: z.number().describe('Total funding entries.'),
214
+ related_identifiers: z.array(RelatedIdentifierSchema).describe('First 50 related identifiers.'),
182
215
  related_identifier_count: z.number().describe('Total related identifiers.'),
183
216
  code_repository: z
184
217
  .string()
185
218
  .optional()
186
219
  .describe('Source code repository URL; absent when none is linked.'),
187
- communities: z
188
- .array(z
189
- .object({
190
- slug: z.string().describe('Community slug — feeds zenodo_search_records community.'),
191
- title: z.string().optional().describe('Community title.'),
192
- })
193
- .describe('One community.'))
194
- .describe('Up to 25 communities holding this record.'),
220
+ communities: z.array(CommunitySchema).describe('First 25 communities holding this record.'),
221
+ community_count: z.number().describe('Total communities holding this record.'),
195
222
  versions: z
196
223
  .object({
197
224
  index: z
@@ -244,6 +271,225 @@ const RecordSchema = z
244
271
  zenodo_url: z.string().describe('Record landing page on zenodo.org.'),
245
272
  })
246
273
  .describe('The deposit’s metadata.');
274
+ const ContinuationSchema = z
275
+ .object({
276
+ field: FieldSchema.describe('The field that was cut.'),
277
+ index: z
278
+ .number()
279
+ .optional()
280
+ .describe('Position of the additional description whose text was cut (0 = first). Present only for an additional description’s text.'),
281
+ unit: UnitSchema.describe('characters for description text, entries for a list (additional_descriptions without index is the list).'),
282
+ shown: z
283
+ .number()
284
+ .describe('Characters or entries of the field returned so far, counted from its start; the cursor resumes here.'),
285
+ total: z.number().describe('Full length of the field in characters, or its total entries.'),
286
+ id: z
287
+ .string()
288
+ .describe('Record id the cursor is bound to; pass it as id beside the cursor, whatever id form this call took.'),
289
+ cursor: z
290
+ .string()
291
+ .describe('Pass as cursor, with this entry’s id, to get the next window of this field.'),
292
+ })
293
+ .describe('One field cut short in this response.');
294
+ const WindowSchema = z
295
+ .object({
296
+ field: FieldSchema.describe('The field this window continues.'),
297
+ index: z
298
+ .number()
299
+ .optional()
300
+ .describe('Position of the additional description whose text this window continues (0 = first). Present only for an additional description’s text.'),
301
+ unit: UnitSchema.describe('characters for a text window, entries for a list window.'),
302
+ offset: z.number().describe('Characters or entries of the field before this window.'),
303
+ shown: z.number().describe('Characters or entries in this window.'),
304
+ total: z.number().describe('Full length of the field in characters, or its total entries.'),
305
+ text: z
306
+ .string()
307
+ .optional()
308
+ .describe('Up to 8,000 characters of the field’s plain text, starting at offset. Present when unit is characters.'),
309
+ additional_descriptions: z
310
+ .array(AdditionalDescriptionSchema)
311
+ .optional()
312
+ .describe('Up to 5 additional descriptions starting at offset, each text cut at 1,000 characters. Present when field is additional_descriptions and unit is entries.'),
313
+ creators: z
314
+ .array(PersonSchema)
315
+ .optional()
316
+ .describe('Up to 25 creators starting at offset. Present when field is creators.'),
317
+ contributors: z
318
+ .array(PersonSchema)
319
+ .optional()
320
+ .describe('Up to 25 contributors starting at offset. Present when field is contributors.'),
321
+ keywords: z
322
+ .array(z.string())
323
+ .optional()
324
+ .describe('Up to 50 subject keywords starting at offset. Present when field is keywords.'),
325
+ funding: z
326
+ .array(FundingSchema)
327
+ .optional()
328
+ .describe('Up to 50 funding entries starting at offset. Present when field is funding.'),
329
+ related_identifiers: z
330
+ .array(RelatedIdentifierSchema)
331
+ .optional()
332
+ .describe('Up to 50 related identifiers starting at offset. Present when field is related_identifiers.'),
333
+ communities: z
334
+ .array(CommunitySchema)
335
+ .optional()
336
+ .describe('Up to 25 communities starting at offset. Present when field is communities.'),
337
+ })
338
+ .describe('One window of a field a previous response cut short.');
339
+ const isHighSurrogate = (code) => code >= 0xd800 && code <= 0xdbff;
340
+ /** `n`, or `n − 1` when cutting `text` at `n` would split a surrogate pair. */
341
+ const cutAt = (text, n) => n < text.length && isHighSurrogate(text.charCodeAt(n - 1)) ? n - 1 : n;
342
+ function additionalDescriptionView(d) {
343
+ return {
344
+ ...(d.type ? { type: d.type } : {}),
345
+ text: d.text.slice(0, cutAt(d.text, ADDITIONAL_DESCRIPTION_TEXT_CAP)),
346
+ length: d.text.length,
347
+ truncated: d.text.length > ADDITIONAL_DESCRIPTION_TEXT_CAP,
348
+ };
349
+ }
350
+ /** A continuation for `field` resuming at `shown`, its cursor bound to this record revision. */
351
+ function continuation(record, cut, limit) {
352
+ return {
353
+ ...cut,
354
+ id: record.recid,
355
+ cursor: encodeCursor({
356
+ offset: cut.shown,
357
+ limit,
358
+ recid: record.recid,
359
+ ...(record.revision !== undefined ? { rev: record.revision } : {}),
360
+ field: cut.field,
361
+ ...(cut.index !== undefined ? { index: cut.index } : {}),
362
+ }),
363
+ };
364
+ }
365
+ /** One page of a list field from `offset`, at the field's display cap. */
366
+ function listPage(record, field, offset) {
367
+ const page = (all, size, arm) => {
368
+ const items = all.slice(offset, offset + size);
369
+ return { arm: arm(items), size, shown: items.length, total: all.length };
370
+ };
371
+ switch (field) {
372
+ case 'additional_descriptions':
373
+ return page(record.additional_descriptions, ADDITIONAL_DESCRIPTIONS_CAP, (items) => ({
374
+ additional_descriptions: items.map(additionalDescriptionView),
375
+ }));
376
+ case 'creators':
377
+ return page(record.creators, PEOPLE_CAP, (creators) => ({ creators }));
378
+ case 'contributors':
379
+ return page(record.contributors, PEOPLE_CAP, (contributors) => ({ contributors }));
380
+ case 'keywords':
381
+ return page(record.keywords, LIST_CAP, (keywords) => ({ keywords }));
382
+ case 'funding':
383
+ return page(record.funding, LIST_CAP, (funding) => ({ funding }));
384
+ case 'related_identifiers':
385
+ return page(record.related_identifiers, LIST_CAP, (related_identifiers) => ({
386
+ related_identifiers,
387
+ }));
388
+ case 'communities':
389
+ return page(record.communities, COMMUNITIES_CAP, (communities) => ({ communities }));
390
+ }
391
+ }
392
+ /** Continuations for the cut texts among additional descriptions `from`…`to - 1`. */
393
+ function additionalTextCuts(record, from, to) {
394
+ return record.additional_descriptions.slice(from, to).flatMap((d, i) => d.text.length > ADDITIONAL_DESCRIPTION_TEXT_CAP
395
+ ? [
396
+ continuation(record, {
397
+ field: 'additional_descriptions',
398
+ index: from + i,
399
+ unit: 'characters',
400
+ shown: cutAt(d.text, ADDITIONAL_DESCRIPTION_TEXT_CAP),
401
+ total: d.text.length,
402
+ }, TEXT_WINDOW),
403
+ ]
404
+ : []);
405
+ }
406
+ /** Every field the record view cuts, in record order; files page through zenodo_list_files instead. */
407
+ function recordContinuations(record) {
408
+ const cuts = [];
409
+ const description = record.description;
410
+ if (description && description.length > DESCRIPTION_CAP) {
411
+ cuts.push(continuation(record, {
412
+ field: 'description',
413
+ unit: 'characters',
414
+ shown: cutAt(description, DESCRIPTION_CAP),
415
+ total: description.length,
416
+ }, TEXT_WINDOW));
417
+ }
418
+ for (const field of LIST_FIELDS) {
419
+ const { size, shown, total } = listPage(record, field, 0);
420
+ if (shown < total) {
421
+ cuts.push(continuation(record, { field, unit: 'entries', shown, total }, size));
422
+ }
423
+ if (field === 'additional_descriptions')
424
+ cuts.push(...additionalTextCuts(record, 0, shown));
425
+ }
426
+ return cuts;
427
+ }
428
+ /** The window a cursor names in `record`, or why it names none. */
429
+ function openWindow(record, state) {
430
+ const revision = (rev) => (rev === undefined ? '' : ` revision ${rev}`);
431
+ if (state.recid !== record.recid || state.rev !== record.revision) {
432
+ return `The cursor continues record ${state.recid}${revision(state.rev)}, but id resolves to record ${record.recid}${revision(record.revision)}; the cursor belongs to another record or the record has changed since it was issued.`;
433
+ }
434
+ const { field, index, offset } = state;
435
+ const name = `${field}${index === undefined ? '' : ` index ${index}`}`;
436
+ const pastEnd = `The cursor points past the end of ${name} in record ${record.recid}.`;
437
+ const noSuchField = `Record ${record.recid} has no ${name} for the cursor to continue.`;
438
+ const text = field === 'description' && index === undefined
439
+ ? record.description
440
+ : field === 'additional_descriptions' && index !== undefined
441
+ ? record.additional_descriptions[index]?.text
442
+ : undefined;
443
+ if (text !== undefined) {
444
+ if (offset >= text.length)
445
+ return pastEnd;
446
+ const end = cutAt(text, Math.min(offset + TEXT_WINDOW, text.length));
447
+ const cut = { field, ...(index === undefined ? {} : { index }), unit: 'characters' };
448
+ return {
449
+ window: {
450
+ ...cut,
451
+ offset,
452
+ shown: end - offset,
453
+ total: text.length,
454
+ text: text.slice(offset, end),
455
+ },
456
+ continuations: end < text.length
457
+ ? [continuation(record, { ...cut, shown: end, total: text.length }, TEXT_WINDOW)]
458
+ : [],
459
+ size: TEXT_WINDOW,
460
+ };
461
+ }
462
+ if (field === 'description' || index !== undefined)
463
+ return noSuchField;
464
+ const page = listPage(record, field, offset);
465
+ if (offset >= page.total)
466
+ return pastEnd;
467
+ const end = offset + page.shown;
468
+ return {
469
+ window: { field, unit: 'entries', offset, shown: page.shown, total: page.total, ...page.arm },
470
+ continuations: [
471
+ ...(end < page.total
472
+ ? [
473
+ continuation(record, { field, unit: 'entries', shown: end, total: page.total }, page.size),
474
+ ]
475
+ : []),
476
+ ...(field === 'additional_descriptions' ? additionalTextCuts(record, offset, end) : []),
477
+ ],
478
+ size: page.size,
479
+ };
480
+ }
481
+ /** The continuation state `cursor` carries, or undefined when it is not one this tool issued. */
482
+ function readCursor(cursor, ctx) {
483
+ let decoded;
484
+ try {
485
+ decoded = decodeCursor(cursor, ctx);
486
+ }
487
+ catch {
488
+ return;
489
+ }
490
+ const state = CursorStateSchema.safeParse(decoded);
491
+ return state.success ? state.data : undefined;
492
+ }
247
493
  /** Applies get_record's display caps to a normalized record. */
248
494
  function toRecordView(record, latestRecid) {
249
495
  const description = record.description;
@@ -262,29 +508,30 @@ function toRecordView(record, latestRecid) {
262
508
  ...(record.resource_type ? { resource_type: record.resource_type } : {}),
263
509
  ...(description
264
510
  ? {
265
- description: description.slice(0, DESCRIPTION_CAP),
511
+ description: description.slice(0, cutAt(description, DESCRIPTION_CAP)),
266
512
  description_truncated: description.length > DESCRIPTION_CAP,
267
513
  description_length: description.length,
268
514
  }
269
515
  : {}),
270
516
  additional_descriptions: record.additional_descriptions
271
517
  .slice(0, ADDITIONAL_DESCRIPTIONS_CAP)
272
- .map((d) => ({
273
- ...(d.type ? { type: d.type } : {}),
274
- text: d.text.slice(0, ADDITIONAL_DESCRIPTION_TEXT_CAP),
275
- })),
518
+ .map(additionalDescriptionView),
519
+ additional_description_count: record.additional_descriptions.length,
276
520
  creators: record.creators.slice(0, PEOPLE_CAP),
277
521
  creator_count: record.creators.length,
278
522
  contributors: record.contributors.slice(0, PEOPLE_CAP),
279
523
  contributor_count: record.contributors.length,
280
524
  keywords: record.keywords.slice(0, LIST_CAP),
525
+ keyword_count: record.keywords.length,
281
526
  rights: record.rights,
282
527
  access: record.access,
283
528
  funding: record.funding.slice(0, LIST_CAP),
529
+ funding_count: record.funding.length,
284
530
  related_identifiers: record.related_identifiers.slice(0, LIST_CAP),
285
531
  related_identifier_count: record.related_identifiers.length,
286
532
  ...(record.code_repository ? { code_repository: record.code_repository } : {}),
287
533
  communities: record.communities.slice(0, COMMUNITIES_CAP),
534
+ community_count: record.communities.length,
288
535
  versions: { ...record.versions, ...(latestRecid ? { latest_recid: latestRecid } : {}) },
289
536
  ...(record.stats ? { stats: record.stats } : {}),
290
537
  files: {
@@ -315,9 +562,84 @@ function renderPerson(p) {
315
562
  parts.push(`— ${affiliations.join('; ')}`);
316
563
  return `- ${parts.join(' ')}`;
317
564
  }
565
+ function renderFunding(f) {
566
+ const funder = [
567
+ f.funder_name ? inline(f.funder_name) : undefined,
568
+ f.funder_id ? `(${inline(f.funder_id)})` : undefined,
569
+ ]
570
+ .filter(Boolean)
571
+ .join(' ');
572
+ const award = [
573
+ f.award_acronym ? inline(f.award_acronym) : undefined,
574
+ f.award_number ? `no. ${inline(f.award_number)}` : undefined,
575
+ f.award_id ? `[award id ${inline(f.award_id)}]` : undefined,
576
+ f.award_program ? `program ${inline(f.award_program)}` : undefined,
577
+ f.award_doi ? `DOI ${inline(f.award_doi)}` : undefined,
578
+ f.award_url ? inline(f.award_url) : undefined,
579
+ ]
580
+ .filter(Boolean)
581
+ .join(', ');
582
+ const lines = [`- ${funder || 'Unnamed funder'}${award ? ` — ${award}` : ''}`];
583
+ if (f.award_title) {
584
+ lines.push(hasLineBreak(f.award_title)
585
+ ? quoteBlock(f.award_title, ' Award title (untrusted):')
586
+ : ` Award title: ${f.award_title}`);
587
+ }
588
+ return lines;
589
+ }
590
+ function renderRelated(x) {
591
+ return `- ${inline(x.relation ?? 'related')}: ${inline(x.identifier)}${x.scheme ? ` (${inline(x.scheme)})` : ''}${x.resource_type ? ` [${inline(x.resource_type)}]` : ''}`;
592
+ }
593
+ function renderCommunities(communities) {
594
+ return communities
595
+ .map((c) => `${inline(c.slug)}${c.title ? ` (${inline(c.title)})` : ''}`)
596
+ .join('; ');
597
+ }
598
+ /** One additional description at its position in the record, with its length and any cut. */
599
+ function renderAdditionalDescription(d, index, total) {
600
+ return [
601
+ '',
602
+ `### Additional description${d.type ? ` — ${inline(d.type)}` : ''} (index ${index} of ${total}: ${d.length} chars${d.truncated ? ', truncated to 1,000' : ''})`,
603
+ quoteBlock(d.text),
604
+ ];
605
+ }
606
+ /** Every continuation of one response names the same record id, so the heading names it once. */
607
+ function renderContinuations(continuations) {
608
+ return [
609
+ '',
610
+ `### Continuations (pass a cursor with id ${continuations[0]?.id} for the next window)`,
611
+ ...continuations.map((c) => `- ${c.field}${c.index === undefined ? '' : ` index ${c.index}`}: ${c.shown} of ${c.total} ${c.unit} returned so far — cursor: \`${c.cursor}\``),
612
+ ];
613
+ }
614
+ function renderWindow(w) {
615
+ const lines = [
616
+ '',
617
+ `### Window: ${w.field}${w.index === undefined ? '' : ` index ${w.index}`} — ${w.unit} from offset ${w.offset}, ${w.shown} shown of ${w.total}`,
618
+ ];
619
+ if (w.text !== undefined)
620
+ lines.push(quoteBlock(w.text));
621
+ if (w.additional_descriptions) {
622
+ w.additional_descriptions.forEach((d, i) => {
623
+ lines.push(...renderAdditionalDescription(d, w.offset + i, w.total));
624
+ });
625
+ }
626
+ if (w.creators)
627
+ lines.push(...w.creators.map(renderPerson));
628
+ if (w.contributors)
629
+ lines.push(...w.contributors.map(renderPerson));
630
+ if (w.keywords)
631
+ lines.push(`**Keywords:** ${w.keywords.map(inline).join('; ')}`);
632
+ if (w.funding)
633
+ lines.push(...w.funding.flatMap(renderFunding));
634
+ if (w.related_identifiers)
635
+ lines.push(...w.related_identifiers.map(renderRelated));
636
+ if (w.communities)
637
+ lines.push(`**Communities:** ${renderCommunities(w.communities)}`);
638
+ return lines;
639
+ }
318
640
  export const getRecord = tool('zenodo_get_record', {
319
641
  title: 'Get a Zenodo record',
320
- description: 'Resolve one Zenodo deposit from a record id, a Zenodo DOI (10.5281/zenodo.N), a concept DOI or concept record id (resolves to the latest version), another DOI deposited on Zenodo, or a zenodo.org or doi.org URL, and return its full metadata: description, creators with ORCIDs and ROR affiliations, license, access and embargo, funding and grants, related identifiers, communities, version position, usage counts, and the first 25 files. Optionally includes a formatted citation. A miss (an unknown id, a deleted record with its removal tombstone, restricted metadata, or a DOI not registered on Zenodo) returns found: false with guidance instead of an error.',
642
+ description: 'Resolve one Zenodo deposit from a record id, a Zenodo DOI (10.5281/zenodo.N), a concept DOI or concept record id (resolves to the latest version), another DOI deposited on Zenodo, or a zenodo.org or doi.org URL, and return its full metadata: description, creators with ORCIDs and ROR affiliations, license, access and embargo, funding and grants, related identifiers, communities, version position, usage counts, and the first 25 files. Optionally includes a formatted citation. Long text and lists are cut at display caps with their totals reported; every cut except the file list (paged with zenodo_list_files) is listed in continuations with a cursor and the record id it is bound to, and passing both back returns the next window of that field (8,000 characters of text, or the next page of entries) in place of the record. A miss (an unknown id, a deleted record with its removal tombstone, restricted metadata, or a DOI not registered on Zenodo) returns found: false with guidance instead of an error.',
321
643
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
322
644
  input: z.object({
323
645
  id: z
@@ -329,6 +651,9 @@ export const getRecord = tool('zenodo_get_record', {
329
651
  citation_style: z
330
652
  .preprocess(enumPreprocess(CITATION_STYLES), z.enum(CITATION_STYLES).optional())
331
653
  .describe('Include a formatted citation of the resolved version: bibtex, csl-json, or a text style (apa, chicago-author-date, harvard-cite-them-right, ieee, modern-language-association, nature). Case, spaces, hyphens, and underscores are ignored when matching (BibTeX, APA, CSL JSON). Omit for none.'),
654
+ cursor: z
655
+ .preprocess(blankToUndefined, z.string().max(500).optional())
656
+ .describe('A continuations[].cursor from an earlier zenodo_get_record response, passed unchanged with that continuation’s id as id. Returns that field’s next window (8,000 characters of text, or the next page of entries) as window, in place of record. Omit to get the record.'),
332
657
  }),
333
658
  output: z.object({
334
659
  found: z.boolean().describe('True when the record resolved.'),
@@ -345,7 +670,12 @@ export const getRecord = tool('zenodo_get_record', {
345
670
  .describe('Why nothing resolved. Present when not found.'),
346
671
  guidance: z.string().optional().describe('What to do next. Present when not found.'),
347
672
  tombstone: TombstoneSchema.optional().describe('Removal tombstone. Present when miss_kind is deleted.'),
348
- record: RecordSchema.optional().describe('The record. Present when found.'),
673
+ record: RecordSchema.optional().describe('The record. Present when found and no cursor was passed.'),
674
+ window: WindowSchema.optional().describe('The next window of the field the cursor continues. Present when found and a cursor was passed, in place of record.'),
675
+ continuations: z
676
+ .array(ContinuationSchema)
677
+ .optional()
678
+ .describe('Every field this response cut short, each with a cursor for the rest. Absent when nothing was cut; files are paged with zenodo_list_files instead.'),
349
679
  citation: z
350
680
  .string()
351
681
  .optional()
@@ -356,9 +686,15 @@ export const getRecord = tool('zenodo_get_record', {
356
686
  .describe('The citation style used. Present with citation.'),
357
687
  }),
358
688
  enrichment: {
359
- truncated: z.boolean().describe('True when the record has more files than the 25 shown.'),
360
- shown: z.number().describe('File entries shown.'),
361
- cap: z.number().describe('Maximum file entries shown (25).'),
689
+ truncated: z
690
+ .boolean()
691
+ .describe('Without cursor: true when anything was cut, files past the 25 shown or any field in continuations. With cursor: true when continuations lists a next step, because the field continues past this window or an additional description on this page was cut.'),
692
+ shown: z
693
+ .number()
694
+ .describe('Without cursor: file entries shown. With cursor: characters or entries in the window.'),
695
+ cap: z
696
+ .number()
697
+ .describe('Without cursor: maximum file entries shown (25). With cursor: the window size (8,000 characters, or the field’s page of entries).'),
362
698
  notice: z.string().optional().describe('File access notes or a pointer to zenodo_list_files.'),
363
699
  },
364
700
  errors: [
@@ -368,6 +704,12 @@ export const getRecord = tool('zenodo_get_record', {
368
704
  when: 'id matches no accepted form, or is a GitHub-badge latestdoi id or a non-zenodo.org host',
369
705
  recovery: 'Pass a Zenodo record id (22705923), a DOI (10.5281/zenodo.22705923), or a zenodo.org/records URL as id; for a title or keyword, use zenodo_search_records.',
370
706
  },
707
+ {
708
+ reason: 'invalid_cursor',
709
+ code: JsonRpcErrorCode.ValidationError,
710
+ when: 'cursor is not one this tool returned in continuations, belongs to another record, or the record was edited after the cursor was issued',
711
+ recovery: 'Pass the cursor with the record id the message names as id to continue that record; otherwise call zenodo_get_record with this id and no cursor for this record’s own continuations, and pass one of those cursors unchanged with the id it lists.',
712
+ },
371
713
  {
372
714
  reason: 'record_unavailable',
373
715
  code: JsonRpcErrorCode.ServiceUnavailable,
@@ -388,7 +730,11 @@ export const getRecord = tool('zenodo_get_record', {
388
730
  ctx.enrich({ truncated: false, shown: 0, cap: FILES_CAP });
389
731
  const ref = parseRecordRef(input.id);
390
732
  if (ref.kind === 'invalid') {
391
- throw ctx.fail('invalid_identifier', ref.message, ctx.recoveryFor('invalid_identifier'));
733
+ throw ctx.fail('invalid_identifier', ref.message);
734
+ }
735
+ const cursor = input.cursor === undefined ? undefined : readCursor(input.cursor, ctx);
736
+ if (input.cursor !== undefined && !cursor) {
737
+ throw ctx.fail('invalid_cursor', 'The cursor is not a zenodo_get_record continuation; it may be cut short or altered.');
392
738
  }
393
739
  const service = getZenodoService();
394
740
  const inputKind = ref.inputKind;
@@ -414,12 +760,32 @@ export const getRecord = tool('zenodo_get_record', {
414
760
  record = lookup.record;
415
761
  resolvedFrom = record.concept_recid === ref.recid ? 'concept_to_latest' : 'version';
416
762
  }
763
+ const opened = cursor && openWindow(record, cursor);
764
+ if (typeof opened === 'string')
765
+ throw ctx.fail('invalid_cursor', opened);
417
766
  const [latestRecid, citation] = await Promise.all([
418
- record.versions.is_latest === false ? service.latestRecid(record.recid, ctx) : undefined,
767
+ !opened && record.versions.is_latest === false
768
+ ? service.latestRecid(record.recid, ctx)
769
+ : undefined,
419
770
  input.citation_style
420
771
  ? service.getCitation(record.recid, input.citation_style, ctx)
421
772
  : undefined,
422
773
  ]);
774
+ const cited = citation !== undefined && input.citation_style
775
+ ? { citation, citation_style: input.citation_style }
776
+ : {};
777
+ if (opened) {
778
+ const { window, continuations, size } = opened;
779
+ ctx.enrich({ truncated: continuations.length > 0, shown: window.shown, cap: size });
780
+ return {
781
+ found: true,
782
+ input_kind: inputKind,
783
+ resolved_from: resolvedFrom,
784
+ window,
785
+ ...(continuations.length ? { continuations } : {}),
786
+ ...cited,
787
+ };
788
+ }
423
789
  const view = toRecordView(record, latestRecid);
424
790
  ctx.enrich({ shown: view.files.shown });
425
791
  const fileCount = record.files.count ?? record.files.entries.length;
@@ -431,17 +797,19 @@ export const getRecord = tool('zenodo_get_record', {
431
797
  ctx.enrich.truncated({
432
798
  shown: view.files.shown,
433
799
  cap: FILES_CAP,
434
- guidance: `Showing ${FILES_CAP} of ${fileCount} files; page the rest with zenodo_list_files.`,
800
+ guidance: `Showing ${FILES_CAP} of ${fileCount.toLocaleString('en-US')} files; page the rest with zenodo_list_files.`,
435
801
  });
436
802
  }
803
+ const continuations = recordContinuations(record);
804
+ if (continuations.length)
805
+ ctx.enrich({ truncated: true });
437
806
  return {
438
807
  found: true,
439
808
  input_kind: inputKind,
440
809
  resolved_from: resolvedFrom,
441
810
  record: view,
442
- ...(citation !== undefined && input.citation_style
443
- ? { citation, citation_style: input.citation_style }
444
- : {}),
811
+ ...(continuations.length ? { continuations } : {}),
812
+ ...cited,
445
813
  };
446
814
  },
447
815
  format: (result) => {
@@ -503,43 +871,17 @@ export const getRecord = tool('zenodo_get_record', {
503
871
  if (r.contributors.length || r.contributor_count) {
504
872
  lines.push('', `### Contributors (${r.contributor_count})`, ...r.contributors.map(renderPerson));
505
873
  }
506
- if (r.keywords.length)
507
- lines.push('', `**Keywords:** ${r.keywords.map(inline).join('; ')}`);
508
- if (r.communities.length) {
509
- lines.push(`**Communities:** ${r.communities.map((c) => `${inline(c.slug)}${c.title ? ` (${inline(c.title)})` : ''}`).join('; ')}`);
874
+ if (r.keywords.length || r.keyword_count) {
875
+ lines.push('', `**Keywords (${r.keyword_count}):** ${r.keywords.map(inline).join('; ')}`);
510
876
  }
511
- if (r.funding.length) {
512
- lines.push('', '### Funding');
513
- for (const f of r.funding) {
514
- const funder = [
515
- f.funder_name ? inline(f.funder_name) : undefined,
516
- f.funder_id ? `(${inline(f.funder_id)})` : undefined,
517
- ]
518
- .filter(Boolean)
519
- .join(' ');
520
- const award = [
521
- f.award_acronym ? inline(f.award_acronym) : undefined,
522
- f.award_number ? `no. ${inline(f.award_number)}` : undefined,
523
- f.award_id ? `[award id ${inline(f.award_id)}]` : undefined,
524
- f.award_program ? `program ${inline(f.award_program)}` : undefined,
525
- f.award_doi ? `DOI ${inline(f.award_doi)}` : undefined,
526
- f.award_url ? inline(f.award_url) : undefined,
527
- ]
528
- .filter(Boolean)
529
- .join(', ');
530
- lines.push(`- ${funder || 'Unnamed funder'}${award ? ` — ${award}` : ''}`);
531
- if (f.award_title) {
532
- lines.push(hasLineBreak(f.award_title)
533
- ? quoteBlock(f.award_title, ' Award title (untrusted):')
534
- : ` Award title: ${f.award_title}`);
535
- }
536
- }
877
+ if (r.communities.length || r.community_count) {
878
+ lines.push(`**Communities (${r.community_count}):** ${renderCommunities(r.communities)}`);
879
+ }
880
+ if (r.funding.length || r.funding_count) {
881
+ lines.push('', `### Funding (${r.funding_count})`, ...r.funding.flatMap(renderFunding));
537
882
  }
538
883
  if (r.related_identifiers.length || r.related_identifier_count) {
539
- lines.push('', `### Related identifiers (${r.related_identifier_count})`);
540
- for (const x of r.related_identifiers) {
541
- lines.push(`- ${inline(x.relation ?? 'related')}: ${inline(x.identifier)}${x.scheme ? ` (${inline(x.scheme)})` : ''}${x.resource_type ? ` [${inline(x.resource_type)}]` : ''}`);
542
- }
884
+ lines.push('', `### Related identifiers (${r.related_identifier_count})`, ...r.related_identifiers.map(renderRelated));
543
885
  }
544
886
  if (r.description) {
545
887
  lines.push('', `### Description (${r.description_length ?? r.description.length} chars${r.description_truncated ? ', truncated to 4,000' : ''})`, quoteBlock(r.description));
@@ -547,15 +889,19 @@ export const getRecord = tool('zenodo_get_record', {
547
889
  else if (r.description_length !== undefined || r.description_truncated !== undefined) {
548
890
  lines.push(`Description length: ${r.description_length}, truncated: ${r.description_truncated}`);
549
891
  }
550
- for (const d of r.additional_descriptions) {
551
- lines.push('', `### Additional description${d.type ? ` — ${inline(d.type)}` : ''}`, quoteBlock(d.text));
552
- }
892
+ r.additional_descriptions.forEach((d, i) => {
893
+ lines.push(...renderAdditionalDescription(d, i, r.additional_description_count));
894
+ });
553
895
  const files = r.files;
554
896
  lines.push('', `### Files (enabled: ${files.enabled}, count: ${files.count ?? 'not disclosed'}, total bytes: ${files.total_bytes ?? 'not disclosed'}, shown: ${files.shown})`);
555
897
  for (const f of files.entries) {
556
898
  lines.push(`- ${inline(f.key)} — ${f.size ?? '?'} bytes${f.mimetype ? `, ${inline(f.mimetype)}` : ''}${f.md5 ? `, md5 ${inline(f.md5)}` : ''} — ${f.download_url}`);
557
899
  }
558
900
  }
901
+ if (result.window)
902
+ lines.push(...renderWindow(result.window));
903
+ if (result.continuations)
904
+ lines.push(...renderContinuations(result.continuations));
559
905
  if (result.citation) {
560
906
  lines.push('', quoteBlock(result.citation, `Citation (${result.citation_style ?? 'requested style'}, untrusted):`));
561
907
  }