@etsquare/mcp-server-sec 0.4.0 → 0.5.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.
@@ -45,6 +45,14 @@ interface EarningsActualsApiInput {
45
45
  quarters?: number;
46
46
  metrics?: string[];
47
47
  }
48
+ /** Stored KPI extractions request. */
49
+ interface KpiExtractionsApiInput {
50
+ ticker?: string;
51
+ sector?: string;
52
+ sic_code?: string;
53
+ fiscal_year?: number;
54
+ limit?: number;
55
+ }
48
56
  /** Subset of DiscoverMetricsInput that the API v1 backend accepts. */
49
57
  interface DiscoverMetricsApiInput {
50
58
  question: string;
@@ -70,6 +78,7 @@ export declare class ETSquareClient {
70
78
  getInstitutionalHoldings(input: InstitutionalHoldingsApiInput): Promise<Record<string, unknown>>;
71
79
  getInsiderTransactions(input: InsiderTransactionsApiInput): Promise<Record<string, unknown>>;
72
80
  getEarningsActuals(input: EarningsActualsApiInput): Promise<Record<string, unknown>>;
81
+ getKpiExtractions(input: KpiExtractionsApiInput): Promise<Record<string, unknown>>;
73
82
  discoverMetrics(input: DiscoverMetricsApiInput): Promise<Record<string, unknown>>;
74
83
  getChunk(input: GetChunkInput): Promise<Record<string, unknown>>;
75
84
  getChunkContext(input: GetChunkContextInput): Promise<Record<string, unknown>>;
@@ -136,6 +136,24 @@ export class ETSquareClient {
136
136
  body: JSON.stringify(body),
137
137
  });
138
138
  }
139
+ async getKpiExtractions(input) {
140
+ const body = {
141
+ limit: input.limit || 50,
142
+ };
143
+ if (input.ticker)
144
+ body.ticker = input.ticker;
145
+ if (input.sector)
146
+ body.sector = input.sector;
147
+ if (input.sic_code)
148
+ body.sic_code = input.sic_code;
149
+ if (input.fiscal_year !== undefined)
150
+ body.fiscal_year = input.fiscal_year;
151
+ return this.request('/api/v1/kpis', {
152
+ method: 'POST',
153
+ headers: this.headers,
154
+ body: JSON.stringify(body),
155
+ });
156
+ }
139
157
  async discoverMetrics(input) {
140
158
  const body = {
141
159
  question: input.question,
package/dist/index.js CHANGED
@@ -152,19 +152,25 @@ function humanizeColumnName(col) {
152
152
  // ─── Structured Response Builders ───────────────────────────────────────────
153
153
  function buildSearchContext(apiResponse, originalQuery) {
154
154
  const results = apiResponse.narrative_results || [];
155
+ const metricRows = Array.isArray(apiResponse.xbrl_metrics) ? apiResponse.xbrl_metrics : [];
155
156
  const ec = apiResponse.execution_contract || {};
157
+ const narrativeTickers = [...new Set(results.map((r) => r.ticker).filter(Boolean))];
158
+ const metricTickers = [...new Set(metricRows.map((r) => r.ticker).filter(Boolean))];
159
+ const resolvedTickers = [...new Set([...narrativeTickers, ...metricTickers])];
156
160
  return {
157
161
  query_received: originalQuery,
158
162
  query_type: apiResponse.query_type || null,
159
163
  scope_confidence: apiResponse.scope_confidence || null,
160
- tickers_resolved: [...new Set(results.map((r) => r.ticker).filter(Boolean))],
164
+ tickers_resolved: resolvedTickers,
165
+ tickers_with_narrative: narrativeTickers,
166
+ tickers_with_metrics: metricTickers,
161
167
  scope_effective: ec.scope_effective || null,
162
168
  mode_effective: ec.mode_effective || null,
163
169
  mode_source: ec.mode_source || null,
164
170
  scope_source: ec.scope_source || null,
165
171
  intent_matched: apiResponse.intent_matched || null,
166
172
  results_returned: results.length,
167
- unique_tickers: new Set(results.map((r) => r.ticker)).size,
173
+ unique_tickers: resolvedTickers.length,
168
174
  unique_forms: [...new Set(results.map((r) => r.form_type).filter(Boolean))],
169
175
  template_match: ec.template_match || null,
170
176
  relaxations_applied: ec.relaxations_applied || [],
@@ -172,6 +178,38 @@ function buildSearchContext(apiResponse, originalQuery) {
172
178
  processing_time_ms: apiResponse.processing_time_ms || null,
173
179
  };
174
180
  }
181
+ function backfillMissingMetricTickerRows(rows, requestedTickers) {
182
+ if (!Array.isArray(rows) || rows.length === 0)
183
+ return rows;
184
+ const requested = requestedTickers.filter(Boolean);
185
+ if (requested.length < 2)
186
+ return rows;
187
+ const covered = new Set(rows
188
+ .map((row) => String(row?.ticker || '').trim().toUpperCase())
189
+ .filter(Boolean));
190
+ const missing = requested.filter((ticker) => !covered.has(String(ticker).trim().toUpperCase()));
191
+ if (missing.length === 0)
192
+ return rows;
193
+ const rowKeys = [];
194
+ const seenKeys = new Set();
195
+ for (const row of rows) {
196
+ if (!row || typeof row !== 'object')
197
+ continue;
198
+ for (const key of Object.keys(row)) {
199
+ if (key === 'ticker' || seenKeys.has(key))
200
+ continue;
201
+ seenKeys.add(key);
202
+ rowKeys.push(key);
203
+ }
204
+ }
205
+ const placeholders = missing.map((ticker) => {
206
+ const placeholder = { ticker };
207
+ for (const key of rowKeys)
208
+ placeholder[key] = null;
209
+ return placeholder;
210
+ });
211
+ return [...rows, ...placeholders];
212
+ }
175
213
  function deriveLayoutHint(apiResponse) {
176
214
  const results = apiResponse.narrative_results || [];
177
215
  const tickers = new Set(results.map((r) => r.ticker));
@@ -920,6 +958,120 @@ server.registerTool('etsquare_earnings_actuals', {
920
958
  };
921
959
  }
922
960
  });
961
+ server.registerTool('etsquare_kpi_extractions', {
962
+ title: 'Get Sector KPI Extractions',
963
+ description: 'Query stored KPI extractions from SEC filings for supported sectors such as restaurants, airlines, banks, REITs, pharma, and SaaS.\n\n' +
964
+ 'This returns previously extracted KPI rows with provenance like filing date, fiscal period, and source chunk IDs when available.\n\n' +
965
+ 'Use this for stored non-XBRL operating metrics such as guest traffic, same-store sales, load factor, NIM, deposit trends, or sector-specific KPIs.\n\n' +
966
+ 'Coverage is sector-dependent and may be partial. If coverage is sparse or you need fresh narrative detail, use etsquare_search in NARRATIVE mode.',
967
+ inputSchema: {
968
+ ticker: z.string().min(1).max(10).optional()
969
+ .describe('Single ticker filter (e.g., "TXRH")'),
970
+ sector: z.string().min(2).max(50).optional()
971
+ .describe('Sector key such as "restaurants", "airlines", "banks", "reits", "pharma", or "saas"'),
972
+ sic_code: z.string().length(4).optional()
973
+ .describe('Optional SIC code filter'),
974
+ fiscal_year: z.number().min(1990).max(2100).optional()
975
+ .describe('Optional fiscal year filter'),
976
+ limit: z.number().min(1).max(500).default(50)
977
+ .describe('Maximum rows to return (default: 50)'),
978
+ response_format: z.enum(['text', 'structured']).default('text')
979
+ .describe('"text" returns a readable summary (default). "structured" returns the raw extraction payload as JSON.'),
980
+ },
981
+ }, async (input) => {
982
+ if (containsGuardrailBypass(input))
983
+ return guardrailViolationResponse();
984
+ try {
985
+ const result = await client.getKpiExtractions(input);
986
+ const extractions = Array.isArray(result.extractions)
987
+ ? result.extractions
988
+ : [];
989
+ const filters = result.filters && typeof result.filters === 'object'
990
+ ? result.filters
991
+ : {};
992
+ const count = typeof result.count === 'number'
993
+ ? result.count
994
+ : extractions.length;
995
+ if (input.response_format === 'structured') {
996
+ return {
997
+ content: [{
998
+ type: 'text',
999
+ text: JSON.stringify({
1000
+ count,
1001
+ filters,
1002
+ extractions,
1003
+ }, null, 2),
1004
+ }],
1005
+ };
1006
+ }
1007
+ if (extractions.length === 0) {
1008
+ const filterBits = [
1009
+ input.ticker ? `ticker=${input.ticker.toUpperCase()}` : null,
1010
+ input.sector ? `sector=${input.sector}` : null,
1011
+ input.sic_code ? `sic_code=${input.sic_code}` : null,
1012
+ input.fiscal_year ? `fiscal_year=${input.fiscal_year}` : null,
1013
+ ].filter(Boolean);
1014
+ const suffix = filterBits.length > 0 ? ` for ${filterBits.join(', ')}` : '';
1015
+ return {
1016
+ content: [{
1017
+ type: 'text',
1018
+ text: `No stored KPI extractions found${suffix}. Coverage is sector-dependent. For fresh filing detail, use etsquare_search in NARRATIVE mode.`,
1019
+ }],
1020
+ };
1021
+ }
1022
+ let text = `Found ${count} KPI extraction`;
1023
+ text += count === 1 ? '' : 's';
1024
+ if (Object.keys(filters).length > 0) {
1025
+ const filterText = Object.entries(filters)
1026
+ .map(([key, value]) => `${key}=${value}`)
1027
+ .join(', ');
1028
+ text += ` for ${filterText}`;
1029
+ }
1030
+ text += '.\n\n';
1031
+ for (const extraction of extractions.slice(0, 20)) {
1032
+ const ticker = extraction.ticker || '—';
1033
+ const periodBits = [
1034
+ extraction.form_type || null,
1035
+ extraction.filing_date || null,
1036
+ extraction.fiscal_year != null ? `FY${extraction.fiscal_year}` : null,
1037
+ extraction.fiscal_period || null,
1038
+ ].filter(Boolean);
1039
+ text += `### ${ticker}${extraction.sector ? ` (${extraction.sector})` : ''}\n`;
1040
+ if (periodBits.length > 0) {
1041
+ text += `${periodBits.join(' | ')}\n`;
1042
+ }
1043
+ const kpiJson = extraction.kpi_json && typeof extraction.kpi_json === 'object'
1044
+ ? extraction.kpi_json
1045
+ : null;
1046
+ if (kpiJson) {
1047
+ const entries = Object.entries(kpiJson)
1048
+ .slice(0, 12)
1049
+ .map(([key, value]) => `- ${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`);
1050
+ if (entries.length > 0) {
1051
+ text += `${entries.join('\n')}\n`;
1052
+ }
1053
+ }
1054
+ if (extraction.notes) {
1055
+ text += `Notes: ${extraction.notes}\n`;
1056
+ }
1057
+ text += '\n';
1058
+ }
1059
+ if (extractions.length > 20) {
1060
+ text += `Showing 20 of ${count} stored KPI extractions.\n\n`;
1061
+ }
1062
+ text += 'Coverage is sector-dependent and derived from stored extractions, not live on-demand parsing.';
1063
+ return {
1064
+ content: [{ type: 'text', text }],
1065
+ };
1066
+ }
1067
+ catch (error) {
1068
+ const message = error instanceof Error ? error.message : 'Unknown error';
1069
+ return {
1070
+ content: [{ type: 'text', text: `KPI extraction request failed: ${message}` }],
1071
+ isError: true,
1072
+ };
1073
+ }
1074
+ });
923
1075
  server.registerTool('etsquare_execute_metrics', {
924
1076
  title: 'Execute Financial Metrics Query',
925
1077
  description: 'Execute an XBRL metrics template by template_id to get structured financial data ' +
@@ -1307,9 +1459,10 @@ server.registerTool('etsquare_compare', {
1307
1459
  const allResults = Array.isArray(searchResult.narrative_results)
1308
1460
  ? searchResult.narrative_results
1309
1461
  : [];
1310
- const xbrlMetrics = Array.isArray(searchResult.xbrl_metrics)
1462
+ const rawXbrlMetrics = Array.isArray(searchResult.xbrl_metrics)
1311
1463
  ? searchResult.xbrl_metrics
1312
1464
  : [];
1465
+ const xbrlMetrics = backfillMissingMetricTickerRows(rawXbrlMetrics, input.tickers);
1313
1466
  // Group results by ticker
1314
1467
  const perTicker = {};
1315
1468
  for (const ticker of input.tickers) {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@etsquare/mcp-server-sec",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
- "description": "MCP server for Claude Desktop: search 3.6M+ SEC filing sections, financial statements, insider trades, institutional ownership, earnings actuals, XBRL metrics, and company lookup.",
5
+ "description": "MCP server for Claude Desktop: search SEC filing sections, financial statements, insider trades, institutional ownership, earnings actuals, XBRL metrics, and company lookup.",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "bin": {