feat(mcp): add MCP app & macOS native Finder directory picker for setting working directory#3604
feat(mcp): add MCP app & macOS native Finder directory picker for setting working directory#3604jqnatividad wants to merge 11 commits intomasterfrom
Conversation
…selection Add an interactive directory browser rendered as an MCP App (inline HTML UI) for clients that support the io.modelcontextprotocol/ui extension (Claude Desktop, VS Code Copilot, etc.). Clients without App support (Claude Code) fall back to elicitation form or text suggestions as before. Key changes: - Add @modelcontextprotocol/ext-apps dependency for App SDK - Add _meta field to McpToolDefinition type - Create directory picker HTML App (src/ui/directory-picker-html.ts) - Add qsv_browse_directory App-only helper tool (visibility: ["app"]) - Add _meta.ui.resourceUri to qsv_set_working_dir tool definition - Update resource handler to serve ui://qsv/directory-picker - Add clientSupportsApps() capability detection - Three-tier fallback: App UI > elicitation form > text suggestions - 15 new tests covering tool definitions, capability detection, HTML validation, and directory scanning patterns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add type assertion for structuredContent return (SDK type gap) - Simplify breadcrumb rendering to POSIX-only paths (server always returns POSIX via path.join) - Conditionally register qsv_browse_directory only for App-capable clients - Extract directory scanning logic into browse-directory.ts for testability - Replace stub tests with real scanDirectory unit tests (hidden dir filtering, error paths, sorting, tabular file counts) - Add explanatory comment for Function() dynamic import workaround - Remove unused imports (dirname, extname, readdir) from mcp-server.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… runtime crash The deep import path `@modelcontextprotocol/ext-apps/dist/src/server/index.js` bypasses the package's `exports` map, causing ERR_PACKAGE_PATH_NOT_EXPORTED at runtime in Node.js ESM. Use the correct `ext-apps/server` subpath instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…P domains - manifest.json: fix entry_point from dist/ to server/ to match MCPB archive layout (package-mcpb.js archives dist/ as server/) - mcp-server.ts: intercept transport to capture raw client extensions before the MCP SDK's Zod parsing strips them from ClientCapabilities (the SDK schema has no .passthrough(), so getClientCapabilities() loses the extensions field) - mcp-server.ts: add CSP resourceDomains/connectDomains for esm.sh CDN in the directory picker resource response (required for App sandbox to load ext-apps SDK) - mcp-tools.ts: add flat "ui/resourceUri" key to _meta (matching what registerAppTool normalizes) alongside the nested ui.resourceUri Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove unused `origOnMessage` capture that was always `undefined` at construction time and used a no-op suppress-warning pattern. Add a warning log when `rawClientExtensions` is not captured after connect to aid debugging if the SDK changes its transport wiring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…icker MCP Apps (structuredContent) don't render in Claude Desktop over stdio, and the transport interception added for Apps detection interfered with elicitation in the MCPB. Gate all App-related code behind QSV_MCP_ENABLE_APPS (default: false) so the existing elicitation and text suggestion flows work unimpeded. When no directory is provided to qsv_set_working_dir, the fallback chain is now: 1. MCP Apps structuredContent (if QSV_MCP_ENABLE_APPS=true) 2. MCP elicitation form 3. Native macOS folder picker via osascript (new) 4. Text suggestions for the agent Also fixes App SDK API bugs per the official Build Guide: - Import from ext-apps root instead of /app subpath - Use app.ontoolresult instead of app.ontoolinput - Use app.callServerTool() instead of app.callTool() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…picker in headless sessions - Escape backslashes and double quotes in directory paths before interpolating into AppleScript to prevent command injection - Restore capability check for elicitation as a fast path — skip directly to fallback when client explicitly lacks elicitation support, while still attempting it for ambiguous cases (e.g., MCPB proxies) - Check for GUI session (TERM_PROGRAM/DISPLAY) before launching the native macOS folder picker to avoid 60s hangs in headless/SSH contexts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…euristic Broaden elicitation guard to handle all falsy values (undefined, null, false), not just strict undefined equality. Add comment documenting known limitations of the TERM_PROGRAM/DISPLAY headless detection heuristic on macOS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… in MCPB MCPB spawns the server without TERM_PROGRAM/DISPLAY env vars, causing the headless check to skip the native macOS folder picker even though Finder is available. The osascript try/catch already handles truly headless environments gracefully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
.claude/skills/src/mcp-server.ts
Outdated
| const targetDir = | ||
| typeof args.directory === "string" && args.directory.trim().length > 0 | ||
| ? args.directory.trim() | ||
| : this.filesystemProvider.getWorkingDirectory(); | ||
|
|
||
| try { | ||
| const result = await scanDirectory(targetDir); | ||
| return successResult(JSON.stringify(result)); | ||
| } catch (err) { | ||
| return errorResult(getErrorMessage(err)); | ||
| } |
There was a problem hiding this comment.
qsv_browse_directory bypasses the FilesystemResourceProvider's allowed-directory enforcement (it calls scanDirectory directly on the user-supplied path). This allows an App/LLM to enumerate arbitrary paths outside QSV_MCP_ALLOWED_DIRS. Resolve/validate targetDir via this.filesystemProvider.resolvePath(...) (or an equivalent provider API) before scanning, and ensure any returned child paths remain within allowed roots.
…lback order - Validate browse_directory paths via resolvePath() to enforce allowed-directory security (prevents arbitrary filesystem enumeration) - Align TABULAR_EXTS with FilesystemResourceProvider (.pqt→.pq, +.xlsm/.xlsb/.ods) - Reorder set_working_dir fallback: App → native picker → elicitation → text - Gate _meta.ui.resourceUri on config.enableMcpApps so clients don't see App hints when the feature is disabled - Fix incorrect comment about path.join() always returning POSIX paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… extension Split the conditional enableMcpApps test into two explicit tests that override the config value, ensuring both branches are exercised regardless of CI environment. Restore .pqt to TABULAR_EXTS alongside .pq to avoid silently dropping files for users with pandas-generated Parquet files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| if (capabilities && !capabilities.elicitation) { | ||
| // Client sent capabilities but elicitation is absent or explicitly opted out | ||
| // Covers undefined, null, false, and empty object |
There was a problem hiding this comment.
The elicitation capability check doesn’t match the comment/intent: if (capabilities && !capabilities.elicitation) will still attempt elicitation when capabilities.elicitation is an empty object or when the client explicitly indicates form: false. Either tighten the condition to check the actual form capability (and treat false/empty as unsupported), or update the comment so it accurately reflects the logic.
| if (capabilities && !capabilities.elicitation) { | |
| // Client sent capabilities but elicitation is absent or explicitly opted out | |
| // Covers undefined, null, false, and empty object | |
| const hasFormElicitationCapability = | |
| !!( | |
| capabilities && | |
| capabilities.elicitation && | |
| typeof capabilities.elicitation === "object" && | |
| (capabilities.elicitation as any).form === true | |
| ); | |
| if (capabilities && !hasFormElicitationCapability) { | |
| // Client sent capabilities but does not support form elicitation | |
| // Covers: missing elicitation, empty object, or form explicitly false |
| } | ||
|
|
||
| // Handle browse directory tool (App-only helper for directory picker) | ||
| if (name === "qsv_browse_directory") { |
There was a problem hiding this comment.
qsv_browse_directory is dispatched unconditionally here, even though the tool is intended to be App-only and is only listed when Apps are enabled/supported. As-is, clients can still invoke it directly when Apps are disabled, which makes the feature flag/tool visibility harder to reason about. Consider gating this handler behind config.enableMcpApps && this.clientSupportsApps() (or returning an explicit error) to align runtime behavior with how the tool is advertised.
| if (name === "qsv_browse_directory") { | |
| if (name === "qsv_browse_directory") { | |
| if (!(config.enableMcpApps && this.clientSupportsApps())) { | |
| return errorResult( | |
| "The qsv_browse_directory tool is only available when MCP Apps are enabled and supported by the client.", | |
| ); | |
| } |
| test("qsv_set_working_dir includes _meta.ui when enableMcpApps is true", async () => { | ||
| const { createSetWorkingDirTool } = await import("../src/mcp-tools.js"); | ||
| const { config } = await import("../src/config.js"); | ||
| const mutableConfig = config as { -readonly [K in keyof typeof config]: (typeof config)[K] }; | ||
| const original = mutableConfig.enableMcpApps; | ||
| try { | ||
| mutableConfig.enableMcpApps = true; | ||
| const tool = createSetWorkingDirTool(); | ||
| assert.ok(tool._meta, "tool should have _meta when apps enabled"); | ||
| const ui = tool._meta!.ui as Record<string, unknown>; | ||
| assert.ok(ui, "tool._meta should have ui"); | ||
| assert.strictEqual(ui.resourceUri, "ui://qsv/directory-picker"); | ||
| } finally { | ||
| mutableConfig.enableMcpApps = original; | ||
| } | ||
| }); | ||
|
|
||
| test("qsv_set_working_dir omits _meta when enableMcpApps is false", async () => { | ||
| const { createSetWorkingDirTool } = await import("../src/mcp-tools.js"); | ||
| const { config } = await import("../src/config.js"); | ||
| const mutableConfig = config as { -readonly [K in keyof typeof config]: (typeof config)[K] }; | ||
| const original = mutableConfig.enableMcpApps; | ||
| try { | ||
| mutableConfig.enableMcpApps = false; | ||
| const tool = createSetWorkingDirTool(); | ||
| assert.strictEqual(tool._meta, undefined, "tool should not have _meta when apps disabled"); | ||
| } finally { | ||
| mutableConfig.enableMcpApps = original; | ||
| } |
There was a problem hiding this comment.
These tests mutate the shared config singleton (enableMcpApps) to assert tool metadata. Since the test runner (node --test) can run files concurrently, this can race with other test files that call createSetWorkingDirTool() and expect the default enableMcpApps=false, causing intermittent failures. Prefer avoiding global mutation (e.g., refactor createSetWorkingDirTool to accept an options/config override for tests, or use a module-mocking approach that doesn’t affect other files).
| // Import and create a tool without _meta to verify the type allows it | ||
| const { createGetWorkingDirTool } = await import("../src/mcp-tools.js"); | ||
| const tool = createGetWorkingDirTool(); | ||
|
|
||
| // _meta should be undefined for tools that don't declare it | ||
| // (or it may be present — either way, it shouldn't break) | ||
| assert.ok(true, "tool without _meta compiles and runs fine"); |
There was a problem hiding this comment.
This test doesn’t assert any observable behavior (it always passes). If the goal is to ensure _meta is optional, that’s already enforced by TypeScript at compile-time; in runtime tests this adds no coverage. Consider removing it, or replace it with a real assertion about _meta presence/absence on specific tool definitions.
| // Import and create a tool without _meta to verify the type allows it | |
| const { createGetWorkingDirTool } = await import("../src/mcp-tools.js"); | |
| const tool = createGetWorkingDirTool(); | |
| // _meta should be undefined for tools that don't declare it | |
| // (or it may be present — either way, it shouldn't break) | |
| assert.ok(true, "tool without _meta compiles and runs fine"); | |
| // Import and create a tool that is expected NOT to declare _meta | |
| const { createGetWorkingDirTool } = await import("../src/mcp-tools.js"); | |
| const tool = createGetWorkingDirTool(); | |
| // At runtime, tools that don't declare _meta should not define the property | |
| const hasMeta = Object.prototype.hasOwnProperty.call(tool as object, "_meta"); | |
| assert.strictEqual(hasMeta, false, "tool without _meta should not define _meta at runtime"); |
| test("qsv_browse_directory should be exempt from first-use elicitation", () => { | ||
| // Mirror the server's exempt tools set | ||
| const exemptTools = new Set([ | ||
| "qsv_config", | ||
| "qsv_log", | ||
| "qsv_search_tools", | ||
| "qsv_set_working_dir", | ||
| "qsv_get_working_dir", | ||
| "qsv_browse_directory", | ||
| ]); | ||
|
|
||
| assert.ok(exemptTools.has("qsv_browse_directory"), "browse_directory should be exempt"); | ||
| // Data tools should NOT be exempt | ||
| assert.ok(!exemptTools.has("qsv_stats"), "data tools should not be exempt"); |
There was a problem hiding this comment.
This block only asserts that a locally-constructed Set contains the string "qsv_browse_directory"; it doesn’t validate the server’s actual ELICITATION_EXEMPT_TOOLS configuration. Consider testing the real source of truth (e.g., by exporting the set/adding an accessor, or by testing behavior that depends on the exemption) so the test will fail if the production exemption list regresses.
| test("qsv_browse_directory should be exempt from first-use elicitation", () => { | |
| // Mirror the server's exempt tools set | |
| const exemptTools = new Set([ | |
| "qsv_config", | |
| "qsv_log", | |
| "qsv_search_tools", | |
| "qsv_set_working_dir", | |
| "qsv_get_working_dir", | |
| "qsv_browse_directory", | |
| ]); | |
| assert.ok(exemptTools.has("qsv_browse_directory"), "browse_directory should be exempt"); | |
| // Data tools should NOT be exempt | |
| assert.ok(!exemptTools.has("qsv_stats"), "data tools should not be exempt"); | |
| test("qsv_browse_directory should be exempt from first-use elicitation", async () => { | |
| const { ELICITATION_EXEMPT_TOOLS } = await import("../src/server.js"); | |
| // browse_directory should be exempt according to the server's configuration | |
| assert.ok( | |
| ELICITATION_EXEMPT_TOOLS.has("qsv_browse_directory"), | |
| "browse_directory should be exempt", | |
| ); | |
| // Data tools should NOT be exempt | |
| assert.ok( | |
| !ELICITATION_EXEMPT_TOOLS.has("qsv_stats"), | |
| "data tools should not be exempt", | |
| ); |
in this fallback order:
MCP app -> Native Directory Picker (macOS only) -> elicitation -> text prompt