diff --git a/CLI-MCP/ToolCatalog.swift b/CLI-MCP/ToolCatalog.swift index 32972d8a..bb19719e 100644 --- a/CLI-MCP/ToolCatalog.swift +++ b/CLI-MCP/ToolCatalog.swift @@ -52,7 +52,7 @@ struct ProgramaTool { } /// JSON Schema building blocks shared by every `*Tools.swift` file. Kept minimal on purpose -- -/// only the shapes this catalog's 96 tools actually need (see the escape hatch in the Phase 3 +/// only the shapes this catalog's 187 tools actually need (see the escape hatch in the Phase 3 /// briefing for what to do if a handler needs something richer). enum ProgramaToolSchema { static func string(_ description: String) -> Value { @@ -161,11 +161,13 @@ enum ToolCatalog { /// The full, ordered tool catalog. `ListTools` and `CallTool` are both driven off this one /// table (see `register(on:)`), so they cannot drift apart. /// + /// `browser.*` (85 methods) is exposed via `BrowserTools.swift` (82 tools) plus the three + /// focus-stealing browser methods in `FocusTools.swift` (`browser.focus_webview`, + /// `browser.focus`, `browser.tab.switch`). + /// /// Deliberately excludes (see `docs/plans/mcp-server.md` ยง3 and the Phase 3 briefing for /// the authoritative rationale, restated here so a future reader doesn't mistake these for /// oversights): - /// - `browser.*` (85 methods): a separate Playwright-style browser-automation surface, - /// deferred to a future tranche. /// - `debug.*`: DEBUG-build-only test-harness hooks that can simulate keystrokes and /// activate the app. /// - `auth.login`, `settings.open`, `feedback.open`, `feedback.submit`, `markdown.open`: @@ -185,6 +187,7 @@ enum ToolCatalog { + NotificationTools.tools + ReviewTools.tools + FocusTools.tools + + BrowserTools.tools /// Installs both the `ListTools` and `CallTool` method handlers, dispatching every call /// through `MCPSocketBridge` by tool name. Both handlers close over the same `all` table diff --git a/CLI-MCP/Tools/BrowserTools.swift b/CLI-MCP/Tools/BrowserTools.swift new file mode 100644 index 00000000..09d6f76e --- /dev/null +++ b/CLI-MCP/Tools/BrowserTools.swift @@ -0,0 +1,1118 @@ +import MCP + +/// `browser.*` tools other than the three focus-stealing browser methods (`browser.focus_webview`, +/// `browser.focus`, `browser.tab.switch` -- see `FocusTools.swift`). Handlers live in +/// `Sources/TerminalController+BrowserAutomation.swift`; the socket dispatch table is +/// `Sources/BrowserRPCDispatcher.swift`. +/// +/// The embedded browser is a per-workspace WKWebView, not a Chromium/CDP surface, so several +/// Playwright-shaped methods here (`viewport.set`, `geolocation.set`, `offline.set`, +/// `trace.start`/`stop`, `network.route`/`unroute`/`requests`, `screencast.start`/`stop`, +/// `input_mouse`/`input_keyboard`/`input_touch`) are always-`not_supported` stubs on this +/// platform; they are still exposed so a caller gets a clear "not supported on WKWebView" error +/// instead of an unknown-tool error, and so the tool list matches the full socket method surface. +/// +/// Almost every tool here resolves its target with `surface_id` (falling back to the workspace's +/// focused browser surface when omitted) via `v2BrowserWithPanel`/`v2ResolveWorkspace`, the same +/// fallback chain documented on `ProgramaToolSchema.surfaceRoutingIdProperty` -- these tools use a +/// local, more specific `surface_id` description instead of that shared one because `surface_id` +/// is their primary target, not just a routing fallback. +/// +/// `browser_open_split` and `browser_tab_new` create new browser UI but never move keyboard focus +/// or raise/activate the Programa window: both call into focus-adjacent app APIs internally, but +/// those APIs are gated by `socketCommandAllowsInAppFocusMutations` (`Sources/ +/// TerminalController.swift`), which only allows the mutation for methods in `focusIntentV2Methods` +/// -- and `browser.open_split`/`browser.tab.new` are not in that set. Neither handler exposes a +/// caller-controlled `focus` parameter, so there is nothing to strip from these schemas the way +/// `worktree_create` strips `focus` in `WorktreeTools.swift`. +enum BrowserTools { + private static let selectorProperty = ProgramaToolSchema.string( + "CSS selector identifying the target element. Also accepts sel, element_ref, or ref as aliases (e.g. a ref returned by browser_find_role/browser_snapshot)." + ) + + private static let retryAttemptsProperty = ProgramaToolSchema.integer( + "Number of times to retry if the selector doesn't resolve yet (useful for elements that render asynchronously). Defaults to 3." + ) + + private static let exactProperty = ProgramaToolSchema.boolean( + "If true, require an exact match instead of a substring/contains match. Defaults to false." + ) + + private static func surfaceIdProperty(_ extra: String = "") -> Value { + ProgramaToolSchema.string( + "Browser surface UUID or short ref (e.g. surface:3) to target. Defaults to the workspace's focused browser surface if omitted." + extra + ) + } + + static let tools: [ProgramaTool] = [ + ProgramaTool( + name: "browser_open_split", + socketMethod: "browser.open_split", + description: "Opens a new browser surface as a split next to a source surface (or reuses an existing sibling browser pane when one is already positioned to the right). Never raises/activates the Programa window or switches the selected workspace; inside the target workspace the new tab becomes that workspace's focused surface, so the user only sees a change if they are already looking at that workspace. Returns the new surface_id.", + inputSchema: ProgramaToolSchema.object(properties: [ + "url": ProgramaToolSchema.string("Initial URL to load. Opens a blank browser tab if omitted."), + "respect_external_open_rules": ProgramaToolSchema.boolean("If true, URLs matched by the user's configured external-open rules are opened in the default system browser instead of inside Programa. Defaults to false."), + "surface_id": ProgramaToolSchema.string("Source surface UUID or short ref to split from. Defaults to the workspace's focused surface."), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ]) + ), + ProgramaTool( + name: "browser_navigate", + socketMethod: "browser.navigate", + description: "Navigates an existing browser surface to a URL.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "surface_id": surfaceIdProperty(" Required (this tool does not fall back to the focused surface)."), + "url": ProgramaToolSchema.string("URL to navigate to."), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["surface_id", "url"] + ) + ), + ProgramaTool( + name: "browser_back", + socketMethod: "browser.back", + description: "Navigates a browser surface back one entry in its history.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "surface_id": surfaceIdProperty(" Required (this tool does not fall back to the focused surface)."), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["surface_id"] + ) + ), + ProgramaTool( + name: "browser_forward", + socketMethod: "browser.forward", + description: "Navigates a browser surface forward one entry in its history.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "surface_id": surfaceIdProperty(" Required (this tool does not fall back to the focused surface)."), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["surface_id"] + ) + ), + ProgramaTool( + name: "browser_reload", + socketMethod: "browser.reload", + description: "Reloads a browser surface's current page.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "surface_id": surfaceIdProperty(" Required (this tool does not fall back to the focused surface)."), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["surface_id"] + ) + ), + ProgramaTool( + name: "browser_url_get", + socketMethod: "browser.url.get", + description: "Returns a browser surface's current URL.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "surface_id": surfaceIdProperty(" Required (this tool does not fall back to the focused surface)."), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["surface_id"] + ) + ), + ProgramaTool( + name: "browser_is_webview_focused", + socketMethod: "browser.is_webview_focused", + description: "Reports whether keyboard focus is currently inside a browser surface's web view. Read-only; does not move focus.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "surface_id": surfaceIdProperty(" Required (this tool does not fall back to the focused surface)."), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["surface_id"] + ) + ), + ProgramaTool( + name: "browser_snapshot", + socketMethod: "browser.snapshot", + description: "Returns an accessibility-tree-style snapshot of the page (roles, names, and element refs usable by selector-based tools), plus title/url/ready_state and the page text/HTML.", + inputSchema: ProgramaToolSchema.object(properties: [ + "interactive": ProgramaToolSchema.boolean("If true, include only interactive elements (links, buttons, inputs, etc). Defaults to false (include the full tree)."), + "cursor": ProgramaToolSchema.boolean("If true, include cursor-style metadata in the snapshot. Defaults to false."), + "compact": ProgramaToolSchema.boolean("If true, produce a more compact tree. Defaults to false."), + "max_depth": ProgramaToolSchema.integer("Maximum tree depth to walk, from 0 to 64. Defaults to 12."), + "selector": ProgramaToolSchema.string("Optional CSS selector to scope the snapshot to a subtree instead of the whole document."), + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ]) + ), + ProgramaTool( + name: "browser_eval", + socketMethod: "browser.eval", + description: "Runs arbitrary JavaScript in the browser surface's page context and returns its result (JSON-normalized).", + inputSchema: ProgramaToolSchema.object( + properties: [ + "script": ProgramaToolSchema.string("JavaScript expression or statement(s) to evaluate."), + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["script"] + ) + ), + ProgramaTool( + name: "browser_wait", + socketMethod: "browser.wait", + description: "Waits (up to a timeout) for a condition to become true: a selector to appear, the URL to contain a substring, the page text to contain a substring, a document.readyState value, or a custom JS boolean expression. Provide at most one condition; defaults to waiting for document.readyState === 'complete'.", + inputSchema: ProgramaToolSchema.object(properties: [ + "timeout_ms": ProgramaToolSchema.integer("Maximum time to wait, in milliseconds. Defaults to 5000."), + "selector": selectorProperty, + "url_contains": ProgramaToolSchema.string("Wait until location.href contains this substring."), + "text_contains": ProgramaToolSchema.string("Wait until document.body's text contains this substring."), + "load_state": ProgramaToolSchema.stringEnum("Wait until document.readyState reaches this state ('interactive' also matches 'complete').", ["loading", "interactive", "complete"]), + "function": ProgramaToolSchema.string("Custom JavaScript boolean expression to poll, e.g. 'window.myFlag === true'."), + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ]) + ), + ProgramaTool( + name: "browser_click", + socketMethod: "browser.click", + description: "Clicks the element matched by a selector (scrolls it into view first, dispatches a real click event or calls .click()).", + inputSchema: ProgramaToolSchema.object( + properties: [ + "selector": selectorProperty, + "retry_attempts": retryAttemptsProperty, + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["selector"] + ) + ), + ProgramaTool( + name: "browser_dblclick", + socketMethod: "browser.dblclick", + description: "Double-clicks the element matched by a selector.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "selector": selectorProperty, + "retry_attempts": retryAttemptsProperty, + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["selector"] + ) + ), + ProgramaTool( + name: "browser_hover", + socketMethod: "browser.hover", + description: "Hovers the element matched by a selector (dispatches mouseover/mouseenter events).", + inputSchema: ProgramaToolSchema.object( + properties: [ + "selector": selectorProperty, + "retry_attempts": retryAttemptsProperty, + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["selector"] + ) + ), + ProgramaTool( + name: "browser_type", + socketMethod: "browser.type", + description: "Appends text to the element matched by a selector (focuses it, then appends to its value/textContent and fires input/change events). Use browser_fill to replace the value instead of appending.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "text": ProgramaToolSchema.string("Text to append."), + "selector": selectorProperty, + "retry_attempts": retryAttemptsProperty, + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["selector", "text"] + ) + ), + ProgramaTool( + name: "browser_fill", + socketMethod: "browser.fill", + description: "Sets the element matched by a selector to an exact value (replacing any existing value) and fires input/change events. Accepts an empty string to clear the field.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "text": ProgramaToolSchema.string("Value to set. Also accepts value as an alias."), + "value": ProgramaToolSchema.string("Alias for text."), + "selector": selectorProperty, + "retry_attempts": retryAttemptsProperty, + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["selector", "text"] + ) + ), + ProgramaTool( + name: "browser_press", + socketMethod: "browser.press", + description: "Dispatches a keydown, keypress, and keyup for a key to the page's currently focused element (or body if none).", + inputSchema: ProgramaToolSchema.object( + properties: [ + "key": ProgramaToolSchema.string("Key value to dispatch, e.g. 'Enter', 'Tab', 'a'."), + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["key"] + ) + ), + ProgramaTool( + name: "browser_keydown", + socketMethod: "browser.keydown", + description: "Dispatches only a keydown event for a key to the page's currently focused element (or body if none). Use browser_press for a full keydown/keypress/keyup sequence.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "key": ProgramaToolSchema.string("Key value to dispatch."), + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["key"] + ) + ), + ProgramaTool( + name: "browser_keyup", + socketMethod: "browser.keyup", + description: "Dispatches only a keyup event for a key to the page's currently focused element (or body if none).", + inputSchema: ProgramaToolSchema.object( + properties: [ + "key": ProgramaToolSchema.string("Key value to dispatch."), + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["key"] + ) + ), + ProgramaTool( + name: "browser_check", + socketMethod: "browser.check", + description: "Sets a checkbox/radio element matched by a selector to checked, firing input/change events. Fails if the element has no 'checked' property.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "selector": selectorProperty, + "retry_attempts": retryAttemptsProperty, + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["selector"] + ) + ), + ProgramaTool( + name: "browser_uncheck", + socketMethod: "browser.uncheck", + description: "Sets a checkbox/radio element matched by a selector to unchecked, firing input/change events. Fails if the element has no 'checked' property.", + inputSchema: ProgramaToolSchema.object( + properties: [ + "selector": selectorProperty, + "retry_attempts": retryAttemptsProperty, + "surface_id": surfaceIdProperty(), + "window_id": ProgramaToolSchema.windowIdProperty, + "workspace_id": ProgramaToolSchema.workspaceIdProperty, + ], + required: ["selector"] + ) + ), + ProgramaTool( + name: "browser_select", + socketMethod: "browser.select", + description: "Sets a