fdic-mcp-server 1.29.0 → 1.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +664 -26
  2. package/dist/server.js +664 -26
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -32,7 +32,7 @@ var import_types = require("@modelcontextprotocol/sdk/types.js");
32
32
  var import_express2 = __toESM(require("express"));
33
33
 
34
34
  // src/constants.ts
35
- var VERSION = true ? "1.29.0" : process.env.npm_package_version ?? "0.0.0-dev";
35
+ var VERSION = true ? "1.30.1" : process.env.npm_package_version ?? "0.0.0-dev";
36
36
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
37
37
  var CHARACTER_LIMIT = 5e4;
38
38
  var DEFAULT_FDIC_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
@@ -73,6 +73,42 @@ var RateLimiter = class {
73
73
  return true;
74
74
  }
75
75
  };
76
+ var ConcurrentLimiter = class {
77
+ maxConcurrent;
78
+ active = /* @__PURE__ */ new Map();
79
+ constructor(maxConcurrent) {
80
+ this.maxConcurrent = maxConcurrent;
81
+ }
82
+ acquire(key) {
83
+ const current = this.active.get(key) ?? 0;
84
+ if (current >= this.maxConcurrent) {
85
+ return void 0;
86
+ }
87
+ this.active.set(key, current + 1);
88
+ let released = false;
89
+ return () => {
90
+ if (released) {
91
+ return;
92
+ }
93
+ released = true;
94
+ const latest = this.active.get(key) ?? 0;
95
+ if (latest <= 1) {
96
+ this.active.delete(key);
97
+ return;
98
+ }
99
+ this.active.set(key, latest - 1);
100
+ };
101
+ }
102
+ };
103
+
104
+ // src/requestIdentity.ts
105
+ function getRequestIp(req) {
106
+ const forwarded = req.get("x-forwarded-for");
107
+ if (forwarded) {
108
+ return forwarded.split(",")[0]?.trim() || req.ip || "unknown";
109
+ }
110
+ return req.ip || "unknown";
111
+ }
76
112
 
77
113
  // src/chat.ts
78
114
  var CHAT_SYSTEM_PROMPT = `You are a demo assistant for the FDIC BankFind MCP Server. You help users
@@ -195,13 +231,6 @@ function mapMessagesToContents(messages) {
195
231
  parts: [{ text: message.content }]
196
232
  }));
197
233
  }
198
- function getRequestIp(req) {
199
- const forwarded = req.get("x-forwarded-for");
200
- if (forwarded) {
201
- return forwarded.split(",")[0]?.trim() || req.ip || "unknown";
202
- }
203
- return req.ip || "unknown";
204
- }
205
234
  function getResponseParts(response) {
206
235
  return response.candidates?.[0]?.content?.parts ?? [];
207
236
  }
@@ -38735,8 +38764,542 @@ NOTE: Requires FRED_API_KEY environment variable for reliable data access. Degra
38735
38764
  );
38736
38765
  }
38737
38766
 
38738
- // src/tools/chatgptRetrieval.ts
38767
+ // src/tools/qbpLite.ts
38739
38768
  var import_zod21 = require("zod");
38769
+ var QBP_LITE_FETCH_LIMIT = 1e4;
38770
+ var QBP_LITE_FIELDS = [
38771
+ "CERT",
38772
+ "NAME",
38773
+ "REPDTE",
38774
+ "CB",
38775
+ "SPECGRP",
38776
+ "SPECGRPDESC",
38777
+ "ASSET",
38778
+ "DEP",
38779
+ "DEPDOM",
38780
+ "NETINC",
38781
+ "ROA",
38782
+ "ROE",
38783
+ "NIMY",
38784
+ "ERNAST",
38785
+ "LNLSNET",
38786
+ "LNRE",
38787
+ "LNCI",
38788
+ "LNCON",
38789
+ "LNCRCD",
38790
+ "LNAG",
38791
+ "SC",
38792
+ "EQTOT",
38793
+ "NCLNLSR",
38794
+ "NTLNLSR",
38795
+ "RBC",
38796
+ "RBC1AAJ",
38797
+ "IDT1RWAJR",
38798
+ "RBCT1",
38799
+ "RBCT1C",
38800
+ "RBCT1CER",
38801
+ "RBCRWAJ",
38802
+ "RWAJ",
38803
+ "RWAJT",
38804
+ "P3RER",
38805
+ "P3CIR",
38806
+ "P3CONR",
38807
+ "P3CRCDR",
38808
+ "P3AGR",
38809
+ "NTRER",
38810
+ "NTCIR",
38811
+ "NTCONR",
38812
+ "NTCRCDR",
38813
+ "NTAGR"
38814
+ ].join(",");
38815
+ var QbpLiteSchema = import_zod21.z.object({
38816
+ repdte: import_zod21.z.string().regex(/^\d{8}$/).optional().describe(
38817
+ "Quarter-end Report Date (REPDTE) in YYYYMMDD format. If omitted, the tool searches backward from the latest likely published quarter until data is found."
38818
+ ),
38819
+ trend_quarters: import_zod21.z.number().int().min(8).max(40).default(20).describe(
38820
+ "Number of quarterly observations to return for trend charts, including the current quarter. Default 20 quarters."
38821
+ ),
38822
+ include_community_banks: import_zod21.z.boolean().default(true).describe(
38823
+ "Include a compact community-bank-vs-industry comparison using the public community-bank flag."
38824
+ )
38825
+ });
38826
+ var EXECUTIVE_METRICS = [
38827
+ {
38828
+ id: "institution_count",
38829
+ label: "Number of institutions reporting",
38830
+ unit: "count"
38831
+ },
38832
+ { id: "total_assets", label: "Total assets", unit: "$thousands" },
38833
+ {
38834
+ id: "total_loans_and_leases",
38835
+ label: "Total loans and leases",
38836
+ unit: "$thousands"
38837
+ },
38838
+ { id: "domestic_deposits", label: "Domestic deposits", unit: "$thousands" },
38839
+ { id: "net_income", label: "Net income", unit: "$thousands" },
38840
+ { id: "roa_pct", label: "Return on assets", unit: "percent" },
38841
+ { id: "roe_pct", label: "Return on equity", unit: "percent" },
38842
+ { id: "net_interest_margin_pct", label: "Net interest margin", unit: "percent" },
38843
+ { id: "noncurrent_loans_pct", label: "Noncurrent loans to loans", unit: "percent" },
38844
+ { id: "net_chargeoffs_pct", label: "Net charge-offs to loans", unit: "percent" },
38845
+ { id: "leverage_ratio_pct", label: "Core capital (leverage) ratio", unit: "percent" }
38846
+ ];
38847
+ function sumField(records, field) {
38848
+ let total = 0;
38849
+ let seen = false;
38850
+ for (const record of records) {
38851
+ const value = asNumber(record[field]);
38852
+ if (value !== null) {
38853
+ total += value;
38854
+ seen = true;
38855
+ }
38856
+ }
38857
+ return seen ? total : null;
38858
+ }
38859
+ function weightedAverage(records, valueField, weightField) {
38860
+ let weightedSum = 0;
38861
+ let weightSum = 0;
38862
+ for (const record of records) {
38863
+ const value = asNumber(record[valueField]);
38864
+ const weight = asNumber(record[weightField]);
38865
+ if (value !== null && weight !== null && weight > 0) {
38866
+ weightedSum += value * weight;
38867
+ weightSum += weight;
38868
+ }
38869
+ }
38870
+ return weightSum > 0 ? weightedSum / weightSum : null;
38871
+ }
38872
+ function pctChange2(current, prior) {
38873
+ if (current === null || prior === null || prior === 0) return null;
38874
+ return (current - prior) / prior * 100;
38875
+ }
38876
+ function change2(current, prior) {
38877
+ if (current === null || prior === null) return null;
38878
+ return current - prior;
38879
+ }
38880
+ function dividePct(numerator, denominator) {
38881
+ if (numerator === null || denominator === null || denominator === 0) return null;
38882
+ return numerator / denominator * 100;
38883
+ }
38884
+ function sumRatioPct(records, numeratorField, denominatorField) {
38885
+ return dividePct(sumField(records, numeratorField), sumField(records, denominatorField));
38886
+ }
38887
+ function aggregateFinancialRecords(records) {
38888
+ const totalAssets = sumField(records, "ASSET");
38889
+ const totalLoans = sumField(records, "LNLSNET");
38890
+ const equityCapital = sumField(records, "EQTOT");
38891
+ return {
38892
+ institution_count: records.length,
38893
+ total_assets: totalAssets,
38894
+ total_loans_and_leases: totalLoans,
38895
+ domestic_deposits: sumField(records, "DEPDOM"),
38896
+ total_deposits: sumField(records, "DEP"),
38897
+ securities: sumField(records, "SC"),
38898
+ equity_capital: equityCapital,
38899
+ net_income: sumField(records, "NETINC"),
38900
+ roa_pct: weightedAverage(records, "ROA", "ASSET"),
38901
+ roe_pct: weightedAverage(records, "ROE", "EQTOT"),
38902
+ net_interest_margin_pct: weightedAverage(records, "NIMY", "ERNAST"),
38903
+ noncurrent_loans_pct: weightedAverage(records, "NCLNLSR", "LNLSNET"),
38904
+ net_chargeoffs_pct: weightedAverage(records, "NTLNLSR", "LNLSNET"),
38905
+ leverage_ratio_pct: weightedAverage(records, "RBC1AAJ", "ASSET"),
38906
+ common_equity_tier1_ratio_pct: sumRatioPct(records, "RBCT1C", "RWAJ") ?? weightedAverage(records, "RBCT1CER", "RWAJ"),
38907
+ tier1_risk_based_ratio_pct: sumRatioPct(records, "RBCT1", "RWAJ") ?? weightedAverage(records, "IDT1RWAJR", "RWAJ"),
38908
+ total_risk_based_ratio_pct: sumRatioPct(records, "RBC", "RWAJT") ?? weightedAverage(records, "RBCRWAJ", "RWAJT")
38909
+ };
38910
+ }
38911
+ function buildExecutiveSnapshot(current, priorQuarter, yearAgo) {
38912
+ return EXECUTIVE_METRICS.map(({ id, label, unit }) => {
38913
+ const currentValue = current[id];
38914
+ const priorValue = priorQuarter?.[id] ?? null;
38915
+ const yearAgoValue = yearAgo?.[id] ?? null;
38916
+ return {
38917
+ id,
38918
+ label,
38919
+ unit,
38920
+ change_unit: unit === "percent" ? "percentage_points" : unit,
38921
+ current: currentValue,
38922
+ prior_quarter_change: change2(currentValue, priorValue),
38923
+ prior_quarter_change_pct: unit === "percent" ? null : pctChange2(currentValue, priorValue),
38924
+ year_over_year_change: change2(currentValue, yearAgoValue),
38925
+ year_over_year_change_pct: unit === "percent" ? null : pctChange2(currentValue, yearAgoValue)
38926
+ };
38927
+ });
38928
+ }
38929
+ function isCommunityBank(record) {
38930
+ const value = record.CB;
38931
+ return value === 1 || value === "1";
38932
+ }
38933
+ function assetSizeGroup(asset) {
38934
+ if (asset === null) return "Unknown";
38935
+ if (asset < 1e5) return "Assets < $100 Million";
38936
+ if (asset < 1e6) return "Assets $100 Million - $1 Billion";
38937
+ if (asset < 1e7) return "Assets $1 Billion - $10 Billion";
38938
+ if (asset < 25e7) return "Assets $10 Billion - $250 Billion";
38939
+ return "Assets > $250 Billion";
38940
+ }
38941
+ function buildSeriesPoint(repdte, records) {
38942
+ const metrics = aggregateFinancialRecords(records);
38943
+ return {
38944
+ repdte,
38945
+ ...metrics
38946
+ };
38947
+ }
38948
+ function buildAssetSizeNimSeries(groupedRecords) {
38949
+ const rows = [];
38950
+ for (const [repdte, records] of groupedRecords) {
38951
+ const groups = /* @__PURE__ */ new Map();
38952
+ for (const record of records) {
38953
+ const group = assetSizeGroup(asNumber(record.ASSET));
38954
+ const groupRecords = groups.get(group);
38955
+ if (groupRecords) {
38956
+ groupRecords.push(record);
38957
+ } else {
38958
+ groups.set(group, [record]);
38959
+ }
38960
+ }
38961
+ rows.push({
38962
+ repdte,
38963
+ group: "Industry",
38964
+ institution_count: records.length,
38965
+ total_assets: sumField(records, "ASSET"),
38966
+ net_interest_margin_pct: weightedAverage(records, "NIMY", "ERNAST")
38967
+ });
38968
+ for (const [group, groupRecords] of groups) {
38969
+ rows.push({
38970
+ repdte,
38971
+ group,
38972
+ institution_count: groupRecords.length,
38973
+ total_assets: sumField(groupRecords, "ASSET"),
38974
+ net_interest_margin_pct: weightedAverage(groupRecords, "NIMY", "ERNAST")
38975
+ });
38976
+ }
38977
+ }
38978
+ return rows;
38979
+ }
38980
+ function buildLoanAndDepositSeries(series) {
38981
+ const byDate = new Map(series.map((point) => [point.repdte, point]));
38982
+ return series.map((point) => {
38983
+ const priorQuarter = getPriorQuarterDates(point.repdte, 1)[0];
38984
+ const yearAgo = getReportDateOneYearPrior(point.repdte);
38985
+ const priorPoint = byDate.get(priorQuarter);
38986
+ const yearAgoPoint = byDate.get(yearAgo);
38987
+ return {
38988
+ repdte: point.repdte,
38989
+ total_loans_and_leases: point.total_loans_and_leases,
38990
+ quarterly_loan_change: change2(
38991
+ point.total_loans_and_leases,
38992
+ priorPoint?.total_loans_and_leases ?? null
38993
+ ),
38994
+ loan_growth_12_month_pct: pctChange2(
38995
+ point.total_loans_and_leases,
38996
+ yearAgoPoint?.total_loans_and_leases ?? null
38997
+ ),
38998
+ domestic_deposits: point.domestic_deposits,
38999
+ quarterly_domestic_deposit_change: change2(
39000
+ point.domestic_deposits,
39001
+ priorPoint?.domestic_deposits ?? null
39002
+ ),
39003
+ domestic_deposit_growth_12_month_pct: pctChange2(
39004
+ point.domestic_deposits,
39005
+ yearAgoPoint?.domestic_deposits ?? null
39006
+ )
39007
+ };
39008
+ });
39009
+ }
39010
+ function buildPortfolioPerformance(records) {
39011
+ const portfolios = [
39012
+ {
39013
+ id: "real_estate",
39014
+ label: "Real estate loans",
39015
+ balanceField: "LNRE",
39016
+ noncurrentRateField: "P3RER",
39017
+ chargeoffRateField: "NTRER"
39018
+ },
39019
+ {
39020
+ id: "commercial_industrial",
39021
+ label: "Commercial and industrial loans",
39022
+ balanceField: "LNCI",
39023
+ noncurrentRateField: "P3CIR",
39024
+ chargeoffRateField: "NTCIR"
39025
+ },
39026
+ {
39027
+ id: "consumer",
39028
+ label: "Consumer loans",
39029
+ balanceField: "LNCON",
39030
+ noncurrentRateField: "P3CONR",
39031
+ chargeoffRateField: "NTCONR"
39032
+ },
39033
+ {
39034
+ id: "credit_card",
39035
+ label: "Credit card loans",
39036
+ balanceField: "LNCRCD",
39037
+ noncurrentRateField: "P3CRCDR",
39038
+ chargeoffRateField: "NTCRCDR"
39039
+ },
39040
+ {
39041
+ id: "farm",
39042
+ label: "Farm loans",
39043
+ balanceField: "LNAG",
39044
+ noncurrentRateField: "P3AGR",
39045
+ chargeoffRateField: "NTAGR"
39046
+ }
39047
+ ];
39048
+ return portfolios.map((portfolio) => ({
39049
+ id: portfolio.id,
39050
+ label: portfolio.label,
39051
+ balance: sumField(records, portfolio.balanceField),
39052
+ balance_share_of_total_loans_pct: dividePct(
39053
+ sumField(records, portfolio.balanceField),
39054
+ sumField(records, "LNLSNET")
39055
+ ),
39056
+ noncurrent_rate_pct: weightedAverage(
39057
+ records,
39058
+ portfolio.noncurrentRateField,
39059
+ portfolio.balanceField
39060
+ ),
39061
+ net_chargeoff_rate_pct: weightedAverage(
39062
+ records,
39063
+ portfolio.chargeoffRateField,
39064
+ portfolio.balanceField
39065
+ )
39066
+ }));
39067
+ }
39068
+ function buildCommunityComparison(groupedRecords) {
39069
+ const rows = [];
39070
+ for (const [repdte, records] of groupedRecords) {
39071
+ for (const [group, groupRecords] of [
39072
+ ["Industry", records],
39073
+ ["Community Banks", records.filter(isCommunityBank)]
39074
+ ]) {
39075
+ const metrics = aggregateFinancialRecords(groupRecords);
39076
+ rows.push({
39077
+ repdte,
39078
+ group,
39079
+ institution_count: metrics.institution_count,
39080
+ total_assets: metrics.total_assets,
39081
+ total_loans_and_leases: metrics.total_loans_and_leases,
39082
+ domestic_deposits: metrics.domestic_deposits,
39083
+ net_income: metrics.net_income,
39084
+ roa_pct: metrics.roa_pct,
39085
+ net_interest_margin_pct: metrics.net_interest_margin_pct,
39086
+ noncurrent_loans_pct: metrics.noncurrent_loans_pct,
39087
+ net_chargeoffs_pct: metrics.net_chargeoffs_pct
39088
+ });
39089
+ }
39090
+ }
39091
+ return rows;
39092
+ }
39093
+ function buildTruncationWarning2(repdte, returnedCount, totalCount, limit = QBP_LITE_FETCH_LIMIT) {
39094
+ if (returnedCount >= limit && totalCount > limit) {
39095
+ return `REPDTE ${repdte}: results truncated at ${limit.toLocaleString()} of ${totalCount.toLocaleString()} institutions.`;
39096
+ }
39097
+ return null;
39098
+ }
39099
+ async function fetchRecordsForRepdte(repdte) {
39100
+ const response = await queryEndpoint(ENDPOINTS.FINANCIALS, {
39101
+ filters: `REPDTE:${repdte}`,
39102
+ fields: QBP_LITE_FIELDS,
39103
+ limit: QBP_LITE_FETCH_LIMIT,
39104
+ sort_by: "CERT",
39105
+ sort_order: "ASC"
39106
+ });
39107
+ const records = extractRecords(response);
39108
+ return {
39109
+ records,
39110
+ total: response.meta.total,
39111
+ truncationWarning: buildTruncationWarning2(
39112
+ repdte,
39113
+ records.length,
39114
+ response.meta.total
39115
+ )
39116
+ };
39117
+ }
39118
+ async function hasFinancialData(repdte) {
39119
+ const response = await queryEndpoint(ENDPOINTS.FINANCIALS, {
39120
+ filters: `REPDTE:${repdte}`,
39121
+ fields: "CERT,REPDTE",
39122
+ limit: 1
39123
+ });
39124
+ return response.meta.total > 0;
39125
+ }
39126
+ async function resolveReportDate(params) {
39127
+ if (params.repdte) {
39128
+ const validationError = validateQuarterEndDate(params.repdte, "repdte");
39129
+ if (validationError) {
39130
+ throw new Error(validationError);
39131
+ }
39132
+ return params.repdte;
39133
+ }
39134
+ const candidates = [getDefaultReportDate(), ...getPriorQuarterDates(getDefaultReportDate(), 7)];
39135
+ for (const candidate of candidates) {
39136
+ if (await hasFinancialData(candidate)) {
39137
+ return candidate;
39138
+ }
39139
+ }
39140
+ throw new Error("No BankFind financial data found in the latest eight expected reporting quarters.");
39141
+ }
39142
+ async function buildQbpLiteData(params) {
39143
+ const trendQuarters = params.trend_quarters ?? 20;
39144
+ const includeCommunityBanks = params.include_community_banks ?? true;
39145
+ const repdte = await resolveReportDate(params);
39146
+ const priorQuarterRepdte = getPriorQuarterDates(repdte, 1)[0];
39147
+ const yearAgoRepdte = getReportDateOneYearPrior(repdte);
39148
+ const trendDates = [...getPriorQuarterDates(repdte, trendQuarters - 1)].reverse();
39149
+ const requiredDates = [.../* @__PURE__ */ new Set([...trendDates, repdte, priorQuarterRepdte, yearAgoRepdte])].sort();
39150
+ const recordsByDate = /* @__PURE__ */ new Map();
39151
+ const warnings = [];
39152
+ await mapWithConcurrency(requiredDates, 4, async (date) => {
39153
+ const { records, truncationWarning } = await fetchRecordsForRepdte(date);
39154
+ recordsByDate.set(date, records);
39155
+ if (records.length === 0) {
39156
+ warnings.push(`No financial records found for REPDTE ${date}.`);
39157
+ }
39158
+ if (truncationWarning) {
39159
+ warnings.push(truncationWarning);
39160
+ }
39161
+ });
39162
+ const currentRecords = recordsByDate.get(repdte) ?? [];
39163
+ if (currentRecords.length === 0) {
39164
+ throw new Error(`No financial records found for current REPDTE ${repdte}.`);
39165
+ }
39166
+ const trendRecords = new Map(
39167
+ [...trendDates, repdte].filter((date) => (recordsByDate.get(date)?.length ?? 0) > 0).map((date) => [date, recordsByDate.get(date) ?? []])
39168
+ );
39169
+ const trendSeries = [...trendRecords.entries()].map(
39170
+ ([date, records]) => buildSeriesPoint(date, records)
39171
+ );
39172
+ const current = aggregateFinancialRecords(currentRecords);
39173
+ const priorQuarter = recordsByDate.has(priorQuarterRepdte) ? aggregateFinancialRecords(recordsByDate.get(priorQuarterRepdte) ?? []) : null;
39174
+ const yearAgo = recordsByDate.has(yearAgoRepdte) ? aggregateFinancialRecords(recordsByDate.get(yearAgoRepdte) ?? []) : null;
39175
+ return {
39176
+ report: {
39177
+ title: "QBP Lite: FDIC-Insured Institutions",
39178
+ repdte,
39179
+ prior_quarter_repdte: priorQuarterRepdte,
39180
+ year_ago_repdte: yearAgoRepdte,
39181
+ trend_start_repdte: trendSeries[0]?.repdte ?? repdte,
39182
+ trend_end_repdte: repdte
39183
+ },
39184
+ executive_snapshot: buildExecutiveSnapshot(current, priorQuarter, yearAgo),
39185
+ charts: {
39186
+ quarterly_net_income_and_roa: trendSeries.map((point) => ({
39187
+ repdte: point.repdte,
39188
+ net_income: point.net_income,
39189
+ roa_pct: point.roa_pct
39190
+ })),
39191
+ net_interest_margin_by_asset_size: buildAssetSizeNimSeries(trendRecords),
39192
+ loans_and_deposits: buildLoanAndDepositSeries(trendSeries),
39193
+ credit_quality: trendSeries.map((point) => ({
39194
+ repdte: point.repdte,
39195
+ noncurrent_loans_pct: point.noncurrent_loans_pct,
39196
+ net_chargeoffs_pct: point.net_chargeoffs_pct
39197
+ })),
39198
+ loan_performance_by_portfolio: buildPortfolioPerformance(currentRecords),
39199
+ capital_ratios: trendSeries.map((point) => ({
39200
+ repdte: point.repdte,
39201
+ leverage_ratio_pct: point.leverage_ratio_pct,
39202
+ common_equity_tier1_ratio_pct: point.common_equity_tier1_ratio_pct,
39203
+ tier1_risk_based_ratio_pct: point.tier1_risk_based_ratio_pct,
39204
+ total_risk_based_ratio_pct: point.total_risk_based_ratio_pct
39205
+ })),
39206
+ community_banks_vs_industry: includeCommunityBanks ? buildCommunityComparison(trendRecords) : []
39207
+ },
39208
+ data_notes: {
39209
+ source_dataset: "FDIC BankFind financials endpoint",
39210
+ date_field: "REPDTE",
39211
+ dollar_units: "Thousands of dollars unless converted by the client.",
39212
+ aggregation_notes: [
39213
+ "Dollar fields are summed across reporting institutions.",
39214
+ "ROA, ROE, credit-quality ratios, and leverage ratio are weighted using the closest available public denominator field.",
39215
+ "Leverage ratio is asset-weighted using period-end ASSET as a proxy for average assets because the average-asset denominator is not exposed as a separate public BankFind field.",
39216
+ "Net interest margin is weighted by earning assets.",
39217
+ "Risk-based capital ratios are calculated from summed public capital-dollar and risk-weighted-asset fields when available, with public ratio fields used only as a fallback.",
39218
+ "The report uses public quarterly Call Report-derived BankFind data; it is not an official FDIC QBP publication."
39219
+ ],
39220
+ known_exclusions: [
39221
+ "Official Problem Bank List counts are excluded because they depend on confidential CAMELS ratings.",
39222
+ "Deposit Insurance Fund balance, reserve ratio, assessments earned, and fund income or expense are excluded because they are DIF accounting data rather than BankFind institution financials.",
39223
+ "Assessment-rate distribution data is excluded because public BankFind financials do not provide institution assessment-rate ranges.",
39224
+ "Community-bank merger-adjusted prior-period series are excluded; community-bank comparisons use the public CB flag available in BankFind records."
39225
+ ],
39226
+ warnings
39227
+ }
39228
+ };
39229
+ }
39230
+ function formatMetricValue2(value, unit) {
39231
+ if (value === null) return "n/a";
39232
+ if (unit === "percent") return `${value.toFixed(2)}%`;
39233
+ return Math.round(value).toLocaleString();
39234
+ }
39235
+ function formatChangeValue(value, unit) {
39236
+ if (value === null) return "n/a";
39237
+ if (unit === "percentage_points") return `${value.toFixed(2)} ppts`;
39238
+ return Math.round(value).toLocaleString();
39239
+ }
39240
+ function formatQbpLiteText(data) {
39241
+ const lines = [
39242
+ `${data.report.title}`,
39243
+ `Quarter ended: ${data.report.repdte}`,
39244
+ `Prior quarter: ${data.report.prior_quarter_repdte} | Year ago: ${data.report.year_ago_repdte}`,
39245
+ "",
39246
+ "Executive Snapshot"
39247
+ ];
39248
+ for (const metric of data.executive_snapshot) {
39249
+ const current = formatMetricValue2(metric.current, metric.unit);
39250
+ const qoq = formatChangeValue(
39251
+ metric.prior_quarter_change,
39252
+ metric.change_unit
39253
+ );
39254
+ const yoy = formatChangeValue(
39255
+ metric.year_over_year_change,
39256
+ metric.change_unit
39257
+ );
39258
+ lines.push(`- ${metric.label}: ${current}; QoQ change ${qoq}; YoY change ${yoy}`);
39259
+ }
39260
+ lines.push(
39261
+ "",
39262
+ "Chart-ready datasets included: quarterly_net_income_and_roa, net_interest_margin_by_asset_size, loans_and_deposits, credit_quality, loan_performance_by_portfolio, capital_ratios, community_banks_vs_industry.",
39263
+ "",
39264
+ "Known exclusions: official Problem Bank List counts, DIF accounting, assessment-rate distributions, and merger-adjusted community-bank prior-period series."
39265
+ );
39266
+ return truncateIfNeeded(
39267
+ lines.join("\n"),
39268
+ CHARACTER_LIMIT,
39269
+ "Use structuredContent for the complete QBP Lite dataset."
39270
+ );
39271
+ }
39272
+ function registerQbpLiteTools(server) {
39273
+ server.registerTool(
39274
+ "fdic_qbp_lite_data",
39275
+ {
39276
+ title: "Generate QBP Lite Data Bundle",
39277
+ description: "Build chart-ready data for a concise QBP Lite report from reproducible public BankFind quarterly financials. Includes executive snapshot metrics, trend series, community-bank comparison data, source notes, and explicit exclusions for non-public or non-BankFind QBP items.",
39278
+ inputSchema: QbpLiteSchema,
39279
+ outputSchema: FdicAnalysisOutputSchema,
39280
+ annotations: {
39281
+ readOnlyHint: true,
39282
+ destructiveHint: false,
39283
+ idempotentHint: true,
39284
+ openWorldHint: true
39285
+ }
39286
+ },
39287
+ async (params) => {
39288
+ try {
39289
+ const data = await buildQbpLiteData(params);
39290
+ return {
39291
+ content: [{ type: "text", text: formatQbpLiteText(data) }],
39292
+ structuredContent: data
39293
+ };
39294
+ } catch (err) {
39295
+ return formatToolError(err);
39296
+ }
39297
+ }
39298
+ );
39299
+ }
39300
+
39301
+ // src/tools/chatgptRetrieval.ts
39302
+ var import_zod22 = require("zod");
38740
39303
 
38741
39304
  // src/tools/shared/chatgptUrls.ts
38742
39305
  var FDIC_BANKFIND_BASE_URL = "https://banks.data.fdic.gov/bankfind-suite";
@@ -38756,11 +39319,11 @@ function getBranchCitationUrl() {
38756
39319
  }
38757
39320
 
38758
39321
  // src/tools/chatgptRetrieval.ts
38759
- var SearchInputSchema = import_zod21.z.object({
38760
- query: import_zod21.z.string().min(1).describe("Natural-language search query.")
39322
+ var SearchInputSchema = import_zod22.z.object({
39323
+ query: import_zod22.z.string().min(1).describe("Natural-language search query.")
38761
39324
  });
38762
- var FetchInputSchema = import_zod21.z.object({
38763
- id: import_zod21.z.string().min(1).describe(
39325
+ var FetchInputSchema = import_zod22.z.object({
39326
+ id: import_zod22.z.string().min(1).describe(
38764
39327
  "Retrieval item id, such as institution:<CERT>, failure:<CERT>, branch:<UNINUM>, or schema:<endpoint>."
38765
39328
  )
38766
39329
  });
@@ -39198,7 +39761,7 @@ function registerChatGptRetrievalTools(server, options = {}) {
39198
39761
  }
39199
39762
 
39200
39763
  // src/tools/chatgptBankDeepDive.ts
39201
- var import_zod22 = require("zod");
39764
+ var import_zod23 = require("zod");
39202
39765
 
39203
39766
  // src/resources/chatgptAppResources.ts
39204
39767
  var BANK_DEEP_DIVE_WIDGET_URI = "ui://widget/fdic-bank-deep-dive-v1.html";
@@ -39459,9 +40022,9 @@ function registerChatGptAppResources(server) {
39459
40022
  }
39460
40023
 
39461
40024
  // src/tools/chatgptBankDeepDive.ts
39462
- var BankDeepDiveInputSchema = import_zod22.z.object({
39463
- cert: import_zod22.z.number().int().positive().describe("FDIC Certificate Number of the institution to render."),
39464
- repdte: import_zod22.z.string().regex(/^\d{8}$/).optional().describe(
40025
+ var BankDeepDiveInputSchema = import_zod23.z.object({
40026
+ cert: import_zod23.z.number().int().positive().describe("FDIC Certificate Number of the institution to render."),
40027
+ repdte: import_zod23.z.string().regex(/^\d{8}$/).optional().describe(
39465
40028
  "Quarter-end report date in YYYYMMDD format. Defaults to the most recent likely published quarter."
39466
40029
  )
39467
40030
  });
@@ -39760,28 +40323,28 @@ function registerSchemaResources(server) {
39760
40323
  }
39761
40324
 
39762
40325
  // src/prompts/workflows.ts
39763
- var import_zod23 = require("zod");
40326
+ var import_zod24 = require("zod");
39764
40327
  var BankDeepDiveArgs = {
39765
- bank: import_zod23.z.string().min(1).describe("Bank name or FDIC Certificate Number (CERT)."),
39766
- repdte: import_zod23.z.string().regex(/^\d{8}$/).optional().describe(
40328
+ bank: import_zod24.z.string().min(1).describe("Bank name or FDIC Certificate Number (CERT)."),
40329
+ repdte: import_zod24.z.string().regex(/^\d{8}$/).optional().describe(
39767
40330
  "Optional quarter-end report date in YYYYMMDD format (0331, 0630, 0930, or 1231)."
39768
40331
  )
39769
40332
  };
39770
40333
  var FailureForensicsArgs = {
39771
- bank: import_zod23.z.string().min(1).describe("Failed bank name or FDIC Certificate Number (CERT)."),
39772
- lookback_quarters: import_zod23.z.string().regex(/^\d+$/).optional().describe(
40334
+ bank: import_zod24.z.string().min(1).describe("Failed bank name or FDIC Certificate Number (CERT)."),
40335
+ lookback_quarters: import_zod24.z.string().regex(/^\d+$/).optional().describe(
39773
40336
  "Number of pre-failure quarters to reconstruct (default 12 if omitted)."
39774
40337
  )
39775
40338
  };
39776
40339
  var PortfolioSurveillanceArgs = {
39777
- scope: import_zod23.z.string().min(1).describe(
40340
+ scope: import_zod24.z.string().min(1).describe(
39778
40341
  "Universe to screen \u2014 e.g., 'state:NC', 'asset_min:1000000,asset_max:10000000', or a comma-separated CERT list ('certs:3511,29846,...')."
39779
40342
  ),
39780
- repdte: import_zod23.z.string().regex(/^\d{8}$/).optional().describe("Optional quarter-end report date in YYYYMMDD format.")
40343
+ repdte: import_zod24.z.string().regex(/^\d{8}$/).optional().describe("Optional quarter-end report date in YYYYMMDD format.")
39781
40344
  };
39782
40345
  var ExaminerOverlayArgs = {
39783
- bank: import_zod23.z.string().min(1).describe("Bank name or FDIC Certificate Number (CERT)."),
39784
- qualitative_notes: import_zod23.z.string().optional().describe(
40346
+ bank: import_zod24.z.string().min(1).describe("Bank name or FDIC Certificate Number (CERT)."),
40347
+ qualitative_notes: import_zod24.z.string().optional().describe(
39785
40348
  "Optional qualitative analyst inputs (management quality, governance, exam findings) to overlay onto the public proxy assessment."
39786
40349
  )
39787
40350
  };
@@ -39954,6 +40517,7 @@ function createServer(options = {}) {
39954
40517
  registerFranchiseFootprintTools(server);
39955
40518
  registerHoldingCompanyProfileTools(server);
39956
40519
  registerRegionalContextTools(server);
40520
+ registerQbpLiteTools(server);
39957
40521
  }
39958
40522
  if (profile.chatgptCanonical || profile.chatgptAliases) {
39959
40523
  registerChatGptRetrievalTools(server, {
@@ -40005,6 +40569,21 @@ function parseAllowedOrigins(rawOrigins, port) {
40005
40569
  }
40006
40570
  var DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
40007
40571
  var DEFAULT_SESSION_SWEEP_INTERVAL_MS = 5 * 60 * 1e3;
40572
+ var DEFAULT_MCP_RATE_LIMIT_MAX_REQUESTS = 120;
40573
+ var DEFAULT_MCP_RATE_LIMIT_WINDOW_MS = 6e4;
40574
+ var DEFAULT_MCP_STREAM_RATE_LIMIT_MAX_REQUESTS = 2;
40575
+ var DEFAULT_MCP_STREAM_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1e3;
40576
+ var DEFAULT_MCP_MAX_CONCURRENT_STREAMS_PER_IP = 1;
40577
+ function parsePositiveInteger(rawValue, fallback, name) {
40578
+ if (rawValue === void 0 || rawValue.trim().length === 0) {
40579
+ return fallback;
40580
+ }
40581
+ const value = Number.parseInt(rawValue, 10);
40582
+ if (!Number.isInteger(value) || value < 1) {
40583
+ throw new Error(`${name} must be a positive integer. Received: ${rawValue}`);
40584
+ }
40585
+ return value;
40586
+ }
40008
40587
  async function closeSession(sessions, sessionId) {
40009
40588
  const session = sessions.get(sessionId);
40010
40589
  if (!session) {
@@ -40040,6 +40619,17 @@ function sendInvalidSessionResponse(res) {
40040
40619
  id: null
40041
40620
  });
40042
40621
  }
40622
+ function sendMcpRateLimitResponse(res, retryAfterSeconds) {
40623
+ res.setHeader("Retry-After", String(retryAfterSeconds));
40624
+ res.status(429).json({
40625
+ jsonrpc: "2.0",
40626
+ error: {
40627
+ code: -32e3,
40628
+ message: "Rate limit exceeded. Try again shortly."
40629
+ },
40630
+ id: null
40631
+ });
40632
+ }
40043
40633
  function createApp(options = {}) {
40044
40634
  const app = (0, import_express2.default)();
40045
40635
  const serverFactory = options.serverFactory ?? (() => createServer());
@@ -40050,6 +40640,29 @@ function createApp(options = {}) {
40050
40640
  const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS;
40051
40641
  const sessionSweepIntervalMs = options.sessionSweepIntervalMs ?? DEFAULT_SESSION_SWEEP_INTERVAL_MS;
40052
40642
  const stateless = options.stateless ?? process.env.FDIC_MCP_STATELESS_HTTP === "true";
40643
+ const mcpRateLimiter = options.mcpRateLimiter ?? new RateLimiter({
40644
+ maxRequests: parsePositiveInteger(
40645
+ process.env.MCP_RATE_LIMIT_MAX_REQUESTS_PER_MINUTE,
40646
+ DEFAULT_MCP_RATE_LIMIT_MAX_REQUESTS,
40647
+ "MCP_RATE_LIMIT_MAX_REQUESTS_PER_MINUTE"
40648
+ ),
40649
+ windowMs: DEFAULT_MCP_RATE_LIMIT_WINDOW_MS
40650
+ });
40651
+ const mcpStreamRateLimiter = options.mcpStreamRateLimiter ?? new RateLimiter({
40652
+ maxRequests: parsePositiveInteger(
40653
+ process.env.MCP_STREAM_RATE_LIMIT_MAX_REQUESTS_PER_HOUR,
40654
+ DEFAULT_MCP_STREAM_RATE_LIMIT_MAX_REQUESTS,
40655
+ "MCP_STREAM_RATE_LIMIT_MAX_REQUESTS_PER_HOUR"
40656
+ ),
40657
+ windowMs: DEFAULT_MCP_STREAM_RATE_LIMIT_WINDOW_MS
40658
+ });
40659
+ const mcpStreamConcurrentLimiter = options.mcpStreamConcurrentLimiter ?? new ConcurrentLimiter(
40660
+ parsePositiveInteger(
40661
+ process.env.MCP_MAX_CONCURRENT_STREAMS_PER_IP,
40662
+ DEFAULT_MCP_MAX_CONCURRENT_STREAMS_PER_IP,
40663
+ "MCP_MAX_CONCURRENT_STREAMS_PER_IP"
40664
+ )
40665
+ );
40053
40666
  app.use(import_express2.default.json());
40054
40667
  if (!stateless) {
40055
40668
  const sessionSweepTimer = setInterval(() => {
@@ -40062,6 +40675,31 @@ function createApp(options = {}) {
40062
40675
  res.json({ status: "ok", server: "fdic-mcp-server", version: VERSION });
40063
40676
  });
40064
40677
  app.all("/mcp", async (req, res) => {
40678
+ const requestIp = getRequestIp(req);
40679
+ if (!mcpRateLimiter.check(requestIp)) {
40680
+ sendMcpRateLimitResponse(
40681
+ res,
40682
+ Math.ceil(DEFAULT_MCP_RATE_LIMIT_WINDOW_MS / 1e3)
40683
+ );
40684
+ return;
40685
+ }
40686
+ if (req.method === "GET") {
40687
+ const releaseStream = mcpStreamConcurrentLimiter.acquire(requestIp);
40688
+ if (!releaseStream) {
40689
+ sendMcpRateLimitResponse(res, 60);
40690
+ return;
40691
+ }
40692
+ if (!mcpStreamRateLimiter.check(requestIp)) {
40693
+ releaseStream();
40694
+ sendMcpRateLimitResponse(
40695
+ res,
40696
+ Math.ceil(DEFAULT_MCP_STREAM_RATE_LIMIT_WINDOW_MS / 1e3)
40697
+ );
40698
+ return;
40699
+ }
40700
+ res.once("close", releaseStream);
40701
+ res.once("finish", releaseStream);
40702
+ }
40065
40703
  if (stateless) {
40066
40704
  let server;
40067
40705
  let transport;