Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/questdb
Submodule questdb updated 164 files
13 changes: 12 additions & 1 deletion src/components/McpSetupCommand/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { CopyButton } from "../CopyButton"
import { CopyCommand } from "../icons/copy-command"
import { EXPECTED_BRIDGE_VERSION } from "../../utils/mcp/protocolVersion"
import { color } from "../../utils"
import { trackEvent } from "../../modules/ConsoleEventTracker"
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"

export const SETUP_COMMAND = `npx @questdb/mcp-bridge@${EXPECTED_BRIDGE_VERSION} setup`

Expand Down Expand Up @@ -46,7 +48,13 @@ const CopyCommandButton = styled(CopyButton)`
}
`

export const McpSetupCommand = ({ iconSize = 16 }: { iconSize?: number }) => (
export const McpSetupCommand = ({
iconSize = 16,
source,
}: {
iconSize?: number
source: "promo" | "onboarding"
}) => (
<>
<Code>
npx <Package>@questdb/mcp-bridge@{EXPECTED_BRIDGE_VERSION}</Package> setup
Expand All @@ -57,6 +65,9 @@ export const McpSetupCommand = ({ iconSize = 16 }: { iconSize?: number }) => (
size="sm"
text={SETUP_COMMAND}
icon={<CopyCommand size={iconSize} />}
onCopy={() =>
void trackEvent(ConsoleEvent.MCP_SETUP_COMMAND_COPY, { source })
}
/>
</>
)
2 changes: 1 addition & 1 deletion src/components/NotebookOnboardingModal/Step2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const Step2 = ({
seamlessly
</Instruction>
<CommandBox>
<McpSetupCommand />
<McpSetupCommand source="onboarding" />
</CommandBox>
</CommandGroup>

Expand Down
27 changes: 24 additions & 3 deletions src/components/NotebookOnboardingModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { useLocalStorage } from "../../providers/LocalStorageProvider"
import { useEditor } from "../../providers/EditorProvider"
import { createDefaultNotebookViewState } from "../../store/notebook"
import { shouldShowNotebookModal } from "../../utils/notebookOnboarding"
import { trackEvent } from "../../modules/ConsoleEventTracker"
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"
import { PRIMARY_BUTTON_HOOK } from "./shared"
import { Step1 } from "./Step1"
import { Step2 } from "./Step2"
Expand Down Expand Up @@ -134,6 +136,7 @@ export const NotebookOnboardingModal = () => {

useEffect(() => {
if (open) {
void trackEvent(ConsoleEvent.NOTEBOOK_ONBOARDING_OPEN)
updateNotebookOnboarding({ showNotebookPromo: false })
}
}, [open, updateNotebookOnboarding])
Expand All @@ -147,6 +150,10 @@ export const NotebookOnboardingModal = () => {
}, [open, step])

const createNotebook = () => {
void trackEvent(ConsoleEvent.NOTEBOOK_CREATE, {
source: "onboarding_modal",
step,
})
updateNotebookOnboarding({ showMcpPromo: false })
void addBuffer({ notebookViewState: createDefaultNotebookViewState() })
setOpen(false)
Expand All @@ -156,7 +163,10 @@ export const NotebookOnboardingModal = () => {
<RadixDialog.Root
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) setOpen(false)
if (!isOpen) {
void trackEvent(ConsoleEvent.NOTEBOOK_ONBOARDING_CLOSE, { step })
setOpen(false)
}
}}
>
<RadixDialog.Portal>
Expand All @@ -172,13 +182,24 @@ export const NotebookOnboardingModal = () => {
<HiddenTitle>
{step === 0 ? "Introducing Notebooks" : "QuestDB MCP Connection"}
</HiddenTitle>
<CloseButton title="Close" onClick={() => setOpen(false)}>
<CloseButton
title="Close"
onClick={() => {
void trackEvent(ConsoleEvent.NOTEBOOK_ONBOARDING_CLOSE, { step })
setOpen(false)
}}
>
<XIcon size={16} color="#858585" weight="bold" />
</CloseButton>
{step === 0 ? (
<Step1
onCreateNotebook={createNotebook}
onNext={() => setStep(1)}
onNext={() => {
void trackEvent(ConsoleEvent.NOTEBOOK_ONBOARDING_STEP, {
step: 1,
})
setStep(1)
}}
/>
) : (
<Step2 onCreateNotebook={createNotebook} />
Expand Down
88 changes: 88 additions & 0 deletions src/modules/ConsoleEventTracker/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export enum ConsoleEvent {
SIDEBAR_NAVIGATE = "sidebar.navigate",

TAB_ADD = "tab.add",
TAB_ARCHIVE = "tab.archive",
TAB_IMPORT = "tab.import",
TAB_EXPORT = "tab.export",
TAB_RENAME = "tab.rename",
Expand All @@ -101,4 +102,91 @@ export enum ConsoleEvent {
HELP_FEEDBACK_SUBMIT = "help.feedback_submit",

NEWS_OPEN = "news.open",

NOTEBOOK_CREATE = "notebook.create",
NOTEBOOK_DUPLICATE = "notebook.duplicate",
NOTEBOOK_BUILD_WITH_AI = "notebook.build_with_ai",
NOTEBOOK_LOAD_RETRY = "notebook.load_retry",
NOTEBOOK_LAYOUT_MODE_CHANGE = "notebook.layout_mode_change",
NOTEBOOK_CELL_ADD = "notebook.cell_add",
NOTEBOOK_CELL_LIMIT_REACHED = "notebook.cell_limit_reached",
NOTEBOOK_CELL_DELETE = "notebook.cell_delete",
NOTEBOOK_CELL_DUPLICATE = "notebook.cell_duplicate",
NOTEBOOK_CELL_MOVE = "notebook.cell_move",
NOTEBOOK_CELL_RENAME = "notebook.cell_rename",
NOTEBOOK_CELL_RUN = "notebook.cell_run",
NOTEBOOK_CELL_DRAW = "notebook.cell_draw",
NOTEBOOK_CELL_RUN_CANCEL = "notebook.cell_run_cancel",
NOTEBOOK_CELL_AUTOREFRESH_CHANGE = "notebook.cell_autorefresh_change",
NOTEBOOK_CELL_RESIZE = "notebook.cell_resize",
NOTEBOOK_CELL_EXPAND_WIDTH = "notebook.cell_expand_width",
NOTEBOOK_CELL_SIZE_RESET = "notebook.cell_size_reset",
NOTEBOOK_CELL_VIEW_CHANGE = "notebook.cell_view_change",
NOTEBOOK_CELL_VIEW_MAXIMIZE = "notebook.cell_view_maximize",
NOTEBOOK_CELL_MAXIMIZE = "notebook.cell_maximize",
NOTEBOOK_DRAW_TOGGLE = "notebook.draw_toggle",
NOTEBOOK_DRAW_REFUSED = "notebook.draw_refused",
NOTEBOOK_CHART_SETTINGS_OPEN = "notebook.chart_settings_open",
NOTEBOOK_CHART_SETTINGS_SAVE = "notebook.chart_settings_save",
NOTEBOOK_CHART_SETTINGS_CANCEL = "notebook.chart_settings_cancel",
NOTEBOOK_CHART_SAVE_BLOCKED = "notebook.chart_save_blocked",
NOTEBOOK_CHART_TYPE_CHANGE = "notebook.chart_type_change",
NOTEBOOK_CHART_RESET_AUTO = "notebook.chart_reset_auto",
NOTEBOOK_MARKDOWN_APPLY = "notebook.markdown_apply",
NOTEBOOK_VARIABLES_OPEN = "notebook.variables_open",
NOTEBOOK_VARIABLES_APPLY = "notebook.variables_apply",
NOTEBOOK_VARIABLES_IMPORT = "notebook.variables_import",
NOTEBOOK_RESULT_TAB_SWITCH = "notebook.result_tab_switch",
NOTEBOOK_ONBOARDING_OPEN = "notebook.onboarding_open",
NOTEBOOK_ONBOARDING_STEP = "notebook.onboarding_step",
NOTEBOOK_ONBOARDING_CLOSE = "notebook.onboarding_close",

MCP_ADD_CELL = "mcp.add_cell",
MCP_UPDATE_CELL = "mcp.update_cell",
MCP_DELETE_CELL = "mcp.delete_cell",
MCP_RUN_CELL = "mcp.run_cell",
MCP_RUN_QUERY = "mcp.run_query",
MCP_VALIDATE_QUERY = "mcp.validate_query",
MCP_CREATE_NOTEBOOK = "mcp.create_notebook",
MCP_ACTIVATE_NOTEBOOK = "mcp.activate_notebook",
MCP_DUPLICATE_NOTEBOOK = "mcp.duplicate_notebook",
MCP_DELETE_NOTEBOOK = "mcp.delete_notebook",
MCP_LIST_CELLS = "mcp.list_cells",
MCP_GET_CELL = "mcp.get_cell",
MCP_GET_NOTEBOOK_STATE = "mcp.get_notebook_state",
MCP_MOVE_CELL_UP = "mcp.move_cell_up",
MCP_MOVE_CELL_DOWN = "mcp.move_cell_down",
MCP_DUPLICATE_CELL = "mcp.duplicate_cell",
MCP_SET_LAYOUT_MODE = "mcp.set_layout_mode",
MCP_SET_CELL_LAYOUT = "mcp.set_cell_layout",
MCP_SET_CELL_MODE = "mcp.set_cell_mode",
MCP_SET_CELL_CHART_CONFIG = "mcp.set_cell_chart_config",
MCP_SET_CELL_AUTOREFRESH = "mcp.set_cell_autorefresh",
MCP_SET_CELL_NAME = "mcp.set_cell_name",
MCP_SET_CELL_VIEW_MAXIMIZED = "mcp.set_cell_view_maximized",
MCP_SET_CELL_MAXIMIZED = "mcp.set_cell_maximized",
MCP_APPLY_NOTEBOOK_STATE = "mcp.apply_notebook_state",
MCP_GET_TABLES = "mcp.get_tables",
MCP_GET_TABLE_SCHEMA = "mcp.get_table_schema",
MCP_GET_TABLE_DETAILS = "mcp.get_table_details",
MCP_GET_QUESTDB_TOC = "mcp.get_questdb_toc",
MCP_GET_QUESTDB_DOCUMENTATION = "mcp.get_questdb_documentation",
MCP_GET_WORKSPACE_STATE = "mcp.get_workspace_state",
MCP_GET_RECENT_USER_ACTIONS = "mcp.get_recent_user_actions",
MCP_UNKNOWN_TOOL = "mcp.unknown_tool",

MCP_PAIRING_PROMPTED = "mcp.pairing_prompted",
MCP_PAIRING_ACCEPTED = "mcp.pairing_accepted",
MCP_PAIRING_DECLINED = "mcp.pairing_declined",
MCP_MANUAL_PAIR_SUBMIT = "mcp.manual_pair_submit",
MCP_CONNECTED = "mcp.connected",
MCP_CONNECT_FAILED = "mcp.connect_failed",
MCP_DISCONNECTED = "mcp.disconnected",
MCP_DISCONNECT_CLICK = "mcp.disconnect_click",
MCP_PERMISSIONS_CHANGE = "mcp.permissions_change",

MCP_SETUP_COMMAND_COPY = "mcp.setup_command_copy",
MCP_AGENT_CHANGES_VIEW = "mcp.agent_changes_view",
MCP_AGENT_CHANGES_DISMISS = "mcp.agent_changes_dismiss",
MCP_STATUS_POPOVER_OPEN = "mcp.status_popover_open",
}
164 changes: 164 additions & 0 deletions src/modules/ConsoleEventTracker/mcpToolEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest"
import { ConsoleEvent } from "./events"
import {
applyNotebookStateLayoutMode,
classifyToolResult,
mcpToolCallEvent,
permissionLevelOf,
} from "./mcpToolEvents"
import {
INVALID_BUFFER_ID_MESSAGE,
STATE_NOT_FETCHED_MESSAGE,
STATE_STALE_MESSAGE,
} from "../../utils/notebooks/notebookToolMessages"
import { mcpTools } from "../../utils/tools/tools"

const textResult = (text: string, isError = true) => ({
content: [{ type: "text", text }],
isError,
})

describe("mcpToolCallEvent", () => {
it("maps known tool names to their enum member", () => {
expect(mcpToolCallEvent("add_cell")).toBe(ConsoleEvent.MCP_ADD_CELL)
expect(mcpToolCallEvent("apply_notebook_state")).toBe(
ConsoleEvent.MCP_APPLY_NOTEBOOK_STATE,
)
expect(mcpToolCallEvent("get_workspace_state")).toBe(
ConsoleEvent.MCP_GET_WORKSPACE_STATE,
)
expect(mcpToolCallEvent("get_recent_user_actions")).toBe(
ConsoleEvent.MCP_GET_RECENT_USER_ACTIONS,
)
})

it("maps every advertised MCP tool", () => {
expect(
mcpTools
.map((tool) => tool.name)
.filter((name) => mcpToolCallEvent(name) === null),
).toEqual([])
})

it("does not treat MCP lifecycle events as tool calls", () => {
expect(mcpToolCallEvent("connected")).toBeNull()
expect(mcpToolCallEvent("pairing_accepted")).toBeNull()
expect(mcpToolCallEvent("unknown_tool")).toBeNull()
})

it("returns null for unmapped tool names", () => {
expect(mcpToolCallEvent("brand_new_tool")).toBeNull()
expect(mcpToolCallEvent("")).toBeNull()
})
})

describe("classifyToolResult", () => {
it("classifies success", () => {
expect(classifyToolResult(textResult('{"ok":true}', false))).toEqual({
outcome: "ok",
})
})

it("classifies generic tool errors", () => {
expect(classifyToolResult(textResult("something broke"))).toEqual({
outcome: "tool_error",
})
})

it("classifies schema-access denials", () => {
const result = textResult(
"PERMISSION_DENIED: tool 'get_tables' requires the 'grantSchemaAccess' permission. " +
"Ask the user to grant it in the QuestDB console (footer → MCP popover or AI Assistant settings).",
)
expect(classifyToolResult(result)).toEqual({
outcome: "denied",
reasonCode: "schema_access",
})
})

it("classifies write-SQL denials", () => {
const result = textResult(
"PERMISSION_DENIED: this SQL is 'INSERT' (write operation) and " +
"requires the 'write' permission. Ask the user to grant it in the " +
"QuestDB console (footer → MCP popover or AI Assistant settings).",
)
expect(classifyToolResult(result)).toEqual({
outcome: "denied",
reasonCode: "write_sql",
})
})

it("classifies other PERMISSION_DENIED messages without a reasonCode", () => {
const result = textResult(
"PERMISSION_DENIED: this SQL needs the 'read' permission to execute.",
)
expect(classifyToolResult(result)).toEqual({ outcome: "denied" })
})

it("classifies freshness-gate denials, including with an appended digest block", () => {
expect(classifyToolResult(textResult(STATE_STALE_MESSAGE))).toEqual({
outcome: "denied",
reasonCode: "stale",
})
expect(classifyToolResult(textResult(STATE_NOT_FETCHED_MESSAGE))).toEqual({
outcome: "denied",
reasonCode: "not_fetched",
})
expect(
classifyToolResult(
textResult(
`${INVALID_BUFFER_ID_MESSAGE}\n\n<since_last_check>\n active_buffer: { id: 3 }\n</since_last_check>`,
),
),
).toEqual({ outcome: "denied", reasonCode: "invalid_buffer_id" })
})

it("never treats a stale JSON tool error as a gate denial", () => {
const result = textResult(
JSON.stringify({
error_code: "stale",
message: "STATE_STALE: mid-apply",
}),
)
expect(classifyToolResult(result)).toEqual({ outcome: "tool_error" })
})
})

describe("applyNotebookStateLayoutMode", () => {
it("extracts a valid layout_mode from the tool input", () => {
expect(applyNotebookStateLayoutMode({ layout_mode: "grid" })).toEqual({
layoutMode: "grid",
})
expect(applyNotebookStateLayoutMode({ layout_mode: "list" })).toEqual({
layoutMode: "list",
})
})

it("returns an empty payload when absent or invalid", () => {
expect(applyNotebookStateLayoutMode({ buffer_id: 1 })).toEqual({})
expect(applyNotebookStateLayoutMode({ layout_mode: "weird" })).toEqual({})
expect(applyNotebookStateLayoutMode(undefined)).toEqual({})
expect(applyNotebookStateLayoutMode(null)).toEqual({})
})
})

describe("permissionLevelOf", () => {
it("encodes the permission hierarchy", () => {
expect(
permissionLevelOf({ grantSchemaAccess: true, read: true, write: true }),
).toBe("write")
expect(
permissionLevelOf({ grantSchemaAccess: true, read: true, write: false }),
).toBe("read")
expect(
permissionLevelOf({ grantSchemaAccess: true, read: false, write: false }),
).toBe("schema")
expect(
permissionLevelOf({
grantSchemaAccess: false,
read: false,
write: false,
}),
).toBe("none")
})
})
Loading
Loading