diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts b/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts index 4637c4584c..521ad46107 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.spec.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ readPluginLocalFileChunk: vi.fn(), writePluginLocalFileChunk: vi.fn(), closePluginLocalFile: vi.fn(), + openPluginWorkbench: vi.fn(), })); vi.mock("@/lib/backend/tauri", () => ({ @@ -41,6 +42,7 @@ vi.mock("@/stores/connectionStore", () => ({ reopenPluginConnection: mocks.reopenPluginConnection, }), })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openPluginWorkbench: mocks.openPluginWorkbench }) })); vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) })); import PluginWorkbenchHost from "./PluginWorkbenchHost.vue"; diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index 72fdc34a2e..de489fc0b9 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -20,6 +20,8 @@ import { } from "@/lib/plugins/pluginHostBridge"; import { getCachedPluginUiHtml, getOrLoadPluginUiHtml } from "@/lib/plugins/pluginUiHtmlCache"; import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance"; +import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin"; +import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry"; import { downloadPluginFile, cancelPluginDownload } from "@/lib/plugins/pluginFileDownload"; import type { InstalledPlugin, PluginUiContribution } from "@/types/database"; import { useI18n } from "vue-i18n"; @@ -27,6 +29,7 @@ import { useTheme } from "@/composables/useTheme"; import { useSettingsStore } from "@/stores/settingsStore"; import { useConnectionStore } from "@/stores/connectionStore"; import { createPluginAiCompletion } from "@/lib/plugins/pluginAiCompletion"; +import { useQueryStore } from "@/stores/queryStore"; import { OPEN_PLUGIN_AI_CONVERSATION } from "@/lib/ai/aiPluginConversation"; const props = withDefaults( @@ -400,6 +403,12 @@ function createBridge() { ...(isTauriRuntime() ? aiCompletion : {}), setAiRecommendations: (update) => emit("recommendations", update), openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options), + // §4/§5 bridge command execution, scoped to this plugin's own manifest: + // the single-plugin registry is equivalent here because findCommand / + // findWorkbench / enablement never cross plugins. Panel commands dock, + // tab commands open tabs, §4.1 reuse with `instance_key` placeholder + // scoping — identical to menu execution. + executeCommand: (pluginId, commandId, context) => executePluginCommand(createFrontendPluginRegistry([props.plugin], appLocale.value), useQueryStore(), pluginId, commandId, context), openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context), reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId), // PR-A4 generic extension point: a read-only, secret-free, plugin-scoped connection list (for in-panel connection switching). diff --git a/apps/desktop/src/lib/plugins/pluginAiRecommendations.ts b/apps/desktop/src/lib/plugins/pluginAiRecommendations.ts index f3f34adc39..b5ea148283 100644 --- a/apps/desktop/src/lib/plugins/pluginAiRecommendations.ts +++ b/apps/desktop/src/lib/plugins/pluginAiRecommendations.ts @@ -77,6 +77,26 @@ export function interpolatePluginAiRecommendation(item: PluginAiRecommendation, }; } +/** + * Shared `{{path}}` interpolation (same prototype-safe resolution as + * recommendations) for non-recommendation templates such as command + * `instance_key` scoping. Returns null when any placeholder cannot be + * resolved to a scalar — callers decide the fallback (recommendations drop + * the item; instance keys fall back to the literal template). + */ +export function interpolatePluginTemplate(value: string, context: Record): string | null { + let unresolved = false; + const interpolated = value.replace(PLACEHOLDER, (_match, path: string) => { + const resolved = contextValueText(readContextPath(context, path)); + if (resolved === undefined) { + unresolved = true; + return ""; + } + return resolved; + }); + return unresolved ? null : interpolated; +} + /** * Resolve, order, and cap a list of recommendations for a context. * The input is never mutated and the result contains fresh objects. diff --git a/apps/desktop/src/lib/plugins/pluginCommandRegistry.spec.ts b/apps/desktop/src/lib/plugins/pluginCommandRegistry.spec.ts index 161b36dd8b..9b1ab77fba 100644 --- a/apps/desktop/src/lib/plugins/pluginCommandRegistry.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginCommandRegistry.spec.ts @@ -225,4 +225,51 @@ describe("plugin command registry (PR-A4)", () => { expect(registry.findCommandTargetingWorkbench("io.dbx.ssh", "other.workbench")).toBeUndefined(); expect(registry.findCommandTargetingWorkbench("other.plugin", "io.dbx.ssh.workbench")).toBeUndefined(); }); + + // §4.1 instance_key placeholder scoping: one panel instance per resolved + // connection, re-activation per key, literal fallback when unresolvable. + it("scopes panel instances by instance_key placeholders from the execution context", () => { + const registry = createFrontendPluginRegistry([ + installedPlugin("io.dbx.ldap", [ + { type: "workbench", id: "io.dbx.ldap.logpanel", label: "Logs" }, + { type: "command", id: "open-conn-logs", label: "Connection logs", action: { type: "open-workbench", workbench: "io.dbx.ldap.logpanel", presentation: "panel", reuse: "singleton", instance_key: "logs:{{connectionId}}" } }, + ] as unknown as InstalledPlugin["manifest"]["contributions"]), + ]); + const openPluginWorkbench = vi.fn(); + const { entries, activeEntryId } = usePluginBottomDock(); + + executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ldap", "open-conn-logs", { connectionId: "conn-a" }); + executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ldap", "open-conn-logs", { connectionId: "conn-b" }); + expect(entries.value.map((entry) => entry.instanceKey)).toEqual(["logs:conn-a", "logs:conn-b"]); + + // the same connection re-activates its own instance; caller-only extras do not fork it + executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ldap", "open-conn-logs", { connectionId: "conn-a", caller: "only" }); + expect(entries.value).toHaveLength(2); + expect(activeEntryId.value).toBe(entries.value[0]!.id); + + // unresolvable template (no context) falls back to the literal key + executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ldap", "open-conn-logs"); + expect(entries.value).toHaveLength(3); + expect(entries.value[2]!.instanceKey).toBe("logs:{{connectionId}}"); + expect(openPluginWorkbench).not.toHaveBeenCalled(); + }); + + it("merges caller context over the command context and keeps reserved fields host-owned", () => { + const registry = createFrontendPluginRegistry([ + installedPlugin("io.dbx.ldap", [ + { type: "workbench", id: "io.dbx.ldap.logpanel", label: "Logs" }, + { type: "command", id: "open-logs", label: "Logs", action: { type: "open-workbench", workbench: "io.dbx.ldap.logpanel", presentation: "panel", context: { plugin: { mode: "logs" }, from: "command" } } }, + ] as unknown as InstalledPlugin["manifest"]["contributions"]), + ]); + const { entries } = usePluginBottomDock(); + executePluginCommand(registry, { openPluginWorkbench: vi.fn() } as never, "io.dbx.ldap", "open-logs", { plugin: { mode: "override" }, connectionId: "c1", workbenchId: "plugin-forged", restored: true, surface: "tab" }); + const entry = entries.value[0]!; + expect(entry.context.plugin).toEqual({ mode: "override" }); + expect(entry.context.from).toBe("command"); + expect(entry.context.connectionId).toBe("c1"); + // §11: reserved identity fields are host-authored — forged values never pass. + expect(entry.context.workbenchId).toBe(entry.id); + expect(entry.context.restored).toBe(false); + expect(entry.context.surface).toBe("panel"); + }); }); diff --git a/apps/desktop/src/lib/plugins/pluginCommandRegistry.ts b/apps/desktop/src/lib/plugins/pluginCommandRegistry.ts index 345c340ed9..4f5d3f9b79 100644 --- a/apps/desktop/src/lib/plugins/pluginCommandRegistry.ts +++ b/apps/desktop/src/lib/plugins/pluginCommandRegistry.ts @@ -11,6 +11,7 @@ import * as api from "@/lib/backend/api"; import { uuid } from "@/lib/common/utils"; import { activatePluginDockEntry, addPluginDockEntry, findReusableDockEntry } from "@/lib/plugins/pluginBottomDock"; import { createFrontendPluginRegistry, evaluatePluginCommandConditions, type FrontendPluginRegistry } from "@/lib/plugins/frontendPlugin"; +import { interpolatePluginTemplate } from "@/lib/plugins/pluginAiRecommendations"; import { useQueryStore } from "@/stores/queryStore"; import type { PluginCommandContribution, PluginConditionContextKeys } from "@/types/database"; @@ -31,7 +32,7 @@ export interface PluginCommandExecutionResult { * §5.4 re-validate enablement right before execution (against the current context snapshot; the surface key follows * presentation). */ -export function executePluginCommand(registry: FrontendPluginRegistry, queryStore: ReturnType, pluginId: string, commandId: string): PluginCommandExecutionResult { +export function executePluginCommand(registry: FrontendPluginRegistry, queryStore: ReturnType, pluginId: string, commandId: string, callerContext?: Record): PluginCommandExecutionResult { const command = registry.findCommand(pluginId, commandId)?.contribution; if (!command) return { error: `Unknown command '${pluginId}.${commandId}'` }; const action = command.action; @@ -44,15 +45,28 @@ export function executePluginCommand(registry: FrontendPluginRegistry, queryStor if (!evaluatePluginCommandConditions(command.enablement?.all, commandContextKeys)) { return { error: `Command '${pluginId}.${commandId}' is disabled by enablement` }; } + // Execution context: command-declared payload first, caller context (bridge + // executeCommand) over it; reserved identity fields (§11) are stripped from + // the caller — the host owns workbenchId/restored/surface on every surface. + const callerPayload = callerContext && typeof callerContext === "object" && !Array.isArray(callerContext) ? { ...callerContext } : {}; + delete callerPayload.workbenchId; + delete callerPayload.restored; + delete callerPayload.surface; + const executionContext = { ...(action.context ?? {}), ...callerPayload }; // presentation: panel -> adds an entry to the global bottom dock (§8.3); tab (default) // -> opens a workbench tab. if (action.presentation === "panel") { // §4.1: singleton (default) re-activates the existing panel instance // (reuse key = pluginId + commandId + presentation + instance_key); // reuse:"new" always spawns one. The dock "+" menu bypasses this path by - // design — it is the explicit multi-open affordance. + // design — it is the explicit multi-open affordance. `instance_key` + // supports the shared `{{path}}` placeholders resolved against the + // execution context, so `logs:{{connectionId}}` scopes one panel instance + // per connection; an unresolvable template falls back to its literal (a + // manifest without placeholders behaves exactly as before). + const instanceKey = action.instance_key ? (interpolatePluginTemplate(action.instance_key, executionContext) ?? action.instance_key) : undefined; if (action.reuse !== "new") { - const existing = findReusableDockEntry(pluginId, command.id, action.instance_key); + const existing = findReusableDockEntry(pluginId, command.id, instanceKey); if (existing) { activatePluginDockEntry(existing.id); return {}; @@ -63,17 +77,17 @@ export function executePluginCommand(registry: FrontendPluginRegistry, queryStor workbenchContributionId: action.workbench, kind: "command", commandId: command.id, - instanceKey: action.instance_key, + instanceKey, title: command.label, icon: command.icon, - commandContext: action.context ?? {}, + commandContext: executionContext, }); return {}; } - // Host-authored context: rebuilt from action.context — plugin-supplied reserved fields + // Host-authored context: built from the execution context — plugin-supplied reserved fields // (workbenchId/restored/surface) never pass through; the workbenchId is generated by the host. const context = { - ...(action.context ?? {}), + ...executionContext, workbenchId: uuid(), restored: false, surface: "tab", diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index 38bf6c6d72..3053cb12d3 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -437,6 +437,68 @@ describe("PluginHostBridge", () => { expect(openWorkbench).toHaveBeenNthCalledWith(2, "sample", "sample.other", undefined, { forceNew: false }); }); + it("routes host.executeCommand to the surface handler scoped to the calling plugin", async () => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const executeCommand = vi.fn(() => ({ error: "Command 'sample.open-panel' is disabled by enablement" })); + const bridge = new PluginHostBridge(plugin(["host.workbench"]), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + executeCommand, + }); + bridge.handleWindowMessage({ + source: target, + data: { source: "dbx-plugin", version: 1, type: "request", id: "3", method: "host.executeCommand", params: { commandId: "open-panel", context: { connectionId: "c1" } } }, + } as MessageEvent); + await vi.waitFor(() => expect(messages).toHaveLength(1)); + expect(executeCommand).toHaveBeenCalledWith("sample", "open-panel", { connectionId: "c1" }); + // Expected business outcomes resolve as a result (no rejection) so webviews + // can surface a notice instead of catching. + expect(messages[0]).toMatchObject({ id: "3", result: { error: "Command 'sample.open-panel' is disabled by enablement" } }); + }); + + it("normalizes a void executeCommand handler to an empty result object", async () => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const executeCommand = vi.fn(); + const bridge = new PluginHostBridge(plugin(["host.workbench"]), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + executeCommand, + }); + bridge.handleWindowMessage({ + source: target, + data: { source: "dbx-plugin", version: 1, type: "request", id: "4", method: "host.executeCommand", params: { commandId: "open-panel" } }, + } as MessageEvent); + await vi.waitFor(() => expect(messages).toHaveLength(1)); + expect(executeCommand).toHaveBeenCalledWith("sample", "open-panel", undefined); + expect(messages[0]).toMatchObject({ id: "4", result: {} }); + }); + + it("host.executeCommand requires the host.workbench permission", async () => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + const executeCommand = vi.fn(); + const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + executeCommand, + }); + bridge.handleWindowMessage({ + source: target, + data: { source: "dbx-plugin", version: 1, type: "request", id: "5", method: "host.executeCommand", params: { commandId: "open-panel" } }, + } as MessageEvent); + await vi.waitFor(() => expect(messages).toHaveLength(1)); + expect(messages[0]).toMatchObject({ id: "5", error: "Plugin has not declared permission 'host.workbench'" }); + expect(executeCommand).not.toHaveBeenCalled(); + }); + it("reopens a plugin connection through host.reopenConnection", async () => { const messages: unknown[] = []; const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index 1d2ea19436..f08cc0e6cb 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -162,6 +162,17 @@ export interface PluginHostBridgeApi { openAiConversation?(request: AiPluginConversationRequest): Promise; setAiRecommendations?(update: PluginAiRecommendationHostUpdate): void; openWorkbench?(pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }): Promise | void; + /** + * §4/§5 command execution asked from a plugin webview: the same registry + * path as menu execution — enablement (§5.4), §4.1 singleton reuse, + * host-authored context — scoped to the calling plugin's own declared + * commands. The optional context merges over the command-declared context + * (caller wins; reserved identity fields are host-owned) and feeds + * `instance_key` `{{path}}` placeholders, so per-connection instances are + * one `logs:{{connectionId}}` declaration away. Resolves `{ error }` for + * expected business outcomes; only bridge or permission failures reject. + */ + executeCommand?(pluginId: string, commandId: string, context?: Record): Promise<{ error?: string } | null | void> | { error?: string } | null | void; openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise | void; /** Explicit user-triggered reconnect of an owned plugin connection (full flow, interactive password prompt allowed). */ reopenConnection?(pluginId: string, connectionId: string): Promise; @@ -663,6 +674,12 @@ export class PluginHostBridge { await this.api.openWorkbench(this.plugin.manifest.id, requireProtocolName(input.contributionId, "workbench contribution"), isRecord(input.context) ? input.context : undefined, { forceNew: input.forceNew === true }); return null; } + if (method === "host.executeCommand") { + this.requirePermission("host.workbench"); + if (!this.api.executeCommand) throw new Error("Host command execution is unavailable"); + const input = requireRecord(params, "host.executeCommand params"); + return (await this.api.executeCommand(this.plugin.manifest.id, requireProtocolName(input.commandId, "command id"), isRecord(input.context) ? input.context : undefined)) ?? {}; + } if (method === "host.reopenConnection") { if (!this.api.reopenConnection) throw new Error("Connection reopen is unavailable"); const input = requireRecord(params, "host.reopenConnection params"); @@ -1209,6 +1226,15 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string { return URL.createObjectURL(new Blob([decode(asset.dataBase64)], { type: asset.contentType })); }, openWorkbench: (contributionId, childContext, options) => request('host.openWorkbench', { contributionId, context: childContext, forceNew: !!(options && options.forceNew) }), + // §4/§5 command execution from a webview — the same registry path a menu + // placement takes (enablement, §4.1 reuse, host-authored context), + // scoped to the plugin's own declared commands. The optional context + // merges over the command context and scopes instance_key placeholders + // (e.g. one panel per connection). Resolves { error } for expected + // business outcomes so callers can surface a notice without try/catch. + // NOTE: this block is the sandbox bootstrap template source — comments + // here must not contain backticks or dollar-brace interpolation. + executeCommand: (commandId, context) => request('host.executeCommand', { commandId, context }), openFilesystem: (providerId, childContext) => request('host.openFilesystem', { providerId, context: childContext }), reopenConnection: (connectionId) => request('host.reopenConnection', { connectionId }), // Estimated plans only: mode must be sent explicitly so a plugin states diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index e2d9c7ed5e..55fac9ce8f 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -350,6 +350,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `sendBinary(channel, data)` / `onBinary(fn)` | 发送或接收二进制数据;需要 `stdio-framed` 和 `host.binary`。 | | `readAsset(path)` / `readAssetUrl(path)` | 读取包内资源;路径不能越出插件资源根目录。 | | `openWorkbench(id, context)` | 打开本插件的另一个工作台;需要 `host.workbench`。 | +| `executeCommand(commandId, context?)` | 执行本插件自己声明的命令,与菜单执行完全同径——重新检查 `enablement`、按单例复用,并遵循声明的 `presentation`(panel 停靠底部面板,tab 打开工作台标签页)。`context` 合并到命令上下文之上,并解析 `instance_key` 的 `{{path}}` 占位符(如 `instance_key: "logs:{{connectionId}}"` 即每个连接一个面板实例)。预期结果通过 `{ error }` 返回;需要 `host.workbench`。 | | `openFilesystem(id, context)` | 打开本插件的文件系统入口;需要 `host.filesystem`。 | | `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 | | `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 | diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index b4956c460c..e46eff1e50 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -350,6 +350,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `sendBinary(channel, data)` / `onBinary(fn)` | Sends or receives binary data; requires framed transport and `host.binary`. | | `readAsset(path)` / `readAssetUrl(path)` | Reads a packaged asset within the plugin resource root. | | `openWorkbench(id, context)` | Opens another workbench from this plugin; requires `host.workbench`. | +| `executeCommand(commandId, context?)` | Executes one of this plugin's own declared commands through the same path as a menu placement — re-checks `enablement`, applies singleton reuse, and follows the declared `presentation` (panel commands dock, tab commands open tabs). `context` merges over the command context and scopes `instance_key` `{{path}}` placeholders, so `instance_key: "logs:{{connectionId}}"` yields one panel instance per connection. Resolves `{ error }` for expected outcomes; requires `host.workbench`. | | `openFilesystem(id, context)` | Opens a filesystem entry; requires `host.filesystem`. | | `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. | | `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. | diff --git a/plugins/README.md b/plugins/README.md index fd610dcb56..72933b0eb3 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -369,6 +369,7 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san - `sendBinary(channel, data)` — requires `host.binary` - `readAsset(path)` / `readAssetUrl(path)` - `openWorkbench(contributionId, context)` — requires `host.workbench` +- `executeCommand(commandId, context?)` — executes one of this plugin's own declared commands through the same path a menu placement takes: `enablement` is re-checked, `presentation: "panel"` commands dock in the bottom panel and `presentation: "tab"` (default) commands open a workbench tab, with §4.1 singleton reuse. `context` merges over the command-declared context (reserved identity fields stay host-owned) and scopes `instance_key` `{{path}}` placeholders — declare `instance_key: "logs:{{connectionId}}"` to get one panel instance per connection; a template that cannot resolve falls back to its literal. Resolves `{ error }` for expected outcomes (unknown command, enablement-blocked); requires `host.workbench` - `openFilesystem(providerId, context)` — requires `host.filesystem` - `getPlanCapabilities(connectionId)` / `explainPlan(request)` — reads an estimated execution plan for one connection; requires `host.plans:read`, see [Estimated execution plans](#estimated-execution-plans) - `getTableMetadata({ connectionId, database?, schema?, table })` — reads narrow schema metadata for one table on an already-open connection; requires `host.schema:read`, see [Table Schema Metadata](#table-schema-metadata)