fdic-mcp-server 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamable
30
30
  var import_express = __toESM(require("express"));
31
31
 
32
32
  // src/constants.ts
33
- var VERSION = true ? "1.0.6" : process.env.npm_package_version ?? "0.0.0-dev";
33
+ var VERSION = true ? "1.0.8" : process.env.npm_package_version ?? "0.0.0-dev";
34
34
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
35
35
  var CHARACTER_LIMIT = 5e4;
36
36
  var ENDPOINTS = {
@@ -54,42 +54,93 @@ var apiClient = import_axios.default.create({
54
54
  "User-Agent": `fdic-mcp-server/${VERSION}`
55
55
  }
56
56
  });
57
- async function queryEndpoint(endpoint, params) {
58
- try {
59
- const queryParams = {
60
- limit: params.limit ?? 20,
61
- offset: params.offset ?? 0,
62
- output: "json"
63
- };
64
- if (params.filters) queryParams.filters = params.filters;
65
- if (params.fields) queryParams.fields = params.fields;
66
- if (params.sort_by) queryParams.sort_by = params.sort_by;
67
- if (params.sort_order) queryParams.sort_order = params.sort_order;
68
- const response = await apiClient.get(`/${endpoint}`, {
69
- params: queryParams
70
- });
71
- return response.data;
72
- } catch (err) {
73
- if (err instanceof import_axios.AxiosError) {
74
- const status = err.response?.status;
75
- const detail = err.response?.data?.message ?? err.message;
76
- if (status === 400) {
77
- throw new Error(
78
- `Bad request to FDIC API: ${detail}. Check your filter syntax (use ElasticSearch query string syntax, e.g. STNAME:"California" AND ACTIVE:1).`
79
- );
80
- } else if (status === 429) {
81
- throw new Error(
82
- "FDIC API rate limit exceeded. Please wait a moment and try again."
83
- );
84
- } else if (status === 500) {
85
- throw new Error(
86
- "FDIC API server error. The service may be temporarily unavailable. Try again later."
87
- );
88
- } else {
89
- throw new Error(`FDIC API error (HTTP ${status}): ${detail}`);
57
+ var QUERY_CACHE_TTL_MS = 6e4;
58
+ var queryCache = /* @__PURE__ */ new Map();
59
+ function pruneExpiredQueryCache(now) {
60
+ for (const [key, entry] of queryCache.entries()) {
61
+ if (entry.expiresAt <= now) {
62
+ queryCache.delete(key);
63
+ }
64
+ }
65
+ }
66
+ function getCacheKey(endpoint, params) {
67
+ return JSON.stringify([
68
+ endpoint,
69
+ params.filters ?? null,
70
+ params.fields ?? null,
71
+ params.limit ?? null,
72
+ params.offset ?? null,
73
+ params.sort_by ?? null,
74
+ params.sort_order ?? null
75
+ ]);
76
+ }
77
+ async function queryEndpoint(endpoint, params, options = {}) {
78
+ if (options.signal?.aborted) {
79
+ throw new Error("FDIC API request was canceled before it started.");
80
+ }
81
+ const shouldUseCache = !options.signal;
82
+ const now = Date.now();
83
+ pruneExpiredQueryCache(now);
84
+ const cacheKey = getCacheKey(endpoint, params);
85
+ const cached = shouldUseCache ? queryCache.get(cacheKey) : void 0;
86
+ if (cached && cached.expiresAt > now) {
87
+ return cached.value;
88
+ }
89
+ const requestPromise = (async () => {
90
+ try {
91
+ const queryParams = {
92
+ limit: params.limit ?? 20,
93
+ offset: params.offset ?? 0,
94
+ output: "json"
95
+ };
96
+ if (params.filters) queryParams.filters = params.filters;
97
+ if (params.fields) queryParams.fields = params.fields;
98
+ if (params.sort_by) queryParams.sort_by = params.sort_by;
99
+ if (params.sort_order) queryParams.sort_order = params.sort_order;
100
+ const response = await apiClient.get(`/${endpoint}`, {
101
+ params: queryParams,
102
+ signal: options.signal
103
+ });
104
+ return response.data;
105
+ } catch (err) {
106
+ if (options.signal?.aborted || typeof err === "object" && err !== null && "code" in err && err.code === "ERR_CANCELED" || err instanceof DOMException && err.name === "AbortError") {
107
+ throw new Error("FDIC API request was canceled.");
90
108
  }
109
+ if (err instanceof import_axios.AxiosError) {
110
+ const status = err.response?.status;
111
+ const detail = err.response?.data?.message ?? err.message;
112
+ if (status === 400) {
113
+ throw new Error(
114
+ `Bad request to FDIC API: ${detail}. Check your filter syntax (use ElasticSearch query string syntax, e.g. STNAME:"California" AND ACTIVE:1).`
115
+ );
116
+ } else if (status === 429) {
117
+ throw new Error(
118
+ "FDIC API rate limit exceeded. Please wait a moment and try again."
119
+ );
120
+ } else if (status === 500) {
121
+ throw new Error(
122
+ "FDIC API server error. The service may be temporarily unavailable. Try again later."
123
+ );
124
+ } else {
125
+ throw new Error(`FDIC API error (HTTP ${status}): ${detail}`);
126
+ }
127
+ }
128
+ throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
91
129
  }
92
- throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
130
+ })();
131
+ if (shouldUseCache) {
132
+ queryCache.set(cacheKey, {
133
+ expiresAt: now + QUERY_CACHE_TTL_MS,
134
+ value: requestPromise
135
+ });
136
+ }
137
+ try {
138
+ return await requestPromise;
139
+ } catch (error) {
140
+ if (shouldUseCache) {
141
+ queryCache.delete(cacheKey);
142
+ }
143
+ throw error;
93
144
  }
94
145
  }
95
146
  function extractRecords(response) {
@@ -1020,6 +1071,710 @@ Prefer concise human-readable summaries or tables when answering users. Structur
1020
1071
  );
1021
1072
  }
1022
1073
 
1074
+ // src/tools/analysis.ts
1075
+ var import_zod7 = require("zod");
1076
+ var CHUNK_SIZE = 25;
1077
+ var MAX_CONCURRENCY = 4;
1078
+ var ANALYSIS_TIMEOUT_MS = 9e4;
1079
+ var SortFieldSchema = import_zod7.z.enum([
1080
+ "asset_growth",
1081
+ "asset_growth_pct",
1082
+ "dep_growth",
1083
+ "dep_growth_pct",
1084
+ "netinc_change",
1085
+ "netinc_change_pct",
1086
+ "roa_change",
1087
+ "roe_change",
1088
+ "offices_change",
1089
+ "assets_per_office_change",
1090
+ "deposits_per_office_change",
1091
+ "deposits_to_assets_change"
1092
+ ]);
1093
+ var AnalysisModeSchema = import_zod7.z.enum(["snapshot", "timeseries"]);
1094
+ var SnapshotAnalysisSchema = import_zod7.z.object({
1095
+ state: import_zod7.z.string().optional().describe(
1096
+ 'State name for the institution roster filter. Example: "North Carolina"'
1097
+ ),
1098
+ certs: import_zod7.z.array(import_zod7.z.number().int().positive()).max(100).optional().describe(
1099
+ "Optional list of FDIC certificate numbers to compare directly. Max 100."
1100
+ ),
1101
+ institution_filters: import_zod7.z.string().optional().describe(
1102
+ 'Additional institution-level filter used when building the comparison set. Example: BKCLASS:N or CITY:"Charlotte"'
1103
+ ),
1104
+ active_only: import_zod7.z.boolean().default(true).describe("Limit the comparison set to currently active institutions."),
1105
+ start_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Starting report date in YYYYMMDD format."),
1106
+ end_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Ending report date in YYYYMMDD format."),
1107
+ analysis_mode: AnalysisModeSchema.default("snapshot").describe(
1108
+ "Use snapshot for two-point comparison or timeseries for quarterly trend analysis across the date range."
1109
+ ),
1110
+ include_demographics: import_zod7.z.boolean().default(true).describe(
1111
+ "Include office-count changes from the demographics dataset when available."
1112
+ ),
1113
+ limit: import_zod7.z.number().int().min(1).max(100).default(10).describe("Maximum number of ranked comparisons to return."),
1114
+ sort_by: SortFieldSchema.default("asset_growth").describe(
1115
+ "Comparison field used to rank institutions."
1116
+ ),
1117
+ sort_order: import_zod7.z.enum(["ASC", "DESC"]).default("DESC").describe("Sort direction for the ranked comparisons.")
1118
+ }).superRefine((value, ctx) => {
1119
+ if (!value.state && (!value.certs || value.certs.length === 0)) {
1120
+ ctx.addIssue({
1121
+ code: import_zod7.z.ZodIssueCode.custom,
1122
+ message: "Provide either state or certs.",
1123
+ path: ["state"]
1124
+ });
1125
+ }
1126
+ });
1127
+ function asNumber(value) {
1128
+ return typeof value === "number" ? value : null;
1129
+ }
1130
+ function buildCertFilters(certs) {
1131
+ const filters = [];
1132
+ for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
1133
+ const chunk = certs.slice(i, i + CHUNK_SIZE);
1134
+ filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
1135
+ }
1136
+ return filters;
1137
+ }
1138
+ async function mapWithConcurrency(values, limit, mapper) {
1139
+ const results = new Array(values.length);
1140
+ let nextIndex = 0;
1141
+ async function worker() {
1142
+ while (true) {
1143
+ const currentIndex = nextIndex;
1144
+ nextIndex += 1;
1145
+ if (currentIndex >= values.length) return;
1146
+ results[currentIndex] = await mapper(values[currentIndex], currentIndex);
1147
+ }
1148
+ }
1149
+ await Promise.all(
1150
+ Array.from({ length: Math.min(limit, values.length) }, () => worker())
1151
+ );
1152
+ return results;
1153
+ }
1154
+ function ratio(numerator, denominator) {
1155
+ if (numerator === null || denominator === null || denominator === 0) {
1156
+ return null;
1157
+ }
1158
+ return numerator / denominator;
1159
+ }
1160
+ function pctChange(start, end) {
1161
+ if (start === null || end === null || start === 0) return null;
1162
+ return (end - start) / start * 100;
1163
+ }
1164
+ function change(start, end) {
1165
+ if (start === null || end === null) return null;
1166
+ return end - start;
1167
+ }
1168
+ function getQuarterIndex(repdte) {
1169
+ const year = Number.parseInt(repdte.slice(0, 4), 10);
1170
+ const month = Number.parseInt(repdte.slice(4, 6), 10);
1171
+ const quarter = month / 3;
1172
+ if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) {
1173
+ return null;
1174
+ }
1175
+ return year * 4 + quarter;
1176
+ }
1177
+ function yearsBetween(startRepdte, endRepdte) {
1178
+ const startQuarterIndex = getQuarterIndex(startRepdte);
1179
+ const endQuarterIndex = getQuarterIndex(endRepdte);
1180
+ if (startQuarterIndex !== null && endQuarterIndex !== null) {
1181
+ return Math.max((endQuarterIndex - startQuarterIndex) / 4, 0);
1182
+ }
1183
+ const start = /* @__PURE__ */ new Date(
1184
+ `${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}T00:00:00Z`
1185
+ );
1186
+ const end = /* @__PURE__ */ new Date(
1187
+ `${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}T00:00:00Z`
1188
+ );
1189
+ return Math.max(
1190
+ (end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3),
1191
+ 0
1192
+ );
1193
+ }
1194
+ function cagr(start, end, years) {
1195
+ if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
1196
+ return null;
1197
+ }
1198
+ return (Math.pow(end / start, 1 / years) - 1) * 100;
1199
+ }
1200
+ function formatPercent(value) {
1201
+ return value === null ? "n/a" : `${value.toFixed(1)}%`;
1202
+ }
1203
+ function formatChange(value, digits = 4) {
1204
+ if (value === null) return "n/a";
1205
+ return value.toFixed(digits);
1206
+ }
1207
+ function formatInteger(value) {
1208
+ return value === null ? "n/a" : `${Math.round(value).toLocaleString()}`;
1209
+ }
1210
+ function sortComparisons(comparisons, sortBy, sortOrder) {
1211
+ const direction = sortOrder === "ASC" ? 1 : -1;
1212
+ return [...comparisons].sort((left, right) => {
1213
+ const leftValue = asNumber(left[sortBy]) ?? Number.NEGATIVE_INFINITY;
1214
+ const rightValue = asNumber(right[sortBy]) ?? Number.NEGATIVE_INFINITY;
1215
+ return (leftValue - rightValue) * direction;
1216
+ });
1217
+ }
1218
+ function classifyInsights(comparison) {
1219
+ const insights = [];
1220
+ const assetGrowthPct = asNumber(comparison.asset_growth_pct);
1221
+ const depGrowthPct = asNumber(comparison.dep_growth_pct);
1222
+ const roaChange = asNumber(comparison.roa_change);
1223
+ const roeChange = asNumber(comparison.roe_change);
1224
+ const officesChange = asNumber(comparison.offices_change);
1225
+ const depositsToAssetsChange = asNumber(comparison.deposits_to_assets_change);
1226
+ if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && roaChange !== null && roaChange > 0) {
1227
+ insights.push("growth_with_better_profitability");
1228
+ }
1229
+ if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && officesChange !== null && officesChange > 0) {
1230
+ insights.push("growth_with_branch_expansion");
1231
+ }
1232
+ if (assetGrowthPct !== null && assetGrowthPct >= 20 && (roaChange === null || roaChange <= 0) && (roeChange === null || roeChange <= 0)) {
1233
+ insights.push("balance_sheet_growth_without_profitability");
1234
+ }
1235
+ if (assetGrowthPct !== null && assetGrowthPct > 0 && officesChange !== null && officesChange < 0) {
1236
+ insights.push("growth_with_branch_consolidation");
1237
+ }
1238
+ if (depositsToAssetsChange !== null && depositsToAssetsChange < 0 && depGrowthPct !== null && depGrowthPct < 0) {
1239
+ insights.push("deposit_mix_softening");
1240
+ }
1241
+ return insights;
1242
+ }
1243
+ function buildTopLevelInsights(comparisons) {
1244
+ return {
1245
+ growth_with_better_profitability: comparisons.filter(
1246
+ (comparison) => comparison.insights?.includes(
1247
+ "growth_with_better_profitability"
1248
+ )
1249
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1250
+ growth_with_branch_expansion: comparisons.filter(
1251
+ (comparison) => comparison.insights?.includes(
1252
+ "growth_with_branch_expansion"
1253
+ )
1254
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1255
+ balance_sheet_growth_without_profitability: comparisons.filter(
1256
+ (comparison) => comparison.insights?.includes(
1257
+ "balance_sheet_growth_without_profitability"
1258
+ )
1259
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1260
+ growth_with_branch_consolidation: comparisons.filter(
1261
+ (comparison) => comparison.insights?.includes(
1262
+ "growth_with_branch_consolidation"
1263
+ )
1264
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1265
+ deposit_mix_softening: comparisons.filter(
1266
+ (comparison) => comparison.insights?.includes(
1267
+ "deposit_mix_softening"
1268
+ )
1269
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1270
+ sustained_asset_growth: comparisons.filter(
1271
+ (comparison) => comparison.insights?.includes(
1272
+ "sustained_asset_growth"
1273
+ )
1274
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1275
+ multi_quarter_roa_decline: comparisons.filter(
1276
+ (comparison) => comparison.insights?.includes(
1277
+ "multi_quarter_roa_decline"
1278
+ )
1279
+ ).slice(0, 5).map((comparison) => String(comparison.name))
1280
+ };
1281
+ }
1282
+ function longestMonotonicStreak(values, direction) {
1283
+ let longest = 0;
1284
+ let current = 0;
1285
+ for (let i = 1; i < values.length; i += 1) {
1286
+ const previous = values[i - 1];
1287
+ const next = values[i];
1288
+ if (previous === null || next === null) {
1289
+ current = 0;
1290
+ continue;
1291
+ }
1292
+ const matches = direction === "up" ? next > previous : next < previous;
1293
+ if (matches) {
1294
+ current += 1;
1295
+ longest = Math.max(longest, current);
1296
+ } else {
1297
+ current = 0;
1298
+ }
1299
+ }
1300
+ return longest;
1301
+ }
1302
+ function summarizeTimeSeries(records, demographicsByDate, institution) {
1303
+ if (records.length < 2) return null;
1304
+ const sorted = [...records].sort(
1305
+ (left, right) => String(left.REPDTE).localeCompare(String(right.REPDTE))
1306
+ );
1307
+ const start = sorted[0];
1308
+ const end = sorted[sorted.length - 1];
1309
+ const startRepdte = String(start.REPDTE);
1310
+ const endRepdte = String(end.REPDTE);
1311
+ const years = yearsBetween(startRepdte, endRepdte);
1312
+ const assetSeries = sorted.map((record) => asNumber(record.ASSET));
1313
+ const roaSeries = sorted.map((record) => asNumber(record.ROA));
1314
+ const roeSeries = sorted.map((record) => asNumber(record.ROE));
1315
+ const officesSeries = sorted.map(
1316
+ (record) => asNumber(demographicsByDate.get(String(record.REPDTE))?.OFFTOT)
1317
+ );
1318
+ const assetStart = asNumber(start.ASSET);
1319
+ const assetEnd = asNumber(end.ASSET);
1320
+ const depStart = asNumber(start.DEP);
1321
+ const depEnd = asNumber(end.DEP);
1322
+ const roaStart = asNumber(start.ROA);
1323
+ const roaEnd = asNumber(end.ROA);
1324
+ const roeStart = asNumber(start.ROE);
1325
+ const roeEnd = asNumber(end.ROE);
1326
+ const netIncStart = asNumber(start.NETINC);
1327
+ const netIncEnd = asNumber(end.NETINC);
1328
+ const officesStart = officesSeries[0] ?? null;
1329
+ const officesEnd = officesSeries[officesSeries.length - 1] ?? null;
1330
+ const assetsPerOfficeStart = ratio(assetStart, officesStart);
1331
+ const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
1332
+ const depositsPerOfficeStart = ratio(depStart, officesStart);
1333
+ const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
1334
+ const depositsToAssetsStart = ratio(depStart, assetStart);
1335
+ const depositsToAssetsEnd = ratio(depEnd, assetEnd);
1336
+ const peakAsset = Math.max(...assetSeries.filter((value) => value !== null));
1337
+ const troughRoaValues = roaSeries.filter((value) => value !== null);
1338
+ const troughRoa = troughRoaValues.length > 0 ? Math.min(...troughRoaValues) : null;
1339
+ const comparison = {
1340
+ cert: asNumber(start.CERT),
1341
+ name: end.NAME ?? start.NAME ?? institution.NAME,
1342
+ city: institution.CITY,
1343
+ stalp: institution.STALP,
1344
+ analysis_mode: "timeseries",
1345
+ start_repdte: startRepdte,
1346
+ end_repdte: endRepdte,
1347
+ periods_analyzed: sorted.length,
1348
+ asset_start: assetStart,
1349
+ asset_end: assetEnd,
1350
+ asset_growth: change(assetStart, assetEnd),
1351
+ asset_growth_pct: pctChange(assetStart, assetEnd),
1352
+ asset_cagr: cagr(assetStart, assetEnd, years),
1353
+ dep_start: depStart,
1354
+ dep_end: depEnd,
1355
+ dep_growth: change(depStart, depEnd),
1356
+ dep_growth_pct: pctChange(depStart, depEnd),
1357
+ netinc_start: netIncStart,
1358
+ netinc_end: netIncEnd,
1359
+ netinc_change: change(netIncStart, netIncEnd),
1360
+ netinc_change_pct: pctChange(netIncStart, netIncEnd),
1361
+ roa_start: roaStart,
1362
+ roa_end: roaEnd,
1363
+ roa_change: change(roaStart, roaEnd),
1364
+ roe_start: roeStart,
1365
+ roe_end: roeEnd,
1366
+ roe_change: change(roeStart, roeEnd),
1367
+ offices_start: officesStart,
1368
+ offices_end: officesEnd,
1369
+ offices_change: change(officesStart, officesEnd),
1370
+ assets_per_office_start: assetsPerOfficeStart,
1371
+ assets_per_office_end: assetsPerOfficeEnd,
1372
+ assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
1373
+ deposits_per_office_start: depositsPerOfficeStart,
1374
+ deposits_per_office_end: depositsPerOfficeEnd,
1375
+ deposits_per_office_change: change(
1376
+ depositsPerOfficeStart,
1377
+ depositsPerOfficeEnd
1378
+ ),
1379
+ deposits_to_assets_start: depositsToAssetsStart,
1380
+ deposits_to_assets_end: depositsToAssetsEnd,
1381
+ deposits_to_assets_change: change(
1382
+ depositsToAssetsStart,
1383
+ depositsToAssetsEnd
1384
+ ),
1385
+ asset_peak: peakAsset,
1386
+ roa_trough: troughRoa,
1387
+ asset_growth_streak: longestMonotonicStreak(assetSeries, "up"),
1388
+ roa_decline_streak: longestMonotonicStreak(roaSeries, "down"),
1389
+ roe_decline_streak: longestMonotonicStreak(roeSeries, "down"),
1390
+ time_series: sorted.map((record) => {
1391
+ const repdte = String(record.REPDTE);
1392
+ const demo = demographicsByDate.get(repdte);
1393
+ return {
1394
+ repdte,
1395
+ asset: asNumber(record.ASSET),
1396
+ dep: asNumber(record.DEP),
1397
+ netinc: asNumber(record.NETINC),
1398
+ roa: asNumber(record.ROA),
1399
+ roe: asNumber(record.ROE),
1400
+ offices: asNumber(demo?.OFFTOT)
1401
+ };
1402
+ })
1403
+ };
1404
+ comparison.insights = classifyInsights(comparison);
1405
+ if (comparison.asset_growth_streak >= 3) {
1406
+ comparison.insights.push("sustained_asset_growth");
1407
+ }
1408
+ if (comparison.roa_decline_streak >= 2) {
1409
+ comparison.insights.push("multi_quarter_roa_decline");
1410
+ }
1411
+ return comparison;
1412
+ }
1413
+ function formatComparisonText(output) {
1414
+ const header = `Compared ${output.analyzed_count} institutions from ${output.start_repdte} to ${output.end_repdte} (from ${output.total_candidates} candidates), ranked by ${output.sort_by} using ${output.analysis_mode} analysis.`;
1415
+ if (output.comparisons.length === 0) {
1416
+ return header;
1417
+ }
1418
+ const rows = output.comparisons.map((comparison, index) => {
1419
+ const name = String(comparison.name ?? comparison.cert);
1420
+ const city = comparison.city ? `, ${comparison.city}` : "";
1421
+ const base = `${index + 1}. ${name}${city} | Asset growth: ${formatInteger(asNumber(comparison.asset_growth))} (${formatPercent(asNumber(comparison.asset_growth_pct))}) | Deposit growth: ${formatPercent(asNumber(comparison.dep_growth_pct))} | Offices: ${formatInteger(asNumber(comparison.offices_change))} | ROA: ${formatChange(asNumber(comparison.roa_change))} | ROE: ${formatChange(asNumber(comparison.roe_change))}`;
1422
+ if (output.analysis_mode === "timeseries") {
1423
+ return `${base} | Asset CAGR: ${formatPercent(asNumber(comparison.asset_cagr))} | Streaks: asset ${formatInteger(asNumber(comparison.asset_growth_streak))}, ROA decline ${formatInteger(asNumber(comparison.roa_decline_streak))}`;
1424
+ }
1425
+ return base;
1426
+ });
1427
+ const insights = output.insights ? Object.entries(output.insights).filter(([, names]) => names.length > 0).map(([label, names]) => `${label}: ${names.join(", ")}`).join("\n") : "";
1428
+ return insights ? `${header}
1429
+ ${rows.join("\n")}
1430
+ Insights
1431
+ ${insights}` : `${header}
1432
+ ${rows.join("\n")}`;
1433
+ }
1434
+ async function fetchInstitutionRoster(state, institutionFilters, activeOnly, signal) {
1435
+ const filterParts = [];
1436
+ if (state) filterParts.push(`STNAME:"${state}"`);
1437
+ if (activeOnly) filterParts.push("ACTIVE:1");
1438
+ if (institutionFilters) filterParts.push(`(${institutionFilters})`);
1439
+ const response = await queryEndpoint(ENDPOINTS.INSTITUTIONS, {
1440
+ filters: filterParts.join(" AND "),
1441
+ fields: "CERT,NAME,CITY,STALP,ACTIVE",
1442
+ limit: 1e4,
1443
+ offset: 0,
1444
+ sort_by: "CERT",
1445
+ sort_order: "ASC"
1446
+ }, { signal });
1447
+ const records = extractRecords(response);
1448
+ const warning = response.meta.total > records.length ? `Institution roster truncated to ${records.length.toLocaleString()} records out of ${response.meta.total.toLocaleString()} matched institutions. Narrow the comparison set with institution_filters or certs for complete analysis.` : void 0;
1449
+ return { records, warning };
1450
+ }
1451
+ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields, signal) {
1452
+ const certFilters = buildCertFilters(certs);
1453
+ const tasks = repdteFilters.flatMap(
1454
+ (repdteFilter) => certFilters.map((certFilter) => ({
1455
+ repdteFilter,
1456
+ certFilter
1457
+ }))
1458
+ );
1459
+ const responses = await mapWithConcurrency(tasks, MAX_CONCURRENCY, async (task) => {
1460
+ const response = await queryEndpoint(endpoint, {
1461
+ filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
1462
+ fields,
1463
+ limit: 1e4,
1464
+ offset: 0,
1465
+ sort_by: "CERT",
1466
+ sort_order: "ASC"
1467
+ }, { signal });
1468
+ return { repdteFilter: task.repdteFilter, response };
1469
+ });
1470
+ const byDate = /* @__PURE__ */ new Map();
1471
+ for (const { repdteFilter, response } of responses) {
1472
+ if (!byDate.has(repdteFilter)) {
1473
+ byDate.set(repdteFilter, /* @__PURE__ */ new Map());
1474
+ }
1475
+ const target = byDate.get(repdteFilter);
1476
+ for (const record of extractRecords(response)) {
1477
+ const cert = asNumber(record.CERT);
1478
+ if (cert !== null) target.set(cert, record);
1479
+ }
1480
+ }
1481
+ return byDate;
1482
+ }
1483
+ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields, signal) {
1484
+ const certFilters = buildCertFilters(certs);
1485
+ const responses = await mapWithConcurrency(
1486
+ certFilters,
1487
+ MAX_CONCURRENCY,
1488
+ async (certFilter) => queryEndpoint(endpoint, {
1489
+ filters: `(${certFilter}) AND REPDTE:[${startRepdte} TO ${endRepdte}]`,
1490
+ fields,
1491
+ limit: 1e4,
1492
+ offset: 0,
1493
+ sort_by: "REPDTE",
1494
+ sort_order: "ASC"
1495
+ }, { signal })
1496
+ );
1497
+ const grouped = /* @__PURE__ */ new Map();
1498
+ for (const response of responses) {
1499
+ for (const record of extractRecords(response)) {
1500
+ const cert = asNumber(record.CERT);
1501
+ if (cert === null) continue;
1502
+ if (!grouped.has(cert)) grouped.set(cert, []);
1503
+ grouped.get(cert).push(record);
1504
+ }
1505
+ }
1506
+ return grouped;
1507
+ }
1508
+ function buildSnapshotComparison(cert, institution, startFinancial, endFinancial, startDemo, endDemo, startRepdte, endRepdte) {
1509
+ const assetStart = asNumber(startFinancial.ASSET);
1510
+ const assetEnd = asNumber(endFinancial.ASSET);
1511
+ const depStart = asNumber(startFinancial.DEP);
1512
+ const depEnd = asNumber(endFinancial.DEP);
1513
+ const netIncStart = asNumber(startFinancial.NETINC);
1514
+ const netIncEnd = asNumber(endFinancial.NETINC);
1515
+ const roaStart = asNumber(startFinancial.ROA);
1516
+ const roaEnd = asNumber(endFinancial.ROA);
1517
+ const roeStart = asNumber(startFinancial.ROE);
1518
+ const roeEnd = asNumber(endFinancial.ROE);
1519
+ const officesStart = asNumber(startDemo?.OFFTOT);
1520
+ const officesEnd = asNumber(endDemo?.OFFTOT);
1521
+ const assetsPerOfficeStart = ratio(assetStart, officesStart);
1522
+ const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
1523
+ const depositsPerOfficeStart = ratio(depStart, officesStart);
1524
+ const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
1525
+ const depositsToAssetsStart = ratio(depStart, assetStart);
1526
+ const depositsToAssetsEnd = ratio(depEnd, assetEnd);
1527
+ const comparison = {
1528
+ cert,
1529
+ name: endFinancial.NAME ?? startFinancial.NAME ?? institution.NAME,
1530
+ city: institution.CITY,
1531
+ stalp: institution.STALP,
1532
+ analysis_mode: "snapshot",
1533
+ start_repdte: startRepdte,
1534
+ end_repdte: endRepdte,
1535
+ asset_start: assetStart,
1536
+ asset_end: assetEnd,
1537
+ asset_growth: change(assetStart, assetEnd),
1538
+ asset_growth_pct: pctChange(assetStart, assetEnd),
1539
+ dep_start: depStart,
1540
+ dep_end: depEnd,
1541
+ dep_growth: change(depStart, depEnd),
1542
+ dep_growth_pct: pctChange(depStart, depEnd),
1543
+ netinc_start: netIncStart,
1544
+ netinc_end: netIncEnd,
1545
+ netinc_change: change(netIncStart, netIncEnd),
1546
+ netinc_change_pct: pctChange(netIncStart, netIncEnd),
1547
+ roa_start: roaStart,
1548
+ roa_end: roaEnd,
1549
+ roa_change: change(roaStart, roaEnd),
1550
+ roe_start: roeStart,
1551
+ roe_end: roeEnd,
1552
+ roe_change: change(roeStart, roeEnd),
1553
+ offices_start: officesStart,
1554
+ offices_end: officesEnd,
1555
+ offices_change: change(officesStart, officesEnd),
1556
+ assets_per_office_start: assetsPerOfficeStart,
1557
+ assets_per_office_end: assetsPerOfficeEnd,
1558
+ assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
1559
+ deposits_per_office_start: depositsPerOfficeStart,
1560
+ deposits_per_office_end: depositsPerOfficeEnd,
1561
+ deposits_per_office_change: change(
1562
+ depositsPerOfficeStart,
1563
+ depositsPerOfficeEnd
1564
+ ),
1565
+ deposits_to_assets_start: depositsToAssetsStart,
1566
+ deposits_to_assets_end: depositsToAssetsEnd,
1567
+ deposits_to_assets_change: change(
1568
+ depositsToAssetsStart,
1569
+ depositsToAssetsEnd
1570
+ ),
1571
+ cbsa_start: startDemo?.CBSANAME,
1572
+ cbsa_end: endDemo?.CBSANAME
1573
+ };
1574
+ comparison.insights = classifyInsights(comparison);
1575
+ return comparison;
1576
+ }
1577
+ function registerAnalysisTools(server) {
1578
+ server.registerTool(
1579
+ "fdic_compare_bank_snapshots",
1580
+ {
1581
+ title: "Compare Bank Snapshot Trends",
1582
+ description: `Compare FDIC reporting snapshots across a set of institutions and rank the results by growth, profitability, or efficiency changes.
1583
+
1584
+ This tool is designed for heavier analytical prompts that would otherwise require many separate MCP calls. It batches institution roster lookup, financial snapshots, optional office-count snapshots, and can also fetch a quarterly time series inside the server.
1585
+
1586
+ Good uses:
1587
+ - Identify North Carolina banks with the strongest asset growth from 2021 to 2025
1588
+ - Compare whether deposit growth came with branch expansion or profitability improvement
1589
+ - Rank a specific cert list by ROA, ROE, asset-per-office, or deposit-to-asset changes
1590
+ - Pull a quarterly trend series and highlight inflection points, streaks, and structural shifts
1591
+
1592
+ Inputs:
1593
+ - state or certs: choose a geographic roster or provide a direct comparison set
1594
+ - start_repdte, end_repdte: report dates in YYYYMMDD format
1595
+ - analysis_mode: snapshot or timeseries
1596
+ - institution_filters: optional extra institution filter when building the roster
1597
+ - active_only: default true
1598
+ - include_demographics: default true, adds office-count comparisons when available
1599
+ - sort_by: ranking field such as asset_growth, dep_growth_pct, roa_change, assets_per_office_change
1600
+ - sort_order: ASC or DESC
1601
+ - limit: maximum ranked results to return
1602
+
1603
+ Returns concise comparison text plus structured deltas, derived metrics, and insight tags for each institution.`,
1604
+ inputSchema: SnapshotAnalysisSchema,
1605
+ annotations: {
1606
+ readOnlyHint: true,
1607
+ destructiveHint: false,
1608
+ idempotentHint: true,
1609
+ openWorldHint: true
1610
+ }
1611
+ },
1612
+ async ({
1613
+ state,
1614
+ certs,
1615
+ institution_filters,
1616
+ active_only,
1617
+ start_repdte,
1618
+ end_repdte,
1619
+ analysis_mode,
1620
+ include_demographics,
1621
+ limit,
1622
+ sort_by,
1623
+ sort_order
1624
+ }) => {
1625
+ const controller = new AbortController();
1626
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
1627
+ try {
1628
+ const rosterResult = certs && certs.length > 0 ? {
1629
+ records: certs.map((cert) => ({ CERT: cert })),
1630
+ warning: void 0
1631
+ } : await fetchInstitutionRoster(
1632
+ state,
1633
+ institution_filters,
1634
+ active_only,
1635
+ controller.signal
1636
+ );
1637
+ const roster = rosterResult.records;
1638
+ const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
1639
+ if (candidateCerts.length === 0) {
1640
+ const output2 = {
1641
+ total_candidates: 0,
1642
+ analyzed_count: 0,
1643
+ start_repdte,
1644
+ end_repdte,
1645
+ analysis_mode,
1646
+ sort_by,
1647
+ sort_order,
1648
+ comparisons: []
1649
+ };
1650
+ return {
1651
+ content: [
1652
+ { type: "text", text: "No institutions matched the comparison set." }
1653
+ ],
1654
+ structuredContent: output2
1655
+ };
1656
+ }
1657
+ const rosterByCert = new Map(
1658
+ roster.map((record) => [asNumber(record.CERT), record]).filter(
1659
+ (entry) => entry[0] !== null
1660
+ )
1661
+ );
1662
+ let comparisons = [];
1663
+ if (analysis_mode === "timeseries") {
1664
+ const [financialSeries, demographicsSeries] = await Promise.all([
1665
+ fetchSeriesRecords(
1666
+ ENDPOINTS.FINANCIALS,
1667
+ candidateCerts,
1668
+ start_repdte,
1669
+ end_repdte,
1670
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
1671
+ controller.signal
1672
+ ),
1673
+ include_demographics ? fetchSeriesRecords(
1674
+ ENDPOINTS.DEMOGRAPHICS,
1675
+ candidateCerts,
1676
+ start_repdte,
1677
+ end_repdte,
1678
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1679
+ controller.signal
1680
+ ) : Promise.resolve(/* @__PURE__ */ new Map())
1681
+ ]);
1682
+ comparisons = candidateCerts.map(
1683
+ (cert) => summarizeTimeSeries(
1684
+ financialSeries.get(cert) ?? [],
1685
+ new Map(
1686
+ (demographicsSeries.get(cert) ?? []).map((record) => [
1687
+ String(record.REPDTE),
1688
+ record
1689
+ ])
1690
+ ),
1691
+ rosterByCert.get(cert) ?? {}
1692
+ )
1693
+ ).filter((comparison) => comparison !== null);
1694
+ } else {
1695
+ const [financialSnapshots, demographicSnapshots] = await Promise.all([
1696
+ fetchBatchedRecordsForDates(
1697
+ ENDPOINTS.FINANCIALS,
1698
+ candidateCerts,
1699
+ [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1700
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
1701
+ controller.signal
1702
+ ),
1703
+ include_demographics ? fetchBatchedRecordsForDates(
1704
+ ENDPOINTS.DEMOGRAPHICS,
1705
+ candidateCerts,
1706
+ [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1707
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1708
+ controller.signal
1709
+ ) : Promise.resolve(/* @__PURE__ */ new Map())
1710
+ ]);
1711
+ const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
1712
+ const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
1713
+ const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
1714
+ const endDemographics = demographicSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
1715
+ comparisons = candidateCerts.map((cert) => {
1716
+ const startFinancial = startFinancials.get(cert);
1717
+ const endFinancial = endFinancials.get(cert);
1718
+ if (!startFinancial || !endFinancial) return null;
1719
+ return buildSnapshotComparison(
1720
+ cert,
1721
+ rosterByCert.get(cert) ?? {},
1722
+ startFinancial,
1723
+ endFinancial,
1724
+ startDemographics.get(cert),
1725
+ endDemographics.get(cert),
1726
+ start_repdte,
1727
+ end_repdte
1728
+ );
1729
+ }).filter((comparison) => comparison !== null);
1730
+ }
1731
+ const sortedComparisons = sortComparisons(
1732
+ comparisons,
1733
+ sort_by,
1734
+ sort_order
1735
+ );
1736
+ const ranked = sortedComparisons.slice(0, limit);
1737
+ const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
1738
+ const output = {
1739
+ total_candidates: candidateCerts.length,
1740
+ analyzed_count: comparisons.length,
1741
+ start_repdte,
1742
+ end_repdte,
1743
+ analysis_mode,
1744
+ sort_by,
1745
+ sort_order,
1746
+ warnings: rosterResult.warning ? [rosterResult.warning] : [],
1747
+ insights: buildTopLevelInsights(sortedComparisons),
1748
+ ...pagination,
1749
+ comparisons: ranked
1750
+ };
1751
+ const text = truncateIfNeeded(
1752
+ [
1753
+ rosterResult.warning ? `Warning: ${rosterResult.warning}` : null,
1754
+ formatComparisonText(output)
1755
+ ].filter((value) => value !== null).join("\n\n"),
1756
+ CHARACTER_LIMIT
1757
+ );
1758
+ return {
1759
+ content: [{ type: "text", text }],
1760
+ structuredContent: output
1761
+ };
1762
+ } catch (err) {
1763
+ if (controller.signal.aborted) {
1764
+ return formatToolError(
1765
+ new Error(
1766
+ `Analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the comparison set with certs or institution_filters and try again.`
1767
+ )
1768
+ );
1769
+ }
1770
+ return formatToolError(err);
1771
+ } finally {
1772
+ clearTimeout(timeoutId);
1773
+ }
1774
+ }
1775
+ );
1776
+ }
1777
+
1023
1778
  // src/index.ts
1024
1779
  function createServer() {
1025
1780
  const server = new import_mcp.McpServer({
@@ -1033,6 +1788,7 @@ function createServer() {
1033
1788
  registerFinancialTools(server);
1034
1789
  registerSodTools(server);
1035
1790
  registerDemographicsTools(server);
1791
+ registerAnalysisTools(server);
1036
1792
  return server;
1037
1793
  }
1038
1794
  async function runStdio() {