fdic-mcp-server 1.0.8 → 1.1.1

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 (4) hide show
  1. package/README.md +154 -185
  2. package/dist/index.js +732 -54
  3. package/dist/server.js +736 -56
  4. package/package.json +5 -2
package/dist/server.js CHANGED
@@ -32,7 +32,8 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  createApp: () => createApp,
34
34
  createServer: () => createServer,
35
- main: () => main
35
+ main: () => main,
36
+ parseHttpPort: () => parseHttpPort
36
37
  });
37
38
  module.exports = __toCommonJS(index_exports);
38
39
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
@@ -41,7 +42,7 @@ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamable
41
42
  var import_express = __toESM(require("express"));
42
43
 
43
44
  // src/constants.ts
44
- var VERSION = true ? "1.0.8" : process.env.npm_package_version ?? "0.0.0-dev";
45
+ var VERSION = true ? "1.1.1" : process.env.npm_package_version ?? "0.0.0-dev";
45
46
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
46
47
  var CHARACTER_LIMIT = 5e4;
47
48
  var ENDPOINTS = {
@@ -66,12 +67,23 @@ var apiClient = import_axios.default.create({
66
67
  }
67
68
  });
68
69
  var QUERY_CACHE_TTL_MS = 6e4;
70
+ var QUERY_CACHE_MAX_ENTRIES = 500;
69
71
  var queryCache = /* @__PURE__ */ new Map();
70
72
  function pruneExpiredQueryCache(now) {
71
73
  for (const [key, entry] of queryCache.entries()) {
72
- if (entry.expiresAt <= now) {
73
- queryCache.delete(key);
74
+ if (entry.expiresAt > now) {
75
+ break;
74
76
  }
77
+ queryCache.delete(key);
78
+ }
79
+ }
80
+ function evictOverflowQueryCache() {
81
+ while (queryCache.size > QUERY_CACHE_MAX_ENTRIES) {
82
+ const oldestKey = queryCache.keys().next().value;
83
+ if (oldestKey === void 0) {
84
+ break;
85
+ }
86
+ queryCache.delete(oldestKey);
75
87
  }
76
88
  }
77
89
  function getCacheKey(endpoint, params) {
@@ -85,6 +97,38 @@ function getCacheKey(endpoint, params) {
85
97
  params.sort_order ?? null
86
98
  ]);
87
99
  }
100
+ function isRecord(value) {
101
+ return typeof value === "object" && value !== null;
102
+ }
103
+ function validateFdicResponseShape(endpoint, payload) {
104
+ if (!isRecord(payload)) {
105
+ throw new Error(
106
+ `Unexpected FDIC API response shape for endpoint ${endpoint}: expected an object payload.`
107
+ );
108
+ }
109
+ const { data, meta } = payload;
110
+ if (!Array.isArray(data)) {
111
+ throw new Error(
112
+ `Unexpected FDIC API response shape for endpoint ${endpoint}: expected 'data' to be an array.`
113
+ );
114
+ }
115
+ if (!isRecord(meta) || typeof meta.total !== "number") {
116
+ throw new Error(
117
+ `Unexpected FDIC API response shape for endpoint ${endpoint}: expected 'meta.total' to be a number.`
118
+ );
119
+ }
120
+ return {
121
+ data: data.map((item, index) => {
122
+ if (!isRecord(item) || !isRecord(item.data)) {
123
+ throw new Error(
124
+ `Unexpected FDIC API response shape for endpoint ${endpoint}: expected data[${index}] to contain an object 'data' property.`
125
+ );
126
+ }
127
+ return { data: item.data };
128
+ }),
129
+ meta: { total: meta.total }
130
+ };
131
+ }
88
132
  async function queryEndpoint(endpoint, params, options = {}) {
89
133
  if (options.signal?.aborted) {
90
134
  throw new Error("FDIC API request was canceled before it started.");
@@ -112,7 +156,7 @@ async function queryEndpoint(endpoint, params, options = {}) {
112
156
  params: queryParams,
113
157
  signal: options.signal
114
158
  });
115
- return response.data;
159
+ return validateFdicResponseShape(endpoint, response.data);
116
160
  } catch (err) {
117
161
  if (options.signal?.aborted || typeof err === "object" && err !== null && "code" in err && err.code === "ERR_CANCELED" || err instanceof DOMException && err.name === "AbortError") {
118
162
  throw new Error("FDIC API request was canceled.");
@@ -144,6 +188,7 @@ async function queryEndpoint(endpoint, params, options = {}) {
144
188
  expiresAt: now + QUERY_CACHE_TTL_MS,
145
189
  value: requestPromise
146
190
  });
191
+ evictOverflowQueryCache();
147
192
  }
148
193
  try {
149
194
  return await requestPromise;
@@ -155,7 +200,14 @@ async function queryEndpoint(endpoint, params, options = {}) {
155
200
  }
156
201
  }
157
202
  function extractRecords(response) {
158
- return response.data.map((item) => item.data);
203
+ return response.data.map((item, index) => {
204
+ if (!isRecord(item) || !isRecord(item.data)) {
205
+ throw new Error(
206
+ `Unexpected FDIC API response shape: expected data[${index}] to contain an object 'data' property.`
207
+ );
208
+ }
209
+ return item.data;
210
+ });
159
211
  }
160
212
  function buildPaginationInfo(total, offset, count) {
161
213
  const has_more = total > offset + count;
@@ -167,6 +219,10 @@ function buildPaginationInfo(total, offset, count) {
167
219
  ...has_more ? { next_offset: offset + count } : {}
168
220
  };
169
221
  }
222
+ function buildTruncationWarning(label, total, count, guidance) {
223
+ if (total <= count) return void 0;
224
+ return `${label} truncated to ${count.toLocaleString()} records out of ${total.toLocaleString()} matched rows. ${guidance}`;
225
+ }
170
226
  function truncateIfNeeded(text, charLimit) {
171
227
  if (text.length <= charLimit) return text;
172
228
  return text.slice(0, charLimit) + `
@@ -1084,9 +1140,40 @@ Prefer concise human-readable summaries or tables when answering users. Structur
1084
1140
 
1085
1141
  // src/tools/analysis.ts
1086
1142
  var import_zod7 = require("zod");
1143
+
1144
+ // src/tools/shared/queryUtils.ts
1087
1145
  var CHUNK_SIZE = 25;
1088
1146
  var MAX_CONCURRENCY = 4;
1089
1147
  var ANALYSIS_TIMEOUT_MS = 9e4;
1148
+ function asNumber(value) {
1149
+ return typeof value === "number" ? value : null;
1150
+ }
1151
+ function buildCertFilters(certs) {
1152
+ const filters = [];
1153
+ for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
1154
+ const chunk = certs.slice(i, i + CHUNK_SIZE);
1155
+ filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
1156
+ }
1157
+ return filters;
1158
+ }
1159
+ async function mapWithConcurrency(values, limit, mapper) {
1160
+ const results = new Array(values.length);
1161
+ let nextIndex = 0;
1162
+ async function worker() {
1163
+ while (true) {
1164
+ const currentIndex = nextIndex;
1165
+ nextIndex += 1;
1166
+ if (currentIndex >= values.length) return;
1167
+ results[currentIndex] = await mapper(values[currentIndex], currentIndex);
1168
+ }
1169
+ }
1170
+ await Promise.all(
1171
+ Array.from({ length: Math.min(limit, values.length) }, () => worker())
1172
+ );
1173
+ return results;
1174
+ }
1175
+
1176
+ // src/tools/analysis.ts
1090
1177
  var SortFieldSchema = import_zod7.z.enum([
1091
1178
  "asset_growth",
1092
1179
  "asset_growth_pct",
@@ -1135,32 +1222,9 @@ var SnapshotAnalysisSchema = import_zod7.z.object({
1135
1222
  });
1136
1223
  }
1137
1224
  });
1138
- function asNumber(value) {
1139
- return typeof value === "number" ? value : null;
1140
- }
1141
- function buildCertFilters(certs) {
1142
- const filters = [];
1143
- for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
1144
- const chunk = certs.slice(i, i + CHUNK_SIZE);
1145
- filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
1146
- }
1147
- return filters;
1148
- }
1149
- async function mapWithConcurrency(values, limit, mapper) {
1150
- const results = new Array(values.length);
1151
- let nextIndex = 0;
1152
- async function worker() {
1153
- while (true) {
1154
- const currentIndex = nextIndex;
1155
- nextIndex += 1;
1156
- if (currentIndex >= values.length) return;
1157
- results[currentIndex] = await mapper(values[currentIndex], currentIndex);
1158
- }
1159
- }
1160
- await Promise.all(
1161
- Array.from({ length: Math.min(limit, values.length) }, () => worker())
1162
- );
1163
- return results;
1225
+ function maxOrNull(values) {
1226
+ const nonNullValues = values.filter((value) => value !== null);
1227
+ return nonNullValues.length > 0 ? Math.max(...nonNullValues) : null;
1164
1228
  }
1165
1229
  function ratio(numerator, denominator) {
1166
1230
  if (numerator === null || denominator === null || denominator === 0) {
@@ -1344,7 +1408,7 @@ function summarizeTimeSeries(records, demographicsByDate, institution) {
1344
1408
  const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
1345
1409
  const depositsToAssetsStart = ratio(depStart, assetStart);
1346
1410
  const depositsToAssetsEnd = ratio(depEnd, assetEnd);
1347
- const peakAsset = Math.max(...assetSeries.filter((value) => value !== null));
1411
+ const peakAsset = maxOrNull(assetSeries);
1348
1412
  const troughRoaValues = roaSeries.filter((value) => value !== null);
1349
1413
  const troughRoa = troughRoaValues.length > 0 ? Math.min(...troughRoaValues) : null;
1350
1414
  const comparison = {
@@ -1467,29 +1531,46 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
1467
1531
  certFilter
1468
1532
  }))
1469
1533
  );
1470
- const responses = await mapWithConcurrency(tasks, MAX_CONCURRENCY, async (task) => {
1471
- const response = await queryEndpoint(endpoint, {
1472
- filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
1473
- fields,
1474
- limit: 1e4,
1475
- offset: 0,
1476
- sort_by: "CERT",
1477
- sort_order: "ASC"
1478
- }, { signal });
1479
- return { repdteFilter: task.repdteFilter, response };
1480
- });
1534
+ const responses = await mapWithConcurrency(
1535
+ tasks,
1536
+ MAX_CONCURRENCY,
1537
+ async (task) => {
1538
+ const response = await queryEndpoint(
1539
+ endpoint,
1540
+ {
1541
+ filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
1542
+ fields,
1543
+ limit: 1e4,
1544
+ offset: 0,
1545
+ sort_by: "CERT",
1546
+ sort_order: "ASC"
1547
+ },
1548
+ { signal }
1549
+ );
1550
+ return { repdteFilter: task.repdteFilter, response };
1551
+ }
1552
+ );
1481
1553
  const byDate = /* @__PURE__ */ new Map();
1554
+ const warnings = /* @__PURE__ */ new Set();
1482
1555
  for (const { repdteFilter, response } of responses) {
1483
1556
  if (!byDate.has(repdteFilter)) {
1484
1557
  byDate.set(repdteFilter, /* @__PURE__ */ new Map());
1485
1558
  }
1559
+ const records = extractRecords(response);
1560
+ const warning = buildTruncationWarning(
1561
+ `${endpoint} batch for ${repdteFilter}`,
1562
+ response.meta.total,
1563
+ records.length,
1564
+ "Narrow the comparison set with institution_filters or certs for complete analysis."
1565
+ );
1566
+ if (warning) warnings.add(warning);
1486
1567
  const target = byDate.get(repdteFilter);
1487
- for (const record of extractRecords(response)) {
1568
+ for (const record of records) {
1488
1569
  const cert = asNumber(record.CERT);
1489
1570
  if (cert !== null) target.set(cert, record);
1490
1571
  }
1491
1572
  }
1492
- return byDate;
1573
+ return { byDate, warnings: [...warnings] };
1493
1574
  }
1494
1575
  async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields, signal) {
1495
1576
  const certFilters = buildCertFilters(certs);
@@ -1506,15 +1587,24 @@ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, field
1506
1587
  }, { signal })
1507
1588
  );
1508
1589
  const grouped = /* @__PURE__ */ new Map();
1590
+ const warnings = /* @__PURE__ */ new Set();
1509
1591
  for (const response of responses) {
1510
- for (const record of extractRecords(response)) {
1592
+ const records = extractRecords(response);
1593
+ const warning = buildTruncationWarning(
1594
+ `${endpoint} batch for REPDTE:[${startRepdte} TO ${endRepdte}]`,
1595
+ response.meta.total,
1596
+ records.length,
1597
+ "Narrow the comparison set with certs or a shorter date range for complete analysis."
1598
+ );
1599
+ if (warning) warnings.add(warning);
1600
+ for (const record of records) {
1511
1601
  const cert = asNumber(record.CERT);
1512
1602
  if (cert === null) continue;
1513
1603
  if (!grouped.has(cert)) grouped.set(cert, []);
1514
1604
  grouped.get(cert).push(record);
1515
1605
  }
1516
1606
  }
1517
- return grouped;
1607
+ return { grouped, warnings: [...warnings] };
1518
1608
  }
1519
1609
  function buildSnapshotComparison(cert, institution, startFinancial, endFinancial, startDemo, endDemo, startRepdte, endRepdte) {
1520
1610
  const assetStart = asNumber(startFinancial.ASSET);
@@ -1671,8 +1761,9 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1671
1761
  )
1672
1762
  );
1673
1763
  let comparisons = [];
1764
+ const warnings = rosterResult.warning ? [rosterResult.warning] : [];
1674
1765
  if (analysis_mode === "timeseries") {
1675
- const [financialSeries, demographicsSeries] = await Promise.all([
1766
+ const [financialSeriesResult, demographicsSeriesResult] = await Promise.all([
1676
1767
  fetchSeriesRecords(
1677
1768
  ENDPOINTS.FINANCIALS,
1678
1769
  candidateCerts,
@@ -1688,8 +1779,17 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1688
1779
  end_repdte,
1689
1780
  "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1690
1781
  controller.signal
1691
- ) : Promise.resolve(/* @__PURE__ */ new Map())
1782
+ ) : Promise.resolve({
1783
+ grouped: /* @__PURE__ */ new Map(),
1784
+ warnings: []
1785
+ })
1692
1786
  ]);
1787
+ warnings.push(
1788
+ ...financialSeriesResult.warnings,
1789
+ ...demographicsSeriesResult.warnings
1790
+ );
1791
+ const financialSeries = financialSeriesResult.grouped;
1792
+ const demographicsSeries = demographicsSeriesResult.grouped;
1693
1793
  comparisons = candidateCerts.map(
1694
1794
  (cert) => summarizeTimeSeries(
1695
1795
  financialSeries.get(cert) ?? [],
@@ -1703,7 +1803,7 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1703
1803
  )
1704
1804
  ).filter((comparison) => comparison !== null);
1705
1805
  } else {
1706
- const [financialSnapshots, demographicSnapshots] = await Promise.all([
1806
+ const [financialSnapshotsResult, demographicSnapshotsResult] = await Promise.all([
1707
1807
  fetchBatchedRecordsForDates(
1708
1808
  ENDPOINTS.FINANCIALS,
1709
1809
  candidateCerts,
@@ -1717,8 +1817,17 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1717
1817
  [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1718
1818
  "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1719
1819
  controller.signal
1720
- ) : Promise.resolve(/* @__PURE__ */ new Map())
1820
+ ) : Promise.resolve({
1821
+ byDate: /* @__PURE__ */ new Map(),
1822
+ warnings: []
1823
+ })
1721
1824
  ]);
1825
+ warnings.push(
1826
+ ...financialSnapshotsResult.warnings,
1827
+ ...demographicSnapshotsResult.warnings
1828
+ );
1829
+ const financialSnapshots = financialSnapshotsResult.byDate;
1830
+ const demographicSnapshots = demographicSnapshotsResult.byDate;
1722
1831
  const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
1723
1832
  const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
1724
1833
  const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
@@ -1754,14 +1863,14 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1754
1863
  analysis_mode,
1755
1864
  sort_by,
1756
1865
  sort_order,
1757
- warnings: rosterResult.warning ? [rosterResult.warning] : [],
1866
+ warnings,
1758
1867
  insights: buildTopLevelInsights(sortedComparisons),
1759
1868
  ...pagination,
1760
1869
  comparisons: ranked
1761
1870
  };
1762
1871
  const text = truncateIfNeeded(
1763
1872
  [
1764
- rosterResult.warning ? `Warning: ${rosterResult.warning}` : null,
1873
+ ...warnings.map((warning) => `Warning: ${warning}`),
1765
1874
  formatComparisonText(output)
1766
1875
  ].filter((value) => value !== null).join("\n\n"),
1767
1876
  CHARACTER_LIMIT
@@ -1786,6 +1895,565 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1786
1895
  );
1787
1896
  }
1788
1897
 
1898
+ // src/tools/peerGroup.ts
1899
+ var import_zod8 = require("zod");
1900
+
1901
+ // src/tools/shared/financialMetrics.ts
1902
+ function safeRatio(numerator, denominator) {
1903
+ if (numerator === null || denominator === null || denominator === 0) {
1904
+ return null;
1905
+ }
1906
+ return numerator / denominator;
1907
+ }
1908
+ function safeRatioPositiveDenom(numerator, denominator) {
1909
+ if (numerator === null || denominator === null || denominator <= 0) {
1910
+ return null;
1911
+ }
1912
+ return numerator / denominator;
1913
+ }
1914
+ function deriveMetrics(raw) {
1915
+ const asset = asNumber(raw.ASSET);
1916
+ const dep = asNumber(raw.DEP);
1917
+ const eqtot = asNumber(raw.EQTOT);
1918
+ const lnlsnet = asNumber(raw.LNLSNET);
1919
+ const intinc = asNumber(raw.INTINC);
1920
+ const eintexp = asNumber(raw.EINTEXP);
1921
+ const nonii = asNumber(raw.NONII);
1922
+ const nonix = asNumber(raw.NONIX);
1923
+ const netInterestIncome = intinc !== null && eintexp !== null ? intinc - eintexp : null;
1924
+ const revenueDenominator = netInterestIncome !== null && nonii !== null ? netInterestIncome + nonii : null;
1925
+ const equityRatioRaw = safeRatio(eqtot, asset);
1926
+ const efficiencyRatioRaw = safeRatioPositiveDenom(nonix, revenueDenominator);
1927
+ return {
1928
+ asset,
1929
+ dep,
1930
+ roa: asNumber(raw.ROA),
1931
+ roe: asNumber(raw.ROE),
1932
+ netnim: asNumber(raw.NETNIM),
1933
+ equity_ratio: equityRatioRaw !== null ? equityRatioRaw * 100 : null,
1934
+ efficiency_ratio: efficiencyRatioRaw !== null ? efficiencyRatioRaw * 100 : null,
1935
+ loan_to_deposit: safeRatio(lnlsnet, dep),
1936
+ deposits_to_assets: safeRatio(dep, asset),
1937
+ noninterest_income_share: safeRatioPositiveDenom(nonii, revenueDenominator)
1938
+ };
1939
+ }
1940
+ function computeMedian(values) {
1941
+ if (values.length === 0) return null;
1942
+ const sorted = [...values].sort((a, b) => a - b);
1943
+ const mid = Math.floor(sorted.length / 2);
1944
+ if (sorted.length % 2 === 1) return sorted[mid];
1945
+ return (sorted[mid - 1] + sorted[mid]) / 2;
1946
+ }
1947
+
1948
+ // src/tools/peerGroup.ts
1949
+ function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
1950
+ if (peerValues.length === 0) return null;
1951
+ const ascending = higherIsBetter === false;
1952
+ const all = [...peerValues, subjectValue];
1953
+ const sorted = [...all].sort(
1954
+ (a, b) => ascending ? a - b : b - a
1955
+ );
1956
+ const ranks = /* @__PURE__ */ new Map();
1957
+ for (let i = 0; i < sorted.length; i++) {
1958
+ if (!ranks.has(sorted[i])) {
1959
+ ranks.set(sorted[i], i + 1);
1960
+ }
1961
+ }
1962
+ const rank = ranks.get(subjectValue);
1963
+ const of = all.length;
1964
+ const percentile = Math.round((1 - (rank - 1) / of) * 100);
1965
+ return { rank, of, percentile };
1966
+ }
1967
+ function formatRepdteHuman(repdte) {
1968
+ if (repdte.length !== 8) return repdte;
1969
+ const year = repdte.slice(0, 4);
1970
+ const month = repdte.slice(4, 6);
1971
+ const day = repdte.slice(6, 8);
1972
+ const date = /* @__PURE__ */ new Date(`${year}-${month}-${day}T00:00:00Z`);
1973
+ if (Number.isNaN(date.getTime())) return repdte;
1974
+ return date.toLocaleDateString("en-US", {
1975
+ year: "numeric",
1976
+ month: "long",
1977
+ day: "numeric",
1978
+ timeZone: "UTC"
1979
+ });
1980
+ }
1981
+ var METRIC_KEYS = [
1982
+ "asset",
1983
+ "dep",
1984
+ "roa",
1985
+ "roe",
1986
+ "netnim",
1987
+ "equity_ratio",
1988
+ "efficiency_ratio",
1989
+ "loan_to_deposit",
1990
+ "deposits_to_assets",
1991
+ "noninterest_income_share"
1992
+ ];
1993
+ var METRIC_DEFINITIONS = {
1994
+ asset: { higher_is_better: true, unit: "$thousands", label: "Total Assets" },
1995
+ dep: { higher_is_better: true, unit: "$thousands", label: "Total Deposits" },
1996
+ roa: { higher_is_better: true, unit: "%", label: "Return on Assets" },
1997
+ roe: { higher_is_better: true, unit: "%", label: "Return on Equity" },
1998
+ netnim: {
1999
+ higher_is_better: true,
2000
+ unit: "%",
2001
+ label: "Net Interest Margin"
2002
+ },
2003
+ equity_ratio: {
2004
+ higher_is_better: true,
2005
+ unit: "%",
2006
+ label: "Equity Capital Ratio"
2007
+ },
2008
+ efficiency_ratio: {
2009
+ higher_is_better: false,
2010
+ unit: "%",
2011
+ label: "Efficiency Ratio"
2012
+ },
2013
+ loan_to_deposit: {
2014
+ higher_is_better: null,
2015
+ unit: "ratio",
2016
+ label: "Loan-to-Deposit Ratio",
2017
+ ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
2018
+ },
2019
+ deposits_to_assets: {
2020
+ higher_is_better: null,
2021
+ unit: "ratio",
2022
+ label: "Deposits-to-Assets Ratio",
2023
+ ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
2024
+ },
2025
+ noninterest_income_share: {
2026
+ higher_is_better: true,
2027
+ unit: "ratio",
2028
+ label: "Non-Interest Income Share"
2029
+ }
2030
+ };
2031
+ var PeerGroupInputSchema = import_zod8.z.object({
2032
+ cert: import_zod8.z.number().int().positive().optional().describe(
2033
+ "Subject institution CERT number. When provided, auto-derives peer criteria and ranks this bank against peers."
2034
+ ),
2035
+ repdte: import_zod8.z.string().regex(/^\d{8}$/).describe("Report date in YYYYMMDD format."),
2036
+ asset_min: import_zod8.z.number().positive().optional().describe(
2037
+ "Minimum total assets ($thousands) for peer selection. Defaults to 50% of subject's report-date assets when cert is provided."
2038
+ ),
2039
+ asset_max: import_zod8.z.number().positive().optional().describe(
2040
+ "Maximum total assets ($thousands) for peer selection. Defaults to 200% of subject's report-date assets when cert is provided."
2041
+ ),
2042
+ charter_classes: import_zod8.z.array(import_zod8.z.string()).optional().describe(
2043
+ `Charter class codes to include (e.g., ["N", "SM"]). Defaults to the subject's charter class when cert is provided.`
2044
+ ),
2045
+ state: import_zod8.z.string().regex(/^[A-Z]{2}$/).optional().describe('Two-letter state code (e.g., "NC", "TX").'),
2046
+ raw_filter: import_zod8.z.string().optional().describe(
2047
+ "Advanced: raw ElasticSearch query string appended to peer selection criteria with AND."
2048
+ ),
2049
+ active_only: import_zod8.z.boolean().default(true).describe(
2050
+ "Limit to institutions where ACTIVE:1 (currently operating, FDIC-insured)."
2051
+ ),
2052
+ extra_fields: import_zod8.z.array(import_zod8.z.string()).optional().describe(
2053
+ "Additional FDIC field names to include as raw values in the response. Does not affect peer selection."
2054
+ ),
2055
+ limit: import_zod8.z.number().int().min(1).max(500).default(50).describe(
2056
+ "Max peer records returned in the response. All matched peers are used for ranking regardless of this limit."
2057
+ )
2058
+ }).superRefine((value, ctx) => {
2059
+ if (!value.cert && value.asset_min === void 0 && value.asset_max === void 0 && !value.charter_classes && !value.state && !value.raw_filter) {
2060
+ ctx.addIssue({
2061
+ code: import_zod8.z.ZodIssueCode.custom,
2062
+ message: "At least one peer-group constructor is required: cert, asset_min, asset_max, charter_classes, state, or raw_filter.",
2063
+ path: ["cert"]
2064
+ });
2065
+ }
2066
+ if (value.asset_min !== void 0 && value.asset_max !== void 0 && value.asset_min > value.asset_max) {
2067
+ ctx.addIssue({
2068
+ code: import_zod8.z.ZodIssueCode.custom,
2069
+ message: "asset_min must be <= asset_max.",
2070
+ path: ["asset_min"]
2071
+ });
2072
+ }
2073
+ });
2074
+ var FINANCIAL_FIELDS = "CERT,ASSET,DEP,NETINC,ROA,ROE,NETNIM,EQTOT,LNLSNET,INTINC,EINTEXP,NONII,NONIX";
2075
+ function formatMetricValue(key, value) {
2076
+ if (value === null) return "n/a";
2077
+ const def = METRIC_DEFINITIONS[key];
2078
+ if (def.unit === "$thousands")
2079
+ return `$${Math.round(value).toLocaleString()}k`;
2080
+ if (def.unit === "%") return `${value.toFixed(4)}%`;
2081
+ return value.toFixed(4);
2082
+ }
2083
+ function ordinalSuffix(n) {
2084
+ const mod100 = n % 100;
2085
+ if (mod100 >= 11 && mod100 <= 13) return `${n}th`;
2086
+ const mod10 = n % 10;
2087
+ if (mod10 === 1) return `${n}st`;
2088
+ if (mod10 === 2) return `${n}nd`;
2089
+ if (mod10 === 3) return `${n}rd`;
2090
+ return `${n}th`;
2091
+ }
2092
+ function formatPeerGroupText(repdte, subjectProfile, subjectMetrics, rankings, medians, returnedPeers, peerCount, warnings) {
2093
+ const parts = [];
2094
+ for (const warning of warnings) {
2095
+ parts.push(`Warning: ${warning}`);
2096
+ }
2097
+ const dateStr = formatRepdteHuman(repdte);
2098
+ const subjectLabel = subjectProfile ? ` for ${subjectProfile.NAME} (CERT ${subjectProfile.CERT ?? ""})` : "";
2099
+ parts.push(`Peer group analysis${subjectLabel} as of ${dateStr}.`);
2100
+ parts.push(`${peerCount} peers matched.`);
2101
+ if (subjectMetrics && subjectProfile) {
2102
+ parts.push("");
2103
+ parts.push("Subject rankings:");
2104
+ for (const key of METRIC_KEYS) {
2105
+ const def = METRIC_DEFINITIONS[key];
2106
+ const ranking = rankings[key];
2107
+ const value = formatMetricValue(key, subjectMetrics[key]);
2108
+ const medianValue = formatMetricValue(key, medians[key] ?? null);
2109
+ if (ranking) {
2110
+ const pctLabel = `${ordinalSuffix(ranking.percentile)} percentile`;
2111
+ parts.push(
2112
+ ` ${def.label.padEnd(28)} rank ${String(ranking.rank).padStart(2)} of ${String(ranking.of).padEnd(4)} (${pctLabel.padEnd(18)}) ${value.padStart(16)} median: ${medianValue}`
2113
+ );
2114
+ } else {
2115
+ parts.push(` ${def.label.padEnd(28)} n/a`);
2116
+ }
2117
+ }
2118
+ } else if (peerCount > 0) {
2119
+ parts.push("");
2120
+ parts.push("Peer group medians:");
2121
+ const medianParts = METRIC_KEYS.filter((k) => medians[k] !== null).map(
2122
+ (k) => `${METRIC_DEFINITIONS[k].label}: ${formatMetricValue(k, medians[k] ?? null)}`
2123
+ );
2124
+ parts.push(` ${medianParts.join(" | ")}`);
2125
+ }
2126
+ if (returnedPeers.length > 0) {
2127
+ parts.push("");
2128
+ parts.push(`Peers (${returnedPeers.length} returned):`);
2129
+ for (let i = 0; i < returnedPeers.length; i++) {
2130
+ const p = returnedPeers[i];
2131
+ const location = [p.city, p.stalp].filter(Boolean).join(" ");
2132
+ const locationStr = location ? `, ${location}` : "";
2133
+ parts.push(
2134
+ `${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)}`
2135
+ );
2136
+ }
2137
+ }
2138
+ return parts.join("\n");
2139
+ }
2140
+ function registerPeerGroupTools(server) {
2141
+ server.registerTool(
2142
+ "fdic_peer_group_analysis",
2143
+ {
2144
+ title: "Peer Group Analysis",
2145
+ 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.
2146
+
2147
+ Three usage modes:
2148
+ - Subject-driven: provide cert and repdte \u2014 auto-derives peer criteria from the subject's asset size and charter class
2149
+ - Explicit criteria: provide repdte plus asset_min/asset_max, charter_classes, state, or raw_filter
2150
+ - Subject with overrides: provide cert plus explicit criteria to override auto-derived defaults
2151
+
2152
+ Metrics ranked (fixed order):
2153
+ - Total Assets, Total Deposits, ROA, ROE, Net Interest Margin
2154
+ - Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio
2155
+ - Deposits-to-Assets Ratio, Non-Interest Income Share
2156
+
2157
+ Rankings use competition rank (1, 2, 2, 4). Rank, denominator, and percentile all use the same comparison set: matched peers plus the subject institution.
2158
+
2159
+ Output includes:
2160
+ - Subject rankings and percentiles (when cert provided)
2161
+ - Peer group medians
2162
+ - Peer list with CERTs (pass to fdic_compare_bank_snapshots for trend analysis)
2163
+ - Metric definitions with directionality metadata
2164
+
2165
+ Override precedence: cert derives defaults, then explicit params override them.`,
2166
+ inputSchema: PeerGroupInputSchema,
2167
+ annotations: {
2168
+ readOnlyHint: true,
2169
+ destructiveHint: false,
2170
+ idempotentHint: true,
2171
+ openWorldHint: true
2172
+ }
2173
+ },
2174
+ async (params) => {
2175
+ const controller = new AbortController();
2176
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
2177
+ try {
2178
+ const warnings = [];
2179
+ let subjectProfile = null;
2180
+ let subjectFinancials = null;
2181
+ if (params.cert) {
2182
+ const [profileResponse, financialsResponse] = await Promise.all([
2183
+ queryEndpoint(
2184
+ ENDPOINTS.INSTITUTIONS,
2185
+ {
2186
+ filters: `CERT:${params.cert}`,
2187
+ fields: "CERT,NAME,CITY,STALP,BKCLASS",
2188
+ limit: 1
2189
+ },
2190
+ { signal: controller.signal }
2191
+ ),
2192
+ queryEndpoint(
2193
+ ENDPOINTS.FINANCIALS,
2194
+ {
2195
+ filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
2196
+ fields: FINANCIAL_FIELDS,
2197
+ limit: 1
2198
+ },
2199
+ { signal: controller.signal }
2200
+ )
2201
+ ]);
2202
+ const profileRecords = extractRecords(profileResponse);
2203
+ if (profileRecords.length === 0) {
2204
+ return formatToolError(
2205
+ new Error(
2206
+ `No institution found with CERT number ${params.cert}.`
2207
+ )
2208
+ );
2209
+ }
2210
+ subjectProfile = profileRecords[0];
2211
+ const financialRecords = extractRecords(financialsResponse);
2212
+ if (financialRecords.length === 0) {
2213
+ return formatToolError(
2214
+ new Error(
2215
+ `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.`
2216
+ )
2217
+ );
2218
+ }
2219
+ subjectFinancials = financialRecords[0];
2220
+ }
2221
+ const subjectAsset = subjectFinancials && typeof subjectFinancials.ASSET === "number" ? subjectFinancials.ASSET : null;
2222
+ const assetMin = params.asset_min ?? (subjectAsset !== null ? subjectAsset * 0.5 : void 0);
2223
+ const assetMax = params.asset_max ?? (subjectAsset !== null ? subjectAsset * 2 : void 0);
2224
+ const charterClasses = params.charter_classes ?? (subjectProfile && typeof subjectProfile.BKCLASS === "string" ? [subjectProfile.BKCLASS] : void 0);
2225
+ const { state, active_only, raw_filter } = params;
2226
+ const filterParts = [];
2227
+ if (assetMin !== void 0 || assetMax !== void 0) {
2228
+ const min = assetMin ?? 0;
2229
+ const max = assetMax ?? "*";
2230
+ filterParts.push(`ASSET:[${min} TO ${max}]`);
2231
+ }
2232
+ if (charterClasses && charterClasses.length > 0) {
2233
+ const classFilter = charterClasses.map((cls) => `BKCLASS:${cls}`).join(" OR ");
2234
+ filterParts.push(
2235
+ charterClasses.length > 1 ? `(${classFilter})` : classFilter
2236
+ );
2237
+ }
2238
+ if (state) filterParts.push(`STALP:${state}`);
2239
+ if (active_only) filterParts.push("ACTIVE:1");
2240
+ if (raw_filter) filterParts.push(`(${raw_filter})`);
2241
+ const rosterResponse = await queryEndpoint(
2242
+ ENDPOINTS.INSTITUTIONS,
2243
+ {
2244
+ filters: filterParts.join(" AND "),
2245
+ fields: "CERT,NAME,CITY,STALP,BKCLASS",
2246
+ limit: 1e4,
2247
+ offset: 0,
2248
+ sort_by: "CERT",
2249
+ sort_order: "ASC"
2250
+ },
2251
+ { signal: controller.signal }
2252
+ );
2253
+ let rosterRecords = extractRecords(rosterResponse);
2254
+ if (rosterResponse.meta.total > rosterRecords.length) {
2255
+ warnings.push(
2256
+ `Institution roster truncated to ${rosterRecords.length.toLocaleString()} records out of ${rosterResponse.meta.total.toLocaleString()} matched institutions. Narrow the peer group criteria for complete analysis.`
2257
+ );
2258
+ }
2259
+ if (params.cert) {
2260
+ rosterRecords = rosterRecords.filter(
2261
+ (r) => asNumber(r.CERT) !== params.cert
2262
+ );
2263
+ }
2264
+ const criteriaUsed = {
2265
+ asset_min: assetMin ?? null,
2266
+ asset_max: assetMax ?? null,
2267
+ charter_classes: charterClasses ?? null,
2268
+ state: state ?? null,
2269
+ active_only,
2270
+ raw_filter: raw_filter ?? null
2271
+ };
2272
+ if (rosterRecords.length === 0) {
2273
+ const subjectMetrics2 = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
2274
+ const output2 = {};
2275
+ if (subjectProfile) {
2276
+ output2.subject = {
2277
+ cert: params.cert,
2278
+ name: subjectProfile.NAME,
2279
+ city: subjectProfile.CITY,
2280
+ stalp: subjectProfile.STALP,
2281
+ bkclass: subjectProfile.BKCLASS,
2282
+ metrics: subjectMetrics2,
2283
+ rankings: null
2284
+ };
2285
+ }
2286
+ output2.peer_group = {
2287
+ repdte: params.repdte,
2288
+ criteria_used: criteriaUsed,
2289
+ medians: {}
2290
+ };
2291
+ output2.metric_definitions = METRIC_DEFINITIONS;
2292
+ output2.peers = [];
2293
+ output2.peer_count = 0;
2294
+ output2.returned_count = 0;
2295
+ output2.has_more = false;
2296
+ output2.message = "No peers matched the specified criteria.";
2297
+ output2.warnings = warnings;
2298
+ const text2 = formatPeerGroupText(
2299
+ params.repdte,
2300
+ subjectProfile,
2301
+ subjectMetrics2,
2302
+ {},
2303
+ {},
2304
+ [],
2305
+ 0,
2306
+ warnings
2307
+ );
2308
+ return {
2309
+ content: [{ type: "text", text: text2 }],
2310
+ structuredContent: output2
2311
+ };
2312
+ }
2313
+ const peerCerts = rosterRecords.map((r) => asNumber(r.CERT)).filter((c) => c !== null);
2314
+ const certFilters = buildCertFilters(peerCerts);
2315
+ const extraFieldsCsv = params.extra_fields && params.extra_fields.length > 0 ? "," + params.extra_fields.join(",") : "";
2316
+ const financialResponses = await mapWithConcurrency(
2317
+ certFilters,
2318
+ MAX_CONCURRENCY,
2319
+ async (certFilter) => queryEndpoint(
2320
+ ENDPOINTS.FINANCIALS,
2321
+ {
2322
+ filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
2323
+ fields: FINANCIAL_FIELDS + extraFieldsCsv,
2324
+ limit: 1e4,
2325
+ offset: 0,
2326
+ sort_by: "CERT",
2327
+ sort_order: "ASC"
2328
+ },
2329
+ { signal: controller.signal }
2330
+ )
2331
+ );
2332
+ const peerFinancialsByCert = /* @__PURE__ */ new Map();
2333
+ for (const response of financialResponses) {
2334
+ const records = extractRecords(response);
2335
+ const warning = buildTruncationWarning(
2336
+ `financials batch for REPDTE:${params.repdte}`,
2337
+ response.meta.total,
2338
+ records.length,
2339
+ "Narrow the peer group criteria for complete analysis."
2340
+ );
2341
+ if (warning && !warnings.includes(warning)) warnings.push(warning);
2342
+ for (const record of records) {
2343
+ const cert = asNumber(record.CERT);
2344
+ if (cert !== null) peerFinancialsByCert.set(cert, record);
2345
+ }
2346
+ }
2347
+ const rosterByCert = new Map(
2348
+ rosterRecords.map((r) => [asNumber(r.CERT), r]).filter(
2349
+ (e) => e[0] !== null
2350
+ )
2351
+ );
2352
+ const peers = [];
2353
+ for (const [cert, financials] of peerFinancialsByCert) {
2354
+ const roster = rosterByCert.get(cert);
2355
+ const metrics = deriveMetrics(financials);
2356
+ const extraFields = {};
2357
+ if (params.extra_fields) {
2358
+ for (const field of params.extra_fields) {
2359
+ extraFields[field] = financials[field] ?? null;
2360
+ }
2361
+ }
2362
+ peers.push({
2363
+ cert,
2364
+ name: String(roster?.NAME ?? financials.NAME ?? cert),
2365
+ city: roster?.CITY != null ? String(roster.CITY) : null,
2366
+ stalp: roster?.STALP != null ? String(roster.STALP) : null,
2367
+ bkclass: roster?.BKCLASS != null ? String(roster.BKCLASS) : null,
2368
+ metrics,
2369
+ extraFields
2370
+ });
2371
+ }
2372
+ const peerCount = peers.length;
2373
+ const subjectMetrics = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
2374
+ const rankings = {};
2375
+ const medians = {};
2376
+ for (const key of METRIC_KEYS) {
2377
+ const peerValues = peers.map((p) => p.metrics[key]).filter((v) => v !== null);
2378
+ medians[key] = computeMedian(peerValues);
2379
+ if (subjectMetrics && subjectMetrics[key] !== null) {
2380
+ rankings[key] = computeCompetitionRank(
2381
+ subjectMetrics[key],
2382
+ peerValues,
2383
+ METRIC_DEFINITIONS[key].higher_is_better
2384
+ );
2385
+ } else {
2386
+ rankings[key] = null;
2387
+ }
2388
+ }
2389
+ peers.sort((a, b) => (b.metrics.asset ?? 0) - (a.metrics.asset ?? 0));
2390
+ const returnedPeers = peers.slice(0, params.limit);
2391
+ const returnedCount = returnedPeers.length;
2392
+ const hasMore = peerCount > returnedCount;
2393
+ const output = {};
2394
+ if (subjectProfile && subjectMetrics) {
2395
+ output.subject = {
2396
+ cert: params.cert,
2397
+ name: subjectProfile.NAME,
2398
+ city: subjectProfile.CITY,
2399
+ stalp: subjectProfile.STALP,
2400
+ bkclass: subjectProfile.BKCLASS,
2401
+ metrics: subjectMetrics,
2402
+ rankings
2403
+ };
2404
+ }
2405
+ output.peer_group = {
2406
+ repdte: params.repdte,
2407
+ criteria_used: criteriaUsed,
2408
+ medians
2409
+ };
2410
+ output.metric_definitions = METRIC_DEFINITIONS;
2411
+ output.peers = returnedPeers.map((p) => ({
2412
+ cert: p.cert,
2413
+ name: p.name,
2414
+ city: p.city,
2415
+ stalp: p.stalp,
2416
+ metrics: p.metrics,
2417
+ ...p.extraFields
2418
+ }));
2419
+ output.peer_count = peerCount;
2420
+ output.returned_count = returnedCount;
2421
+ output.has_more = hasMore;
2422
+ output.message = null;
2423
+ output.warnings = warnings;
2424
+ const text = truncateIfNeeded(
2425
+ formatPeerGroupText(
2426
+ params.repdte,
2427
+ subjectProfile,
2428
+ subjectMetrics,
2429
+ rankings,
2430
+ medians,
2431
+ returnedPeers,
2432
+ peerCount,
2433
+ warnings
2434
+ ),
2435
+ CHARACTER_LIMIT
2436
+ );
2437
+ return {
2438
+ content: [{ type: "text", text }],
2439
+ structuredContent: output
2440
+ };
2441
+ } catch (err) {
2442
+ if (controller.signal.aborted) {
2443
+ return formatToolError(
2444
+ new Error(
2445
+ `Peer group analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the peer group criteria and try again.`
2446
+ )
2447
+ );
2448
+ }
2449
+ return formatToolError(err);
2450
+ } finally {
2451
+ clearTimeout(timeoutId);
2452
+ }
2453
+ }
2454
+ );
2455
+ }
2456
+
1789
2457
  // src/index.ts
1790
2458
  function createServer() {
1791
2459
  const server = new import_mcp.McpServer({
@@ -1800,6 +2468,7 @@ function createServer() {
1800
2468
  registerSodTools(server);
1801
2469
  registerDemographicsTools(server);
1802
2470
  registerAnalysisTools(server);
2471
+ registerPeerGroupTools(server);
1803
2472
  return server;
1804
2473
  }
1805
2474
  async function runStdio() {
@@ -1808,6 +2477,16 @@ async function runStdio() {
1808
2477
  await server.connect(transport);
1809
2478
  console.error("FDIC BankFind MCP server running on stdio");
1810
2479
  }
2480
+ function parseHttpPort(rawPort) {
2481
+ const port = Number.parseInt(rawPort ?? "3000", 10);
2482
+ if (Number.isNaN(port)) {
2483
+ throw new Error(`Invalid PORT value: ${rawPort ?? ""}`);
2484
+ }
2485
+ if (port < 0 || port > 65535) {
2486
+ throw new Error(`PORT must be between 0 and 65535. Received: ${port}`);
2487
+ }
2488
+ return port;
2489
+ }
1811
2490
  function createApp() {
1812
2491
  const app = (0, import_express.default)();
1813
2492
  app.use(import_express.default.json());
@@ -1851,7 +2530,7 @@ function createApp() {
1851
2530
  }
1852
2531
  async function runHTTP() {
1853
2532
  const app = createApp();
1854
- const port = parseInt(process.env.PORT ?? "3000");
2533
+ const port = parseHttpPort(process.env.PORT);
1855
2534
  app.listen(port, () => {
1856
2535
  console.error(
1857
2536
  `FDIC BankFind MCP server running on http://localhost:${port}/mcp`
@@ -1870,5 +2549,6 @@ async function main() {
1870
2549
  0 && (module.exports = {
1871
2550
  createApp,
1872
2551
  createServer,
1873
- main
2552
+ main,
2553
+ parseHttpPort
1874
2554
  });