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.
- package/README.md +154 -185
- package/dist/index.js +732 -54
- package/dist/server.js +736 -56
- 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.
|
|
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
|
|
73
|
-
|
|
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) =>
|
|
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
|
|
1139
|
-
|
|
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 =
|
|
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(
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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(
|
|
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 [
|
|
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(
|
|
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
|
|
1866
|
+
warnings,
|
|
1758
1867
|
insights: buildTopLevelInsights(sortedComparisons),
|
|
1759
1868
|
...pagination,
|
|
1760
1869
|
comparisons: ranked
|
|
1761
1870
|
};
|
|
1762
1871
|
const text = truncateIfNeeded(
|
|
1763
1872
|
[
|
|
1764
|
-
|
|
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 =
|
|
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
|
});
|