fdic-mcp-server 1.1.0 → 1.1.2
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 +129 -294
- package/dist/index.js +207 -107
- package/dist/server.js +211 -109
- package/package.json +3 -1
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.1.
|
|
45
|
+
var VERSION = true ? "1.1.2" : 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
|
|
@@ -1788,9 +1897,8 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
|
|
|
1788
1897
|
|
|
1789
1898
|
// src/tools/peerGroup.ts
|
|
1790
1899
|
var import_zod8 = require("zod");
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
}
|
|
1900
|
+
|
|
1901
|
+
// src/tools/shared/financialMetrics.ts
|
|
1794
1902
|
function safeRatio(numerator, denominator) {
|
|
1795
1903
|
if (numerator === null || denominator === null || denominator === 0) {
|
|
1796
1904
|
return null;
|
|
@@ -1804,14 +1912,14 @@ function safeRatioPositiveDenom(numerator, denominator) {
|
|
|
1804
1912
|
return numerator / denominator;
|
|
1805
1913
|
}
|
|
1806
1914
|
function deriveMetrics(raw) {
|
|
1807
|
-
const asset =
|
|
1808
|
-
const dep =
|
|
1809
|
-
const eqtot =
|
|
1810
|
-
const lnlsnet =
|
|
1811
|
-
const intinc =
|
|
1812
|
-
const eintexp =
|
|
1813
|
-
const nonii =
|
|
1814
|
-
const nonix =
|
|
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);
|
|
1815
1923
|
const netInterestIncome = intinc !== null && eintexp !== null ? intinc - eintexp : null;
|
|
1816
1924
|
const revenueDenominator = netInterestIncome !== null && nonii !== null ? netInterestIncome + nonii : null;
|
|
1817
1925
|
const equityRatioRaw = safeRatio(eqtot, asset);
|
|
@@ -1819,9 +1927,9 @@ function deriveMetrics(raw) {
|
|
|
1819
1927
|
return {
|
|
1820
1928
|
asset,
|
|
1821
1929
|
dep,
|
|
1822
|
-
roa:
|
|
1823
|
-
roe:
|
|
1824
|
-
netnim:
|
|
1930
|
+
roa: asNumber(raw.ROA),
|
|
1931
|
+
roe: asNumber(raw.ROE),
|
|
1932
|
+
netnim: asNumber(raw.NETNIM),
|
|
1825
1933
|
equity_ratio: equityRatioRaw !== null ? equityRatioRaw * 100 : null,
|
|
1826
1934
|
efficiency_ratio: efficiencyRatioRaw !== null ? efficiencyRatioRaw * 100 : null,
|
|
1827
1935
|
loan_to_deposit: safeRatio(lnlsnet, dep),
|
|
@@ -1836,6 +1944,8 @@ function computeMedian(values) {
|
|
|
1836
1944
|
if (sorted.length % 2 === 1) return sorted[mid];
|
|
1837
1945
|
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
1838
1946
|
}
|
|
1947
|
+
|
|
1948
|
+
// src/tools/peerGroup.ts
|
|
1839
1949
|
function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
|
|
1840
1950
|
if (peerValues.length === 0) return null;
|
|
1841
1951
|
const ascending = higherIsBetter === false;
|
|
@@ -1850,7 +1960,7 @@ function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
|
|
|
1850
1960
|
}
|
|
1851
1961
|
}
|
|
1852
1962
|
const rank = ranks.get(subjectValue);
|
|
1853
|
-
const of =
|
|
1963
|
+
const of = all.length;
|
|
1854
1964
|
const percentile = Math.round((1 - (rank - 1) / of) * 100);
|
|
1855
1965
|
return { rank, of, percentile };
|
|
1856
1966
|
}
|
|
@@ -1961,34 +2071,7 @@ var PeerGroupInputSchema = import_zod8.z.object({
|
|
|
1961
2071
|
});
|
|
1962
2072
|
}
|
|
1963
2073
|
});
|
|
1964
|
-
var CHUNK_SIZE2 = 25;
|
|
1965
|
-
var MAX_CONCURRENCY2 = 4;
|
|
1966
|
-
var ANALYSIS_TIMEOUT_MS2 = 9e4;
|
|
1967
2074
|
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
2075
|
function formatMetricValue(key, value) {
|
|
1993
2076
|
if (value === null) return "n/a";
|
|
1994
2077
|
const def = METRIC_DEFINITIONS[key];
|
|
@@ -2071,7 +2154,7 @@ Metrics ranked (fixed order):
|
|
|
2071
2154
|
- Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio
|
|
2072
2155
|
- Deposits-to-Assets Ratio, Non-Interest Income Share
|
|
2073
2156
|
|
|
2074
|
-
Rankings use competition rank (1, 2, 2, 4)
|
|
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.
|
|
2075
2158
|
|
|
2076
2159
|
Output includes:
|
|
2077
2160
|
- Subject rankings and percentiles (when cert provided)
|
|
@@ -2090,7 +2173,7 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2090
2173
|
},
|
|
2091
2174
|
async (params) => {
|
|
2092
2175
|
const controller = new AbortController();
|
|
2093
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
2176
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
2094
2177
|
try {
|
|
2095
2178
|
const warnings = [];
|
|
2096
2179
|
let subjectProfile = null;
|
|
@@ -2175,7 +2258,7 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2175
2258
|
}
|
|
2176
2259
|
if (params.cert) {
|
|
2177
2260
|
rosterRecords = rosterRecords.filter(
|
|
2178
|
-
(r) =>
|
|
2261
|
+
(r) => asNumber(r.CERT) !== params.cert
|
|
2179
2262
|
);
|
|
2180
2263
|
}
|
|
2181
2264
|
const criteriaUsed = {
|
|
@@ -2227,12 +2310,12 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2227
2310
|
structuredContent: output2
|
|
2228
2311
|
};
|
|
2229
2312
|
}
|
|
2230
|
-
const peerCerts = rosterRecords.map((r) =>
|
|
2231
|
-
const certFilters =
|
|
2313
|
+
const peerCerts = rosterRecords.map((r) => asNumber(r.CERT)).filter((c) => c !== null);
|
|
2314
|
+
const certFilters = buildCertFilters(peerCerts);
|
|
2232
2315
|
const extraFieldsCsv = params.extra_fields && params.extra_fields.length > 0 ? "," + params.extra_fields.join(",") : "";
|
|
2233
|
-
const financialResponses = await
|
|
2316
|
+
const financialResponses = await mapWithConcurrency(
|
|
2234
2317
|
certFilters,
|
|
2235
|
-
|
|
2318
|
+
MAX_CONCURRENCY,
|
|
2236
2319
|
async (certFilter) => queryEndpoint(
|
|
2237
2320
|
ENDPOINTS.FINANCIALS,
|
|
2238
2321
|
{
|
|
@@ -2248,13 +2331,21 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2248
2331
|
);
|
|
2249
2332
|
const peerFinancialsByCert = /* @__PURE__ */ new Map();
|
|
2250
2333
|
for (const response of financialResponses) {
|
|
2251
|
-
|
|
2252
|
-
|
|
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);
|
|
2253
2344
|
if (cert !== null) peerFinancialsByCert.set(cert, record);
|
|
2254
2345
|
}
|
|
2255
2346
|
}
|
|
2256
2347
|
const rosterByCert = new Map(
|
|
2257
|
-
rosterRecords.map((r) => [
|
|
2348
|
+
rosterRecords.map((r) => [asNumber(r.CERT), r]).filter(
|
|
2258
2349
|
(e) => e[0] !== null
|
|
2259
2350
|
)
|
|
2260
2351
|
);
|
|
@@ -2351,7 +2442,7 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2351
2442
|
if (controller.signal.aborted) {
|
|
2352
2443
|
return formatToolError(
|
|
2353
2444
|
new Error(
|
|
2354
|
-
`Peer group analysis timed out after ${Math.floor(
|
|
2445
|
+
`Peer group analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the peer group criteria and try again.`
|
|
2355
2446
|
)
|
|
2356
2447
|
);
|
|
2357
2448
|
}
|
|
@@ -2386,6 +2477,16 @@ async function runStdio() {
|
|
|
2386
2477
|
await server.connect(transport);
|
|
2387
2478
|
console.error("FDIC BankFind MCP server running on stdio");
|
|
2388
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
|
+
}
|
|
2389
2490
|
function createApp() {
|
|
2390
2491
|
const app = (0, import_express.default)();
|
|
2391
2492
|
app.use(import_express.default.json());
|
|
@@ -2429,7 +2530,7 @@ function createApp() {
|
|
|
2429
2530
|
}
|
|
2430
2531
|
async function runHTTP() {
|
|
2431
2532
|
const app = createApp();
|
|
2432
|
-
const port =
|
|
2533
|
+
const port = parseHttpPort(process.env.PORT);
|
|
2433
2534
|
app.listen(port, () => {
|
|
2434
2535
|
console.error(
|
|
2435
2536
|
`FDIC BankFind MCP server running on http://localhost:${port}/mcp`
|
|
@@ -2448,5 +2549,6 @@ async function main() {
|
|
|
2448
2549
|
0 && (module.exports = {
|
|
2449
2550
|
createApp,
|
|
2450
2551
|
createServer,
|
|
2451
|
-
main
|
|
2552
|
+
main,
|
|
2553
|
+
parseHttpPort
|
|
2452
2554
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdic-mcp-server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "MCP server for the FDIC BankFind Suite API",
|
|
5
|
+
"mcpName": "io.github.jflamb/fdic-mcp-server",
|
|
5
6
|
"main": "dist/server.js",
|
|
6
7
|
"files": [
|
|
7
8
|
"dist",
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"prepack": "npm run build",
|
|
21
22
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
22
23
|
"pack:check": "npm pack --dry-run",
|
|
24
|
+
"registry:sync": "node scripts/sync-server-json.mjs",
|
|
23
25
|
"start": "node dist/index.js",
|
|
24
26
|
"dev": "ts-node src/index.ts",
|
|
25
27
|
"deploy:local": "bash scripts/deploy-local.sh"
|