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