Skip to content

feat(mcp): add MCP app & macOS native Finder directory picker for setting working directory#3604

Open
jqnatividad wants to merge 11 commits intomasterfrom
mcp-app-for-mcp-server
Open

feat(mcp): add MCP app & macOS native Finder directory picker for setting working directory#3604
jqnatividad wants to merge 11 commits intomasterfrom
mcp-app-for-mcp-server

Conversation

@jqnatividad
Copy link
Collaborator

in this fallback order:
MCP app -> Native Directory Picker (macOS only) -> elicitation -> text prompt

jqnatividad and others added 9 commits March 10, 2026 12:52
…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>
@jqnatividad jqnatividad requested a review from Copilot March 11, 2026 07:59
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 8 comments.

Files not reviewed (1)
  • .claude/skills/package-lock.json: Language not supported

Comment on lines +953 to +963
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));
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
jqnatividad and others added 2 commits March 11, 2026 04:43
…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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 5 comments.

Files not reviewed (1)
  • .claude/skills/package-lock.json: Language not supported

Comment on lines +1109 to +1111
if (capabilities && !capabilities.elicitation) {
// Client sent capabilities but elicitation is absent or explicitly opted out
// Covers undefined, null, false, and empty object
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
}

// Handle browse directory tool (App-only helper for directory picker)
if (name === "qsv_browse_directory") {
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.",
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +46
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;
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +76
// 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");
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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");

Copilot uses AI. Check for mistakes.
Comment on lines +272 to +285
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");
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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",
);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants