Skip to content

Commit cce3ca5

Browse files
authored
feat(mcp): give inspect_schema a deadline, foreign keys and the schema names it reads (#2252, part 4) (#2259)
* feat(mcp): give inspect_schema a timeout_ms that bounds every provider call inspect_schema took no time limit, so a slow acquisition, container listing or table description held the call for as long as the provider took. It now takes timeout_ms, 500 to 30000 and 30000 by default. The bounds are one constant, MCP_TIMEOUT_BOUNDS_MS, which the statement tools' timeout_ms and their timeout answer read too. Every provider call of a call, from the acquisition to each table's description, runs through raceDeadline, so none starts after the deadline; one already started runs on, the acquisition included, and the next call on the connection joins an acquisition a timed-out call left, as MCP16 records. A timeout answers mcp_timeout in a sentence of its own, which offers more time only below 30000, and a cancel answers mcp_cancelled at once. The tests that abort a held step now let it finish before the provider cache is cleared, since the answer no longer waits for it. * feat(mcp): return each table's foreign keys from inspect_schema on request describeObject already returns a table's foreign keys, and inspect_schema dropped them. With include_foreign_keys each table carries up to 25 as column, referenced_table and referenced_column, with foreign_keys_omitted counting the rest. Where the provider declares that its engine has no foreign keys, each table answers unavailable instead, and asking for foreign keys alone describes no table there. The input's description says that a composite key comes back as one row per column pair, since no constraint name is returned, and that an empty list may also mean the connection's login cannot see the keys. * feat(mcp): return the schema names inspect_schema reads on request chooseContainer lists every container level it walks, yet a client could not learn which other schemas a connection holds. It now also returns what its last listing answered, and with include_schema_names the page carries schema_names after schema: those names in the provider's order, at most 100 and 4,096 bytes of JSON with no name cut, and schema_names_omitted counting the rest, on every page and inside the 32 KiB result limit. A connection with no container level answers unavailable, and the field sends no request of its own. The schema-not-found refusal now points at include_schema_names, and the first-table-over-the-limit refusal names every include flag to turn off. * fix(mcp): say that inspect_schema's operations profile proves no login on some engines inspect_schema opens under Studio's operations profile, which sends no statement, so where an engine proves its login only for a connection that sends statements, it can list a connection whose statement tool refuses that login. Its description and docs/MCP.md now say so, the docs naming agent-operations and MongoDB as one such engine. The profile itself is unchanged. * test(mcp): clear the acquisition environment for every inspect_schema test isolateAcquisitionEnvironment() runs once at file level, right after pinMcpTestEnvironment(). Each test of the file clears GOVERNANCE_SOURCE and LIBREDB_ENABLED_PROVIDERS and restores them exactly afterwards; the deadline block still pins LIBREDB_SQLITE_WORKER of its own. Before this, most tests that predate this change failed with either variable exported. With GOVERNANCE_SOURCE=postgres and LIBREDB_ENABLED_PROVIDERS=postgres exported, every test of the file passes, as it does with neither set. * test(mcp): clear the acquisition environment for the Sparkplug B schema names test The test of inspect_schema's schema names on Sparkplug B acquires a real connection, so its block clears GOVERNANCE_SOURCE and LIBREDB_ENABLED_PROVIDERS for each test and restores them exactly afterwards. It now passes with GOVERNANCE_SOURCE=postgres exported; the file's older tests still read that variable, as they do on main. * docs(mcp): name Milvus among the engines inspect_schema opens unproved The sentence on inspect_schema's operations profile said that Opting connections in names each engine whose provider proves its login only for a connection that sends statements. That section names most of them but not Milvus or InfluxDB (InfluxQL), so the sentence now names Milvus beside MongoDB and says the section names more such engines, without claiming it names all of them. The documentation test pins the new sentence. * test(mcp): hold inspect_schema's database choice to the deadline, and the foreign keys flag to the size refusal A server-level connection whose database choice is held past timeout_ms 500 now answers the timeout, and no listing on the chosen database's session starts after it. The test that counts the include flags before the first-table size refusal now also sets include_foreign_keys alone beside include_columns false, which must answer the refusal that advises limit 1. Each test fails when its line in inspect_schema is undone: the race of the database choice against the deadline, or the include_foreign_keys term of the bare check. * test(mcp): pin inspect_schema's unavailable foreign keys beside columns, and schema names past the first page On an engine that reads no foreign keys, a table answers foreign_keys unavailable also when the default include_columns describes it. That test fails when foreignKeysOf is handed the description regardless of whether the engine reads foreign keys. schema_names come back on a page with offset 1 too. That test fails when the names are kept to the first page. * docs(mcp): say that inspect_schema lets a step holding Studio's own thread finish past the deadline The steps of an inspect_schema call start only before its deadline, and a call still waiting on one when it passes is answered with the timeout, never a partial page. A step that holds Studio's own thread, as a LibreDB read does, cannot be interrupted, so it is let finish and only the steps after it are refused. The documentation test pins the bullet with its new line. * test(mcp): count the comma between schema names toward their 4 KiB bound A list of 100 names of 38 characters crosses the bound between two names, so the answer keeps 99 and counts one omitted; with the comma left out of the count it would keep all 100 in 4,101 bytes of JSON, which the test refuses. * test(mcp): pin schema_names' 4,096-byte bound to the byte Two lists end at exactly 4,096 and at 4,097 bytes of JSON: the first keeps its last name and the second leaves it, so a count that drops the brackets, charges a comma to the first name or refuses a list of exactly 4,096 bytes fails.
1 parent 4f9bcb0 commit cce3ca5

11 files changed

Lines changed: 891 additions & 34 deletions

File tree

‎docs/BACKLOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9728,6 +9728,7 @@ Found 2026-11-02 while opening `run_kafka_read`, `run_rabbitmq_read` and `run_mt
97289728
`McpConnectionContext.acquire` (`src/lib/mcp/context.ts`) keeps one pending acquisition per connection and profile in `pendingAcquisitions` until it settles, so when the call that started it stops waiting on a timeout or a cancel, the next call on the same connection and profile joins that acquisition: it sends nothing of its own and answers with the first acquisition's outcome.
97299729
On SNMP, a call made at once after a 5,000 ms timeout answered "No SNMP agent answered" about 4.5 s later, although the agent had come back by then.
97309730
Engine-neutral; `docs/providers/snmp.md` section 14 and the SNMP cancel row of `docs/MCP.md` record it, and `tests/integration/mcp/run-snmp-read.test.ts` pins it.
9731+
`inspect_schema` has raced its acquisition against its own `timeout_ms` since #2252, so its calls join an acquisition a timed-out `inspect_schema` call left in the same way, as the `inspect_schema` bullet under "Limits" in `docs/MCP.md` says and `tests/integration/mcp/inspect-schema.test.ts` pins.
97319732
Dropping the entry when its first caller stops waiting would let two acquisitions for one key run at once and the later one overwrite the cached provider, leaking the earlier one; retrying the join would be a fallback.
97329733
Found 2026-11-03 while opening `run_snmp_read`.
97339734

‎docs/MCP.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ In the rest of this guide, `agent-read-only` is Studio's least-privilege read pr
2424
A connection's `database` is left out on MQTT and Sparkplug B, where that setting is a stored value, the discovery topic filter or the group id, as the language's facet declares (`withholdsDatabase`).
2525
- `inspect_schema` lists one connection's tables with their columns and, on request, their indexes.
2626
It works on the same engines as `list_connections`: it lists every object kind the engine reads rows from, such as views beside tables, MongoDB collections, Redis keyspaces and search indexes, and `kind` names which one each entry is.
27+
With `include_foreign_keys` each table also carries `foreign_keys`, at most 25 rows of `{ column, referenced_table, referenced_column }`, and `foreign_keys_omitted`, the count of the rest: a composite key comes back as one row per column pair, since no constraint name is returned, and an empty list may also mean the connection's login cannot see the keys.
28+
On an engine whose provider declares it holds no foreign keys (`declaresForeignKeys: false`), such as LibreDB and MongoDB, each table answers `foreign_keys: { unavailable }` instead, and so on Snowflake and BigQuery, whose providers declare the same although those engines can hold declared keys; with `include_columns` and `include_indexes` off, a call there describes no table.
29+
With `include_schema_names` the page also carries `schema_names` after `schema`, the names `schema` can take on that connection in the provider's order, at most 100 names and 4,096 bytes of JSON with no name cut, and `schema_names_omitted`, the count of the rest; both are on every page of a call, count toward the 32 KiB result limit and leave `total_tables`, `has_more` and `next_offset` to the tables.
30+
On a connection with one container level the names are that level's listing, and on a PostgreSQL connection that reaches every database they are the schemas of the database `database` names; on one with two, such as DuckDB, they are the deepest level under the upper container the session is in, or else the first one listed, since no argument chooses that one; on a connection with no container level, such as SQLite, and on Sparkplug B, whose MCP handle declares none, `schema_names` is `{ unavailable }`.
31+
The names come from the container listings the call makes anyway, so asking for them sends no request of its own.
32+
`timeout_ms`, 500 to 30000 and 30000 by default, bounds the whole call ([Limits](#limits)).
33+
It opens its connection under `agent-operations`, which sends no statement, so on an engine whose provider proves its login only for a connection that sends statements, MongoDB and Milvus among them, it proves no login and may list a connection whose statement tool refuses that login; [Opting connections in](#opting-connections-in) names more such engines.
2734
On Kubernetes it refuses the call by name, because no Kubernetes kind is a table: the refusal names `run_kubectl_read`, which reads its objects, and is answered as soon as the connection resolves, before anything is acquired.
2835
So no object name, a Secret's included, reaches it there ([kubernetes.md](./providers/kubernetes.md#9-capabilities-ai-and-mcp)).
2936
On MQTT it refuses the call the same way, naming `run_mqtt_read`, because a broker declares no relation kind: a topic is a key, and topic names are withheld ([mqtt.md](./providers/mqtt.md) section 15).
@@ -637,6 +644,9 @@ For the first few seconds after that service starts, `opencode mcp list` can say
637644
| SNMP | Studio stops waiting; the read's exchange keeps asking until the provider's own deadline, set to the time left of `timeout_ms`, ends it and closes its socket, and its answer is dropped | The provider's deadline ends the read and closes its socket at `timeout_ms`, and the client gets the timeout answer (measured on Net-SNMP 5.9.4 on 2026-11-03 with the SNMP agent paused after the acquisition: a 500 ms `timeout_ms` answered at 503 ms and the read ended at 471 ms; a cancel at 1,000 ms of a 3,000 ms call answered at 1,002 ms and the read ended at 2,966 ms); a cancel or a `timeout_ms` that passes while the connection is still being opened leaves its connect probe waiting up to 5,000 ms, and a call on the same connection made meanwhile joins that acquisition, sends no datagram and answers its failure ([snmp.md](./providers/snmp.md#14-mcp-read)) |
638645
| OPC UA | Studio stops waiting; the read runs on until its next request meets the provider's own deadline, set to the time left of `timeout_ms`, and its session is closed when the request in flight returns | The provider's deadline refuses the read at `timeout_ms`, and the client gets the timeout answer; a session still opening then is closed as soon as it opens (measured on OPC PLC 2.15.10 on 2026-11-04 with the server paused after the acquisition: a 500 ms `timeout_ms` answered at 501 ms and the read ended at 468 ms; a cancel at 1,000 ms of a 3,000 ms call answered at 1,002 ms and the read ended at 2,962 ms; [opcua.md](./providers/opcua.md#15-mcp-read)) |
639646

647+
- `inspect_schema` takes `timeout_ms` too, 500 to 30000 and 30000 by default, as one deadline over the whole call: opening the connection, the database choice, each container listing, the table listing and each table's description start only before it, and a call still waiting on one of them when it passes is answered with the timeout, never a partial page.
648+
A step that holds Studio's own thread, as a LibreDB read does, cannot be interrupted, so it is let finish and only the steps after it are refused.
649+
A cancel or the deadline ends the wait, not the step it was waiting on: a read already sent may still be running on the database, and an opening the call started runs on, so a later `inspect_schema` call on that connection made while it runs joins it rather than opening the connection again, and answers with its outcome.
640650
- One MCP query against a large SQLite table no longer stops the Studio process: the read runs in a
641651
child process and is killed at `timeout_ms`; only an in-memory SQLite read still blocks it.
642652
- An MCP call on a LibreDB connection that no Studio session holds open opens the file itself and keeps its exclusive lock until that handle has been idle for 30 minutes.

0 commit comments

Comments
 (0)