@cyanheads/secedgar-mcp-server 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +31 -3
  2. package/dist/index.js +5 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/mcp-server/prompts/definitions/company-analysis.prompt.d.ts.map +1 -1
  5. package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js +9 -5
  6. package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js.map +1 -1
  7. package/dist/mcp-server/resources/definitions/filing-types.resource.d.ts.map +1 -1
  8. package/dist/mcp-server/resources/definitions/filing-types.resource.js +6 -0
  9. package/dist/mcp-server/resources/definitions/filing-types.resource.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts +93 -0
  11. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts.map +1 -0
  12. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js +457 -0
  13. package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js.map +1 -0
  14. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts +88 -0
  15. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts.map +1 -0
  16. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js +674 -0
  17. package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js.map +1 -0
  18. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts +10 -13
  19. package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/search-filings.tool.js +292 -118
  21. package/dist/mcp-server/tools/definitions/search-filings.tool.js.map +1 -1
  22. package/dist/services/edgar/beneficial-ownership-parser.d.ts +70 -0
  23. package/dist/services/edgar/beneficial-ownership-parser.d.ts.map +1 -0
  24. package/dist/services/edgar/beneficial-ownership-parser.js +157 -0
  25. package/dist/services/edgar/beneficial-ownership-parser.js.map +1 -0
  26. package/dist/services/edgar/edgar-api-service.d.ts +78 -0
  27. package/dist/services/edgar/edgar-api-service.d.ts.map +1 -1
  28. package/dist/services/edgar/edgar-api-service.js +140 -1
  29. package/dist/services/edgar/edgar-api-service.js.map +1 -1
  30. package/dist/services/edgar/nport-parser.d.ts +68 -0
  31. package/dist/services/edgar/nport-parser.d.ts.map +1 -0
  32. package/dist/services/edgar/nport-parser.js +102 -0
  33. package/dist/services/edgar/nport-parser.js.map +1 -0
  34. package/dist/services/edgar/xml-nodes.d.ts +31 -0
  35. package/dist/services/edgar/xml-nodes.d.ts.map +1 -0
  36. package/dist/services/edgar/xml-nodes.js +74 -0
  37. package/dist/services/edgar/xml-nodes.js.map +1 -0
  38. package/package.json +1 -1
  39. package/server.json +3 -3
@@ -0,0 +1,674 @@
1
+ /**
2
+ * @fileoverview Portfolio holdings of one registered fund, parsed from the NPORT-P report
3
+ * it files each quarter. This is the inverse of the ownership tools: those answer who owns
4
+ * a company, this answers what a fund owns. Routing is the substance of the tool — an
5
+ * NPORT-P covers exactly one fund series, a registrant trust holds many series and files
6
+ * one report per series per period, and the report's series identity appears only inside
7
+ * the document. Series-scoped browse resolves that in one call; a bounded read of each
8
+ * candidate's header is the fallback when it cannot.
9
+ * @module mcp-server/tools/definitions/get-fund-holdings
10
+ */
11
+ import { tool, z } from '@cyanheads/mcp-ts-core';
12
+ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
13
+ import { getCanvasBridge, toDatasetField } from '../../../services/canvas-bridge/canvas-bridge.js';
14
+ import { getEdgarApiService, rawDocumentName } from '../../../services/edgar/edgar-api-service.js';
15
+ import { parseNportHeader, parseNportXml, } from '../../../services/edgar/nport-parser.js';
16
+ /** SEC fund series identifier — the letter S followed by nine digits. */
17
+ const SERIES_ID_PATTERN = /^S\d{9}$/i;
18
+ /** Public quarterly portfolio report, plus its amendment. */
19
+ const NPORT_FORMS = ['NPORT-P', 'NPORT-P/A'];
20
+ /**
21
+ * Filings requested from the series-scoped browse. EDGAR's company browse serves fixed page
22
+ * sizes (10, 20, 40, 80, 100) and rounds a request down to one of them, so anything between
23
+ * 10 and 39 is served as ten. Forty is one request either way and reaches roughly a decade of
24
+ * quarterly reports, which is what report_date can address.
25
+ */
26
+ const SERIES_FEED_COUNT = 40;
27
+ /**
28
+ * Ceiling on how many candidate reports the header scan reads. The scan is the fallback
29
+ * routing path, so the bound trades completeness for a call that returns: it comfortably
30
+ * covers a mid-size trust's per-period batch, and a trust large enough to exceed it lists
31
+ * its series in the fund ticker file, which routes without any scan at all.
32
+ */
33
+ const SERIES_SCAN_LIMIT = 20;
34
+ /**
35
+ * Ceiling on the bytes read from a candidate report before giving up on its header. It binds
36
+ * only on a document that arrives in more than one chunk, because the check runs between
37
+ * reads and the runtime picks the chunk boundary: Bun hands back SEC archive documents in
38
+ * 262,144-byte reads, and `</genInfo>` closes 1,565 bytes into a real report, so a candidate
39
+ * costs one 256 KB read of a document that runs to megabytes. The ceiling is what stops a
40
+ * report carrying no identity block at all from being read to the end.
41
+ */
42
+ const HEADER_MAX_BYTES = 65_536;
43
+ /** Closing tag of the NPORT-P identity block — everything after it is the report body. */
44
+ const HEADER_STOP_AT = '</genInfo>';
45
+ /** Registrant series named in a `series_required` error, capped so the message stays readable. */
46
+ const SERIES_HINT_LIMIT = 12;
47
+ /** Every NPORT-P row in a submissions recent-filings window, in submission order. */
48
+ function nportRows(recent) {
49
+ const rows = [];
50
+ for (let i = 0; i < recent.form.length; i++) {
51
+ if (!NPORT_FORMS.includes(recent.form[i] ?? ''))
52
+ continue;
53
+ rows.push({
54
+ accessionNumber: recent.accessionNumber[i] ?? '',
55
+ filingDate: recent.filingDate[i] ?? '',
56
+ form: recent.form[i] ?? '',
57
+ reportDate: recent.reportDate[i] || undefined,
58
+ document: rawDocumentName(recent.primaryDocument[i]),
59
+ });
60
+ }
61
+ return rows;
62
+ }
63
+ /**
64
+ * Order candidates by the period they report, newest first, with the newest-filed winning a
65
+ * tie. Filing order alone is wrong here: an amendment restating an old period is filed after
66
+ * every report of the periods that followed it, so it would otherwise present a year-old
67
+ * portfolio as the current one.
68
+ */
69
+ function byReportPeriod(a, b) {
70
+ return ((b.reportDate ?? '').localeCompare(a.reportDate ?? '') ||
71
+ b.filingDate.localeCompare(a.filingDate));
72
+ }
73
+ /**
74
+ * Read the identity block of up to `limit` candidates, stopping early once `stopWhen` is
75
+ * satisfied. Each read cancels the response body after the header, so a candidate costs one
76
+ * read chunk of a report that runs to megabytes rather than the whole file.
77
+ */
78
+ async function scanHeaders(api, cik, candidates, limit, stopWhen) {
79
+ const scanned = [];
80
+ const budget = candidates.slice(0, limit);
81
+ for (const candidate of budget) {
82
+ const head = await api.tryGetFilingDocumentHead(cik, candidate.accessionNumber, candidate.document, { maxBytes: HEADER_MAX_BYTES, stopAt: HEADER_STOP_AT });
83
+ if (!head)
84
+ continue;
85
+ const header = parseNportHeader(head);
86
+ scanned.push({ candidate, header });
87
+ if (stopWhen?.(header))
88
+ return { scanned, complete: true };
89
+ }
90
+ return { scanned, complete: budget.length === candidates.length };
91
+ }
92
+ /**
93
+ * Fill in the reporting period of candidates the submissions feed could not date, reading it
94
+ * from each report's own identity block until `wanted` turns up.
95
+ *
96
+ * The feed dates only the filings inside the registrant's recent window, and a trust filing
97
+ * thousands of reports a year outruns that window in months — so a series feed reaching back
98
+ * three years routinely carries reports the window cannot date. Those reports exist and are
99
+ * fetchable, so targeting one must not read as absent.
100
+ */
101
+ async function datePeriods(api, cik, candidates, wanted) {
102
+ const undated = candidates.filter((c) => !c.reportDate);
103
+ if (undated.length === 0)
104
+ return;
105
+ const { scanned } = await scanHeaders(api, cik, undated, SERIES_SCAN_LIMIT, (header) => header.report_period_date === wanted);
106
+ for (const { candidate, header } of scanned) {
107
+ candidate.reportDate = header.report_period_date;
108
+ }
109
+ }
110
+ /**
111
+ * Turn series-browse rows into candidates. The browse feed carries no reporting period, so
112
+ * each row is dated from the registrant's submissions window where that window still reaches
113
+ * it, and left undated where it does not.
114
+ */
115
+ async function datedCandidates(api, filings, registrantCik) {
116
+ const byAccession = new Map(nportRows((await api.getSubmissions(registrantCik)).filings.recent).map((row) => [
117
+ row.accessionNumber,
118
+ row,
119
+ ]));
120
+ return filings.map((f) => {
121
+ const known = byAccession.get(f.accessionNumber);
122
+ return {
123
+ accessionNumber: f.accessionNumber,
124
+ filingDate: f.filingDate,
125
+ form: f.form,
126
+ reportDate: known?.reportDate,
127
+ document: rawDocumentName(known?.document),
128
+ };
129
+ });
130
+ }
131
+ /** Distinct series seen across scanned headers, in first-seen order. */
132
+ function distinctSeries(scanned) {
133
+ const seen = new Map();
134
+ for (const { header } of scanned) {
135
+ if (header.series_id && !seen.has(header.series_id)) {
136
+ seen.set(header.series_id, header.series_name);
137
+ }
138
+ }
139
+ return [...seen].map(([id, name]) => ({ id, name }));
140
+ }
141
+ /** Render a USD amount with a scaled T/B/M/K suffix — fund portfolios span $1M to $2T. */
142
+ function formatUsd(value) {
143
+ if (value >= 1e12)
144
+ return `$${(value / 1e12).toFixed(2)}T`;
145
+ if (value >= 1e9)
146
+ return `$${(value / 1e9).toFixed(2)}B`;
147
+ if (value >= 1e6)
148
+ return `$${(value / 1e6).toFixed(2)}M`;
149
+ if (value >= 1e3)
150
+ return `$${(value / 1e3).toFixed(2)}K`;
151
+ return `$${value.toFixed(2)}`;
152
+ }
153
+ /** Whole days between two YYYY-MM-DD dates. */
154
+ function daysBetween(from, to) {
155
+ return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
156
+ }
157
+ export const getFundHoldingsTool = tool('secedgar_get_fund_holdings', {
158
+ title: 'Get Fund Holdings',
159
+ description: "List what an ETF or mutual fund holds, parsed from the NPORT-P portfolio report it files with the SEC every quarter. The input is the fund — a ticker like VOO, a fund series ID, or the registrant trust — which is the opposite direction from the ownership tools: secedgar_get_institutional_holdings and secedgar_find_holders answer who owns a company, this answers what a fund owns. Each position carries the security name, CUSIP/ISIN/LEI where the filer reports them, share balance, market value in USD, and percent of the fund's net assets, alongside fund-level net assets and total assets. Positions are returned largest-first by percent of net assets, one page of limit rows starting at offset; the full report registers as df_<id> when a canvas is available, which is how a fund running to thousands of positions is aggregated or joined against the 13F and insider dataframes. An NPORT-P covers exactly one fund series and a registrant trust files one report per series, so a trust with several funds needs the specific fund named — pass its ticker or series_id. Reports publish roughly two months after the period they cover, so every result is dated: the holdings are the portfolio as of report_period_date, not as of today.",
160
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
161
+ errors: [
162
+ {
163
+ reason: 'fund_not_found',
164
+ code: JsonRpcErrorCode.NotFound,
165
+ when: 'The fund input resolves to neither an EDGAR company nor a known fund series',
166
+ recovery: 'Use secedgar_company_search with the fund ticker to get its CIK and series ID.',
167
+ },
168
+ {
169
+ reason: 'ambiguous_fund',
170
+ code: JsonRpcErrorCode.ValidationError,
171
+ when: 'The fund name matches several EDGAR companies',
172
+ recovery: 'Retry with the fund ticker or the 10-digit CIK from the matches list.',
173
+ },
174
+ {
175
+ reason: 'series_required',
176
+ code: JsonRpcErrorCode.ValidationError,
177
+ when: 'The input resolves to a registrant trust that files reports for more than one fund series',
178
+ recovery: 'Retry with series_id set to one of the listed series, or pass a fund ticker.',
179
+ },
180
+ {
181
+ reason: 'no_filings_found',
182
+ code: JsonRpcErrorCode.NotFound,
183
+ when: 'No NPORT-P report exists for this fund, or none for the requested report_date',
184
+ recovery: 'Drop report_date to take the newest report, or use secedgar_search_filings with forms ["NPORT-P"] to see what the registrant has filed.',
185
+ },
186
+ ],
187
+ input: z.object({
188
+ fund: z
189
+ .string()
190
+ .trim()
191
+ .min(1, 'Fund cannot be blank')
192
+ .describe('The fund whose portfolio you want — a fund ticker ("VOO", "SCHD"), an SEC fund series ID ("S000002839"), or a 10-digit CIK. A ticker names one share class of one series and routes directly; a CIK names the registrant, which files a separate report per series and needs series_id when it runs more than one fund. Fund trusts are indexed by ticker and series, not by name, so a trust name only resolves for a fund that trades under its own name ("SPDR S&P 500 ETF Trust") — pass the CIK otherwise.'),
193
+ series_id: z
194
+ .string()
195
+ .trim()
196
+ .regex(SERIES_ID_PATTERN, 'Series ID must be the letter S followed by nine digits')
197
+ .optional()
198
+ .describe('SEC fund series identifier ("S000002839"), naming which fund of the registrant to report. Takes precedence over any series the fund input implies. Series IDs come back on fund results from secedgar_company_search and in the series list of a series_required error.'),
199
+ report_date: z
200
+ .string()
201
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'Report date must be YYYY-MM-DD')
202
+ .optional()
203
+ .describe('Target a specific reporting period by its last day (YYYY-MM-DD), e.g. "2025-12-31". Omit for the most recent report. Period ends follow the fund\'s own fiscal quarters, which are not always calendar quarters — Direxion funds report to February, May, August, and November. available_report_periods in the response lists the ones this call identified; a period missing from that list is still worth requesting directly, since a report the submissions window no longer dates is dated by reading it.'),
204
+ limit: z
205
+ .number()
206
+ .int()
207
+ .min(1)
208
+ .max(100)
209
+ .default(20)
210
+ .describe('Number of positions to return inline, largest first by percent of net assets. Default 20. A broad index fund reports thousands of positions, so the inline list is a preview — read the whole portfolio from the dataframe, or page it with offset.'),
211
+ offset: z
212
+ .number()
213
+ .int()
214
+ .min(0)
215
+ .default(0)
216
+ .describe('Position to start the page at, 0-based, over the full ordered holdings list. Pass the returned next_offset to read the next page — the report is parsed whole and sliced, so paging is stable and gap-free.'),
217
+ }),
218
+ output: z.object({
219
+ fund: z.string().describe('The fund input, echoed.'),
220
+ series_id: z
221
+ .string()
222
+ .optional()
223
+ .describe('SEC series ID of the fund this report covers. Absent when the registrant files as a single fund with no series structure, which is how some older exchange-traded trusts are organized.'),
224
+ series_name: z
225
+ .string()
226
+ .optional()
227
+ .describe('Fund name as the filer states it on the report. A closed-end fund organized as a single registrant names itself here with no series_id alongside; absent only when the filer leaves the field blank or writes "N/A".'),
228
+ class_ids: z
229
+ .array(z.string())
230
+ .describe('SEC class IDs of the share classes covered. One report covers every class of the series, so a fund with both an ETF and an admiral-share class reports them together.'),
231
+ registrant_cik: z.string().describe('CIK of the registrant trust, zero-padded to 10 digits.'),
232
+ registrant_name: z.string().describe('EDGAR-conformed name of the registrant trust.'),
233
+ report_period_date: z
234
+ .string()
235
+ .optional()
236
+ .describe("Last day of the period this portfolio is reported as of (YYYY-MM-DD). Holdings are the fund's positions on this date, not today's. Absent only when the filer omits it."),
237
+ report_period_end: z
238
+ .string()
239
+ .optional()
240
+ .describe("Last day of the fiscal year the reporting period falls in (YYYY-MM-DD) — the fund's fiscal year end, not the portfolio date."),
241
+ filing_date: z.string().describe('Date the report was submitted to EDGAR (YYYY-MM-DD).'),
242
+ publication_lag_days: z
243
+ .number()
244
+ .optional()
245
+ .describe('Days between the portfolio date and the filing date. Absent when the report omits its period date.'),
246
+ form: z.string().describe('EDGAR form name — "NPORT-P", or "NPORT-P/A" for an amended report.'),
247
+ accession_number: z
248
+ .string()
249
+ .describe('Accession number — pass to secedgar_get_filing for the full document.'),
250
+ is_final_filing: z
251
+ .boolean()
252
+ .optional()
253
+ .describe('True when the fund reports this as its last filing on the series, which marks a liquidation or merger. Absent when the filing does not answer.'),
254
+ net_assets_usd: z
255
+ .number()
256
+ .optional()
257
+ .describe('Fund net assets in USD at the report date — the denominator of percent_of_net_assets.'),
258
+ total_assets_usd: z
259
+ .number()
260
+ .optional()
261
+ .describe('Fund total assets in USD at the report date.'),
262
+ total_liabilities_usd: z
263
+ .number()
264
+ .optional()
265
+ .describe('Fund total liabilities in USD at the report date.'),
266
+ total_holdings: z
267
+ .number()
268
+ .describe('Positions in the report, before offset and limit — the size of the full portfolio.'),
269
+ offset: z.number().describe('Position the returned page starts at, 0-based.'),
270
+ next_offset: z
271
+ .number()
272
+ .optional()
273
+ .describe('Offset to pass on the next call to continue through the portfolio. Absent on the last page.'),
274
+ available_report_periods: z
275
+ .array(z.string())
276
+ .describe("Period end dates of this fund's reports, newest first — the horizon report_date can address, not the fund's full history. It reaches back roughly a decade of quarterly reports, and a period older than that is refused rather than served. A period inside the horizon can still be missing from the list: the dates come from the registrant's recent submissions window, which a trust filing thousands of reports a year outruns in months, and a report the window no longer reaches is dated by reading it only when report_date asks for it."),
277
+ holdings: z
278
+ .array(z
279
+ .object({
280
+ name: z
281
+ .string()
282
+ .describe('Issuer name as the fund reports it. A derivative position routinely reports the literal "N/A" here and names the instrument in title instead, so group and label positions by title when asset_category marks a derivative.'),
283
+ title: z
284
+ .string()
285
+ .optional()
286
+ .describe("The filer's own title for the security, often an abbreviated trading name."),
287
+ cusip: z
288
+ .string()
289
+ .optional()
290
+ .describe('9-character CUSIP. Absent when the filer omits it.'),
291
+ isin: z
292
+ .string()
293
+ .optional()
294
+ .describe('ISIN. Absent when the filer reports another identifier.'),
295
+ lei: z
296
+ .string()
297
+ .optional()
298
+ .describe('Legal Entity Identifier of the issuer, when reported.'),
299
+ balance: z
300
+ .number()
301
+ .optional()
302
+ .describe('Units held, counted in whatever `units` names — shares, principal, or contracts.'),
303
+ units: z
304
+ .string()
305
+ .optional()
306
+ .describe('What balance counts — NS number of shares, PA principal amount, NC number of contracts, OU other.'),
307
+ currency: z
308
+ .string()
309
+ .optional()
310
+ .describe('ISO 4217 currency the position is denominated in.'),
311
+ value_usd: z
312
+ .number()
313
+ .optional()
314
+ .describe('Market value of the position in USD at the report date.'),
315
+ percent_of_net_assets: z
316
+ .number()
317
+ .optional()
318
+ .describe("Percent of the fund's net assets, as the filer computes it. Negative on a short position — a leveraged fund's swap or futures leg regularly reports several percent below zero — so this is not bounded at 0."),
319
+ payoff_profile: z
320
+ .string()
321
+ .optional()
322
+ .describe('"Long", "Short", or "N/A" for instruments with no direction.'),
323
+ asset_category: z
324
+ .string()
325
+ .optional()
326
+ .describe('SEC asset-type code — EC equity-common, EP equity-preferred, DBT debt, RA repurchase agreement, STIV short-term investment vehicle, DE derivative. A filer that classifies a position as Other reports its own label here instead of a code ("Right"), because the code in that case is just "OTHER".'),
327
+ issuer_category: z
328
+ .string()
329
+ .optional()
330
+ .describe('SEC issuer-type code — CORP corporate, MUN municipal, USGSE US government-sponsored, RF registered fund. A filer that classifies an issuer as Other reports its own label here instead of a code ("Future", "Warrant").'),
331
+ country: z.string().optional().describe('ISO 3166 country of investment.'),
332
+ })
333
+ .describe('One position from the fund portfolio.'))
334
+ .describe('One page of positions, `limit` rows starting at `offset`, largest first by percent of net assets.'),
335
+ dataset: z
336
+ .object({
337
+ name: z
338
+ .string()
339
+ .describe('Dataframe handle (df_XXXXX_XXXXX) — pass to secedgar_dataframe_query.'),
340
+ row_count: z.number().describe('Rows materialized in the dataframe.'),
341
+ expires_at: z.string().describe('ISO 8601 expiry timestamp.'),
342
+ })
343
+ .optional()
344
+ .describe('Canvas dataframe holding every position in the report (the inline holdings[] is a preview capped at limit). Each row carries the fund keys — series_id, registrant_cik, report_period_date, accession_number — alongside the position fields, so it joins against the 13F and insider dataframes on cusip. Absent when canvas is unavailable or the report had no positions.'),
345
+ }),
346
+ enrichment: {
347
+ as_of: z
348
+ .string()
349
+ .describe('The portfolio date these holdings are reported as of, and the publication lag behind it.'),
350
+ notice: z
351
+ .string()
352
+ .optional()
353
+ .describe('Guidance when the report carried no positions or the page fell past the end.'),
354
+ truncated: z
355
+ .boolean()
356
+ .optional()
357
+ .describe('True when the inline holdings list was capped by limit.'),
358
+ shown: z.number().optional().describe('Number of positions shown inline.'),
359
+ cap: z.number().optional().describe('The limit cap applied.'),
360
+ },
361
+ async handler(input, ctx) {
362
+ const api = getEdgarApiService();
363
+ // Phase 1 — registrant and series identity, from local indexes only.
364
+ const fundIsSeries = SERIES_ID_PATTERN.test(input.fund);
365
+ let targetSeries = (input.series_id ?? (fundIsSeries ? input.fund : undefined))?.toUpperCase();
366
+ let registrantCik;
367
+ if (fundIsSeries) {
368
+ registrantCik = (await api.resolveFundSeries(input.fund))?.cik;
369
+ }
370
+ else {
371
+ const resolved = await api.resolveCik(input.fund);
372
+ const candidates = Array.isArray(resolved) ? resolved : [resolved];
373
+ if (candidates.length > 1) {
374
+ const shown = candidates.slice(0, 10);
375
+ const list = shown.map((c) => `${c.ticker ?? c.cik} (${c.name ?? 'Unknown'})`).join(', ');
376
+ throw ctx.fail('ambiguous_fund', `'${input.fund}' matches multiple EDGAR companies: ${list}.`, {
377
+ ...ctx.recoveryFor('ambiguous_fund'),
378
+ matches: shown.map((c) => ({ cik: c.cik, name: c.name, ticker: c.ticker })),
379
+ });
380
+ }
381
+ const match = candidates[0];
382
+ if (!match) {
383
+ throw ctx.fail('fund_not_found', `Fund '${input.fund}' not found in EDGAR.`, {
384
+ ...ctx.recoveryFor('fund_not_found'),
385
+ });
386
+ }
387
+ registrantCik = match.cik;
388
+ targetSeries ??= match.seriesId?.toUpperCase();
389
+ }
390
+ // A registrant naming no series is only answerable when it runs exactly one fund. The
391
+ // fund ticker file settles that without a request; a trust whose series carry no listed
392
+ // ticker returns nothing here and falls through to the header scan below.
393
+ if (!targetSeries && registrantCik) {
394
+ const listed = await api.listFundSeries(registrantCik);
395
+ if (listed.length === 1 && listed[0])
396
+ targetSeries = listed[0].seriesId;
397
+ else if (listed.length > 1) {
398
+ const shown = listed.slice(0, SERIES_HINT_LIMIT);
399
+ const list = shown
400
+ .map((s) => `${s.seriesId}${s.ticker ? ` (${s.ticker})` : ''}`)
401
+ .join(', ');
402
+ const more = listed.length > shown.length ? ` and ${listed.length - shown.length} more` : '';
403
+ throw ctx.fail('series_required', `'${input.fund}' is a registrant with ${listed.length} fund series, each filing its own NPORT-P report. Name one: ${list}${more}.`, {
404
+ ...ctx.recoveryFor('series_required'),
405
+ registrant_cik: registrantCik,
406
+ series: shown.map((s) => ({ series_id: s.seriesId, ticker: s.ticker })),
407
+ });
408
+ }
409
+ }
410
+ // Phase 2 — candidate reports for the fund. Series-scoped browse answers in one call;
411
+ // reading candidate headers is the fallback for a series it does not index.
412
+ let candidates = [];
413
+ if (targetSeries) {
414
+ const feed = await api.getFundSeriesFilings(targetSeries, 'NPORT-P', SERIES_FEED_COUNT);
415
+ // The browse feed is keyed on the series, so the registrant it names is the trust that
416
+ // files for this series. It outranks the one the fund input implied, which is a
417
+ // different trust whenever series_id overrides that input.
418
+ registrantCik = feed.registrantCik ?? registrantCik;
419
+ if (feed.filings.length > 0 && registrantCik) {
420
+ candidates = await datedCandidates(api, feed.filings, registrantCik);
421
+ }
422
+ }
423
+ if (!registrantCik) {
424
+ throw ctx.fail('fund_not_found', `No EDGAR registrant found for fund series ${targetSeries ?? input.fund}.`, { ...ctx.recoveryFor('fund_not_found') });
425
+ }
426
+ const submissions = await api.getSubmissions(registrantCik);
427
+ if (candidates.length === 0) {
428
+ const rows = nportRows(submissions.filings.recent);
429
+ if (rows.length === 0) {
430
+ throw ctx.fail('no_filings_found', `${submissions.name} (CIK ${registrantCik}) has no NPORT-P portfolio reports in its recent submissions window. Money-market funds report their portfolios on N-MFP instead, and an issuer that is not a registered investment company files neither.`, { ...ctx.recoveryFor('no_filings_found') });
431
+ }
432
+ const ordered = [...rows].sort(byReportPeriod);
433
+ if (targetSeries) {
434
+ // Series-scoped browse returned nothing for a series the registrant plainly files
435
+ // for — read headers until the series turns up.
436
+ const { scanned, complete } = await scanHeaders(api, registrantCik, ordered, SERIES_SCAN_LIMIT, (header) => header.series_id?.toUpperCase() === targetSeries);
437
+ const hit = scanned.find((s) => s.header.series_id?.toUpperCase() === targetSeries);
438
+ if (!hit) {
439
+ const bound = complete
440
+ ? `across all ${ordered.length} of its NPORT-P reports`
441
+ : `in the ${scanned.length} most recent of its ${ordered.length} NPORT-P reports (the scan stops there)`;
442
+ throw ctx.fail('no_filings_found', `No NPORT-P report for series ${targetSeries} found under ${submissions.name} (CIK ${registrantCik}) ${bound}. A fund launched since the registrant's last reporting period has not filed one yet; otherwise the series may belong to a different registrant.`, { ...ctx.recoveryFor('no_filings_found'), registrant_cik: registrantCik });
443
+ }
444
+ candidates = [hit.candidate];
445
+ }
446
+ else {
447
+ // No series to route on, so read the newest period's reports to learn whose they are.
448
+ // The batch has to be read even when it holds one report: a registrant's NPORT-P
449
+ // history interleaves its series, and series fiscal quarters are staggered, so one
450
+ // report for the newest period does not mean one fund — it means one fund whose
451
+ // quarter ends latest. Several distinct series mean the ticker file lists none of
452
+ // them, so name them rather than picking one silently.
453
+ const newestPeriod = ordered[0]?.reportDate;
454
+ const newestBatch = ordered.filter((row) => row.reportDate === newestPeriod);
455
+ const { scanned, complete } = await scanHeaders(api, registrantCik, newestBatch, SERIES_SCAN_LIMIT);
456
+ const series = distinctSeries(scanned);
457
+ if (series.length > 1) {
458
+ const list = series
459
+ .slice(0, SERIES_HINT_LIMIT)
460
+ .map((s) => `${s.id}${s.name ? ` (${s.name})` : ''}`)
461
+ .join(', ');
462
+ const bound = complete ? '' : ` — read from the ${scanned.length} newest reports only`;
463
+ throw ctx.fail('series_required', `${submissions.name} (CIK ${registrantCik}) files ${newestBatch.length} NPORT-P reports for period ${newestPeriod}, one per fund series. Name one: ${list}${bound}.`, {
464
+ ...ctx.recoveryFor('series_required'),
465
+ registrant_cik: registrantCik,
466
+ series: series.map((s) => ({ series_id: s.id, series_name: s.name })),
467
+ });
468
+ }
469
+ // The report names a series, so route on it: the registrant's own filing list mixes
470
+ // every series it runs, and a report_date drawn from that mixture would answer with
471
+ // whichever fund happens to close its quarter on that date.
472
+ const only = series[0];
473
+ if (only) {
474
+ targetSeries = only.id.toUpperCase();
475
+ const feed = await api.getFundSeriesFilings(targetSeries, 'NPORT-P', SERIES_FEED_COUNT);
476
+ if (feed.filings.length > 0) {
477
+ candidates = await datedCandidates(api, feed.filings, registrantCik);
478
+ }
479
+ }
480
+ // A registrant that files as a single fund carries no series in the document at all,
481
+ // so its whole NPORT-P history is that one fund's.
482
+ if (candidates.length === 0)
483
+ candidates = ordered;
484
+ }
485
+ }
486
+ // Phase 3 — pick the report. Ordering is by period reported, so an amendment restating
487
+ // an older period never displaces the current portfolio.
488
+ if (input.report_date && !candidates.some((c) => c.reportDate === input.report_date)) {
489
+ await datePeriods(api, registrantCik, candidates, input.report_date);
490
+ }
491
+ const ordered = [...candidates].sort(byReportPeriod);
492
+ const availablePeriods = [
493
+ ...new Set(ordered.map((c) => c.reportDate).filter((d) => Boolean(d))),
494
+ ];
495
+ const matching = input.report_date
496
+ ? ordered.filter((c) => c.reportDate === input.report_date)
497
+ : ordered;
498
+ const target = matching[0];
499
+ if (!target) {
500
+ const undatedNote = ordered.some((c) => !c.reportDate)
501
+ ? ` ${ordered.filter((c) => !c.reportDate).length} of its ${ordered.length} reports could not be dated and are not listed.`
502
+ : '';
503
+ throw ctx.fail('no_filings_found', `No NPORT-P report for period ${input.report_date} under ${submissions.name}. Reported periods: ${availablePeriods.join(', ') || 'none identified'}.${undatedNote}`, {
504
+ ...ctx.recoveryFor('no_filings_found'),
505
+ available_report_periods: availablePeriods,
506
+ });
507
+ }
508
+ // Phase 4 — read the report itself.
509
+ const xml = await api.tryGetFilingDocument(registrantCik, target.accessionNumber, target.document);
510
+ if (!xml) {
511
+ throw ctx.fail('no_filings_found', `NPORT-P document '${target.document}' for filing ${target.accessionNumber} could not be fetched.`, { ...ctx.recoveryFor('no_filings_found') });
512
+ }
513
+ const report = parseNportXml(xml);
514
+ // Largest position first, so a small limit returns the positions that carry the fund.
515
+ const positions = [...report.holdings].sort((a, b) => (b.percent_of_net_assets ?? -1) - (a.percent_of_net_assets ?? -1) ||
516
+ (b.value_usd ?? -1) - (a.value_usd ?? -1));
517
+ let dataset;
518
+ const bridge = getCanvasBridge();
519
+ if (bridge && positions.length > 0) {
520
+ const registered = await bridge.registerDataframe(ctx, {
521
+ rows: positions.map((p) => ({
522
+ registrant_cik: registrantCik,
523
+ registrant_name: submissions.name,
524
+ series_id: report.series_id ?? null,
525
+ series_name: report.series_name ?? null,
526
+ report_period_date: report.report_period_date ?? null,
527
+ filing_date: target.filingDate,
528
+ accession_number: target.accessionNumber,
529
+ name: p.name,
530
+ title: p.title ?? null,
531
+ cusip: p.cusip ?? null,
532
+ isin: p.isin ?? null,
533
+ lei: p.lei ?? null,
534
+ balance: p.balance ?? null,
535
+ units: p.units ?? null,
536
+ currency: p.currency ?? null,
537
+ value_usd: p.value_usd ?? null,
538
+ percent_of_net_assets: p.percent_of_net_assets ?? null,
539
+ payoff_profile: p.payoff_profile ?? null,
540
+ asset_category: p.asset_category ?? null,
541
+ issuer_category: p.issuer_category ?? null,
542
+ country: p.country ?? null,
543
+ })),
544
+ sourceTool: 'secedgar_get_fund_holdings',
545
+ queryParams: {
546
+ fund: input.fund,
547
+ series_id: report.series_id,
548
+ registrant_cik: registrantCik,
549
+ report_date: report.report_period_date,
550
+ accession_number: target.accessionNumber,
551
+ },
552
+ });
553
+ if (registered)
554
+ dataset = toDatasetField(registered);
555
+ }
556
+ const pageEnd = input.offset + input.limit;
557
+ const holdings = positions.slice(input.offset, pageEnd);
558
+ const nextOffset = pageEnd < positions.length ? pageEnd : undefined;
559
+ if (nextOffset !== undefined) {
560
+ ctx.enrich.truncated({ shown: holdings.length, cap: input.limit });
561
+ }
562
+ const lagDays = report.report_period_date
563
+ ? daysBetween(report.report_period_date, target.filingDate)
564
+ : undefined;
565
+ ctx.enrich({
566
+ as_of: report.report_period_date
567
+ ? `Portfolio as of ${report.report_period_date}, filed ${target.filingDate} — ${lagDays} days later. NPORT-P publishes on a lag, so these are not current-day positions.`
568
+ : `This report states no portfolio date; it was filed ${target.filingDate}. NPORT-P publishes on a lag, so these are not current-day positions.`,
569
+ });
570
+ if (holdings.length === 0 && input.offset > 0) {
571
+ ctx.enrich.notice(`Offset (${input.offset}) is at or past the ${positions.length} positions in this report. Lower the offset to page back into the portfolio.`);
572
+ }
573
+ else if (holdings.length === 0) {
574
+ ctx.enrich.notice(`Report ${target.accessionNumber} lists no portfolio positions. A fund that had liquidated by the period end files an empty report — is_final_filing says whether this is one — and secedgar_get_filing on this accession shows the document as filed.`);
575
+ }
576
+ ctx.log.info('Fund holdings retrieved', {
577
+ registrantCik,
578
+ seriesId: report.series_id,
579
+ accessionNumber: target.accessionNumber,
580
+ reportPeriod: report.report_period_date,
581
+ totalHoldings: positions.length,
582
+ returned: holdings.length,
583
+ datasetName: dataset?.name,
584
+ });
585
+ return {
586
+ fund: input.fund,
587
+ series_id: report.series_id,
588
+ series_name: report.series_name,
589
+ class_ids: report.class_ids,
590
+ registrant_cik: registrantCik,
591
+ registrant_name: submissions.name,
592
+ report_period_date: report.report_period_date,
593
+ report_period_end: report.report_period_end,
594
+ filing_date: target.filingDate,
595
+ publication_lag_days: lagDays,
596
+ form: target.form,
597
+ accession_number: target.accessionNumber,
598
+ is_final_filing: report.is_final_filing,
599
+ net_assets_usd: report.net_assets_usd,
600
+ total_assets_usd: report.total_assets_usd,
601
+ total_liabilities_usd: report.total_liabilities_usd,
602
+ total_holdings: positions.length,
603
+ offset: input.offset,
604
+ next_offset: nextOffset,
605
+ available_report_periods: availablePeriods,
606
+ holdings,
607
+ dataset,
608
+ };
609
+ },
610
+ format: (result) => {
611
+ const label = result.series_name ?? result.registrant_name;
612
+ const lines = [
613
+ `**${label} portfolio** — input "${result.fund}"`,
614
+ `Portfolio as of ${result.report_period_date ?? 'an unstated date'}, filed ${result.filing_date}${result.publication_lag_days !== undefined
615
+ ? ` (${result.publication_lag_days} days later)`
616
+ : ''} | ${result.form} ${result.accession_number}`,
617
+ `Registrant: ${result.registrant_name} (CIK ${result.registrant_cik})${result.series_id ? ` | series ${result.series_id}` : ''}${result.class_ids.length > 0 ? ` | classes ${result.class_ids.join(', ')}` : ''}`,
618
+ ];
619
+ if (result.report_period_end)
620
+ lines.push(`Fiscal year end: ${result.report_period_end}`);
621
+ if (result.is_final_filing !== undefined) {
622
+ lines.push(`Final filing on this series: ${result.is_final_filing ? 'yes' : 'no'}`);
623
+ }
624
+ const money = (label, value) => `${label} ${formatUsd(value)} ($${value.toLocaleString('en-US')})`;
625
+ const assets = [];
626
+ if (result.net_assets_usd !== undefined)
627
+ assets.push(money('net assets', result.net_assets_usd));
628
+ if (result.total_assets_usd !== undefined)
629
+ assets.push(money('total assets', result.total_assets_usd));
630
+ if (result.total_liabilities_usd !== undefined)
631
+ assets.push(money('total liabilities', result.total_liabilities_usd));
632
+ if (assets.length > 0)
633
+ lines.push(assets.join(' | '));
634
+ lines.push(`Showing ${result.holdings.length} of ${result.total_holdings} positions from offset ${result.offset}.`);
635
+ if (result.next_offset !== undefined) {
636
+ lines.push(`Next offset: ${result.next_offset} — pass as offset to read the next page.`);
637
+ }
638
+ if (result.available_report_periods.length > 0) {
639
+ lines.push(`Reported periods available: ${result.available_report_periods.join(', ')}`);
640
+ }
641
+ for (const h of result.holdings) {
642
+ lines.push('');
643
+ const ids = [
644
+ h.cusip && `CUSIP ${h.cusip}`,
645
+ h.isin && `ISIN ${h.isin}`,
646
+ h.lei && `LEI ${h.lei}`,
647
+ ]
648
+ .filter(Boolean)
649
+ .join(' ');
650
+ lines.push(`**${h.name}**${h.title && h.title !== h.name ? ` (${h.title})` : ''}${ids ? ` — ${ids}` : ''}`);
651
+ const value = h.value_usd !== undefined
652
+ ? `${formatUsd(h.value_usd)} ($${h.value_usd.toLocaleString('en-US')})`
653
+ : 'value N/A';
654
+ const pct = h.percent_of_net_assets !== undefined
655
+ ? `${h.percent_of_net_assets.toFixed(4)}% of net assets`
656
+ : 'percent N/A';
657
+ const balance = h.balance !== undefined
658
+ ? `${h.balance.toLocaleString()} ${h.units ?? 'units'}`
659
+ : 'balance N/A';
660
+ lines.push(`${balance} | ${value}${h.currency && h.currency !== 'USD' ? ` (${h.currency})` : ''} | ${pct}`);
661
+ const tags = [h.asset_category, h.issuer_category, h.country, h.payoff_profile].filter(Boolean);
662
+ if (tags.length > 0)
663
+ lines.push(tags.join(' | '));
664
+ }
665
+ if (result.dataset) {
666
+ const note = result.dataset.row_count > result.holdings.length
667
+ ? ` — showing ${result.holdings.length} of ${result.dataset.row_count} positions inline; full portfolio on the dataframe`
668
+ : '';
669
+ lines.push(`\nDataset: ${result.dataset.name} (${result.dataset.row_count} rows, expires ${result.dataset.expires_at})${note} — query with secedgar_dataframe_query.`);
670
+ }
671
+ return [{ type: 'text', text: lines.join('\n') }];
672
+ },
673
+ });
674
+ //# sourceMappingURL=get-fund-holdings.tool.js.map