@etsquare/mcp-server-sec 0.2.0 → 0.4.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/dist/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @etsquare/mcp-server-sec
3
+ * @etsquare/mcp-server-sec v0.3.0
4
4
  * MCP server for SEC Intelligence: search SEC filings,
5
- * resolve company tickers, and execute financial metrics templates.
5
+ * resolve company tickers, execute financial metrics templates,
6
+ * compare companies, and access weekly briefs.
6
7
  */
7
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
10
  import { z } from 'zod';
10
11
  import { ETSquareClient } from './etsquare-client.js';
11
- // Guardrail keywords to detect prompt injection attempts
12
+ // ─── Guardrails ─────────────────────────────────────────────────────────────
12
13
  const GUARDRAIL_KEYWORDS = [
13
14
  'reset guardrails',
14
15
  'disable guardrails',
@@ -40,8 +41,8 @@ function guardrailViolationResponse() {
40
41
  isError: true,
41
42
  };
42
43
  }
43
- // Environment configuration
44
- const baseUrl = process.env.ETSQUARE_BASE_URL || 'https://www.etsquare.ai';
44
+ // ─── Environment ────────────────────────────────────────────────────────────
45
+ const baseUrl = process.env.ETSQUARE_BASE_URL || 'https://sec-intelligence-api-4mk2on5fga-uc.a.run.app';
45
46
  const apiKey = process.env.ETSQUARE_API_KEY;
46
47
  const DEBUG = process.env.DEBUG === 'true';
47
48
  function log(level, message, data) {
@@ -51,20 +52,277 @@ function log(level, message, data) {
51
52
  const logData = data ? ` ${JSON.stringify(data)}` : '';
52
53
  console.error(`[${timestamp}] [${level.toUpperCase()}] ${message}${logData}`);
53
54
  }
54
- // Validate API key
55
55
  if (!apiKey) {
56
56
  console.error('ERROR: ETSQUARE_API_KEY environment variable is required');
57
57
  process.exit(1);
58
58
  }
59
- // Initialize client
60
59
  const client = new ETSquareClient({ baseUrl, apiKey });
61
- log('info', `ETSquare MCP Server starting with backend: ${baseUrl}`);
62
- // Initialize MCP Server
60
+ log('info', `ETSquare MCP Server v0.3.0 starting with backend: ${baseUrl}`);
61
+ // ─── Item Code Labels ───────────────────────────────────────────────────────
62
+ const ITEM_CODE_LABELS = {
63
+ // 10-K
64
+ '10k_item_1': 'Business Description',
65
+ '10k_item_1a': 'Risk Factors',
66
+ '10k_item_1_a': 'Risk Factors',
67
+ '10k_item_2': 'Properties',
68
+ '10k_item_3': 'Legal Proceedings',
69
+ '10k_item_5': 'Market for Equity',
70
+ '10k_item_6': 'Selected Financial Data',
71
+ '10k_item_7': 'MD&A',
72
+ '10k_item_7a': 'Market Risk Disclosures',
73
+ '10k_item_7_a': 'Market Risk Disclosures',
74
+ '10k_item_8': 'Financial Statements',
75
+ '10k_item_9a': 'Controls & Procedures',
76
+ // 10-Q
77
+ '10q_item_1': 'Financial Statements (Quarterly)',
78
+ '10q_item_1a': 'Risk Factors (Quarterly)',
79
+ '10q_item_1_a': 'Risk Factors (Quarterly)',
80
+ '10q_item_2': 'MD&A (Quarterly)',
81
+ '10q_item_3': 'Market Risk (Quarterly)',
82
+ // 8-K
83
+ '8k_item_1_01': 'Entry into Material Agreement',
84
+ '8k_item_1_02': 'Termination of Material Agreement',
85
+ '8k_item_1_05': 'Material Cybersecurity Incident',
86
+ '8k_item_2_01': 'Completion of Acquisition/Disposition',
87
+ '8k_item_2_02': 'Results of Operations (Earnings)',
88
+ '8k_item_2_03': 'Creation of Direct Financial Obligation',
89
+ '8k_item_2_04': 'Triggering Events (Acceleration/Default)',
90
+ '8k_item_2_05': 'Costs Associated with Exit/Disposal',
91
+ '8k_item_2_06': 'Material Impairments',
92
+ '8k_item_4_01': 'Change in Accountant',
93
+ '8k_item_4_02': 'Non-Reliance on Financial Statements',
94
+ '8k_item_5_01': 'Change in Control',
95
+ '8k_item_5_02': 'Director/Officer Changes',
96
+ '8k_item_5_03': 'Amendments to Articles/Bylaws',
97
+ '8k_item_7_01': 'Regulation FD Disclosure',
98
+ '8k_item_8_01': 'Other Events',
99
+ '8k_item_9_01': 'Financial Statements and Exhibits',
100
+ };
101
+ function mapItemCodeToLabel(itemCode) {
102
+ if (!itemCode)
103
+ return 'Unknown';
104
+ return ITEM_CODE_LABELS[itemCode] || itemCode;
105
+ }
106
+ // Visualization hints are stored in each template's tags_json.visualization_hint
107
+ // in the database and flow through the API. No static registry needed.
108
+ // ─── Column Type / Role Inference Helpers ───────────────────────────────────
109
+ const TIME_FIELDS = new Set([
110
+ 'fiscal_year', 'fiscal_period', 'period', 'period_end',
111
+ 'filing_date', 'quarter', 'year', 'data_as_of',
112
+ ]);
113
+ const GROUP_FIELDS = new Set(['ticker', 'company_name', 'sic_code', 'sic', 'sector', 'industry', 'name']);
114
+ const SIGNAL_FIELDS = new Set(['trend_direction', 'signal', 'flag']);
115
+ function inferColumnType(col, rows) {
116
+ if (rows.length === 0)
117
+ return 'string';
118
+ const sample = rows[0][col];
119
+ if (typeof sample === 'number')
120
+ return Number.isInteger(sample) ? 'int' : 'float';
121
+ return 'string';
122
+ }
123
+ function inferColumnRole(col) {
124
+ if (TIME_FIELDS.has(col))
125
+ return 'time';
126
+ if (GROUP_FIELDS.has(col))
127
+ return 'group';
128
+ if (SIGNAL_FIELDS.has(col))
129
+ return 'signal';
130
+ return 'measure';
131
+ }
132
+ function inferColumnUnit(col) {
133
+ if (col.endsWith('_pct') || col.includes('margin') || col.includes('growth'))
134
+ return 'pct';
135
+ if (col.endsWith('_b') || col.includes('revenue') || col.includes('income')
136
+ || col.includes('profit') || col.includes('expense'))
137
+ return 'USD_B';
138
+ if (col.includes('ratio') || col.includes('multiple'))
139
+ return 'ratio';
140
+ if (col.includes('eps') || col.includes('per_share'))
141
+ return 'USD';
142
+ return null;
143
+ }
144
+ function humanizeColumnName(col) {
145
+ return col
146
+ .replace(/_/g, ' ')
147
+ .replace(/\b\w/g, c => c.toUpperCase())
148
+ .replace('Pct', '%')
149
+ .replace('Yoy', 'YoY')
150
+ .replace('Qoq', 'QoQ');
151
+ }
152
+ // ─── Structured Response Builders ───────────────────────────────────────────
153
+ function buildSearchContext(apiResponse, originalQuery) {
154
+ const results = apiResponse.narrative_results || [];
155
+ const ec = apiResponse.execution_contract || {};
156
+ return {
157
+ query_received: originalQuery,
158
+ query_type: apiResponse.query_type || null,
159
+ scope_confidence: apiResponse.scope_confidence || null,
160
+ tickers_resolved: [...new Set(results.map((r) => r.ticker).filter(Boolean))],
161
+ scope_effective: ec.scope_effective || null,
162
+ mode_effective: ec.mode_effective || null,
163
+ mode_source: ec.mode_source || null,
164
+ scope_source: ec.scope_source || null,
165
+ intent_matched: apiResponse.intent_matched || null,
166
+ results_returned: results.length,
167
+ unique_tickers: new Set(results.map((r) => r.ticker)).size,
168
+ unique_forms: [...new Set(results.map((r) => r.form_type).filter(Boolean))],
169
+ template_match: ec.template_match || null,
170
+ relaxations_applied: ec.relaxations_applied || [],
171
+ execution_id: apiResponse.execution_id || null,
172
+ processing_time_ms: apiResponse.processing_time_ms || null,
173
+ };
174
+ }
175
+ function deriveLayoutHint(apiResponse) {
176
+ const results = apiResponse.narrative_results || [];
177
+ const tickers = new Set(results.map((r) => r.ticker));
178
+ const ec = apiResponse.execution_contract || {};
179
+ const scopeEffective = ec.scope_effective || '';
180
+ const hasMetrics = Array.isArray(apiResponse.xbrl_metrics) && apiResponse.xbrl_metrics.length > 0;
181
+ if (tickers.size >= 2 && hasMetrics) {
182
+ return {
183
+ suggested: 'comparison_dashboard',
184
+ reason: 'multi_ticker_with_metrics',
185
+ panels: ['metrics_chart', 'narrative_side_by_side'],
186
+ tone: 'comparative',
187
+ };
188
+ }
189
+ if (tickers.size >= 2) {
190
+ return {
191
+ suggested: 'comparison_grid',
192
+ reason: 'multi_ticker_narrative',
193
+ panels: ['narrative_by_ticker'],
194
+ tone: 'comparative',
195
+ };
196
+ }
197
+ if (tickers.size === 1 && hasMetrics) {
198
+ return {
199
+ suggested: 'company_profile',
200
+ reason: 'single_ticker_hybrid',
201
+ panels: ['metrics_chart', 'evidence_cards'],
202
+ tone: 'analytical',
203
+ };
204
+ }
205
+ if (scopeEffective === 'MACRO') {
206
+ return {
207
+ suggested: 'cross_company_theme',
208
+ reason: 'macro_scope',
209
+ panels: ['theme_summary', 'evidence_by_ticker'],
210
+ tone: 'analytical',
211
+ };
212
+ }
213
+ if (scopeEffective === 'INDUSTRY') {
214
+ return {
215
+ suggested: 'industry_overview',
216
+ reason: 'industry_scope',
217
+ panels: ['sector_summary', 'evidence_by_ticker'],
218
+ tone: 'analytical',
219
+ };
220
+ }
221
+ return {
222
+ suggested: 'evidence_cards',
223
+ reason: 'single_company_narrative',
224
+ alternatives: ['evidence_timeline', 'risk_factor_summary'],
225
+ tone: 'analytical',
226
+ };
227
+ }
228
+ function deriveMetricsVizHint(rows, schema) {
229
+ const columns = schema?.columns || [];
230
+ if (columns.length === 0)
231
+ return { chart_type: 'table' };
232
+ const timeCol = columns.find((c) => c.role === 'time');
233
+ const measureCols = columns.filter((c) => c.role === 'measure');
234
+ const groupCol = columns.find((c) => c.role === 'group');
235
+ if (!timeCol || measureCols.length === 0)
236
+ return { chart_type: 'table' };
237
+ const tickerCol = groupCol?.name || 'ticker';
238
+ const tickers = new Set(rows.map((r) => r[tickerCol]).filter(Boolean));
239
+ const primaryMeasure = measureCols[0];
240
+ const secondaryMeasure = measureCols.find((c) => c.unit === 'pct' && c.name !== primaryMeasure.name);
241
+ return {
242
+ chart_type: tickers.size > 1 ? 'grouped_bar' : 'line',
243
+ x: { field: timeCol.name, order: 'chronological', label: timeCol.label },
244
+ y_primary: {
245
+ field: primaryMeasure.name,
246
+ label: primaryMeasure.label,
247
+ format: primaryMeasure.unit === 'USD_B' ? 'currency_b'
248
+ : primaryMeasure.unit === 'pct' ? 'pct' : 'number',
249
+ },
250
+ y_secondary: secondaryMeasure ? {
251
+ field: secondaryMeasure.name,
252
+ label: secondaryMeasure.label,
253
+ chart_type: 'bar',
254
+ opacity: 0.3,
255
+ format: 'pct',
256
+ } : undefined,
257
+ group_by: tickers.size > 1 ? tickerCol : undefined,
258
+ color_palette: tickers.size > 1 ? 'comparison_diverging' : 'single_accent',
259
+ formats: {
260
+ currency_b: { prefix: '$', suffix: 'B', decimals: 1 },
261
+ pct: { suffix: '%', decimals: 1, sign: true },
262
+ number: { decimals: 0, comma_separated: true },
263
+ },
264
+ };
265
+ }
266
+ function computeSummaryStats(columnNames, rows) {
267
+ if (rows.length === 0)
268
+ return null;
269
+ const numericCols = columnNames.filter(c => typeof rows[0]?.[c] === 'number'
270
+ && !['fiscal_year', 'year'].includes(c));
271
+ if (numericCols.length === 0)
272
+ return null;
273
+ const primary = numericCols[0];
274
+ const values = rows.map(r => r[primary]).filter((v) => v != null);
275
+ if (values.length === 0)
276
+ return null;
277
+ const min = Math.min(...values);
278
+ const max = Math.max(...values);
279
+ return {
280
+ row_count: rows.length,
281
+ primary_measure: primary,
282
+ min,
283
+ max,
284
+ range_pct: min !== 0
285
+ ? Math.round(((max - min) / Math.abs(min)) * 1000) / 10
286
+ : null,
287
+ };
288
+ }
289
+ function buildColumnMeta(apiColumns, rows) {
290
+ const columnNames = apiColumns.length > 0
291
+ ? apiColumns.map((c) => typeof c === 'string' ? c : c.name)
292
+ : (rows.length > 0 ? Object.keys(rows[0]) : []);
293
+ return columnNames.map((col) => ({
294
+ name: col,
295
+ type: inferColumnType(col, rows),
296
+ role: inferColumnRole(col),
297
+ unit: inferColumnUnit(col),
298
+ label: humanizeColumnName(col),
299
+ }));
300
+ }
301
+ // ─── Text Formatting Helpers (existing behavior) ────────────────────────────
302
+ function formatNumberForTable(val) {
303
+ if (val === null || val === undefined)
304
+ return 'N/A';
305
+ if (typeof val === 'number')
306
+ return Number.isInteger(val) ? String(val) : val.toFixed(2);
307
+ return String(val);
308
+ }
309
+ function buildMarkdownTable(colNames, rows, maxRows = 20) {
310
+ const header = colNames.join(' | ');
311
+ const separator = colNames.map(() => '---').join(' | ');
312
+ const displayRows = rows.slice(0, maxRows);
313
+ const tableRows = displayRows.map((row) => colNames.map((col) => formatNumberForTable(row[col])).join(' | '));
314
+ const table = [header, separator, ...tableRows].join('\n');
315
+ const suffix = rows.length > maxRows
316
+ ? `\n(Showing ${maxRows} of ${rows.length} rows)`
317
+ : '';
318
+ return `${table}${suffix}`;
319
+ }
320
+ // ─── MCP Server ─────────────────────────────────────────────────────────────
63
321
  const server = new McpServer({
64
322
  name: 'etsquare-mcp-sec',
65
- version: '0.2.0',
323
+ version: '0.3.0',
66
324
  });
67
- // Tool 1: Company Lookup
325
+ // ─── Tool 1: Company Lookup ─────────────────────────────────────────────────
68
326
  server.registerTool('etsquare_lookup_company', {
69
327
  title: 'Look Up Company Ticker',
70
328
  description: 'Resolve a company name or partial ticker to its official SEC ticker symbol and CIK number. ' +
@@ -107,17 +365,20 @@ server.registerTool('etsquare_lookup_company', {
107
365
  };
108
366
  }
109
367
  });
110
- // Tool 2: SEC Filing Search
368
+ // ─── Tool 2: SEC Filing Search ──────────────────────────────────────────────
111
369
  server.registerTool('etsquare_search', {
112
370
  title: 'Search SEC Filings',
113
- description: 'Search 1.2M+ SEC filing sections (10-K, 10-Q, 8-K) with hybrid retrieval. ' +
371
+ description: 'Search 3.4M+ SEC filing sections (10-K, 10-Q, 8-K) with hybrid retrieval. ' +
114
372
  'Returns verbatim filing text with citations — best for qualitative research.\n\n' +
115
373
  'Execution contract (required):\n' +
116
374
  '- mode_lock: NARRATIVE (recommended) | HYBRID\n' +
117
375
  '- scope_lock: COMPANY (specific tickers) | INDUSTRY (SIC sector) | MACRO (cross-market)\n\n' +
118
- 'For structured financial data (revenue, margins, ratios), use etsquare_discover_metrics + etsquare_execute_metrics instead.\n\n' +
376
+ 'For canonical financial statements (income statement, balance sheet, cash flow), use etsquare_financial_statements.\n' +
377
+ 'For custom financial queries or peer comparisons, use etsquare_discover_metrics + etsquare_execute_metrics.\n\n' +
378
+ 'For INDUSTRY scope, use sector to target a specific industry (e.g., sector="semiconductors"). ' +
119
379
  'Use tickers to scope to specific companies (resolve names first with etsquare_lookup_company). ' +
120
- 'Results include verbatim filing text, similarity scores, and SEC source URLs.',
380
+ 'Results include filing text plus safe citation metadata such as chunk_id, accession number, and SEC URL for follow-up retrieval.\n\n' +
381
+ 'Set response_format="structured" for typed JSON with search_context, layout hints, and chart-ready data.',
121
382
  inputSchema: {
122
383
  query: z.string().min(3).describe('What to search for in SEC filings (e.g., "customer concentration risk", "Show NVDA revenue trend")'),
123
384
  mode_lock: z.enum(['NARRATIVE', 'HYBRID']).describe('NARRATIVE for text search, HYBRID for text + any available metrics'),
@@ -125,7 +386,15 @@ server.registerTool('etsquare_search', {
125
386
  top_k: z.number().min(1).max(50).default(5).describe('Number of results to return (default: 5)'),
126
387
  tickers: z.array(z.string()).optional().describe('Limit to specific companies by ticker (e.g., ["AAPL", "MSFT"])'),
127
388
  doc_types: z.array(z.string()).optional().describe('Limit by SEC form type: "10-k", "10-q", "8-k"'),
128
- sic_codes: z.array(z.string()).optional().describe('Limit by SIC industry code (e.g., "7372", "3674")'),
389
+ sector: z.string().optional().describe('Industry/sector hint for INDUSTRY-scope queries. Resolved internally to SIC codes. ' +
390
+ 'Examples: "software", "fintech", "energy", "semiconductors", "biotech", "REITs", "banks", ' +
391
+ '"defense", "utilities", "oil and gas", "healthcare", "cloud", "cybersecurity". ' +
392
+ 'If sic_codes is also provided, sic_codes takes precedence.'),
393
+ sic_codes: z.array(z.string()).optional().describe('Expert override: limit by SIC industry code (e.g., "7372", "3674"). Takes precedence over sector.'),
394
+ response_format: z.enum(['text', 'structured']).default('text')
395
+ .describe('"text" returns numbered citations (default). "structured" returns JSON with search_context, typed results, and layout hints.'),
396
+ include_context: z.boolean().default(false)
397
+ .describe('Auto-expand top 2 truncated results with surrounding context'),
129
398
  },
130
399
  }, async (input) => {
131
400
  if (containsGuardrailBypass(input))
@@ -136,6 +405,7 @@ server.registerTool('etsquare_search', {
136
405
  mode_lock: input.mode_lock,
137
406
  scope_lock: input.scope_lock,
138
407
  top_k: input.top_k,
408
+ response_format: input.response_format,
139
409
  });
140
410
  const result = await client.search(input);
141
411
  const searchResults = Array.isArray(result.narrative_results)
@@ -147,12 +417,78 @@ server.registerTool('etsquare_search', {
147
417
  ? result.xbrl_metrics
148
418
  : [];
149
419
  const resultCount = searchResults.length;
420
+ const executionId = typeof result.execution_id === 'string'
421
+ ? result.execution_id
422
+ : null;
423
+ const researchRunId = typeof result.research_run_id === 'string'
424
+ ? result.research_run_id
425
+ : null;
150
426
  if (resultCount === 0 && xbrlMetrics.length === 0) {
151
427
  let text = `No results found for: "${input.query}"`;
152
428
  text += '\nTry broadening your query or removing filters.';
153
429
  return { content: [{ type: 'text', text }] };
154
430
  }
155
- // Format narrative citations
431
+ // Auto-expand truncated chunks with surrounding context
432
+ if (input.include_context) {
433
+ const truncatedChunks = searchResults
434
+ .filter((r) => r.chunk_text_truncated)
435
+ .slice(0, 2);
436
+ for (const chunk of truncatedChunks) {
437
+ try {
438
+ const ctx = await client.getChunkContext({
439
+ chunk_id: chunk.chunk_id,
440
+ neighbor_span: 1,
441
+ window: 1200,
442
+ });
443
+ const ctxData = ctx.context || {};
444
+ chunk._expanded_context = {
445
+ before: ctxData.before || null,
446
+ highlight: ctxData.highlight || null,
447
+ after: ctxData.after || null,
448
+ };
449
+ }
450
+ catch {
451
+ // Non-fatal — skip context expansion on failure
452
+ }
453
+ }
454
+ }
455
+ // ── Structured response path ──
456
+ if (input.response_format === 'structured') {
457
+ const metricsColumnMeta = xbrlMetrics.length > 0
458
+ ? buildColumnMeta(result.xbrl_output_schema?.columns || [], xbrlMetrics)
459
+ : null;
460
+ const structured = {
461
+ search_context: buildSearchContext(result, input.query),
462
+ results: searchResults
463
+ .slice(0, input.top_k || 5)
464
+ .map((r, i) => ({
465
+ index: i + 1,
466
+ chunk_id: r.chunk_id,
467
+ ticker: r.ticker,
468
+ company_name: r.company_name,
469
+ form_type: r.form_type,
470
+ filing_date: r.filing_date,
471
+ item_code: r.item_code,
472
+ section_label: mapItemCodeToLabel(r.item_code),
473
+ score: r.similarity_score,
474
+ chunk_text: r.chunk_text,
475
+ truncated: r.chunk_text_truncated || false,
476
+ chunk_text_length: r.chunk_text_length || null,
477
+ accession_number: r.accession_number,
478
+ sec_url: r.sec_url,
479
+ expanded_context: r._expanded_context || undefined,
480
+ })),
481
+ metrics: xbrlMetrics.length > 0 ? {
482
+ rows: xbrlMetrics,
483
+ columns: metricsColumnMeta,
484
+ visualization_hint: deriveMetricsVizHint(xbrlMetrics, { columns: metricsColumnMeta }),
485
+ } : null,
486
+ layout_hint: deriveLayoutHint(result),
487
+ };
488
+ log('info', `Search returned ${resultCount} citations, ${xbrlMetrics.length} metrics [structured]`);
489
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
490
+ }
491
+ // ── Text response path (existing behavior) ──
156
492
  const sections = [];
157
493
  if (resultCount > 0) {
158
494
  const formatted = searchResults
@@ -167,10 +503,29 @@ server.registerTool('etsquare_search', {
167
503
  if (r.fiscal_year && r.fiscal_period) {
168
504
  lines[1] += ` | ${r.fiscal_period} FY${r.fiscal_year}`;
169
505
  }
506
+ const metaParts = [];
507
+ if (r.chunk_id)
508
+ metaParts.push(`Chunk ID: ${r.chunk_id}`);
509
+ if (r.accession_number)
510
+ metaParts.push(`Accession: ${r.accession_number}`);
511
+ if (r.chunk_text_truncated)
512
+ metaParts.push('Truncated: yes');
513
+ if (metaParts.length > 0) {
514
+ lines.push(` ${metaParts.join(' | ')}`);
515
+ }
170
516
  if (r.chunk_text) {
171
517
  const snippet = r.chunk_text.substring(0, 600);
172
518
  lines.push(` ${snippet}${r.chunk_text.length > 600 ? '...' : ''}`);
173
519
  }
520
+ // Include expanded context if available
521
+ if (r._expanded_context) {
522
+ if (r._expanded_context.before) {
523
+ lines.push(` [Context before]: ${r._expanded_context.before.substring(0, 300)}...`);
524
+ }
525
+ if (r._expanded_context.after) {
526
+ lines.push(` [Context after]: ${r._expanded_context.after.substring(0, 300)}...`);
527
+ }
528
+ }
174
529
  const url = r.sec_url || r.source_url;
175
530
  if (url) {
176
531
  lines.push(` SEC URL: ${url}`);
@@ -178,27 +533,18 @@ server.registerTool('etsquare_search', {
178
533
  return lines.join('\n');
179
534
  })
180
535
  .join('\n\n');
181
- sections.push(`SEC Filing Citations (${resultCount}):\n\n${formatted}`);
536
+ const headerParts = [`SEC Filing Citations (${resultCount}):`];
537
+ if (executionId)
538
+ headerParts.push(`Execution ID: ${executionId}`);
539
+ if (researchRunId)
540
+ headerParts.push(`Research Run ID: ${researchRunId}`);
541
+ const header = headerParts.join('\n');
542
+ sections.push(`${header}\n\n${formatted}`);
182
543
  }
183
- // Format XBRL metrics if present (handles both wide-format and long-format rows)
184
544
  if (xbrlMetrics.length > 0) {
185
545
  const colNames = Object.keys(xbrlMetrics[0] || {}).filter((k) => !k.startsWith('_'));
186
- const header = colNames.join(' | ');
187
- const separator = colNames.map(() => '---').join(' | ');
188
- const displayRows = xbrlMetrics.slice(0, 20);
189
- const tableRows = displayRows.map((row) => colNames.map((col) => {
190
- const val = row[col];
191
- if (val === null || val === undefined)
192
- return 'N/A';
193
- if (typeof val === 'number')
194
- return Number.isInteger(val) ? String(val) : val.toFixed(2);
195
- return String(val);
196
- }).join(' | '));
197
- const table = [header, separator, ...tableRows].join('\n');
198
- const suffix = xbrlMetrics.length > 20
199
- ? `\n(Showing 20 of ${xbrlMetrics.length} rows)`
200
- : '';
201
- sections.push(`XBRL Metrics (${xbrlMetrics.length}):\n\n${table}${suffix}`);
546
+ const table = buildMarkdownTable(colNames, xbrlMetrics);
547
+ sections.push(`XBRL Metrics (${xbrlMetrics.length}):\n\n${table}`);
202
548
  }
203
549
  log('info', `Search returned ${resultCount} citations, ${xbrlMetrics.length} metrics`);
204
550
  return {
@@ -216,28 +562,448 @@ server.registerTool('etsquare_search', {
216
562
  };
217
563
  }
218
564
  });
219
- // Tool 3: Execute Metrics (XBRL structured data)
565
+ // ─── Tool 3: Execute Metrics ────────────────────────────────────────────────
566
+ server.registerTool('etsquare_financial_statements', {
567
+ title: 'Get Financial Statements',
568
+ description: 'Get canonical income statement, balance sheet, or cash flow statement for a company. ' +
569
+ 'Assembles structured line items from XBRL facts with concept precedence and provenance.\n\n' +
570
+ 'Each line item includes the selected XBRL concept, priority rank, and unit for auditability.\n\n' +
571
+ 'Coverage: ~5,100 tickers for income statement and cash flow. ' +
572
+ 'Balance sheet coverage is limited for some companies pending XBRL pipeline enhancement.\n\n' +
573
+ 'For custom financial queries or peer comparisons, use etsquare_discover_metrics + etsquare_execute_metrics instead.',
574
+ inputSchema: {
575
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
576
+ statement_type: z.enum(['income_statement', 'balance_sheet', 'cash_flow'])
577
+ .describe('Which financial statement to return'),
578
+ period_mode: z.enum(['latest_annual', 'latest_quarterly', 'last_n_annual', 'last_n_quarterly'])
579
+ .default('last_n_annual')
580
+ .describe('Period selection: latest single period or last N periods'),
581
+ n_periods: z.number().min(1).max(20).default(5)
582
+ .describe('Number of periods for last_n modes (default: 5)'),
583
+ },
584
+ }, async (input) => {
585
+ if (containsGuardrailBypass(input))
586
+ return guardrailViolationResponse();
587
+ try {
588
+ const result = await client.getFinancialStatement(input);
589
+ const periods = Array.isArray(result.periods) ? result.periods : [];
590
+ const lineOrder = Array.isArray(result.line_order) ? result.line_order : [];
591
+ const ticker = result.ticker || input.ticker.toUpperCase();
592
+ if (periods.length === 0) {
593
+ return {
594
+ content: [{
595
+ type: 'text',
596
+ text: `No ${input.statement_type.replace(/_/g, ' ')} data found for ${ticker}. ` +
597
+ `The company may not have XBRL data available for the requested period.`,
598
+ }],
599
+ };
600
+ }
601
+ // Format as markdown table
602
+ const labels = lineOrder.filter(label => {
603
+ return periods.some((p) => p.lines?.[label]?.value != null);
604
+ });
605
+ // Build header row
606
+ const periodHeaders = periods.map((p) => `FY${p.fiscal_year}${p.fiscal_period !== 'FY' ? ' ' + p.fiscal_period : ''}`);
607
+ const formatValue = (val, label) => {
608
+ if (val == null)
609
+ return '—';
610
+ if (label.startsWith('eps_'))
611
+ return val.toFixed(2);
612
+ if (label.startsWith('shares_'))
613
+ return (val / 1e6).toFixed(0) + 'M';
614
+ if (Math.abs(val) >= 1e9)
615
+ return (val / 1e9).toFixed(2) + 'B';
616
+ if (Math.abs(val) >= 1e6)
617
+ return (val / 1e6).toFixed(1) + 'M';
618
+ return val.toLocaleString();
619
+ };
620
+ const labelDisplay = (label) => label.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
621
+ let table = `## ${ticker} — ${input.statement_type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}\n\n`;
622
+ table += `| Line Item | ${periodHeaders.join(' | ')} |\n`;
623
+ table += `|---|${periodHeaders.map(() => '---:').join('|')}|\n`;
624
+ for (const label of labels) {
625
+ const values = periods.map((p) => formatValue(p.lines?.[label]?.value, label));
626
+ table += `| ${labelDisplay(label)} | ${values.join(' | ')} |\n`;
627
+ }
628
+ // Add derived metrics (e.g., FCF)
629
+ const derivedKeys = new Set();
630
+ for (const p of periods) {
631
+ if (p.derived) {
632
+ for (const k of Object.keys(p.derived))
633
+ derivedKeys.add(k);
634
+ }
635
+ }
636
+ if (derivedKeys.size > 0) {
637
+ table += `|---|${periodHeaders.map(() => '---:').join('|')}|\n`;
638
+ for (const dk of derivedKeys) {
639
+ const values = periods.map((p) => formatValue(p.derived?.[dk]?.value, dk));
640
+ table += `| **${labelDisplay(dk)}** | ${values.join(' | ')} |\n`;
641
+ }
642
+ }
643
+ // Provenance note
644
+ const firstPeriod = periods[0];
645
+ const sampleLine = firstPeriod?.lines?.[labels[0]];
646
+ if (sampleLine) {
647
+ table += `\n*Source: SEC XBRL filings. Concept precedence via VT_CF_XBRL_CONCEPT_FAMILIES.*`;
648
+ }
649
+ return {
650
+ content: [{ type: 'text', text: table }],
651
+ };
652
+ }
653
+ catch (error) {
654
+ const message = error instanceof Error ? error.message : 'Unknown error';
655
+ return {
656
+ content: [{ type: 'text', text: `Financial statement request failed: ${message}` }],
657
+ isError: true,
658
+ };
659
+ }
660
+ });
661
+ server.registerTool('etsquare_insider_trades', {
662
+ title: 'Insider Trading Activity',
663
+ description: 'Get insider buying and selling activity for a company from SEC Form 3/4/5 filings. ' +
664
+ 'Shows officer/director stock purchases, sales, option exercises, and tax withholdings.\n\n' +
665
+ 'Data is sourced directly from SEC EDGAR ownership filings, filed within 2 business days of each transaction.\n\n' +
666
+ 'Summary includes open-market buy/sell counts, net shares, and total values. ' +
667
+ 'Derivative transactions (options, RSUs) are separated from open-market trades.',
668
+ inputSchema: {
669
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
670
+ days_back: z.number().min(1).max(730).default(90)
671
+ .describe('Days of history to return (default: 90)'),
672
+ transaction_types: z.array(z.string()).optional()
673
+ .describe('Filter by type: "P" (purchase), "S" (sale), "M" (exercise), "F" (tax withholding), "A" (award)'),
674
+ include_derivatives: z.boolean().default(true)
675
+ .describe('Include derivative transactions like options and RSUs (default: true)'),
676
+ },
677
+ }, async (input) => {
678
+ if (containsGuardrailBypass(input))
679
+ return guardrailViolationResponse();
680
+ try {
681
+ const result = await client.getInsiderTransactions(input);
682
+ const transactions = Array.isArray(result.transactions) ? result.transactions : [];
683
+ const summary = result.summary || {};
684
+ const ticker = result.ticker || input.ticker.toUpperCase();
685
+ if (transactions.length === 0) {
686
+ return {
687
+ content: [{
688
+ type: 'text',
689
+ text: `No insider transactions found for ${ticker} in the last ${input.days_back || 90} days.`,
690
+ }],
691
+ };
692
+ }
693
+ // Format summary
694
+ const fmtVal = (v) => {
695
+ if (v == null)
696
+ return '$0';
697
+ if (Math.abs(v) >= 1e9)
698
+ return `$${(v / 1e9).toFixed(1)}B`;
699
+ if (Math.abs(v) >= 1e6)
700
+ return `$${(v / 1e6).toFixed(1)}M`;
701
+ if (Math.abs(v) >= 1e3)
702
+ return `$${(v / 1e3).toFixed(0)}K`;
703
+ return `$${v.toFixed(0)}`;
704
+ };
705
+ let text = `## ${ticker} — Insider Trading (Last ${input.days_back || 90} Days)\n\n`;
706
+ // Summary line
707
+ const netLabel = (summary.net_shares_open_market ?? 0) >= 0 ? 'net buying' : 'net selling';
708
+ text += `**Open market**: ${summary.open_market_buys || 0} buys (${fmtVal(summary.total_buy_value)}) / `;
709
+ text += `${summary.open_market_sells || 0} sells (${fmtVal(summary.total_sell_value)})`;
710
+ if (summary.exercises)
711
+ text += ` | ${summary.exercises} exercises`;
712
+ if (summary.tax_withholdings)
713
+ text += ` | ${summary.tax_withholdings} tax withholdings`;
714
+ text += `\n\n`;
715
+ // Transaction table — show top 20
716
+ const shown = transactions.slice(0, 20);
717
+ text += `| Date | Insider | Title | Type | Shares | Price | Value |\n`;
718
+ text += `|------|---------|-------|------|-------:|------:|------:|\n`;
719
+ for (const t of shown) {
720
+ const shares = t.shares != null ? t.shares.toLocaleString() : '—';
721
+ const price = t.price_per_share != null ? `$${t.price_per_share.toFixed(2)}` : '—';
722
+ const value = t.total_value != null ? fmtVal(t.total_value) : '—';
723
+ const typeLabel = t.is_derivative ? `${t.transaction_type} *` : t.transaction_type;
724
+ text += `| ${t.transaction_date} | ${t.owner_name} | ${t.officer_title || '—'} | ${typeLabel} | ${shares} | ${price} | ${value} |\n`;
725
+ }
726
+ if (transactions.length > 20) {
727
+ text += `\n*Showing 20 of ${transactions.length} transactions.*\n`;
728
+ }
729
+ text += `\n*\\* = derivative transaction (options/RSUs). Source: SEC Form 4 filings via EDGAR.*`;
730
+ return {
731
+ content: [{ type: 'text', text }],
732
+ };
733
+ }
734
+ catch (error) {
735
+ const message = error instanceof Error ? error.message : 'Unknown error';
736
+ return {
737
+ content: [{ type: 'text', text: `Insider trades request failed: ${message}` }],
738
+ isError: true,
739
+ };
740
+ }
741
+ });
742
+ server.registerTool('etsquare_institutional_holdings', {
743
+ title: 'Institutional Ownership (13F)',
744
+ description: 'Get institutional investor holdings for a company from SEC 13F filings. ' +
745
+ 'Shows tracked managers holding the stock, their position sizes, and portfolio concentration.\n\n' +
746
+ 'Data is from tracked institutional managers (top filers by AUM). ' +
747
+ 'Coverage is partial — not all institutional holders are tracked. ' +
748
+ 'Multiple rows per manager may appear due to sub-manager reporting. ' +
749
+ 'Source: SEC EDGAR 13F-HR filings, filed quarterly.',
750
+ inputSchema: {
751
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "NVDA", "AAPL")'),
752
+ quarters: z.number().min(1).max(8).default(2)
753
+ .describe('Quarters of history (default: 2)'),
754
+ },
755
+ }, async (input) => {
756
+ if (containsGuardrailBypass(input))
757
+ return guardrailViolationResponse();
758
+ try {
759
+ const result = await client.getInstitutionalHoldings(input);
760
+ const summary = result.summary || {};
761
+ const holdersByQuarter = result.holders_by_quarter || {};
762
+ const ticker = result.ticker || input.ticker.toUpperCase();
763
+ const quarters = Object.keys(holdersByQuarter).sort().reverse();
764
+ if (quarters.length === 0) {
765
+ return {
766
+ content: [{
767
+ type: 'text',
768
+ text: `No institutional holdings found for ${ticker} in tracked managers. ` +
769
+ `The stock may not be held by the top institutional filers currently tracked.`,
770
+ }],
771
+ };
772
+ }
773
+ const fmtVal = (v) => {
774
+ if (v == null)
775
+ return '—';
776
+ if (Math.abs(v) >= 1e12)
777
+ return `$${(v / 1e12).toFixed(1)}T`;
778
+ if (Math.abs(v) >= 1e9)
779
+ return `$${(v / 1e9).toFixed(1)}B`;
780
+ if (Math.abs(v) >= 1e6)
781
+ return `$${(v / 1e6).toFixed(1)}M`;
782
+ if (Math.abs(v) >= 1e3)
783
+ return `$${(v / 1e3).toFixed(0)}K`;
784
+ return `$${v.toFixed(0)}`;
785
+ };
786
+ let text = `## ${ticker} — Institutional Holders (13F)\n\n`;
787
+ text += `**Distinct tracked managers**: ${summary.distinct_tracked_managers || 0} | `;
788
+ text += `**Holder rows**: ${summary.holder_rows || 0} | `;
789
+ text += `**Tracked shares**: ${(summary.tracked_shares_held || 0).toLocaleString()} | `;
790
+ text += `**Tracked value**: ${fmtVal(summary.tracked_value_usd)}\n`;
791
+ text += `*${summary.coverage_note || 'Partial coverage — tracked managers only'}*\n\n`;
792
+ for (const q of quarters) {
793
+ const holders = holdersByQuarter[q] || [];
794
+ if (holders.length === 0)
795
+ continue;
796
+ text += `### ${q}\n\n`;
797
+ text += `| Manager | Shares | Value | Portfolio % | Discretion |\n`;
798
+ text += `|---------|-------:|------:|------------:|:-----------|\n`;
799
+ for (const h of holders.slice(0, 20)) {
800
+ const shares = h.shares != null ? h.shares.toLocaleString() : '—';
801
+ const value = fmtVal(h.value_usd);
802
+ const pct = h.portfolio_pct != null ? `${h.portfolio_pct.toFixed(2)}%` : '—';
803
+ const disc = h.investment_discretion || '—';
804
+ const label = h.manager_label || h.manager_name || '—';
805
+ text += `| ${label} | ${shares} | ${value} | ${pct} | ${disc} |\n`;
806
+ }
807
+ if (holders.length > 20) {
808
+ text += `\n*Showing 20 of ${holders.length} tracked holders.*\n`;
809
+ }
810
+ text += '\n';
811
+ }
812
+ text += `*Source: SEC 13F-HR filings via EDGAR. Tracked managers only — not full institutional ownership.*`;
813
+ return {
814
+ content: [{ type: 'text', text }],
815
+ };
816
+ }
817
+ catch (error) {
818
+ const message = error instanceof Error ? error.message : 'Unknown error';
819
+ return {
820
+ content: [{ type: 'text', text: `Institutional holdings request failed: ${message}` }],
821
+ isError: true,
822
+ };
823
+ }
824
+ });
825
+ server.registerTool('etsquare_earnings_actuals', {
826
+ title: 'Earnings Actuals & Guidance',
827
+ description: 'Get issuer-reported earnings actuals and forward guidance for a company from SEC 8-K filings. ' +
828
+ 'Extracts revenue, EPS (basic/diluted), and net income from Item 2.02 press releases.\n\n' +
829
+ 'Values are deterministically extracted from exhibit press releases (EX-99.1) attached to 8-K filings. ' +
830
+ 'Only explicit issuer-reported values are included — no analyst estimates or consensus.\n\n' +
831
+ 'Guidance shows forward-looking ranges (low/high) when the issuer provides them.\n\n' +
832
+ 'Coverage: ~1,900 tickers, most recent 1-2 quarters (Dec 2025 onward). ' +
833
+ 'Historical depth is expanding — some tickers may have fewer quarters than requested.\n\n' +
834
+ 'Source: SEC EDGAR 8-K Item 2.02 filings with attached press releases.',
835
+ inputSchema: {
836
+ ticker: z.string().min(1).max(10).describe('Company ticker (e.g., "AAPL", "NVDA")'),
837
+ quarters: z.number().min(1).max(20).default(4)
838
+ .describe('Quarters of history (default: 4)'),
839
+ metrics: z.array(z.string()).optional()
840
+ .describe('Filter metrics: "revenue", "diluted_eps", "basic_eps", "net_income"'),
841
+ },
842
+ }, async (input) => {
843
+ if (containsGuardrailBypass(input))
844
+ return guardrailViolationResponse();
845
+ try {
846
+ const result = await client.getEarningsActuals(input);
847
+ const summary = result.summary || {};
848
+ const actuals = result.actuals || [];
849
+ const guidance = result.guidance || [];
850
+ const ticker = result.ticker || input.ticker.toUpperCase();
851
+ if (actuals.length === 0 && guidance.length === 0) {
852
+ return {
853
+ content: [{
854
+ type: 'text',
855
+ text: `No earnings actuals found for ${ticker}. ` +
856
+ `Earnings data is extracted from 8-K Item 2.02 press releases — ` +
857
+ `the company may not have earnings filings in the extracted dataset yet.`,
858
+ }],
859
+ };
860
+ }
861
+ const fmtVal = (v, unit) => {
862
+ if (v == null)
863
+ return '—';
864
+ if (unit === 'USD_PER_SHARE')
865
+ return `$${v.toFixed(2)}`;
866
+ if (Math.abs(v) >= 1e12)
867
+ return `$${(v / 1e12).toFixed(1)}T`;
868
+ if (Math.abs(v) >= 1e9)
869
+ return `$${(v / 1e9).toFixed(2)}B`;
870
+ if (Math.abs(v) >= 1e6)
871
+ return `$${(v / 1e6).toFixed(1)}M`;
872
+ if (Math.abs(v) >= 1e3)
873
+ return `$${(v / 1e3).toFixed(0)}K`;
874
+ return `$${v.toFixed(0)}`;
875
+ };
876
+ let text = `## ${ticker} — Earnings Actuals\n\n`;
877
+ text += `**Metrics found**: ${summary.total_actuals || 0} actuals, ${summary.total_guidance || 0} guidance\n`;
878
+ if (summary.date_range?.earliest && summary.date_range?.latest) {
879
+ text += `**Period**: ${summary.date_range.earliest} to ${summary.date_range.latest}\n`;
880
+ }
881
+ text += '\n';
882
+ if (actuals.length > 0) {
883
+ text += `| Filing Date | Metric | Value | GAAP | FY | FQ | Confidence |\n`;
884
+ text += `|-------------|--------|------:|:----:|---:|---:|-----------:|\n`;
885
+ for (const a of actuals) {
886
+ const val = fmtVal(a.value, a.unit);
887
+ const gaap = a.gaap_flag || '—';
888
+ const fy = a.fiscal_year ?? '—';
889
+ const fq = a.fiscal_quarter ?? '—';
890
+ const conf = a.confidence != null ? `${(a.confidence * 100).toFixed(0)}%` : '—';
891
+ text += `| ${a.filing_date || '—'} | ${a.metric_name} | ${val} | ${gaap} | ${fy} | ${fq} | ${conf} |\n`;
892
+ }
893
+ text += '\n';
894
+ }
895
+ if (guidance.length > 0) {
896
+ text += `### Forward Guidance\n\n`;
897
+ text += `| Filing Date | Metric | Low | High | Midpoint | GAAP | Target FY | Target FQ |\n`;
898
+ text += `|-------------|--------|----:|-----:|---------:|:----:|----------:|----------:|\n`;
899
+ for (const g of guidance) {
900
+ const low = fmtVal(g.low_value, g.unit);
901
+ const high = fmtVal(g.high_value, g.unit);
902
+ const mid = g.midpoint != null ? fmtVal(g.midpoint, g.unit) : '—';
903
+ const gaap = g.gaap_flag || '—';
904
+ const fy = g.fiscal_year ?? '—';
905
+ const fq = g.fiscal_quarter ?? '—';
906
+ text += `| ${g.filing_date || '—'} | ${g.metric_name} | ${low} | ${high} | ${mid} | ${gaap} | ${fy} | ${fq} |\n`;
907
+ }
908
+ text += '\n';
909
+ }
910
+ text += `*Source: SEC 8-K Item 2.02 press releases — deterministic extraction from issuer filings.*`;
911
+ return {
912
+ content: [{ type: 'text', text }],
913
+ };
914
+ }
915
+ catch (error) {
916
+ const message = error instanceof Error ? error.message : 'Unknown error';
917
+ return {
918
+ content: [{ type: 'text', text: `Earnings actuals request failed: ${message}` }],
919
+ isError: true,
920
+ };
921
+ }
922
+ });
220
923
  server.registerTool('etsquare_execute_metrics', {
221
924
  title: 'Execute Financial Metrics Query',
222
925
  description: 'Execute an XBRL metrics template by template_id to get structured financial data ' +
223
926
  '(numbers, ratios, time series). Returns tabular rows with columns like revenue, margins, EPS, etc.\n\n' +
224
- 'Common bind_params: p_ticker (company ticker), p_sic_code (industry), ' +
225
- 'p_start_year / p_end_year (time range, e.g., 2023-2025).',
927
+ 'Common bind_params: p_tickers (comma-separated tickers), p_ticker (single ticker), p_sic_code (industry), ' +
928
+ 'p_start_year / p_end_year (time range, e.g., 2023-2025).\n\n' +
929
+ 'Use the tickers array to pass multiple companies — automatically joins as p_tickers comma-separated string.\n\n' +
930
+ 'Set response_format="structured" for typed JSON with column metadata, visualization hints, and summary stats.',
226
931
  inputSchema: {
227
932
  template_id: z.string().min(10).describe('Template ID for metrics execution'),
228
933
  bind_params: z.record(z.unknown()).optional().describe('Template parameters as key-value pairs'),
229
934
  row_limit: z.number().min(1).max(500).optional().describe('Max rows to return (default: 100)'),
935
+ tickers: z.array(z.string()).max(5).optional()
936
+ .describe('Execute same template for multiple tickers and merge results. Overrides p_ticker in bind_params.'),
937
+ response_format: z.enum(['text', 'structured']).default('text')
938
+ .describe('"text" returns markdown table (default). "structured" returns JSON with column metadata and viz hints.'),
230
939
  },
231
940
  }, async (input) => {
232
941
  if (containsGuardrailBypass(input))
233
942
  return guardrailViolationResponse();
234
943
  try {
235
- log('debug', 'Executing metrics template', { template_id: input.template_id });
236
- const result = await client.executeMetrics(input);
237
- const rows = result.rows || [];
238
- const rowCount = result.row_count || rows.length;
239
- const truncated = result.truncated || false;
240
- const columns = result.columns || [];
944
+ log('debug', 'Executing metrics template', {
945
+ template_id: input.template_id,
946
+ tickers: input.tickers,
947
+ });
948
+ let rows = [];
949
+ let columns = [];
950
+ let rowCount = 0;
951
+ let truncated = false;
952
+ let vizHintFromApi = null;
953
+ // Multi-ticker: join as comma-separated p_tickers (single call)
954
+ // Templates use p_tickers (plural, comma-separated) for multi-company queries.
955
+ // Fallback: if p_tickers fails, try N parallel calls with p_ticker (singular).
956
+ if (input.tickers && input.tickers.length > 0) {
957
+ const joinedTickers = input.tickers.join(',');
958
+ const baseParams = { ...(input.bind_params || {}) };
959
+ // Remove any existing p_ticker — p_tickers takes precedence
960
+ delete baseParams.p_ticker;
961
+ try {
962
+ // Primary: single call with p_tickers (comma-separated)
963
+ const result = await client.executeMetrics({
964
+ template_id: input.template_id,
965
+ bind_params: { ...baseParams, p_tickers: joinedTickers },
966
+ row_limit: input.row_limit,
967
+ });
968
+ rows = result.rows || [];
969
+ rowCount = result.row_count || rows.length;
970
+ truncated = result.truncated || false;
971
+ columns = result.columns || [];
972
+ vizHintFromApi = result.visualization_hint || null;
973
+ }
974
+ catch {
975
+ // Fallback: parallel calls with p_ticker (singular) per ticker
976
+ log('debug', 'p_tickers failed, falling back to parallel p_ticker calls');
977
+ const results = await Promise.allSettled(input.tickers.map(ticker => client.executeMetrics({
978
+ template_id: input.template_id,
979
+ bind_params: { ...baseParams, p_ticker: ticker },
980
+ row_limit: input.row_limit,
981
+ })));
982
+ for (const res of results) {
983
+ if (res.status === 'fulfilled' && res.value) {
984
+ const val = res.value;
985
+ if (Array.isArray(val.rows))
986
+ rows.push(...val.rows);
987
+ if (!columns.length && Array.isArray(val.columns))
988
+ columns = val.columns;
989
+ if (val.truncated)
990
+ truncated = true;
991
+ if (!vizHintFromApi)
992
+ vizHintFromApi = val.visualization_hint || null;
993
+ }
994
+ }
995
+ rowCount = rows.length;
996
+ }
997
+ }
998
+ else {
999
+ // Single execution
1000
+ const result = await client.executeMetrics(input);
1001
+ rows = result.rows || [];
1002
+ rowCount = result.row_count || rows.length;
1003
+ truncated = result.truncated || false;
1004
+ columns = result.columns || [];
1005
+ vizHintFromApi = result.visualization_hint || null;
1006
+ }
241
1007
  if (rowCount === 0) {
242
1008
  return {
243
1009
  content: [{
@@ -246,21 +1012,31 @@ server.registerTool('etsquare_execute_metrics', {
246
1012
  }],
247
1013
  };
248
1014
  }
249
- const colNames = columns.length > 0 ? columns.map((c) => c.name) : Object.keys(rows[0] || {});
250
- const header = colNames.join(' | ');
251
- const separator = colNames.map(() => '---').join(' | ');
252
- const displayRows = rows.slice(0, 20);
253
- const tableRows = displayRows.map((row) => colNames.map((col) => {
254
- const val = row[col];
255
- if (val === null || val === undefined)
256
- return 'N/A';
257
- if (typeof val === 'number')
258
- return Number.isInteger(val) ? String(val) : val.toFixed(2);
259
- return String(val);
260
- }).join(' | '));
261
- const table = [header, separator, ...tableRows].join('\n');
1015
+ // ── Structured response path ──
1016
+ if (input.response_format === 'structured') {
1017
+ const columnMeta = buildColumnMeta(columns, rows);
1018
+ // API-returned hint takes precedence over auto-derived
1019
+ const autoHint = deriveMetricsVizHint(rows, { columns: columnMeta });
1020
+ const colNames = columnMeta.map((c) => c.name);
1021
+ const structured = {
1022
+ template_id: input.template_id,
1023
+ columns: columnMeta,
1024
+ rows,
1025
+ row_count: rowCount,
1026
+ truncated,
1027
+ visualization_hint: vizHintFromApi || autoHint,
1028
+ summary_stats: computeSummaryStats(colNames, rows),
1029
+ };
1030
+ log('info', `Metrics query returned ${rowCount} rows [structured]`);
1031
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
1032
+ }
1033
+ // ── Text response path (existing behavior) ──
1034
+ const colNames = columns.length > 0
1035
+ ? columns.map((c) => c.name)
1036
+ : Object.keys(rows[0] || {});
1037
+ const table = buildMarkdownTable(colNames, rows);
262
1038
  const suffix = truncated || rows.length > 20
263
- ? `\n\n(Showing ${displayRows.length} of ${rowCount} rows${truncated ? ', results truncated' : ''})`
1039
+ ? `\n\n(Showing ${Math.min(rows.length, 20)} of ${rowCount} rows${truncated ? ', results truncated' : ''})`
264
1040
  : '';
265
1041
  log('info', `Metrics query returned ${rowCount} rows`);
266
1042
  return {
@@ -278,18 +1054,24 @@ server.registerTool('etsquare_execute_metrics', {
278
1054
  };
279
1055
  }
280
1056
  });
281
- // Tool 4: Discover Metrics Templates
1057
+ // ─── Tool 4: Discover Metrics Templates ─────────────────────────────────────
282
1058
  server.registerTool('etsquare_discover_metrics', {
283
1059
  title: 'Discover Financial Metrics Templates',
284
1060
  description: 'Find available XBRL metrics templates by business question. ' +
285
1061
  'Returns template IDs and metadata — use the template_id with etsquare_execute_metrics.\n\n' +
1062
+ 'Use this only for structured metrics such as revenue, gross margin, operating margin, EPS, debt, liquidity, or cash flow.\n\n' +
1063
+ 'Do not use this for narrative KPI questions like same-store sales, comparable sales, traffic, guest count, average check, or AUV. ' +
1064
+ 'For those, use etsquare_search in NARRATIVE mode instead.\n\n' +
286
1065
  'Example: "revenue trend by quarter" → returns matching templates with their required bind_params.\n\n' +
287
- 'Workflow: discover_metrics → pick template_id → execute_metrics with bind_params.',
1066
+ 'Workflow: discover_metrics → pick template_id → execute_metrics with bind_params.\n\n' +
1067
+ 'Set response_format="structured" for JSON with similarity scores and visualization hints.',
288
1068
  inputSchema: {
289
- question: z.string().min(3).describe('Business question (e.g., "revenue trend by quarter", "profit margins comparison")'),
1069
+ question: z.string().min(3).describe('Structured metrics question (e.g., "revenue trend by quarter", "gross margin trend", "EPS trend")'),
290
1070
  scenario: z.enum(['snapshot', 'trends', 'peer_benchmark']).optional().describe('Filter by scenario type'),
291
1071
  metric_family: z.string().optional().describe('Filter by metric family: REVENUE, EARNINGS, PROFITABILITY_MARGIN, LEVERAGE_DEBT, FREE_CASH_FLOW, LIQUIDITY'),
292
1072
  max_results: z.number().min(1).max(10).default(5).optional().describe('Max templates to return'),
1073
+ response_format: z.enum(['text', 'structured']).default('text')
1074
+ .describe('"text" returns formatted list (default). "structured" returns JSON with scores and viz hints.'),
293
1075
  },
294
1076
  }, async (input) => {
295
1077
  if (containsGuardrailBypass(input))
@@ -300,14 +1082,49 @@ server.registerTool('etsquare_discover_metrics', {
300
1082
  const templates = Array.isArray(result.templates)
301
1083
  ? result.templates
302
1084
  : [];
1085
+ const guidance = result.guidance && typeof result.guidance === 'object'
1086
+ ? result.guidance
1087
+ : null;
303
1088
  if (templates.length === 0) {
304
- return {
305
- content: [{
306
- type: 'text',
307
- text: `No metrics templates found for: "${input.question}"\nTry a different question or remove filters.`,
308
- }],
1089
+ let text = `No structured metrics templates found for: "${input.question}"`;
1090
+ if (guidance?.message) {
1091
+ text += `\n${guidance.message}`;
1092
+ }
1093
+ if (guidance?.suggested_tool === 'etsquare_search') {
1094
+ text += '\nRecommended next step: use etsquare_search with NARRATIVE mode.';
1095
+ }
1096
+ const examples = Array.isArray(guidance?.valid_metrics_examples)
1097
+ ? guidance.valid_metrics_examples
1098
+ : [];
1099
+ if (examples.length > 0) {
1100
+ text += `\nValid metrics examples: ${examples.join('; ')}`;
1101
+ }
1102
+ return { content: [{ type: 'text', text }] };
1103
+ }
1104
+ // ── Structured response path ──
1105
+ if (input.response_format === 'structured') {
1106
+ const structuredTemplates = templates.map((t, i) => ({
1107
+ template_id: t.template_id,
1108
+ name: t.title,
1109
+ description: t.description,
1110
+ recommended: i === 0,
1111
+ similarity_score: t.similarity_score || null,
1112
+ confidence_score: t.confidence_score || null,
1113
+ scenario: t.scenario,
1114
+ category: t.category,
1115
+ required_params: t.required_params || {},
1116
+ columns: t.columns || [],
1117
+ visualization_hint: t.visualization_hint || null,
1118
+ }));
1119
+ const structured = {
1120
+ question: result.question || input.question,
1121
+ templates: structuredTemplates,
1122
+ guidance: guidance || null,
309
1123
  };
1124
+ log('info', `Discovered ${templates.length} templates [structured]`);
1125
+ return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }] };
310
1126
  }
1127
+ // ── Text response path (existing behavior) ──
311
1128
  const formatted = templates
312
1129
  .map((t, i) => {
313
1130
  const lines = [
@@ -354,7 +1171,295 @@ server.registerTool('etsquare_discover_metrics', {
354
1171
  };
355
1172
  }
356
1173
  });
357
- // Start server
1174
+ // ─── Tool 5: Get Full Chunk Text ────────────────────────────────────────────
1175
+ server.registerTool('etsquare_get_chunk', {
1176
+ title: 'Get Full Chunk Text',
1177
+ description: 'Fetch the full text for a specific search result chunk. ' +
1178
+ 'Use this when etsquare_search returns a truncated snippet and you need the complete chunk text. ' +
1179
+ 'Requires both chunk_id and execution_id from a prior search result.',
1180
+ inputSchema: {
1181
+ chunk_id: z.string().length(32).describe('Chunk ID from etsquare_search output'),
1182
+ execution_id: z.string().min(32).describe('Execution ID from the etsquare_search response header'),
1183
+ },
1184
+ }, async (input) => {
1185
+ if (containsGuardrailBypass(input))
1186
+ return guardrailViolationResponse();
1187
+ try {
1188
+ log('debug', 'Fetching full chunk text', { chunk_id: input.chunk_id });
1189
+ const result = await client.getChunk(input);
1190
+ const chunkText = typeof result.chunk_text === 'string'
1191
+ ? result.chunk_text
1192
+ : '';
1193
+ const chunkLength = result.chunk_text_length;
1194
+ return {
1195
+ content: [{
1196
+ type: 'text',
1197
+ text: `Chunk ${input.chunk_id}${typeof chunkLength === 'number' ? ` (${chunkLength} chars)` : ''}:\n\n${chunkText}`,
1198
+ }],
1199
+ };
1200
+ }
1201
+ catch (error) {
1202
+ log('error', 'Chunk fetch failed', { error: error instanceof Error ? error.message : error });
1203
+ return {
1204
+ content: [{ type: 'text', text: `Chunk fetch failed: ${error instanceof Error ? error.message : 'Unknown error'}` }],
1205
+ isError: true,
1206
+ };
1207
+ }
1208
+ });
1209
+ // ─── Tool 6: Get Chunk Context ──────────────────────────────────────────────
1210
+ server.registerTool('etsquare_get_chunk_context', {
1211
+ title: 'Get Chunk Context',
1212
+ description: 'Fetch the highlighted chunk plus surrounding context from the same filing section. ' +
1213
+ 'Use this when you need the paragraphs immediately before and after a search hit.',
1214
+ inputSchema: {
1215
+ chunk_id: z.string().length(32).describe('Chunk ID from etsquare_search output'),
1216
+ neighbor_span: z.number().min(0).max(5).default(1).optional().describe('How many adjacent chunks to include before and after'),
1217
+ window: z.number().min(200).max(4000).default(900).optional().describe('Approximate character budget for surrounding context'),
1218
+ },
1219
+ }, async (input) => {
1220
+ if (containsGuardrailBypass(input))
1221
+ return guardrailViolationResponse();
1222
+ try {
1223
+ log('debug', 'Fetching chunk context', { chunk_id: input.chunk_id });
1224
+ const result = await client.getChunkContext(input);
1225
+ const filing = result.filing || {};
1226
+ const context = result.context || {};
1227
+ const navigation = result.navigation || {};
1228
+ const lines = [
1229
+ `Chunk ID: ${result.chunk_id || input.chunk_id}`,
1230
+ ];
1231
+ if (result.sec_url)
1232
+ lines.push(`SEC URL: ${result.sec_url}`);
1233
+ if (filing.ticker || filing.company) {
1234
+ lines.push(`Filing: ${filing.ticker || 'N/A'} - ${filing.company || 'Unknown'} (${filing.form_type || filing.doc_subtype || 'N/A'})`);
1235
+ }
1236
+ if (filing.item_code || filing.accession_number) {
1237
+ lines.push(`Section: ${filing.item_code || 'N/A'}${filing.accession_number ? ` | Accession: ${filing.accession_number}` : ''}`);
1238
+ }
1239
+ if (typeof navigation.chunk_position === 'number' || typeof navigation.total_chunks_in_item === 'number') {
1240
+ lines.push(`Position: ${navigation.chunk_position ?? 'N/A'} of ${navigation.total_chunks_in_item ?? 'N/A'}`);
1241
+ }
1242
+ lines.push('');
1243
+ if (context.before) {
1244
+ lines.push('Before:');
1245
+ lines.push(String(context.before));
1246
+ lines.push('');
1247
+ }
1248
+ lines.push('Highlight:');
1249
+ lines.push(String(context.highlight || ''));
1250
+ if (context.after) {
1251
+ lines.push('');
1252
+ lines.push('After:');
1253
+ lines.push(String(context.after));
1254
+ }
1255
+ return {
1256
+ content: [{
1257
+ type: 'text',
1258
+ text: lines.join('\n'),
1259
+ }],
1260
+ };
1261
+ }
1262
+ catch (error) {
1263
+ log('error', 'Chunk context fetch failed', { error: error instanceof Error ? error.message : error });
1264
+ return {
1265
+ content: [{ type: 'text', text: `Chunk context fetch failed: ${error instanceof Error ? error.message : 'Unknown error'}` }],
1266
+ isError: true,
1267
+ };
1268
+ }
1269
+ });
1270
+ // ─── Tool 7: Compare Companies ──────────────────────────────────────────────
1271
+ server.registerTool('etsquare_compare', {
1272
+ title: 'Compare Companies in SEC Filings',
1273
+ description: 'Compare 2-5 companies across SEC filings in a single call. ' +
1274
+ 'Returns per-ticker results grouped for easy side-by-side analysis.\n\n' +
1275
+ 'Resolve company names to tickers first with etsquare_lookup_company.\n\n' +
1276
+ 'Use mode_lock=HYBRID to include both narrative text and structured metrics.\n\n' +
1277
+ 'Always returns structured JSON (comparison results are inherently structured).',
1278
+ inputSchema: {
1279
+ tickers: z.array(z.string()).min(2).max(5)
1280
+ .describe('Company tickers to compare (2-5). Resolve names first with etsquare_lookup_company.'),
1281
+ query: z.string().min(3)
1282
+ .describe('Comparison question (e.g., "revenue growth and risk factors", "tariff risk exposure")'),
1283
+ mode_lock: z.enum(['NARRATIVE', 'HYBRID']).default('NARRATIVE')
1284
+ .describe('HYBRID recommended for comparisons (gets both text and metrics)'),
1285
+ top_k_per_ticker: z.number().int().min(1).max(10).default(3)
1286
+ .describe('Narrative results per company'),
1287
+ doc_types: z.array(z.string()).optional()
1288
+ .describe('Limit by form type (e.g., ["10-k", "8-k"])'),
1289
+ },
1290
+ }, async (input) => {
1291
+ if (containsGuardrailBypass(input))
1292
+ return guardrailViolationResponse();
1293
+ try {
1294
+ log('debug', 'Executing comparison', {
1295
+ tickers: input.tickers,
1296
+ query: input.query,
1297
+ mode_lock: input.mode_lock,
1298
+ });
1299
+ const searchResult = await client.search({
1300
+ query: input.query,
1301
+ mode_lock: input.mode_lock,
1302
+ scope_lock: 'COMPANY',
1303
+ tickers: input.tickers,
1304
+ top_k: input.top_k_per_ticker * input.tickers.length,
1305
+ doc_types: input.doc_types,
1306
+ });
1307
+ const allResults = Array.isArray(searchResult.narrative_results)
1308
+ ? searchResult.narrative_results
1309
+ : [];
1310
+ const xbrlMetrics = Array.isArray(searchResult.xbrl_metrics)
1311
+ ? searchResult.xbrl_metrics
1312
+ : [];
1313
+ // Group results by ticker
1314
+ const perTicker = {};
1315
+ for (const ticker of input.tickers) {
1316
+ perTicker[ticker] = allResults
1317
+ .filter((r) => r.ticker === ticker)
1318
+ .slice(0, input.top_k_per_ticker)
1319
+ .map((r, i) => ({
1320
+ index: i + 1,
1321
+ chunk_id: r.chunk_id,
1322
+ company_name: r.company_name,
1323
+ form_type: r.form_type,
1324
+ filing_date: r.filing_date,
1325
+ item_code: r.item_code,
1326
+ section_label: mapItemCodeToLabel(r.item_code),
1327
+ score: r.similarity_score,
1328
+ chunk_text: r.chunk_text,
1329
+ truncated: r.chunk_text_truncated || false,
1330
+ accession_number: r.accession_number,
1331
+ sec_url: r.sec_url,
1332
+ }));
1333
+ }
1334
+ const hasMetrics = xbrlMetrics.length > 0;
1335
+ const metricsColumnMeta = hasMetrics
1336
+ ? buildColumnMeta(searchResult.xbrl_output_schema?.columns || [], xbrlMetrics)
1337
+ : null;
1338
+ const response = {
1339
+ comparison: {
1340
+ tickers: input.tickers,
1341
+ query: input.query,
1342
+ per_ticker: perTicker,
1343
+ metrics: hasMetrics ? {
1344
+ rows: xbrlMetrics,
1345
+ columns: metricsColumnMeta,
1346
+ visualization_hint: deriveMetricsVizHint(xbrlMetrics, { columns: metricsColumnMeta }),
1347
+ } : null,
1348
+ },
1349
+ layout_hint: {
1350
+ suggested: hasMetrics ? 'comparison_dashboard' : 'comparison_grid',
1351
+ reason: 'multi_ticker_compare',
1352
+ panels: hasMetrics
1353
+ ? ['metrics_chart', 'narrative_side_by_side']
1354
+ : ['narrative_side_by_side'],
1355
+ tone: 'comparative',
1356
+ },
1357
+ search_context: buildSearchContext(searchResult, input.query),
1358
+ };
1359
+ const tickerCounts = input.tickers
1360
+ .map(t => `${t}: ${perTicker[t]?.length || 0}`)
1361
+ .join(', ');
1362
+ log('info', `Compare returned results: ${tickerCounts}, ${xbrlMetrics.length} metrics`);
1363
+ return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
1364
+ }
1365
+ catch (error) {
1366
+ log('error', 'Compare failed', { error: error instanceof Error ? error.message : error });
1367
+ return {
1368
+ content: [{ type: 'text', text: `Compare failed: ${error instanceof Error ? error.message : 'Unknown error'}` }],
1369
+ isError: true,
1370
+ };
1371
+ }
1372
+ });
1373
+ // ─── Tool 8: Weekly Brief ───────────────────────────────────────────────────
1374
+ server.registerTool('etsquare_weekly_brief', {
1375
+ title: 'Weekly SEC Filing Intelligence Brief',
1376
+ description: 'Get the latest weekly SEC filing intelligence brief. ' +
1377
+ 'Returns notable 8-K events, edge signals (unusual filings), and sector heatmap. ' +
1378
+ 'Updated weekly. Great for market monitoring and identifying emerging risks.\n\n' +
1379
+ 'Default response_format is "structured" (full JSON). Use "text" for readable summary.',
1380
+ inputSchema: {
1381
+ sections: z.array(z.enum(['notable', 'edge_signals', 'heatmap', 'methodology'])).optional()
1382
+ .describe('Filter to specific sections. Returns all if omitted.'),
1383
+ response_format: z.enum(['text', 'structured']).default('structured')
1384
+ .describe('"structured" returns full JSON (default). "text" returns a readable summary.'),
1385
+ },
1386
+ }, async (input) => {
1387
+ if (containsGuardrailBypass(input))
1388
+ return guardrailViolationResponse();
1389
+ try {
1390
+ log('debug', 'Fetching weekly brief', { sections: input.sections });
1391
+ const brief = await client.weeklyBrief();
1392
+ if (!brief || (!brief.notable && !brief.edge_signals)) {
1393
+ return { content: [{ type: 'text', text: 'No weekly brief available for the current period.' }] };
1394
+ }
1395
+ // Client-side section filtering
1396
+ let output = brief;
1397
+ if (input.sections && input.sections.length > 0) {
1398
+ output = {
1399
+ week_start: brief.week_start,
1400
+ week_end: brief.week_end,
1401
+ generated_at: brief.generated_at,
1402
+ };
1403
+ if (input.sections.includes('notable'))
1404
+ output.notable = brief.notable;
1405
+ if (input.sections.includes('edge_signals'))
1406
+ output.edge_signals = brief.edge_signals;
1407
+ if (input.sections.includes('heatmap'))
1408
+ output.heatmap = brief.heatmap;
1409
+ if (input.sections.includes('methodology'))
1410
+ output.methodology = brief.methodology;
1411
+ }
1412
+ // ── Structured response path ──
1413
+ if (input.response_format === 'structured') {
1414
+ log('info', `Weekly brief returned: ${(brief.notable || []).length} notable, ${(brief.edge_signals || []).length} edge signals`);
1415
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
1416
+ }
1417
+ // ── Text response path ──
1418
+ const lines = [];
1419
+ lines.push(`SEC Weekly Brief: ${brief.week_start || 'N/A'} to ${brief.week_end || 'N/A'}`);
1420
+ lines.push(`Generated: ${brief.generated_at || 'N/A'}`);
1421
+ lines.push('');
1422
+ if (output.notable?.length) {
1423
+ lines.push(`Notable Filings (${output.notable.length}):`);
1424
+ for (const n of output.notable) {
1425
+ lines.push(` ${n.ticker || 'N/A'} — ${n.company_name || 'Unknown'} (${n.form_type || ''} Item ${n.item_code || ''})`);
1426
+ lines.push(` Filed: ${n.filed || 'N/A'} | Score: ${n.score_notable || 'N/A'}`);
1427
+ if (n.summary)
1428
+ lines.push(` ${n.summary}`);
1429
+ if (n.sec_url)
1430
+ lines.push(` ${n.sec_url}`);
1431
+ lines.push('');
1432
+ }
1433
+ }
1434
+ if (output.edge_signals?.length) {
1435
+ lines.push(`Edge Signals (${output.edge_signals.length}):`);
1436
+ for (const e of output.edge_signals) {
1437
+ lines.push(` ${e.ticker || 'N/A'} — ${e.company_name || 'Unknown'}`);
1438
+ if (e.signal_reason)
1439
+ lines.push(` ${e.signal_reason}`);
1440
+ if (e.citation)
1441
+ lines.push(` Verbatim: "${e.citation}"`);
1442
+ lines.push('');
1443
+ }
1444
+ }
1445
+ if (output.heatmap?.length) {
1446
+ lines.push(`Sector Heatmap (${output.heatmap.length} sectors):`);
1447
+ for (const h of output.heatmap) {
1448
+ lines.push(` ${h.sector || h.sic_description || 'N/A'}: ${h.filing_count || h.count || 0} filings`);
1449
+ }
1450
+ }
1451
+ log('info', `Weekly brief returned: ${(brief.notable || []).length} notable, ${(brief.edge_signals || []).length} edge signals`);
1452
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1453
+ }
1454
+ catch (error) {
1455
+ log('error', 'Weekly brief fetch failed', { error: error instanceof Error ? error.message : error });
1456
+ return {
1457
+ content: [{ type: 'text', text: `Weekly brief unavailable: ${error instanceof Error ? error.message : 'Unknown error'}` }],
1458
+ isError: true,
1459
+ };
1460
+ }
1461
+ });
1462
+ // ─── Start Server ───────────────────────────────────────────────────────────
358
1463
  const transport = new StdioServerTransport();
359
1464
  await server.connect(transport);
360
- log('info', 'ETSquare MCP Server ready and listening on stdio');
1465
+ log('info', 'ETSquare MCP Server v0.3.0 ready and listening on stdio (8 tools)');