Skip to content

Commit c37042c

Browse files
authored
fix(mcp): list a seed a load-time rule refuses at every open as unavailable (#2252, part 2) (#2255)
* feat(db): record the engines whose open refuses a handle with file access denied MCP must know before any provider exists that no open of a connection can succeed for its caller, and no provider declares it: SQLite's connect() refuses a handle opened with file access denied, because its drivers cannot confine a statement's file access, while DuckDB opens one with external access off. REFUSES_DENIED_FILE_ACCESS records it beside READS_FILE_ACCESS_POSTURE, true for SQLite alone, and joins the registries. A census builds each engine that reads the posture under the context each MCP profile gives a denied caller: SQLite rejects with its exact error before touching a path that does not exist, and DuckDB opens a real file. * fix(mcp): list a connection no MCP open can serve for its caller as unavailable A SQLite seed offered to every role was listed with run_read_query although every open of it is refused, for an admin's token too. mcpConnectionUnavailable asks editorExecutionContext for the posture of the caller's own copy, the call every MCP open makes, so the listing and the open agree. Such a connection now lists read_tool: null and unavailable, a sentence that says why and what an operator changes, and the description says so. Every other row lists as before, with one audit event per call. * fix(mcp): refuse an unavailable connection in inspect_schema before acquiring it inspect_schema built a provider for such a connection and relayed SQLite's refusal behind the untrusted-data notice as the database's error. It now refuses it once the connection resolves, with the sentence list_connections gives it and a decision event recorded as mcp_execution_failed, as it refuses an engine with no tables, and builds nothing. Statement tools are unchanged and still answer the provider's refusal at open. * docs(mcp): define unavailable and state the SQLite seed requirement The guide said read_tool is null where inspect_schema is the only read and named no SQLite seed requirement, though a SQLite seed opens only for an admin's token on a seed offered to admins alone. It now defines unavailable beside read_tool and states that requirement beside the PostgreSQL and SQL Server ones, the SQLite provider doc says how MCP lists and refuses such a seed, and tests pin each line. * docs(backlog): close MCP7, now that list_connections says why a SQLite seed cannot be read MCP7's done-when holds: list_connections lists such a seed with unavailable, docs/MCP.md states the SQLite seed requirement, and tests pin both. D460 also names the new census among the tests that pin SQLite's refusal. * test(mcp): set aside LIBREDB_ENABLED_PROVIDERS in the unavailable-connection agreement test The agreement test acquires real SQLite providers, and acquisition checks LIBREDB_ENABLED_PROVIDERS first, so a developer's setting decided it. * docs(mcp): say Studio opens a SQLite seed only for an admin where no other role can use it The sentence spoke of every SQLite connection, but an admin's own inline SQLite connection opens with full file access. D460 now also names the agreement test that pins the SQLite refusal.
1 parent 570678b commit c37042c

13 files changed

Lines changed: 547 additions & 18 deletions

‎docs/BACKLOG.md‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ None of it is a GitHub issue.
4242
- [Agent M1 deferrals (#328)](#agent-m1-deferrals-328) — A2–A8 · 5
4343
- [Agent M2 deferrals (#329)](#agent-m2-deferrals-329) — B2-B171 · 82
4444
- [Passkey deferrals (#785)](#passkey-deferrals-785) — PK1-PK9 · 8
45-
- [MCP server deferrals (#246)](#mcp-server-deferrals-246) — MCP1-MCP23 · 23
45+
- [MCP server deferrals (#246)](#mcp-server-deferrals-246) — MCP1-MCP23 · 22
4646
- [Governance follow-ups](#governance-follow-ups) — GOV1-GOV31 · 31
4747

4848
---
@@ -4523,7 +4523,7 @@ The DuckDB refusal names that case: `FILE_ACCESS_DENIED_REASON` in `src/lib/db/p
45234523
Measured 2026-11-05 through `POST /api/db/query` with the real driver and an admin session: SQLite seeds offered to `["admin", "user"]` and to `["*"]` each answered 400 with that sentence, and one offered to `["admin"]` answered 200.
45244524
Found 2026-11-05 by the review of the change that reads a shared seed's file-access audience from its operator roles.
45254525

4526-
**Done when:** the SQLite refusal names a connection another role can use the way the DuckDB one does, for example "SQLite connections open only for an administrator on a connection no other role can use, because this driver cannot confine statement-level file access", every test that pins the sentence (in `tests/integration/mcp/context.test.ts`, `tests/integration/db/reserved-storage-paths.test.ts`, `tests/isolated/factory.test.ts` and `e2e/embedded-samples.spec.ts`) asserts the new wording, one of them for an administrator on a seed offered to `["admin", "user"]`, and any document that quotes the sentence follows it.
4526+
**Done when:** the SQLite refusal names a connection another role can use the way the DuckDB one does, for example "SQLite connections open only for an administrator on a connection no other role can use, because this driver cannot confine statement-level file access", every test that pins the sentence (in `tests/integration/mcp/context.test.ts`, `tests/integration/db/reserved-storage-paths.test.ts`, `tests/isolated/factory.test.ts`, `tests/unit/db/refuses-denied-file-access.test.ts`, `tests/integration/mcp/list-connections.test.ts` and `e2e/embedded-samples.spec.ts`) asserts the new wording, one of them for an administrator on a seed offered to `["admin", "user"]`, and any document that quotes the sentence follows it.
45274527

45284528
## Value interpolation
45294529

@@ -9611,15 +9611,6 @@ Found 2026-11-01 while verifying `run_read_query` on ClickHouse; pre-existing an
96119611

96129612
**Done when:** every statement tool's row refusal states the limit the row actually exceeds, or the cap is measured on one copy of the rows, with a test on a row between 16 and 32 KiB on each served arm, and `docs/MCP.md` says which bytes the 32 KiB counts.
96139613

9614-
### MCP7. `list_connections` names `run_read_query` for a SQLite seed every MCP call refuses
9615-
9616-
A SQLite seed whose `roles` admit a non-admin role is opened with external file access denied, so every `run_read_query` on it answers "SQLite connections require an administrator because this driver cannot confine statement-level file access" (`connect()` in `src/lib/db/providers/sql/sqlite.ts`), whoever holds the token; `docs/providers/sqlite.md` says so, and `docs/MCP.md` does not.
9617-
`list_connections` still gives that connection `read_tool: "run_read_query"`.
9618-
Measured 2026-11-01 with a seed set to `roles: ["*"]` and an admin's token.
9619-
Found 2026-11-01 while verifying `run_read_query` on ClickHouse.
9620-
9621-
**Done when:** `list_connections` gives such a seed a `read_tool` that can run, or says why none can, `docs/MCP.md` states the SQLite seed requirement beside the PostgreSQL, SQL Server and ClickHouse ones, and a test pins both.
9622-
96239614
### MCP8. An InfluxDB 3 refusal through `run_read_query` names `SHOW` and `DESCRIBE`, which the tool refuses
96249615

96259616
`run_read_query`'s statement fence admits a `TABLE` statement on an InfluxDB 3 (SQL) connection, and the provider's read policy then refuses it with `notARead` of `INFLUX_SQL_POLICY_SENTENCES` in `src/lib/db/providers/timeseries/influxdb/sql-policy.ts`: "Only SELECT, WITH, VALUES, SHOW, EXPLAIN and DESCRIBE run on an InfluxDB 3 connection in Studio".

‎docs/MCP.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ In the rest of this guide, `agent-read-only` is Studio's least-privilege read pr
1818
- The tools, each annotated read-only and closed-world:
1919
- `list_connections` lists the connections opted in for MCP that your token's role may use, without credentials.
2020
It works for every engine but Oxia, Firestore, Modbus and NETCONF, whose connections the seed file cannot opt in.
21-
Each connection's `read_tool` names the statement tool below that runs a statement on it, and is `null` where `inspect_schema` is the only read.
21+
Each connection's `read_tool` names the statement tool below that runs a statement on it, and is `null` where `inspect_schema` is the only read or where the connection carries `unavailable`.
22+
`unavailable` is a sentence that says why no tool can read the connection and what an operator changes, and `inspect_schema` refuses that connection with it as soon as the connection resolves, before anything is acquired.
23+
A SQLite seed carries it for every token but an admin's, and for an admin's token too where the seed's `roles` name any role but admin ([Opting connections in](#opting-connections-in)).
2224
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`).
2325
- `inspect_schema` lists one connection's tables with their columns and, on request, their indexes.
2426
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.
@@ -321,6 +323,7 @@ An empty `list_connections` answer means no connection is opted in for your toke
321323
Where an engine's line under "Read-only is the database's own enforcement" above says it refuses a login that could do more than read, an opted-in seed for that engine needs a least-privilege principal.
322324
On PostgreSQL the seed's role must not be a superuser and must not hold `pg_read_server_files`, `pg_write_server_files` or `pg_execute_server_program`.
323325
On SQL Server the login must hold no fixed server role, neither `CONTROL SERVER` nor `ADMINISTER BULK OPERATIONS`, and none of `db_owner`, `db_accessadmin`, `db_securityadmin`, `db_ddladmin`, `db_backupoperator` or `db_datawriter`, and it must be granted `SHOWPLAN`.
326+
A SQLite file has no user, so a SQLite seed proves no principal, but MCP opens it only for an admin's token on a seed offered to admins alone, `roles: ["admin"]`: SQLite's drivers cannot confine a statement's file access, so Studio opens a SQLite seed only for an admin, and only where no other role can use it ([sqlite.md](./providers/sqlite.md)), and `list_connections` lists every other SQLite seed a token can see, such as one offered to every role, `roles: ["*"]`, with `read_tool: null` and `unavailable`.
324327
On libSQL the seed's token must be a read-only token (a JWT whose claims carry `"a": "ro"` and nothing beyond the standard claims) on a server that checks it, as the open proves for `run_read_query` ([libsql.md](./providers/libsql.md) section 3.6): a seed that names a user signs in with HTTP Basic and is refused, and so is a seed with no token, a writable token, or a server that runs a write the token sends, such as one that checks no credential.
325328
On InfluxDB 3 (SQL) every Core token is an admin token, so no seed token is least-privilege and none is asked for: `run_read_query` refuses instead a server that is not InfluxDB 3 Core on the 3.12 line, whose planner's write refusals were captured, checking it at the open ([influxdb3.md](./providers/influxdb3.md) section 3.5).
326329
On Elasticsearch with security on, the open asks the server what the seed's login may do and lists its privileges, and refuses, before any statement runs, a login that holds the cluster privilege `all`, reads the restricted system indices it asks about, or is listed with an index privilege granted with `allow_restricted_indices`, on this cluster or a remote one, so a seed signed in as a superuser is refused; that check cannot name every restricted index, and the server lists no privileges for an API key with role descriptors of its own, so it does not refuse every login that may read one.

‎docs/providers/sqlite.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,6 +1747,7 @@ so hard links cannot alias reserved files. Other user database files beside them
17471747
SQLite connections are refused when the server-derived file-access posture is denied,
17481748
including agent handles. HTTP and MCP derive that posture from the verified role and
17491749
managed connection: non-admin callers and seeds shared with non-admin roles are denied.
1750+
Where it is denied, MCP's `list_connections` lists the seed with `read_tool: null` and an `unavailable` sentence that says what an operator changes, and `inspect_schema` refuses it before anything is acquired ([MCP.md](../MCP.md#opting-connections-in)).
17501751
SQLite's adapters cannot confine statement-level file access, so a denied connection
17511752
cannot be opened safely. Administrators retain SQLite access on private connections.
17521753
Embedded callers using the cached factory must explicitly pass `{ allowExternalFileAccess: true }`

‎src/lib/db/compatibility.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,70 @@ export const READS_FILE_ACCESS_POSTURE: Readonly<Record<DatabaseType, boolean>>
456456
libredb: false,
457457
});
458458

459+
/**
460+
* Which shipped engines refuse to open a handle at all when its file-access posture is denied, rather
461+
* than confining it. SQLite's `connect()` refuses before any file I/O because its drivers cannot confine
462+
* statement-level file access, while DuckDB, the other engine that reads the posture, opens the handle
463+
* with external access off. A true row is always a `READS_FILE_ACCESS_POSTURE` engine.
464+
*
465+
* Static because MCP decides before any provider exists: `list_connections` and `inspect_schema` ask,
466+
* through `mcpConnectionUnavailable` in `src/lib/mcp/availability.ts`, whether any open of a listed
467+
* connection can succeed for its caller, and no provider declares the fact. Reading `connection.type`
468+
* there was the alternative and is forbidden by `CLAUDE.md`.
469+
*
470+
* An exhaustive Record for the reason `EXTERNAL` gives, so a new type-id cannot join without someone
471+
* answering, and `tests/unit/db/refuses-denied-file-access.test.ts` holds every row to what `connect()`
472+
* does under the context an MCP open gives a caller whose file access is denied. Frozen like the records
473+
* above it.
474+
*/
475+
export const REFUSES_DENIED_FILE_ACCESS: Readonly<Record<DatabaseType, boolean>> = Object.freeze({
476+
postgres: false,
477+
bigquery: false,
478+
snowflake: false,
479+
spark: false,
480+
mysql: false,
481+
// Its open refuses a denied handle before any file I/O.
482+
sqlite: true,
483+
libsql: false,
484+
// It reads the posture too, and confines a denied handle instead of refusing it.
485+
duckdb: false,
486+
oracle: false,
487+
db2: false,
488+
mssql: false,
489+
clickhouse: false,
490+
druid: false,
491+
trino: false,
492+
cassandra: false,
493+
elasticsearch: false,
494+
opensearch: false,
495+
mongodb: false,
496+
couchbase: false,
497+
redis: false,
498+
prometheus: false,
499+
kafka: false,
500+
etcd: false,
501+
neo4j: false,
502+
milvus: false,
503+
qdrant: false,
504+
weaviate: false,
505+
influxdb: false,
506+
influxdb3: false,
507+
oxia: false,
508+
databend: false,
509+
s3: false,
510+
kubernetes: false,
511+
mqtt: false,
512+
firestore: false,
513+
rabbitmq: false,
514+
opcua: false,
515+
mtconnect: false,
516+
modbus: false,
517+
netconf: false,
518+
snmp: false,
519+
sparkplug: false,
520+
libredb: false,
521+
});
522+
459523
/**
460524
* Which shipped engines keep one handle per database file in this process: the static answer
461525
* `ProviderCapabilities.singleWriterFile` gives once a provider is built.

‎src/lib/mcp/availability.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { editorExecutionContext } from "@/lib/api/execution-context";
2+
import { REFUSES_DENIED_FILE_ACCESS } from "@/lib/db/compatibility";
3+
import type { ManagedConnection } from "@/lib/seed";
4+
import type { McpCaller } from "./context";
5+
6+
/**
7+
* Whether any MCP open of a listed connection can succeed for its caller (#2252, part 2).
8+
*
9+
* The file-access posture is the one load-time rule decided per caller: the seed schema refuses the others (a read
10+
* credential where one is required, read roots) when the file loads, so a listed connection already meets them. This
11+
* asks `editorExecutionContext` for the posture of the caller's own listed copy, the call `McpConnectionContext.acquire`
12+
* makes, and Vault resolution never rewrites the id, type, roles or `sharedWithNonAdminRoles` it reads, so the listing
13+
* and the open agree. It builds no provider and reads no Vault reference.
14+
*
15+
* The engine's row is read first, so only an engine that refuses a denied handle asks for a posture. The sentence names
16+
* no engine, as `list_connections` serves every engine.
17+
*/
18+
19+
/** What `list_connections` lists and `inspect_schema` answers for a connection no MCP open can serve for its caller. */
20+
export const MCP_CONNECTION_UNAVAILABLE_TEXT =
21+
"This connection is not available to MCP: its engine opens only with full file access, which Studio grants only to an admin on a seed limited to the admin role, so an operator limits this seed's roles to admin.";
22+
23+
/**
24+
* The sentence when `connection`'s engine refuses a handle opened with file access denied and `caller`'s posture on
25+
* that record denies it, otherwise null. `refuses` is injectable for the tests.
26+
*/
27+
export function mcpConnectionUnavailable(
28+
caller: McpCaller,
29+
connection: ManagedConnection,
30+
refuses: Readonly<Record<ManagedConnection["type"], boolean>> = REFUSES_DENIED_FILE_ACCESS,
31+
): string | null {
32+
if (!refuses[connection.type]) return null;
33+
return editorExecutionContext(caller, connection).allowExternalFileAccess ? null : MCP_CONNECTION_UNAVAILABLE_TEXT;
34+
}

‎src/lib/mcp/tools/inspect-schema.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type McpCallRecord,
1414
type McpToolAuditReason,
1515
} from "../audit";
16+
import { mcpConnectionUnavailable } from "../availability";
1617
import { DATABASE_ARGUMENT_DESCRIPTION, McpDatabaseChoiceError, providerInDatabase } from "./database-choice";
1718
import { type McpStatementTool, mcpTablelessReadTool } from "./statement-arms";
1819
import {
@@ -289,6 +290,11 @@ export async function inspectSchema(args: InspectSchemaInput, call: McpToolCall)
289290
recordOrRefuse(() => recordMcpDecision(resolved, "mcp_execution_failed")) ??
290291
ownWordsError(tablelessText(connection.type, tableless))
291292
);
293+
// Ahead of any acquisition too: a connection no MCP open can serve for this caller is refused with the sentence
294+
// list_connections lists it with, so no provider is built for an open that would be refused.
295+
const unavailable = mcpConnectionUnavailable(call.context.caller, connection);
296+
if (unavailable !== null)
297+
return recordOrRefuse(() => recordMcpDecision(resolved, "mcp_execution_failed")) ?? ownWordsError(unavailable);
292298
const unrecorded = recordOrRefuse(() => recordMcpDecision(resolved));
293299
if (unrecorded !== null) return unrecorded;
294300

‎src/lib/mcp/tools/list-connections.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { z } from "zod";
33
import { isFileBased } from "@/lib/db-ui-config";
44
import type { ManagedConnection } from "@/lib/seed";
55
import { newMcpCorrelationId, recordMcpDecision, type McpCallRecord } from "../audit";
6+
import { mcpConnectionUnavailable } from "../availability";
67
import {
78
MCP_CONNECTIONS_UNREADABLE,
89
MCP_CONNECTIONS_UNREADABLE_TEXT,
10+
type McpCaller,
911
type McpConnectionContext,
1012
type McpToolCall,
1113
} from "../context";
@@ -25,6 +27,8 @@ import { MCP_STATEMENT_TOOLS, mcpStatementToolFor, mcpWithholdsDatabase } from "
2527
* may use, without credentials and without a server-side file path, paged under the result cap.
2628
* Each names its read_tool, the statement tool that serves its engine, read from the same
2729
* served-engine resolver the tools register and gate on, or null where none does.
30+
* A connection no MCP open can serve for this caller (`mcpConnectionUnavailable`, `src/lib/mcp/availability.ts`) lists
31+
* null too, with `unavailable`, the sentence that says why and what an operator changes.
2832
* A connection whose database setting is a stored value on its engine (a topic filter, a group id),
2933
* as its language's facet says, lists no database.
3034
*
@@ -46,6 +50,7 @@ export const ListedConnectionSchema = z.object({
4650
database: z.string().optional(),
4751
environment: z.string().optional(),
4852
read_tool: z.enum(MCP_STATEMENT_TOOLS).nullable(),
53+
unavailable: z.string().optional(),
4954
});
5055

5156
const ListConnectionsOutputSchema = z.object({
@@ -60,15 +65,16 @@ type ListedConnection = z.infer<typeof ListedConnectionSchema>;
6065

6166
const LIST_CONNECTIONS_TITLE = "List connections";
6267
export const LIST_CONNECTIONS_DESCRIPTION =
63-
"List the database connections this deployment opted in for MCP that your token's role may use, without credentials. Works for every engine. Each connection's read_tool names the tool that runs a statement on it, or is null where inspect_schema is the only read. An empty list means no connection is opted in: an operator adds mcp: true to a seed connection. Pages with offset; has_more and next_offset say when more connections exist.";
68+
"List the database connections this deployment opted in for MCP that your token's role may use, without credentials. Works for every engine. Each connection's read_tool names the tool that runs a statement on it, or is null where inspect_schema is the only read. A connection that carries unavailable cannot be read by any tool, and unavailable says why and what an operator changes. An empty list means no connection is opted in: an operator adds mcp: true to a seed connection. Pages with offset; has_more and next_offset say when more connections exist.";
6469

6570
/** The file name alone, cut after the last / or \, so no server-side path reaches the client. */
6671
function fileName(path: string): string {
6772
return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
6873
}
6974

70-
function listed(connection: ManagedConnection): ListedConnection {
75+
function listed(connection: ManagedConnection, caller: McpCaller): ListedConnection {
7176
const { database, environment } = connection;
77+
const unavailable = mcpConnectionUnavailable(caller, connection);
7278
return {
7379
id: connection.id,
7480
name: connection.name,
@@ -77,7 +83,8 @@ function listed(connection: ManagedConnection): ListedConnection {
7783
? {}
7884
: { database: isFileBased(connection.type) ? fileName(database) : database }),
7985
...(environment === undefined ? {} : { environment }),
80-
read_tool: mcpStatementToolFor(connection.type),
86+
read_tool: unavailable === null ? mcpStatementToolFor(connection.type) : null,
87+
...(unavailable === null ? {} : { unavailable }),
8188
};
8289
}
8390

@@ -120,7 +127,7 @@ async function listConnections(args: ListConnectionsInput, call: McpToolCall): P
120127
}
121128
const matching = visible
122129
.filter((connection) => args.environment === "all" || connection.environment === args.environment)
123-
.map(listed);
130+
.map((connection) => listed(connection, call.context.caller));
124131
const result = page(matching, args.offset);
125132
return recordOrRefuse(() => recordMcpDecision(record, result.isError ? "mcp_result_too_large" : undefined)) ?? result;
126133
}

0 commit comments

Comments
 (0)