@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.
- package/README.md +5 -2
- package/dist/index.js +1 -1
- package/dist/index.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 +3 -2
- package/dist/mcp-server/resources/definitions/filing-types.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/company-search.tool.d.ts +9 -0
- package/dist/mcp-server/tools/definitions/company-search.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/company-search.tool.js +145 -21
- package/dist/mcp-server/tools/definitions/company-search.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/dataframe-query.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/dataframe-query.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/dataframe-query.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/dataframe-query.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts +29 -2
- package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-filings.tool.js +377 -39
- package/dist/mcp-server/tools/definitions/search-filings.tool.js.map +1 -1
- package/dist/services/canvas-bridge/canvas-bridge.d.ts.map +1 -1
- package/dist/services/canvas-bridge/canvas-bridge.js +14 -2
- package/dist/services/canvas-bridge/canvas-bridge.js.map +1 -1
- package/dist/services/edgar/edgar-api-service.d.ts +63 -1
- package/dist/services/edgar/edgar-api-service.d.ts.map +1 -1
- package/dist/services/edgar/edgar-api-service.js +134 -2
- package/dist/services/edgar/edgar-api-service.js.map +1 -1
- package/dist/services/edgar/types.d.ts +42 -1
- package/dist/services/edgar/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -3
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview
|
|
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
|
|
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
|
-
.
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
172
|
-
total_is_exact: z
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|