Skip to content
13 changes: 2 additions & 11 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ None of it is a GitHub issue.
- [Agent M1 deferrals (#328)](#agent-m1-deferrals-328) — A2–A8 · 5
- [Agent M2 deferrals (#329)](#agent-m2-deferrals-329) — B2-B171 · 82
- [Passkey deferrals (#785)](#passkey-deferrals-785) — PK1-PK9 · 8
- [MCP server deferrals (#246)](#mcp-server-deferrals-246) — MCP1-MCP23 · 23
- [MCP server deferrals (#246)](#mcp-server-deferrals-246) — MCP1-MCP23 · 22
- [Governance follow-ups](#governance-follow-ups) — GOV1-GOV31 · 31

---
Expand Down Expand Up @@ -4523,7 +4523,7 @@ The DuckDB refusal names that case: `FILE_ACCESS_DENIED_REASON` in `src/lib/db/p
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.
Found 2026-11-05 by the review of the change that reads a shared seed's file-access audience from its operator roles.

**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.
**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.

## Value interpolation

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

**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.

### MCP7. `list_connections` names `run_read_query` for a SQLite seed every MCP call refuses

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.
`list_connections` still gives that connection `read_tool: "run_read_query"`.
Measured 2026-11-01 with a seed set to `roles: ["*"]` and an admin's token.
Found 2026-11-01 while verifying `run_read_query` on ClickHouse.

**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.

### MCP8. An InfluxDB 3 refusal through `run_read_query` names `SHOW` and `DESCRIBE`, which the tool refuses

`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".
Expand Down
5 changes: 4 additions & 1 deletion docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ In the rest of this guide, `agent-read-only` is Studio's least-privilege read pr
- The tools, each annotated read-only and closed-world:
- `list_connections` lists the connections opted in for MCP that your token's role may use, without credentials.
It works for every engine but Oxia, Firestore, Modbus and NETCONF, whose connections the seed file cannot opt in.
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.
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`.
`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.
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)).
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`).
- `inspect_schema` lists one connection's tables with their columns and, on request, their indexes.
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.
Expand Down Expand Up @@ -321,6 +323,7 @@ An empty `list_connections` answer means no connection is opted in for your toke
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.
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`.
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`.
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`.
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.
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).
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.
Expand Down
1 change: 1 addition & 0 deletions docs/providers/sqlite.md
Original file line number Diff line number Diff line change
Expand Up @@ -1747,6 +1747,7 @@ so hard links cannot alias reserved files. Other user database files beside them
SQLite connections are refused when the server-derived file-access posture is denied,
including agent handles. HTTP and MCP derive that posture from the verified role and
managed connection: non-admin callers and seeds shared with non-admin roles are denied.
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)).
SQLite's adapters cannot confine statement-level file access, so a denied connection
cannot be opened safely. Administrators retain SQLite access on private connections.
Embedded callers using the cached factory must explicitly pass `{ allowExternalFileAccess: true }`
Expand Down
64 changes: 64 additions & 0 deletions src/lib/db/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,70 @@ export const READS_FILE_ACCESS_POSTURE: Readonly<Record<DatabaseType, boolean>>
libredb: false,
});

/**
* Which shipped engines refuse to open a handle at all when its file-access posture is denied, rather
* than confining it. SQLite's `connect()` refuses before any file I/O because its drivers cannot confine
* statement-level file access, while DuckDB, the other engine that reads the posture, opens the handle
* with external access off. A true row is always a `READS_FILE_ACCESS_POSTURE` engine.
*
* Static because MCP decides before any provider exists: `list_connections` and `inspect_schema` ask,
* through `mcpConnectionUnavailable` in `src/lib/mcp/availability.ts`, whether any open of a listed
* connection can succeed for its caller, and no provider declares the fact. Reading `connection.type`
* there was the alternative and is forbidden by `CLAUDE.md`.
*
* An exhaustive Record for the reason `EXTERNAL` gives, so a new type-id cannot join without someone
* answering, and `tests/unit/db/refuses-denied-file-access.test.ts` holds every row to what `connect()`
* does under the context an MCP open gives a caller whose file access is denied. Frozen like the records
* above it.
*/
export const REFUSES_DENIED_FILE_ACCESS: Readonly<Record<DatabaseType, boolean>> = Object.freeze({
postgres: false,
bigquery: false,
snowflake: false,
spark: false,
mysql: false,
// Its open refuses a denied handle before any file I/O.
sqlite: true,
libsql: false,
// It reads the posture too, and confines a denied handle instead of refusing it.
duckdb: false,
oracle: false,
db2: false,
mssql: false,
clickhouse: false,
druid: false,
trino: false,
cassandra: false,
elasticsearch: false,
opensearch: false,
mongodb: false,
couchbase: false,
redis: false,
prometheus: false,
kafka: false,
etcd: false,
neo4j: false,
milvus: false,
qdrant: false,
weaviate: false,
influxdb: false,
influxdb3: false,
oxia: false,
databend: false,
s3: false,
kubernetes: false,
mqtt: false,
firestore: false,
rabbitmq: false,
opcua: false,
mtconnect: false,
modbus: false,
netconf: false,
snmp: false,
sparkplug: false,
libredb: false,
});

/**
* Which shipped engines keep one handle per database file in this process: the static answer
* `ProviderCapabilities.singleWriterFile` gives once a provider is built.
Expand Down
34 changes: 34 additions & 0 deletions src/lib/mcp/availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { editorExecutionContext } from "@/lib/api/execution-context";
import { REFUSES_DENIED_FILE_ACCESS } from "@/lib/db/compatibility";
import type { ManagedConnection } from "@/lib/seed";
import type { McpCaller } from "./context";

/**
* Whether any MCP open of a listed connection can succeed for its caller (#2252, part 2).
*
* The file-access posture is the one load-time rule decided per caller: the seed schema refuses the others (a read
* credential where one is required, read roots) when the file loads, so a listed connection already meets them. This
* asks `editorExecutionContext` for the posture of the caller's own listed copy, the call `McpConnectionContext.acquire`
* makes, and Vault resolution never rewrites the id, type, roles or `sharedWithNonAdminRoles` it reads, so the listing
* and the open agree. It builds no provider and reads no Vault reference.
*
* The engine's row is read first, so only an engine that refuses a denied handle asks for a posture. The sentence names
* no engine, as `list_connections` serves every engine.
*/

/** What `list_connections` lists and `inspect_schema` answers for a connection no MCP open can serve for its caller. */
export const MCP_CONNECTION_UNAVAILABLE_TEXT =
"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.";

/**
* The sentence when `connection`'s engine refuses a handle opened with file access denied and `caller`'s posture on
* that record denies it, otherwise null. `refuses` is injectable for the tests.
*/
export function mcpConnectionUnavailable(
caller: McpCaller,
connection: ManagedConnection,
refuses: Readonly<Record<ManagedConnection["type"], boolean>> = REFUSES_DENIED_FILE_ACCESS,
): string | null {
if (!refuses[connection.type]) return null;
return editorExecutionContext(caller, connection).allowExternalFileAccess ? null : MCP_CONNECTION_UNAVAILABLE_TEXT;
}
6 changes: 6 additions & 0 deletions src/lib/mcp/tools/inspect-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type McpCallRecord,
type McpToolAuditReason,
} from "../audit";
import { mcpConnectionUnavailable } from "../availability";
import { DATABASE_ARGUMENT_DESCRIPTION, McpDatabaseChoiceError, providerInDatabase } from "./database-choice";
import { type McpStatementTool, mcpTablelessReadTool } from "./statement-arms";
import {
Expand Down Expand Up @@ -289,6 +290,11 @@ export async function inspectSchema(args: InspectSchemaInput, call: McpToolCall)
recordOrRefuse(() => recordMcpDecision(resolved, "mcp_execution_failed")) ??
ownWordsError(tablelessText(connection.type, tableless))
);
// Ahead of any acquisition too: a connection no MCP open can serve for this caller is refused with the sentence
// list_connections lists it with, so no provider is built for an open that would be refused.
const unavailable = mcpConnectionUnavailable(call.context.caller, connection);
if (unavailable !== null)
return recordOrRefuse(() => recordMcpDecision(resolved, "mcp_execution_failed")) ?? ownWordsError(unavailable);
const unrecorded = recordOrRefuse(() => recordMcpDecision(resolved));
if (unrecorded !== null) return unrecorded;

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

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

const LIST_CONNECTIONS_TITLE = "List connections";
export const LIST_CONNECTIONS_DESCRIPTION =
"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.";
"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.";

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

function listed(connection: ManagedConnection): ListedConnection {
function listed(connection: ManagedConnection, caller: McpCaller): ListedConnection {
const { database, environment } = connection;
const unavailable = mcpConnectionUnavailable(caller, connection);
return {
id: connection.id,
name: connection.name,
Expand All @@ -77,7 +83,8 @@ function listed(connection: ManagedConnection): ListedConnection {
? {}
: { database: isFileBased(connection.type) ? fileName(database) : database }),
...(environment === undefined ? {} : { environment }),
read_tool: mcpStatementToolFor(connection.type),
read_tool: unavailable === null ? mcpStatementToolFor(connection.type) : null,
...(unavailable === null ? {} : { unavailable }),
};
}

Expand Down Expand Up @@ -120,7 +127,7 @@ async function listConnections(args: ListConnectionsInput, call: McpToolCall): P
}
const matching = visible
.filter((connection) => args.environment === "all" || connection.environment === args.environment)
.map(listed);
.map((connection) => listed(connection, call.context.caller));
const result = page(matching, args.offset);
return recordOrRefuse(() => recordMcpDecision(record, result.isError ? "mcp_result_too_large" : undefined)) ?? result;
}
Expand Down
Loading
Loading