@cyanheads/secedgar-mcp-server 0.13.1 → 0.15.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.
Files changed (74) hide show
  1. package/README.md +63 -7
  2. package/dist/index.js +9 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/mcp-server/prompts/definitions/company-analysis.prompt.d.ts.map +1 -1
  5. package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js +11 -7
  6. package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js.map +1 -1
  7. package/dist/mcp-server/resources/definitions/filing-types.resource.d.ts.map +1 -1
  8. package/dist/mcp-server/resources/definitions/filing-types.resource.js +24 -2
  9. package/dist/mcp-server/resources/definitions/filing-types.resource.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/compare-companies.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/compare-companies.tool.js +25 -11
  12. package/dist/mcp-server/tools/definitions/compare-companies.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/find-holders.tool.d.ts +61 -0
  14. package/dist/mcp-server/tools/definitions/find-holders.tool.d.ts.map +1 -0
  15. package/dist/mcp-server/tools/definitions/find-holders.tool.js +396 -0
  16. package/dist/mcp-server/tools/definitions/find-holders.tool.js.map +1 -0
  17. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts +93 -0
  18. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts.map +1 -0
  19. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js +457 -0
  20. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js.map +1 -0
  21. package/dist/mcp-server/tools/definitions/get-financials.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/get-financials.tool.js +34 -17
  23. package/dist/mcp-server/tools/definitions/get-financials.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts +88 -0
  25. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts.map +1 -0
  26. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js +674 -0
  27. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js.map +1 -0
  28. package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.d.ts +3 -2
  29. package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.js +11 -6
  31. package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/get-material-events.tool.d.ts +63 -0
  33. package/dist/mcp-server/tools/definitions/get-material-events.tool.d.ts.map +1 -0
  34. package/dist/mcp-server/tools/definitions/get-material-events.tool.js +380 -0
  35. package/dist/mcp-server/tools/definitions/get-material-events.tool.js.map +1 -0
  36. package/dist/mcp-server/tools/definitions/get-snapshot.tool.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/definitions/get-snapshot.tool.js +30 -8
  38. package/dist/mcp-server/tools/definitions/get-snapshot.tool.js.map +1 -1
  39. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts +10 -13
  40. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts.map +1 -1
  41. package/dist/mcp-server/tools/definitions/search-filings.tool.js +295 -133
  42. package/dist/mcp-server/tools/definitions/search-filings.tool.js.map +1 -1
  43. package/dist/services/edgar/beneficial-ownership-parser.d.ts +70 -0
  44. package/dist/services/edgar/beneficial-ownership-parser.d.ts.map +1 -0
  45. package/dist/services/edgar/beneficial-ownership-parser.js +157 -0
  46. package/dist/services/edgar/beneficial-ownership-parser.js.map +1 -0
  47. package/dist/services/edgar/concept-map.d.ts +3 -1
  48. package/dist/services/edgar/concept-map.d.ts.map +1 -1
  49. package/dist/services/edgar/concept-map.js +45 -5
  50. package/dist/services/edgar/concept-map.js.map +1 -1
  51. package/dist/services/edgar/concept-series.d.ts +94 -22
  52. package/dist/services/edgar/concept-series.d.ts.map +1 -1
  53. package/dist/services/edgar/concept-series.js +171 -33
  54. package/dist/services/edgar/concept-series.js.map +1 -1
  55. package/dist/services/edgar/edgar-api-service.d.ts +85 -0
  56. package/dist/services/edgar/edgar-api-service.d.ts.map +1 -1
  57. package/dist/services/edgar/edgar-api-service.js +153 -1
  58. package/dist/services/edgar/edgar-api-service.js.map +1 -1
  59. package/dist/services/edgar/eight-k-items.d.ts +62 -0
  60. package/dist/services/edgar/eight-k-items.d.ts.map +1 -0
  61. package/dist/services/edgar/eight-k-items.js +131 -0
  62. package/dist/services/edgar/eight-k-items.js.map +1 -0
  63. package/dist/services/edgar/nport-parser.d.ts +68 -0
  64. package/dist/services/edgar/nport-parser.d.ts.map +1 -0
  65. package/dist/services/edgar/nport-parser.js +102 -0
  66. package/dist/services/edgar/nport-parser.js.map +1 -0
  67. package/dist/services/edgar/types.d.ts +39 -0
  68. package/dist/services/edgar/types.d.ts.map +1 -1
  69. package/dist/services/edgar/xml-nodes.d.ts +31 -0
  70. package/dist/services/edgar/xml-nodes.d.ts.map +1 -0
  71. package/dist/services/edgar/xml-nodes.js +74 -0
  72. package/dist/services/edgar/xml-nodes.js.map +1 -0
  73. package/package.json +1 -1
  74. package/server.json +3 -3
@@ -1,19 +1,24 @@
1
1
  /**
2
2
  * @fileoverview Search EDGAR filings since 1993 — EFTS full-text for 2001-present,
3
3
  * archive-backed browse (submissions history + quarterly full-index) for pre-2001
4
- * date ranges.
4
+ * date ranges, a bounded local document scan for pre-2001 entity-scoped free text,
5
+ * and an auto-split merge for a range straddling the full-text floor.
5
6
  * @module mcp-server/tools/definitions/search-filings
6
7
  */
7
8
  import { tool, z } from '@cyanheads/mcp-ts-core';
8
9
  import { JsonRpcErrorCode, McpError, notFound, validationError, } from '@cyanheads/mcp-ts-core/errors';
9
10
  import { getCanvasBridge, toDatasetField } from '../../../services/canvas-bridge/canvas-bridge.js';
10
- import { getEdgarApiService, quartersInRange, selectArchivePages, } from '../../../services/edgar/edgar-api-service.js';
11
+ import { cleanDisplayName, getEdgarApiService, quartersInRange, selectArchivePages, } from '../../../services/edgar/edgar-api-service.js';
12
+ import { filingToExtract } from '../../../services/edgar/filing-to-text.js';
11
13
  /**
12
14
  * The EFTS full-text index only covers filings from this date onward, even
13
15
  * though the archives reach 1993. A date range entirely before this floor is
14
- * routed to the archive-backed paths; a range straddling it is rejected (#77).
16
+ * routed to the archive-backed paths; a range straddling it is split at the
17
+ * floor and the two sides merged (#87).
15
18
  */
16
19
  const EFTS_FULLTEXT_FLOOR = '2001-01-01';
20
+ /** Last day the archive arms serve when a range is split at {@link EFTS_FULLTEXT_FLOOR}. */
21
+ const ARCHIVE_ERA_END = '2000-12-31';
17
22
  /**
18
23
  * Cap on submissions archive pages fetched in one call (pre-2001 entity-scoped
19
24
  * arm) — bounds latency and the rate-limited request budget. Mirrors the same
@@ -28,6 +33,14 @@ const ARCHIVE_PAGE_SCAN_CAP = 10;
28
33
  * hard, and `dataset.truncated` discloses when older quarters went unscanned.
29
34
  */
30
35
  const FULL_INDEX_QUARTER_SCAN_CAP = 8;
36
+ /**
37
+ * Cap on filing documents fetched in one pre-2001 local text scan. The cost is
38
+ * SEC's rate limit, not the work: a document fetches in ~47ms and converts in
39
+ * ~16ms, so at `EDGAR_RATE_LIMIT_RPS` (default 10) a full 50-document scan is
40
+ * ~5s of throttling and little else. `scan.capped` discloses when candidates
41
+ * were left unscanned (#87).
42
+ */
43
+ const DOCUMENT_SCAN_CAP = 50;
31
44
  function eftsHitToRow(hit) {
32
45
  const displayName = hit._source.display_names?.[0] || '';
33
46
  return {
@@ -35,7 +48,7 @@ function eftsHitToRow(hit) {
35
48
  form: hit._source.form ?? null,
36
49
  filing_date: hit._source.file_date,
37
50
  period_ending: hit._source.period_ending ?? null,
38
- company_name: cleanCompanyName(displayName),
51
+ company_name: cleanDisplayName(displayName),
39
52
  cik: hit._source.ciks?.[0] ?? null,
40
53
  ticker: extractTicker(displayName),
41
54
  file_description: hit._source.file_description ?? null,
@@ -82,18 +95,6 @@ function buildFormDistribution(forms) {
82
95
  }
83
96
  return Object.keys(dist).length > 0 ? dist : undefined;
84
97
  }
85
- /**
86
- * EDGAR's `display_names[0]` embeds the ticker(s) and CIK in trailing parentheticals
87
- * (e.g., "Apple Inc. (AAPL) (CIK 0000320193)"). Strip them so consumers see a clean
88
- * company name — ticker and CIK are already surfaced as their own fields.
89
- */
90
- function cleanCompanyName(displayName) {
91
- return displayName
92
- .replace(/\s*\(CIK\s*\d+\)/gi, '')
93
- .replace(/\s*\([A-Z0-9,\s.-]+\)\s*$/, '')
94
- .replace(/\s+/g, ' ')
95
- .trim();
96
- }
97
98
  /**
98
99
  * Pull the primary ticker out of `display_names[0]`. Strips the CIK parenthetical
99
100
  * first, then captures the trailing parens iff they look like ticker symbols
@@ -146,20 +147,84 @@ async function resolveEntityTargeting(query, ctx) {
146
147
  return { query };
147
148
  }
148
149
  /**
149
- * Shared tail for the archive-backed arms: sort the fully-matched rows, slice
150
- * the inline window, recompute the form distribution, register the full set to
151
- * the canvas (source-tagged), and emit the query echo / boundary notices.
152
- * `scanTruncated` (a scan cap bound before the range was exhausted) drives both
153
- * `total_is_exact` and the dataframe `truncated` flag.
150
+ * Compile one query term to a matcher over converted filing text. Bare words and
151
+ * phrases are word-boundary anchored so `revenue` does not match `unrevenued`;
152
+ * a `*` suffix drops the trailing boundary for prefix matching. Whitespace inside
153
+ * a phrase matches any run, because line wrapping in the converted text is not
154
+ * the filer's.
155
+ */
156
+ function termToRegExp(term) {
157
+ const wildcard = term.endsWith('*');
158
+ const body = wildcard ? term.slice(0, -1) : term;
159
+ const escaped = body.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
160
+ const lead = /^\w/.test(body) ? '\\b' : '';
161
+ const tail = wildcard ? '' : /\w$/.test(body) ? '\\b' : '';
162
+ return new RegExp(`${lead}${escaped}${tail}`, 'i');
163
+ }
164
+ /**
165
+ * Parse a free-text query into {@link LocalQuery}. Tokens are whitespace-separated
166
+ * except inside double quotes; a leading `-` marks an exclusion; a bare uppercase
167
+ * `OR` opens a new alternative group (EDGAR's own convention — lowercase "or" is
168
+ * an ordinary search term).
154
169
  */
155
- async function finishArchiveResult(ctx, args) {
156
- // Full-index and submissions have no relevance score, so relevance collapses
157
- // to date-descending; only an explicit ascending request reorders.
158
- const sorted = [...args.matched].sort((a, b) => args.sort === 'filing_date_asc'
170
+ function parseLocalQuery(query) {
171
+ const groups = [];
172
+ const exclusions = [];
173
+ let current = [];
174
+ for (const token of query.match(/-?"[^"]*"|\S+/g) ?? []) {
175
+ if (token === 'OR') {
176
+ groups.push(current);
177
+ current = [];
178
+ continue;
179
+ }
180
+ const negated = token.startsWith('-');
181
+ const term = (negated ? token.slice(1) : token).replaceAll('"', '').trim();
182
+ if (!term)
183
+ continue;
184
+ if (negated)
185
+ exclusions.push(termToRegExp(term));
186
+ else
187
+ current.push(termToRegExp(term));
188
+ }
189
+ groups.push(current);
190
+ return { groups: groups.filter((group) => group.length > 0), exclusions };
191
+ }
192
+ /** True when the converted filing text satisfies the compiled query. */
193
+ function matchesLocalQuery(text, parsed) {
194
+ if (parsed.exclusions.some((re) => re.test(text)))
195
+ return false;
196
+ if (parsed.groups.length === 0)
197
+ return true;
198
+ return parsed.groups.some((group) => group.every((re) => re.test(text)));
199
+ }
200
+ /**
201
+ * Order rows for the inline window. The archives carry no relevance score, so
202
+ * relevance collapses to date-descending; only an explicit ascending request
203
+ * reorders. Also picks which end of a candidate list the scan cap keeps.
204
+ */
205
+ function sortRows(rows, sort) {
206
+ return [...rows].sort((a, b) => sort === 'filing_date_asc'
159
207
  ? a.filing_date.localeCompare(b.filing_date)
160
208
  : b.filing_date.localeCompare(a.filing_date));
161
- const total = sorted.length;
162
- const totalIsExact = !args.scanTruncated;
209
+ }
210
+ /**
211
+ * Shared tail for every row set this tool assembles in memory (the pre-2001 arms
212
+ * and the straddling merge): sort, slice the inline window, recompute the form
213
+ * distribution, register the full set to the canvas (source-tagged), and emit the
214
+ * query echo / boundary notices.
215
+ *
216
+ * **`offset` model.** `offset` indexes the sorted row list assembled by this call
217
+ * — every row the tool holds, across every source it read. The 2001+ full-text
218
+ * path instead offsets into a single ≤100-row EFTS window (or, under
219
+ * sort=relevance, EDGAR's server-side pagination). The assembled-list model is
220
+ * the only one a merge can honor: once archive rows interleave with EFTS rows by
221
+ * date, an offset relative to either source's own window addresses nothing the
222
+ * caller can see. `total` may exceed the assembled row count (EFTS reports its
223
+ * true match count while returning one window), so paging past the rows in hand
224
+ * is a real state — the offset-exceeded notice names it.
225
+ */
226
+ async function finishAssembledResult(ctx, args) {
227
+ const sorted = sortRows(args.matched, args.sort);
163
228
  const sliced = sorted.slice(args.offset, args.offset + args.limit);
164
229
  const results = sliced.map(toInlineResult);
165
230
  const formDistribution = buildFormDistribution(sorted.map((row) => row.form));
@@ -170,44 +235,45 @@ async function finishArchiveResult(ctx, args) {
170
235
  rows: sorted,
171
236
  sourceTool: 'secedgar_search_filings',
172
237
  queryParams: args.queryParams,
173
- truncated: args.scanTruncated,
238
+ truncated: args.truncated,
174
239
  });
175
240
  if (registered)
176
- dataset = { ...toDatasetField(registered), truncated: args.scanTruncated };
241
+ dataset = { ...toDatasetField(registered), truncated: args.truncated };
177
242
  }
178
243
  ctx.enrich.echo(args.effectiveQuery);
179
- if (total === 0) {
244
+ if (args.total === 0) {
180
245
  ctx.enrich.notice(`No filings matched ${args.zeroHitCriteria}. ${args.coverageNote}`);
181
246
  }
182
247
  else if (results.length === 0 && args.offset >= sorted.length) {
183
- ctx.enrich.notice(`Offset (${args.offset}) exceeds the ${sorted.length} matched filings. ${total} filings matched — lower the offset or query the full set via secedgar_dataframe_query.`);
248
+ ctx.enrich.notice(`Offset (${args.offset}) exceeds the ${sorted.length} rows this search assembled. ${args.total} filings matched — lower the offset or query the full set via secedgar_dataframe_query.`);
184
249
  }
185
- else if (total > results.length) {
250
+ else if (args.total > results.length) {
186
251
  ctx.enrich.truncated({
187
252
  shown: results.length,
188
253
  cap: args.limit,
189
- guidance: args.scanTruncated
190
- ? `${args.coverageNote} The scan hit its cap before exhausting the range — older filings may exist beyond the scanned window. Narrow the range, or query the dataframe.`
254
+ guidance: args.truncated
255
+ ? `${args.coverageNote} More matches exist beyond the rows fetched — narrow the range, or query the dataframe.`
191
256
  : `${args.coverageNote} Query the full matched set via secedgar_dataframe_query, or page with offset.`,
192
257
  });
193
258
  }
194
259
  return {
195
- total,
196
- total_is_exact: totalIsExact,
260
+ total: args.total,
261
+ total_is_exact: args.totalIsExact,
197
262
  results,
198
263
  form_distribution: formDistribution,
264
+ scan: args.scan,
199
265
  dataset,
200
266
  };
201
267
  }
202
268
  /**
203
- * Pre-2001 entity-scoped arm: resolve the CIK's full submissions history (the
204
- * `recent` window plus the `filings.files[]` archive pages reused from #78) and
205
- * filter by form + date. Reuses `getSubmissions`/`selectArchivePages`/
206
- * `fetchArchivePage` as-is. Rows are tagged `source: 'submissions'`; the
207
- * EFTS-only fields (period_ending, ticker, file_description, sic, location) are
208
- * left null for a uniform archive-row shape.
269
+ * Pre-2001 entity-scoped rows: the CIK's full submissions history (the `recent`
270
+ * window plus the `filings.files[]` archive pages reused from #78) filtered by
271
+ * form + date. Reuses `getSubmissions`/`selectArchivePages`/`fetchArchivePage`
272
+ * as-is. Rows are tagged `source: 'submissions'`; the EFTS-only fields
273
+ * (period_ending, ticker, file_description, sic, location) are left null for a
274
+ * uniform archive-row shape.
209
275
  */
210
- async function serveSubmissionsArchive(ctx, args) {
276
+ async function collectSubmissionsRows(ctx, args) {
211
277
  const api = getEdgarApiService();
212
278
  // A `cik:` targeting token is only shape-validated (1-10 digits), never checked
213
279
  // against the ticker cache, so a well-formed filing-agent/transmitter CIK reaches
@@ -267,32 +333,15 @@ async function serveSubmissionsArchive(ctx, args) {
267
333
  break;
268
334
  pushBlock(await api.fetchArchivePage(page.name));
269
335
  }
270
- const formsNote = forms ? `, forms ${forms.join(', ')}` : '';
271
- return finishArchiveResult(ctx, {
272
- matched: rows,
273
- sort: args.sort,
274
- limit: args.limit,
275
- offset: args.offset,
276
- scanTruncated,
277
- queryParams: {
278
- entity_cik: args.entityCik,
279
- forms: args.forms,
280
- start_date: args.startDate,
281
- end_date: args.endDate,
282
- source: 'submissions',
283
- },
284
- effectiveQuery: `(pre-2001 archive: CIK ${args.entityCik}${formsNote}, ${args.startDate} to ${args.endDate})`,
285
- coverageNote: 'Served from the entity submissions history — full-text search covers 2001-present only.',
286
- zeroHitCriteria: `CIK ${args.entityCik}${forms ? `, forms [${forms.join(', ')}]` : ''}, dates ${args.startDate} to ${args.endDate}`,
287
- });
336
+ return { rows, scanTruncated };
288
337
  }
289
338
  /**
290
- * Pre-2001 unscoped browse arm: fetch the quarterly full-index (`master.idx`)
339
+ * Pre-2001 unscoped browse rows: fetch the quarterly full-index (`master.idx`)
291
340
  * files spanning the range and filter by form + date client-side (the index is
292
341
  * not form-filterable server-side). Bounded by FULL_INDEX_QUARTER_SCAN_CAP —
293
342
  * the heaviest arm. Rows are tagged `source: 'full-index'`.
294
343
  */
295
- async function serveFullIndexBrowse(ctx, args) {
344
+ async function collectFullIndexRows(args) {
296
345
  const api = getEdgarApiService();
297
346
  const quarters = quartersInRange(args.startDate, args.endDate);
298
347
  const quarterLimit = Math.min(quarters.length, FULL_INDEX_QUARTER_SCAN_CAP);
@@ -323,25 +372,69 @@ async function serveFullIndexBrowse(ctx, args) {
323
372
  });
324
373
  }
325
374
  }
326
- return finishArchiveResult(ctx, {
327
- matched: rows,
328
- sort: args.sort,
329
- limit: args.limit,
330
- offset: args.offset,
331
- scanTruncated,
332
- queryParams: {
333
- forms: args.forms,
334
- start_date: args.startDate,
335
- end_date: args.endDate,
336
- source: 'full-index',
337
- },
338
- effectiveQuery: `(pre-2001 full-index browse: forms ${args.forms.join(', ')}, ${args.startDate} to ${args.endDate})`,
339
- coverageNote: 'Served from the quarterly EDGAR full-index — full-text search covers 2001-present only.',
340
- zeroHitCriteria: `forms [${args.forms.join(', ')}], dates ${args.startDate} to ${args.endDate}`,
375
+ return { rows, scanTruncated };
376
+ }
377
+ /**
378
+ * Read candidate filings and keep the ones whose text satisfies the query — the
379
+ * pre-2001 entity-scoped free-text arm. There is no pre-2001 full-text index, so
380
+ * the match is local and the candidate set must already be bounded by the form +
381
+ * date pre-filter that produced it.
382
+ *
383
+ * The unit fetched is the whole accession `.txt`. A pre-1997 filing's index lists
384
+ * its exhibits with **empty `name` fields**, so per-document URLs do not exist —
385
+ * SEC's original flat-submission format concatenates every exhibit into that one
386
+ * file as SGML `<DOCUMENT>` blocks (Apple's 1996 10-K holds 7). A hit can
387
+ * therefore land in an attached exhibit rather than the primary form's body,
388
+ * which is why the scan discloses its shape rather than presenting a match as
389
+ * necessarily being in the requested form.
390
+ *
391
+ * Candidates arrive pre-sorted, so the cap keeps the end the caller's `sort`
392
+ * asked for. Documents are fetched concurrently; `EdgarApiService`'s throttle
393
+ * serializes them at the configured request rate, which is the real cost.
394
+ */
395
+ async function scanFilingText(ctx, candidates, parsed) {
396
+ const api = getEdgarApiService();
397
+ const targets = candidates.slice(0, DOCUMENT_SCAN_CAP);
398
+ const outcomes = await Promise.all(targets.map(async (row) => {
399
+ const text = await api.tryGetFilingDocument(row.cik ?? '', row.accession_number, `${row.accession_number}.txt`);
400
+ if (text === null)
401
+ return null;
402
+ return matchesLocalQuery(filingToExtract(text), parsed) ? row : null;
403
+ }));
404
+ const rows = outcomes.filter((row) => row !== null);
405
+ const scan = {
406
+ candidates: candidates.length,
407
+ scanned: targets.length,
408
+ matched: rows.length,
409
+ capped: candidates.length > targets.length,
410
+ };
411
+ ctx.log.info('Pre-2001 local text scan completed', scan);
412
+ return { rows, scan };
413
+ }
414
+ /**
415
+ * Fetch one 100-row EFTS window plus the index's own match total. Used by the
416
+ * straddling merge for the ≥2001 side, where the rows join an archive row set and
417
+ * are sorted and sliced together — so the window is always fetched from offset 0
418
+ * regardless of the caller's `offset`, which addresses the merged list instead.
419
+ */
420
+ async function fetchEftsRows(args) {
421
+ const response = await getEdgarApiService().searchFilings({
422
+ query: args.query,
423
+ forms: args.forms,
424
+ ciks: args.entityCik ? [args.entityCik] : undefined,
425
+ startDate: args.startDate,
426
+ endDate: args.endDate,
427
+ from: 0,
428
+ size: 100,
341
429
  });
430
+ return {
431
+ rows: response.hits.hits.map(eftsHitToRow),
432
+ total: response.hits.total.value,
433
+ totalIsExact: response.hits.total.relation === 'eq',
434
+ };
342
435
  }
343
436
  export const searchFilingsTool = tool('secedgar_search_filings', {
344
- description: 'Search EDGAR filings since 1993. Full-text search covers 2001-present (the EFTS index floor); pre-2001 date ranges (to 1993) are served from the archives by form and entity/date — pre-2001 full-text matching requires entity scope (ticker:/cik:). Supports exact phrases, boolean operators, wildcards, and entity targeting (ticker:AAPL or cik:320193 in query).',
437
+ description: "Search EDGAR filings since 1993. Full-text search covers 2001-present (the EFTS index floor); pre-2001 date ranges (to 1993) are served from the archives by form and entity/date. Pre-2001 free text needs entity scope (ticker:/cik:) — with it, the tool reads the entity's matching filings and matches the terms locally, which costs a few seconds (SEC's request rate caps the scan at roughly 5s for the 50-document maximum). A range crossing 2001-01-01 is split at the boundary and the two eras merged, each row tagged with its source. Supports exact phrases, boolean operators, wildcards, and entity targeting (ticker:AAPL or cik:320193 in query).",
345
438
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
346
439
  // Agent-facing context for the success path — the query as EDGAR executed it and
347
440
  // an optional notice for empty results. Populated via ctx.enrich so it reaches
@@ -389,23 +482,11 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
389
482
  when: 'Neither a full-text query nor a forms filter was provided (a date range cannot stand alone)',
390
483
  recovery: 'Provide search terms, or browse by form type (forms: ["S-1"]) or entity (ticker:AAPL / cik:320193 in the query) — optionally narrowed by date.',
391
484
  },
392
- {
393
- reason: 'straddling_date_range',
394
- code: JsonRpcErrorCode.ValidationError,
395
- when: 'The date range crosses 2001-01-01, spanning both the EFTS full-text era and the pre-2001 archives',
396
- recovery: 'Split at 2001-01-01: run one search for the ≥2001 side and another for the <2001 side, then combine the results.',
397
- },
398
485
  {
399
486
  reason: 'pre2001_full_text_unscoped',
400
487
  code: JsonRpcErrorCode.ValidationError,
401
- when: 'A pre-2001 date range carries free-text terms with no entity scope — no pre-2001 full-text index exists',
402
- recovery: 'Add ticker: or cik: entity scope to the query, or drop the text terms to browse pre-2001 filings by form and date.',
403
- },
404
- {
405
- reason: 'pre2001_full_text_scoped',
406
- code: JsonRpcErrorCode.ValidationError,
407
- when: 'A pre-2001 date range carries free-text terms alongside entity scope — local document matching is not yet supported',
408
- recovery: "Drop the text terms to browse this entity's pre-2001 filings by form and date.",
488
+ when: 'A date range reaching before 2001-01-01 carries free-text terms with no entity scope — no pre-2001 full-text index exists, and nothing bounds a local scan',
489
+ recovery: 'Add ticker: or cik: entity scope to the query, drop the text terms to browse by form and date, or start the range at 2001-01-01.',
409
490
  },
410
491
  ],
411
492
  input: z.object({
@@ -419,11 +500,11 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
419
500
  .describe('Full-text search query. Supports: exact phrases ("material weakness"), boolean operators (revenue OR income), exclusion (-preliminary), wildcard suffix (account*), entity targeting (ticker:AAPL or cik:320193 in the query). Terms are AND\'d by default.'),
420
501
  ])
421
502
  .optional()
422
- .describe('Full-text search query. Optional — omit (or pass "") to browse by form type and/or entity instead, e.g. every S-1 in a date window, or a company\'s filings via ticker:/cik:. A date range alone is not a valid search; pair it with forms or entity targeting. Full-text terms match only filings from 2001 onward (the EFTS index floor); for a pre-2001 date range, drop the text terms (browse by form/date) or add ticker:/cik: entity scope. When present, supports exact phrases ("material weakness"), boolean operators (revenue OR income), exclusion (-preliminary), wildcard suffix (account*), and entity targeting (ticker:AAPL or cik:320193 in the query); terms are AND\'d by default.'),
503
+ .describe('Full-text search query. Optional — omit (or pass "") to browse by form type and/or entity instead, e.g. every S-1 in a date window, or a company\'s filings via ticker:/cik:. A date range alone is not a valid search; pair it with forms or entity targeting. The EFTS index that serves free text starts at 2001-01-01; a date range reaching earlier needs ticker:/cik: entity scope, which lets the tool read that entity\'s filings and match the terms locally (bounded to 50 documents, a few seconds at SEC\'s request rate), or drop the text terms to browse by form and date. When present, supports exact phrases ("material weakness"), boolean operators (revenue OR income), exclusion (-preliminary), wildcard suffix (account*), and entity targeting (ticker:AAPL or cik:320193 in the query); terms are AND\'d by default. The pre-2001 local scan honors the same phrase / OR / exclusion / wildcard syntax.'),
423
504
  forms: z
424
505
  .array(z.string())
425
506
  .optional()
426
- .describe('Filter to specific form types (e.g., ["10-K", "10-Q", "8-K"]). Without this, searches all form types. Note: "10-K" also matches amendments filed as 10-K/A. Ownership forms (3, 4, 5) are indexed by the reporting person (e.g., "LEVINSON ARTHUR D"), not the issuer — rows carry no transaction code, share count, or price. Use secedgar_get_insider_transactions to retrieve parsed ownership XML with person, relationship, transaction code, shares, and price.'),
507
+ .describe('Filter to specific form types (e.g., ["10-K", "10-Q", "8-K"]). Without this, searches all form types. Note: "10-K" also matches amendments filed as 10-K/A. SEC renamed the blockholder schedules on 2024-12-18 — filings before that date are "SC 13D"/"SC 13G", filings after are "SCHEDULE 13D"/"SCHEDULE 13G" — so a filter spanning that boundary must list both spellings. Ownership forms (3, 4, 5) are indexed by the reporting person (e.g., "LEVINSON ARTHUR D"), not the issuer — rows carry no transaction code, share count, or price. Use secedgar_get_insider_transactions to retrieve parsed ownership XML with person, relationship, transaction code, shares, and price.'),
427
508
  start_date: z
428
509
  .union([
429
510
  z.literal(''),
@@ -451,7 +532,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
451
532
  .min(0)
452
533
  .max(9999)
453
534
  .default(0)
454
- .describe('Pagination offset. For sort=relevance, EDGAR pages server-side up to its 10,000-result cap. For date sorts (the default) and entity targeting, the tool fetches a single 100-row window and slices it client-side — offsets at or past the window return nothing; switch to sort=relevance for deep pagination, or narrow the search (forms, dates, entity targeting).'),
535
+ .describe('Pagination offset. For sort=relevance on a 2001-onward search, EDGAR pages server-side up to its 10,000-result cap. Everywhere else the offset indexes the rows this call assembled and sorted: a single 100-row window for date sorts and entity targeting, the full matched set on a pre-2001 archive path, or both together on a range that crosses 2001-01-01. Offsets at or past those rows return nothing even when total is larger — switch to sort=relevance for deep pagination on a 2001-onward search, narrow the search (forms, dates, entity targeting), or query the dataframe. On a crossing range the two sides are assembled unevenly — the archive side contributes every row it matched, the full-text side one window of its total — so once the window runs out the rows jump to the pre-2001 era with the remaining full-text matches absent from the middle; search the 2001-onward era on its own to page through those.'),
455
536
  sort: z
456
537
  .enum(['filing_date_desc', 'filing_date_asc', 'relevance'])
457
538
  .default('filing_date_desc')
@@ -460,10 +541,10 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
460
541
  output: z.object({
461
542
  total: z
462
543
  .number()
463
- .describe("Total matching filings. On the full-text (2001+) path this is capped at 10,000; entity targeting (ticker:/cik:) scopes server-side via the EFTS ciks param, so it is the entity's exact match count up to the cap. On a pre-2001 archive path it is the exact count within the scanned window (see total_is_exact)."),
544
+ .describe("Total matching filings, which can exceed the rows returned inline or materialized. On the full-text (2001+) path this is capped at 10,000; entity targeting (ticker:/cik:) scopes server-side via the EFTS ciks param, so it is the entity's exact match count up to the cap. On a pre-2001 archive path it is the exact count within the scanned window (see total_is_exact). On a range crossing 2001-01-01 it is the sum of both eras' counts."),
464
545
  total_is_exact: z
465
546
  .boolean()
466
- .describe('False when total is a lower bound — the full-text path hit its 10,000 cap, or a pre-2001 archive scan hit its page/quarter cap before exhausting the range.'),
547
+ .describe('False when total is a lower bound — the full-text path hit its 10,000 cap, a pre-2001 archive scan hit its page/quarter cap before exhausting the range, or a pre-2001 local text scan hit its document cap (scan.capped).'),
467
548
  results: z
468
549
  .array(z
469
550
  .object({
@@ -478,7 +559,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
478
559
  period_ending: z
479
560
  .string()
480
561
  .optional()
481
- .describe('Period the filing reports on (YYYY-MM-DD). Absent for filings without a reporting period (e.g., proxy statements, ownership reports) and for all pre-2001 archive-sourced rows (source submissions/full-index), which carry no period field.'),
562
+ .describe('Period the filing reports on (YYYY-MM-DD). Absent for filings without a reporting period (e.g., proxy statements, ownership reports) and for all pre-2001 archive-sourced rows (source submissions/full-index), which carry no period field. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only.'),
482
563
  company_name: z
483
564
  .string()
484
565
  .describe('Filing entity, with ticker/CIK parentheticals stripped.'),
@@ -486,23 +567,23 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
486
567
  ticker: z
487
568
  .string()
488
569
  .optional()
489
- .describe('Primary ticker symbol parsed from the EFTS display name. Absent for private filers, foreign filers without a US listing, filings whose display name omits the ticker parenthetical, and all pre-2001 archive-sourced rows. For multi-class issuers (e.g., BRK-A / BRK-B), this is the first class listed.'),
570
+ .describe('Primary ticker symbol parsed from the EFTS display name. Absent for private filers, foreign filers without a US listing, filings whose display name omits the ticker parenthetical, and all pre-2001 archive-sourced rows. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only. For multi-class issuers (e.g., BRK-A / BRK-B), this is the first class listed.'),
490
571
  file_description: z
491
572
  .string()
492
573
  .optional()
493
- .describe('SEC-provided description of the matching document (e.g., "EX-99.1"). Absent when SEC published none, and for pre-2001 archive-sourced rows.'),
574
+ .describe('SEC-provided description of the matching document (e.g., "EX-99.1"). Absent when SEC published none, and for pre-2001 archive-sourced rows. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only.'),
494
575
  sic: z
495
576
  .string()
496
577
  .optional()
497
- .describe('SIC industry code for the filer. Absent for filers without a classification, and for pre-2001 archive-sourced rows.'),
578
+ .describe('SIC industry code for the filer. Absent for filers without a classification, and for pre-2001 archive-sourced rows. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only.'),
498
579
  location: z
499
580
  .string()
500
581
  .optional()
501
- .describe('Business location (state or country code). Absent when SEC has no location for this filer, and for pre-2001 archive-sourced rows.'),
582
+ .describe('Business location (state or country code). Absent when SEC has no location for this filer, and for pre-2001 archive-sourced rows. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only.'),
502
583
  source: z
503
584
  .enum(['efts', 'submissions', 'full-index'])
504
585
  .optional()
505
- .describe('Which EDGAR backend served this row: "efts" (2001+ full-text index), "submissions" (a pre-2001 entity-scoped filing history), or "full-index" (a pre-2001 unscoped quarterly index browse). Provenance is carried into the canvas dataframe as a `source` column.'),
586
+ .describe('Which EDGAR backend served this row: "efts" (2001+ full-text index), "submissions" (a pre-2001 entity-scoped filing history), or "full-index" (a pre-2001 unscoped quarterly index browse). A date range crossing 2001-01-01 is split at the boundary and returns rows of two sources in one result set, so read this per row rather than per result. Provenance is carried into the canvas dataframe as a `source` column.'),
506
587
  })
507
588
  .describe('One matching filing hit.'))
508
589
  .describe('Matching filings.'),
@@ -510,6 +591,21 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
510
591
  .record(z.string(), z.number())
511
592
  .optional()
512
593
  .describe('Count of results by form type. Helps narrow follow-up searches.'),
594
+ scan: z
595
+ .object({
596
+ candidates: z
597
+ .number()
598
+ .describe('Filings the form + date pre-filter selected before any document was read.'),
599
+ scanned: z
600
+ .number()
601
+ .describe('Candidate documents actually fetched and matched against. Capped at 50 per call.'),
602
+ matched: z.number().describe('Scanned filings whose text satisfied the query terms.'),
603
+ capped: z
604
+ .boolean()
605
+ .describe('True when candidates exceeded the document cap, so the unscanned remainder may hold further matches — narrow the form or date filter to bring them into range.'),
606
+ })
607
+ .optional()
608
+ .describe("Present only on the pre-2001 entity-scoped free-text path, where no full-text index exists and terms are matched by reading documents. Reports the scan's shape so a partial read is never presented as a complete one. Each document read is the whole accession .txt — SEC's original flat-submission format concatenates every exhibit into one file, and pre-1997 filings expose no per-document URL at all — so a match may sit in an attached exhibit rather than the body of the requested form. Absent on every other path."),
513
609
  dataset: z
514
610
  .object({
515
611
  name: z
@@ -543,46 +639,106 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
543
639
  const { query, entityCik } = await resolveEntityTargeting(input.query ?? '', ctx);
544
640
  const api = getEdgarApiService();
545
641
  // Date-based routing at the EFTS full-text floor (2001-01-01). EFTS only
546
- // indexes filings from 2001; a range entirely before it is served from the
547
- // archives, a range crossing it is rejected with a split instruction (#77).
642
+ // indexes filings from 2001, so a range reaching earlier is served from the
643
+ // archives — entirely, or (when it also reaches 2001 onward) as the older half
644
+ // of a two-source merge split at the floor (#77, #87).
548
645
  const startDate = input.start_date || undefined;
549
646
  const endDate = input.end_date || undefined;
550
- if (startDate && endDate && endDate < EFTS_FULLTEXT_FLOOR) {
551
- const hasFreeText = query.trim().length > 0;
552
- if (hasFreeText) {
553
- // No pre-2001 full-text index exists. Reject honestly rather than silently
554
- // dropping the text terms; bounded local matching is a deferred follow-up.
555
- if (entityCik) {
556
- throw ctx.fail('pre2001_full_text_scoped', "Full-text matching of pre-2001 filings is not yet supported. Drop the text terms to browse this entity's pre-2001 filings by form and date.", { ...ctx.recoveryFor('pre2001_full_text_scoped') });
557
- }
558
- throw ctx.fail('pre2001_full_text_unscoped', 'Full-text search is unavailable before 2001 (the EFTS index starts at 2001-01-01). For pre-2001 filings, add ticker:/cik: entity scope, or drop the text terms to browse by form and date.', { ...ctx.recoveryFor('pre2001_full_text_unscoped') });
647
+ const hasFreeText = query.trim().length > 0;
648
+ if (startDate && endDate && startDate < EFTS_FULLTEXT_FLOOR) {
649
+ const straddles = endDate >= EFTS_FULLTEXT_FLOOR;
650
+ const archiveEnd = straddles ? ARCHIVE_ERA_END : endDate;
651
+ // Free text before 2001 is matched by reading documents, which only an entity
652
+ // scope bounds. Unscoped, the pre-2001 half of the range cannot be served at
653
+ // all — reject rather than answer half a range and call it the answer.
654
+ if (hasFreeText && !entityCik) {
655
+ throw ctx.fail('pre2001_full_text_unscoped', `Full-text search is unavailable before ${EFTS_FULLTEXT_FLOOR} (where the EFTS index starts), so the ${startDate}..${archiveEnd} part of this range has no index to search. Add ticker:/cik: entity scope to match those filings by reading them, drop the text terms to browse by form and date, or start the range at ${EFTS_FULLTEXT_FLOOR}.`, { ...ctx.recoveryFor('pre2001_full_text_unscoped') });
559
656
  }
657
+ let archive;
658
+ let scan;
560
659
  if (entityCik) {
561
- return await serveSubmissionsArchive(ctx, {
660
+ archive = await collectSubmissionsRows(ctx, {
562
661
  entityCik,
563
662
  forms: input.forms,
564
663
  startDate,
565
- endDate,
566
- sort: input.sort,
567
- limit: input.limit,
568
- offset: input.offset,
664
+ endDate: archiveEnd,
569
665
  });
666
+ if (hasFreeText) {
667
+ const scanned = await scanFilingText(ctx, sortRows(archive.rows, input.sort), parseLocalQuery(query));
668
+ archive = { rows: scanned.rows, scanTruncated: archive.scanTruncated };
669
+ scan = scanned.scan;
670
+ }
570
671
  }
571
- // Unscoped + no free text → forms are guaranteed present by the missing_criteria guard.
572
- return await serveFullIndexBrowse(ctx, {
573
- forms: input.forms ?? [],
574
- startDate,
575
- endDate,
672
+ else {
673
+ // Unscoped + no free text → forms are guaranteed present by the missing_criteria guard.
674
+ archive = await collectFullIndexRows({
675
+ forms: input.forms ?? [],
676
+ startDate,
677
+ endDate: archiveEnd,
678
+ });
679
+ }
680
+ const efts = straddles
681
+ ? await fetchEftsRows({
682
+ query,
683
+ forms: input.forms,
684
+ entityCik,
685
+ startDate: EFTS_FULLTEXT_FLOOR,
686
+ endDate,
687
+ })
688
+ : undefined;
689
+ // `total` counts both eras' true match totals: the archive side's count is
690
+ // exact within what it scanned, while EFTS reports its own total independently
691
+ // of the single window fetched.
692
+ const archiveIncomplete = archive.scanTruncated || (scan?.capped ?? false);
693
+ const total = archive.rows.length + (efts?.total ?? 0);
694
+ const eftsWindowIncomplete = efts !== undefined && efts.total > efts.rows.length;
695
+ const formsNote = input.forms?.length ? `, forms ${input.forms.join(', ')}` : '';
696
+ const scopeNote = entityCik ? `CIK ${entityCik}` : 'full-index browse';
697
+ const archiveSource = entityCik
698
+ ? 'the entity submissions history'
699
+ : 'the quarterly EDGAR full-index';
700
+ const scanNote = scan
701
+ ? ` Read ${scan.scanned} of ${scan.candidates} candidate filings for the text terms${scan.capped ? ` (document cap ${DOCUMENT_SCAN_CAP})` : ''}; ${scan.matched} matched. Each read covers the whole accession .txt, so a match may sit in an attached exhibit rather than the body of the requested form.`
702
+ : '';
703
+ // The archive side contributes every row it matched; the full-text side contributes one
704
+ // window of a total it reports independently. When that window is short of its total,
705
+ // the merged list runs out of full-text rows and drops straight into the archive era —
706
+ // a gap in the middle of the date order that neither offset nor the dataframe reaches,
707
+ // since both address the rows this call assembled.
708
+ const windowNote = efts && eftsWindowIncomplete
709
+ ? ` The ${EFTS_FULLTEXT_FLOOR}-onward side contributed one ${efts.rows.length}-row window of its ${efts.total} matches, so the merged rows jump from the end of that window straight to ${ARCHIVE_ERA_END} — search ${EFTS_FULLTEXT_FLOOR} to ${endDate} on its own to page through the rest.`
710
+ : '';
711
+ return await finishAssembledResult(ctx, {
712
+ matched: [...archive.rows, ...(efts?.rows ?? [])],
713
+ total,
714
+ totalIsExact: !archiveIncomplete && (efts?.totalIsExact ?? true),
576
715
  sort: input.sort,
577
716
  limit: input.limit,
578
717
  offset: input.offset,
718
+ truncated: archiveIncomplete || eftsWindowIncomplete,
719
+ queryParams: {
720
+ query: hasFreeText ? query : undefined,
721
+ entity_cik: entityCik,
722
+ forms: input.forms,
723
+ start_date: startDate,
724
+ end_date: endDate,
725
+ source: straddles ? 'efts+archive' : entityCik ? 'submissions' : 'full-index',
726
+ },
727
+ effectiveQuery: straddles
728
+ ? `${hasFreeText ? `${query} ` : ''}(split at ${EFTS_FULLTEXT_FLOOR}: ${scopeNote}${formsNote}, ${startDate} to ${endDate})`
729
+ : `${hasFreeText ? `${query} ` : ''}(pre-2001 archive: ${scopeNote}${formsNote}, ${startDate} to ${endDate})`,
730
+ coverageNote: straddles
731
+ ? `Merged across the ${EFTS_FULLTEXT_FLOOR} boundary — filings from ${EFTS_FULLTEXT_FLOOR} on come from the full-text index, earlier ones from ${archiveSource}. Read each row's source; the EFTS-only fields (period_ending, ticker, file_description, sic, location) are null on archive rows.${windowNote}${scanNote}`
732
+ : `Served from ${archiveSource} — full-text search covers 2001-present only.${scanNote}`,
733
+ zeroHitCriteria: [
734
+ ...(hasFreeText ? [`"${query}"`] : []),
735
+ ...(entityCik ? [`CIK ${entityCik}`] : []),
736
+ ...(input.forms?.length ? [`forms [${input.forms.join(', ')}]`] : []),
737
+ `dates ${startDate} to ${endDate}`,
738
+ ].join(', '),
739
+ scan,
579
740
  });
580
741
  }
581
- if (startDate && endDate && startDate < EFTS_FULLTEXT_FLOOR) {
582
- // endDate ≥ 2001 (handled above) but startDate < 2001 → straddling. Reject
583
- // with a split instruction; merged straddling output is a deferred follow-up.
584
- throw ctx.fail('straddling_date_range', `The date range ${startDate}..${endDate} crosses 2001-01-01. Full-text search covers 2001-present; pre-2001 filings are served from the archives. Search the two eras separately: ${EFTS_FULLTEXT_FLOOR} onward, and before ${EFTS_FULLTEXT_FLOOR}.`, { ...ctx.recoveryFor('straddling_date_range') });
585
- }
586
742
  // EFTS scores by relevance and exposes no sort param. When the caller wants
587
743
  // a date sort (the default) or entity filtering, we over-fetch the EFTS
588
744
  // window and reorder/slice client-side. Pure relevance mode keeps the
@@ -623,7 +779,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
623
779
  form: hit._source.form ?? undefined,
624
780
  filing_date: hit._source.file_date,
625
781
  period_ending: hit._source.period_ending ?? undefined,
626
- company_name: cleanCompanyName(displayName),
782
+ company_name: cleanDisplayName(displayName),
627
783
  cik: hit._source.ciks?.[0] || '',
628
784
  ...(ticker !== null && { ticker }),
629
785
  file_description: hit._source.file_description ?? undefined,
@@ -725,10 +881,10 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
725
881
  };
726
882
  },
727
883
  format: (result) => {
728
- const source = result.results[0]?.source;
884
+ const fromArchive = result.results.some((r) => r.source === 'submissions' || r.source === 'full-index');
729
885
  const exactness = result.total_is_exact
730
886
  ? 'exact'
731
- : source === 'submissions' || source === 'full-index'
887
+ : fromArchive || result.scan
732
888
  ? 'partial — scan cap reached, more may exist'
733
889
  : 'capped at 10,000';
734
890
  const lines = [`Found ${result.total} filings (${exactness})`];
@@ -747,6 +903,12 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
747
903
  .join(', ');
748
904
  lines.push(`\nForm distribution: ${dist}`);
749
905
  }
906
+ if (result.scan) {
907
+ const cappedNote = result.scan.capped
908
+ ? `Capped — the remaining ${result.scan.candidates - result.scan.scanned} went unread; narrow the form or date filter to reach them.`
909
+ : 'Not capped — every candidate was read.';
910
+ lines.push(`\nPre-2001 text scan: read ${result.scan.scanned} of ${result.scan.candidates} candidate filings, ${result.scan.matched} matched. ${cappedNote} Each read covers the whole accession .txt, so a match may sit in an attached exhibit rather than the body of the requested form.`);
911
+ }
750
912
  if (result.dataset) {
751
913
  const truncatedNote = result.dataset.truncated
752
914
  ? ' (truncated — more matches exist beyond the materialized set)'