@cyanheads/secedgar-mcp-server 0.14.0 → 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 (39) hide show
  1. package/README.md +31 -3
  2. package/dist/index.js +5 -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 +9 -5
  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 +6 -0
  9. package/dist/mcp-server/resources/definitions/filing-types.resource.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts +93 -0
  11. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts.map +1 -0
  12. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js +457 -0
  13. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js.map +1 -0
  14. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts +88 -0
  15. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts.map +1 -0
  16. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js +674 -0
  17. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js.map +1 -0
  18. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts +10 -13
  19. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/search-filings.tool.js +292 -118
  21. package/dist/mcp-server/tools/definitions/search-filings.tool.js.map +1 -1
  22. package/dist/services/edgar/beneficial-ownership-parser.d.ts +70 -0
  23. package/dist/services/edgar/beneficial-ownership-parser.d.ts.map +1 -0
  24. package/dist/services/edgar/beneficial-ownership-parser.js +157 -0
  25. package/dist/services/edgar/beneficial-ownership-parser.js.map +1 -0
  26. package/dist/services/edgar/edgar-api-service.d.ts +78 -0
  27. package/dist/services/edgar/edgar-api-service.d.ts.map +1 -1
  28. package/dist/services/edgar/edgar-api-service.js +140 -1
  29. package/dist/services/edgar/edgar-api-service.js.map +1 -1
  30. package/dist/services/edgar/nport-parser.d.ts +68 -0
  31. package/dist/services/edgar/nport-parser.d.ts.map +1 -0
  32. package/dist/services/edgar/nport-parser.js +102 -0
  33. package/dist/services/edgar/nport-parser.js.map +1 -0
  34. package/dist/services/edgar/xml-nodes.d.ts +31 -0
  35. package/dist/services/edgar/xml-nodes.d.ts.map +1 -0
  36. package/dist/services/edgar/xml-nodes.js +74 -0
  37. package/dist/services/edgar/xml-nodes.js.map +1 -0
  38. package/package.json +1 -1
  39. 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
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 {
@@ -134,20 +147,84 @@ async function resolveEntityTargeting(query, ctx) {
134
147
  return { query };
135
148
  }
136
149
  /**
137
- * Shared tail for the archive-backed arms: sort the fully-matched rows, slice
138
- * the inline window, recompute the form distribution, register the full set to
139
- * the canvas (source-tagged), and emit the query echo / boundary notices.
140
- * `scanTruncated` (a scan cap bound before the range was exhausted) drives both
141
- * `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).
142
169
  */
143
- async function finishArchiveResult(ctx, args) {
144
- // Full-index and submissions have no relevance score, so relevance collapses
145
- // to date-descending; only an explicit ascending request reorders.
146
- 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'
147
207
  ? a.filing_date.localeCompare(b.filing_date)
148
208
  : b.filing_date.localeCompare(a.filing_date));
149
- const total = sorted.length;
150
- 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);
151
228
  const sliced = sorted.slice(args.offset, args.offset + args.limit);
152
229
  const results = sliced.map(toInlineResult);
153
230
  const formDistribution = buildFormDistribution(sorted.map((row) => row.form));
@@ -158,44 +235,45 @@ async function finishArchiveResult(ctx, args) {
158
235
  rows: sorted,
159
236
  sourceTool: 'secedgar_search_filings',
160
237
  queryParams: args.queryParams,
161
- truncated: args.scanTruncated,
238
+ truncated: args.truncated,
162
239
  });
163
240
  if (registered)
164
- dataset = { ...toDatasetField(registered), truncated: args.scanTruncated };
241
+ dataset = { ...toDatasetField(registered), truncated: args.truncated };
165
242
  }
166
243
  ctx.enrich.echo(args.effectiveQuery);
167
- if (total === 0) {
244
+ if (args.total === 0) {
168
245
  ctx.enrich.notice(`No filings matched ${args.zeroHitCriteria}. ${args.coverageNote}`);
169
246
  }
170
247
  else if (results.length === 0 && args.offset >= sorted.length) {
171
- 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.`);
172
249
  }
173
- else if (total > results.length) {
250
+ else if (args.total > results.length) {
174
251
  ctx.enrich.truncated({
175
252
  shown: results.length,
176
253
  cap: args.limit,
177
- guidance: args.scanTruncated
178
- ? `${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.`
179
256
  : `${args.coverageNote} Query the full matched set via secedgar_dataframe_query, or page with offset.`,
180
257
  });
181
258
  }
182
259
  return {
183
- total,
184
- total_is_exact: totalIsExact,
260
+ total: args.total,
261
+ total_is_exact: args.totalIsExact,
185
262
  results,
186
263
  form_distribution: formDistribution,
264
+ scan: args.scan,
187
265
  dataset,
188
266
  };
189
267
  }
190
268
  /**
191
- * Pre-2001 entity-scoped arm: resolve the CIK's full submissions history (the
192
- * `recent` window plus the `filings.files[]` archive pages reused from #78) and
193
- * filter by form + date. Reuses `getSubmissions`/`selectArchivePages`/
194
- * `fetchArchivePage` as-is. Rows are tagged `source: 'submissions'`; the
195
- * EFTS-only fields (period_ending, ticker, file_description, sic, location) are
196
- * 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.
197
275
  */
198
- async function serveSubmissionsArchive(ctx, args) {
276
+ async function collectSubmissionsRows(ctx, args) {
199
277
  const api = getEdgarApiService();
200
278
  // A `cik:` targeting token is only shape-validated (1-10 digits), never checked
201
279
  // against the ticker cache, so a well-formed filing-agent/transmitter CIK reaches
@@ -255,32 +333,15 @@ async function serveSubmissionsArchive(ctx, args) {
255
333
  break;
256
334
  pushBlock(await api.fetchArchivePage(page.name));
257
335
  }
258
- const formsNote = forms ? `, forms ${forms.join(', ')}` : '';
259
- return finishArchiveResult(ctx, {
260
- matched: rows,
261
- sort: args.sort,
262
- limit: args.limit,
263
- offset: args.offset,
264
- scanTruncated,
265
- queryParams: {
266
- entity_cik: args.entityCik,
267
- forms: args.forms,
268
- start_date: args.startDate,
269
- end_date: args.endDate,
270
- source: 'submissions',
271
- },
272
- effectiveQuery: `(pre-2001 archive: CIK ${args.entityCik}${formsNote}, ${args.startDate} to ${args.endDate})`,
273
- coverageNote: 'Served from the entity submissions history — full-text search covers 2001-present only.',
274
- zeroHitCriteria: `CIK ${args.entityCik}${forms ? `, forms [${forms.join(', ')}]` : ''}, dates ${args.startDate} to ${args.endDate}`,
275
- });
336
+ return { rows, scanTruncated };
276
337
  }
277
338
  /**
278
- * 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`)
279
340
  * files spanning the range and filter by form + date client-side (the index is
280
341
  * not form-filterable server-side). Bounded by FULL_INDEX_QUARTER_SCAN_CAP —
281
342
  * the heaviest arm. Rows are tagged `source: 'full-index'`.
282
343
  */
283
- async function serveFullIndexBrowse(ctx, args) {
344
+ async function collectFullIndexRows(args) {
284
345
  const api = getEdgarApiService();
285
346
  const quarters = quartersInRange(args.startDate, args.endDate);
286
347
  const quarterLimit = Math.min(quarters.length, FULL_INDEX_QUARTER_SCAN_CAP);
@@ -311,25 +372,69 @@ async function serveFullIndexBrowse(ctx, args) {
311
372
  });
312
373
  }
313
374
  }
314
- return finishArchiveResult(ctx, {
315
- matched: rows,
316
- sort: args.sort,
317
- limit: args.limit,
318
- offset: args.offset,
319
- scanTruncated,
320
- queryParams: {
321
- forms: args.forms,
322
- start_date: args.startDate,
323
- end_date: args.endDate,
324
- source: 'full-index',
325
- },
326
- effectiveQuery: `(pre-2001 full-index browse: forms ${args.forms.join(', ')}, ${args.startDate} to ${args.endDate})`,
327
- coverageNote: 'Served from the quarterly EDGAR full-index — full-text search covers 2001-present only.',
328
- 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,
329
429
  });
430
+ return {
431
+ rows: response.hits.hits.map(eftsHitToRow),
432
+ total: response.hits.total.value,
433
+ totalIsExact: response.hits.total.relation === 'eq',
434
+ };
330
435
  }
331
436
  export const searchFilingsTool = tool('secedgar_search_filings', {
332
- 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).",
333
438
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
334
439
  // Agent-facing context for the success path — the query as EDGAR executed it and
335
440
  // an optional notice for empty results. Populated via ctx.enrich so it reaches
@@ -377,23 +482,11 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
377
482
  when: 'Neither a full-text query nor a forms filter was provided (a date range cannot stand alone)',
378
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.',
379
484
  },
380
- {
381
- reason: 'straddling_date_range',
382
- code: JsonRpcErrorCode.ValidationError,
383
- when: 'The date range crosses 2001-01-01, spanning both the EFTS full-text era and the pre-2001 archives',
384
- recovery: 'Split at 2001-01-01: run one search for the ≥2001 side and another for the <2001 side, then combine the results.',
385
- },
386
485
  {
387
486
  reason: 'pre2001_full_text_unscoped',
388
487
  code: JsonRpcErrorCode.ValidationError,
389
- when: 'A pre-2001 date range carries free-text terms with no entity scope — no pre-2001 full-text index exists',
390
- recovery: 'Add ticker: or cik: entity scope to the query, or drop the text terms to browse pre-2001 filings by form and date.',
391
- },
392
- {
393
- reason: 'pre2001_full_text_scoped',
394
- code: JsonRpcErrorCode.ValidationError,
395
- when: 'A pre-2001 date range carries free-text terms alongside entity scope — local document matching is not yet supported',
396
- 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.',
397
490
  },
398
491
  ],
399
492
  input: z.object({
@@ -407,11 +500,11 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
407
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.'),
408
501
  ])
409
502
  .optional()
410
- .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.'),
411
504
  forms: z
412
505
  .array(z.string())
413
506
  .optional()
414
- .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.'),
415
508
  start_date: z
416
509
  .union([
417
510
  z.literal(''),
@@ -439,7 +532,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
439
532
  .min(0)
440
533
  .max(9999)
441
534
  .default(0)
442
- .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.'),
443
536
  sort: z
444
537
  .enum(['filing_date_desc', 'filing_date_asc', 'relevance'])
445
538
  .default('filing_date_desc')
@@ -448,10 +541,10 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
448
541
  output: z.object({
449
542
  total: z
450
543
  .number()
451
- .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."),
452
545
  total_is_exact: z
453
546
  .boolean()
454
- .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).'),
455
548
  results: z
456
549
  .array(z
457
550
  .object({
@@ -466,7 +559,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
466
559
  period_ending: z
467
560
  .string()
468
561
  .optional()
469
- .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.'),
470
563
  company_name: z
471
564
  .string()
472
565
  .describe('Filing entity, with ticker/CIK parentheticals stripped.'),
@@ -474,23 +567,23 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
474
567
  ticker: z
475
568
  .string()
476
569
  .optional()
477
- .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.'),
478
571
  file_description: z
479
572
  .string()
480
573
  .optional()
481
- .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.'),
482
575
  sic: z
483
576
  .string()
484
577
  .optional()
485
- .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.'),
486
579
  location: z
487
580
  .string()
488
581
  .optional()
489
- .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.'),
490
583
  source: z
491
584
  .enum(['efts', 'submissions', 'full-index'])
492
585
  .optional()
493
- .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.'),
494
587
  })
495
588
  .describe('One matching filing hit.'))
496
589
  .describe('Matching filings.'),
@@ -498,6 +591,21 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
498
591
  .record(z.string(), z.number())
499
592
  .optional()
500
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."),
501
609
  dataset: z
502
610
  .object({
503
611
  name: z
@@ -531,46 +639,106 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
531
639
  const { query, entityCik } = await resolveEntityTargeting(input.query ?? '', ctx);
532
640
  const api = getEdgarApiService();
533
641
  // Date-based routing at the EFTS full-text floor (2001-01-01). EFTS only
534
- // indexes filings from 2001; a range entirely before it is served from the
535
- // 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).
536
645
  const startDate = input.start_date || undefined;
537
646
  const endDate = input.end_date || undefined;
538
- if (startDate && endDate && endDate < EFTS_FULLTEXT_FLOOR) {
539
- const hasFreeText = query.trim().length > 0;
540
- if (hasFreeText) {
541
- // No pre-2001 full-text index exists. Reject honestly rather than silently
542
- // dropping the text terms; bounded local matching is a deferred follow-up.
543
- if (entityCik) {
544
- 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') });
545
- }
546
- 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') });
547
656
  }
657
+ let archive;
658
+ let scan;
548
659
  if (entityCik) {
549
- return await serveSubmissionsArchive(ctx, {
660
+ archive = await collectSubmissionsRows(ctx, {
550
661
  entityCik,
551
662
  forms: input.forms,
552
663
  startDate,
553
- endDate,
554
- sort: input.sort,
555
- limit: input.limit,
556
- offset: input.offset,
664
+ endDate: archiveEnd,
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
+ }
671
+ }
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,
557
678
  });
558
679
  }
559
- // Unscoped + no free text → forms are guaranteed present by the missing_criteria guard.
560
- return await serveFullIndexBrowse(ctx, {
561
- forms: input.forms ?? [],
562
- startDate,
563
- endDate,
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),
564
715
  sort: input.sort,
565
716
  limit: input.limit,
566
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,
567
740
  });
568
741
  }
569
- if (startDate && endDate && startDate < EFTS_FULLTEXT_FLOOR) {
570
- // endDate ≥ 2001 (handled above) but startDate < 2001 → straddling. Reject
571
- // with a split instruction; merged straddling output is a deferred follow-up.
572
- 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') });
573
- }
574
742
  // EFTS scores by relevance and exposes no sort param. When the caller wants
575
743
  // a date sort (the default) or entity filtering, we over-fetch the EFTS
576
744
  // window and reorder/slice client-side. Pure relevance mode keeps the
@@ -713,10 +881,10 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
713
881
  };
714
882
  },
715
883
  format: (result) => {
716
- const source = result.results[0]?.source;
884
+ const fromArchive = result.results.some((r) => r.source === 'submissions' || r.source === 'full-index');
717
885
  const exactness = result.total_is_exact
718
886
  ? 'exact'
719
- : source === 'submissions' || source === 'full-index'
887
+ : fromArchive || result.scan
720
888
  ? 'partial — scan cap reached, more may exist'
721
889
  : 'capped at 10,000';
722
890
  const lines = [`Found ${result.total} filings (${exactness})`];
@@ -735,6 +903,12 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
735
903
  .join(', ');
736
904
  lines.push(`\nForm distribution: ${dist}`);
737
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
+ }
738
912
  if (result.dataset) {
739
913
  const truncatedNote = result.dataset.truncated
740
914
  ? ' (truncated — more matches exist beyond the materialized set)'