fdic-mcp-server 1.10.2 → 1.12.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 (3) hide show
  1. package/dist/index.js +1051 -1
  2. package/dist/server.js +1051 -1
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -46,7 +46,7 @@ var import_types = require("@modelcontextprotocol/sdk/types.js");
46
46
  var import_express2 = __toESM(require("express"));
47
47
 
48
48
  // src/constants.ts
49
- var VERSION = true ? "1.10.2" : process.env.npm_package_version ?? "0.0.0-dev";
49
+ var VERSION = true ? "1.12.0" : process.env.npm_package_version ?? "0.0.0-dev";
50
50
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
51
51
  var CHARACTER_LIMIT = 5e4;
52
52
  var DEFAULT_FDIC_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
@@ -34365,6 +34365,1053 @@ Override precedence: cert derives defaults, then explicit params override them.`
34365
34365
  );
34366
34366
  }
34367
34367
 
34368
+ // src/tools/bankHealth.ts
34369
+ var import_zod9 = require("zod");
34370
+
34371
+ // src/tools/shared/camelsScoring.ts
34372
+ var SCORING_RULES = {
34373
+ // Capital
34374
+ tier1_leverage: { thresholds: [8, 6, 5, 4], higher_is_better: true, weight: 0.35, label: "Tier 1 Leverage Ratio", unit: "%" },
34375
+ tier1_rbc: { thresholds: [10, 8, 6, 4], higher_is_better: true, weight: 0.35, label: "Tier 1 Risk-Based Capital", unit: "%" },
34376
+ equity_ratio: { thresholds: [10, 8, 7, 5], higher_is_better: true, weight: 0.3, label: "Equity / Assets", unit: "%" },
34377
+ // Asset Quality
34378
+ noncurrent_loans_ratio: { thresholds: [1, 2, 3, 5], higher_is_better: false, weight: 0.3, label: "Noncurrent Loans / Loans", unit: "%" },
34379
+ net_chargeoff_ratio: { thresholds: [0.25, 0.5, 1, 2], higher_is_better: false, weight: 0.25, label: "Net Charge-Off Ratio", unit: "%" },
34380
+ reserve_coverage: { thresholds: [150, 100, 80, 50], higher_is_better: true, weight: 0.25, label: "Reserve Coverage", unit: "%" },
34381
+ noncurrent_assets_ratio: { thresholds: [0.5, 1, 2, 4], higher_is_better: false, weight: 0.2, label: "Noncurrent Assets / Assets", unit: "%" },
34382
+ // Earnings
34383
+ roa: { thresholds: [1, 0.75, 0.5, 0], higher_is_better: true, weight: 0.3, label: "Return on Assets", unit: "%" },
34384
+ nim: { thresholds: [3.5, 3, 2.5, 2], higher_is_better: true, weight: 0.25, label: "Net Interest Margin", unit: "%" },
34385
+ efficiency_ratio: { thresholds: [55, 60, 70, 85], higher_is_better: false, weight: 0.25, label: "Efficiency Ratio", unit: "%" },
34386
+ roe: { thresholds: [10, 8, 5, 0], higher_is_better: true, weight: 0.2, label: "Return on Equity", unit: "%" },
34387
+ // Liquidity
34388
+ core_deposit_ratio: { thresholds: [80, 70, 60, 45], higher_is_better: true, weight: 0.3, label: "Core Deposits / Deposits", unit: "%" },
34389
+ loan_to_deposit: { thresholds: [80, 85, 95, 105], higher_is_better: false, weight: 0.25, label: "Loan-to-Deposit Ratio", unit: "%" },
34390
+ brokered_deposit_ratio: { thresholds: [5, 10, 15, 25], higher_is_better: false, weight: 0.25, label: "Brokered Deposits / Deposits", unit: "%" },
34391
+ cash_ratio: { thresholds: [8, 5, 3, 1], higher_is_better: true, weight: 0.2, label: "Cash / Assets", unit: "%" },
34392
+ // Sensitivity
34393
+ nim_4q_change: { thresholds: [0.1, 0, -0.15, -0.3], higher_is_better: true, weight: 0.5, label: "NIM 4Q Change", unit: "pp" },
34394
+ securities_to_assets: { thresholds: [25, 30, 40, 50], higher_is_better: false, weight: 0.5, label: "Securities / Assets", unit: "%" }
34395
+ };
34396
+ var COMPONENT_METRIC_MAP = {
34397
+ C: ["tier1_leverage", "tier1_rbc", "equity_ratio"],
34398
+ A: ["noncurrent_loans_ratio", "net_chargeoff_ratio", "reserve_coverage", "noncurrent_assets_ratio"],
34399
+ E: ["roa", "nim", "efficiency_ratio", "roe"],
34400
+ L: ["core_deposit_ratio", "loan_to_deposit", "brokered_deposit_ratio", "cash_ratio"],
34401
+ S: ["nim_4q_change", "securities_to_assets"]
34402
+ };
34403
+ var RATING_LABELS = {
34404
+ 1: "Strong",
34405
+ 2: "Satisfactory",
34406
+ 3: "Fair",
34407
+ 4: "Marginal",
34408
+ 5: "Unsatisfactory"
34409
+ };
34410
+ var CAMELS_FIELDS = [
34411
+ "CERT",
34412
+ "REPDTE",
34413
+ "ASSET",
34414
+ "EQTOT",
34415
+ "EQV",
34416
+ "IDT1CER",
34417
+ "IDT1RWAJR",
34418
+ "NPERFV",
34419
+ "NCLNLSR",
34420
+ "NTLNLSR",
34421
+ "LNATRESR",
34422
+ "LNRESNCR",
34423
+ "ELNATRY",
34424
+ "ROA",
34425
+ "ROAPTX",
34426
+ "ROE",
34427
+ "NIMY",
34428
+ "EEFFR",
34429
+ "NETINC",
34430
+ "INTINC",
34431
+ "EINTEXP",
34432
+ "NONII",
34433
+ "NONIX",
34434
+ "DEP",
34435
+ "COREDEP",
34436
+ "BROR",
34437
+ "LNLSDEPR",
34438
+ "DEPDASTR",
34439
+ "CHBALR",
34440
+ "SC"
34441
+ ].join(",");
34442
+ function safe(v) {
34443
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
34444
+ }
34445
+ function safeDivide(num, den) {
34446
+ if (num === null || den === null || den === 0) return null;
34447
+ return num / den;
34448
+ }
34449
+ function computeCamelsMetrics(current, prior) {
34450
+ const dep = safe(current.DEP);
34451
+ const asset = safe(current.ASSET);
34452
+ const coredep = safe(current.COREDEP);
34453
+ const sc = safe(current.SC);
34454
+ const intinc = safe(current.INTINC);
34455
+ const eintexp = safe(current.EINTEXP);
34456
+ const nonii = safe(current.NONII);
34457
+ const nii = intinc !== null && eintexp !== null ? intinc - eintexp : null;
34458
+ const totalRevenue = nii !== null && nonii !== null ? nii + nonii : null;
34459
+ const nimNow = safe(current.NIMY);
34460
+ const nim4qAgo = prior && prior.length >= 4 ? safe(prior[3].NIMY) : null;
34461
+ const nim4qChange = nimNow !== null && nim4qAgo !== null ? nimNow - nim4qAgo : null;
34462
+ const coreDepRatio = safeDivide(coredep, dep);
34463
+ const secToAssets = safeDivide(sc, asset);
34464
+ const niiShare = totalRevenue !== null && totalRevenue > 0 && nonii !== null ? nonii / totalRevenue * 100 : null;
34465
+ return {
34466
+ equity_ratio: safe(current.EQV),
34467
+ tier1_leverage: safe(current.IDT1CER),
34468
+ tier1_rbc: safe(current.IDT1RWAJR),
34469
+ noncurrent_assets_ratio: safe(current.NPERFV),
34470
+ noncurrent_loans_ratio: safe(current.NCLNLSR),
34471
+ net_chargeoff_ratio: safe(current.NTLNLSR),
34472
+ reserve_to_loans: safe(current.LNATRESR),
34473
+ reserve_coverage: safe(current.LNRESNCR),
34474
+ provision_ratio: safe(current.ELNATRY),
34475
+ roa: safe(current.ROA),
34476
+ roe: safe(current.ROE),
34477
+ nim: nimNow,
34478
+ efficiency_ratio: safe(current.EEFFR),
34479
+ pretax_roa: safe(current.ROAPTX),
34480
+ noninterest_income_share: niiShare,
34481
+ loan_to_deposit: safe(current.LNLSDEPR),
34482
+ deposits_to_assets: safe(current.DEPDASTR),
34483
+ core_deposit_ratio: coreDepRatio !== null ? coreDepRatio * 100 : null,
34484
+ brokered_deposit_ratio: safe(current.BROR),
34485
+ cash_ratio: safe(current.CHBALR),
34486
+ securities_to_assets: secToAssets !== null ? secToAssets * 100 : null,
34487
+ nim_4q_change: nim4qChange
34488
+ };
34489
+ }
34490
+ function scoreMetric(value, rule) {
34491
+ if (value === null) return 3;
34492
+ const [t1, t2, t3, t4] = rule.thresholds;
34493
+ if (rule.higher_is_better) {
34494
+ if (value >= t1) return 1;
34495
+ if (value >= t2) return 2;
34496
+ if (value >= t3) return 3;
34497
+ if (value >= t4) return 4;
34498
+ return 5;
34499
+ }
34500
+ if (value <= t1) return 1;
34501
+ if (value <= t2) return 2;
34502
+ if (value <= t3) return 3;
34503
+ if (value <= t4) return 4;
34504
+ return 5;
34505
+ }
34506
+ function scoreComponent(component, metrics) {
34507
+ const metricNames = COMPONENT_METRIC_MAP[component];
34508
+ let weightedSum = 0;
34509
+ let totalWeight = 0;
34510
+ const scored = [];
34511
+ const flags = [];
34512
+ for (const metricName of metricNames) {
34513
+ const rule = SCORING_RULES[metricName];
34514
+ const value = metrics[metricName];
34515
+ const rating2 = scoreMetric(value, rule);
34516
+ scored.push({
34517
+ name: metricName,
34518
+ label: rule.label,
34519
+ value,
34520
+ rating: rating2,
34521
+ rating_label: RATING_LABELS[rating2],
34522
+ unit: rule.unit
34523
+ });
34524
+ if (value !== null) {
34525
+ weightedSum += rating2 * rule.weight;
34526
+ totalWeight += rule.weight;
34527
+ }
34528
+ if (rating2 >= 4 && value !== null) {
34529
+ flags.push(`${rule.label} at ${value.toFixed(2)}${rule.unit} rated ${RATING_LABELS[rating2]}`);
34530
+ }
34531
+ }
34532
+ const raw = totalWeight > 0 ? weightedSum / totalWeight : 3;
34533
+ const rating = Math.round(raw);
34534
+ return { component, rating, label: RATING_LABELS[rating], metrics: scored, flags };
34535
+ }
34536
+ var COMPONENT_WEIGHTS = {
34537
+ C: 0.25,
34538
+ A: 0.25,
34539
+ E: 0.2,
34540
+ L: 0.15,
34541
+ S: 0.15
34542
+ };
34543
+ function compositeScore(components) {
34544
+ let sum = 0;
34545
+ const allFlags = [];
34546
+ for (const c of components) {
34547
+ sum += c.rating * (COMPONENT_WEIGHTS[c.component] ?? 0);
34548
+ allFlags.push(...c.flags);
34549
+ }
34550
+ const rating = Math.round(sum);
34551
+ return { rating, label: RATING_LABELS[rating], components, flags: allFlags };
34552
+ }
34553
+ function analyzeTrend(metricName, timeseries, higherIsBetter) {
34554
+ const label = SCORING_RULES[metricName]?.label ?? metricName;
34555
+ const valid = timeseries.filter(
34556
+ (t) => t.value !== null
34557
+ );
34558
+ if (valid.length < 2) {
34559
+ return {
34560
+ metric: metricName,
34561
+ label,
34562
+ values: valid,
34563
+ direction: "stable",
34564
+ magnitude: "minimal",
34565
+ quarters_analyzed: valid.length
34566
+ };
34567
+ }
34568
+ const n = valid.length;
34569
+ const xMean = (n - 1) / 2;
34570
+ const yMean = valid.reduce((s, v) => s + v.value, 0) / n;
34571
+ let num = 0;
34572
+ let den = 0;
34573
+ for (let i = 0; i < n; i++) {
34574
+ num += (i - xMean) * (valid[i].value - yMean);
34575
+ den += (i - xMean) ** 2;
34576
+ }
34577
+ const slope = den !== 0 ? num / den : 0;
34578
+ const relSlope = yMean !== 0 ? slope / Math.abs(yMean) : 0;
34579
+ const direction = higherIsBetter && relSlope > 0.02 || !higherIsBetter && relSlope < -0.02 ? "improving" : higherIsBetter && relSlope < -0.02 || !higherIsBetter && relSlope > 0.02 ? "deteriorating" : "stable";
34580
+ const absMag = Math.abs(relSlope);
34581
+ const magnitude = absMag > 0.1 ? "significant" : absMag > 0.03 ? "moderate" : "minimal";
34582
+ return {
34583
+ metric: metricName,
34584
+ label,
34585
+ values: valid,
34586
+ direction,
34587
+ magnitude,
34588
+ quarters_analyzed: n
34589
+ };
34590
+ }
34591
+ function isStale(repdte) {
34592
+ const year = Number.parseInt(repdte.slice(0, 4), 10);
34593
+ const month = Number.parseInt(repdte.slice(4, 6), 10);
34594
+ const day = Number.parseInt(repdte.slice(6, 8), 10);
34595
+ const reportDate = new Date(Date.UTC(year, month - 1, day));
34596
+ if (Number.isNaN(reportDate.getTime())) return true;
34597
+ const daysSince = (Date.now() - reportDate.getTime()) / 864e5;
34598
+ return daysSince > 120;
34599
+ }
34600
+ function formatRating(rating) {
34601
+ return `${rating} - ${RATING_LABELS[rating]}`;
34602
+ }
34603
+
34604
+ // src/tools/bankHealth.ts
34605
+ var COMPONENT_NAMES = {
34606
+ C: "Capital Adequacy",
34607
+ A: "Asset Quality",
34608
+ E: "Earnings",
34609
+ L: "Liquidity",
34610
+ S: "Sensitivity to Market Risk"
34611
+ };
34612
+ var TREND_METRICS = [
34613
+ { key: "tier1_leverage", fdic_field: "IDT1CER", higher_is_better: true },
34614
+ { key: "noncurrent_loans_ratio", fdic_field: "NCLNLSR", higher_is_better: false },
34615
+ { key: "roa", fdic_field: "ROA", higher_is_better: true },
34616
+ { key: "nim", fdic_field: "NIMY", higher_is_better: true },
34617
+ { key: "efficiency_ratio", fdic_field: "EEFFR", higher_is_better: false },
34618
+ { key: "loan_to_deposit", fdic_field: "LNLSDEPR", higher_is_better: false }
34619
+ ];
34620
+ function formatHealthSummaryText(summary) {
34621
+ const parts = [];
34622
+ const { institution: inst, composite } = summary;
34623
+ parts.push(`CAMELS-Style Health Assessment: ${inst.name} (CERT ${inst.cert})`);
34624
+ parts.push(`${inst.city}, ${inst.state} | Charter: ${inst.charter_class} | Assets: $${Math.round(inst.total_assets).toLocaleString()}k`);
34625
+ parts.push(`Report Date: ${inst.report_date} | Data: ${inst.data_staleness}`);
34626
+ parts.push("");
34627
+ parts.push("NOTE: This is an analytical assessment based on public financial data, not an official regulatory CAMELS rating.");
34628
+ parts.push("");
34629
+ parts.push(`Composite Rating: ${formatRating(composite.rating)}`);
34630
+ parts.push("");
34631
+ for (const comp of summary.components) {
34632
+ const name = COMPONENT_NAMES[comp.component] ?? comp.component;
34633
+ parts.push(`${name} (${comp.component}): ${formatRating(comp.rating)}`);
34634
+ for (const m of comp.metrics) {
34635
+ const val = m.value !== null ? `${m.value.toFixed(2)}${m.unit}` : "n/a";
34636
+ parts.push(` ${m.label.padEnd(30)} ${val.padStart(10)} ${m.rating_label}`);
34637
+ }
34638
+ if (comp.flags.length > 0) {
34639
+ for (const flag of comp.flags) {
34640
+ parts.push(` \u26A0 ${flag}`);
34641
+ }
34642
+ }
34643
+ parts.push("");
34644
+ }
34645
+ if (summary.trends.length > 0) {
34646
+ parts.push("Trend Analysis:");
34647
+ for (const t of summary.trends) {
34648
+ if (t.direction !== "stable") {
34649
+ parts.push(` ${t.label}: ${t.direction} (${t.magnitude}, ${t.quarters_analyzed}Q analyzed)`);
34650
+ }
34651
+ }
34652
+ parts.push("");
34653
+ }
34654
+ if (summary.risk_signals.length > 0) {
34655
+ parts.push("Risk Signals:");
34656
+ for (const signal of summary.risk_signals) {
34657
+ parts.push(` \u2022 ${signal}`);
34658
+ }
34659
+ parts.push("");
34660
+ }
34661
+ return parts.join("\n");
34662
+ }
34663
+ function getPriorQuarterDates(repdte, count) {
34664
+ const dates = [];
34665
+ const suffixes = ["0331", "0630", "0930", "1231"];
34666
+ let year = Number.parseInt(repdte.slice(0, 4), 10);
34667
+ let qIdx = suffixes.indexOf(repdte.slice(4));
34668
+ if (qIdx === -1) return dates;
34669
+ for (let i = 0; i < count; i++) {
34670
+ qIdx--;
34671
+ if (qIdx < 0) {
34672
+ qIdx = 3;
34673
+ year--;
34674
+ }
34675
+ dates.push(`${year}${suffixes[qIdx]}`);
34676
+ }
34677
+ return dates;
34678
+ }
34679
+ function collectRiskSignals(metrics, components, trends) {
34680
+ const signals = [];
34681
+ if (metrics.tier1_leverage !== null && metrics.tier1_leverage < 5) {
34682
+ signals.push(`Tier 1 leverage ratio at ${metrics.tier1_leverage.toFixed(2)}% \u2014 below well-capitalized threshold (5%)`);
34683
+ }
34684
+ if (metrics.roa !== null && metrics.roa < 0) {
34685
+ signals.push(`Operating losses: ROA at ${metrics.roa.toFixed(2)}%`);
34686
+ }
34687
+ if (metrics.reserve_coverage !== null && metrics.reserve_coverage < 50) {
34688
+ signals.push(`Reserve coverage critically low at ${metrics.reserve_coverage.toFixed(1)}%`);
34689
+ }
34690
+ if (metrics.brokered_deposit_ratio !== null && metrics.brokered_deposit_ratio > 15) {
34691
+ signals.push(`High brokered deposit reliance: ${metrics.brokered_deposit_ratio.toFixed(1)}%`);
34692
+ }
34693
+ if (metrics.noncurrent_loans_ratio !== null && metrics.noncurrent_loans_ratio > 3) {
34694
+ signals.push(`Elevated noncurrent loans: ${metrics.noncurrent_loans_ratio.toFixed(2)}%`);
34695
+ }
34696
+ for (const comp of components) {
34697
+ if (comp.rating >= 4) {
34698
+ const name = COMPONENT_NAMES[comp.component] ?? comp.component;
34699
+ signals.push(`${name} component rated ${comp.rating} (${comp.label})`);
34700
+ }
34701
+ }
34702
+ for (const t of trends) {
34703
+ if (t.direction === "deteriorating" && t.magnitude === "significant") {
34704
+ signals.push(`${t.label} deteriorating significantly over ${t.quarters_analyzed} quarters`);
34705
+ }
34706
+ }
34707
+ return signals;
34708
+ }
34709
+ var BankHealthInputSchema = import_zod9.z.object({
34710
+ cert: import_zod9.z.number().int().positive().describe("FDIC Certificate Number of the institution to analyze."),
34711
+ repdte: import_zod9.z.string().regex(/^\d{8}$/).optional().describe(
34712
+ "Report Date (YYYYMMDD). Defaults to the most recent quarter likely to have published data."
34713
+ ),
34714
+ quarters: import_zod9.z.number().int().min(1).max(20).default(8).describe("Number of prior quarters to fetch for trend analysis (default 8).")
34715
+ });
34716
+ function registerBankHealthTools(server) {
34717
+ server.registerTool(
34718
+ "fdic_analyze_bank_health",
34719
+ {
34720
+ title: "Analyze Bank Health (CAMELS-Style)",
34721
+ description: `Produce a CAMELS-style analytical assessment for a single FDIC-insured institution.
34722
+
34723
+ Scores five components \u2014 Capital (C), Asset Quality (A), Earnings (E), Liquidity (L), Sensitivity (S) \u2014 using published FDIC financial data and derives a weighted composite rating (1=Strong to 5=Unsatisfactory).
34724
+
34725
+ Output includes:
34726
+ - Composite and component ratings with individual metric scores
34727
+ - Trend analysis across prior quarters for key metrics
34728
+ - Risk signals flagging critical and warning-level concerns
34729
+ - Structured JSON for programmatic consumption
34730
+
34731
+ NOTE: Management (M) is omitted \u2014 cannot be assessed from public data. Sensitivity (S) uses proxy metrics (NIM trend, securities concentration). This is an analytical tool, not an official regulatory rating.`,
34732
+ inputSchema: BankHealthInputSchema,
34733
+ annotations: {
34734
+ readOnlyHint: true,
34735
+ destructiveHint: false,
34736
+ idempotentHint: true,
34737
+ openWorldHint: true
34738
+ }
34739
+ },
34740
+ async (rawParams, extra) => {
34741
+ const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
34742
+ const controller = new AbortController();
34743
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
34744
+ const progressToken = extra._meta?.progressToken;
34745
+ try {
34746
+ const dateError = validateQuarterEndDate(params.repdte, "repdte");
34747
+ if (dateError) {
34748
+ return formatToolError(new Error(dateError));
34749
+ }
34750
+ await sendProgressNotification(server.server, progressToken, 0.1, "Fetching institution profile");
34751
+ const [profileResponse, financialsResponse] = await Promise.all([
34752
+ queryEndpoint(
34753
+ ENDPOINTS.INSTITUTIONS,
34754
+ {
34755
+ filters: `CERT:${params.cert}`,
34756
+ fields: "CERT,NAME,CITY,STALP,BKCLASS,ASSET,ACTIVE",
34757
+ limit: 1
34758
+ },
34759
+ { signal: controller.signal }
34760
+ ),
34761
+ queryEndpoint(
34762
+ ENDPOINTS.FINANCIALS,
34763
+ {
34764
+ filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
34765
+ fields: CAMELS_FIELDS,
34766
+ limit: 1
34767
+ },
34768
+ { signal: controller.signal }
34769
+ )
34770
+ ]);
34771
+ const profileRecords = extractRecords(profileResponse);
34772
+ if (profileRecords.length === 0) {
34773
+ return formatToolError(new Error(`No institution found with CERT number ${params.cert}.`));
34774
+ }
34775
+ const profile = profileRecords[0];
34776
+ const financialRecords = extractRecords(financialsResponse);
34777
+ if (financialRecords.length === 0) {
34778
+ return formatToolError(
34779
+ new Error(
34780
+ `No financial data for CERT ${params.cert} at report date ${params.repdte}. Try an earlier quarter-end date (0331, 0630, 0930, 1231).`
34781
+ )
34782
+ );
34783
+ }
34784
+ const currentFinancials = financialRecords[0];
34785
+ await sendProgressNotification(server.server, progressToken, 0.3, "Fetching prior quarters for trend analysis");
34786
+ const priorDates = getPriorQuarterDates(params.repdte, params.quarters);
34787
+ let priorQuarters = [];
34788
+ if (priorDates.length > 0) {
34789
+ const dateFilter = priorDates.map((d) => `REPDTE:${d}`).join(" OR ");
34790
+ const priorResponse = await queryEndpoint(
34791
+ ENDPOINTS.FINANCIALS,
34792
+ {
34793
+ filters: `CERT:${params.cert} AND (${dateFilter})`,
34794
+ fields: CAMELS_FIELDS,
34795
+ sort_by: "REPDTE",
34796
+ sort_order: "DESC",
34797
+ limit: params.quarters
34798
+ },
34799
+ { signal: controller.signal }
34800
+ );
34801
+ priorQuarters = extractRecords(priorResponse);
34802
+ }
34803
+ await sendProgressNotification(server.server, progressToken, 0.6, "Computing CAMELS scores");
34804
+ const metrics = computeCamelsMetrics(currentFinancials, priorQuarters);
34805
+ const components = ["C", "A", "E", "L", "S"].map(
34806
+ (c) => scoreComponent(c, metrics)
34807
+ );
34808
+ const composite = compositeScore(components);
34809
+ await sendProgressNotification(server.server, progressToken, 0.8, "Analyzing trends");
34810
+ const allQuarters = [currentFinancials, ...priorQuarters];
34811
+ const trends = [];
34812
+ for (const tm of TREND_METRICS) {
34813
+ const timeseries = allQuarters.map((q) => ({
34814
+ repdte: String(q.REPDTE ?? ""),
34815
+ value: typeof q[tm.fdic_field] === "number" ? q[tm.fdic_field] : null
34816
+ }));
34817
+ timeseries.reverse();
34818
+ trends.push(analyzeTrend(String(tm.key), timeseries, tm.higher_is_better));
34819
+ }
34820
+ const riskSignals = collectRiskSignals(metrics, components, trends);
34821
+ const staleness = isStale(params.repdte) ? "stale (>120 days old)" : "current";
34822
+ const summary = {
34823
+ institution: {
34824
+ cert: params.cert,
34825
+ name: String(profile.NAME ?? ""),
34826
+ city: String(profile.CITY ?? ""),
34827
+ state: String(profile.STALP ?? ""),
34828
+ charter_class: String(profile.BKCLASS ?? ""),
34829
+ total_assets: typeof currentFinancials.ASSET === "number" ? currentFinancials.ASSET : 0,
34830
+ report_date: params.repdte,
34831
+ data_staleness: staleness
34832
+ },
34833
+ composite: { rating: composite.rating, label: composite.label },
34834
+ components,
34835
+ trends,
34836
+ outliers: [],
34837
+ risk_signals: riskSignals
34838
+ };
34839
+ const text = truncateIfNeeded(
34840
+ formatHealthSummaryText(summary),
34841
+ CHARACTER_LIMIT
34842
+ );
34843
+ return {
34844
+ content: [{ type: "text", text }],
34845
+ structuredContent: summary
34846
+ };
34847
+ } catch (err) {
34848
+ return formatToolError(err);
34849
+ } finally {
34850
+ clearTimeout(timeoutId);
34851
+ }
34852
+ }
34853
+ );
34854
+ }
34855
+
34856
+ // src/tools/peerHealth.ts
34857
+ var import_zod10 = require("zod");
34858
+ var PeerHealthInputSchema = import_zod10.z.object({
34859
+ cert: import_zod10.z.number().int().positive().optional().describe("Subject institution CERT to highlight in the ranking. Optional."),
34860
+ certs: import_zod10.z.array(import_zod10.z.number().int().positive()).max(50).optional().describe("Explicit list of CERTs to compare (max 50)."),
34861
+ state: import_zod10.z.string().regex(/^[A-Z]{2}$/).optional().describe('Two-letter state code to select all active institutions (e.g., "WY").'),
34862
+ asset_min: import_zod10.z.number().positive().optional().describe("Minimum total assets ($thousands) for peer selection."),
34863
+ asset_max: import_zod10.z.number().positive().optional().describe("Maximum total assets ($thousands) for peer selection."),
34864
+ repdte: import_zod10.z.string().regex(/^\d{8}$/).optional().describe("Report Date (YYYYMMDD). Defaults to the most recent quarter."),
34865
+ sort_by: import_zod10.z.enum(["composite", "capital", "asset_quality", "earnings", "liquidity", "sensitivity"]).default("composite").describe("Sort results by composite or a specific CAMELS component rating."),
34866
+ limit: import_zod10.z.number().int().min(1).max(100).default(25).describe("Max institutions to return in the response.")
34867
+ });
34868
+ function sortKeyToComponent(key) {
34869
+ const map = {
34870
+ capital: "C",
34871
+ asset_quality: "A",
34872
+ earnings: "E",
34873
+ liquidity: "L",
34874
+ sensitivity: "S"
34875
+ };
34876
+ return map[key] ?? null;
34877
+ }
34878
+ function registerPeerHealthTools(server) {
34879
+ server.registerTool(
34880
+ "fdic_compare_peer_health",
34881
+ {
34882
+ title: "Compare Peer Health (CAMELS Rankings)",
34883
+ description: `Compare CAMELS-style health scores across a group of FDIC-insured institutions.
34884
+
34885
+ Three usage modes:
34886
+ - Explicit list: provide certs (up to 50) for a specific comparison set
34887
+ - State-wide scan: provide state to compare all active institutions in that state
34888
+ - Asset-based: provide asset_min/asset_max to compare institutions by size
34889
+
34890
+ Optionally provide cert to highlight a subject institution's position in the ranking.
34891
+
34892
+ Output: Ranked list of institutions with CAMELS composite and component scores, sorted by composite or any individual component.
34893
+
34894
+ NOTE: This is an analytical assessment, not official regulatory ratings.`,
34895
+ inputSchema: PeerHealthInputSchema,
34896
+ annotations: {
34897
+ readOnlyHint: true,
34898
+ destructiveHint: false,
34899
+ idempotentHint: true,
34900
+ openWorldHint: true
34901
+ }
34902
+ },
34903
+ async (rawParams, extra) => {
34904
+ const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
34905
+ const controller = new AbortController();
34906
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
34907
+ const progressToken = extra._meta?.progressToken;
34908
+ try {
34909
+ if (!params.certs && !params.state && params.asset_min === void 0 && params.asset_max === void 0 && !params.cert) {
34910
+ return formatToolError(new Error("At least one selection criteria required: certs, state, asset_min/asset_max, or cert."));
34911
+ }
34912
+ const dateError = validateQuarterEndDate(params.repdte, "repdte");
34913
+ if (dateError) {
34914
+ return formatToolError(new Error(dateError));
34915
+ }
34916
+ await sendProgressNotification(server.server, progressToken, 0.1, "Building peer roster");
34917
+ let peerCerts;
34918
+ if (params.certs) {
34919
+ peerCerts = [...params.certs];
34920
+ if (params.cert && !peerCerts.includes(params.cert)) {
34921
+ peerCerts.push(params.cert);
34922
+ }
34923
+ } else {
34924
+ const filterParts = ["ACTIVE:1"];
34925
+ if (params.state) filterParts.push(`STALP:${params.state}`);
34926
+ if (params.asset_min !== void 0 || params.asset_max !== void 0) {
34927
+ const min = params.asset_min ?? 0;
34928
+ const max = params.asset_max ?? "*";
34929
+ filterParts.push(`ASSET:[${min} TO ${max}]`);
34930
+ }
34931
+ if (params.cert && !params.state && params.asset_min === void 0) {
34932
+ const profileResp = await queryEndpoint(
34933
+ ENDPOINTS.INSTITUTIONS,
34934
+ {
34935
+ filters: `CERT:${params.cert}`,
34936
+ fields: "CERT,ASSET,STALP,BKCLASS",
34937
+ limit: 1
34938
+ },
34939
+ { signal: controller.signal }
34940
+ );
34941
+ const profileRecs = extractRecords(profileResp);
34942
+ if (profileRecs.length === 0) {
34943
+ return formatToolError(new Error(`No institution found with CERT ${params.cert}.`));
34944
+ }
34945
+ const subjectAsset = asNumber(profileRecs[0].ASSET);
34946
+ if (subjectAsset !== null) {
34947
+ filterParts.push(`ASSET:[${subjectAsset * 0.5} TO ${subjectAsset * 2}]`);
34948
+ }
34949
+ const bkclass = profileRecs[0].BKCLASS;
34950
+ if (typeof bkclass === "string") {
34951
+ filterParts.push(`BKCLASS:${bkclass}`);
34952
+ }
34953
+ }
34954
+ const rosterResp = await queryEndpoint(
34955
+ ENDPOINTS.INSTITUTIONS,
34956
+ {
34957
+ filters: filterParts.join(" AND "),
34958
+ fields: "CERT",
34959
+ limit: 1e4,
34960
+ sort_by: "CERT",
34961
+ sort_order: "ASC"
34962
+ },
34963
+ { signal: controller.signal }
34964
+ );
34965
+ peerCerts = extractRecords(rosterResp).map((r) => asNumber(r.CERT)).filter((c) => c !== null);
34966
+ }
34967
+ if (peerCerts.length === 0) {
34968
+ return formatToolError(new Error("No institutions matched the specified criteria."));
34969
+ }
34970
+ await sendProgressNotification(server.server, progressToken, 0.4, `Fetching financials for ${peerCerts.length} institutions`);
34971
+ const certFilters = buildCertFilters(peerCerts);
34972
+ const financialResponses = await mapWithConcurrency(
34973
+ certFilters,
34974
+ MAX_CONCURRENCY,
34975
+ async (certFilter) => queryEndpoint(
34976
+ ENDPOINTS.FINANCIALS,
34977
+ {
34978
+ filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
34979
+ fields: CAMELS_FIELDS,
34980
+ limit: 1e4,
34981
+ sort_by: "CERT",
34982
+ sort_order: "ASC"
34983
+ },
34984
+ { signal: controller.signal }
34985
+ )
34986
+ );
34987
+ const allFinancials = financialResponses.flatMap(extractRecords);
34988
+ const rosterResp2 = await queryEndpoint(
34989
+ ENDPOINTS.INSTITUTIONS,
34990
+ {
34991
+ filters: peerCerts.length <= 25 ? peerCerts.map((c) => `CERT:${c}`).join(" OR ") : `CERT:[${Math.min(...peerCerts)} TO ${Math.max(...peerCerts)}]`,
34992
+ fields: "CERT,NAME,CITY,STALP",
34993
+ limit: 1e4,
34994
+ sort_by: "CERT",
34995
+ sort_order: "ASC"
34996
+ },
34997
+ { signal: controller.signal }
34998
+ );
34999
+ const profileMap = /* @__PURE__ */ new Map();
35000
+ for (const r of extractRecords(rosterResp2)) {
35001
+ const c = asNumber(r.CERT);
35002
+ if (c !== null) profileMap.set(c, r);
35003
+ }
35004
+ await sendProgressNotification(server.server, progressToken, 0.7, "Computing CAMELS scores");
35005
+ const entries = [];
35006
+ for (const fin of allFinancials) {
35007
+ const cert = asNumber(fin.CERT);
35008
+ if (cert === null) continue;
35009
+ const metrics = computeCamelsMetrics(fin);
35010
+ const components = ["C", "A", "E", "L", "S"].map(
35011
+ (c) => scoreComponent(c, metrics)
35012
+ );
35013
+ const comp = compositeScore(components);
35014
+ const profile = profileMap.get(cert);
35015
+ entries.push({
35016
+ cert,
35017
+ name: String(profile?.NAME ?? `CERT ${cert}`),
35018
+ city: profile?.CITY ? String(profile.CITY) : null,
35019
+ state: profile?.STALP ? String(profile.STALP) : null,
35020
+ total_assets: asNumber(fin.ASSET),
35021
+ composite_rating: comp.rating,
35022
+ composite_label: comp.label,
35023
+ component_ratings: Object.fromEntries(components.map((c) => [c.component, c.rating])),
35024
+ flags: comp.flags
35025
+ });
35026
+ }
35027
+ const sortComponent = sortKeyToComponent(params.sort_by);
35028
+ entries.sort((a, b) => {
35029
+ const aVal = sortComponent ? a.component_ratings[sortComponent] ?? 3 : a.composite_rating;
35030
+ const bVal = sortComponent ? b.component_ratings[sortComponent] ?? 3 : b.composite_rating;
35031
+ if (aVal !== bVal) return aVal - bVal;
35032
+ return (b.total_assets ?? 0) - (a.total_assets ?? 0);
35033
+ });
35034
+ const subjectRank = params.cert ? entries.findIndex((e) => e.cert === params.cert) + 1 : null;
35035
+ const returned = entries.slice(0, params.limit);
35036
+ await sendProgressNotification(server.server, progressToken, 0.9, "Formatting results");
35037
+ const parts = [];
35038
+ parts.push(`CAMELS Peer Health Comparison \u2014 ${entries.length} institutions ranked by ${params.sort_by}`);
35039
+ parts.push(`Report Date: ${params.repdte}`);
35040
+ parts.push("NOTE: Analytical assessment, not official regulatory ratings.");
35041
+ parts.push("");
35042
+ if (subjectRank && subjectRank > 0 && params.cert) {
35043
+ const subj = entries[subjectRank - 1];
35044
+ parts.push(`Subject: ${subj.name} (CERT ${subj.cert}) \u2014 Rank ${subjectRank} of ${entries.length}, Composite: ${formatRating(subj.composite_rating)}`);
35045
+ parts.push("");
35046
+ }
35047
+ for (let i = 0; i < returned.length; i++) {
35048
+ const e = returned[i];
35049
+ const rank = entries.indexOf(e) + 1;
35050
+ const marker = params.cert && e.cert === params.cert ? " \u25C4 SUBJECT" : "";
35051
+ const location = [e.city, e.state].filter(Boolean).join(", ");
35052
+ const compStr = ["C", "A", "E", "L", "S"].map((c) => `${c}:${e.component_ratings[c] ?? "?"}`).join(" ");
35053
+ const assetStr = e.total_assets !== null ? `$${Math.round(e.total_assets).toLocaleString()}k` : "n/a";
35054
+ parts.push(
35055
+ `${String(rank).padStart(3)}. ${e.name} (${location}) CERT ${e.cert}${marker}`
35056
+ );
35057
+ parts.push(
35058
+ ` Composite: ${formatRating(e.composite_rating)} | ${compStr} | Assets: ${assetStr}`
35059
+ );
35060
+ if (e.flags.length > 0) {
35061
+ parts.push(` Flags: ${e.flags.join("; ")}`);
35062
+ }
35063
+ }
35064
+ if (entries.length > returned.length) {
35065
+ parts.push("");
35066
+ parts.push(`Showing ${returned.length} of ${entries.length}. Increase limit to see more.`);
35067
+ }
35068
+ const text = truncateIfNeeded(parts.join("\n"), CHARACTER_LIMIT);
35069
+ return {
35070
+ content: [{ type: "text", text }],
35071
+ structuredContent: {
35072
+ report_date: params.repdte,
35073
+ sort_by: params.sort_by,
35074
+ total_institutions: entries.length,
35075
+ returned_count: returned.length,
35076
+ subject_cert: params.cert ?? null,
35077
+ subject_rank: subjectRank,
35078
+ institutions: returned
35079
+ }
35080
+ };
35081
+ } catch (err) {
35082
+ return formatToolError(err);
35083
+ } finally {
35084
+ clearTimeout(timeoutId);
35085
+ }
35086
+ }
35087
+ );
35088
+ }
35089
+
35090
+ // src/tools/riskSignals.ts
35091
+ var import_zod11 = require("zod");
35092
+ var TREND_METRICS2 = [
35093
+ { key: "tier1_leverage", fdic_field: "IDT1CER", higher_is_better: true, category: "capital" },
35094
+ { key: "noncurrent_loans_ratio", fdic_field: "NCLNLSR", higher_is_better: false, category: "asset_quality" },
35095
+ { key: "roa", fdic_field: "ROA", higher_is_better: true, category: "earnings" },
35096
+ { key: "nim", fdic_field: "NIMY", higher_is_better: true, category: "earnings" },
35097
+ { key: "efficiency_ratio", fdic_field: "EEFFR", higher_is_better: false, category: "earnings" },
35098
+ { key: "loan_to_deposit", fdic_field: "LNLSDEPR", higher_is_better: false, category: "liquidity" }
35099
+ ];
35100
+ function classifyRiskSignals(metrics, trends) {
35101
+ const signals = [];
35102
+ if (metrics.tier1_leverage !== null && metrics.tier1_leverage < 5) {
35103
+ signals.push({ severity: "critical", category: "capital", message: `Tier 1 leverage at ${metrics.tier1_leverage.toFixed(2)}% \u2014 below well-capitalized threshold (5%)` });
35104
+ }
35105
+ if (metrics.tier1_rbc !== null && metrics.tier1_rbc < 6) {
35106
+ signals.push({ severity: "critical", category: "capital", message: `Tier 1 risk-based capital at ${metrics.tier1_rbc.toFixed(2)}% \u2014 below well-capitalized threshold (6%)` });
35107
+ }
35108
+ if (metrics.roa !== null && metrics.roa < 0) {
35109
+ signals.push({ severity: "critical", category: "earnings", message: `Operating losses: ROA at ${metrics.roa.toFixed(2)}%` });
35110
+ }
35111
+ if (metrics.reserve_coverage !== null && metrics.reserve_coverage < 50) {
35112
+ signals.push({ severity: "critical", category: "asset_quality", message: `Reserve coverage critically low at ${metrics.reserve_coverage.toFixed(1)}%` });
35113
+ }
35114
+ if (metrics.noncurrent_loans_ratio !== null && metrics.noncurrent_loans_ratio > 3) {
35115
+ signals.push({ severity: "warning", category: "asset_quality", message: `Noncurrent loans elevated at ${metrics.noncurrent_loans_ratio.toFixed(2)}%` });
35116
+ }
35117
+ if (metrics.brokered_deposit_ratio !== null && metrics.brokered_deposit_ratio > 15) {
35118
+ signals.push({ severity: "warning", category: "liquidity", message: `High brokered deposit reliance: ${metrics.brokered_deposit_ratio.toFixed(1)}%` });
35119
+ }
35120
+ if (metrics.nim_4q_change !== null && metrics.nim_4q_change < -0.3) {
35121
+ signals.push({ severity: "warning", category: "sensitivity", message: `NIM compressed ${Math.abs(metrics.nim_4q_change).toFixed(2)}pp over 4 quarters` });
35122
+ }
35123
+ const components = ["C", "A", "E", "L", "S"].map(
35124
+ (c) => scoreComponent(c, metrics)
35125
+ );
35126
+ const componentCategoryMap = {
35127
+ C: "capital",
35128
+ A: "asset_quality",
35129
+ E: "earnings",
35130
+ L: "liquidity",
35131
+ S: "sensitivity"
35132
+ };
35133
+ const componentNames = {
35134
+ C: "Capital",
35135
+ A: "Asset Quality",
35136
+ E: "Earnings",
35137
+ L: "Liquidity",
35138
+ S: "Sensitivity"
35139
+ };
35140
+ for (const comp of components) {
35141
+ if (comp.rating >= 4) {
35142
+ signals.push({
35143
+ severity: "warning",
35144
+ category: componentCategoryMap[comp.component],
35145
+ message: `${componentNames[comp.component]} component rated ${formatRating(comp.rating)}`
35146
+ });
35147
+ }
35148
+ }
35149
+ const trendCategoryMap = {
35150
+ tier1_leverage: "capital",
35151
+ noncurrent_loans_ratio: "asset_quality",
35152
+ roa: "earnings",
35153
+ nim: "earnings",
35154
+ efficiency_ratio: "earnings",
35155
+ loan_to_deposit: "liquidity"
35156
+ };
35157
+ for (const t of trends) {
35158
+ if (t.direction === "deteriorating") {
35159
+ const cat = trendCategoryMap[t.metric] ?? "trend";
35160
+ if (t.magnitude === "significant") {
35161
+ signals.push({ severity: "warning", category: cat, message: `${t.label} deteriorating significantly over ${t.quarters_analyzed} quarters` });
35162
+ } else if (t.magnitude === "moderate") {
35163
+ signals.push({ severity: "info", category: cat, message: `${t.label} deteriorating moderately over ${t.quarters_analyzed} quarters` });
35164
+ }
35165
+ }
35166
+ }
35167
+ return signals;
35168
+ }
35169
+ function getPriorQuarterDates2(repdte, count) {
35170
+ const dates = [];
35171
+ const suffixes = ["0331", "0630", "0930", "1231"];
35172
+ let year = Number.parseInt(repdte.slice(0, 4), 10);
35173
+ let qIdx = suffixes.indexOf(repdte.slice(4));
35174
+ if (qIdx === -1) return dates;
35175
+ for (let i = 0; i < count; i++) {
35176
+ qIdx--;
35177
+ if (qIdx < 0) {
35178
+ qIdx = 3;
35179
+ year--;
35180
+ }
35181
+ dates.push(`${year}${suffixes[qIdx]}`);
35182
+ }
35183
+ return dates;
35184
+ }
35185
+ var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
35186
+ var RiskSignalsInputSchema = import_zod11.z.object({
35187
+ state: import_zod11.z.string().regex(/^[A-Z]{2}$/).optional().describe("Scan all active institutions in this state."),
35188
+ certs: import_zod11.z.array(import_zod11.z.number().int().positive()).max(50).optional().describe("Specific CERTs to scan (max 50)."),
35189
+ asset_min: import_zod11.z.number().positive().optional().describe("Minimum total assets ($thousands) filter."),
35190
+ asset_max: import_zod11.z.number().positive().optional().describe("Maximum total assets ($thousands) filter."),
35191
+ repdte: import_zod11.z.string().regex(/^\d{8}$/).optional().describe("Report Date (YYYYMMDD). Defaults to the most recent quarter."),
35192
+ min_severity: import_zod11.z.enum(["info", "warning", "critical"]).default("warning").describe("Minimum severity level to include in results (default: warning)."),
35193
+ quarters: import_zod11.z.number().int().min(1).max(12).default(4).describe("Prior quarters to fetch for trend analysis (default 4)."),
35194
+ limit: import_zod11.z.number().int().min(1).max(100).default(25).describe("Max flagged institutions to return.")
35195
+ });
35196
+ function registerRiskSignalTools(server) {
35197
+ server.registerTool(
35198
+ "fdic_detect_risk_signals",
35199
+ {
35200
+ title: "Detect Risk Signals (Early Warning)",
35201
+ description: `Scan FDIC-insured institutions for early warning risk signals using CAMELS-style analysis.
35202
+
35203
+ Scans institutions for:
35204
+ - Critical: undercapitalized (Tier 1 < 5%), operating losses (ROA < 0), reserve coverage < 50%
35205
+ - Warning: CAMELS component rated 4+, significant deteriorating trends, brokered deposits > 15%, noncurrent loans > 3%
35206
+ - Info: moderate deteriorating trends
35207
+
35208
+ Three scan modes:
35209
+ - State-wide: provide state to scan all active institutions
35210
+ - Explicit list: provide certs (up to 50)
35211
+ - Asset-based: provide asset_min/asset_max
35212
+
35213
+ Output: Ranked list of flagged institutions sorted by signal severity count.
35214
+
35215
+ NOTE: Analytical screening tool, not official supervisory ratings.`,
35216
+ inputSchema: RiskSignalsInputSchema,
35217
+ annotations: {
35218
+ readOnlyHint: true,
35219
+ destructiveHint: false,
35220
+ idempotentHint: true,
35221
+ openWorldHint: true
35222
+ }
35223
+ },
35224
+ async (rawParams, extra) => {
35225
+ const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
35226
+ const controller = new AbortController();
35227
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
35228
+ const progressToken = extra._meta?.progressToken;
35229
+ try {
35230
+ if (!params.certs && !params.state && params.asset_min === void 0 && params.asset_max === void 0) {
35231
+ return formatToolError(new Error("At least one selection criteria required: certs, state, or asset_min/asset_max."));
35232
+ }
35233
+ const dateError = validateQuarterEndDate(params.repdte, "repdte");
35234
+ if (dateError) {
35235
+ return formatToolError(new Error(dateError));
35236
+ }
35237
+ await sendProgressNotification(server.server, progressToken, 0.1, "Building institution roster");
35238
+ let targetCerts;
35239
+ if (params.certs) {
35240
+ targetCerts = [...params.certs];
35241
+ } else {
35242
+ const filterParts = ["ACTIVE:1"];
35243
+ if (params.state) filterParts.push(`STALP:${params.state}`);
35244
+ if (params.asset_min !== void 0 || params.asset_max !== void 0) {
35245
+ const min = params.asset_min ?? 0;
35246
+ const max = params.asset_max ?? "*";
35247
+ filterParts.push(`ASSET:[${min} TO ${max}]`);
35248
+ }
35249
+ const rosterResp = await queryEndpoint(
35250
+ ENDPOINTS.INSTITUTIONS,
35251
+ {
35252
+ filters: filterParts.join(" AND "),
35253
+ fields: "CERT",
35254
+ limit: 1e4,
35255
+ sort_by: "CERT",
35256
+ sort_order: "ASC"
35257
+ },
35258
+ { signal: controller.signal }
35259
+ );
35260
+ targetCerts = extractRecords(rosterResp).map((r) => asNumber(r.CERT)).filter((c) => c !== null);
35261
+ }
35262
+ if (targetCerts.length === 0) {
35263
+ return formatToolError(new Error("No institutions matched the specified criteria."));
35264
+ }
35265
+ await sendProgressNotification(server.server, progressToken, 0.3, `Fetching financials for ${targetCerts.length} institutions`);
35266
+ const certFilters = buildCertFilters(targetCerts);
35267
+ const currentResponses = await mapWithConcurrency(
35268
+ certFilters,
35269
+ MAX_CONCURRENCY,
35270
+ async (certFilter) => queryEndpoint(
35271
+ ENDPOINTS.FINANCIALS,
35272
+ {
35273
+ filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
35274
+ fields: CAMELS_FIELDS,
35275
+ limit: 1e4,
35276
+ sort_by: "CERT",
35277
+ sort_order: "ASC"
35278
+ },
35279
+ { signal: controller.signal }
35280
+ )
35281
+ );
35282
+ const allCurrentFinancials = currentResponses.flatMap(extractRecords);
35283
+ const priorDates = getPriorQuarterDates2(params.repdte, params.quarters);
35284
+ let priorByInstitution = /* @__PURE__ */ new Map();
35285
+ if (priorDates.length > 0) {
35286
+ await sendProgressNotification(server.server, progressToken, 0.5, "Fetching prior quarters for trends");
35287
+ const dateFilter = priorDates.map((d) => `REPDTE:${d}`).join(" OR ");
35288
+ const priorResponses = await mapWithConcurrency(
35289
+ certFilters,
35290
+ MAX_CONCURRENCY,
35291
+ async (certFilter) => queryEndpoint(
35292
+ ENDPOINTS.FINANCIALS,
35293
+ {
35294
+ filters: `(${certFilter}) AND (${dateFilter})`,
35295
+ fields: CAMELS_FIELDS,
35296
+ limit: 1e4,
35297
+ sort_by: "REPDTE",
35298
+ sort_order: "DESC"
35299
+ },
35300
+ { signal: controller.signal }
35301
+ )
35302
+ );
35303
+ for (const rec of priorResponses.flatMap(extractRecords)) {
35304
+ const cert = asNumber(rec.CERT);
35305
+ if (cert === null) continue;
35306
+ const existing = priorByInstitution.get(cert) ?? [];
35307
+ existing.push(rec);
35308
+ priorByInstitution.set(cert, existing);
35309
+ }
35310
+ }
35311
+ const profileResp = await queryEndpoint(
35312
+ ENDPOINTS.INSTITUTIONS,
35313
+ {
35314
+ filters: targetCerts.length <= 25 ? targetCerts.map((c) => `CERT:${c}`).join(" OR ") : `CERT:[${Math.min(...targetCerts)} TO ${Math.max(...targetCerts)}]`,
35315
+ fields: "CERT,NAME,CITY,STALP",
35316
+ limit: 1e4,
35317
+ sort_by: "CERT",
35318
+ sort_order: "ASC"
35319
+ },
35320
+ { signal: controller.signal }
35321
+ );
35322
+ const profileMap = /* @__PURE__ */ new Map();
35323
+ for (const r of extractRecords(profileResp)) {
35324
+ const c = asNumber(r.CERT);
35325
+ if (c !== null) profileMap.set(c, r);
35326
+ }
35327
+ await sendProgressNotification(server.server, progressToken, 0.7, "Analyzing risk signals");
35328
+ const minSeverityOrder = SEVERITY_ORDER[params.min_severity];
35329
+ const results = [];
35330
+ for (const fin of allCurrentFinancials) {
35331
+ const cert = asNumber(fin.CERT);
35332
+ if (cert === null) continue;
35333
+ const priorQuarters = priorByInstitution.get(cert) ?? [];
35334
+ const metrics = computeCamelsMetrics(fin, priorQuarters);
35335
+ const allQuarters = [fin, ...priorQuarters];
35336
+ const trends = [];
35337
+ for (const tm of TREND_METRICS2) {
35338
+ const timeseries = allQuarters.map((q) => ({
35339
+ repdte: String(q.REPDTE ?? ""),
35340
+ value: typeof q[tm.fdic_field] === "number" ? q[tm.fdic_field] : null
35341
+ }));
35342
+ timeseries.reverse();
35343
+ trends.push(analyzeTrend(String(tm.key), timeseries, tm.higher_is_better));
35344
+ }
35345
+ const allSignals = classifyRiskSignals(metrics, trends);
35346
+ const filteredSignals = allSignals.filter((s) => SEVERITY_ORDER[s.severity] <= minSeverityOrder);
35347
+ if (filteredSignals.length === 0) continue;
35348
+ const components = ["C", "A", "E", "L", "S"].map((c) => scoreComponent(c, metrics));
35349
+ const comp = compositeScore(components);
35350
+ const profile = profileMap.get(cert);
35351
+ results.push({
35352
+ cert,
35353
+ name: String(profile?.NAME ?? `CERT ${cert}`),
35354
+ city: profile?.CITY ? String(profile.CITY) : null,
35355
+ state: profile?.STALP ? String(profile.STALP) : null,
35356
+ total_assets: asNumber(fin.ASSET),
35357
+ composite_rating: comp.rating,
35358
+ composite_label: comp.label,
35359
+ signals: filteredSignals,
35360
+ critical_count: filteredSignals.filter((s) => s.severity === "critical").length,
35361
+ warning_count: filteredSignals.filter((s) => s.severity === "warning").length
35362
+ });
35363
+ }
35364
+ results.sort((a, b) => {
35365
+ if (a.critical_count !== b.critical_count) return b.critical_count - a.critical_count;
35366
+ if (a.warning_count !== b.warning_count) return b.warning_count - a.warning_count;
35367
+ return b.composite_rating - a.composite_rating;
35368
+ });
35369
+ const returned = results.slice(0, params.limit);
35370
+ await sendProgressNotification(server.server, progressToken, 0.9, "Formatting results");
35371
+ const parts = [];
35372
+ parts.push(`Risk Signal Scan \u2014 ${results.length} flagged of ${allCurrentFinancials.length} institutions scanned`);
35373
+ parts.push(`Report Date: ${params.repdte} | Min Severity: ${params.min_severity}`);
35374
+ parts.push("NOTE: Analytical screening tool, not official supervisory ratings.");
35375
+ parts.push("");
35376
+ if (returned.length === 0) {
35377
+ parts.push("No institutions flagged at the specified severity level.");
35378
+ }
35379
+ for (let i = 0; i < returned.length; i++) {
35380
+ const r = returned[i];
35381
+ const location = [r.city, r.state].filter(Boolean).join(", ");
35382
+ const assetStr = r.total_assets !== null ? `$${Math.round(r.total_assets).toLocaleString()}k` : "n/a";
35383
+ parts.push(`${i + 1}. ${r.name} (${location}) CERT ${r.cert} | Assets: ${assetStr}`);
35384
+ parts.push(` Composite: ${formatRating(r.composite_rating)} | Critical: ${r.critical_count} | Warnings: ${r.warning_count}`);
35385
+ for (const s of r.signals) {
35386
+ const icon = s.severity === "critical" ? "\u{1F534}" : s.severity === "warning" ? "\u{1F7E1}" : "\u{1F535}";
35387
+ parts.push(` ${icon} [${s.severity}] ${s.message}`);
35388
+ }
35389
+ parts.push("");
35390
+ }
35391
+ if (results.length > returned.length) {
35392
+ parts.push(`Showing ${returned.length} of ${results.length} flagged institutions. Increase limit to see more.`);
35393
+ }
35394
+ const text = truncateIfNeeded(parts.join("\n"), CHARACTER_LIMIT);
35395
+ return {
35396
+ content: [{ type: "text", text }],
35397
+ structuredContent: {
35398
+ report_date: params.repdte,
35399
+ min_severity: params.min_severity,
35400
+ institutions_scanned: allCurrentFinancials.length,
35401
+ institutions_flagged: results.length,
35402
+ returned_count: returned.length,
35403
+ institutions: returned
35404
+ }
35405
+ };
35406
+ } catch (err) {
35407
+ return formatToolError(err);
35408
+ } finally {
35409
+ clearTimeout(timeoutId);
35410
+ }
35411
+ }
35412
+ );
35413
+ }
35414
+
34368
35415
  // src/resources/schemaResources.ts
34369
35416
  var RESOURCE_SCHEME = "fdic";
34370
35417
  var INDEX_URI = `${RESOURCE_SCHEME}://schemas/index`;
@@ -34450,6 +35497,9 @@ function createServer() {
34450
35497
  registerDemographicsTools(server);
34451
35498
  registerAnalysisTools(server);
34452
35499
  registerPeerGroupTools(server);
35500
+ registerBankHealthTools(server);
35501
+ registerPeerHealthTools(server);
35502
+ registerRiskSignalTools(server);
34453
35503
  registerSchemaResources(server);
34454
35504
  return server;
34455
35505
  }