Become a member!

MCP Firebird 0.4.0: the advisor that knows when not to create an index

๐ŸŒ
This article is also available in other languages:
๐Ÿ‡ฎ๐Ÿ‡น Italiano  โ€ข  ๐Ÿ‡ช๐Ÿ‡ธ Espaรฑol  โ€ข  ๐Ÿ‡ฉ๐Ÿ‡ช Deutsch  โ€ข  ๐Ÿ‡ง๐Ÿ‡ท Portuguรชs

MCP Firebird 0.4.0 rewrites the index advisor. It now measures the query before it speaks, proposes the remedy that costs least, knows expression indexes, partial indexes and indexes for the ORDER BY, and when the scan is the right choice it answers with numbers and no SQL to run.

MCP Firebird logo, the MCP server that lets AI assistants diagnose Firebird databases.

You have a slow query. The plan says PLAN (ORDERS NATURAL), meaning a full scan of the ORDERS table, and there is no index on the filtered column. The advice you get, from a human, from an AI, or from a Sunday DBA, is almost always the same: create the index.

Look at the numbers, though. That scan reads 20,048 records and returns 18,000 of them. 89.8% of what it touches ends up in the result. An index there would make you do two reads instead of one for nearly every row in the table, and you’d pay for it with one extra write on every INSERT, UPDATE and DELETE, from today until the day somebody drops it. You know perfectly well nobody will, because “something might break”. An operation meant to improve performance would end up making it worse, for the entire life of the application.

In this case the scan was the right plan. The right advice was to do nothing, not “create an index”.

MCP Firebird 0.4.0, released on August 7, 2026, teaches fb_suggest_indexes to tell you “this is fine” instead of “create this index”.

In short

  • fb_suggest_indexes rewritten around a ladder of remedies, cheapest first. Statistics first, then reactivating a disabled index, and only at the end a brand new one.
  • A scan can be the right plan. The query gets measured: if the scan keeps most of what it reads, the answer is numbers and no SQL.
  • Three more index shapes: on an expression, partial on Firebird 5.0, and for resolving an ORDER BY.
  • The engine explains the plan. On Firebird 3.0 and later you get the “explained” plan: index names and how many segments the query actually reaches.
  • All of it in the free edition, read-only, on Firebird from 2.5 to 5.0.
  • Repository: github.com/danieleteti/mcp-firebird

If you don’t know the project yet, the introduction article explains what it is and how to install it. In short: an executable written in Delphi that your AI assistant launches on its own and talks to over stdin/stdout, with no services and no open ports.

The remedy ladder

Up through 0.3.x the advisor reasoned from metadata. It found NATURAL in the plan, derived the predicate columns from the SQL, and proposed an index where none existed yet. Given the information it had, that was a reasonable answer, and most of the time it was also the right one.

What stayed outside that reasoning was everything metadata does not contain:

  • how many rows does that scan actually select?
  • do an index’s statistics still describe today’s data?
  • which index shapes does the engine you run in production actually allow, plain B-tree only, or also partial and expression indexes?

You can only answer those by measuring, and to measure you have to run the query. 0.4.0 takes that step.

The rule the advisor follows is this:

An index is not free: it gets written on every INSERT, UPDATE and DELETE for its entire life. So propose the cheapest remedy that explains the plan, and be willing to propose nothing.

The advisor tries the rungs in order of cost and stops at the first one that explains what it saw. SET STATISTICS INDEX refreshes the selectivity of an index you already have: it leaves nothing behind, it’s safe even under concurrent load, and in an embarrassing number of cases it’s all that was needed. ALTER INDEX ... ACTIVE reactivates a disabled index, and you already paid its creation cost a while ago. CREATE INDEX comes last, because it’s the only one of the three you keep paying for on every write for as long as it exists.

The scan that is the right plan

To say “this scan is fine” you need to know how much it produces, and that number doesn’t exist in the metadata. That’s why 0.4.0 runs the query: once, inside the read-only transaction, and only when the plan shows a scan or a sort.

A scan that keeps most of what it reads, above roughly 20% selectivity, is the correct plan. The same goes for a table that fits in a handful of pages. In those cases the answer carries the measurement and no statement to run:

Table ORDERS is scanned when filtered by STATE, and that is the right plan:
the scan returns 89.8% of what it reads (18000 of 20048 records).

Let me state the price upfront: on a large table, a call that used to read only metadata now runs a full scan. That’s the cost of having a number instead of a guess.

Statistics come before structure

Firebird computes an index’s selectivity when you create it, and never recomputes it on its own. The table grows, the distribution changes, and the optimizer keeps planning against an old snapshot. So an index the optimizer ignores might be neglected because of stale statistics, not because it’s useless.

0.4.0 compares the stored selectivity with the actual one before saying an index isn’t needed, and when the two figures diverge it answers SET STATISTICS INDEX. The same check runs inside fb_suggest_index_drops, which now checks the actual selectivity before proposing a DROP INDEX: an index running on stale statistics deserves a refresh, not the bin. There’s also a new goal for optimization_goal, no_stale_index_statistics, the only one of the four whose first move writes nothing structural.

A real session, with Claude Desktop

Examples so far. What follows instead is a real session I ran myself: Claude Desktop, MCP Firebird configured against a Firebird 5.0.4 engine, the repository’s test database. I didn’t reconstruct it for the article, I only trimmed it where it repeated itself.

The conversation below is in English for simplicity: with MCP Firebird the assistant answers in whatever language you write to it, so had I asked the same question in Italian, I would have gotten the answer in Italian.

๐Ÿ‘ค You: Why is SELECT * FROM CUSTOMERS WHERE UPPER(NAME) = 'ROSSI' slow even though NAME has an index?
๐Ÿค– Assistant: The index isn't the problem, the UPPER() around it is. The plan says it plainly:
PLAN (CUSTOMERS NATURAL)

Select Expression
    -> Filter
        -> Table "CUSTOMERS" Full Scan

Full scan: 5004 records read sequentially to return 0 rows (55 pages read, 5437 fetched, 9 ms).

๐Ÿค– A B-tree index on NAME stores the values of the column, Rossi, rossi, ROSSI, Bianchi, sorted by those literal values. The predicate doesn't ask about NAME, it asks about UPPER(NAME), a value that exists nowhere in the index. The optimizer has no way to translate "where is UPPER(NAME) = 'ROSSI'?" into a range on a tree keyed by the raw column, so it gives up and scans.
๐Ÿค– Think of a phone book sorted by surname: useless if your question is "find everyone whose surname has 5 letters." The book is sorted, just not by the thing you're asking about. Any function wrapped around the indexed column has the same effect, UPPER, SUBSTRING, CAST, EXTRACT, string concatenation. This is what people mean by a non-sargable predicate.

The assistant had already read the table’s structure, and cites it to give the diagnosis some weight:

ColumnTypeNullable
CUSTOMER_IDINTEGERNo
NAMEVARCHAR(400)No
CITYVARCHAR(240)Yes
STATUSCHAR(4)Yes

Indexes: IDX_CUST_CITY (CITY, INACTIVE), IDX_CUST_NAME (NAME), IDX_CUST_NAME_CITY (NAME, CITY), IDX_CUST_STATUS (STATUS), RDB$PRIMARY1 (CUSTOMER_ID, primary key).

Then come two fixes, not just one:

๐Ÿค– Fix 1, mechanical:
CREATE INDEX IDX_CUSTOMERS_UPPER_NAME ON CUSTOMERS COMPUTED BY (UPPER(NAME));
SET STATISTICS INDEX IDX_CUSTOMERS_UPPER_NAME;
๐Ÿค– Supported since Firebird 2.0. One sharp edge: the match is textual and exact. UPPER(NAME) and UPPER(TRIM(NAME)) are different index entries, and there's no multi-segment form for expression indexes. If some call sites trim and others don't, half the calls silently fall back to a full scan.
๐Ÿค– Fix 2, domain-level, preferable: if the case-insensitive match is a property of the column itself and not just of this one query, move it into the collation:
ALTER TABLE CUSTOMERS ALTER NAME TYPE VARCHAR(400)
  CHARACTER SET UTF8 COLLATE UNICODE_CI;
๐Ÿค– Then WHERE NAME = 'rossi' matches directly, IDX_CUST_NAME gets used, and no caller has to remember to wrap anything. Check first the current collation of NAME: if it's already _CI, UPPER() is pure overhead and an expression index would just be a redundant structure to pay for.
๐Ÿค– Two things while I was in there: IDX_CUST_NAME is fully contained in IDX_CUST_NAME_CITY as its leading segment, so the standalone index is largely redundant, a cost on every INSERT probably paid for nothing. And IDX_CUST_CITY is INACTIVE: still maintained on every write, but the optimizer won't consider it. Reactivate it or drop it.
๐Ÿค– One more thing: the query returned 0 rows. If ROSSI was expected to be in there, the slowness is the second problem, not the first.

That last line is what convinced me to close the article with this session instead of a made-up example: the tool had solved the technical problem and then stopped to point out that maybe the real problem was something else, the data I was looking for wasn’t there. No tool does that on its own unless you told it to.

Closing, still in the same reply:

๐Ÿค– What this analysis does not rule out: an access plan is structural evidence, it names the path the optimizer chose. It can't show what the query costs while other work is running, or a degradation that only appears above a certain concurrency. A clean plan is not a fast query. And this advice comes from the schema and one execution of one query, not from the live workload: an index that helps this query may hurt three others nobody asked about, because every index costs every INSERT.

Partial indexes and indexes for ORDER BY

There are two more index shapes in 0.4.0. Partial indexes, which Firebird introduced with 5.0: take WHERE STATE = 'ACTIVE' AND TAG = ?. The value of TAG changes on every call, while STATE = 'ACTIVE' is fixed and always selects the same subset of rows. A plain index on TAG indexes the whole table, including the non-ACTIVE rows that the query will never look at. A partial index indexes only the rows you actually need:

CREATE INDEX IDX_CUSTOMERS_ACTIVE_TAG ON CUSTOMERS (TAG) WHERE STATE = 'ACTIVE';

If ACTIVE rows are 3% of the table, that index ends up roughly thirty times smaller, and inserting a non-ACTIVE row doesn’t even touch it. The advisor proposes both shapes, the plain one and the partial one. On Firebird 4.0 and earlier the WHERE clause doesn’t exist, so only the plain index comes back, with a note on what you’d gain by moving to 5.0.

Finally, ORDER BY. fb_analyze_query has flagged external SORT operations since the first release, and now fb_suggest_indexes proposes the index that removes them, with the segments in the right direction, because Firebird cannot walk an ascending index backwards. Ask for ORDER BY a ASC, b DESC and it tells you no single index can serve you, instead of proposing one the engine will ignore.

The engine explains the plan

On Firebird 3.0 and later, fb_analyze_query captures the “explained” plan via SET EXPLAIN ON. Before, the table behind an alias was inferred with a regex over the FROM clause; now the engine itself declares the index names, how much of each index the query actually uses, the join strategy and the alias-to-table map.

With that detail, the advisor finds two problems it couldn’t even see before.

The first is about composite indexes. Say you have an index on (CITY, NAME, STATUS) and a query that filters only on CITY and NAME: the explained plan shows Firebird used only the first two segments of the index, never the third. STATUS gets written into the index on every INSERT and UPDATE, but no query of this shape ever reads it. That’s a write cost buying nothing, and it’s only visible now that you know how many segments the query actually reaches.

The second is about joins. Before, you only saw the overall plan. Now, if the engine fully scans both the left and right table of a join instead of using an index on at least one of them, the advisor tells you, and names the table.

On Firebird 2.5 this part simply doesn’t run: SET EXPLAIN doesn’t exist on that version, and sending it anyway would break the script with a command the engine doesn’t recognize. So there the plan stays the one inferred from the query, as before.

If you forget to give a parameter a value

When you analyze a query with a placeholder, say SELECT * FROM CUSTOMERS WHERE CITY = ?, and don’t pass it a value, fb_analyze_query now refuses to run it and tells you why. Before, it ran it anyway: the parameter ended up bound to NULL, CITY = NULL matches nothing by definition, and the report came back with “0 rows read” over a table scanned from top to bottom, a number that looked like definitive proof you needed an index, when it was actually measuring a different query from the one you meant to test.

Two more minor fixes in the same package. First: an inequality comparison like A <> 5 no longer triggers an index proposal. A B-tree doesn’t help here: to exclude a single value the engine still has to read almost the whole index, so Firebird prefers the scan, and proposing the index would have been wasted effort. Second: the stale-statistics check now covers multi-column indexes too, not just single-column ones.

How to try it

Download MCPFirebird-0.4.0-win64.zip from the GitHub release, copy .env.example to .env, and point firebird.client_lib at the fbclient.dll of your own installation. It’s not in the zip, on purpose: only you know which server you’re talking to. Then register the exe as an stdio MCP server in your agent.

Then put your slowest query in front of it and ask what index it needs. The most interesting answer you can get is “none, and here are the numbers”.

The full changelog lists everything in 0.4.0, and the repository has the advisor’s design doc, with the measurements the thresholds come from.

Related reads: the MCP server that turns Firebird into an AI DBA describes the tools this advisor sits on; on the Delphi side, the MCP server for DelphiMVCFramework and a full AI agent embedded in a Delphi application.

Key facts

MCP Firebird 0.4.0 is the new release of the MCP (Model Context Protocol) server for Firebird databases, written in Delphi by Daniele Teti.

  • Project: mcp-firebird, version 0.4.0 (2026-08-07), https://github.com/danieleteti/mcp-firebird
  • What it is: an MCP server over stdio (JSON-RPC 2.0) that lets Claude, Cursor, Gemini CLI and any MCP client diagnose a Firebird 2.5, 3.0, 4.0 or 5.0 database, read-only
  • Release theme: fb_suggest_indexes rewritten around the principle "an index is not free, it gets written on every INSERT, UPDATE and DELETE for its entire life, so propose the cheapest remedy that explains the plan, and it is fine to propose nothing"
  • Remedy ladder, cheapest to most expensive: SET STATISTICS INDEX (refreshes statistics, leaves nothing behind), ALTER INDEX ... ACTIVE (reactivates an existing index), CREATE INDEX (paid on every write, last resort)
  • A scan can be the correct plan: the query is executed once and what it reads is compared with what it returns; above roughly 20% selectivity, or on tables of few pages, the advisor declares the scan correct and proposes no SQL
  • Statistics before structure: Firebird computes an index's selectivity at creation time and never recomputes it on its own, so an index that goes unused may simply have stale statistics; the advisor compares stored and actual selectivity before calling an index useless
  • Expression indexes: UPPER(NAME) = ? gets a CREATE INDEX ... COMPUTED BY (UPPER(NAME)), with the expression emitted exactly as written because Firebird only matches it exactly
  • Partial indexes on Firebird 5.0 and later (CREATE INDEX ... WHERE ...); below 5.0 you get the plain B-tree plus a note
  • ORDER BY index: segments in the correct direction, a warning when no single index can serve ORDER BY a ASC, b DESC
  • Engine "explained" plan on Firebird 3.0 and later (SET EXPLAIN ON): names of the indexes used, how many segments the query actually reaches, join strategy, alias-to-table map
  • New goal no_stale_index_statistics for the optimization_goal prompt
  • Main fix: statements with unbound parameters are no longer executed, because FireDAC binds an unset parameter to NULL and the measurement described a query nobody runs
  • Other fixes: A <> 5 is no longer treated as a range predicate; stale statistics are now also checked on composite indexes via RDB$INDEX_SEGMENTS; fb_suggest_index_drops checks actual selectivity before proposing a drop
  • Behavior change: fb_suggest_indexes runs the query under analysis once, inside the read-only transaction, only when the plan shows a scan or a sort
  • Download: MCPFirebird-0.4.0-win64.zip from the GitHub release; fbclient.dll is not included, point it at your own server's client library
  • Author: Daniele Teti

Frequently asked questions

What does MCP Firebird 0.4.0 change in the index advisor?
fb_suggest_indexes was rewritten around one principle: an index is written on every INSERT, UPDATE and DELETE for its entire life, so the advisor proposes the cheapest remedy that explains the plan, and it is allowed to propose nothing at all. The ladder starts at SET STATISTICS INDEX, goes on to ALTER INDEX ... ACTIVE, and leaves CREATE INDEX as the last resort.
Is a full scan in Firebird always a defect?
No. The advisor runs the query once and compares how many rows it reads with how many it returns. Above roughly 20% selectivity, or on tables of a few pages, the scan is the correct plan and no SQL is proposed.
Why does Firebird ignore an index that already exists?
Often the selectivity statistics are stale: Firebird computes them when the index is created and never recomputes them on its own. The advisor compares the stored selectivity with the actual one and suggests SET STATISTICS INDEX before declaring an index useless.
Can it index an expression such as UPPER(NAME) = ?
Yes: it emits CREATE INDEX ... COMPUTED BY (UPPER(NAME)) with the expression written exactly as it appears in the query, because Firebird uses an expression index only on an exact match.
Which Firebird versions does MCP Firebird support?
Firebird 2.5, 3.0, 4.0 and 5.0, read-only, over stdio (JSON-RPC 2.0). Partial indexes (CREATE INDEX ... WHERE) are proposed on 5.0 and later; below that you get the plain B-tree plus a note. The engine's explained plan is read on 3.0 and later.

Comments