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