fdic-mcp-server 1.0.7 → 1.1.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
@@ -30,7 +30,7 @@ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamable
30
30
  var import_express = __toESM(require("express"));
31
31
 
32
32
  // src/constants.ts
33
- var VERSION = true ? "1.0.7" : process.env.npm_package_version ?? "0.0.0-dev";
33
+ var VERSION = true ? "1.1.0" : process.env.npm_package_version ?? "0.0.0-dev";
34
34
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
35
35
  var CHARACTER_LIMIT = 5e4;
36
36
  var ENDPOINTS = {
@@ -56,6 +56,13 @@ var apiClient = import_axios.default.create({
56
56
  });
57
57
  var QUERY_CACHE_TTL_MS = 6e4;
58
58
  var queryCache = /* @__PURE__ */ new Map();
59
+ function pruneExpiredQueryCache(now) {
60
+ for (const [key, entry] of queryCache.entries()) {
61
+ if (entry.expiresAt <= now) {
62
+ queryCache.delete(key);
63
+ }
64
+ }
65
+ }
59
66
  function getCacheKey(endpoint, params) {
60
67
  return JSON.stringify([
61
68
  endpoint,
@@ -67,10 +74,15 @@ function getCacheKey(endpoint, params) {
67
74
  params.sort_order ?? null
68
75
  ]);
69
76
  }
70
- async function queryEndpoint(endpoint, params) {
71
- const cacheKey = getCacheKey(endpoint, params);
77
+ async function queryEndpoint(endpoint, params, options = {}) {
78
+ if (options.signal?.aborted) {
79
+ throw new Error("FDIC API request was canceled before it started.");
80
+ }
81
+ const shouldUseCache = !options.signal;
72
82
  const now = Date.now();
73
- const cached = queryCache.get(cacheKey);
83
+ pruneExpiredQueryCache(now);
84
+ const cacheKey = getCacheKey(endpoint, params);
85
+ const cached = shouldUseCache ? queryCache.get(cacheKey) : void 0;
74
86
  if (cached && cached.expiresAt > now) {
75
87
  return cached.value;
76
88
  }
@@ -86,10 +98,14 @@ async function queryEndpoint(endpoint, params) {
86
98
  if (params.sort_by) queryParams.sort_by = params.sort_by;
87
99
  if (params.sort_order) queryParams.sort_order = params.sort_order;
88
100
  const response = await apiClient.get(`/${endpoint}`, {
89
- params: queryParams
101
+ params: queryParams,
102
+ signal: options.signal
90
103
  });
91
104
  return response.data;
92
105
  } catch (err) {
106
+ if (options.signal?.aborted || typeof err === "object" && err !== null && "code" in err && err.code === "ERR_CANCELED" || err instanceof DOMException && err.name === "AbortError") {
107
+ throw new Error("FDIC API request was canceled.");
108
+ }
93
109
  if (err instanceof import_axios.AxiosError) {
94
110
  const status = err.response?.status;
95
111
  const detail = err.response?.data?.message ?? err.message;
@@ -112,14 +128,18 @@ async function queryEndpoint(endpoint, params) {
112
128
  throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
113
129
  }
114
130
  })();
115
- queryCache.set(cacheKey, {
116
- expiresAt: now + QUERY_CACHE_TTL_MS,
117
- value: requestPromise
118
- });
131
+ if (shouldUseCache) {
132
+ queryCache.set(cacheKey, {
133
+ expiresAt: now + QUERY_CACHE_TTL_MS,
134
+ value: requestPromise
135
+ });
136
+ }
119
137
  try {
120
138
  return await requestPromise;
121
139
  } catch (error) {
122
- queryCache.delete(cacheKey);
140
+ if (shouldUseCache) {
141
+ queryCache.delete(cacheKey);
142
+ }
123
143
  throw error;
124
144
  }
125
145
  }
@@ -1055,6 +1075,7 @@ Prefer concise human-readable summaries or tables when answering users. Structur
1055
1075
  var import_zod7 = require("zod");
1056
1076
  var CHUNK_SIZE = 25;
1057
1077
  var MAX_CONCURRENCY = 4;
1078
+ var ANALYSIS_TIMEOUT_MS = 9e4;
1058
1079
  var SortFieldSchema = import_zod7.z.enum([
1059
1080
  "asset_growth",
1060
1081
  "asset_growth_pct",
@@ -1144,14 +1165,31 @@ function change(start, end) {
1144
1165
  if (start === null || end === null) return null;
1145
1166
  return end - start;
1146
1167
  }
1168
+ function getQuarterIndex(repdte) {
1169
+ const year = Number.parseInt(repdte.slice(0, 4), 10);
1170
+ const month = Number.parseInt(repdte.slice(4, 6), 10);
1171
+ const quarter = month / 3;
1172
+ if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) {
1173
+ return null;
1174
+ }
1175
+ return year * 4 + quarter;
1176
+ }
1147
1177
  function yearsBetween(startRepdte, endRepdte) {
1178
+ const startQuarterIndex = getQuarterIndex(startRepdte);
1179
+ const endQuarterIndex = getQuarterIndex(endRepdte);
1180
+ if (startQuarterIndex !== null && endQuarterIndex !== null) {
1181
+ return Math.max((endQuarterIndex - startQuarterIndex) / 4, 0);
1182
+ }
1148
1183
  const start = /* @__PURE__ */ new Date(
1149
- `${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}`
1184
+ `${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}T00:00:00Z`
1150
1185
  );
1151
1186
  const end = /* @__PURE__ */ new Date(
1152
- `${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}`
1187
+ `${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}T00:00:00Z`
1188
+ );
1189
+ return Math.max(
1190
+ (end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3),
1191
+ 0
1153
1192
  );
1154
- return Math.max((end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3), 0);
1155
1193
  }
1156
1194
  function cagr(start, end, years) {
1157
1195
  if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
@@ -1223,6 +1261,21 @@ function buildTopLevelInsights(comparisons) {
1223
1261
  (comparison) => comparison.insights?.includes(
1224
1262
  "growth_with_branch_consolidation"
1225
1263
  )
1264
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1265
+ deposit_mix_softening: comparisons.filter(
1266
+ (comparison) => comparison.insights?.includes(
1267
+ "deposit_mix_softening"
1268
+ )
1269
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1270
+ sustained_asset_growth: comparisons.filter(
1271
+ (comparison) => comparison.insights?.includes(
1272
+ "sustained_asset_growth"
1273
+ )
1274
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1275
+ multi_quarter_roa_decline: comparisons.filter(
1276
+ (comparison) => comparison.insights?.includes(
1277
+ "multi_quarter_roa_decline"
1278
+ )
1226
1279
  ).slice(0, 5).map((comparison) => String(comparison.name))
1227
1280
  };
1228
1281
  }
@@ -1378,7 +1431,7 @@ Insights
1378
1431
  ${insights}` : `${header}
1379
1432
  ${rows.join("\n")}`;
1380
1433
  }
1381
- async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
1434
+ async function fetchInstitutionRoster(state, institutionFilters, activeOnly, signal) {
1382
1435
  const filterParts = [];
1383
1436
  if (state) filterParts.push(`STNAME:"${state}"`);
1384
1437
  if (activeOnly) filterParts.push("ACTIVE:1");
@@ -1390,10 +1443,12 @@ async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
1390
1443
  offset: 0,
1391
1444
  sort_by: "CERT",
1392
1445
  sort_order: "ASC"
1393
- });
1394
- return extractRecords(response);
1446
+ }, { signal });
1447
+ const records = extractRecords(response);
1448
+ const warning = response.meta.total > records.length ? `Institution roster truncated to ${records.length.toLocaleString()} records out of ${response.meta.total.toLocaleString()} matched institutions. Narrow the comparison set with institution_filters or certs for complete analysis.` : void 0;
1449
+ return { records, warning };
1395
1450
  }
1396
- async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields) {
1451
+ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields, signal) {
1397
1452
  const certFilters = buildCertFilters(certs);
1398
1453
  const tasks = repdteFilters.flatMap(
1399
1454
  (repdteFilter) => certFilters.map((certFilter) => ({
@@ -1409,7 +1464,7 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
1409
1464
  offset: 0,
1410
1465
  sort_by: "CERT",
1411
1466
  sort_order: "ASC"
1412
- });
1467
+ }, { signal });
1413
1468
  return { repdteFilter: task.repdteFilter, response };
1414
1469
  });
1415
1470
  const byDate = /* @__PURE__ */ new Map();
@@ -1425,7 +1480,7 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
1425
1480
  }
1426
1481
  return byDate;
1427
1482
  }
1428
- async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields) {
1483
+ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields, signal) {
1429
1484
  const certFilters = buildCertFilters(certs);
1430
1485
  const responses = await mapWithConcurrency(
1431
1486
  certFilters,
@@ -1437,7 +1492,7 @@ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, field
1437
1492
  offset: 0,
1438
1493
  sort_by: "REPDTE",
1439
1494
  sort_order: "ASC"
1440
- })
1495
+ }, { signal })
1441
1496
  );
1442
1497
  const grouped = /* @__PURE__ */ new Map();
1443
1498
  for (const response of responses) {
@@ -1567,12 +1622,19 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1567
1622
  sort_by,
1568
1623
  sort_order
1569
1624
  }) => {
1625
+ const controller = new AbortController();
1626
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
1570
1627
  try {
1571
- const roster = certs && certs.length > 0 ? certs.map((cert) => ({ CERT: cert })) : await fetchInstitutionRoster(
1628
+ const rosterResult = certs && certs.length > 0 ? {
1629
+ records: certs.map((cert) => ({ CERT: cert })),
1630
+ warning: void 0
1631
+ } : await fetchInstitutionRoster(
1572
1632
  state,
1573
1633
  institution_filters,
1574
- active_only
1634
+ active_only,
1635
+ controller.signal
1575
1636
  );
1637
+ const roster = rosterResult.records;
1576
1638
  const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
1577
1639
  if (candidateCerts.length === 0) {
1578
1640
  const output2 = {
@@ -1605,14 +1667,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1605
1667
  candidateCerts,
1606
1668
  start_repdte,
1607
1669
  end_repdte,
1608
- "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
1670
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
1671
+ controller.signal
1609
1672
  ),
1610
1673
  include_demographics ? fetchSeriesRecords(
1611
1674
  ENDPOINTS.DEMOGRAPHICS,
1612
1675
  candidateCerts,
1613
1676
  start_repdte,
1614
1677
  end_repdte,
1615
- "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
1678
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1679
+ controller.signal
1616
1680
  ) : Promise.resolve(/* @__PURE__ */ new Map())
1617
1681
  ]);
1618
1682
  comparisons = candidateCerts.map(
@@ -1633,13 +1697,15 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1633
1697
  ENDPOINTS.FINANCIALS,
1634
1698
  candidateCerts,
1635
1699
  [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1636
- "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
1700
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
1701
+ controller.signal
1637
1702
  ),
1638
1703
  include_demographics ? fetchBatchedRecordsForDates(
1639
1704
  ENDPOINTS.DEMOGRAPHICS,
1640
1705
  candidateCerts,
1641
1706
  [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1642
- "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
1707
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1708
+ controller.signal
1643
1709
  ) : Promise.resolve(/* @__PURE__ */ new Map())
1644
1710
  ]);
1645
1711
  const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
@@ -1662,10 +1728,12 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1662
1728
  );
1663
1729
  }).filter((comparison) => comparison !== null);
1664
1730
  }
1665
- const ranked = sortComparisons(comparisons, sort_by, sort_order).slice(
1666
- 0,
1667
- limit
1731
+ const sortedComparisons = sortComparisons(
1732
+ comparisons,
1733
+ sort_by,
1734
+ sort_order
1668
1735
  );
1736
+ const ranked = sortedComparisons.slice(0, limit);
1669
1737
  const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
1670
1738
  const output = {
1671
1739
  total_candidates: candidateCerts.length,
@@ -1675,12 +1743,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1675
1743
  analysis_mode,
1676
1744
  sort_by,
1677
1745
  sort_order,
1678
- insights: buildTopLevelInsights(comparisons),
1746
+ warnings: rosterResult.warning ? [rosterResult.warning] : [],
1747
+ insights: buildTopLevelInsights(sortedComparisons),
1679
1748
  ...pagination,
1680
1749
  comparisons: ranked
1681
1750
  };
1682
1751
  const text = truncateIfNeeded(
1683
- formatComparisonText(output),
1752
+ [
1753
+ rosterResult.warning ? `Warning: ${rosterResult.warning}` : null,
1754
+ formatComparisonText(output)
1755
+ ].filter((value) => value !== null).join("\n\n"),
1684
1756
  CHARACTER_LIMIT
1685
1757
  );
1686
1758
  return {
@@ -1688,7 +1760,593 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1688
1760
  structuredContent: output
1689
1761
  };
1690
1762
  } catch (err) {
1763
+ if (controller.signal.aborted) {
1764
+ return formatToolError(
1765
+ new Error(
1766
+ `Analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the comparison set with certs or institution_filters and try again.`
1767
+ )
1768
+ );
1769
+ }
1770
+ return formatToolError(err);
1771
+ } finally {
1772
+ clearTimeout(timeoutId);
1773
+ }
1774
+ }
1775
+ );
1776
+ }
1777
+
1778
+ // src/tools/peerGroup.ts
1779
+ var import_zod8 = require("zod");
1780
+ function asNumber2(value) {
1781
+ return typeof value === "number" ? value : null;
1782
+ }
1783
+ function safeRatio(numerator, denominator) {
1784
+ if (numerator === null || denominator === null || denominator === 0) {
1785
+ return null;
1786
+ }
1787
+ return numerator / denominator;
1788
+ }
1789
+ function safeRatioPositiveDenom(numerator, denominator) {
1790
+ if (numerator === null || denominator === null || denominator <= 0) {
1791
+ return null;
1792
+ }
1793
+ return numerator / denominator;
1794
+ }
1795
+ function deriveMetrics(raw) {
1796
+ const asset = asNumber2(raw.ASSET);
1797
+ const dep = asNumber2(raw.DEP);
1798
+ const eqtot = asNumber2(raw.EQTOT);
1799
+ const lnlsnet = asNumber2(raw.LNLSNET);
1800
+ const intinc = asNumber2(raw.INTINC);
1801
+ const eintexp = asNumber2(raw.EINTEXP);
1802
+ const nonii = asNumber2(raw.NONII);
1803
+ const nonix = asNumber2(raw.NONIX);
1804
+ const netInterestIncome = intinc !== null && eintexp !== null ? intinc - eintexp : null;
1805
+ const revenueDenominator = netInterestIncome !== null && nonii !== null ? netInterestIncome + nonii : null;
1806
+ const equityRatioRaw = safeRatio(eqtot, asset);
1807
+ const efficiencyRatioRaw = safeRatioPositiveDenom(nonix, revenueDenominator);
1808
+ return {
1809
+ asset,
1810
+ dep,
1811
+ roa: asNumber2(raw.ROA),
1812
+ roe: asNumber2(raw.ROE),
1813
+ netnim: asNumber2(raw.NETNIM),
1814
+ equity_ratio: equityRatioRaw !== null ? equityRatioRaw * 100 : null,
1815
+ efficiency_ratio: efficiencyRatioRaw !== null ? efficiencyRatioRaw * 100 : null,
1816
+ loan_to_deposit: safeRatio(lnlsnet, dep),
1817
+ deposits_to_assets: safeRatio(dep, asset),
1818
+ noninterest_income_share: safeRatioPositiveDenom(nonii, revenueDenominator)
1819
+ };
1820
+ }
1821
+ function computeMedian(values) {
1822
+ if (values.length === 0) return null;
1823
+ const sorted = [...values].sort((a, b) => a - b);
1824
+ const mid = Math.floor(sorted.length / 2);
1825
+ if (sorted.length % 2 === 1) return sorted[mid];
1826
+ return (sorted[mid - 1] + sorted[mid]) / 2;
1827
+ }
1828
+ function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
1829
+ if (peerValues.length === 0) return null;
1830
+ const ascending = higherIsBetter === false;
1831
+ const all = [...peerValues, subjectValue];
1832
+ const sorted = [...all].sort(
1833
+ (a, b) => ascending ? a - b : b - a
1834
+ );
1835
+ const ranks = /* @__PURE__ */ new Map();
1836
+ for (let i = 0; i < sorted.length; i++) {
1837
+ if (!ranks.has(sorted[i])) {
1838
+ ranks.set(sorted[i], i + 1);
1839
+ }
1840
+ }
1841
+ const rank = ranks.get(subjectValue);
1842
+ const of = peerValues.length;
1843
+ const percentile = Math.round((1 - (rank - 1) / of) * 100);
1844
+ return { rank, of, percentile };
1845
+ }
1846
+ function formatRepdteHuman(repdte) {
1847
+ if (repdte.length !== 8) return repdte;
1848
+ const year = repdte.slice(0, 4);
1849
+ const month = repdte.slice(4, 6);
1850
+ const day = repdte.slice(6, 8);
1851
+ const date = /* @__PURE__ */ new Date(`${year}-${month}-${day}T00:00:00Z`);
1852
+ if (Number.isNaN(date.getTime())) return repdte;
1853
+ return date.toLocaleDateString("en-US", {
1854
+ year: "numeric",
1855
+ month: "long",
1856
+ day: "numeric",
1857
+ timeZone: "UTC"
1858
+ });
1859
+ }
1860
+ var METRIC_KEYS = [
1861
+ "asset",
1862
+ "dep",
1863
+ "roa",
1864
+ "roe",
1865
+ "netnim",
1866
+ "equity_ratio",
1867
+ "efficiency_ratio",
1868
+ "loan_to_deposit",
1869
+ "deposits_to_assets",
1870
+ "noninterest_income_share"
1871
+ ];
1872
+ var METRIC_DEFINITIONS = {
1873
+ asset: { higher_is_better: true, unit: "$thousands", label: "Total Assets" },
1874
+ dep: { higher_is_better: true, unit: "$thousands", label: "Total Deposits" },
1875
+ roa: { higher_is_better: true, unit: "%", label: "Return on Assets" },
1876
+ roe: { higher_is_better: true, unit: "%", label: "Return on Equity" },
1877
+ netnim: {
1878
+ higher_is_better: true,
1879
+ unit: "%",
1880
+ label: "Net Interest Margin"
1881
+ },
1882
+ equity_ratio: {
1883
+ higher_is_better: true,
1884
+ unit: "%",
1885
+ label: "Equity Capital Ratio"
1886
+ },
1887
+ efficiency_ratio: {
1888
+ higher_is_better: false,
1889
+ unit: "%",
1890
+ label: "Efficiency Ratio"
1891
+ },
1892
+ loan_to_deposit: {
1893
+ higher_is_better: null,
1894
+ unit: "ratio",
1895
+ label: "Loan-to-Deposit Ratio",
1896
+ ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
1897
+ },
1898
+ deposits_to_assets: {
1899
+ higher_is_better: null,
1900
+ unit: "ratio",
1901
+ label: "Deposits-to-Assets Ratio",
1902
+ ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
1903
+ },
1904
+ noninterest_income_share: {
1905
+ higher_is_better: true,
1906
+ unit: "ratio",
1907
+ label: "Non-Interest Income Share"
1908
+ }
1909
+ };
1910
+ var PeerGroupInputSchema = import_zod8.z.object({
1911
+ cert: import_zod8.z.number().int().positive().optional().describe(
1912
+ "Subject institution CERT number. When provided, auto-derives peer criteria and ranks this bank against peers."
1913
+ ),
1914
+ repdte: import_zod8.z.string().regex(/^\d{8}$/).describe("Report date in YYYYMMDD format."),
1915
+ asset_min: import_zod8.z.number().positive().optional().describe(
1916
+ "Minimum total assets ($thousands) for peer selection. Defaults to 50% of subject's report-date assets when cert is provided."
1917
+ ),
1918
+ asset_max: import_zod8.z.number().positive().optional().describe(
1919
+ "Maximum total assets ($thousands) for peer selection. Defaults to 200% of subject's report-date assets when cert is provided."
1920
+ ),
1921
+ charter_classes: import_zod8.z.array(import_zod8.z.string()).optional().describe(
1922
+ `Charter class codes to include (e.g., ["N", "SM"]). Defaults to the subject's charter class when cert is provided.`
1923
+ ),
1924
+ state: import_zod8.z.string().regex(/^[A-Z]{2}$/).optional().describe('Two-letter state code (e.g., "NC", "TX").'),
1925
+ raw_filter: import_zod8.z.string().optional().describe(
1926
+ "Advanced: raw ElasticSearch query string appended to peer selection criteria with AND."
1927
+ ),
1928
+ active_only: import_zod8.z.boolean().default(true).describe(
1929
+ "Limit to institutions where ACTIVE:1 (currently operating, FDIC-insured)."
1930
+ ),
1931
+ extra_fields: import_zod8.z.array(import_zod8.z.string()).optional().describe(
1932
+ "Additional FDIC field names to include as raw values in the response. Does not affect peer selection."
1933
+ ),
1934
+ limit: import_zod8.z.number().int().min(1).max(500).default(50).describe(
1935
+ "Max peer records returned in the response. All matched peers are used for ranking regardless of this limit."
1936
+ )
1937
+ }).superRefine((value, ctx) => {
1938
+ if (!value.cert && value.asset_min === void 0 && value.asset_max === void 0 && !value.charter_classes && !value.state && !value.raw_filter) {
1939
+ ctx.addIssue({
1940
+ code: import_zod8.z.ZodIssueCode.custom,
1941
+ message: "At least one peer-group constructor is required: cert, asset_min, asset_max, charter_classes, state, or raw_filter.",
1942
+ path: ["cert"]
1943
+ });
1944
+ }
1945
+ if (value.asset_min !== void 0 && value.asset_max !== void 0 && value.asset_min > value.asset_max) {
1946
+ ctx.addIssue({
1947
+ code: import_zod8.z.ZodIssueCode.custom,
1948
+ message: "asset_min must be <= asset_max.",
1949
+ path: ["asset_min"]
1950
+ });
1951
+ }
1952
+ });
1953
+ var CHUNK_SIZE2 = 25;
1954
+ var MAX_CONCURRENCY2 = 4;
1955
+ var ANALYSIS_TIMEOUT_MS2 = 9e4;
1956
+ var FINANCIAL_FIELDS = "CERT,ASSET,DEP,NETINC,ROA,ROE,NETNIM,EQTOT,LNLSNET,INTINC,EINTEXP,NONII,NONIX";
1957
+ function buildCertFilters2(certs) {
1958
+ const filters = [];
1959
+ for (let i = 0; i < certs.length; i += CHUNK_SIZE2) {
1960
+ const chunk = certs.slice(i, i + CHUNK_SIZE2);
1961
+ filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
1962
+ }
1963
+ return filters;
1964
+ }
1965
+ async function mapWithConcurrency2(values, limit, mapper) {
1966
+ const results = new Array(values.length);
1967
+ let nextIndex = 0;
1968
+ async function worker() {
1969
+ while (true) {
1970
+ const currentIndex = nextIndex;
1971
+ nextIndex += 1;
1972
+ if (currentIndex >= values.length) return;
1973
+ results[currentIndex] = await mapper(values[currentIndex], currentIndex);
1974
+ }
1975
+ }
1976
+ await Promise.all(
1977
+ Array.from({ length: Math.min(limit, values.length) }, () => worker())
1978
+ );
1979
+ return results;
1980
+ }
1981
+ function formatMetricValue(key, value) {
1982
+ if (value === null) return "n/a";
1983
+ const def = METRIC_DEFINITIONS[key];
1984
+ if (def.unit === "$thousands")
1985
+ return `$${Math.round(value).toLocaleString()}k`;
1986
+ if (def.unit === "%") return `${value.toFixed(4)}%`;
1987
+ return value.toFixed(4);
1988
+ }
1989
+ function ordinalSuffix(n) {
1990
+ const mod100 = n % 100;
1991
+ if (mod100 >= 11 && mod100 <= 13) return `${n}th`;
1992
+ const mod10 = n % 10;
1993
+ if (mod10 === 1) return `${n}st`;
1994
+ if (mod10 === 2) return `${n}nd`;
1995
+ if (mod10 === 3) return `${n}rd`;
1996
+ return `${n}th`;
1997
+ }
1998
+ function formatPeerGroupText(repdte, subjectProfile, subjectMetrics, rankings, medians, returnedPeers, peerCount, warnings) {
1999
+ const parts = [];
2000
+ for (const warning of warnings) {
2001
+ parts.push(`Warning: ${warning}`);
2002
+ }
2003
+ const dateStr = formatRepdteHuman(repdte);
2004
+ const subjectLabel = subjectProfile ? ` for ${subjectProfile.NAME} (CERT ${subjectProfile.CERT ?? ""})` : "";
2005
+ parts.push(`Peer group analysis${subjectLabel} as of ${dateStr}.`);
2006
+ parts.push(`${peerCount} peers matched.`);
2007
+ if (subjectMetrics && subjectProfile) {
2008
+ parts.push("");
2009
+ parts.push("Subject rankings:");
2010
+ for (const key of METRIC_KEYS) {
2011
+ const def = METRIC_DEFINITIONS[key];
2012
+ const ranking = rankings[key];
2013
+ const value = formatMetricValue(key, subjectMetrics[key]);
2014
+ const medianValue = formatMetricValue(key, medians[key] ?? null);
2015
+ if (ranking) {
2016
+ const pctLabel = `${ordinalSuffix(ranking.percentile)} percentile`;
2017
+ parts.push(
2018
+ ` ${def.label.padEnd(28)} rank ${String(ranking.rank).padStart(2)} of ${String(ranking.of).padEnd(4)} (${pctLabel.padEnd(18)}) ${value.padStart(16)} median: ${medianValue}`
2019
+ );
2020
+ } else {
2021
+ parts.push(` ${def.label.padEnd(28)} n/a`);
2022
+ }
2023
+ }
2024
+ } else if (peerCount > 0) {
2025
+ parts.push("");
2026
+ parts.push("Peer group medians:");
2027
+ const medianParts = METRIC_KEYS.filter((k) => medians[k] !== null).map(
2028
+ (k) => `${METRIC_DEFINITIONS[k].label}: ${formatMetricValue(k, medians[k] ?? null)}`
2029
+ );
2030
+ parts.push(` ${medianParts.join(" | ")}`);
2031
+ }
2032
+ if (returnedPeers.length > 0) {
2033
+ parts.push("");
2034
+ parts.push(`Peers (${returnedPeers.length} returned):`);
2035
+ for (let i = 0; i < returnedPeers.length; i++) {
2036
+ const p = returnedPeers[i];
2037
+ const location = [p.city, p.stalp].filter(Boolean).join(" ");
2038
+ const locationStr = location ? `, ${location}` : "";
2039
+ parts.push(
2040
+ `${i + 1}. ${p.name}${locationStr} (CERT ${p.cert}) | Asset: ${formatMetricValue("asset", p.metrics.asset)} | ROA: ${formatMetricValue("roa", p.metrics.roa)} | ROE: ${formatMetricValue("roe", p.metrics.roe)}`
2041
+ );
2042
+ }
2043
+ }
2044
+ return parts.join("\n");
2045
+ }
2046
+ function registerPeerGroupTools(server) {
2047
+ server.registerTool(
2048
+ "fdic_peer_group_analysis",
2049
+ {
2050
+ title: "Peer Group Analysis",
2051
+ description: `Build a peer group for an FDIC-insured institution and rank it against peers on financial and efficiency metrics at a single report date.
2052
+
2053
+ Three usage modes:
2054
+ - Subject-driven: provide cert and repdte \u2014 auto-derives peer criteria from the subject's asset size and charter class
2055
+ - Explicit criteria: provide repdte plus asset_min/asset_max, charter_classes, state, or raw_filter
2056
+ - Subject with overrides: provide cert plus explicit criteria to override auto-derived defaults
2057
+
2058
+ Metrics ranked (fixed order):
2059
+ - Total Assets, Total Deposits, ROA, ROE, Net Interest Margin
2060
+ - Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio
2061
+ - Deposits-to-Assets Ratio, Non-Interest Income Share
2062
+
2063
+ Rankings use competition rank (1, 2, 2, 4) with metric-specific denominators. Subject is excluded from peer set.
2064
+
2065
+ Output includes:
2066
+ - Subject rankings and percentiles (when cert provided)
2067
+ - Peer group medians
2068
+ - Peer list with CERTs (pass to fdic_compare_bank_snapshots for trend analysis)
2069
+ - Metric definitions with directionality metadata
2070
+
2071
+ Override precedence: cert derives defaults, then explicit params override them.`,
2072
+ inputSchema: PeerGroupInputSchema,
2073
+ annotations: {
2074
+ readOnlyHint: true,
2075
+ destructiveHint: false,
2076
+ idempotentHint: true,
2077
+ openWorldHint: true
2078
+ }
2079
+ },
2080
+ async (params) => {
2081
+ const controller = new AbortController();
2082
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS2);
2083
+ try {
2084
+ const warnings = [];
2085
+ let subjectProfile = null;
2086
+ let subjectFinancials = null;
2087
+ if (params.cert) {
2088
+ const [profileResponse, financialsResponse] = await Promise.all([
2089
+ queryEndpoint(
2090
+ ENDPOINTS.INSTITUTIONS,
2091
+ {
2092
+ filters: `CERT:${params.cert}`,
2093
+ fields: "CERT,NAME,CITY,STALP,BKCLASS",
2094
+ limit: 1
2095
+ },
2096
+ { signal: controller.signal }
2097
+ ),
2098
+ queryEndpoint(
2099
+ ENDPOINTS.FINANCIALS,
2100
+ {
2101
+ filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
2102
+ fields: FINANCIAL_FIELDS,
2103
+ limit: 1
2104
+ },
2105
+ { signal: controller.signal }
2106
+ )
2107
+ ]);
2108
+ const profileRecords = extractRecords(profileResponse);
2109
+ if (profileRecords.length === 0) {
2110
+ return formatToolError(
2111
+ new Error(
2112
+ `No institution found with CERT number ${params.cert}.`
2113
+ )
2114
+ );
2115
+ }
2116
+ subjectProfile = profileRecords[0];
2117
+ const financialRecords = extractRecords(financialsResponse);
2118
+ if (financialRecords.length === 0) {
2119
+ return formatToolError(
2120
+ new Error(
2121
+ `No financial data for CERT ${params.cert} at report date ${params.repdte}. Auto-derivation of peer criteria requires asset data at the specified report date.`
2122
+ )
2123
+ );
2124
+ }
2125
+ subjectFinancials = financialRecords[0];
2126
+ }
2127
+ const subjectAsset = subjectFinancials && typeof subjectFinancials.ASSET === "number" ? subjectFinancials.ASSET : null;
2128
+ const assetMin = params.asset_min ?? (subjectAsset !== null ? subjectAsset * 0.5 : void 0);
2129
+ const assetMax = params.asset_max ?? (subjectAsset !== null ? subjectAsset * 2 : void 0);
2130
+ const charterClasses = params.charter_classes ?? (subjectProfile && typeof subjectProfile.BKCLASS === "string" ? [subjectProfile.BKCLASS] : void 0);
2131
+ const { state, active_only, raw_filter } = params;
2132
+ const filterParts = [];
2133
+ if (assetMin !== void 0 || assetMax !== void 0) {
2134
+ const min = assetMin ?? 0;
2135
+ const max = assetMax ?? "*";
2136
+ filterParts.push(`ASSET:[${min} TO ${max}]`);
2137
+ }
2138
+ if (charterClasses && charterClasses.length > 0) {
2139
+ const classFilter = charterClasses.map((cls) => `BKCLASS:${cls}`).join(" OR ");
2140
+ filterParts.push(
2141
+ charterClasses.length > 1 ? `(${classFilter})` : classFilter
2142
+ );
2143
+ }
2144
+ if (state) filterParts.push(`STALP:${state}`);
2145
+ if (active_only) filterParts.push("ACTIVE:1");
2146
+ if (raw_filter) filterParts.push(`(${raw_filter})`);
2147
+ const rosterResponse = await queryEndpoint(
2148
+ ENDPOINTS.INSTITUTIONS,
2149
+ {
2150
+ filters: filterParts.join(" AND "),
2151
+ fields: "CERT,NAME,CITY,STALP,BKCLASS",
2152
+ limit: 1e4,
2153
+ offset: 0,
2154
+ sort_by: "CERT",
2155
+ sort_order: "ASC"
2156
+ },
2157
+ { signal: controller.signal }
2158
+ );
2159
+ let rosterRecords = extractRecords(rosterResponse);
2160
+ if (rosterResponse.meta.total > rosterRecords.length) {
2161
+ warnings.push(
2162
+ `Institution roster truncated to ${rosterRecords.length.toLocaleString()} records out of ${rosterResponse.meta.total.toLocaleString()} matched institutions. Narrow the peer group criteria for complete analysis.`
2163
+ );
2164
+ }
2165
+ if (params.cert) {
2166
+ rosterRecords = rosterRecords.filter(
2167
+ (r) => asNumber2(r.CERT) !== params.cert
2168
+ );
2169
+ }
2170
+ const criteriaUsed = {
2171
+ asset_min: assetMin ?? null,
2172
+ asset_max: assetMax ?? null,
2173
+ charter_classes: charterClasses ?? null,
2174
+ state: state ?? null,
2175
+ active_only,
2176
+ raw_filter: raw_filter ?? null
2177
+ };
2178
+ if (rosterRecords.length === 0) {
2179
+ const subjectMetrics2 = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
2180
+ const output2 = {};
2181
+ if (subjectProfile) {
2182
+ output2.subject = {
2183
+ cert: params.cert,
2184
+ name: subjectProfile.NAME,
2185
+ city: subjectProfile.CITY,
2186
+ stalp: subjectProfile.STALP,
2187
+ bkclass: subjectProfile.BKCLASS,
2188
+ metrics: subjectMetrics2,
2189
+ rankings: null
2190
+ };
2191
+ }
2192
+ output2.peer_group = {
2193
+ repdte: params.repdte,
2194
+ criteria_used: criteriaUsed,
2195
+ medians: {}
2196
+ };
2197
+ output2.metric_definitions = METRIC_DEFINITIONS;
2198
+ output2.peers = [];
2199
+ output2.peer_count = 0;
2200
+ output2.returned_count = 0;
2201
+ output2.has_more = false;
2202
+ output2.message = "No peers matched the specified criteria.";
2203
+ output2.warnings = warnings;
2204
+ const text2 = formatPeerGroupText(
2205
+ params.repdte,
2206
+ subjectProfile,
2207
+ subjectMetrics2,
2208
+ {},
2209
+ {},
2210
+ [],
2211
+ 0,
2212
+ warnings
2213
+ );
2214
+ return {
2215
+ content: [{ type: "text", text: text2 }],
2216
+ structuredContent: output2
2217
+ };
2218
+ }
2219
+ const peerCerts = rosterRecords.map((r) => asNumber2(r.CERT)).filter((c) => c !== null);
2220
+ const certFilters = buildCertFilters2(peerCerts);
2221
+ const extraFieldsCsv = params.extra_fields && params.extra_fields.length > 0 ? "," + params.extra_fields.join(",") : "";
2222
+ const financialResponses = await mapWithConcurrency2(
2223
+ certFilters,
2224
+ MAX_CONCURRENCY2,
2225
+ async (certFilter) => queryEndpoint(
2226
+ ENDPOINTS.FINANCIALS,
2227
+ {
2228
+ filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
2229
+ fields: FINANCIAL_FIELDS + extraFieldsCsv,
2230
+ limit: 1e4,
2231
+ offset: 0,
2232
+ sort_by: "CERT",
2233
+ sort_order: "ASC"
2234
+ },
2235
+ { signal: controller.signal }
2236
+ )
2237
+ );
2238
+ const peerFinancialsByCert = /* @__PURE__ */ new Map();
2239
+ for (const response of financialResponses) {
2240
+ for (const record of extractRecords(response)) {
2241
+ const cert = asNumber2(record.CERT);
2242
+ if (cert !== null) peerFinancialsByCert.set(cert, record);
2243
+ }
2244
+ }
2245
+ const rosterByCert = new Map(
2246
+ rosterRecords.map((r) => [asNumber2(r.CERT), r]).filter(
2247
+ (e) => e[0] !== null
2248
+ )
2249
+ );
2250
+ const peers = [];
2251
+ for (const [cert, financials] of peerFinancialsByCert) {
2252
+ const roster = rosterByCert.get(cert);
2253
+ const metrics = deriveMetrics(financials);
2254
+ const extraFields = {};
2255
+ if (params.extra_fields) {
2256
+ for (const field of params.extra_fields) {
2257
+ extraFields[field] = financials[field] ?? null;
2258
+ }
2259
+ }
2260
+ peers.push({
2261
+ cert,
2262
+ name: String(roster?.NAME ?? financials.NAME ?? cert),
2263
+ city: roster?.CITY != null ? String(roster.CITY) : null,
2264
+ stalp: roster?.STALP != null ? String(roster.STALP) : null,
2265
+ bkclass: roster?.BKCLASS != null ? String(roster.BKCLASS) : null,
2266
+ metrics,
2267
+ extraFields
2268
+ });
2269
+ }
2270
+ const peerCount = peers.length;
2271
+ const subjectMetrics = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
2272
+ const rankings = {};
2273
+ const medians = {};
2274
+ for (const key of METRIC_KEYS) {
2275
+ const peerValues = peers.map((p) => p.metrics[key]).filter((v) => v !== null);
2276
+ medians[key] = computeMedian(peerValues);
2277
+ if (subjectMetrics && subjectMetrics[key] !== null) {
2278
+ rankings[key] = computeCompetitionRank(
2279
+ subjectMetrics[key],
2280
+ peerValues,
2281
+ METRIC_DEFINITIONS[key].higher_is_better
2282
+ );
2283
+ } else {
2284
+ rankings[key] = null;
2285
+ }
2286
+ }
2287
+ peers.sort((a, b) => (b.metrics.asset ?? 0) - (a.metrics.asset ?? 0));
2288
+ const returnedPeers = peers.slice(0, params.limit);
2289
+ const returnedCount = returnedPeers.length;
2290
+ const hasMore = peerCount > returnedCount;
2291
+ const output = {};
2292
+ if (subjectProfile && subjectMetrics) {
2293
+ output.subject = {
2294
+ cert: params.cert,
2295
+ name: subjectProfile.NAME,
2296
+ city: subjectProfile.CITY,
2297
+ stalp: subjectProfile.STALP,
2298
+ bkclass: subjectProfile.BKCLASS,
2299
+ metrics: subjectMetrics,
2300
+ rankings
2301
+ };
2302
+ }
2303
+ output.peer_group = {
2304
+ repdte: params.repdte,
2305
+ criteria_used: criteriaUsed,
2306
+ medians
2307
+ };
2308
+ output.metric_definitions = METRIC_DEFINITIONS;
2309
+ output.peers = returnedPeers.map((p) => ({
2310
+ cert: p.cert,
2311
+ name: p.name,
2312
+ city: p.city,
2313
+ stalp: p.stalp,
2314
+ metrics: p.metrics,
2315
+ ...p.extraFields
2316
+ }));
2317
+ output.peer_count = peerCount;
2318
+ output.returned_count = returnedCount;
2319
+ output.has_more = hasMore;
2320
+ output.message = null;
2321
+ output.warnings = warnings;
2322
+ const text = truncateIfNeeded(
2323
+ formatPeerGroupText(
2324
+ params.repdte,
2325
+ subjectProfile,
2326
+ subjectMetrics,
2327
+ rankings,
2328
+ medians,
2329
+ returnedPeers,
2330
+ peerCount,
2331
+ warnings
2332
+ ),
2333
+ CHARACTER_LIMIT
2334
+ );
2335
+ return {
2336
+ content: [{ type: "text", text }],
2337
+ structuredContent: output
2338
+ };
2339
+ } catch (err) {
2340
+ if (controller.signal.aborted) {
2341
+ return formatToolError(
2342
+ new Error(
2343
+ `Peer group analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS2 / 1e3)} seconds. Narrow the peer group criteria and try again.`
2344
+ )
2345
+ );
2346
+ }
1691
2347
  return formatToolError(err);
2348
+ } finally {
2349
+ clearTimeout(timeoutId);
1692
2350
  }
1693
2351
  }
1694
2352
  );
@@ -1708,6 +2366,7 @@ function createServer() {
1708
2366
  registerSodTools(server);
1709
2367
  registerDemographicsTools(server);
1710
2368
  registerAnalysisTools(server);
2369
+ registerPeerGroupTools(server);
1711
2370
  return server;
1712
2371
  }
1713
2372
  async function runStdio() {