@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.
- package/README.md +31 -3
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/company-analysis.prompt.d.ts.map +1 -1
- package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js +9 -5
- package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js.map +1 -1
- package/dist/mcp-server/resources/definitions/filing-types.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/filing-types.resource.js +6 -0
- package/dist/mcp-server/resources/definitions/filing-types.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts +93 -0
- package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js +457 -0
- package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts +88 -0
- package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js +674 -0
- package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts +10 -13
- package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-filings.tool.js +292 -118
- package/dist/mcp-server/tools/definitions/search-filings.tool.js.map +1 -1
- package/dist/services/edgar/beneficial-ownership-parser.d.ts +70 -0
- package/dist/services/edgar/beneficial-ownership-parser.d.ts.map +1 -0
- package/dist/services/edgar/beneficial-ownership-parser.js +157 -0
- package/dist/services/edgar/beneficial-ownership-parser.js.map +1 -0
- package/dist/services/edgar/edgar-api-service.d.ts +78 -0
- package/dist/services/edgar/edgar-api-service.d.ts.map +1 -1
- package/dist/services/edgar/edgar-api-service.js +140 -1
- package/dist/services/edgar/edgar-api-service.js.map +1 -1
- package/dist/services/edgar/nport-parser.d.ts +68 -0
- package/dist/services/edgar/nport-parser.d.ts.map +1 -0
- package/dist/services/edgar/nport-parser.js +102 -0
- package/dist/services/edgar/nport-parser.js.map +1 -0
- package/dist/services/edgar/xml-nodes.d.ts +31 -0
- package/dist/services/edgar/xml-nodes.d.ts.map +1 -0
- package/dist/services/edgar/xml-nodes.js +74 -0
- package/dist/services/edgar/xml-nodes.js.map +1 -0
- package/package.json +1 -1
- 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
|
|
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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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.
|
|
238
|
+
truncated: args.truncated,
|
|
162
239
|
});
|
|
163
240
|
if (registered)
|
|
164
|
-
dataset = { ...toDatasetField(registered), truncated: args.
|
|
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}
|
|
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.
|
|
178
|
-
? `${args.coverageNote}
|
|
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
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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:
|
|
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
|
|
390
|
-
recovery: 'Add ticker: or cik: entity scope to the query,
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
535
|
-
// archives,
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
throw ctx.fail('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
|
-
|
|
660
|
+
archive = await collectSubmissionsRows(ctx, {
|
|
550
661
|
entityCik,
|
|
551
662
|
forms: input.forms,
|
|
552
663
|
startDate,
|
|
553
|
-
endDate,
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
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
|
-
:
|
|
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)'
|