fdic_compare_bank_snapshots
Compare FDIC bank financial snapshots to analyze growth, profitability, or efficiency changes across institutions over time.
Instructions
Compare FDIC reporting snapshots across a set of institutions and rank the results by growth, profitability, or efficiency changes.
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.
Good uses:
Identify North Carolina banks with the strongest asset growth from 2021 to 2025
Compare whether deposit growth came with branch expansion or profitability improvement
Rank a specific cert list by ROA, ROE, asset-per-office, or deposit-to-asset changes
Pull a quarterly trend series and highlight inflection points, streaks, and structural shifts
Inputs:
state or certs: choose a geographic roster or provide a direct comparison set
start_repdte, end_repdte: Report Dates (REPDTE) in YYYYMMDD format — must be quarter-end dates (0331, 0630, 0930, 1231)
analysis_mode: snapshot or timeseries
institution_filters: optional extra institution filter when building the roster
active_only: default true
include_demographics: default true, adds office-count comparisons when available
sort_by: ranking field (default: asset_growth). All options: asset_growth, asset_growth_pct, dep_growth, dep_growth_pct, netinc_change, netinc_change_pct, roa_change, roe_change, offices_change, assets_per_office_change, deposits_per_office_change, deposits_to_assets_change
sort_order: ASC or DESC
limit: maximum ranked results to return
Returns concise comparison text plus structured deltas, derived metrics, and insight tags for each institution.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| state | No | State name for the institution roster filter. Example: "North Carolina" | |
| certs | No | Optional list of FDIC certificate numbers to compare directly. Max 100. | |
| institution_filters | No | Additional institution-level filter used when building the comparison set. Example: BKCLASS:N or CITY:"Charlotte" | |
| active_only | No | Limit the comparison set to currently active institutions. | |
| start_repdte | No | Starting Report Date (REPDTE) in YYYYMMDD format. Must be a quarter-end date: March 31 (0331), June 30 (0630), September 30 (0930), or December 31 (1231). Example: 20210331 for Q1 2021. If omitted, defaults to the same quarter one year before end_repdte. | |
| end_repdte | No | Ending Report Date (REPDTE) in YYYYMMDD format. Must be a quarter-end date: March 31 (0331), June 30 (0630), September 30 (0930), or December 31 (1231). Must be later than start_repdte. Example: 20251231 for Q4 2025. If omitted, defaults to the most recent quarter-end date with published data (~90-day lag). | |
| analysis_mode | No | Use snapshot for two-point comparison or timeseries for quarterly trend analysis across the date range. | snapshot |
| include_demographics | No | Include office-count changes from the demographics dataset when available. | |
| limit | No | Maximum number of ranked comparisons to return. | |
| sort_by | No | Comparison field used to rank institutions. Valid options: asset_growth, asset_growth_pct, dep_growth, dep_growth_pct, netinc_change, netinc_change_pct, roa_change, roe_change, offices_change, assets_per_office_change, deposits_per_office_change, deposits_to_assets_change. | asset_growth |
| sort_order | No | Sort direction for the ranked comparisons. | DESC |
Implementation Reference
- src/tools/analysis.ts:915-1210 (handler)The handler function for the `fdic_compare_bank_snapshots` tool, which orchestrates data fetching, metrics calculation, and formatting for bank snapshot analysis.
async (rawParams, extra) => { const { state, certs, institution_filters, active_only, start_repdte, end_repdte, analysis_mode, include_demographics, limit, sort_by, sort_order, } = resolveSnapshotDefaults(rawParams); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS); const progressToken = extra._meta?.progressToken; try { const validationError = validateSnapshotAnalysisParams({ state, certs, institution_filters, active_only, start_repdte, end_repdte, analysis_mode, include_demographics, limit, sort_by, sort_order, }); if (validationError) { return formatToolError(new Error(validationError)); } await sendProgressNotification( server.server, progressToken, 0.1, "Fetching institution roster", ); const rosterResult = certs && certs.length > 0 ? { records: certs.map((cert) => ({ CERT: cert })), warning: undefined, } : await fetchInstitutionRoster( state, institution_filters, active_only, controller.signal, ); const roster = rosterResult.records; const warnings = rosterResult.warning ? [rosterResult.warning] : []; const candidateCerts = roster .map((record) => asNumber(record.CERT)) .filter((cert): cert is number => cert !== null); if (candidateCerts.length === 0) { const output = buildAnalysisOutput({ totalCandidates: 0, analyzedCount: 0, startRepdte: start_repdte, endRepdte: end_repdte, analysisMode: analysis_mode, sortBy: sort_by, sortOrder: sort_order, warnings, comparisons: [], limitCount: 0, }); return { content: [ { type: "text", text: "No institutions matched the comparison set." }, ], structuredContent: output, }; } const rosterByCert = new Map( roster .map((record) => [asNumber(record.CERT), record] as const) .filter( (entry): entry is readonly [number, InstitutionRecord] => entry[0] !== null, ), ); let comparisons: ComparisonRecord[] = []; if (analysis_mode === "timeseries") { await sendProgressNotification( server.server, progressToken, 0.3, include_demographics ? "Fetching financial and demographic time series" : "Fetching financial time series", ); const [financialSeriesResult, demographicsSeriesResult] = await Promise.all([ fetchSeriesRecords( ENDPOINTS.FINANCIALS, candidateCerts, start_repdte, end_repdte, "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE", controller.signal, ), include_demographics ? fetchSeriesRecords( ENDPOINTS.DEMOGRAPHICS, candidateCerts, start_repdte, end_repdte, "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME", controller.signal, ) : Promise.resolve({ grouped: new Map<number, InstitutionRecord[]>(), warnings: [], }), ]); warnings.push( ...financialSeriesResult.warnings, ...demographicsSeriesResult.warnings, ); await sendProgressNotification( server.server, progressToken, 0.9, "Computing metrics and insights", ); const financialSeries = financialSeriesResult.grouped; const demographicsSeries = demographicsSeriesResult.grouped; comparisons = candidateCerts .map((cert) => summarizeTimeSeries( financialSeries.get(cert) ?? [], new Map( (demographicsSeries.get(cert) ?? []).map((record) => [ String(record.REPDTE), record, ]), ), rosterByCert.get(cert) ?? {}, ), ) .filter((comparison): comparison is ComparisonRecord => comparison !== null); } else { await sendProgressNotification( server.server, progressToken, 0.3, include_demographics ? "Fetching financial and demographic snapshots" : "Fetching financial snapshots", ); const [financialSnapshotsResult, demographicSnapshotsResult] = await Promise.all([ fetchBatchedRecordsForDates( ENDPOINTS.FINANCIALS, candidateCerts, [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`], "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE", controller.signal, ), include_demographics ? fetchBatchedRecordsForDates( ENDPOINTS.DEMOGRAPHICS, candidateCerts, [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`], "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME", controller.signal, ) : Promise.resolve({ byDate: new Map<string, Map<number, InstitutionRecord>>(), warnings: [], }), ]); warnings.push( ...financialSnapshotsResult.warnings, ...demographicSnapshotsResult.warnings, ); await sendProgressNotification( server.server, progressToken, 0.9, "Computing metrics and insights", ); const financialSnapshots = financialSnapshotsResult.byDate; const demographicSnapshots = demographicSnapshotsResult.byDate; const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? new Map<number, InstitutionRecord>(); const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? new Map<number, InstitutionRecord>(); const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? new Map<number, InstitutionRecord>(); const endDemographics = demographicSnapshots.get(`REPDTE:${end_repdte}`) ?? new Map<number, InstitutionRecord>(); comparisons = candidateCerts .map((cert) => { const startFinancial = startFinancials.get(cert); const endFinancial = endFinancials.get(cert); if (!startFinancial || !endFinancial) return null; return buildSnapshotComparison( cert, rosterByCert.get(cert) ?? {}, startFinancial, endFinancial, startDemographics.get(cert), endDemographics.get(cert), start_repdte, end_repdte, ); }) .filter((comparison): comparison is ComparisonRecord => comparison !== null); } const sortedComparisons = sortComparisons( comparisons, sort_by, sort_order, ); const ranked = sortedComparisons.slice(0, limit); const output = buildAnalysisOutput({ totalCandidates: candidateCerts.length, analyzedCount: comparisons.length, startRepdte: start_repdte, endRepdte: end_repdte, analysisMode: analysis_mode, sortBy: sort_by, sortOrder: sort_order, warnings, comparisons: ranked, insightComparisons: sortedComparisons, limitCount: ranked.length, }); const textOutput = { ...output, insights: buildTopLevelInsights(ranked), }; const text = truncateIfNeeded( [ ...warnings.map((warning) => `Warning: ${warning}`), formatComparisonText(textOutput), ] .filter((value): value is string => value !== null) .join("\n\n"), CHARACTER_LIMIT, "Reduce the number of certs, narrow institution_filters, request fewer fields, or shorten the date range.", ); await sendProgressNotification( server.server, progressToken, 1, "Analysis complete", ); return { content: [{ type: "text", text }], structuredContent: output, }; } catch (err) { if (controller.signal.aborted) { return formatToolError( new Error( `Analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1000)} seconds. ` + `Try reducing the comparison set: use certs (max 100) instead of a state-wide roster, add institution_filters (e.g., BKCLASS:N), or shorten the date range for timeseries mode.`, ), ); } return formatToolError(err); } finally { clearTimeout(timeoutId); } }, ); - src/tools/analysis.ts:44-105 (schema)Input schema definition for the `fdic_compare_bank_snapshots` tool.
const SnapshotAnalysisSchema = z.object({ state: z .string() .optional() .describe( 'State name for the institution roster filter. Example: "North Carolina"', ), certs: z .array(z.number().int().positive()) .max(100) .optional() .describe( "Optional list of FDIC certificate numbers to compare directly. Max 100.", ), institution_filters: z .string() .optional() .describe( 'Additional institution-level filter used when building the comparison set. Example: BKCLASS:N or CITY:"Charlotte"', ), active_only: z .boolean() .default(true) .describe("Limit the comparison set to currently active institutions."), start_repdte: z .string() .regex(/^\d{8}$/) .optional() .describe( "Starting Report Date (REPDTE) in YYYYMMDD format. Must be a quarter-end date: March 31 (0331), June 30 (0630), September 30 (0930), or December 31 (1231). Example: 20210331 for Q1 2021. If omitted, defaults to the same quarter one year before end_repdte.", ), end_repdte: z .string() .regex(/^\d{8}$/) .optional() .describe( "Ending Report Date (REPDTE) in YYYYMMDD format. Must be a quarter-end date: March 31 (0331), June 30 (0630), September 30 (0930), or December 31 (1231). Must be later than start_repdte. Example: 20251231 for Q4 2025. If omitted, defaults to the most recent quarter-end date with published data (~90-day lag).", ), analysis_mode: AnalysisModeSchema.default("snapshot").describe( "Use snapshot for two-point comparison or timeseries for quarterly trend analysis across the date range.", ), include_demographics: z .boolean() .default(true) .describe( "Include office-count changes from the demographics dataset when available.", ), limit: z .number() .int() .min(1) .max(100) .default(10) .describe("Maximum number of ranked comparisons to return."), sort_by: SortFieldSchema.default("asset_growth").describe( "Comparison field used to rank institutions. Valid options: asset_growth, asset_growth_pct, dep_growth, dep_growth_pct, netinc_change, netinc_change_pct, roa_change, roe_change, offices_change, assets_per_office_change, deposits_per_office_change, deposits_to_assets_change.", ), sort_order: z .enum(["ASC", "DESC"]) .default("DESC") .describe("Sort direction for the ranked comparisons."), }); - src/tools/analysis.ts:880-1210 (registration)Tool registration for `fdic_compare_bank_snapshots` within the MCP server.
export function registerAnalysisTools(server: McpServer): void { server.registerTool( "fdic_compare_bank_snapshots", { title: "Compare Bank Snapshot Trends", description: `Compare FDIC reporting snapshots across a set of institutions and rank the results by growth, profitability, or efficiency changes. 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. Good uses: - Identify North Carolina banks with the strongest asset growth from 2021 to 2025 - Compare whether deposit growth came with branch expansion or profitability improvement - Rank a specific cert list by ROA, ROE, asset-per-office, or deposit-to-asset changes - Pull a quarterly trend series and highlight inflection points, streaks, and structural shifts Inputs: - state or certs: choose a geographic roster or provide a direct comparison set - start_repdte, end_repdte: Report Dates (REPDTE) in YYYYMMDD format — must be quarter-end dates (0331, 0630, 0930, 1231) - analysis_mode: snapshot or timeseries - institution_filters: optional extra institution filter when building the roster - active_only: default true - include_demographics: default true, adds office-count comparisons when available - sort_by: ranking field (default: asset_growth). All options: asset_growth, asset_growth_pct, dep_growth, dep_growth_pct, netinc_change, netinc_change_pct, roa_change, roe_change, offices_change, assets_per_office_change, deposits_per_office_change, deposits_to_assets_change - sort_order: ASC or DESC - limit: maximum ranked results to return Returns concise comparison text plus structured deltas, derived metrics, and insight tags for each institution.`, inputSchema: SnapshotAnalysisSchema, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (rawParams, extra) => { const { state, certs, institution_filters, active_only, start_repdte, end_repdte, analysis_mode, include_demographics, limit, sort_by, sort_order, } = resolveSnapshotDefaults(rawParams); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS); const progressToken = extra._meta?.progressToken; try { const validationError = validateSnapshotAnalysisParams({ state, certs, institution_filters, active_only, start_repdte, end_repdte, analysis_mode, include_demographics, limit, sort_by, sort_order, }); if (validationError) { return formatToolError(new Error(validationError)); } await sendProgressNotification( server.server, progressToken, 0.1, "Fetching institution roster", ); const rosterResult = certs && certs.length > 0 ? { records: certs.map((cert) => ({ CERT: cert })), warning: undefined, } : await fetchInstitutionRoster( state, institution_filters, active_only, controller.signal, ); const roster = rosterResult.records; const warnings = rosterResult.warning ? [rosterResult.warning] : []; const candidateCerts = roster .map((record) => asNumber(record.CERT)) .filter((cert): cert is number => cert !== null); if (candidateCerts.length === 0) { const output = buildAnalysisOutput({ totalCandidates: 0, analyzedCount: 0, startRepdte: start_repdte, endRepdte: end_repdte, analysisMode: analysis_mode, sortBy: sort_by, sortOrder: sort_order, warnings, comparisons: [], limitCount: 0, }); return { content: [ { type: "text", text: "No institutions matched the comparison set." }, ], structuredContent: output, }; } const rosterByCert = new Map( roster .map((record) => [asNumber(record.CERT), record] as const) .filter( (entry): entry is readonly [number, InstitutionRecord] => entry[0] !== null, ), ); let comparisons: ComparisonRecord[] = []; if (analysis_mode === "timeseries") { await sendProgressNotification( server.server, progressToken, 0.3, include_demographics ? "Fetching financial and demographic time series" : "Fetching financial time series", ); const [financialSeriesResult, demographicsSeriesResult] = await Promise.all([ fetchSeriesRecords( ENDPOINTS.FINANCIALS, candidateCerts, start_repdte, end_repdte, "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE", controller.signal, ), include_demographics ? fetchSeriesRecords( ENDPOINTS.DEMOGRAPHICS, candidateCerts, start_repdte, end_repdte, "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME", controller.signal, ) : Promise.resolve({ grouped: new Map<number, InstitutionRecord[]>(), warnings: [], }), ]); warnings.push( ...financialSeriesResult.warnings, ...demographicsSeriesResult.warnings, ); await sendProgressNotification( server.server, progressToken, 0.9, "Computing metrics and insights", ); const financialSeries = financialSeriesResult.grouped; const demographicsSeries = demographicsSeriesResult.grouped; comparisons = candidateCerts .map((cert) => summarizeTimeSeries( financialSeries.get(cert) ?? [], new Map( (demographicsSeries.get(cert) ?? []).map((record) => [ String(record.REPDTE), record, ]), ), rosterByCert.get(cert) ?? {}, ), ) .filter((comparison): comparison is ComparisonRecord => comparison !== null); } else { await sendProgressNotification( server.server, progressToken, 0.3, include_demographics ? "Fetching financial and demographic snapshots" : "Fetching financial snapshots", ); const [financialSnapshotsResult, demographicSnapshotsResult] = await Promise.all([ fetchBatchedRecordsForDates( ENDPOINTS.FINANCIALS, candidateCerts, [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`], "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE", controller.signal, ), include_demographics ? fetchBatchedRecordsForDates( ENDPOINTS.DEMOGRAPHICS, candidateCerts, [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`], "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME", controller.signal, ) : Promise.resolve({ byDate: new Map<string, Map<number, InstitutionRecord>>(), warnings: [], }), ]); warnings.push( ...financialSnapshotsResult.warnings, ...demographicSnapshotsResult.warnings, ); await sendProgressNotification( server.server, progressToken, 0.9, "Computing metrics and insights", ); const financialSnapshots = financialSnapshotsResult.byDate; const demographicSnapshots = demographicSnapshotsResult.byDate; const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? new Map<number, InstitutionRecord>(); const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? new Map<number, InstitutionRecord>(); const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? new Map<number, InstitutionRecord>(); const endDemographics = demographicSnapshots.get(`REPDTE:${end_repdte}`) ?? new Map<number, InstitutionRecord>(); comparisons = candidateCerts .map((cert) => { const startFinancial = startFinancials.get(cert); const endFinancial = endFinancials.get(cert); if (!startFinancial || !endFinancial) return null; return buildSnapshotComparison( cert, rosterByCert.get(cert) ?? {}, startFinancial, endFinancial, startDemographics.get(cert), endDemographics.get(cert), start_repdte, end_repdte, ); }) .filter((comparison): comparison is ComparisonRecord => comparison !== null); } const sortedComparisons = sortComparisons( comparisons, sort_by, sort_order, ); const ranked = sortedComparisons.slice(0, limit); const output = buildAnalysisOutput({ totalCandidates: candidateCerts.length, analyzedCount: comparisons.length, startRepdte: start_repdte, endRepdte: end_repdte, analysisMode: analysis_mode, sortBy: sort_by, sortOrder: sort_order, warnings, comparisons: ranked, insightComparisons: sortedComparisons, limitCount: ranked.length, }); const textOutput = { ...output, insights: buildTopLevelInsights(ranked), }; const text = truncateIfNeeded( [ ...warnings.map((warning) => `Warning: ${warning}`), formatComparisonText(textOutput), ] .filter((value): value is string => value !== null) .join("\n\n"), CHARACTER_LIMIT, "Reduce the number of certs, narrow institution_filters, request fewer fields, or shorten the date range.", ); await sendProgressNotification( server.server, progressToken, 1, "Analysis complete", ); return { content: [{ type: "text", text }], structuredContent: output, }; } catch (err) { if (controller.signal.aborted) { return formatToolError( new Error( `Analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1000)} seconds. ` + `Try reducing the comparison set: use certs (max 100) instead of a state-wide roster, add institution_filters (e.g., BKCLASS:N), or shorten the date range for timeseries mode.`, ), ); } return formatToolError(err); } finally { clearTimeout(timeoutId); } }, );