@cyanheads/ris-austria-mcp-server 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,17 +3,18 @@
3
3
  * or its rendition URLs, with explicit binding status and the amtssigniert Authentisch PDF
4
4
  * surfaced wherever it exists. Two addressing modes route to the same fetch path:
5
5
  * `document_number` + `application`, or a `document_url` from a result's content_urls — the
6
- * latter also carrying the one route to a draft's companion documents (`Materialien_`,
7
- * `Schreiben_`, `Anlagen_`), whose per-record opaque filenames no document number reaches.
6
+ * latter also carrying the one route to a draft's companion documents, whose per-record
7
+ * opaque filenames no document number reaches (see {@link COMPANION_STEMS} for the shapes
8
+ * RIS files them under).
8
9
  * Format availability varies by application (full text · authentic-PDF-only · PDF-only ·
9
10
  * metadata-only) — a text-format request against a non-text application degrades to a
10
11
  * `format_unavailable` notice on a success result, never an error. Markdown conversion drops
11
12
  * the `.sr-only` screen-reader twin RIS ships beside every abbreviated citation, keeping the
12
- * visible form; `html` and `xml` pass through untouched. Oversized markdown carrying
13
- * §/Artikel/Anlage structure overflows to a retrievable section outline (never a silent
14
- * truncation, and never for a rendition with no such headings); a follow-up call
15
- * with `sections:[…]` returns just the chosen sections, and a selector matching nothing gets
16
- * the outline back with a notice rather than the whole document. The shared
13
+ * visible form; `html` and `xml` pass through untouched. Oversized markdown overflows to a
14
+ * retrievable outline rather than a silent truncation — its §/Artikel/Anlage sections, or
15
+ * `Part n of N` byte windows where it carries no such headings; a follow-up call with
16
+ * `sections:[…]` returns just the chosen entries, and a selector matching nothing gets the
17
+ * outline back with a notice rather than the whole document. The shared
17
18
  * {@link renderDocument} helper backs both this tool and the `ris://document/…` resource.
18
19
  * @module mcp-server/tools/definitions/ris-get-document
19
20
  */
@@ -35,18 +36,20 @@ const BINDING_STATUSES = [
35
36
  'translation',
36
37
  ];
37
38
  /**
38
- * Serialized-length budget for inlining document text. Above it, markdown overflows to a
39
- * §/Artikel/Anlage section outline instead of returning whole — but only when the rendition
40
- * segments into at least two of those sections. A rendition carrying none of them has nothing
41
- * to outline and returns whole however large: court decisions, gazette and announcement
42
- * bodies, and consolidated `§ 0` promulgation records all do, measured live to 399,972 bytes
43
- * on a Bvwg decision.
39
+ * Serialized-length budget for inlining document text, and the size of one addressable
40
+ * window. Above it, markdown overflows to an outline instead of returning whole — its
41
+ * §/Artikel/Anlage sections where it carries at least two such headings, else the byte
42
+ * windows {@link windowDocument} cuts, so a rendition the segmenter cannot split is
43
+ * retrievable rather than returned whole at any size (court decisions, gazette and
44
+ * announcement bodies, and consolidated `§ 0` promulgation records all carry no headings,
45
+ * measured live to 399,972 bytes on a Bvwg decision).
44
46
  *
45
47
  * Sized against what one tool result can carry into a working context (≈10K tokens of legal
46
48
  * prose), not against what counts as a pathological document: across live samples the
47
49
  * documents callers routinely read land well under it — consolidated-law sections at 2–3 KB,
48
50
  * drafts and bills at 6–36 KB — while the multi-act bills and long translations that motivated
49
- * the outline (71 KB–199 KB) route through it.
51
+ * the outline (71 KB–199 KB) route through it. One number serves both roles because a window
52
+ * is by definition one result's worth, which is what the budget already means.
50
53
  *
51
54
  * Handed to `outlineOnOverflow`, which measures `JSON.stringify` length (≈ UTF-8 bytes for
52
55
  * the mostly-Latin legal text); the exact `byte_size` is reported separately.
@@ -64,15 +67,37 @@ const APPLICATION_BY_SEGMENT = new Map(RIS_APPLICATIONS.flatMap((app) => app.con
64
67
  */
65
68
  const RENDITION_EXTENSIONS = new Set([...RIS_CONTENT_FORMATS, 'pdfsig']);
66
69
  /**
67
- * Filename stem of a companion document — the `Erläuterungen`, `Textgegenüberstellung`,
70
+ * Filename stems of a companion document — the `Erläuterungen`, `Textgegenüberstellung`,
68
71
  * `Vorblatt und WFA`, cover letter, and annex texts a `Begut` draft or `RegV` bill ships
69
- * beside its main text, surfaced on a `ris_search_drafts` record's `materials`. RIS names
70
- * them `{Materialien|Schreiben|Anlagen}_{NNNN}_{UUID}` (underscore-separated UUID), a shape
71
- * every one of 2,436 companion URLs sampled live across 200 draft records matches exactly.
72
- * The stem is opaque and per-record — the URL is the only handle, so nothing about a
73
- * companion is reconstructible from a document number.
72
+ * beside its main text, surfaced on a `ris_search_drafts` record's `materials`. The stem is
73
+ * opaque and per-record — the URL is the only handle, so nothing about a companion is
74
+ * reconstructible from a document number, and this list is what decides whether a passed
75
+ * `document_url` may address a file other than the main rendition. Three shapes, every one
76
+ * of the 23,483 companion URLs across the full live Begut + RegV corpus (7,185 records,
77
+ * every page of both applications, drafts from 2003 through 2026):
78
+ *
79
+ * - `{Materialien|Schreiben|Anlagen}_{NNNN}_{UUID}` — 4,763, all on records from 2026.
80
+ * - `COO_{n}_{n}_{n}_{n}` — 18,719, the Fabasoft object address, on every record before
81
+ * 2026. Not a date: the leading group is a constant store id, so nothing here is a year.
82
+ * Refusing it refused four companion URLs in five (#35).
83
+ * - `{NNNN}_{UUID}` — 1, the ordinal + UUID tail with no prefix, on one RegV record.
84
+ *
85
+ * Each alternative is anchored end to end over hex digits, `_`, and its own literal prefix,
86
+ * so an accepted stem can carry no `/`, `.`, `%`, backslash, or null byte. Widening happens
87
+ * by adding a whole alternative, never by relaxing one into a prefix or substring test — RIS
88
+ * names the per-record inline formula images `Material-COO_…` and `Anlage-COO_…`, ~55 on
89
+ * every draft record, and a passed filename matching none of these shapes is content this
90
+ * tool would otherwise fetch blind inside the document's folder.
74
91
  */
75
- const COMPANION_STEM = /^(?:Materialien|Schreiben|Anlagen)_\d{4}_[0-9A-F]{8}(?:_[0-9A-F]{4}){3}_[0-9A-F]{12}$/iu;
92
+ const COMPANION_STEMS = [
93
+ /^(?:Materialien|Schreiben|Anlagen)_\d{4}_[0-9A-F]{8}(?:_[0-9A-F]{4}){3}_[0-9A-F]{12}$/iu,
94
+ /^COO(?:_\d{1,10}){4}$/u,
95
+ /^\d{4}_[0-9A-F]{8}(?:_[0-9A-F]{4}){3}_[0-9A-F]{12}$/iu,
96
+ ];
97
+ /** Whether a `document_url`'s filename stem addresses a companion document. */
98
+ function isCompanionStem(stem) {
99
+ return COMPANION_STEMS.some((pattern) => pattern.test(stem));
100
+ }
76
101
  /**
77
102
  * Drop RIS's screen-reader twin, pass every other span through untouched. RIS renditions
78
103
  * carry each abbreviated legal reference twice — the visible citation in
@@ -159,25 +184,34 @@ export function parseDocumentUrl(url, contentBaseUrl) {
159
184
  }
160
185
  // A folder URL (…/{segment}/{documentNumber}[/]) carries no trailing filename and resolves
161
186
  // fine. A trailing filename addresses one of exactly two things: the main-document
162
- // rendition, named {documentNumber}.{ext}, or a companion document, named with one of the
163
- // three {@link COMPANION_STEM} prefixes. Anything else — an unknown stem, an unknown
164
- // extension, deeper nesting — is content this tool would otherwise fetch blind or silently
165
- // swap for the main document, so it is rejected here.
187
+ // rendition, named {documentNumber}.{ext}, or a companion document, named in one of the
188
+ // {@link COMPANION_STEMS} shapes. Anything else — an unknown stem, an unknown extension,
189
+ // deeper nesting — is content this tool would otherwise fetch blind or silently swap for
190
+ // the main document, so it is rejected here. Each of the three causes reports itself: they
191
+ // ask different things of the caller, and a caller who passed a URL a search result handed
192
+ // them learns nothing from being told to pass a URL a search result handed them.
166
193
  if (filename !== undefined) {
167
194
  const decodedFilename = decodePathSegment(filename);
168
195
  if (decodedFilename === undefined) {
169
196
  return { error: `"${filename}" is not a decodable filename (malformed % escape)` };
170
197
  }
198
+ if (rest.length > 0) {
199
+ return {
200
+ error: `"${decodedFilename}/${rest.join('/')}" nests below the document folder — RIS files every rendition directly in /Dokumente/${segment}/${documentNumber}/, so drop the trailing path`,
201
+ };
202
+ }
171
203
  const dot = decodedFilename.lastIndexOf('.');
172
204
  const stem = dot === -1 ? decodedFilename : decodedFilename.slice(0, dot);
173
205
  const extension = dot === -1 ? '' : decodedFilename.slice(dot + 1).toLowerCase();
206
+ if (!RENDITION_EXTENSIONS.has(extension)) {
207
+ return {
208
+ error: `"${decodedFilename}" does not end in a RIS rendition extension (${[...RENDITION_EXTENSIONS].join(', ')}) — the extension is only checked and then discarded, so pass any rendition URL of the document you want and select the rendition with format`,
209
+ };
210
+ }
174
211
  const isMainDocument = stem === documentNumber;
175
- const addressable = rest.length === 0 &&
176
- RENDITION_EXTENSIONS.has(extension) &&
177
- (isMainDocument || COMPANION_STEM.test(stem));
178
- if (!addressable) {
212
+ if (!isMainDocument && !isCompanionStem(stem)) {
179
213
  return {
180
- error: `"${decodedFilename}" is neither a main-document rendition (${documentNumber}.{ext}) nor a companion document (Materialien_/Schreiben_/Anlagen_…) — pass a URL exactly as returned in a result's content_urls or materials`,
214
+ error: `"${decodedFilename}" is neither ${documentNumber}'s own rendition (${documentNumber}.${extension}) nor one of its companion documents — RIS assigns companion filenames opaquely, so one cannot be composed by hand. Copy a materials[].url from a ris_search_drafts record verbatim, or drop the filename to read ${documentNumber} itself`,
181
215
  };
182
216
  }
183
217
  if (!isMainDocument) {
@@ -194,10 +228,13 @@ function authenticPdfFrom(pdfUrl) {
194
228
  * Construct the rendition URLs for a document from its application's format availability.
195
229
  * `buildDocumentContentUrl` throws `ValidationError` for an unsafe document number.
196
230
  *
197
- * A companion document (`contentName`) is published as XML, HTML and PDF; RTF only
198
- * sometimes (present on 291 of 715 companion references sampled live) and the signed
199
- * `.pdfsig` never — RIS 404s the renditions it does not list, so only the three it always
200
- * carries are constructed.
231
+ * A companion document (`contentName`) is published as XML, HTML and PDF on 20,654 of the
232
+ * 23,483 companions in the live Begut + RegV corpus — RTF on 61% of those, the signed
233
+ * `.pdfsig` on none — so those three are what gets constructed. The other 2,829 (12.0%,
234
+ * nearly all Begut covering letters) carry PDF, or PDF and RTF, and nothing else: RIS 404s a
235
+ * rendition it does not list, so the constructed XML and HTML are dead for them and only the
236
+ * PDF resolves. Which case a companion falls in is legible only from the search result that
237
+ * listed it, never from the URL, so the same three are built either way.
201
238
  */
202
239
  function buildRenditionUrls(service, app, documentNumber, contentName) {
203
240
  if (app.formats === 'none')
@@ -265,6 +302,7 @@ export function segmentDocument(text) {
265
302
  const seen = (counts.get(name) ?? 0) + 1;
266
303
  counts.set(name, seen);
267
304
  sections.push({
305
+ kind: 'section',
268
306
  name: seen > 1 ? `${name} (${seen})` : name,
269
307
  text: slice,
270
308
  bytes: encoder.encode(slice).length,
@@ -286,24 +324,142 @@ export function segmentDocument(text) {
286
324
  });
287
325
  return sections;
288
326
  }
327
+ /**
328
+ * Shape of a window name. `Part n of N` is the one entry name {@link segmentDocument} can
329
+ * never produce — a section name always opens with a §/Artikel/Anlage marker — so it also
330
+ * identifies a roster's kind on a surface that carries only names and byte sizes.
331
+ */
332
+ const WINDOW_NAME = /^Part \d+ of \d+$/u;
333
+ /**
334
+ * Cut rendered document text into near-even windows snapped to line boundaries, named
335
+ * `Part 1 of N` … `Part N of N` in document order. The retrieval path for a rendition
336
+ * {@link segmentDocument} cannot split; returns `[]` under the budget and for text that
337
+ * carries no line break to cut on, which the caller reads as "nothing to outline".
338
+ *
339
+ * Even windows rather than fill-the-budget windows: filling leaves a runt tail (a 40,440-byte
340
+ * decision splits 39,602 + 838, buying a round-trip for a scrap) while an even split of the
341
+ * same document is 20,196 + 20,244. Each window's target is the bytes still unassigned over
342
+ * the windows still to cut, so the remainder that snapping to the next line break carries
343
+ * forward is spread across the rest instead of piling onto the tail.
344
+ *
345
+ * Two rules keep the budget a ceiling rather than an aspiration. A window never takes a line
346
+ * that would carry it past the budget, so snapping to the next line break cannot overshoot;
347
+ * and when the tail still lands over — the target sitting just under the budget leaves the
348
+ * last window everything the earlier ones declined — the window count rises until every
349
+ * window fits. A line longer than the budget is the exception, and the reason the count stops
350
+ * rising rather than climbing to one window per line: splitting inside a line would put the
351
+ * cut where the caller cannot see it, so such a line sits alone in a window that goes over.
352
+ *
353
+ * Window bytes sum to the document's byte size exactly — the windows concatenate back to the
354
+ * input, so nothing is dropped or double-counted.
355
+ */
356
+ export function windowDocument(text, budget) {
357
+ const encoder = new TextEncoder();
358
+ const total = encoder.encode(text).length;
359
+ if (total <= budget)
360
+ return [];
361
+ const lines = text.split('\n');
362
+ if (lines.length < 2)
363
+ return []; // no line break to cut on
364
+ const lineBytes = lines.map((line, i) => encoder.encode(line).length + (i === lines.length - 1 ? 0 : 1));
365
+ /** Line-index boundaries of `count` runs, each targeting an even share of what is left. */
366
+ const cut = (count) => {
367
+ const bounds = [0];
368
+ let cursor = 0;
369
+ let consumed = 0;
370
+ for (let remaining = count; remaining > 1; remaining -= 1) {
371
+ const target = (total - consumed) / remaining;
372
+ const limit = lines.length - (remaining - 1); // leave one line per remaining window
373
+ let bytes = 0;
374
+ while (cursor < limit && bytes < target) {
375
+ const next = lineBytes[cursor] ?? 0;
376
+ if (bytes > 0 && bytes + next > budget)
377
+ break;
378
+ bytes += next;
379
+ cursor += 1;
380
+ }
381
+ bounds.push(cursor);
382
+ consumed += bytes;
383
+ }
384
+ bounds.push(lines.length);
385
+ return bounds;
386
+ };
387
+ /** Every run fits the budget, or is the lone over-budget line nothing can split. */
388
+ const fits = (bounds) => bounds.slice(0, -1).every((from, i) => {
389
+ const to = bounds[i + 1] ?? lines.length;
390
+ return to - from <= 1 || lineBytes.slice(from, to).reduce((sum, n) => sum + n, 0) <= budget;
391
+ });
392
+ // A document with fewer lines than the budget asks windows for cannot have more windows
393
+ // than it has lines — every line over the budget is one nothing can split.
394
+ const floor = Math.min(Math.ceil(total / budget), lines.length);
395
+ let bounds = cut(floor);
396
+ for (let count = floor + 1; count <= lines.length && !fits(bounds); count += 1) {
397
+ bounds = cut(count);
398
+ }
399
+ const slices = bounds
400
+ .slice(0, -1)
401
+ .map((from, i) => {
402
+ const to = bounds[i + 1] ?? lines.length;
403
+ return lines.slice(from, to).join('\n') + (to < lines.length ? '\n' : '');
404
+ })
405
+ .filter((slice) => slice !== '');
406
+ if (slices.length < 2)
407
+ return [];
408
+ return slices.map((slice, i) => ({
409
+ kind: 'window',
410
+ name: `Part ${i + 1} of ${slices.length}`,
411
+ text: slice,
412
+ bytes: encoder.encode(slice).length,
413
+ }));
414
+ }
415
+ /**
416
+ * The one roster of what a caller can address in a rendition — §/Artikel/Anlage sections
417
+ * where the markdown carries at least two of them, else the byte windows an over-budget
418
+ * document is cut into. Both the outline and the `sections:[…]` selector resolve through
419
+ * here, so every entry the outline advertises is retrievable by name.
420
+ *
421
+ * Markdown only. `html` and `xml` are byte-identical passthroughs of the published artifact
422
+ * and a mid-document slice of either is not well-formed, so they stay whole at any size and
423
+ * a caller who needs one has `content_urls`.
424
+ *
425
+ * The two-entry floor matches `outlineOnOverflow`'s own short-circuit: an outline whose only
426
+ * possible `sections` argument returns the same bytes costs a round-trip and buys nothing.
427
+ */
428
+ export function addressableSections(text, format) {
429
+ const sections = segmentDocument(text);
430
+ if (sections.length >= 2)
431
+ return sections;
432
+ return format === 'markdown' ? windowDocument(text, OUTLINE_BUDGET_BYTES) : [];
433
+ }
289
434
  /**
290
435
  * Resolve a `sections:[…]` selector against rendered document text — the selective-retrieval
291
- * counterpart to the outline. The handler re-fetches the document (the upstream query is
292
- * deterministic, so it reproduces the same text) and slices it to the requested sections.
436
+ * counterpart to the outline, resolving through the same {@link addressableSections} roster
437
+ * so every advertised name retrieves. The handler re-fetches the document (the upstream query
438
+ * is deterministic, so it reproduces the same text) and slices it to the requested entries.
293
439
  * Reports the unmatched names and the full roster alongside the matched text so the handler
294
440
  * can disclose a mismatch instead of silently returning something the caller didn't ask for.
295
441
  */
296
- export function selectDocumentSections(text, want) {
297
- const sections = segmentDocument(text);
442
+ export function selectDocumentSections(text, want, format) {
443
+ const sections = addressableSections(text, format);
444
+ const windowed = sections[0]?.kind === 'window';
298
445
  const names = new Set(sections.map((section) => section.name));
299
446
  const wanted = new Set(want);
300
447
  const matched = sections.filter((section) => wanted.has(section.name));
448
+ const available = sections.map(({ name, bytes }) => ({ name, bytes }));
449
+ // Same split as the outline's: a section roster is a menu, so largest first puts the
450
+ // substantial units on top; a window roster is a reading sequence and keeps document order.
451
+ if (!windowed)
452
+ available.sort((a, b) => b.bytes - a.bytes);
301
453
  return {
302
- available: sections
303
- .map(({ name, bytes }) => ({ name, bytes }))
304
- .sort((a, b) => b.bytes - a.bytes),
305
- text: matched.map((section) => section.text).join('\n\n'),
454
+ available,
455
+ // No separator: every entry is a contiguous slice that already carries the line break
456
+ // ending it, so a multi-entry selection reassembles the source byte for byte and its
457
+ // size is the sum of the byte sizes the outline advertised. Inserting one would put
458
+ // bytes on the wire that are not in the document — enough, in a table-heavy RIS body,
459
+ // to split a table at the seam and orphan its rows from their header row.
460
+ text: matched.map((section) => section.text).join(''),
306
461
  unmatched: [...new Set(want)].filter((name) => !names.has(name)),
462
+ windowed,
307
463
  };
308
464
  }
309
465
  /** Quote a caller-supplied section-name list for a notice. */
@@ -312,16 +468,29 @@ function quoteNames(names) {
312
468
  }
313
469
  /**
314
470
  * Apply the outline-on-overflow contract to rendered document text: whole under the byte
315
- * budget (or with fewer than two structural sections), else a section outline. Shares
316
- * {@link segmentDocument} as the section extractor so the tool and the resource segment
317
- * identically; the `notice` builder is caller-specific (each points at its own re-call).
471
+ * budget (or with fewer than two addressable entries), else an outline of `roster`. The
472
+ * caller passes the roster it already resolved through {@link addressableSections}, so the
473
+ * outline it advertises and the wording of its notice describe the same entries; the `notice`
474
+ * builder is caller-specific (each points at its own re-call).
475
+ *
476
+ * `outlineOnOverflow` sorts entries largest first and offers no opt-out. That reads as a menu
477
+ * for a section roster, where the names say what each entry is. A window roster is a reading
478
+ * sequence, and a caller that walks `sections` in array order — the obvious way to read one
479
+ * — would take the document out of order, so document order is restored for it here.
318
480
  */
319
- export function outlineDocument(text, notice) {
320
- return outlineOnOverflow({ text }, {
481
+ export function outlineDocument(text, roster, notice) {
482
+ const decision = outlineOnOverflow({ text }, {
321
483
  budget: OUTLINE_BUDGET_BYTES,
322
- extract: (doc) => segmentDocument(doc.text).map(({ name, bytes }) => ({ name, bytes })),
484
+ extract: () => roster.map(({ name, bytes }) => ({ name, bytes })),
323
485
  notice,
324
486
  });
487
+ if (decision.kind === 'full' || roster[0]?.kind !== 'window')
488
+ return decision;
489
+ const order = new Map(roster.map((entry, i) => [entry.name, i]));
490
+ return {
491
+ ...decision,
492
+ sections: [...decision.sections].sort((a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0)),
493
+ };
325
494
  }
326
495
  /** Name the three largest sections, quoted, as examples for a re-call notice. */
327
496
  export function exampleSectionNames(sections) {
@@ -331,15 +500,20 @@ export function exampleSectionNames(sections) {
331
500
  .join(', ');
332
501
  }
333
502
  /**
334
- * Render an outline — the section roster the agent picks from — to markdown. Shared by the
335
- * tool's `format()` (as a content block) and the resource (as its body), so both client
336
- * surfaces list identical sections. Deliberately says nothing about *why* the outline was
337
- * returned: the tool emits one both on a byte overflow and on an unmatched `sections`
338
- * selector, and the accompanying notice is what separates the two.
503
+ * Render an outline — the roster the agent picks from — to markdown. Shared by the tool's
504
+ * `format()` (as a content block) and the resource (as its body), so both client surfaces
505
+ * list identical entries. A roster is all sections or all windows ({@link addressableSections}
506
+ * never mixes them), and the heading names which, since this surface carries only names and
507
+ * byte sizes: windows are a reading sequence listed in document order, sections a menu listed
508
+ * largest first. Deliberately says nothing about *why* the outline was returned: the tool
509
+ * emits one both on a byte overflow and on an unmatched `sections` selector, and the
510
+ * accompanying notice is what separates the two.
339
511
  */
340
512
  export function renderOutlineSections(sections) {
341
513
  return [
342
- `**${sections.length} sections** (retrieve by name):`,
514
+ WINDOW_NAME.test(sections[0]?.name ?? '')
515
+ ? `**${sections.length} windows** (retrieve by name, in this order):`
516
+ : `**${sections.length} sections** (retrieve by name):`,
343
517
  ...sections.map((section) => `- \`${section.name}\` — ${section.bytes} bytes`),
344
518
  ].join('\n');
345
519
  }
@@ -398,10 +572,10 @@ const ContentUrlsSchema = z
398
572
  pdf: z.string().optional().describe('PDF rendition URL.'),
399
573
  rtf: z.string().optional().describe('RTF rendition URL.'),
400
574
  })
401
- .describe('Constructed rendition URLs. Empty for authentic-PDF-only (Bvb/GrA/KmGer) and metadata-only (BgblAlt) applications — see authentic_pdf_url and the notice. For a companion document (a draft’s Erläuterungen, Textgegenüberstellung, WFA, cover letter, or annex) these are the companion’s own XML/HTML/PDF, not the parent document’s.');
575
+ .describe('Constructed rendition URLs. Empty for authentic-PDF-only (Bvb/GrA/KmGer) and metadata-only (BgblAlt) applications — see authentic_pdf_url and the notice. For a companion document (a draft’s Erläuterungen, Textgegenüberstellung, WFA, cover letter, or annex) these are the companion’s own XML/HTML/PDF, not the parent document’s — constructed rather than read back from RIS, so for the roughly one companion in eight that RIS files as a PDF only, xml and html return 404 and pdf is the one that resolves.');
402
576
  export const risGetDocument = tool('ris_get_document', {
403
577
  title: 'Get RIS Document',
404
- description: 'Fetch one RIS document’s full text or its rendition URLs, with explicit binding status and the amtssigniert authentic PDF surfaced wherever it exists. Address the document exactly one of two ways: document_number plus application (both copied verbatim from a ris_search_* or ris_lookup_citation result), or a document_url from a result’s content_urls — or, for a draft’s companion documents (Erläuterungen, Textgegenüberstellung, WFA, cover letter, annexes), from a ris_search_drafts record’s materials[].urls, which is the only route to them. format: markdown (default — the HTML rendition converted to markdown), html (raw HTML rendition), xml (the RIS Nutzdaten XML), or urls_only (no fetch — every rendition URL, including the Authentisch PDF). Format availability varies by application and the tool degrades explicitly, never silently: consolidated law, gazettes, case law, drafts, and most sectoral collections carry full text; district and municipal promulgations and court rules (Bvb, GrA, KmGer) publish only the signed authentic PDF; party-transparency decisions and council minutes (Upts, Mrp) are PDF-only; the 1848–1940 imperial gazettes (BgblAlt) are metadata-only — for these a text-format request returns a format_unavailable notice with the usable URL, not an error. Every result carries binding_status; only authentic (amtssigniert) publications are legally binding. This tool returns content, not fresh metadata — the metadata rides the search/lookup step that produced the document number. When the markdown text overflows the 40,000-byte budget and carries at least two §/Artikel/Anlage headings the tool returns a section outline (kind: outline) instead of truncating; re-call with sections:[…] naming outline entries to retrieve just those, and a name matching no section returns the outline again with a notice rather than the whole document. Outlining needs those headings: a rendition without them — most court decisions, many gazette and announcement bodies, and every raw html/xml rendition — has nothing to outline and returns whole at any size. Markdown drops the screen-reader expansions RIS ships alongside each abbreviated citation, keeping the visible citation form; raw html/xml renditions are returned exactly as published.',
578
+ description: 'Fetch one RIS document’s full text or its rendition URLs, with explicit binding status and the amtssigniert authentic PDF surfaced wherever it exists. Address the document exactly one of two ways: document_number plus application (both copied verbatim from a ris_search_* or ris_lookup_citation result), or a document_url from a result’s content_urls — or, for a draft’s companion documents (Erläuterungen, Textgegenüberstellung, WFA, cover letter, annexes), a ris_search_drafts record’s materials[].url, which is the only route to them. format: markdown (default — the HTML rendition converted to markdown), html (raw HTML rendition), xml (the RIS Nutzdaten XML), or urls_only (no fetch — every rendition URL, including the Authentisch PDF). Format availability varies by application and the tool degrades explicitly, never silently: consolidated law, gazettes, case law, drafts, and most sectoral collections carry full text; district and municipal promulgations and court rules (Bvb, GrA, KmGer) publish only the signed authentic PDF; party-transparency decisions and council minutes (Upts, Mrp) are PDF-only; the 1848–1940 imperial gazettes (BgblAlt) are metadata-only — for these a text-format request returns a format_unavailable notice with the usable URL, not an error. Every result carries binding_status; only authentic (amtssigniert) publications are legally binding. This tool returns content, not fresh metadata — the metadata rides the search/lookup step that produced the document number. When the markdown text overflows the 40,000-byte budget the tool returns an outline (kind: outline) instead of truncating: the document’s §/Artikel/Anlage sections where it carries at least two such headings, otherwise contiguous byte windows named Part 1 of N … Part N of N covering the whole text and listed in document order. Re-call with sections:[…] naming outline entries to retrieve just those; a name matching no entry returns the outline again with a notice rather than the whole document. Windows are cut at line breaks, not at sentence or § boundaries, so one can open mid-sentence — read them in order and pull the neighbour when a passage straddles a cut. Raw html and xml renditions are never sliced and return whole at any size. Markdown drops the screen-reader expansions RIS ships alongside each abbreviated citation, keeping the visible citation form; raw html/xml renditions are returned exactly as published.',
405
579
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
406
580
  input: z.object({
407
581
  document_number: z
@@ -415,7 +589,7 @@ export const risGetDocument = tool('ris_get_document', {
415
589
  document_url: z
416
590
  .string()
417
591
  .optional()
418
- .describe('A https://www.ris.bka.gv.at/Dokumente/… rendition URL as returned in a result’s content_urls — the alternative to document_number + application. Also the only way to read a draft’s companion documents: pass a URL from a ris_search_drafts record’s materials[].urls (Materialien_/Schreiben_/Anlagen_… — the Erläuterungen, Textgegenüberstellung, WFA, cover letter, or annex), whose filenames are opaque and per-record and so cannot be reached through document_number. Any other filename is rejected.'),
592
+ .describe('A https://www.ris.bka.gv.at/Dokumente/… rendition URL as returned in a result’s content_urls — the alternative to document_number + application. Also the only way to read a draft’s companion documents: pass a ris_search_drafts record’s materials[].url (the Erläuterungen, Textgegenüberstellung, WFA, cover letter, or annex), whose filenames are opaque and per-record and so cannot be reached through document_number. Every companion filename RIS publishes is accepted, whichever shape it carries. The URL’s own extension is only checked against the rendition extensions RIS uses and is then discarded — format selects which rendition is returned, for a companion exactly as for a main document. A filename that is neither this document’s own rendition nor one of its companions is rejected; companion filenames cannot be composed by hand, so copy one verbatim.'),
419
593
  format: z
420
594
  .enum(['markdown', 'html', 'xml', 'urls_only'])
421
595
  .default('markdown')
@@ -423,7 +597,7 @@ export const risGetDocument = tool('ris_get_document', {
423
597
  sections: z
424
598
  .array(z.string())
425
599
  .optional()
426
- .describe('Section names to retrieve, each copied verbatim from a prior outline response (kind: outline). Omit for the full document — which returns a §/Artikel/Anlage outline instead when the markdown overflows the 40,000-byte budget and carries at least two such headings. A name that matches no section is never silently ignored: a total miss returns the outline (kind: outline) with a notice, a partial miss returns the matched sections with a notice naming other sections to pick from. Applies to markdown only — html, xml, and urls_only carry no headings to select from and return in full.'),
600
+ .describe('Entry names to retrieve, each copied verbatim from a prior outline response (kind: outline) — §/Artikel/Anlage section names, or window names of the form "Part 2 of 6". Omit for the full document, which returns an outline instead when the markdown overflows the 40,000-byte budget. A name that matches no entry is never silently ignored: a total miss returns the outline (kind: outline) with a notice, a partial miss returns the matched entries with a notice naming others to pick from. Applies to markdown only — html, xml, and urls_only are never sliced and return in full.'),
427
601
  }),
428
602
  output: z.object({
429
603
  text: z
@@ -440,14 +614,14 @@ export const risGetDocument = tool('ris_get_document', {
440
614
  truncated: z
441
615
  .boolean()
442
616
  .optional()
443
- .describe('Present and true when the full text isn’t inline because a section outline was returned instead (kind: outline) — either the document overflowed the byte budget, or a sections:[…] selector matched nothing. The notice names which. Retrieve sections via the sections input, or fetch content_urls for the whole artifact.'),
617
+ .describe('Present and true when the full text isn’t inline because an outline was returned instead (kind: outline) — either the document overflowed the byte budget, or a sections:[…] selector matched nothing. The notice names which. Retrieve entries via the sections input, or fetch content_urls for the whole artifact.'),
444
618
  kind: z
445
619
  .enum(['full', 'outline'])
446
- .describe('full — the complete response (document text, selected sections, or rendition URLs). outline — sections lists the retrievable §/Artikel/Anlage units instead of the text, either because it overflowed the byte budget or because a sections:[…] selector matched nothing; re-call with sections:[…] naming entries from it.'),
620
+ .describe('full — the complete response (document text, selected entries, or rendition URLs). outline — sections lists the retrievable entries instead of the text, either because the document overflowed the byte budget or because a sections:[…] selector matched nothing; re-call with sections:[…] naming entries from it.'),
447
621
  sections: z
448
- .array(OUTLINE_VARIANT.shape.sections.element.describe('A retrievable §/Artikel/Anlage section — its name and UTF-8 byte size.'))
622
+ .array(OUTLINE_VARIANT.shape.sections.element.describe('A retrievable entry — a §/Artikel/Anlage section or a Part n of N byte window, with its UTF-8 byte size.'))
449
623
  .optional()
450
- .describe('Present when kind = outline: the document’s §/Artikel/Anlage sections, largest first, each with its UTF-8 byte size. Copy names into the sections input verbatim to retrieve them.'),
624
+ .describe('Present when kind = outline: the document’s addressable entries, each with its UTF-8 byte size — §/Artikel/Anlage sections listed largest first, or, when the markdown carries no such headings, Part n of N byte windows listed in document order and summing to byte_size. Copy names into the sections input verbatim to retrieve them; naming several in one call returns their text concatenated in document order, with nothing inserted between them.'),
451
625
  binding_status: z
452
626
  .enum(BINDING_STATUSES)
453
627
  .describe('Legal binding status: authentic (amtssigniert, legally binding), consolidated_informational (consolidated view — not binding), historical_record (superseded/pre-e-Recht promulgation), decision (court/tribunal ruling), preparatory (draft/bill/minutes), administrative_directive (binds the administration, not citizens), or translation (unofficial English).'),
@@ -475,8 +649,8 @@ export const risGetDocument = tool('ris_get_document', {
475
649
  {
476
650
  reason: 'unsupported_url',
477
651
  code: JsonRpcErrorCode.ValidationError,
478
- when: 'document_url fails the host + /Dokumente/ path-prefix allowlist, or its path segment is not a recognized RIS application — thrown locally, nothing fetched.',
479
- recovery: 'Only ris.bka.gv.at /Dokumente/ URLs are fetchable — pass a URL exactly as returned in content_urls, or switch to document_number + application.',
652
+ when: 'document_url fails the host + /Dokumente/ path-prefix allowlist, its path segment is not a recognized RIS application, or its trailing filename addresses neither the document’s own rendition nor one of its companion documents — thrown locally, nothing fetched.',
653
+ recovery: 'Only ris.bka.gv.at /Dokumente/ URLs are fetchable, and only a document’s own rendition or a companion filed in the same folder — copy the URL verbatim from a result’s content_urls or a ris_search_drafts record’s materials, or switch to document_number + application.',
480
654
  },
481
655
  {
482
656
  reason: 'document_not_found',
@@ -538,9 +712,18 @@ export const risGetDocument = tool('ris_get_document', {
538
712
  });
539
713
  }
540
714
  if (err.code === JsonRpcErrorCode.NotFound) {
541
- throw ctx.fail('document_not_found', err.message, {
542
- ...ctx.recoveryFor('document_not_found'),
543
- });
715
+ // The contract's recovery is written for the document_number + application mode.
716
+ // A companion was addressed by a URL, and its likeliest 404 is a rendition RIS
717
+ // never filed rather than a mistyped identifier — one companion in eight is filed
718
+ // as PDF (or PDF and RTF) with no html or xml twin to render, nearly all of them
719
+ // review-draft covering letters, and RIS 404s a rendition it does not list.
720
+ throw ctx.fail('document_not_found', err.message, resolvedContentName === undefined
721
+ ? { ...ctx.recoveryFor('document_not_found') }
722
+ : {
723
+ recovery: {
724
+ hint: `This companion document has no ${format} rendition — about one in eight is filed as a PDF with no text rendition at all, and RIS returns 404 for a rendition it does not publish. Re-call with format: urls_only and fetch content_urls.pdf yourself. If the PDF 404s too, the URL itself is stale — take a fresh one from a ris_search_drafts record's materials.`,
725
+ },
726
+ });
544
727
  }
545
728
  if (err.code === JsonRpcErrorCode.ServiceUnavailable) {
546
729
  throw ctx.fail('upstream_error', err.message, { ...ctx.recoveryFor('upstream_error') });
@@ -579,13 +762,18 @@ export const risGetDocument = tool('ris_get_document', {
579
762
  // roster (driven by the no-match, not by the byte budget) so the caller can tell "your
580
763
  // names were wrong" from "that section really is that large".
581
764
  if (requestedSections.length > 0) {
582
- const selection = selectDocumentSections(rendition.text, requestedSections);
765
+ const selection = selectDocumentSections(rendition.text, requestedSections, format);
766
+ // How the roster was built — the two kinds want different re-call guidance, and for a
767
+ // windowed roster the outline exists *because* of the byte budget.
768
+ const roster = selection.windowed
769
+ ? `${selection.available.length} contiguous byte windows, since it carries no §/Artikel/Anlage headings and overflows the ${OUTLINE_BUDGET_BYTES}-byte budget`
770
+ : `${selection.available.length} §/Artikel/Anlage sections`;
583
771
  if (selection.text !== '') {
584
772
  if (selection.unmatched.length > 0) {
585
773
  // Names candidates from the roster rather than promising an outline on a re-call
586
774
  // without sections:[…] — that re-call only outlines a document over the byte
587
775
  // budget, and returns the whole text for every document under it.
588
- ctx.enrich.notice(`Returned only the matched sections — ${quoteNames(selection.unmatched)} named no section of this document and ${selection.unmatched.length === 1 ? 'was' : 'were'} skipped. This document has ${selection.available.length} §/Artikel/Anlage sections; copy a name verbatim and re-call ris_get_document with the same addressing — e.g. ${exampleSectionNames(selection.available)}.`);
776
+ ctx.enrich.notice(`Returned only the matched entries — ${quoteNames(selection.unmatched)} named no entry of this document and ${selection.unmatched.length === 1 ? 'was' : 'were'} skipped. This document has ${roster}; copy a name verbatim and re-call ris_get_document with the same addressing — e.g. ${exampleSectionNames(selection.available)}.`);
589
777
  }
590
778
  return {
591
779
  ...base,
@@ -595,7 +783,9 @@ export const risGetDocument = tool('ris_get_document', {
595
783
  };
596
784
  }
597
785
  if (selection.available.length > 0) {
598
- ctx.enrich.notice(`sections:[…] matched nothing — ${quoteNames(selection.unmatched)} named no section of this document, so the outline below is a selector mismatch, not a size overflow. Copy a name verbatim from sections and re-call ris_get_document with the same addressing — e.g. ${exampleSectionNames(selection.available)}.`);
786
+ ctx.enrich.notice(selection.windowed
787
+ ? `sections:[…] matched nothing — ${quoteNames(selection.unmatched)} named no entry of this document. It is addressed as ${roster}. Copy a name verbatim from sections and re-call ris_get_document with the same addressing — e.g. ${exampleSectionNames(selection.available)}.`
788
+ : `sections:[…] matched nothing — ${quoteNames(selection.unmatched)} named no section of this document, so the outline below is a selector mismatch, not a size overflow. Copy a name verbatim from sections and re-call ris_get_document with the same addressing — e.g. ${exampleSectionNames(selection.available)}.`);
599
789
  return {
600
790
  ...base,
601
791
  kind: 'outline',
@@ -604,12 +794,18 @@ export const risGetDocument = tool('ris_get_document', {
604
794
  ...(byteSize !== undefined && { byte_size: byteSize }),
605
795
  };
606
796
  }
607
- // Nothing to select from and nothing to list — an html/xml rendition or markdown with
608
- // no §/Artikel/Anlage headings. Say so, then return the document whole below.
609
- ctx.enrich.notice(`sections:[…] was ignored — this rendition carries no §/Artikel/Anlage headings to select from, so ${quoteNames(selection.unmatched)} could not be resolved. The whole document follows.`);
797
+ // Nothing to select from and nothing to list — a raw html/xml rendition, or markdown
798
+ // under the budget with no §/Artikel/Anlage headings. Both halves are named: a caller
799
+ // told only about headings would retry the same selector on a larger document of the
800
+ // same shape. Say so, then return the document whole below.
801
+ ctx.enrich.notice(`sections:[…] was ignored — this rendition has no addressable entries, so ${quoteNames(selection.unmatched)} could not be resolved. Raw html and xml are never sliced; markdown carries entries when it has §/Artikel/Anlage headings, or once it overflows the ${OUTLINE_BUDGET_BYTES}-byte budget. The whole document follows.`);
610
802
  }
611
- // Disclosure — whole text under budget (or unsegmentable), else a section outline.
612
- const decision = outlineDocument(rendition.text, (sections) => `Document too large to return in full${byteSize !== undefined ? ` (${byteSize} bytes)` : ''}. Re-call ris_get_document with the same addressing plus sections:[…] naming entries from the outline — e.g. ${exampleSectionNames(sections)}.`);
803
+ // Disclosure — whole text under budget (or with nothing addressable), else an outline.
804
+ const addressable = addressableSections(rendition.text, format);
805
+ const size = byteSize !== undefined ? ` (${byteSize} bytes)` : '';
806
+ const decision = outlineDocument(rendition.text, addressable, (sections) => addressable[0]?.kind === 'window'
807
+ ? `Document too large to return in full${size}. It carries no §/Artikel/Anlage headings, so the outline lists ${sections.length} contiguous windows covering the whole text — read them in the order they are named, and expect a window to open mid-sentence, since the cuts are line breaks rather than sentence or § boundaries. Re-call ris_get_document with the same addressing plus sections:[…] naming entries from the outline — e.g. ${exampleSectionNames(addressable)}.`
808
+ : `Document too large to return in full${size}. Re-call ris_get_document with the same addressing plus sections:[…] naming entries from the outline — e.g. ${exampleSectionNames(sections)}.`);
613
809
  if (decision.kind === 'full') {
614
810
  return {
615
811
  ...base,