@cyanheads/secedgar-mcp-server 0.12.2 → 0.12.4

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 (29) hide show
  1. package/README.md +5 -2
  2. package/dist/index.js +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/mcp-server/resources/definitions/filing-types.resource.d.ts.map +1 -1
  5. package/dist/mcp-server/resources/definitions/filing-types.resource.js +3 -2
  6. package/dist/mcp-server/resources/definitions/filing-types.resource.js.map +1 -1
  7. package/dist/mcp-server/tools/definitions/company-search.tool.d.ts +9 -0
  8. package/dist/mcp-server/tools/definitions/company-search.tool.d.ts.map +1 -1
  9. package/dist/mcp-server/tools/definitions/company-search.tool.js +145 -21
  10. package/dist/mcp-server/tools/definitions/company-search.tool.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/dataframe-query.tool.d.ts +5 -0
  12. package/dist/mcp-server/tools/definitions/dataframe-query.tool.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/dataframe-query.tool.js +6 -0
  14. package/dist/mcp-server/tools/definitions/dataframe-query.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts +29 -2
  16. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/search-filings.tool.js +377 -39
  18. package/dist/mcp-server/tools/definitions/search-filings.tool.js.map +1 -1
  19. package/dist/services/canvas-bridge/canvas-bridge.d.ts.map +1 -1
  20. package/dist/services/canvas-bridge/canvas-bridge.js +14 -2
  21. package/dist/services/canvas-bridge/canvas-bridge.js.map +1 -1
  22. package/dist/services/edgar/edgar-api-service.d.ts +63 -1
  23. package/dist/services/edgar/edgar-api-service.d.ts.map +1 -1
  24. package/dist/services/edgar/edgar-api-service.js +134 -2
  25. package/dist/services/edgar/edgar-api-service.js.map +1 -1
  26. package/dist/services/edgar/types.d.ts +42 -1
  27. package/dist/services/edgar/types.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/server.json +3 -3
@@ -1,11 +1,33 @@
1
1
  /**
2
- * @fileoverview Full-text search across all EDGAR filing documents since 1993.
2
+ * @fileoverview Search EDGAR filings since 1993 — EFTS full-text for 2001-present,
3
+ * archive-backed browse (submissions history + quarterly full-index) for pre-2001
4
+ * date ranges.
3
5
  * @module mcp-server/tools/definitions/search-filings
4
6
  */
5
7
  import { tool, z } from '@cyanheads/mcp-ts-core';
6
8
  import { JsonRpcErrorCode, validationError } from '@cyanheads/mcp-ts-core/errors';
7
9
  import { getCanvasBridge, toDatasetField } from '../../../services/canvas-bridge/canvas-bridge.js';
8
- import { getEdgarApiService } from '../../../services/edgar/edgar-api-service.js';
10
+ import { getEdgarApiService, quartersInRange, selectArchivePages, } from '../../../services/edgar/edgar-api-service.js';
11
+ /**
12
+ * The EFTS full-text index only covers filings from this date onward, even
13
+ * 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).
15
+ */
16
+ const EFTS_FULLTEXT_FLOOR = '2001-01-01';
17
+ /**
18
+ * Cap on submissions archive pages fetched in one call (pre-2001 entity-scoped
19
+ * arm) — bounds latency and the rate-limited request budget. Mirrors the same
20
+ * constant in company-search.tool.ts. Pre-2001 pages are a filer's oldest, so
21
+ * the cap rarely binds; when it does, `dataset.truncated` discloses it.
22
+ */
23
+ const ARCHIVE_PAGE_SCAN_CAP = 10;
24
+ /**
25
+ * Cap on quarterly full-index files fetched in one unscoped pre-2001 browse.
26
+ * Each `master.idx` is a multi-MB whole-quarter download and is not
27
+ * form-filterable server-side, so this arm is the heaviest — the cap bounds it
28
+ * hard, and `dataset.truncated` discloses when older quarters went unscanned.
29
+ */
30
+ const FULL_INDEX_QUARTER_SCAN_CAP = 8;
9
31
  function eftsHitToRow(hit) {
10
32
  const displayName = hit._source.display_names?.[0] || '';
11
33
  return {
@@ -19,8 +41,47 @@ function eftsHitToRow(hit) {
19
41
  file_description: hit._source.file_description ?? null,
20
42
  sic: hit._source.sics?.[0] ?? null,
21
43
  location: hit._source.biz_locations?.[0] ?? null,
44
+ source: 'efts',
45
+ };
46
+ }
47
+ /** Map a fully-keyed {@link SearchRow} to an inline output row (nulls → omitted optionals). */
48
+ function toInlineResult(row) {
49
+ return {
50
+ accession_number: row.accession_number,
51
+ ...(row.form != null && { form: row.form }),
52
+ filing_date: row.filing_date,
53
+ ...(row.period_ending != null && { period_ending: row.period_ending }),
54
+ company_name: row.company_name,
55
+ cik: row.cik ?? '',
56
+ ...(row.ticker != null && { ticker: row.ticker }),
57
+ ...(row.file_description != null && { file_description: row.file_description }),
58
+ ...(row.sic != null && { sic: row.sic }),
59
+ ...(row.location != null && { location: row.location }),
60
+ source: row.source,
22
61
  };
23
62
  }
63
+ /**
64
+ * True when a filing's form matches a requested form, counting amendments
65
+ * (requested "10-K" matches "10-K" and "10-K/A"), mirroring EFTS forms
66
+ * semantics so the archive-backed arms honor the same forms contract as the
67
+ * post-2001 full-text path.
68
+ */
69
+ function formMatches(filingForm, requestedForms) {
70
+ const f = filingForm.toUpperCase();
71
+ return requestedForms.some((req) => {
72
+ const r = req.toUpperCase();
73
+ return f === r || f.startsWith(`${r}/`);
74
+ });
75
+ }
76
+ /** Count filings by form, dropping the field when nothing carried a form. */
77
+ function buildFormDistribution(forms) {
78
+ const dist = {};
79
+ for (const form of forms) {
80
+ if (form)
81
+ dist[form] = (dist[form] ?? 0) + 1;
82
+ }
83
+ return Object.keys(dist).length > 0 ? dist : undefined;
84
+ }
24
85
  /**
25
86
  * EDGAR's `display_names[0]` embeds the ticker(s) and CIK in trailing parentheticals
26
87
  * (e.g., "Apple Inc. (AAPL) (CIK 0000320193)"). Strip them so consumers see a clean
@@ -84,8 +145,183 @@ async function resolveEntityTargeting(query, ctx) {
84
145
  }
85
146
  return { query };
86
147
  }
148
+ /**
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.
154
+ */
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'
159
+ ? a.filing_date.localeCompare(b.filing_date)
160
+ : b.filing_date.localeCompare(a.filing_date));
161
+ const total = sorted.length;
162
+ const totalIsExact = !args.scanTruncated;
163
+ const sliced = sorted.slice(args.offset, args.offset + args.limit);
164
+ const results = sliced.map(toInlineResult);
165
+ const formDistribution = buildFormDistribution(sorted.map((row) => row.form));
166
+ let dataset;
167
+ const bridge = getCanvasBridge();
168
+ if (bridge && sorted.length > args.limit) {
169
+ const registered = await bridge.registerDataframe(ctx, {
170
+ rows: sorted,
171
+ sourceTool: 'secedgar_search_filings',
172
+ queryParams: args.queryParams,
173
+ truncated: args.scanTruncated,
174
+ });
175
+ if (registered)
176
+ dataset = { ...toDatasetField(registered), truncated: args.scanTruncated };
177
+ }
178
+ ctx.enrich.echo(args.effectiveQuery);
179
+ if (total === 0) {
180
+ ctx.enrich.notice(`No filings matched ${args.zeroHitCriteria}. ${args.coverageNote}`);
181
+ }
182
+ 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.`);
184
+ }
185
+ else if (total > results.length) {
186
+ ctx.enrich.truncated({
187
+ shown: results.length,
188
+ 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.`
191
+ : `${args.coverageNote} Query the full matched set via secedgar_dataframe_query, or page with offset.`,
192
+ });
193
+ }
194
+ return {
195
+ total,
196
+ total_is_exact: totalIsExact,
197
+ results,
198
+ form_distribution: formDistribution,
199
+ dataset,
200
+ };
201
+ }
202
+ /**
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.
209
+ */
210
+ async function serveSubmissionsArchive(ctx, args) {
211
+ const api = getEdgarApiService();
212
+ const submissions = await api.getSubmissions(args.entityCik);
213
+ const companyName = submissions.name;
214
+ const forms = args.forms?.length ? args.forms : undefined;
215
+ const rows = [];
216
+ const pushBlock = (block) => {
217
+ for (let i = 0; i < block.accessionNumber.length; i++) {
218
+ const form = block.form[i] ?? '';
219
+ const filingDate = block.filingDate[i] ?? '';
220
+ if (filingDate < args.startDate || filingDate > args.endDate)
221
+ continue;
222
+ if (forms && !formMatches(form, forms))
223
+ continue;
224
+ rows.push({
225
+ accession_number: block.accessionNumber[i] ?? '',
226
+ form: form || null,
227
+ filing_date: filingDate,
228
+ period_ending: null,
229
+ company_name: companyName,
230
+ cik: args.entityCik,
231
+ ticker: null,
232
+ file_description: null,
233
+ sic: null,
234
+ location: null,
235
+ source: 'submissions',
236
+ });
237
+ }
238
+ };
239
+ pushBlock(submissions.filings.recent);
240
+ // Older filings live in the archive pages covering the requested pre-2001 range.
241
+ const pages = selectArchivePages(submissions.filings.files, args.startDate, args.endDate);
242
+ const pageLimit = Math.min(pages.length, ARCHIVE_PAGE_SCAN_CAP);
243
+ const scanTruncated = pages.length > pageLimit;
244
+ for (let i = 0; i < pageLimit; i++) {
245
+ const page = pages[i];
246
+ if (!page)
247
+ break;
248
+ pushBlock(await api.fetchArchivePage(page.name));
249
+ }
250
+ const formsNote = forms ? `, forms ${forms.join(', ')}` : '';
251
+ return finishArchiveResult(ctx, {
252
+ matched: rows,
253
+ sort: args.sort,
254
+ limit: args.limit,
255
+ offset: args.offset,
256
+ scanTruncated,
257
+ queryParams: {
258
+ entity_cik: args.entityCik,
259
+ forms: args.forms,
260
+ start_date: args.startDate,
261
+ end_date: args.endDate,
262
+ source: 'submissions',
263
+ },
264
+ effectiveQuery: `(pre-2001 archive: CIK ${args.entityCik}${formsNote}, ${args.startDate} to ${args.endDate})`,
265
+ coverageNote: 'Served from the entity submissions history — full-text search covers 2001-present only.',
266
+ zeroHitCriteria: `CIK ${args.entityCik}${forms ? `, forms [${forms.join(', ')}]` : ''}, dates ${args.startDate} to ${args.endDate}`,
267
+ });
268
+ }
269
+ /**
270
+ * Pre-2001 unscoped browse arm: fetch the quarterly full-index (`master.idx`)
271
+ * files spanning the range and filter by form + date client-side (the index is
272
+ * not form-filterable server-side). Bounded by FULL_INDEX_QUARTER_SCAN_CAP —
273
+ * the heaviest arm. Rows are tagged `source: 'full-index'`.
274
+ */
275
+ async function serveFullIndexBrowse(ctx, args) {
276
+ const api = getEdgarApiService();
277
+ const quarters = quartersInRange(args.startDate, args.endDate);
278
+ const quarterLimit = Math.min(quarters.length, FULL_INDEX_QUARTER_SCAN_CAP);
279
+ const scanTruncated = quarters.length > quarterLimit;
280
+ const rows = [];
281
+ for (let i = 0; i < quarterLimit; i++) {
282
+ const quarter = quarters[i];
283
+ if (!quarter)
284
+ break;
285
+ const entries = await api.fetchFullIndexQuarter(quarter.year, quarter.quarter);
286
+ for (const entry of entries) {
287
+ if (entry.filingDate < args.startDate || entry.filingDate > args.endDate)
288
+ continue;
289
+ if (!formMatches(entry.form, args.forms))
290
+ continue;
291
+ rows.push({
292
+ accession_number: entry.accessionNumber,
293
+ form: entry.form || null,
294
+ filing_date: entry.filingDate,
295
+ period_ending: null,
296
+ company_name: entry.companyName,
297
+ cik: entry.cik,
298
+ ticker: null,
299
+ file_description: null,
300
+ sic: null,
301
+ location: null,
302
+ source: 'full-index',
303
+ });
304
+ }
305
+ }
306
+ return finishArchiveResult(ctx, {
307
+ matched: rows,
308
+ sort: args.sort,
309
+ limit: args.limit,
310
+ offset: args.offset,
311
+ scanTruncated,
312
+ queryParams: {
313
+ forms: args.forms,
314
+ start_date: args.startDate,
315
+ end_date: args.endDate,
316
+ source: 'full-index',
317
+ },
318
+ effectiveQuery: `(pre-2001 full-index browse: forms ${args.forms.join(', ')}, ${args.startDate} to ${args.endDate})`,
319
+ coverageNote: 'Served from the quarterly EDGAR full-index — full-text search covers 2001-present only.',
320
+ zeroHitCriteria: `forms [${args.forms.join(', ')}], dates ${args.startDate} to ${args.endDate}`,
321
+ });
322
+ }
87
323
  export const searchFilingsTool = tool('secedgar_search_filings', {
88
- description: 'Search the full-text index of EDGAR filings since 1993. Supports exact phrases, boolean operators, wildcards, and entity targeting (ticker:AAPL or cik:320193 in query).',
324
+ 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).',
89
325
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
90
326
  // Agent-facing context for the success path — the query as EDGAR executed it and
91
327
  // an optional notice for empty results. Populated via ctx.enrich so it reaches
@@ -121,13 +357,43 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
121
357
  when: 'A cik: targeting token in the query is not a 1-10 digit number',
122
358
  recovery: 'Pass a numeric CIK such as cik:320193 — find it with secedgar_company_search if unknown.',
123
359
  },
360
+ {
361
+ reason: 'missing_criteria',
362
+ code: JsonRpcErrorCode.ValidationError,
363
+ when: 'Neither a full-text query nor a forms filter was provided (a date range cannot stand alone)',
364
+ 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.',
365
+ },
366
+ {
367
+ reason: 'straddling_date_range',
368
+ code: JsonRpcErrorCode.ValidationError,
369
+ when: 'The date range crosses 2001-01-01, spanning both the EFTS full-text era and the pre-2001 archives',
370
+ recovery: 'Split at 2001-01-01: run one search for the ≥2001 side and another for the <2001 side, then combine the results.',
371
+ },
372
+ {
373
+ reason: 'pre2001_full_text_unscoped',
374
+ code: JsonRpcErrorCode.ValidationError,
375
+ when: 'A pre-2001 date range carries free-text terms with no entity scope — no pre-2001 full-text index exists',
376
+ recovery: 'Add ticker: or cik: entity scope to the query, or drop the text terms to browse pre-2001 filings by form and date.',
377
+ },
378
+ {
379
+ reason: 'pre2001_full_text_scoped',
380
+ code: JsonRpcErrorCode.ValidationError,
381
+ when: 'A pre-2001 date range carries free-text terms alongside entity scope — local document matching is not yet supported',
382
+ recovery: "Drop the text terms to browse this entity's pre-2001 filings by form and date.",
383
+ },
124
384
  ],
125
385
  input: z.object({
126
386
  query: z
127
- .string()
128
- .trim()
129
- .min(1, 'Query cannot be blank')
130
- .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.'),
387
+ .union([
388
+ z.literal(''),
389
+ z
390
+ .string()
391
+ .trim()
392
+ .min(1, 'Query cannot be blank')
393
+ .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.'),
394
+ ])
395
+ .optional()
396
+ .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.'),
131
397
  forms: z
132
398
  .array(z.string())
133
399
  .optional()
@@ -163,13 +429,15 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
163
429
  sort: z
164
430
  .enum(['filing_date_desc', 'filing_date_asc', 'relevance'])
165
431
  .default('filing_date_desc')
166
- .describe('Result ordering. "filing_date_desc" (default) returns most recent first. "filing_date_asc" returns oldest first. "relevance" returns SEC\'s native search-score order, which weights term match strength over recency. Date sorts re-order the top 100 hits returned by the search index — for broad queries with more than 100 matches and no entity targeting, date-newest filings may sit outside that window. Entity targeting (ticker:/cik:) or a narrower query keeps matches inside the window when absolute recency matters.'),
432
+ .describe('Result ordering. "filing_date_desc" (default) returns most recent first. "filing_date_asc" returns oldest first. "relevance" returns SEC\'s native search-score order, which weights term match strength over recency. Date sorts re-order the top 100 hits returned by the search index — for broad queries with more than 100 matches and no entity targeting, date-newest filings may sit outside that window. Entity targeting (ticker:/cik:) or a narrower query keeps matches inside the window when absolute recency matters. On the no-query browse path (forms/entity only), EFTS has no relevance signal — every hit scores null — and returns filings in natural date-descending order, so all sort modes effectively yield newest-first. Pre-2001 archive results carry no relevance score either, so relevance collapses to date-descending there.'),
167
433
  }),
168
434
  output: z.object({
169
435
  total: z
170
436
  .number()
171
- .describe("Total matching filings (capped at 10,000). Entity targeting (ticker:/cik:) scopes server-side via the EFTS ciks param, so this is the entity's exact match count up to the cap."),
172
- total_is_exact: z.boolean().describe('False only when total hits the 10,000 cap.'),
437
+ .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)."),
438
+ total_is_exact: z
439
+ .boolean()
440
+ .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.'),
173
441
  results: z
174
442
  .array(z
175
443
  .object({
@@ -184,7 +452,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
184
452
  period_ending: z
185
453
  .string()
186
454
  .optional()
187
- .describe('Period the filing reports on (YYYY-MM-DD). Absent for filings without a reporting period (e.g., proxy statements, ownership reports).'),
455
+ .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.'),
188
456
  company_name: z
189
457
  .string()
190
458
  .describe('Filing entity, with ticker/CIK parentheticals stripped.'),
@@ -192,21 +460,25 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
192
460
  ticker: z
193
461
  .string()
194
462
  .optional()
195
- .describe('Primary ticker symbol parsed from the EFTS display name. Absent for private filers, foreign filers without a US listing, and filings whose display name omits the ticker parenthetical. For multi-class issuers (e.g., BRK-A / BRK-B), this is the first class listed.'),
463
+ .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.'),
196
464
  file_description: z
197
465
  .string()
198
466
  .optional()
199
- .describe('SEC-provided description of the matching document (e.g., "EX-99.1"). Absent when SEC published none.'),
467
+ .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.'),
200
468
  sic: z
201
469
  .string()
202
470
  .optional()
203
- .describe('SIC industry code for the filer. Absent for filers without a classification.'),
471
+ .describe('SIC industry code for the filer. Absent for filers without a classification, and for pre-2001 archive-sourced rows.'),
204
472
  location: z
205
473
  .string()
206
474
  .optional()
207
- .describe('Business location (state or country code). Absent when SEC has no location for this filer.'),
475
+ .describe('Business location (state or country code). Absent when SEC has no location for this filer, and for pre-2001 archive-sourced rows.'),
476
+ source: z
477
+ .enum(['efts', 'submissions', 'full-index'])
478
+ .optional()
479
+ .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.'),
208
480
  })
209
- .describe('One matching filing hit from the full-text search index.'))
481
+ .describe('One matching filing hit.'))
210
482
  .describe('Matching filings.'),
211
483
  form_distribution: z
212
484
  .record(z.string(), z.number())
@@ -221,19 +493,70 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
221
493
  expires_at: z.string().describe('ISO 8601 expiry timestamp.'),
222
494
  truncated: z
223
495
  .boolean()
224
- .describe('True when EFTS reported more text matches than the window we already fetched — additional rows exist beyond the dataframe. Page further with `offset` for the inline view; the canvas dataframe is bounded by the single response window.'),
496
+ .describe('True when more matches exist beyond the materialized set — the full-text window was exceeded, or a pre-2001 archive scan hit its cap. Each row carries a `source` column so provenance survives into secedgar_dataframe_query.'),
225
497
  })
226
498
  .optional()
227
- .describe('Canvas dataframe holding the hits already fetched for the inline response. Absent when total ≤ inline limit, canvas is unavailable, or materialization failed. The dataframe contains the raw EFTS results (entity-scoped server-side via the ciks param when ticker:/cik: was used) — query with secedgar_dataframe_query SQL.'),
499
+ .describe('Canvas dataframe holding the fetched hits (full-text window, or the full pre-2001 archive match set), each tagged with its `source`. Absent when total ≤ inline limit, canvas is unavailable, or materialization failed. Query with secedgar_dataframe_query SQL.'),
228
500
  }),
229
501
  async handler(input, ctx) {
230
502
  // Validate date range: both or neither
231
503
  if ((input.start_date && !input.end_date) || (!input.start_date && input.end_date)) {
232
504
  throw ctx.fail('invalid_date_range', 'Both start_date and end_date are required when filtering by date.', { ...ctx.recoveryFor('invalid_date_range') });
233
505
  }
506
+ // Browse mode (#79): EFTS accepts a forms-only or entity-scoped request with no
507
+ // full-text term, but rejects one with neither ("Blank search not valid"). Guard
508
+ // the both-absent shape here — checked against the RAW query/forms (before the
509
+ // entity token is stripped), so a bare cik:/ticker: query still passes (it has raw
510
+ // content even though it strips to blank). A date range does not stand alone.
511
+ if (!input.query?.trim() && !input.forms?.length) {
512
+ throw ctx.fail('missing_criteria', 'A full-text query or a forms filter is required — a date range alone is not a valid search.', { ...ctx.recoveryFor('missing_criteria') });
513
+ }
234
514
  // Resolve ticker:/cik: entity targeting → stripped query + CIK for the
235
515
  // server-side ciks param. Throws typed validation errors on bad tokens (#61).
236
- const { query, entityCik } = await resolveEntityTargeting(input.query, ctx);
516
+ // Default to '' so a browse request (undefined query) resolves cleanly.
517
+ const { query, entityCik } = await resolveEntityTargeting(input.query ?? '', ctx);
518
+ const api = getEdgarApiService();
519
+ // Date-based routing at the EFTS full-text floor (2001-01-01). EFTS only
520
+ // indexes filings from 2001; a range entirely before it is served from the
521
+ // archives, a range crossing it is rejected with a split instruction (#77).
522
+ const startDate = input.start_date || undefined;
523
+ const endDate = input.end_date || undefined;
524
+ if (startDate && endDate && endDate < EFTS_FULLTEXT_FLOOR) {
525
+ const hasFreeText = query.trim().length > 0;
526
+ if (hasFreeText) {
527
+ // No pre-2001 full-text index exists. Reject honestly rather than silently
528
+ // dropping the text terms; bounded local matching is a deferred follow-up.
529
+ if (entityCik) {
530
+ 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') });
531
+ }
532
+ 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') });
533
+ }
534
+ if (entityCik) {
535
+ return await serveSubmissionsArchive(ctx, {
536
+ entityCik,
537
+ forms: input.forms,
538
+ startDate,
539
+ endDate,
540
+ sort: input.sort,
541
+ limit: input.limit,
542
+ offset: input.offset,
543
+ });
544
+ }
545
+ // Unscoped + no free text → forms are guaranteed present by the missing_criteria guard.
546
+ return await serveFullIndexBrowse(ctx, {
547
+ forms: input.forms ?? [],
548
+ startDate,
549
+ endDate,
550
+ sort: input.sort,
551
+ limit: input.limit,
552
+ offset: input.offset,
553
+ });
554
+ }
555
+ if (startDate && endDate && startDate < EFTS_FULLTEXT_FLOOR) {
556
+ // endDate ≥ 2001 (handled above) but startDate < 2001 → straddling. Reject
557
+ // with a split instruction; merged straddling output is a deferred follow-up.
558
+ 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') });
559
+ }
237
560
  // EFTS scores by relevance and exposes no sort param. When the caller wants
238
561
  // a date sort (the default) or entity filtering, we over-fetch the EFTS
239
562
  // window and reorder/slice client-side. Pure relevance mode keeps the
@@ -241,7 +564,6 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
241
564
  const wideFetch = entityCik !== undefined || input.sort !== 'relevance';
242
565
  const fetchFrom = wideFetch ? 0 : input.offset;
243
566
  const fetchSize = wideFetch ? 100 : input.limit;
244
- const api = getEdgarApiService();
245
567
  const response = await api.searchFilings({
246
568
  query,
247
569
  forms: input.forms,
@@ -281,6 +603,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
281
603
  file_description: hit._source.file_description ?? undefined,
282
604
  sic: hit._source.sics?.[0] ?? undefined,
283
605
  location: hit._source.biz_locations?.[0] ?? undefined,
606
+ source: 'efts',
284
607
  };
285
608
  });
286
609
  // Form distribution must reflect the same set as `total`.
@@ -289,14 +612,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
289
612
  // case, recompute from the post-filter `hits` to keep the counts consistent.
290
613
  let formDistribution;
291
614
  if (entityCik || input.forms?.length) {
292
- const dist = {};
293
- for (const hit of hits) {
294
- const form = hit._source.form;
295
- if (form)
296
- dist[form] = (dist[form] ?? 0) + 1;
297
- }
298
- if (Object.keys(dist).length > 0)
299
- formDistribution = dist;
615
+ formDistribution = buildFormDistribution(hits.map((hit) => hit._source.form));
300
616
  }
301
617
  else if (response.aggregations?.form_filter?.buckets) {
302
618
  formDistribution = {};
@@ -325,6 +641,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
325
641
  start_date: input.start_date,
326
642
  end_date: input.end_date,
327
643
  entity_cik: entityCik,
644
+ source: 'efts',
328
645
  },
329
646
  truncated,
330
647
  });
@@ -339,21 +656,36 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
339
656
  });
340
657
  const effectiveQuery = entityCik
341
658
  ? `${query ? `${query} ` : ''}(entity scope: CIK ${entityCik})`
342
- : query;
659
+ : query || (input.forms?.length ? `(browse: forms ${input.forms.join(', ')})` : '');
343
660
  ctx.enrich.echo(effectiveQuery);
344
661
  if (total === 0) {
345
- // Genuine no-match — EFTS returned zero hits. Echo all active criteria.
346
- const formsPart = input.forms?.length ? ` | Forms: ${input.forms.join(', ')}` : '';
347
- const datePart = input.start_date && input.end_date
348
- ? ` | Date: ${input.start_date} to ${input.end_date}`
349
- : '';
350
- ctx.enrich.notice(`No filings matched "${input.query}"${formsPart}${datePart}. Try broader search terms, remove form filters, or widen the date range.`);
662
+ // Genuine no-match — EFTS returned zero hits. Echo all active criteria; on the
663
+ // browse path (no query text) lead with the form/entity criteria instead of an
664
+ // empty quoted query.
665
+ const criteria = [];
666
+ if (query)
667
+ criteria.push(`"${query}"`);
668
+ if (entityCik)
669
+ criteria.push(`entity CIK ${entityCik}`);
670
+ if (input.forms?.length)
671
+ criteria.push(`forms [${input.forms.join(', ')}]`);
672
+ if (input.start_date && input.end_date) {
673
+ criteria.push(`dates ${input.start_date} to ${input.end_date}`);
674
+ }
675
+ const criteriaText = criteria.length > 0 ? criteria.join(', ') : 'the given criteria';
676
+ ctx.enrich.notice(`No filings matched ${criteriaText}. Broaden the query, remove the form filter, or widen the date range.`);
351
677
  }
352
678
  else if (results.length === 0 && wideFetch && input.offset >= hits.length) {
353
679
  // The offset exceeded the client-side window (wideFetch fetches 100 rows max).
354
680
  // There are results — the caller just paged past the available window. The
355
681
  // materialized dataframe holds the same window, so SQL paging reaches no further.
356
- ctx.enrich.notice(`Offset (${input.offset}) exceeds the fetched window (${hits.length} rows — date sorts and entity targeting fetch a single window). ${total} filings matched: switch to sort=relevance for EDGAR-side pagination up to 10,000 results, or narrow the search with forms, dates, or entity targeting.`);
682
+ // Entity targeting forces wideFetch regardless of sort (see `wideFetch` above),
683
+ // so switching to sort=relevance does NOT unlock deeper EDGAR-side pagination on
684
+ // that path — only the non-entity path pages server-side via relevance.
685
+ const deeperPaging = entityCik
686
+ ? 'narrow the search with forms or dates, or query the full window via secedgar_dataframe_query'
687
+ : 'switch to sort=relevance for EDGAR-side pagination up to 10,000 results, or narrow the search with forms, dates, or entity targeting';
688
+ ctx.enrich.notice(`Offset (${input.offset}) exceeds the fetched window (${hits.length} rows — date sorts and entity targeting fetch a single window). ${total} filings matched: ${deeperPaging}.`);
357
689
  }
358
690
  else if (total > results.length) {
359
691
  ctx.enrich.truncated({ shown: results.length, cap: input.limit });
@@ -367,15 +699,21 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
367
699
  };
368
700
  },
369
701
  format: (result) => {
370
- const exactness = result.total_is_exact ? 'exact' : 'capped at 10,000';
702
+ const source = result.results[0]?.source;
703
+ const exactness = result.total_is_exact
704
+ ? 'exact'
705
+ : source === 'submissions' || source === 'full-index'
706
+ ? 'partial — scan cap reached, more may exist'
707
+ : 'capped at 10,000';
371
708
  const lines = [`Found ${result.total} filings (${exactness})`];
372
709
  for (const r of result.results) {
373
710
  const period = r.period_ending ? ` (period: ${r.period_ending})` : '';
374
711
  const desc = r.file_description ? ` — ${r.file_description}` : '';
375
712
  const sic = r.sic ? ` | SIC ${r.sic}` : '';
376
713
  const loc = r.location ? ` | ${r.location}` : '';
714
+ const src = r.source ? ` | source: ${r.source}` : '';
377
715
  const ident = r.ticker ? `${r.ticker}, CIK ${r.cik}` : `CIK ${r.cik}`;
378
- lines.push(`- ${r.form ?? 'N/A'} ${r.filing_date}${period} — ${r.company_name} (${ident})${sic}${loc}${desc} [${r.accession_number}]`);
716
+ lines.push(`- ${r.form ?? 'N/A'} ${r.filing_date}${period} — ${r.company_name} (${ident})${sic}${loc}${desc}${src} [${r.accession_number}]`);
379
717
  }
380
718
  if (result.form_distribution) {
381
719
  const dist = Object.entries(result.form_distribution)
@@ -385,7 +723,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
385
723
  }
386
724
  if (result.dataset) {
387
725
  const truncatedNote = result.dataset.truncated
388
- ? ' (truncated — more matches exist beyond the EFTS window)'
726
+ ? ' (truncated — more matches exist beyond the materialized set)'
389
727
  : '';
390
728
  lines.push(`\nDataset: ${result.dataset.name} (${result.dataset.row_count} rows, expires ${result.dataset.expires_at})${truncatedNote} — query with secedgar_dataframe_query.`);
391
729
  }