fdic-mcp-server 1.0.5 → 1.0.7

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.5" : process.env.npm_package_version ?? "0.0.0-dev";
33
+ var VERSION = true ? "1.0.7" : 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,73 @@ var apiClient = import_axios.default.create({
54
54
  "User-Agent": `fdic-mcp-server/${VERSION}`
55
55
  }
56
56
  });
57
+ var QUERY_CACHE_TTL_MS = 6e4;
58
+ var queryCache = /* @__PURE__ */ new Map();
59
+ function getCacheKey(endpoint, params) {
60
+ return JSON.stringify([
61
+ endpoint,
62
+ params.filters ?? null,
63
+ params.fields ?? null,
64
+ params.limit ?? null,
65
+ params.offset ?? null,
66
+ params.sort_by ?? null,
67
+ params.sort_order ?? null
68
+ ]);
69
+ }
57
70
  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}`);
71
+ const cacheKey = getCacheKey(endpoint, params);
72
+ const now = Date.now();
73
+ const cached = queryCache.get(cacheKey);
74
+ if (cached && cached.expiresAt > now) {
75
+ return cached.value;
76
+ }
77
+ const requestPromise = (async () => {
78
+ try {
79
+ const queryParams = {
80
+ limit: params.limit ?? 20,
81
+ offset: params.offset ?? 0,
82
+ output: "json"
83
+ };
84
+ if (params.filters) queryParams.filters = params.filters;
85
+ if (params.fields) queryParams.fields = params.fields;
86
+ if (params.sort_by) queryParams.sort_by = params.sort_by;
87
+ if (params.sort_order) queryParams.sort_order = params.sort_order;
88
+ const response = await apiClient.get(`/${endpoint}`, {
89
+ params: queryParams
90
+ });
91
+ return response.data;
92
+ } catch (err) {
93
+ if (err instanceof import_axios.AxiosError) {
94
+ const status = err.response?.status;
95
+ const detail = err.response?.data?.message ?? err.message;
96
+ if (status === 400) {
97
+ throw new Error(
98
+ `Bad request to FDIC API: ${detail}. Check your filter syntax (use ElasticSearch query string syntax, e.g. STNAME:"California" AND ACTIVE:1).`
99
+ );
100
+ } else if (status === 429) {
101
+ throw new Error(
102
+ "FDIC API rate limit exceeded. Please wait a moment and try again."
103
+ );
104
+ } else if (status === 500) {
105
+ throw new Error(
106
+ "FDIC API server error. The service may be temporarily unavailable. Try again later."
107
+ );
108
+ } else {
109
+ throw new Error(`FDIC API error (HTTP ${status}): ${detail}`);
110
+ }
90
111
  }
112
+ throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
91
113
  }
92
- throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
114
+ })();
115
+ queryCache.set(cacheKey, {
116
+ expiresAt: now + QUERY_CACHE_TTL_MS,
117
+ value: requestPromise
118
+ });
119
+ try {
120
+ return await requestPromise;
121
+ } catch (error) {
122
+ queryCache.delete(cacheKey);
123
+ throw error;
93
124
  }
94
125
  }
95
126
  function extractRecords(response) {
@@ -111,6 +142,37 @@ function truncateIfNeeded(text, charLimit) {
111
142
 
112
143
  [Response truncated at ${charLimit} characters. Use limit/offset parameters to paginate or narrow your query with filters.]`;
113
144
  }
145
+ function formatValue(value) {
146
+ if (value === null || value === void 0) return "n/a";
147
+ if (typeof value === "number") return Number.isInteger(value) ? `${value}` : value.toFixed(4);
148
+ if (typeof value === "string") return value;
149
+ if (typeof value === "boolean") return value ? "true" : "false";
150
+ return JSON.stringify(value);
151
+ }
152
+ function summarizeRecord(record, preferredKeys, maxFields = 4) {
153
+ const orderedKeys = [
154
+ ...preferredKeys.filter((key) => key in record),
155
+ ...Object.keys(record).filter(
156
+ (key) => key !== "ID" && !preferredKeys.includes(key)
157
+ )
158
+ ];
159
+ return orderedKeys.slice(0, maxFields).map((key) => `${key}: ${formatValue(record[key])}`).join(" | ");
160
+ }
161
+ function formatSearchResultText(label, records, pagination, preferredKeys) {
162
+ const header = `Found ${pagination.total} ${label} (showing ${pagination.count}, offset ${pagination.offset}).`;
163
+ if (records.length === 0) {
164
+ return header;
165
+ }
166
+ const rows = records.map((record, index) => `${index + 1}. ${summarizeRecord(record, preferredKeys)}`).join("\n");
167
+ const footer = pagination.has_more ? `
168
+ More results available. Use offset ${pagination.next_offset} to continue.` : "";
169
+ return `${header}
170
+ ${rows}${footer}`;
171
+ }
172
+ function formatLookupResultText(label, record, preferredKeys) {
173
+ return `${label}
174
+ ${summarizeRecord(record, preferredKeys, 8)}`;
175
+ }
114
176
  function formatToolError(err) {
115
177
  const message = err instanceof Error ? err.message : String(err);
116
178
  return {
@@ -183,7 +245,7 @@ Args:
183
245
  - sort_by (string, optional): Field to sort by
184
246
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
185
247
 
186
- Returns JSON with { total, offset, count, has_more, next_offset?, institutions[] }`,
248
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and institution records.`,
187
249
  inputSchema: CommonQuerySchema,
188
250
  annotations: {
189
251
  readOnlyHint: true,
@@ -203,7 +265,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, institutions[]
203
265
  );
204
266
  const output = { ...pagination, institutions: records };
205
267
  const text = truncateIfNeeded(
206
- JSON.stringify(output, null, 2),
268
+ formatSearchResultText("institutions", records, pagination, [
269
+ "CERT",
270
+ "NAME",
271
+ "CITY",
272
+ "STALP",
273
+ "ASSET",
274
+ "ACTIVE"
275
+ ]),
207
276
  CHARACTER_LIMIT
208
277
  );
209
278
  return {
@@ -227,7 +296,7 @@ Args:
227
296
  - cert (number): FDIC Certificate Number (e.g., 3511 for Bank of America)
228
297
  - fields (string, optional): Comma-separated list of fields to return
229
298
 
230
- Returns full institution profile including financial metrics, charter info, locations, and regulatory details.`,
299
+ Returns a detailed institution profile suitable for concise summaries, with structured fields available for exact values when needed.`,
231
300
  inputSchema: CertSchema,
232
301
  annotations: {
233
302
  readOnlyHint: true,
@@ -256,7 +325,16 @@ Returns full institution profile including financial metrics, charter info, loca
256
325
  };
257
326
  }
258
327
  const output = records[0];
259
- const text = JSON.stringify(output, null, 2);
328
+ const text = formatLookupResultText("Institution details", output, [
329
+ "CERT",
330
+ "NAME",
331
+ "CITY",
332
+ "STALP",
333
+ "ASSET",
334
+ "DEP",
335
+ "ACTIVE",
336
+ "REGAGNT"
337
+ ]);
260
338
  return {
261
339
  content: [{ type: "text", text }],
262
340
  structuredContent: output
@@ -304,7 +382,7 @@ Args:
304
382
  - sort_by (string, optional): Field to sort by (e.g., FAILDATE, COST)
305
383
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
306
384
 
307
- Returns JSON with { total, offset, count, has_more, next_offset?, failures[] }`,
385
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and failure records.`,
308
386
  inputSchema: CommonQuerySchema,
309
387
  annotations: {
310
388
  readOnlyHint: true,
@@ -324,7 +402,15 @@ Returns JSON with { total, offset, count, has_more, next_offset?, failures[] }`,
324
402
  );
325
403
  const output = { ...pagination, failures: records };
326
404
  const text = truncateIfNeeded(
327
- JSON.stringify(output, null, 2),
405
+ formatSearchResultText("failures", records, pagination, [
406
+ "CERT",
407
+ "NAME",
408
+ "CITY",
409
+ "STALP",
410
+ "FAILDATE",
411
+ "COST",
412
+ "RESTYPE"
413
+ ]),
328
414
  CHARACTER_LIMIT
329
415
  );
330
416
  return {
@@ -348,7 +434,7 @@ Args:
348
434
  - cert (number): FDIC Certificate Number of the failed institution
349
435
  - fields (string, optional): Comma-separated list of fields to return
350
436
 
351
- Returns failure details including failure date, resolution method, and cost to FDIC.`,
437
+ Returns detailed failure information suitable for concise summaries, with structured fields available for exact values when needed.`,
352
438
  inputSchema: CertSchema,
353
439
  annotations: {
354
440
  readOnlyHint: true,
@@ -377,7 +463,16 @@ Returns failure details including failure date, resolution method, and cost to F
377
463
  };
378
464
  }
379
465
  const output = records[0];
380
- const text = JSON.stringify(output, null, 2);
466
+ const text = formatLookupResultText("Failure details", output, [
467
+ "CERT",
468
+ "NAME",
469
+ "FAILDATE",
470
+ "RESTYPE",
471
+ "COST",
472
+ "QBFASSET",
473
+ "CITY",
474
+ "STALP"
475
+ ]);
381
476
  return {
382
477
  content: [{ type: "text", text }],
383
478
  structuredContent: output
@@ -435,7 +530,7 @@ Args:
435
530
  - sort_by (string, optional): Field to sort by
436
531
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
437
532
 
438
- Returns JSON with { total, offset, count, has_more, next_offset?, locations[] }`,
533
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and branch location records.`,
439
534
  inputSchema: LocationQuerySchema,
440
535
  annotations: {
441
536
  readOnlyHint: true,
@@ -462,7 +557,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, locations[] }`
462
557
  );
463
558
  const output = { ...pagination, locations: records };
464
559
  const text = truncateIfNeeded(
465
- JSON.stringify(output, null, 2),
560
+ formatSearchResultText("locations", records, pagination, [
561
+ "CERT",
562
+ "UNINAME",
563
+ "NAMEFULL",
564
+ "CITY",
565
+ "STALP",
566
+ "BRNUM"
567
+ ]),
466
568
  CHARACTER_LIMIT
467
569
  );
468
570
  return {
@@ -523,7 +625,7 @@ Args:
523
625
  - sort_by (string, optional): Field to sort by (e.g., PROCDATE)
524
626
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
525
627
 
526
- Returns JSON with { total, offset, count, has_more, next_offset?, events[] }`,
628
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and event records.`,
527
629
  inputSchema: HistoryQuerySchema,
528
630
  annotations: {
529
631
  readOnlyHint: true,
@@ -550,7 +652,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, events[] }`,
550
652
  );
551
653
  const output = { ...pagination, events: records };
552
654
  const text = truncateIfNeeded(
553
- JSON.stringify(output, null, 2),
655
+ formatSearchResultText("events", records, pagination, [
656
+ "CERT",
657
+ "INSTNAME",
658
+ "TYPE",
659
+ "PROCDATE",
660
+ "PCITY",
661
+ "PSTALP"
662
+ ]),
554
663
  CHARACTER_LIMIT
555
664
  );
556
665
  return {
@@ -620,7 +729,7 @@ Args:
620
729
  - sort_by (string, optional): Field to sort by
621
730
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'DESC' recommended for most recent first)
622
731
 
623
- Returns JSON with { total, offset, count, has_more, next_offset?, financials[] }`,
732
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and quarterly financial records.`,
624
733
  inputSchema: FinancialQuerySchema,
625
734
  annotations: {
626
735
  readOnlyHint: true,
@@ -647,7 +756,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, financials[] }
647
756
  );
648
757
  const output = { ...pagination, financials: records };
649
758
  const text = truncateIfNeeded(
650
- JSON.stringify(output, null, 2),
759
+ formatSearchResultText("financial records", records, pagination, [
760
+ "CERT",
761
+ "NAME",
762
+ "REPDTE",
763
+ "ASSET",
764
+ "DEP",
765
+ "NETINC"
766
+ ]),
651
767
  CHARACTER_LIMIT
652
768
  );
653
769
  return {
@@ -695,7 +811,7 @@ Args:
695
811
  - sort_by (string, optional): Field to sort by (e.g., YEAR, ASSET)
696
812
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
697
813
 
698
- Returns JSON with { total, offset, count, has_more, next_offset?, summary[] }`,
814
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and annual summary records.`,
699
815
  inputSchema: SummaryQuerySchema,
700
816
  annotations: {
701
817
  readOnlyHint: true,
@@ -722,7 +838,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, summary[] }`,
722
838
  );
723
839
  const output = { ...pagination, summary: records };
724
840
  const text = truncateIfNeeded(
725
- JSON.stringify(output, null, 2),
841
+ formatSearchResultText("annual summary records", records, pagination, [
842
+ "CERT",
843
+ "YEAR",
844
+ "ASSET",
845
+ "DEP",
846
+ "NETINC",
847
+ "ROA"
848
+ ]),
726
849
  CHARACTER_LIMIT
727
850
  );
728
851
  return {
@@ -784,7 +907,7 @@ Args:
784
907
  - sort_by (string, optional): Field to sort by (e.g., DEPSUMBR, YEAR)
785
908
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
786
909
 
787
- Returns JSON with { total, offset, count, has_more, next_offset?, deposits[] }`,
910
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and deposit records.`,
788
911
  inputSchema: SodQuerySchema,
789
912
  annotations: {
790
913
  readOnlyHint: true,
@@ -811,7 +934,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, deposits[] }`,
811
934
  );
812
935
  const output = { ...pagination, deposits: records };
813
936
  const text = truncateIfNeeded(
814
- JSON.stringify(output, null, 2),
937
+ formatSearchResultText("deposit records", records, pagination, [
938
+ "CERT",
939
+ "YEAR",
940
+ "UNINAME",
941
+ "NAMEFULL",
942
+ "CITYBR",
943
+ "DEPSUMBR"
944
+ ]),
815
945
  CHARACTER_LIMIT
816
946
  );
817
947
  return {
@@ -873,7 +1003,7 @@ Args:
873
1003
  - sort_by (string, optional): Field to sort by
874
1004
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
875
1005
 
876
- Returns JSON with { total, offset, count, has_more, next_offset?, demographics[] }`,
1006
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and demographic records.`,
877
1007
  inputSchema: DemographicsQuerySchema,
878
1008
  annotations: {
879
1009
  readOnlyHint: true,
@@ -900,7 +1030,657 @@ Returns JSON with { total, offset, count, has_more, next_offset?, demographics[]
900
1030
  );
901
1031
  const output = { ...pagination, demographics: records };
902
1032
  const text = truncateIfNeeded(
903
- JSON.stringify(output, null, 2),
1033
+ formatSearchResultText("demographic records", records, pagination, [
1034
+ "CERT",
1035
+ "REPDTE",
1036
+ "OFFTOT",
1037
+ "OFFSTATE",
1038
+ "METRO",
1039
+ "CBSANAME"
1040
+ ]),
1041
+ CHARACTER_LIMIT
1042
+ );
1043
+ return {
1044
+ content: [{ type: "text", text }],
1045
+ structuredContent: output
1046
+ };
1047
+ } catch (err) {
1048
+ return formatToolError(err);
1049
+ }
1050
+ }
1051
+ );
1052
+ }
1053
+
1054
+ // src/tools/analysis.ts
1055
+ var import_zod7 = require("zod");
1056
+ var CHUNK_SIZE = 25;
1057
+ var MAX_CONCURRENCY = 4;
1058
+ var SortFieldSchema = import_zod7.z.enum([
1059
+ "asset_growth",
1060
+ "asset_growth_pct",
1061
+ "dep_growth",
1062
+ "dep_growth_pct",
1063
+ "netinc_change",
1064
+ "netinc_change_pct",
1065
+ "roa_change",
1066
+ "roe_change",
1067
+ "offices_change",
1068
+ "assets_per_office_change",
1069
+ "deposits_per_office_change",
1070
+ "deposits_to_assets_change"
1071
+ ]);
1072
+ var AnalysisModeSchema = import_zod7.z.enum(["snapshot", "timeseries"]);
1073
+ var SnapshotAnalysisSchema = import_zod7.z.object({
1074
+ state: import_zod7.z.string().optional().describe(
1075
+ 'State name for the institution roster filter. Example: "North Carolina"'
1076
+ ),
1077
+ certs: import_zod7.z.array(import_zod7.z.number().int().positive()).max(100).optional().describe(
1078
+ "Optional list of FDIC certificate numbers to compare directly. Max 100."
1079
+ ),
1080
+ institution_filters: import_zod7.z.string().optional().describe(
1081
+ 'Additional institution-level filter used when building the comparison set. Example: BKCLASS:N or CITY:"Charlotte"'
1082
+ ),
1083
+ active_only: import_zod7.z.boolean().default(true).describe("Limit the comparison set to currently active institutions."),
1084
+ start_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Starting report date in YYYYMMDD format."),
1085
+ end_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Ending report date in YYYYMMDD format."),
1086
+ analysis_mode: AnalysisModeSchema.default("snapshot").describe(
1087
+ "Use snapshot for two-point comparison or timeseries for quarterly trend analysis across the date range."
1088
+ ),
1089
+ include_demographics: import_zod7.z.boolean().default(true).describe(
1090
+ "Include office-count changes from the demographics dataset when available."
1091
+ ),
1092
+ limit: import_zod7.z.number().int().min(1).max(100).default(10).describe("Maximum number of ranked comparisons to return."),
1093
+ sort_by: SortFieldSchema.default("asset_growth").describe(
1094
+ "Comparison field used to rank institutions."
1095
+ ),
1096
+ sort_order: import_zod7.z.enum(["ASC", "DESC"]).default("DESC").describe("Sort direction for the ranked comparisons.")
1097
+ }).superRefine((value, ctx) => {
1098
+ if (!value.state && (!value.certs || value.certs.length === 0)) {
1099
+ ctx.addIssue({
1100
+ code: import_zod7.z.ZodIssueCode.custom,
1101
+ message: "Provide either state or certs.",
1102
+ path: ["state"]
1103
+ });
1104
+ }
1105
+ });
1106
+ function asNumber(value) {
1107
+ return typeof value === "number" ? value : null;
1108
+ }
1109
+ function buildCertFilters(certs) {
1110
+ const filters = [];
1111
+ for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
1112
+ const chunk = certs.slice(i, i + CHUNK_SIZE);
1113
+ filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
1114
+ }
1115
+ return filters;
1116
+ }
1117
+ async function mapWithConcurrency(values, limit, mapper) {
1118
+ const results = new Array(values.length);
1119
+ let nextIndex = 0;
1120
+ async function worker() {
1121
+ while (true) {
1122
+ const currentIndex = nextIndex;
1123
+ nextIndex += 1;
1124
+ if (currentIndex >= values.length) return;
1125
+ results[currentIndex] = await mapper(values[currentIndex], currentIndex);
1126
+ }
1127
+ }
1128
+ await Promise.all(
1129
+ Array.from({ length: Math.min(limit, values.length) }, () => worker())
1130
+ );
1131
+ return results;
1132
+ }
1133
+ function ratio(numerator, denominator) {
1134
+ if (numerator === null || denominator === null || denominator === 0) {
1135
+ return null;
1136
+ }
1137
+ return numerator / denominator;
1138
+ }
1139
+ function pctChange(start, end) {
1140
+ if (start === null || end === null || start === 0) return null;
1141
+ return (end - start) / start * 100;
1142
+ }
1143
+ function change(start, end) {
1144
+ if (start === null || end === null) return null;
1145
+ return end - start;
1146
+ }
1147
+ function yearsBetween(startRepdte, endRepdte) {
1148
+ const start = /* @__PURE__ */ new Date(
1149
+ `${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}`
1150
+ );
1151
+ const end = /* @__PURE__ */ new Date(
1152
+ `${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}`
1153
+ );
1154
+ return Math.max((end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3), 0);
1155
+ }
1156
+ function cagr(start, end, years) {
1157
+ if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
1158
+ return null;
1159
+ }
1160
+ return (Math.pow(end / start, 1 / years) - 1) * 100;
1161
+ }
1162
+ function formatPercent(value) {
1163
+ return value === null ? "n/a" : `${value.toFixed(1)}%`;
1164
+ }
1165
+ function formatChange(value, digits = 4) {
1166
+ if (value === null) return "n/a";
1167
+ return value.toFixed(digits);
1168
+ }
1169
+ function formatInteger(value) {
1170
+ return value === null ? "n/a" : `${Math.round(value).toLocaleString()}`;
1171
+ }
1172
+ function sortComparisons(comparisons, sortBy, sortOrder) {
1173
+ const direction = sortOrder === "ASC" ? 1 : -1;
1174
+ return [...comparisons].sort((left, right) => {
1175
+ const leftValue = asNumber(left[sortBy]) ?? Number.NEGATIVE_INFINITY;
1176
+ const rightValue = asNumber(right[sortBy]) ?? Number.NEGATIVE_INFINITY;
1177
+ return (leftValue - rightValue) * direction;
1178
+ });
1179
+ }
1180
+ function classifyInsights(comparison) {
1181
+ const insights = [];
1182
+ const assetGrowthPct = asNumber(comparison.asset_growth_pct);
1183
+ const depGrowthPct = asNumber(comparison.dep_growth_pct);
1184
+ const roaChange = asNumber(comparison.roa_change);
1185
+ const roeChange = asNumber(comparison.roe_change);
1186
+ const officesChange = asNumber(comparison.offices_change);
1187
+ const depositsToAssetsChange = asNumber(comparison.deposits_to_assets_change);
1188
+ if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && roaChange !== null && roaChange > 0) {
1189
+ insights.push("growth_with_better_profitability");
1190
+ }
1191
+ if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && officesChange !== null && officesChange > 0) {
1192
+ insights.push("growth_with_branch_expansion");
1193
+ }
1194
+ if (assetGrowthPct !== null && assetGrowthPct >= 20 && (roaChange === null || roaChange <= 0) && (roeChange === null || roeChange <= 0)) {
1195
+ insights.push("balance_sheet_growth_without_profitability");
1196
+ }
1197
+ if (assetGrowthPct !== null && assetGrowthPct > 0 && officesChange !== null && officesChange < 0) {
1198
+ insights.push("growth_with_branch_consolidation");
1199
+ }
1200
+ if (depositsToAssetsChange !== null && depositsToAssetsChange < 0 && depGrowthPct !== null && depGrowthPct < 0) {
1201
+ insights.push("deposit_mix_softening");
1202
+ }
1203
+ return insights;
1204
+ }
1205
+ function buildTopLevelInsights(comparisons) {
1206
+ return {
1207
+ growth_with_better_profitability: comparisons.filter(
1208
+ (comparison) => comparison.insights?.includes(
1209
+ "growth_with_better_profitability"
1210
+ )
1211
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1212
+ growth_with_branch_expansion: comparisons.filter(
1213
+ (comparison) => comparison.insights?.includes(
1214
+ "growth_with_branch_expansion"
1215
+ )
1216
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1217
+ balance_sheet_growth_without_profitability: comparisons.filter(
1218
+ (comparison) => comparison.insights?.includes(
1219
+ "balance_sheet_growth_without_profitability"
1220
+ )
1221
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1222
+ growth_with_branch_consolidation: comparisons.filter(
1223
+ (comparison) => comparison.insights?.includes(
1224
+ "growth_with_branch_consolidation"
1225
+ )
1226
+ ).slice(0, 5).map((comparison) => String(comparison.name))
1227
+ };
1228
+ }
1229
+ function longestMonotonicStreak(values, direction) {
1230
+ let longest = 0;
1231
+ let current = 0;
1232
+ for (let i = 1; i < values.length; i += 1) {
1233
+ const previous = values[i - 1];
1234
+ const next = values[i];
1235
+ if (previous === null || next === null) {
1236
+ current = 0;
1237
+ continue;
1238
+ }
1239
+ const matches = direction === "up" ? next > previous : next < previous;
1240
+ if (matches) {
1241
+ current += 1;
1242
+ longest = Math.max(longest, current);
1243
+ } else {
1244
+ current = 0;
1245
+ }
1246
+ }
1247
+ return longest;
1248
+ }
1249
+ function summarizeTimeSeries(records, demographicsByDate, institution) {
1250
+ if (records.length < 2) return null;
1251
+ const sorted = [...records].sort(
1252
+ (left, right) => String(left.REPDTE).localeCompare(String(right.REPDTE))
1253
+ );
1254
+ const start = sorted[0];
1255
+ const end = sorted[sorted.length - 1];
1256
+ const startRepdte = String(start.REPDTE);
1257
+ const endRepdte = String(end.REPDTE);
1258
+ const years = yearsBetween(startRepdte, endRepdte);
1259
+ const assetSeries = sorted.map((record) => asNumber(record.ASSET));
1260
+ const roaSeries = sorted.map((record) => asNumber(record.ROA));
1261
+ const roeSeries = sorted.map((record) => asNumber(record.ROE));
1262
+ const officesSeries = sorted.map(
1263
+ (record) => asNumber(demographicsByDate.get(String(record.REPDTE))?.OFFTOT)
1264
+ );
1265
+ const assetStart = asNumber(start.ASSET);
1266
+ const assetEnd = asNumber(end.ASSET);
1267
+ const depStart = asNumber(start.DEP);
1268
+ const depEnd = asNumber(end.DEP);
1269
+ const roaStart = asNumber(start.ROA);
1270
+ const roaEnd = asNumber(end.ROA);
1271
+ const roeStart = asNumber(start.ROE);
1272
+ const roeEnd = asNumber(end.ROE);
1273
+ const netIncStart = asNumber(start.NETINC);
1274
+ const netIncEnd = asNumber(end.NETINC);
1275
+ const officesStart = officesSeries[0] ?? null;
1276
+ const officesEnd = officesSeries[officesSeries.length - 1] ?? null;
1277
+ const assetsPerOfficeStart = ratio(assetStart, officesStart);
1278
+ const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
1279
+ const depositsPerOfficeStart = ratio(depStart, officesStart);
1280
+ const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
1281
+ const depositsToAssetsStart = ratio(depStart, assetStart);
1282
+ const depositsToAssetsEnd = ratio(depEnd, assetEnd);
1283
+ const peakAsset = Math.max(...assetSeries.filter((value) => value !== null));
1284
+ const troughRoaValues = roaSeries.filter((value) => value !== null);
1285
+ const troughRoa = troughRoaValues.length > 0 ? Math.min(...troughRoaValues) : null;
1286
+ const comparison = {
1287
+ cert: asNumber(start.CERT),
1288
+ name: end.NAME ?? start.NAME ?? institution.NAME,
1289
+ city: institution.CITY,
1290
+ stalp: institution.STALP,
1291
+ analysis_mode: "timeseries",
1292
+ start_repdte: startRepdte,
1293
+ end_repdte: endRepdte,
1294
+ periods_analyzed: sorted.length,
1295
+ asset_start: assetStart,
1296
+ asset_end: assetEnd,
1297
+ asset_growth: change(assetStart, assetEnd),
1298
+ asset_growth_pct: pctChange(assetStart, assetEnd),
1299
+ asset_cagr: cagr(assetStart, assetEnd, years),
1300
+ dep_start: depStart,
1301
+ dep_end: depEnd,
1302
+ dep_growth: change(depStart, depEnd),
1303
+ dep_growth_pct: pctChange(depStart, depEnd),
1304
+ netinc_start: netIncStart,
1305
+ netinc_end: netIncEnd,
1306
+ netinc_change: change(netIncStart, netIncEnd),
1307
+ netinc_change_pct: pctChange(netIncStart, netIncEnd),
1308
+ roa_start: roaStart,
1309
+ roa_end: roaEnd,
1310
+ roa_change: change(roaStart, roaEnd),
1311
+ roe_start: roeStart,
1312
+ roe_end: roeEnd,
1313
+ roe_change: change(roeStart, roeEnd),
1314
+ offices_start: officesStart,
1315
+ offices_end: officesEnd,
1316
+ offices_change: change(officesStart, officesEnd),
1317
+ assets_per_office_start: assetsPerOfficeStart,
1318
+ assets_per_office_end: assetsPerOfficeEnd,
1319
+ assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
1320
+ deposits_per_office_start: depositsPerOfficeStart,
1321
+ deposits_per_office_end: depositsPerOfficeEnd,
1322
+ deposits_per_office_change: change(
1323
+ depositsPerOfficeStart,
1324
+ depositsPerOfficeEnd
1325
+ ),
1326
+ deposits_to_assets_start: depositsToAssetsStart,
1327
+ deposits_to_assets_end: depositsToAssetsEnd,
1328
+ deposits_to_assets_change: change(
1329
+ depositsToAssetsStart,
1330
+ depositsToAssetsEnd
1331
+ ),
1332
+ asset_peak: peakAsset,
1333
+ roa_trough: troughRoa,
1334
+ asset_growth_streak: longestMonotonicStreak(assetSeries, "up"),
1335
+ roa_decline_streak: longestMonotonicStreak(roaSeries, "down"),
1336
+ roe_decline_streak: longestMonotonicStreak(roeSeries, "down"),
1337
+ time_series: sorted.map((record) => {
1338
+ const repdte = String(record.REPDTE);
1339
+ const demo = demographicsByDate.get(repdte);
1340
+ return {
1341
+ repdte,
1342
+ asset: asNumber(record.ASSET),
1343
+ dep: asNumber(record.DEP),
1344
+ netinc: asNumber(record.NETINC),
1345
+ roa: asNumber(record.ROA),
1346
+ roe: asNumber(record.ROE),
1347
+ offices: asNumber(demo?.OFFTOT)
1348
+ };
1349
+ })
1350
+ };
1351
+ comparison.insights = classifyInsights(comparison);
1352
+ if (comparison.asset_growth_streak >= 3) {
1353
+ comparison.insights.push("sustained_asset_growth");
1354
+ }
1355
+ if (comparison.roa_decline_streak >= 2) {
1356
+ comparison.insights.push("multi_quarter_roa_decline");
1357
+ }
1358
+ return comparison;
1359
+ }
1360
+ function formatComparisonText(output) {
1361
+ 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.`;
1362
+ if (output.comparisons.length === 0) {
1363
+ return header;
1364
+ }
1365
+ const rows = output.comparisons.map((comparison, index) => {
1366
+ const name = String(comparison.name ?? comparison.cert);
1367
+ const city = comparison.city ? `, ${comparison.city}` : "";
1368
+ 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))}`;
1369
+ if (output.analysis_mode === "timeseries") {
1370
+ 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))}`;
1371
+ }
1372
+ return base;
1373
+ });
1374
+ const insights = output.insights ? Object.entries(output.insights).filter(([, names]) => names.length > 0).map(([label, names]) => `${label}: ${names.join(", ")}`).join("\n") : "";
1375
+ return insights ? `${header}
1376
+ ${rows.join("\n")}
1377
+ Insights
1378
+ ${insights}` : `${header}
1379
+ ${rows.join("\n")}`;
1380
+ }
1381
+ async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
1382
+ const filterParts = [];
1383
+ if (state) filterParts.push(`STNAME:"${state}"`);
1384
+ if (activeOnly) filterParts.push("ACTIVE:1");
1385
+ if (institutionFilters) filterParts.push(`(${institutionFilters})`);
1386
+ const response = await queryEndpoint(ENDPOINTS.INSTITUTIONS, {
1387
+ filters: filterParts.join(" AND "),
1388
+ fields: "CERT,NAME,CITY,STALP,ACTIVE",
1389
+ limit: 1e4,
1390
+ offset: 0,
1391
+ sort_by: "CERT",
1392
+ sort_order: "ASC"
1393
+ });
1394
+ return extractRecords(response);
1395
+ }
1396
+ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields) {
1397
+ const certFilters = buildCertFilters(certs);
1398
+ const tasks = repdteFilters.flatMap(
1399
+ (repdteFilter) => certFilters.map((certFilter) => ({
1400
+ repdteFilter,
1401
+ certFilter
1402
+ }))
1403
+ );
1404
+ const responses = await mapWithConcurrency(tasks, MAX_CONCURRENCY, async (task) => {
1405
+ const response = await queryEndpoint(endpoint, {
1406
+ filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
1407
+ fields,
1408
+ limit: 1e4,
1409
+ offset: 0,
1410
+ sort_by: "CERT",
1411
+ sort_order: "ASC"
1412
+ });
1413
+ return { repdteFilter: task.repdteFilter, response };
1414
+ });
1415
+ const byDate = /* @__PURE__ */ new Map();
1416
+ for (const { repdteFilter, response } of responses) {
1417
+ if (!byDate.has(repdteFilter)) {
1418
+ byDate.set(repdteFilter, /* @__PURE__ */ new Map());
1419
+ }
1420
+ const target = byDate.get(repdteFilter);
1421
+ for (const record of extractRecords(response)) {
1422
+ const cert = asNumber(record.CERT);
1423
+ if (cert !== null) target.set(cert, record);
1424
+ }
1425
+ }
1426
+ return byDate;
1427
+ }
1428
+ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields) {
1429
+ const certFilters = buildCertFilters(certs);
1430
+ const responses = await mapWithConcurrency(
1431
+ certFilters,
1432
+ MAX_CONCURRENCY,
1433
+ async (certFilter) => queryEndpoint(endpoint, {
1434
+ filters: `(${certFilter}) AND REPDTE:[${startRepdte} TO ${endRepdte}]`,
1435
+ fields,
1436
+ limit: 1e4,
1437
+ offset: 0,
1438
+ sort_by: "REPDTE",
1439
+ sort_order: "ASC"
1440
+ })
1441
+ );
1442
+ const grouped = /* @__PURE__ */ new Map();
1443
+ for (const response of responses) {
1444
+ for (const record of extractRecords(response)) {
1445
+ const cert = asNumber(record.CERT);
1446
+ if (cert === null) continue;
1447
+ if (!grouped.has(cert)) grouped.set(cert, []);
1448
+ grouped.get(cert).push(record);
1449
+ }
1450
+ }
1451
+ return grouped;
1452
+ }
1453
+ function buildSnapshotComparison(cert, institution, startFinancial, endFinancial, startDemo, endDemo, startRepdte, endRepdte) {
1454
+ const assetStart = asNumber(startFinancial.ASSET);
1455
+ const assetEnd = asNumber(endFinancial.ASSET);
1456
+ const depStart = asNumber(startFinancial.DEP);
1457
+ const depEnd = asNumber(endFinancial.DEP);
1458
+ const netIncStart = asNumber(startFinancial.NETINC);
1459
+ const netIncEnd = asNumber(endFinancial.NETINC);
1460
+ const roaStart = asNumber(startFinancial.ROA);
1461
+ const roaEnd = asNumber(endFinancial.ROA);
1462
+ const roeStart = asNumber(startFinancial.ROE);
1463
+ const roeEnd = asNumber(endFinancial.ROE);
1464
+ const officesStart = asNumber(startDemo?.OFFTOT);
1465
+ const officesEnd = asNumber(endDemo?.OFFTOT);
1466
+ const assetsPerOfficeStart = ratio(assetStart, officesStart);
1467
+ const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
1468
+ const depositsPerOfficeStart = ratio(depStart, officesStart);
1469
+ const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
1470
+ const depositsToAssetsStart = ratio(depStart, assetStart);
1471
+ const depositsToAssetsEnd = ratio(depEnd, assetEnd);
1472
+ const comparison = {
1473
+ cert,
1474
+ name: endFinancial.NAME ?? startFinancial.NAME ?? institution.NAME,
1475
+ city: institution.CITY,
1476
+ stalp: institution.STALP,
1477
+ analysis_mode: "snapshot",
1478
+ start_repdte: startRepdte,
1479
+ end_repdte: endRepdte,
1480
+ asset_start: assetStart,
1481
+ asset_end: assetEnd,
1482
+ asset_growth: change(assetStart, assetEnd),
1483
+ asset_growth_pct: pctChange(assetStart, assetEnd),
1484
+ dep_start: depStart,
1485
+ dep_end: depEnd,
1486
+ dep_growth: change(depStart, depEnd),
1487
+ dep_growth_pct: pctChange(depStart, depEnd),
1488
+ netinc_start: netIncStart,
1489
+ netinc_end: netIncEnd,
1490
+ netinc_change: change(netIncStart, netIncEnd),
1491
+ netinc_change_pct: pctChange(netIncStart, netIncEnd),
1492
+ roa_start: roaStart,
1493
+ roa_end: roaEnd,
1494
+ roa_change: change(roaStart, roaEnd),
1495
+ roe_start: roeStart,
1496
+ roe_end: roeEnd,
1497
+ roe_change: change(roeStart, roeEnd),
1498
+ offices_start: officesStart,
1499
+ offices_end: officesEnd,
1500
+ offices_change: change(officesStart, officesEnd),
1501
+ assets_per_office_start: assetsPerOfficeStart,
1502
+ assets_per_office_end: assetsPerOfficeEnd,
1503
+ assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
1504
+ deposits_per_office_start: depositsPerOfficeStart,
1505
+ deposits_per_office_end: depositsPerOfficeEnd,
1506
+ deposits_per_office_change: change(
1507
+ depositsPerOfficeStart,
1508
+ depositsPerOfficeEnd
1509
+ ),
1510
+ deposits_to_assets_start: depositsToAssetsStart,
1511
+ deposits_to_assets_end: depositsToAssetsEnd,
1512
+ deposits_to_assets_change: change(
1513
+ depositsToAssetsStart,
1514
+ depositsToAssetsEnd
1515
+ ),
1516
+ cbsa_start: startDemo?.CBSANAME,
1517
+ cbsa_end: endDemo?.CBSANAME
1518
+ };
1519
+ comparison.insights = classifyInsights(comparison);
1520
+ return comparison;
1521
+ }
1522
+ function registerAnalysisTools(server) {
1523
+ server.registerTool(
1524
+ "fdic_compare_bank_snapshots",
1525
+ {
1526
+ title: "Compare Bank Snapshot Trends",
1527
+ description: `Compare FDIC reporting snapshots across a set of institutions and rank the results by growth, profitability, or efficiency changes.
1528
+
1529
+ 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.
1530
+
1531
+ Good uses:
1532
+ - Identify North Carolina banks with the strongest asset growth from 2021 to 2025
1533
+ - Compare whether deposit growth came with branch expansion or profitability improvement
1534
+ - Rank a specific cert list by ROA, ROE, asset-per-office, or deposit-to-asset changes
1535
+ - Pull a quarterly trend series and highlight inflection points, streaks, and structural shifts
1536
+
1537
+ Inputs:
1538
+ - state or certs: choose a geographic roster or provide a direct comparison set
1539
+ - start_repdte, end_repdte: report dates in YYYYMMDD format
1540
+ - analysis_mode: snapshot or timeseries
1541
+ - institution_filters: optional extra institution filter when building the roster
1542
+ - active_only: default true
1543
+ - include_demographics: default true, adds office-count comparisons when available
1544
+ - sort_by: ranking field such as asset_growth, dep_growth_pct, roa_change, assets_per_office_change
1545
+ - sort_order: ASC or DESC
1546
+ - limit: maximum ranked results to return
1547
+
1548
+ Returns concise comparison text plus structured deltas, derived metrics, and insight tags for each institution.`,
1549
+ inputSchema: SnapshotAnalysisSchema,
1550
+ annotations: {
1551
+ readOnlyHint: true,
1552
+ destructiveHint: false,
1553
+ idempotentHint: true,
1554
+ openWorldHint: true
1555
+ }
1556
+ },
1557
+ async ({
1558
+ state,
1559
+ certs,
1560
+ institution_filters,
1561
+ active_only,
1562
+ start_repdte,
1563
+ end_repdte,
1564
+ analysis_mode,
1565
+ include_demographics,
1566
+ limit,
1567
+ sort_by,
1568
+ sort_order
1569
+ }) => {
1570
+ try {
1571
+ const roster = certs && certs.length > 0 ? certs.map((cert) => ({ CERT: cert })) : await fetchInstitutionRoster(
1572
+ state,
1573
+ institution_filters,
1574
+ active_only
1575
+ );
1576
+ const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
1577
+ if (candidateCerts.length === 0) {
1578
+ const output2 = {
1579
+ total_candidates: 0,
1580
+ analyzed_count: 0,
1581
+ start_repdte,
1582
+ end_repdte,
1583
+ analysis_mode,
1584
+ sort_by,
1585
+ sort_order,
1586
+ comparisons: []
1587
+ };
1588
+ return {
1589
+ content: [
1590
+ { type: "text", text: "No institutions matched the comparison set." }
1591
+ ],
1592
+ structuredContent: output2
1593
+ };
1594
+ }
1595
+ const rosterByCert = new Map(
1596
+ roster.map((record) => [asNumber(record.CERT), record]).filter(
1597
+ (entry) => entry[0] !== null
1598
+ )
1599
+ );
1600
+ let comparisons = [];
1601
+ if (analysis_mode === "timeseries") {
1602
+ const [financialSeries, demographicsSeries] = await Promise.all([
1603
+ fetchSeriesRecords(
1604
+ ENDPOINTS.FINANCIALS,
1605
+ candidateCerts,
1606
+ start_repdte,
1607
+ end_repdte,
1608
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
1609
+ ),
1610
+ include_demographics ? fetchSeriesRecords(
1611
+ ENDPOINTS.DEMOGRAPHICS,
1612
+ candidateCerts,
1613
+ start_repdte,
1614
+ end_repdte,
1615
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
1616
+ ) : Promise.resolve(/* @__PURE__ */ new Map())
1617
+ ]);
1618
+ comparisons = candidateCerts.map(
1619
+ (cert) => summarizeTimeSeries(
1620
+ financialSeries.get(cert) ?? [],
1621
+ new Map(
1622
+ (demographicsSeries.get(cert) ?? []).map((record) => [
1623
+ String(record.REPDTE),
1624
+ record
1625
+ ])
1626
+ ),
1627
+ rosterByCert.get(cert) ?? {}
1628
+ )
1629
+ ).filter((comparison) => comparison !== null);
1630
+ } else {
1631
+ const [financialSnapshots, demographicSnapshots] = await Promise.all([
1632
+ fetchBatchedRecordsForDates(
1633
+ ENDPOINTS.FINANCIALS,
1634
+ candidateCerts,
1635
+ [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1636
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
1637
+ ),
1638
+ include_demographics ? fetchBatchedRecordsForDates(
1639
+ ENDPOINTS.DEMOGRAPHICS,
1640
+ candidateCerts,
1641
+ [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1642
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
1643
+ ) : Promise.resolve(/* @__PURE__ */ new Map())
1644
+ ]);
1645
+ const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
1646
+ const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
1647
+ const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
1648
+ const endDemographics = demographicSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
1649
+ comparisons = candidateCerts.map((cert) => {
1650
+ const startFinancial = startFinancials.get(cert);
1651
+ const endFinancial = endFinancials.get(cert);
1652
+ if (!startFinancial || !endFinancial) return null;
1653
+ return buildSnapshotComparison(
1654
+ cert,
1655
+ rosterByCert.get(cert) ?? {},
1656
+ startFinancial,
1657
+ endFinancial,
1658
+ startDemographics.get(cert),
1659
+ endDemographics.get(cert),
1660
+ start_repdte,
1661
+ end_repdte
1662
+ );
1663
+ }).filter((comparison) => comparison !== null);
1664
+ }
1665
+ const ranked = sortComparisons(comparisons, sort_by, sort_order).slice(
1666
+ 0,
1667
+ limit
1668
+ );
1669
+ const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
1670
+ const output = {
1671
+ total_candidates: candidateCerts.length,
1672
+ analyzed_count: comparisons.length,
1673
+ start_repdte,
1674
+ end_repdte,
1675
+ analysis_mode,
1676
+ sort_by,
1677
+ sort_order,
1678
+ insights: buildTopLevelInsights(comparisons),
1679
+ ...pagination,
1680
+ comparisons: ranked
1681
+ };
1682
+ const text = truncateIfNeeded(
1683
+ formatComparisonText(output),
904
1684
  CHARACTER_LIMIT
905
1685
  );
906
1686
  return {
@@ -927,6 +1707,7 @@ function createServer() {
927
1707
  registerFinancialTools(server);
928
1708
  registerSodTools(server);
929
1709
  registerDemographicsTools(server);
1710
+ registerAnalysisTools(server);
930
1711
  return server;
931
1712
  }
932
1713
  async function runStdio() {