diff --git a/.gitleaksignore b/.gitleaksignore
index d9b9fcb25..f8bc1e186 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -1,3 +1,5 @@
8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:174
8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:175
8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:176
+b45446d25724585fee8379601c351e0bb1960373:e2e/tests/console/mcpBridgePermissions.spec.js:generic-api-key:9
+b45446d25724585fee8379601c351e0bb1960373:src/utils/mcp/consumePendingPair.test.ts:generic-api-key:27
diff --git a/e2e/commands.js b/e2e/commands.js
index 785af348d..d939c9bc9 100644
--- a/e2e/commands.js
+++ b/e2e/commands.js
@@ -722,8 +722,13 @@ Cypress.Commands.add("getSearchResultGroups", () => {
return cy.getByDataHook("search-result-buffer-group")
})
-Cypress.Commands.add("createTabWithContent", (content, title) => {
+Cypress.Commands.add("addEditorTab", () => {
cy.getByDataHook("new-tab-button").click()
+ cy.getByDataHook("new-tab-editor").click()
+})
+
+Cypress.Commands.add("createTabWithContent", (content, title) => {
+ cy.addEditorTab()
cy.get(".chrome-tab-was-just-added").should("be.visible")
cy.get(".chrome-tab-was-just-added").should("not.exist")
cy.typeQueryDirectly(content)
diff --git a/e2e/questdb b/e2e/questdb
index 3b6d3ed93..88f051fee 160000
--- a/e2e/questdb
+++ b/e2e/questdb
@@ -1 +1 @@
-Subproject commit 3b6d3ed9325c78c6e3ccc6942608afab772c03a0
+Subproject commit 88f051feefbb5cdfe986851a14f33277a0f838f4
diff --git a/e2e/tests/console/aiAssistant.spec.js b/e2e/tests/console/aiAssistant.spec.js
index 248aa5119..5bc3533f1 100644
--- a/e2e/tests/console/aiAssistant.spec.js
+++ b/e2e/tests/console/aiAssistant.spec.js
@@ -396,8 +396,9 @@ describe("ai assistant", () => {
// Then
cy.getByDataHook("ai-settings-modal-step-two").should("be.visible")
- // When
- cy.getByDataHook("ai-settings-schema-access").click()
+ // When - drop permissions to None so schema tools are excluded.
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-none").click()
cy.getByDataHook("multi-step-modal-next-button").click()
// Then - AI chat should be available
@@ -420,9 +421,10 @@ describe("ai assistant", () => {
})
})
- // When - Open settings modal and enable schema access
+ // When - Open settings modal and re-enable schema access
cy.getByDataHook("ai-assistant-settings-button").click()
- cy.getByDataHook("ai-settings-schema-access").click()
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-schema").click()
cy.getByDataHook("ai-settings-save").click()
cy.get(".toast-success-container").should("be.visible").click()
@@ -1234,7 +1236,7 @@ describe("ai assistant", () => {
// When - Close chat and create a new tab
cy.getByDataHook("chat-window-close").click()
- cy.get(".new-tab-button").click()
+ cy.addEditorTab()
// Then - New tab should be created (2 tabs total now)
cy.getEditorTabs().should("have.length", 2)
@@ -3026,7 +3028,7 @@ Syntax: \`avg(column)\`
// Close the tab that the suggestion was applied to (archive the buffer)
// First, create another tab so we can close the current one
- cy.get(".new-tab-button").click()
+ cy.addEditorTab()
cy.getEditorTabs().should("have.length", 2)
// Close the first tab (the one with the accepted query)
@@ -3336,7 +3338,8 @@ describe("custom providers", () => {
cy.getByDataHook("custom-provider-remove-model").click()
cy.getByDataHook("custom-provider-model-chip").should("not.exist")
- cy.getByDataHook("custom-provider-schema-access").check()
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-schema").click()
cy.getByDataHook("multi-step-modal-next-button").click()
cy.contains("AI Assistant activated successfully").should("be.visible")
@@ -3911,7 +3914,7 @@ describe("custom providers", () => {
"have.value",
"200000",
)
- cy.getByDataHook("custom-provider-schema-access").should("be.checked")
+ cy.getByDataHook("permissions-trigger").should("contain", "Schema access")
cy.getByDataHook("custom-provider-add-model-button").should("be.disabled")
cy.getByDataHook("custom-provider-manual-model-input").type(
@@ -4266,8 +4269,8 @@ describe("custom providers", () => {
cy.get("[data-model='llama3']").should("exist")
cy.get("[data-model='mistral']").should("exist")
- // Schema access toggle is not disabled
- cy.getByDataHook("ai-settings-schema-access").should("not.be.disabled")
+ // Permissions select is not disabled
+ cy.getByDataHook("permissions-trigger").should("not.be.disabled")
// Manage models button visible
cy.getByDataHook("ai-settings-manage-models").should("be.visible")
diff --git a/e2e/tests/console/aiAssistantPermissions.spec.js b/e2e/tests/console/aiAssistantPermissions.spec.js
new file mode 100644
index 000000000..f8f7651d4
--- /dev/null
+++ b/e2e/tests/console/aiAssistantPermissions.spec.js
@@ -0,0 +1,243 @@
+///
+
+const {
+ PROVIDERS,
+ getOpenAIConfiguredSettings,
+ getOpenAIPermissionedSettings,
+ createToolCallFlow,
+} = require("../../utils/aiAssistant")
+
+const installValidateIntercept = () => {
+ cy.intercept("GET", "**/api/v1/sql/validate*", (req) => {
+ const url = new URL(req.url)
+ const sql = (url.searchParams.get("query") || "").trim().toUpperCase()
+ if (sql.startsWith("SELECT") || sql.startsWith("SHOW")) {
+ req.reply({
+ statusCode: 200,
+ body: {
+ query: sql,
+ columns: [{ name: "c1", type: "LONG" }],
+ timestamp: -1,
+ },
+ })
+ return
+ }
+ if (
+ sql.startsWith("INSERT") ||
+ sql.startsWith("UPDATE") ||
+ sql.startsWith("DELETE")
+ ) {
+ req.reply({ statusCode: 200, body: { queryType: "INSERT" } })
+ return
+ }
+ req.reply({ statusCode: 200, body: { queryType: "CREATE TABLE" } })
+ }).as("validate")
+}
+
+const installExecDqlIntercept = () => {
+ cy.intercept("GET", "**/exec*", (req) => {
+ req.reply({
+ statusCode: 200,
+ body: {
+ query: "SELECT 1",
+ columns: [{ name: "c1", type: "LONG" }],
+ dataset: [[1]],
+ count: 1,
+ timestamp: -1,
+ },
+ })
+ }).as("exec")
+}
+
+// get_questdb_toc fetches the docs TOC over the network; stub it so the free
+// tool resolves deterministically instead of reaching questdb.com.
+const installDocsTocIntercept = () => {
+ cy.intercept("GET", "**/web-console/toc-list.json", {
+ statusCode: 200,
+ body: { functions: ["count()"], operators: ["="], sql: ["SELECT"] },
+ }).as("docsToc")
+}
+
+// Every backend a granted tool can touch, stubbed in one call so a single
+// conversation can drive the full surface matrix without hitting the live DB.
+const installToolStubs = () => {
+ installValidateIntercept()
+ installExecDqlIntercept()
+ installDocsTocIntercept()
+}
+
+describe("ai assistant permissions", () => {
+ beforeEach(() => {
+ // Fail loudly on any unmocked provider request — each test scripts its own intercept.
+ cy.intercept("POST", PROVIDERS.openai.endpoint, (req) => {
+ throw new Error(
+ `Unhandled OpenAI request detected! Request body: ${JSON.stringify(
+ req.body,
+ ).slice(0, 200)}...`,
+ )
+ }).as("unhandledOpenAI")
+ })
+
+ describe("PermissionsSection in settings modals", () => {
+ beforeEach(() => {
+ cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings())
+ })
+
+ it("renders permission level select with cascading levels in SettingsModal", () => {
+ cy.getByDataHook("ai-assistant-settings-button")
+ .should("be.visible")
+ .click()
+
+ cy.getByDataHook("permissions").should("be.visible")
+ cy.getByDataHook("permissions-trigger").should("contain", "Schema access")
+
+ // Raise to Write: trigger label updates and all levels listed in menu.
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-write").click()
+ cy.getByDataHook("permissions-trigger").should("contain", "Write")
+
+ // Drop to None: trigger label updates back.
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-none").click()
+ cy.getByDataHook("permissions-trigger").should("contain", "None")
+ })
+ })
+
+ describe("tool permission gate — one pass per level", () => {
+ const GRANTED = { excludes: ["PERMISSION_DENIED"] }
+ const GRANTED_DQL = {
+ includes: ['"type":"dql"'],
+ excludes: ["PERMISSION_DENIED"],
+ }
+ const DENIED_SCHEMA = {
+ includes: ["PERMISSION_DENIED", "grantSchemaAccess"],
+ }
+ const DENIED_READ = { includes: ["PERMISSION_DENIED", "'read' permission"] }
+ const DENIED_WRITE = {
+ includes: ["PERMISSION_DENIED", "'write' permission"],
+ }
+
+ const hasRead = (level) => level === "read" || level === "write"
+
+ // Each surface is one tool call; `expect(level)` is the assertion for that
+ // surface's tool result at the configured level. free tools never gate;
+ // schema tools need grantSchemaAccess; run_query DQL needs read, DDL/DML
+ // needs write.
+ const SURFACES = [
+ {
+ toolCall: { name: "validate_query", args: { query: "SELECT 1" } },
+ expect: () => GRANTED,
+ },
+ {
+ toolCall: { name: "suggest_query", args: { query: "SELECT 1" } },
+ expect: () => GRANTED,
+ },
+ {
+ toolCall: { name: "get_questdb_toc", args: {} },
+ expect: () => GRANTED,
+ },
+ {
+ toolCall: { name: "get_tables", args: {} },
+ expect: (level) => (level === "none" ? DENIED_SCHEMA : GRANTED),
+ },
+ {
+ toolCall: {
+ name: "get_table_schema",
+ args: { table_name: "btc_trades" },
+ },
+ expect: (level) => (level === "none" ? DENIED_SCHEMA : GRANTED),
+ },
+ {
+ toolCall: {
+ name: "get_table_details",
+ args: { table_name: "btc_trades" },
+ },
+ expect: (level) => (level === "none" ? DENIED_SCHEMA : GRANTED),
+ },
+ {
+ toolCall: {
+ name: "run_query",
+ args: { sql: "SELECT count(*) FROM btc_trades" },
+ },
+ expect: (level) => (hasRead(level) ? GRANTED_DQL : DENIED_READ),
+ },
+ {
+ toolCall: { name: "run_query", args: { sql: "DROP TABLE btc_trades" } },
+ expect: (level) => (level === "write" ? GRANTED : DENIED_WRITE),
+ },
+ ]
+
+ const buildSteps = (level) => {
+ const steps = SURFACES.map((surface, index) => ({
+ toolCall: surface.toolCall,
+ ...(index > 0
+ ? { expectToolResult: SURFACES[index - 1].expect(level) }
+ : {}),
+ }))
+ steps.push({
+ finalResponse: {
+ explanation: "Permission matrix probe complete.",
+ sql: null,
+ },
+ expectToolResult: SURFACES[SURFACES.length - 1].expect(level),
+ })
+ return steps
+ }
+
+ const runMatrix = (level) => {
+ const flow = createToolCallFlow({
+ provider: "openai",
+ streaming: true,
+ question: `Probe tool permissions for the ${level} level.`,
+ steps: buildSteps(level),
+ })
+
+ flow.intercept()
+ cy.getByDataHook("ai-chat-button").click()
+ cy.getByDataHook("chat-input-textarea")
+ .should("be.visible")
+ .type(flow.question)
+ cy.getByDataHook("chat-send-button").click()
+ flow.waitForCompletion()
+
+ cy.getByDataHook("chat-message-assistant")
+ .should("be.visible")
+ .should("contain", "matrix probe complete")
+ }
+
+ const LEVELS = {
+ none: { read: false, write: false, grantSchemaAccess: false },
+ schema: { read: false, write: false, grantSchemaAccess: true },
+ read: { read: true, write: false, grantSchemaAccess: true },
+ write: { read: true, write: true, grantSchemaAccess: true },
+ }
+
+ const loadAtLevel = (level) => {
+ cy.loadConsoleWithAuth(
+ false,
+ getOpenAIPermissionedSettings(LEVELS[level]),
+ )
+ installToolStubs()
+ }
+
+ it("none: free tools pass, schema and SQL tools are denied", () => {
+ loadAtLevel("none")
+ runMatrix("none")
+ })
+
+ it("schema access: schema tools pass, SQL tools still denied", () => {
+ loadAtLevel("schema")
+ runMatrix("schema")
+ })
+
+ it("read access: DQL runs, write SQL still denied", () => {
+ loadAtLevel("read")
+ runMatrix("read")
+ })
+
+ it("write access: every tool surface is granted", () => {
+ loadAtLevel("write")
+ runMatrix("write")
+ })
+ })
+})
diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js
index ebacbd173..b49ef326a 100644
--- a/e2e/tests/console/editor.spec.js
+++ b/e2e/tests/console/editor.spec.js
@@ -1360,7 +1360,7 @@ describe("editor tabs", () => {
})
it("should open the second empty tab on plus icon click", () => {
- cy.get(".new-tab-button").click()
+ cy.addEditorTab()
cy.get(".chrome-tab-was-just-added").should("not.exist")
cy.getEditorTabs().should("have.length", 2)
;["SQL", "SQL 1"].forEach((title) => {
@@ -1411,7 +1411,7 @@ describe("editor tabs", () => {
})
it("should drag tabs", () => {
- cy.get(".new-tab-button").click()
+ cy.addEditorTab()
cy.get(".chrome-tab-was-just-added").should("not.exist")
cy.getEditorTabByTitle("SQL").should("be.visible")
cy.getEditorTabByTitle("SQL 1").should("be.visible")
@@ -1451,7 +1451,7 @@ describe.skip("editor tabs history", () => {
it("should close and archive tabs", () => {
cy.typeQuery("--1")
;["SQL 1", "SQL 2"].forEach((title) => {
- cy.get(".new-tab-button").click()
+ cy.addEditorTab()
const dragHandle = getTabDragHandleByTitle(title)
cy.get(dragHandle).should("be.visible")
})
@@ -1487,9 +1487,7 @@ describe.skip("editor tabs history", () => {
describe("handling comments", () => {
beforeEach(() => {
- cy.loadConsoleWithAuth(false, {
- "splitter.results.basis": Number.MAX_SAFE_INTEGER.toString(),
- })
+ cy.loadConsoleWithAuth(false)
})
beforeEach(() => {
@@ -1763,7 +1761,7 @@ describe("multiple run buttons with dynamic query log", () => {
cy.getCursorQueryGlyph().should("have.length", 3)
// When
- cy.get(".new-tab-button").click()
+ cy.addEditorTab()
// Then
cy.getEditorTabByTitle("SQL 1")
.should("be.visible")
@@ -1904,7 +1902,7 @@ describe("import/export tabs", () => {
cy.getEditorTabs().should("be.visible")
})
- it("should show error toast when importing invalid file", () => {
+ it("should show validation reason in import summary when importing invalid file", () => {
cy.getByDataHook("editor-tabs-menu-button").click()
cy.getByDataHook("editor-tabs-menu").should("be.visible")
@@ -1914,11 +1912,19 @@ describe("import/export tabs", () => {
{ force: true },
)
- cy.get(".toast-error-container")
- .should("be.visible")
- .should("contain", "Failed to import tabs")
- .should("contain", "(id: 1)")
- .should("contain", "label must be a string")
+ // An invalid tab is skipped rather than aborting the whole import; its
+ // validation reason is surfaced in the summary dialog.
+ cy.getByDataHook("import-summary-dialog").should("be.visible")
+ cy.getByDataHook("import-summary-dialog").should("contain", "0 tab")
+ cy.getByDataHook("import-summary-dialog").should("contain", "1 tab skipped")
+ cy.getByDataHook("import-summary-skipped-item").should("have.length", 1)
+ cy.getByDataHook("import-summary-skipped-item").should(
+ "contain",
+ "label must be a string",
+ )
+
+ cy.getByDataHook("import-summary-close").click()
+ cy.getByDataHook("import-summary-dialog").should("not.exist")
})
it("should import tabs successfully with active and archived tabs", () => {
diff --git a/e2e/tests/console/mcpBridgePermissions.spec.js b/e2e/tests/console/mcpBridgePermissions.spec.js
new file mode 100644
index 000000000..bfeaa1bcb
--- /dev/null
+++ b/e2e/tests/console/mcpBridgePermissions.spec.js
@@ -0,0 +1,433 @@
+///
+
+// E2E coverage for the MCP bridge permission system.
+
+const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || ""
+const baseUrl = `http://localhost:9999${contextPath}`
+
+const TEST_BRIDGE_URL = "ws://127.0.0.1:57123"
+const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789"
+// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped.
+const PROTOCOL_VERSION = "1"
+
+// Fake WebSocket installed before the app boots; exposed on `win.__mcpFakeWS`.
+const installFakeWebSocket = (win) => {
+ class FakeWS {
+ constructor(url) {
+ this.url = url
+ this.readyState = 0
+ this.sent = []
+ this.listeners = {}
+ win.__mcpFakeWS = this
+ // Async "open" mirrors real WebSocket — lets listener registration finish.
+ win.setTimeout(() => {
+ this.readyState = 1
+ this._dispatch("open", {})
+ }, 0)
+ }
+ addEventListener(type, fn) {
+ if (!this.listeners[type]) this.listeners[type] = new Set()
+ this.listeners[type].add(fn)
+ }
+ removeEventListener(type, fn) {
+ this.listeners[type]?.delete(fn)
+ }
+ send(data) {
+ if (this.readyState !== 1) {
+ throw new Error("FakeWS.send while not open")
+ }
+ this.sent.push(data)
+ const parsed = JSON.parse(data)
+ if (parsed.type === "ping") {
+ this.receive({
+ v: PROTOCOL_VERSION,
+ type: "pong",
+ nonce: parsed.nonce,
+ })
+ }
+ }
+ close() {
+ this.readyState = 3
+ this._dispatch("close", { code: 1000, reason: "test-close" })
+ }
+ receive(payload) {
+ this._dispatch("message", { data: JSON.stringify(payload) })
+ }
+ helloAck() {
+ this.receive({
+ v: PROTOCOL_VERSION,
+ type: "hello_ack",
+ sessionId: "test-session",
+ heartbeatIntervalMs: 60000,
+ seenToolCount: 1,
+ })
+ }
+ toolCall(name, args, requestId) {
+ const id = requestId || "req-" + Math.random().toString(36).slice(2)
+ this.receive({
+ v: PROTOCOL_VERSION,
+ type: "tool_call",
+ requestId: id,
+ name,
+ arguments: args,
+ deadlineMs: 15000,
+ })
+ return id
+ }
+ _dispatch(type, ev) {
+ this.listeners[type]?.forEach((fn) => fn(ev))
+ }
+ framesOfType(type) {
+ return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type)
+ }
+ }
+ // Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN.
+ FakeWS.CONNECTING = 0
+ FakeWS.OPEN = 1
+ FakeWS.CLOSING = 2
+ FakeWS.CLOSED = 3
+ win.WebSocket = FakeWS
+}
+
+// Permission classifier calls /api/v1/sql/validate to distinguish DQL vs DDL/DML.
+const installValidateIntercept = () => {
+ cy.intercept("GET", "**/api/v1/sql/validate*", (req) => {
+ const url = new URL(req.url)
+ const sql = (url.searchParams.get("query") || "").trim().toUpperCase()
+ if (sql.startsWith("SELECT") || sql.startsWith("SHOW")) {
+ req.reply({
+ statusCode: 200,
+ body: {
+ query: sql,
+ columns: [{ name: "c1", type: "LONG" }],
+ timestamp: -1,
+ },
+ })
+ return
+ }
+ if (
+ sql.startsWith("INSERT") ||
+ sql.startsWith("UPDATE") ||
+ sql.startsWith("DELETE")
+ ) {
+ req.reply({ statusCode: 200, body: { queryType: "INSERT" } })
+ return
+ }
+ req.reply({ statusCode: 200, body: { queryType: "CREATE TABLE" } })
+ }).as("validate")
+}
+
+const deepLinkSuffix = () =>
+ `?mcp-pair=1&mcp-ws=${encodeURIComponent(TEST_BRIDGE_URL)}` +
+ `&mcp-token=${encodeURIComponent(TEST_BRIDGE_TOKEN)}`
+
+// Visit with deep-link params before login so they survive into the SPA boot.
+const loginAndVisitDeepLink = (seedLocalStorage = {}) => {
+ cy.visit(`${baseUrl}/${deepLinkSuffix()}`, {
+ onBeforeLoad: (win) => {
+ win.localStorage.clear()
+ win.sessionStorage.clear()
+ win.indexedDB.deleteDatabase("web-console")
+ for (const [k, v] of Object.entries(seedLocalStorage)) {
+ win.localStorage.setItem(k, v)
+ }
+ installFakeWebSocket(win)
+ },
+ })
+ cy.loginWithUserAndPassword()
+}
+
+const waitForPaired = () => {
+ cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist")
+ cy.window({ timeout: 10000 }).should((win) => {
+ expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan(0)
+ })
+ cy.window().then((win) => win.__mcpFakeWS.helloAck())
+ cy.getByDataHook("mcp-bridge-status-pill", { timeout: 10000 }).should(
+ "contain",
+ "MCP connected",
+ )
+}
+
+const lastToolResult = (win, requestId) => {
+ const results = win.__mcpFakeWS.framesOfType("tool_result")
+ return results.find((r) => r.requestId === requestId)
+}
+
+describe("MCP bridge permissions (e2e)", () => {
+ beforeEach(() => {
+ installValidateIntercept()
+ })
+
+ describe("consent modal", () => {
+ it("default is Read; user raises to Write before Connect; hello carries { grantSchemaAccess:T, read:T, write:T }", () => {
+ loginAndVisitDeepLink()
+
+ cy.getByDataHook("permissions").should("be.visible")
+ cy.getByDataHook("permissions-trigger").should("contain", "Read")
+
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-write").click()
+ cy.getByDataHook("permissions-trigger").should("contain", "Write")
+
+ cy.getByDataHook("mcp-pair-consent-connect").click()
+
+ cy.window().should((win) => {
+ const hellos = win.__mcpFakeWS.framesOfType("hello")
+ expect(hellos.length).to.be.greaterThan(0)
+ expect(hellos[0].permissions).to.deep.equal({
+ grantSchemaAccess: true,
+ read: true,
+ write: true,
+ })
+ expect(hellos[0].token).to.equal(TEST_BRIDGE_TOKEN)
+ })
+
+ cy.window().then((win) => win.__mcpFakeWS.helloAck())
+ cy.getByDataHook("mcp-bridge-status-pill").should(
+ "contain",
+ "MCP connected",
+ )
+ cy.window().then((win) => {
+ expect(
+ JSON.parse(win.localStorage.getItem("mcp:permissions")),
+ ).to.deep.equal({
+ grantSchemaAccess: true,
+ read: true,
+ write: true,
+ })
+ })
+ })
+
+ it("user drops to None; hello carries all-false", () => {
+ loginAndVisitDeepLink()
+
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-none").click()
+ cy.getByDataHook("permissions-trigger").should("contain", "None")
+
+ cy.getByDataHook("mcp-pair-consent-connect").click()
+ cy.window().should((win) => {
+ const hellos = win.__mcpFakeWS.framesOfType("hello")
+ expect(hellos.length).to.be.greaterThan(0)
+ expect(hellos[0].permissions).to.deep.equal({
+ grantSchemaAccess: false,
+ read: false,
+ write: false,
+ })
+ })
+ })
+ })
+
+ describe("permission gate over the wire", () => {
+ const expectedLabel = (p) => {
+ if (p.write) return "Write"
+ if (p.read) return "Read"
+ if (p.grantSchemaAccess) return "Schema access"
+ return "None"
+ }
+ const setupPaired = (permissions) => {
+ loginAndVisitDeepLink({
+ "mcp:permissions": JSON.stringify(permissions),
+ })
+ cy.getByDataHook("permissions-trigger").should(
+ "contain",
+ expectedLabel(permissions),
+ )
+ cy.getByDataHook("mcp-pair-consent-connect").click()
+ waitForPaired()
+ }
+
+ it("read+write granted: DQL, DML, schema all run", () => {
+ setupPaired({ grantSchemaAccess: true, read: true, write: true })
+
+ const ids = {}
+ cy.window().then((win) => {
+ ids.dql = win.__mcpFakeWS.toolCall("run_query", {
+ sql: "SELECT 1",
+ limit: 10,
+ })
+ ids.dml = win.__mcpFakeWS.toolCall("run_query", {
+ sql: "INSERT INTO t VALUES (1)",
+ limit: 10,
+ })
+ ids.schema = win.__mcpFakeWS.toolCall("get_tables", {})
+ })
+
+ cy.window({ timeout: 10000 }).should((w) => {
+ expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(3)
+ })
+
+ cy.window().then((w) => {
+ // Only PERMISSION_DENIED matters here; other errors are ignored.
+ for (const id of Object.values(ids)) {
+ const r = lastToolResult(w, id)
+ expect(r, `result for ${id}`).to.exist
+ if (r.isError) {
+ expect(r.content[0].text).to.not.match(/PERMISSION_DENIED/)
+ }
+ }
+ })
+ })
+
+ it("read-only: DQL granted, DDL/DML denied via /validate classification", () => {
+ setupPaired({ grantSchemaAccess: true, read: true, write: false })
+
+ const ids = {}
+ cy.window().then((win) => {
+ ids.dql = win.__mcpFakeWS.toolCall("run_query", {
+ sql: "SELECT * FROM t",
+ limit: 10,
+ })
+ ids.dml = win.__mcpFakeWS.toolCall("run_query", {
+ sql: "INSERT INTO t VALUES (1)",
+ limit: 10,
+ })
+ ids.ddl = win.__mcpFakeWS.toolCall("run_query", {
+ sql: "CREATE TABLE t (a INT)",
+ limit: 10,
+ })
+ ids.schema = win.__mcpFakeWS.toolCall("get_tables", {})
+ })
+
+ cy.window({ timeout: 10000 }).should((w) => {
+ expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(4)
+ })
+
+ cy.window().then((w) => {
+ const dml = lastToolResult(w, ids.dml)
+ expect(dml.isError).to.equal(true)
+ expect(dml.content[0].text).to.match(/PERMISSION_DENIED/)
+ expect(dml.content[0].text).to.match(/'write' permission/)
+
+ const ddl = lastToolResult(w, ids.ddl)
+ expect(ddl.isError).to.equal(true)
+ expect(ddl.content[0].text).to.match(/PERMISSION_DENIED/)
+
+ const schema = lastToolResult(w, ids.schema)
+ if (schema.isError) {
+ expect(schema.content[0].text).to.not.match(/PERMISSION_DENIED/)
+ }
+ const dql = lastToolResult(w, ids.dql)
+ if (dql.isError) {
+ expect(dql.content[0].text).to.not.match(/PERMISSION_DENIED/)
+ }
+ })
+ })
+
+ it("no permissions: even DQL + schema reads are denied", () => {
+ setupPaired({ grantSchemaAccess: false, read: false, write: false })
+
+ const ids = {}
+ cy.window().then((win) => {
+ ids.dql = win.__mcpFakeWS.toolCall("run_query", {
+ sql: "SELECT 1",
+ limit: 10,
+ })
+ ids.schema = win.__mcpFakeWS.toolCall("get_tables", {})
+ })
+
+ cy.window({ timeout: 10000 }).should((w) => {
+ expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(2)
+ })
+
+ cy.window().then((w) => {
+ const dql = lastToolResult(w, ids.dql)
+ expect(dql.isError).to.equal(true)
+ expect(dql.content[0].text).to.match(/PERMISSION_DENIED/)
+
+ const schema = lastToolResult(w, ids.schema)
+ expect(schema.isError).to.equal(true)
+ expect(schema.content[0].text).to.match(/PERMISSION_DENIED/)
+ expect(schema.content[0].text).to.match(/'grantSchemaAccess'/)
+ })
+ })
+ })
+
+ describe("popover: submit-only persistence", () => {
+ it("raising to Write without clicking Apply does NOT change localStorage", () => {
+ loginAndVisitDeepLink()
+ cy.getByDataHook("mcp-pair-consent-connect").click()
+ waitForPaired()
+
+ cy.getByDataHook("mcp-bridge-status-pill").click()
+ cy.getByDataHook("mcp-pair-popover").should("be.visible")
+
+ cy.window().then((win) => {
+ expect(
+ JSON.parse(win.localStorage.getItem("mcp:permissions")),
+ ).to.deep.equal({ grantSchemaAccess: true, read: true, write: false })
+ })
+
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-write").click()
+ cy.getByDataHook("mcp-pair-cancel").click()
+
+ cy.window().then((win) => {
+ expect(
+ JSON.parse(win.localStorage.getItem("mcp:permissions")),
+ ).to.deep.equal({ grantSchemaAccess: true, read: true, write: false })
+ })
+
+ cy.getByDataHook("mcp-bridge-status-pill").click()
+ cy.getByDataHook("permissions-trigger").should("contain", "Read")
+ })
+
+ it("raise to Write + Apply: localStorage updates, no reconnect", () => {
+ loginAndVisitDeepLink()
+ cy.getByDataHook("mcp-pair-consent-connect").click()
+ waitForPaired()
+
+ cy.getByDataHook("mcp-bridge-status-pill").click()
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-write").click()
+ cy.getByDataHook("mcp-pair-submit").should("contain", "Connect").click()
+
+ cy.window().then((win) => {
+ expect(
+ JSON.parse(win.localStorage.getItem("mcp:permissions")),
+ ).to.deep.equal({ grantSchemaAccess: true, read: true, write: true })
+ expect(win.__mcpFakeWS.framesOfType("hello")).to.have.length(1)
+ })
+ cy.getByDataHook("mcp-bridge-status-pill").should(
+ "contain",
+ "MCP connected",
+ )
+ })
+ })
+
+ describe("persistence across refresh", () => {
+ it("consented pair auto-restores; new hello carries the previously-committed permissions", () => {
+ loginAndVisitDeepLink()
+ cy.getByDataHook("permissions-trigger").click()
+ cy.getByDataHook("permission-level-write").click()
+ cy.getByDataHook("mcp-pair-consent-connect").click()
+ waitForPaired()
+
+ // cy.reload doesn't accept onBeforeLoad — re-visit so the fake WS reinstalls.
+ cy.visit(baseUrl, {
+ onBeforeLoad: (win) => installFakeWebSocket(win),
+ })
+
+ cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist")
+ cy.window({ timeout: 10000 }).should((win) => {
+ expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan(
+ 0,
+ )
+ })
+ cy.window().then((win) => {
+ const hello = win.__mcpFakeWS.framesOfType("hello")[0]
+ expect(hello.permissions).to.deep.equal({
+ grantSchemaAccess: true,
+ read: true,
+ write: true,
+ })
+ win.__mcpFakeWS.helloAck()
+ })
+ cy.getByDataHook("mcp-bridge-status-pill").should(
+ "contain",
+ "MCP connected",
+ )
+ })
+ })
+})
diff --git a/e2e/utils/aiAssistant.js b/e2e/utils/aiAssistant.js
index 8a6f208e7..c3bbecbe1 100644
--- a/e2e/utils/aiAssistant.js
+++ b/e2e/utils/aiAssistant.js
@@ -34,6 +34,34 @@ function getOpenAIConfiguredSettings(schemaAccess = true) {
}
}
+/**
+ * OpenAI settings with the AI permission scopes (grantSchemaAccess/read/write)
+ * set explicitly. These are the per-provider scopes `getAiPermissions` reads —
+ * NOT the MCP `mcp:permissions` key — so the permission gate decides off them
+ * directly. `grantSchemaAccess` defaults to true; pass false for the "none"
+ * level where even schema tools are denied.
+ */
+function getOpenAIPermissionedSettings({
+ read,
+ write,
+ grantSchemaAccess = true,
+}) {
+ return {
+ "ai.assistant.settings": JSON.stringify({
+ selectedModel: "gpt-5-mini",
+ providers: {
+ openai: {
+ apiKey: "test-openai-key",
+ enabledModels: ["gpt-5-mini", "gpt-5"],
+ grantSchemaAccess,
+ read,
+ write,
+ },
+ },
+ }),
+ }
+}
+
function getAnthropicConfiguredSettings(schemaAccess = true) {
return {
"ai.assistant.settings": JSON.stringify({
@@ -945,6 +973,9 @@ function createToolCallFlow(config) {
for (const expected of step.expectToolResult.includes || []) {
expect(toolOutputContent).to.include(expected)
}
+ for (const forbidden of step.expectToolResult.excludes || []) {
+ expect(toolOutputContent).to.not.include(forbidden)
+ }
}
// Send response
@@ -1136,6 +1167,7 @@ module.exports = {
PROVIDERS,
CUSTOM_PROVIDER_DEFAULTS,
getOpenAIConfiguredSettings,
+ getOpenAIPermissionedSettings,
getAnthropicConfiguredSettings,
getCustomProviderConfiguredSettings,
getCustomProviderEndpoint,
diff --git a/package.json b/package.json
index 15d6a5664..845ec20a1 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,8 @@
"dexie-react-hooks": "^4.2.0",
"dotenv": "^10.0.0",
"draggabilly": "^3.0.0",
- "echarts": "^5.2.2",
+ "echarts": "^5.6.0",
+ "echarts-for-react": "^3.0.6",
"eventemitter3": "^5.0.1",
"fflate": "^0.8.2",
"intersection-observer": "^0.12.2",
@@ -91,6 +92,7 @@
"react": "17.0.2",
"react-calendar": "^4.0.0",
"react-dom": "17.0.2",
+ "react-grid-layout": "^2.2.3",
"react-highlight-words": "^0.20.0",
"react-hook-form": "^7.56.0",
"react-is": "^18.1.0",
@@ -124,13 +126,13 @@
"@styled-icons/styled-icon": "^10.7.0",
"@types/babel__core": "^7",
"@types/draggabilly": "^2.1.6",
- "@types/echarts": "^4.9.22",
"@types/jquery": "3.5.1",
"@types/lodash.merge": "^4.6.9",
"@types/node": "^18.0.0",
"@types/ramda": "0.27.40",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.2",
+ "@types/react-grid-layout": "^2.1.0",
"@types/react-highlight-words": "^0.16.7",
"@types/react-redux": "7.1.9",
"@types/react-transition-group": "4.4.0",
@@ -186,7 +188,7 @@
},
{
"path": "dist/assets/index-*.js",
- "maxSize": "3MB",
+ "maxSize": "3.5MB",
"compression": "none"
},
{
diff --git a/public/assets/icon-notebook.svg b/public/assets/icon-notebook.svg
new file mode 100644
index 000000000..61f8e88bc
--- /dev/null
+++ b/public/assets/icon-notebook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/AIStatusIndicator/AssistantModes.tsx b/src/components/AIStatusIndicator/AssistantModes.tsx
index f2b2bf51c..35a592041 100644
--- a/src/components/AIStatusIndicator/AssistantModes.tsx
+++ b/src/components/AIStatusIndicator/AssistantModes.tsx
@@ -82,6 +82,7 @@ export const getIsExpandableSection = (section: OperationSection) => {
AIOperationStatus.InvestigatingTable,
AIOperationStatus.InvestigatingDocs,
AIOperationStatus.Thinking,
+ AIOperationStatus.RunningQuery,
].includes(section.type)
}
diff --git a/src/components/AIStatusIndicator/AssistantModesCompact.tsx b/src/components/AIStatusIndicator/AssistantModesCompact.tsx
index 9344e885b..345e8480a 100644
--- a/src/components/AIStatusIndicator/AssistantModesCompact.tsx
+++ b/src/components/AIStatusIndicator/AssistantModesCompact.tsx
@@ -361,10 +361,12 @@ export const AssistantModesCompact: React.FC = ({
nextSection,
!nextSection ? endTimestamp : undefined,
)
- const thinkingSegmentText =
- section.type === AIOperationStatus.Thinking
- ? (section.operations[0]?.content ?? "")
- : ""
+ const isContentSection =
+ section.type === AIOperationStatus.Thinking ||
+ section.type === AIOperationStatus.RunningQuery
+ const sectionContentText = isContentSection
+ ? (section.operations[0]?.content ?? "")
+ : ""
return (
= ({
)}
- {isExpandable && section.type === AIOperationStatus.Thinking && (
+ {isExpandable && isContentSection && (
- {thinkingSegmentText ? (
- {thinkingSegmentText}
+ {sectionContentText ? (
+ {sectionContentText}
) : null}
)}
- {isExpandable && section.type !== AIOperationStatus.Thinking && (
+ {isExpandable && !isContentSection && (
diff --git a/src/components/Button/skin.ts b/src/components/Button/skin.ts
index 600e6ecda..1b4580f54 100644
--- a/src/components/Button/skin.ts
+++ b/src/components/Button/skin.ts
@@ -13,6 +13,7 @@ export const skins = [
"secondary",
"success",
"error",
+ "danger",
"warning",
"transparent",
"gradient",
@@ -114,6 +115,23 @@ const themes: {
color: "gray1",
},
},
+ danger: {
+ normal: {
+ background: "dangerBackground",
+ border: "transparent",
+ color: "dangerForeground",
+ },
+ hover: {
+ background: "dangerBackgroundHover",
+ border: "transparent",
+ color: "dangerForeground",
+ },
+ disabled: {
+ background: "selection",
+ border: "gray1",
+ color: "gray1",
+ },
+ },
warning: {
normal: {
background: "selection",
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
index 8253d8f29..cf1c204b8 100644
--- a/src/components/ContextMenu/index.tsx
+++ b/src/components/ContextMenu/index.tsx
@@ -3,29 +3,28 @@ import styled from "styled-components"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
const StyledContent = styled(ContextMenuPrimitive.Content)`
- background-color: #343846; /* vscode-menu-background */
+ background-color: ${({ theme }) => theme.color.backgroundDarker};
border-radius: 0.5rem;
padding: 0.4rem;
- box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); /* vscode-widget-shadow */
+ box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36);
z-index: 9999;
- min-width: 160px;
+ min-width: 16rem;
`
const StyledItem = styled(ContextMenuPrimitive.Item)`
- font-size: 1.3rem;
- height: 3rem;
+ font-size: 1.4rem;
font-family: "system-ui", sans-serif;
cursor: pointer;
- color: rgb(248, 248, 242); /* vscode-menu-foreground */
+ color: ${({ theme }) => theme.color.foreground};
display: flex;
+ gap: 1rem;
+ min-height: 3rem;
align-items: center;
- padding: 1rem 1.2rem;
+ padding: 0.5rem 1rem;
border-radius: 0.4rem;
- border: 1px solid transparent;
&[data-highlighted] {
- background: #043c5c;
- border: 1px solid #8be9fd;
+ background: ${({ theme }) => theme.color.tableSelection};
}
&[data-disabled] {
@@ -35,7 +34,6 @@ const StyledItem = styled(ContextMenuPrimitive.Item)`
`
const IconWrapper = styled.span`
- margin-right: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/components/CopyButton/index.tsx b/src/components/CopyButton/index.tsx
index 1ad8ff249..9b6381fc5 100644
--- a/src/components/CopyButton/index.tsx
+++ b/src/components/CopyButton/index.tsx
@@ -12,7 +12,9 @@ const StyledButton = styled(Button)`
const StyledCheckboxCircle = styled(CheckboxCircle)`
position: absolute;
- transform: translate(75%, -75%);
+ top: 0;
+ right: 0;
+ transform: translate(50%, -50%);
color: ${({ theme }) => theme.color.green};
`
@@ -44,6 +46,7 @@ export const CopyButton = ({
skin="secondary"
size={size}
data-hook="copy-value"
+ data-copied={copied || undefined}
title="Copy to clipboard"
onClick={(e: React.MouseEvent) => {
void copyToClipboard(text)
@@ -57,9 +60,7 @@ export const CopyButton = ({
})}
{...props}
>
- {copied && (
-
- )}
+ {copied && }
{iconOnly ? : "Copy"}
)
diff --git a/src/components/DropdownMenu/index.tsx b/src/components/DropdownMenu/index.tsx
index 63022fa63..9f7e887c5 100644
--- a/src/components/DropdownMenu/index.tsx
+++ b/src/components/DropdownMenu/index.tsx
@@ -11,13 +11,13 @@ export const DropdownMenu = {
Portal: styled(RadixDropdownMenu.Portal)``,
Content: styled(RadixDropdownMenu.Content)`
- display: grid;
- gap: 0.2rem;
- min-width: 22rem;
- background: ${({ theme }) => theme.color.backgroundLighter};
- border-radius: ${({ theme }) => theme.borderRadius};
- box-shadow: 0 5px 5px 0 ${({ theme }) => theme.color.black40};
- padding: 0.5rem 0;
+ background-color: ${({ theme }) => theme.color.backgroundDarker};
+ border-radius: 0.5rem;
+ border: 1px solid ${({ theme }) => theme.color.baseGrey};
+ padding: 0.4rem;
+ box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36);
+ z-index: 9999;
+ min-width: 16rem;
`,
Arrow: styled(RadixDropdownMenu.Arrow)`
@@ -25,29 +25,31 @@ export const DropdownMenu = {
`,
Item: styled(RadixDropdownMenu.Item)`
- border-radius: 3px;
+ font-size: 1.4rem;
+ cursor: pointer;
+ color: ${({ theme }) => theme.color.foreground};
display: flex;
- gap: 1.5rem;
+ gap: 1rem;
+ min-height: 3rem;
align-items: center;
padding: 0.5rem 1rem;
- margin: 0 0.5rem;
+ border-radius: 0.4rem;
user-select: none;
outline: none;
- &[data-disabled] {
- pointer-events: none;
- opacity: 0.8;
+ &[data-highlighted] {
+ background: ${({ theme }) => theme.color.tableSelection};
}
- &:focus {
- background: ${({ theme }) => theme.color.comment};
- cursor: pointer;
+ &[data-disabled] {
+ opacity: 0.5;
+ pointer-events: none;
}
`,
Divider: styled.div`
height: 1px;
background: ${({ theme }) => theme.color.selection};
- margin: 0.5rem 0;
+ margin: 0.3rem 0;
`,
}
diff --git a/src/components/ExplainQueryButton/index.tsx b/src/components/ExplainQueryButton/index.tsx
index 8c07d9a10..580829c7c 100644
--- a/src/components/ExplainQueryButton/index.tsx
+++ b/src/components/ExplainQueryButton/index.tsx
@@ -7,7 +7,7 @@ import { AISparkle } from "../AISparkle"
import { QuestContext } from "../../providers"
import { selectors } from "../../store"
import { useAIStatus } from "../../providers/AIStatusProvider"
-import { useAIConversation } from "../../providers/AIConversationProvider"
+import { useAIConversationActions } from "../../providers/AIConversationProvider"
import type { ConversationId } from "../../providers/AIConversationProvider/types"
import {
executeAIFlow,
@@ -54,7 +54,7 @@ export const ExplainQueryButton = ({
updateConversationName,
persistMessages,
setIsStreaming,
- } = useAIConversation()
+ } = useAIConversationActions()
const handleExplainQuery = () => {
void trackEvent(ConsoleEvent.AI_EXPLAIN_QUERY)
diff --git a/src/components/HighlightedSql/index.tsx b/src/components/HighlightedSql/index.tsx
new file mode 100644
index 000000000..5b08a925b
--- /dev/null
+++ b/src/components/HighlightedSql/index.tsx
@@ -0,0 +1,42 @@
+import React, { useEffect, useState } from "react"
+import styled from "styled-components"
+import { monacoPromise } from "../../utils/monacoInit"
+import { QuestDBLanguageName } from "../../scenes/Editor/Monaco/utils"
+
+type Props = {
+ code: string
+ language?: string
+ className?: string
+}
+
+const Pre = styled.pre`
+ white-space: pre-wrap;
+ overflow-wrap: normal;
+ word-break: normal;
+`
+
+export const HighlightedSql: React.FC = ({
+ code,
+ language = QuestDBLanguageName,
+ className,
+}) => {
+ const [html, setHtml] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+ void monacoPromise
+ .then((monaco) => monaco.editor.colorize(code, language, {}))
+ .then((colorized) => {
+ if (!cancelled) setHtml(colorized)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [code, language])
+
+ return html === null ? (
+ {code}
+ ) : (
+
+ )
+}
diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx
index 9f17b5e32..c1d14b0b9 100644
--- a/src/components/Input/index.tsx
+++ b/src/components/Input/index.tsx
@@ -10,6 +10,10 @@ type InputProps = React.InputHTMLAttributes & {
const errorStyle = css`
border-color: ${({ theme }) => theme.color.red};
background-color: #ff555515;
+ &:focus {
+ border-color: ${({ theme }) => theme.color.red};
+ background: #ff555515;
+ }
`
export const Input = styled.input.attrs((props) => ({
diff --git a/src/components/Markdown/SafeMarkdown.test.tsx b/src/components/Markdown/SafeMarkdown.test.tsx
new file mode 100644
index 000000000..d06353458
--- /dev/null
+++ b/src/components/Markdown/SafeMarkdown.test.tsx
@@ -0,0 +1,59 @@
+import React from "react"
+import { renderToStaticMarkup } from "react-dom/server"
+import { SafeMarkdown } from "./SafeMarkdown"
+
+const render = (markdown: string, allowImages = false) =>
+ renderToStaticMarkup(
+ {markdown},
+ )
+
+describe("SafeMarkdown", () => {
+ it("never renders images by default", () => {
+ const html = render("")
+ expect(html).not.toContain("
{
+ expect(render("", true)).toContain("
{
+ expect(render(")", true)).not.toContain("
{
+ const html = render("")
+ // Raw HTML is escaped to inert text, never a live element.
+ expect(html).not.toContain("