@cyanheads/secedgar-mcp-server 0.12.3 → 0.12.5

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 (42) hide show
  1. package/Dockerfile +1 -1
  2. package/LICENSE +1 -1
  3. package/README.md +10 -7
  4. package/dist/index.js +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js +1 -1
  7. package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/fetch-frames.tool.d.ts +7 -3
  9. package/dist/mcp-server/tools/definitions/fetch-frames.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/fetch-frames.tool.js +36 -5
  11. package/dist/mcp-server/tools/definitions/fetch-frames.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/get-filing.tool.d.ts +2 -2
  13. package/dist/mcp-server/tools/definitions/get-filing.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/get-filing.tool.js +87 -11
  15. package/dist/mcp-server/tools/definitions/get-filing.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/get-financials.tool.d.ts +3 -3
  17. package/dist/mcp-server/tools/definitions/get-financials.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/get-financials.tool.js +10 -2
  19. package/dist/mcp-server/tools/definitions/get-financials.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/get-insider-transactions.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/get-insider-transactions.tool.js +19 -2
  22. package/dist/mcp-server/tools/definitions/get-insider-transactions.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.d.ts +6 -3
  24. package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.js +33 -5
  26. package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/search-concepts.tool.d.ts +8 -8
  28. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts +29 -2
  29. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/search-filings.tool.js +356 -28
  31. package/dist/mcp-server/tools/definitions/search-filings.tool.js.map +1 -1
  32. package/dist/services/canvas-bridge/canvas-bridge.d.ts.map +1 -1
  33. package/dist/services/canvas-bridge/canvas-bridge.js.map +1 -1
  34. package/dist/services/edgar/edgar-api-service.d.ts +34 -1
  35. package/dist/services/edgar/edgar-api-service.d.ts.map +1 -1
  36. package/dist/services/edgar/edgar-api-service.js +79 -0
  37. package/dist/services/edgar/edgar-api-service.js.map +1 -1
  38. package/dist/services/edgar/mirror/edgar-mirror.d.ts.map +1 -1
  39. package/dist/services/edgar/types.d.ts +24 -0
  40. package/dist/services/edgar/types.d.ts.map +1 -1
  41. package/package.json +9 -9
  42. package/server.json +3 -3
@@ -1,5 +1,7 @@
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 { z } from '@cyanheads/mcp-ts-core';
@@ -12,8 +14,8 @@ export declare const searchFilingsTool: import("@cyanheads/mcp-ts-core").ToolDef
12
14
  limit: z.ZodDefault<z.ZodNumber>;
13
15
  offset: z.ZodDefault<z.ZodNumber>;
14
16
  sort: z.ZodDefault<z.ZodEnum<{
15
- filing_date_desc: "filing_date_desc";
16
17
  filing_date_asc: "filing_date_asc";
18
+ filing_date_desc: "filing_date_desc";
17
19
  relevance: "relevance";
18
20
  }>>;
19
21
  }, z.core.$strip>, z.ZodObject<{
@@ -30,6 +32,11 @@ export declare const searchFilingsTool: import("@cyanheads/mcp-ts-core").ToolDef
30
32
  file_description: z.ZodOptional<z.ZodString>;
31
33
  sic: z.ZodOptional<z.ZodString>;
32
34
  location: z.ZodOptional<z.ZodString>;
35
+ source: z.ZodOptional<z.ZodEnum<{
36
+ efts: "efts";
37
+ "full-index": "full-index";
38
+ submissions: "submissions";
39
+ }>>;
33
40
  }, z.core.$strip>>;
34
41
  form_distribution: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
35
42
  dataset: z.ZodOptional<z.ZodObject<{
@@ -53,11 +60,31 @@ export declare const searchFilingsTool: import("@cyanheads/mcp-ts-core").ToolDef
53
60
  readonly code: JsonRpcErrorCode.ValidationError;
54
61
  readonly when: "A cik: targeting token in the query is not a 1-10 digit number";
55
62
  readonly recovery: "Pass a numeric CIK such as cik:320193 — find it with secedgar_company_search if unknown.";
63
+ }, {
64
+ readonly reason: "entity_not_found";
65
+ readonly code: JsonRpcErrorCode.NotFound;
66
+ readonly when: "A cik: targeting token on a pre-2001 date range names a CIK with no EDGAR submissions history";
67
+ readonly recovery: "Confirm the CIK with secedgar_company_search — an accession-number prefix names the filing agent, not the issuer.";
56
68
  }, {
57
69
  readonly reason: "missing_criteria";
58
70
  readonly code: JsonRpcErrorCode.ValidationError;
59
71
  readonly when: "Neither a full-text query nor a forms filter was provided (a date range cannot stand alone)";
60
72
  readonly 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.";
73
+ }, {
74
+ readonly reason: "straddling_date_range";
75
+ readonly code: JsonRpcErrorCode.ValidationError;
76
+ readonly when: "The date range crosses 2001-01-01, spanning both the EFTS full-text era and the pre-2001 archives";
77
+ readonly recovery: "Split at 2001-01-01: run one search for the ≥2001 side and another for the <2001 side, then combine the results.";
78
+ }, {
79
+ readonly reason: "pre2001_full_text_unscoped";
80
+ readonly code: JsonRpcErrorCode.ValidationError;
81
+ readonly when: "A pre-2001 date range carries free-text terms with no entity scope — no pre-2001 full-text index exists";
82
+ readonly recovery: "Add ticker: or cik: entity scope to the query, or drop the text terms to browse pre-2001 filings by form and date.";
83
+ }, {
84
+ readonly reason: "pre2001_full_text_scoped";
85
+ readonly code: JsonRpcErrorCode.ValidationError;
86
+ readonly when: "A pre-2001 date range carries free-text terms alongside entity scope — local document matching is not yet supported";
87
+ readonly recovery: "Drop the text terms to browse this entity's pre-2001 filings by form and date.";
61
88
  }], {
62
89
  readonly effectiveQuery: z.ZodString;
63
90
  readonly notice: z.ZodOptional<z.ZodString>;
@@ -1 +1 @@
1
- {"version":3,"file":"search-filings.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/search-filings.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAsB,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,gBAAgB,EAAmB,MAAM,+BAA+B,CAAC;AAkGlF,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAga5B,CAAC"}
1
+ {"version":3,"file":"search-filings.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/search-filings.tool.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAsB,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EACL,gBAAgB,EAIjB,MAAM,+BAA+B,CAAC;AA6cvC,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+f5B,CAAC"}
@@ -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
- import { JsonRpcErrorCode, validationError } from '@cyanheads/mcp-ts-core/errors';
8
+ import { JsonRpcErrorCode, McpError, notFound, 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,203 @@ 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
+ // A `cik:` targeting token is only shape-validated (1-10 digits), never checked
213
+ // against the ticker cache, so a well-formed filing-agent/transmitter CIK reaches
214
+ // the submissions feed and 404s. Resolve it here to tell a cache-hit registrant
215
+ // from a bare CIK, then convert only the bare-CIK 404 into the declared contract
216
+ // error — no raw SEC URL (#93). A registrant that 404s is an EDGAR-side problem
217
+ // and propagates unchanged, same reasoning as #55 and #76. The lookup is confined
218
+ // to this pre-2001 arm (already multi-fetch) so the EFTS path stays untouched.
219
+ const resolved = await api.resolveCik(args.entityCik);
220
+ const cacheMatch = Array.isArray(resolved) ? resolved[0] : resolved;
221
+ const isBareCikFallback = !cacheMatch?.name && !cacheMatch?.ticker;
222
+ let submissions;
223
+ try {
224
+ submissions = await api.getSubmissions(args.entityCik);
225
+ }
226
+ catch (err) {
227
+ if (isBareCikFallback && err instanceof McpError && err.code === JsonRpcErrorCode.NotFound) {
228
+ ctx.log.debug('CIK has no submissions feed', { cik: args.entityCik });
229
+ throw notFound(`No EDGAR filer found for CIK ${args.entityCik}. If this looks like an accession-number prefix, it's the filing agent, not the issuer — use secedgar_company_search to find the company.`, { reason: 'entity_not_found', ...ctx.recoveryFor('entity_not_found') }, { cause: err });
230
+ }
231
+ throw err;
232
+ }
233
+ const companyName = submissions.name;
234
+ const forms = args.forms?.length ? args.forms : undefined;
235
+ const rows = [];
236
+ const pushBlock = (block) => {
237
+ for (let i = 0; i < block.accessionNumber.length; i++) {
238
+ const form = block.form[i] ?? '';
239
+ const filingDate = block.filingDate[i] ?? '';
240
+ if (filingDate < args.startDate || filingDate > args.endDate)
241
+ continue;
242
+ if (forms && !formMatches(form, forms))
243
+ continue;
244
+ rows.push({
245
+ accession_number: block.accessionNumber[i] ?? '',
246
+ form: form || null,
247
+ filing_date: filingDate,
248
+ period_ending: null,
249
+ company_name: companyName,
250
+ cik: args.entityCik,
251
+ ticker: null,
252
+ file_description: null,
253
+ sic: null,
254
+ location: null,
255
+ source: 'submissions',
256
+ });
257
+ }
258
+ };
259
+ pushBlock(submissions.filings.recent);
260
+ // Older filings live in the archive pages covering the requested pre-2001 range.
261
+ const pages = selectArchivePages(submissions.filings.files, args.startDate, args.endDate);
262
+ const pageLimit = Math.min(pages.length, ARCHIVE_PAGE_SCAN_CAP);
263
+ const scanTruncated = pages.length > pageLimit;
264
+ for (let i = 0; i < pageLimit; i++) {
265
+ const page = pages[i];
266
+ if (!page)
267
+ break;
268
+ pushBlock(await api.fetchArchivePage(page.name));
269
+ }
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
+ });
288
+ }
289
+ /**
290
+ * Pre-2001 unscoped browse arm: fetch the quarterly full-index (`master.idx`)
291
+ * files spanning the range and filter by form + date client-side (the index is
292
+ * not form-filterable server-side). Bounded by FULL_INDEX_QUARTER_SCAN_CAP —
293
+ * the heaviest arm. Rows are tagged `source: 'full-index'`.
294
+ */
295
+ async function serveFullIndexBrowse(ctx, args) {
296
+ const api = getEdgarApiService();
297
+ const quarters = quartersInRange(args.startDate, args.endDate);
298
+ const quarterLimit = Math.min(quarters.length, FULL_INDEX_QUARTER_SCAN_CAP);
299
+ const scanTruncated = quarters.length > quarterLimit;
300
+ const rows = [];
301
+ for (let i = 0; i < quarterLimit; i++) {
302
+ const quarter = quarters[i];
303
+ if (!quarter)
304
+ break;
305
+ const entries = await api.fetchFullIndexQuarter(quarter.year, quarter.quarter);
306
+ for (const entry of entries) {
307
+ if (entry.filingDate < args.startDate || entry.filingDate > args.endDate)
308
+ continue;
309
+ if (!formMatches(entry.form, args.forms))
310
+ continue;
311
+ rows.push({
312
+ accession_number: entry.accessionNumber,
313
+ form: entry.form || null,
314
+ filing_date: entry.filingDate,
315
+ period_ending: null,
316
+ company_name: entry.companyName,
317
+ cik: entry.cik,
318
+ ticker: null,
319
+ file_description: null,
320
+ sic: null,
321
+ location: null,
322
+ source: 'full-index',
323
+ });
324
+ }
325
+ }
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}`,
341
+ });
342
+ }
87
343
  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).',
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).',
89
345
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
90
346
  // Agent-facing context for the success path — the query as EDGAR executed it and
91
347
  // an optional notice for empty results. Populated via ctx.enrich so it reaches
@@ -121,12 +377,36 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
121
377
  when: 'A cik: targeting token in the query is not a 1-10 digit number',
122
378
  recovery: 'Pass a numeric CIK such as cik:320193 — find it with secedgar_company_search if unknown.',
123
379
  },
380
+ {
381
+ reason: 'entity_not_found',
382
+ code: JsonRpcErrorCode.NotFound,
383
+ when: 'A cik: targeting token on a pre-2001 date range names a CIK with no EDGAR submissions history',
384
+ recovery: 'Confirm the CIK with secedgar_company_search — an accession-number prefix names the filing agent, not the issuer.',
385
+ },
124
386
  {
125
387
  reason: 'missing_criteria',
126
388
  code: JsonRpcErrorCode.ValidationError,
127
389
  when: 'Neither a full-text query nor a forms filter was provided (a date range cannot stand alone)',
128
390
  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.',
129
391
  },
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
+ {
399
+ reason: 'pre2001_full_text_unscoped',
400
+ 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.",
409
+ },
130
410
  ],
131
411
  input: z.object({
132
412
  query: z
@@ -139,7 +419,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
139
419
  .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.'),
140
420
  ])
141
421
  .optional()
142
- .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. 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.'),
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.'),
143
423
  forms: z
144
424
  .array(z.string())
145
425
  .optional()
@@ -175,13 +455,15 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
175
455
  sort: z
176
456
  .enum(['filing_date_desc', 'filing_date_asc', 'relevance'])
177
457
  .default('filing_date_desc')
178
- .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.'),
458
+ .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.'),
179
459
  }),
180
460
  output: z.object({
181
461
  total: z
182
462
  .number()
183
- .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."),
184
- total_is_exact: z.boolean().describe('False only when total hits the 10,000 cap.'),
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)."),
464
+ total_is_exact: z
465
+ .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.'),
185
467
  results: z
186
468
  .array(z
187
469
  .object({
@@ -196,7 +478,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
196
478
  period_ending: z
197
479
  .string()
198
480
  .optional()
199
- .describe('Period the filing reports on (YYYY-MM-DD). Absent for filings without a reporting period (e.g., proxy statements, ownership reports).'),
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.'),
200
482
  company_name: z
201
483
  .string()
202
484
  .describe('Filing entity, with ticker/CIK parentheticals stripped.'),
@@ -204,21 +486,25 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
204
486
  ticker: z
205
487
  .string()
206
488
  .optional()
207
- .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.'),
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.'),
208
490
  file_description: z
209
491
  .string()
210
492
  .optional()
211
- .describe('SEC-provided description of the matching document (e.g., "EX-99.1"). Absent when SEC published none.'),
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.'),
212
494
  sic: z
213
495
  .string()
214
496
  .optional()
215
- .describe('SIC industry code for the filer. Absent for filers without a classification.'),
497
+ .describe('SIC industry code for the filer. Absent for filers without a classification, and for pre-2001 archive-sourced rows.'),
216
498
  location: z
217
499
  .string()
218
500
  .optional()
219
- .describe('Business location (state or country code). Absent when SEC has no location for this filer.'),
501
+ .describe('Business location (state or country code). Absent when SEC has no location for this filer, and for pre-2001 archive-sourced rows.'),
502
+ source: z
503
+ .enum(['efts', 'submissions', 'full-index'])
504
+ .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.'),
220
506
  })
221
- .describe('One matching filing hit from the full-text search index.'))
507
+ .describe('One matching filing hit.'))
222
508
  .describe('Matching filings.'),
223
509
  form_distribution: z
224
510
  .record(z.string(), z.number())
@@ -233,10 +519,10 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
233
519
  expires_at: z.string().describe('ISO 8601 expiry timestamp.'),
234
520
  truncated: z
235
521
  .boolean()
236
- .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.'),
522
+ .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.'),
237
523
  })
238
524
  .optional()
239
- .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.'),
525
+ .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.'),
240
526
  }),
241
527
  async handler(input, ctx) {
242
528
  // Validate date range: both or neither
@@ -255,6 +541,48 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
255
541
  // server-side ciks param. Throws typed validation errors on bad tokens (#61).
256
542
  // Default to '' so a browse request (undefined query) resolves cleanly.
257
543
  const { query, entityCik } = await resolveEntityTargeting(input.query ?? '', ctx);
544
+ const api = getEdgarApiService();
545
+ // 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).
548
+ const startDate = input.start_date || undefined;
549
+ 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') });
559
+ }
560
+ if (entityCik) {
561
+ return await serveSubmissionsArchive(ctx, {
562
+ entityCik,
563
+ forms: input.forms,
564
+ startDate,
565
+ endDate,
566
+ sort: input.sort,
567
+ limit: input.limit,
568
+ offset: input.offset,
569
+ });
570
+ }
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,
576
+ sort: input.sort,
577
+ limit: input.limit,
578
+ offset: input.offset,
579
+ });
580
+ }
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
+ }
258
586
  // EFTS scores by relevance and exposes no sort param. When the caller wants
259
587
  // a date sort (the default) or entity filtering, we over-fetch the EFTS
260
588
  // window and reorder/slice client-side. Pure relevance mode keeps the
@@ -262,7 +590,6 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
262
590
  const wideFetch = entityCik !== undefined || input.sort !== 'relevance';
263
591
  const fetchFrom = wideFetch ? 0 : input.offset;
264
592
  const fetchSize = wideFetch ? 100 : input.limit;
265
- const api = getEdgarApiService();
266
593
  const response = await api.searchFilings({
267
594
  query,
268
595
  forms: input.forms,
@@ -302,6 +629,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
302
629
  file_description: hit._source.file_description ?? undefined,
303
630
  sic: hit._source.sics?.[0] ?? undefined,
304
631
  location: hit._source.biz_locations?.[0] ?? undefined,
632
+ source: 'efts',
305
633
  };
306
634
  });
307
635
  // Form distribution must reflect the same set as `total`.
@@ -310,14 +638,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
310
638
  // case, recompute from the post-filter `hits` to keep the counts consistent.
311
639
  let formDistribution;
312
640
  if (entityCik || input.forms?.length) {
313
- const dist = {};
314
- for (const hit of hits) {
315
- const form = hit._source.form;
316
- if (form)
317
- dist[form] = (dist[form] ?? 0) + 1;
318
- }
319
- if (Object.keys(dist).length > 0)
320
- formDistribution = dist;
641
+ formDistribution = buildFormDistribution(hits.map((hit) => hit._source.form));
321
642
  }
322
643
  else if (response.aggregations?.form_filter?.buckets) {
323
644
  formDistribution = {};
@@ -346,6 +667,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
346
667
  start_date: input.start_date,
347
668
  end_date: input.end_date,
348
669
  entity_cik: entityCik,
670
+ source: 'efts',
349
671
  },
350
672
  truncated,
351
673
  });
@@ -403,15 +725,21 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
403
725
  };
404
726
  },
405
727
  format: (result) => {
406
- const exactness = result.total_is_exact ? 'exact' : 'capped at 10,000';
728
+ const source = result.results[0]?.source;
729
+ const exactness = result.total_is_exact
730
+ ? 'exact'
731
+ : source === 'submissions' || source === 'full-index'
732
+ ? 'partial — scan cap reached, more may exist'
733
+ : 'capped at 10,000';
407
734
  const lines = [`Found ${result.total} filings (${exactness})`];
408
735
  for (const r of result.results) {
409
736
  const period = r.period_ending ? ` (period: ${r.period_ending})` : '';
410
737
  const desc = r.file_description ? ` — ${r.file_description}` : '';
411
738
  const sic = r.sic ? ` | SIC ${r.sic}` : '';
412
739
  const loc = r.location ? ` | ${r.location}` : '';
740
+ const src = r.source ? ` | source: ${r.source}` : '';
413
741
  const ident = r.ticker ? `${r.ticker}, CIK ${r.cik}` : `CIK ${r.cik}`;
414
- lines.push(`- ${r.form ?? 'N/A'} ${r.filing_date}${period} — ${r.company_name} (${ident})${sic}${loc}${desc} [${r.accession_number}]`);
742
+ lines.push(`- ${r.form ?? 'N/A'} ${r.filing_date}${period} — ${r.company_name} (${ident})${sic}${loc}${desc}${src} [${r.accession_number}]`);
415
743
  }
416
744
  if (result.form_distribution) {
417
745
  const dist = Object.entries(result.form_distribution)
@@ -421,7 +749,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
421
749
  }
422
750
  if (result.dataset) {
423
751
  const truncatedNote = result.dataset.truncated
424
- ? ' (truncated — more matches exist beyond the EFTS window)'
752
+ ? ' (truncated — more matches exist beyond the materialized set)'
425
753
  : '';
426
754
  lines.push(`\nDataset: ${result.dataset.name} (${result.dataset.row_count} rows, expires ${result.dataset.expires_at})${truncatedNote} — query with secedgar_dataframe_query.`);
427
755
  }