v1.7.0 refreshRoots() clears allowedLocalDirs when host advertises roots capability but returns empty list — wipes CLI-arg directories · Issue #644 · modelcontextprotocol/ext-apps · GitHub
Skip to content

v1.7.0 refreshRoots() clears allowedLocalDirs when host advertises roots capability but returns empty list — wipes CLI-arg directories #644

Open

Description

@wsweatt0216

Version: 1.7.0 (pdf-server-mcp via Claude Desktop extension manager)
OS: Windows 11 / Claude Desktop 1.4758.0
Severity: Functional regression — local PDFs cannot be opened via display_pdf

What happens

list_pdfs returns:

{"localFiles": [], "allowedDirectories": [], "truncated": false}
Even when:

Directories are passed as positional args via the manifest's mcp_config.args
(which parseArgs() should register at line ~34372 in dist/index.js)
A cowork directory mount has been approved by the user
display_pdf against any local file path is rejected with "Local file not in allowed list".

Root cause
In dist/server.js refreshRoots():

async function refreshRoots(server) {
  if (!server.getClientCapabilities()?.roots)
    return;
  try {
    const { roots } = await server.listRoots();
    allowedLocalDirs.clear();   // <-- this line
    for (const root of roots) { ... }
  }
}
When --stdio mode runs, useClientRoots: true is hardcoded, so
refreshRoots() always fires on init via oninitialized. Claude Desktop
1.4758.0 advertises the roots capability but server.listRoots() returns
an empty array (cowork mounts not propagating as MCP roots). The
unconditional clear() then wipes any directories registered via CLI args
in main() lines 34372/34376.

Reproduction
Install pdf-server-mcp 1.7.0 in Claude Desktop on Windows.
In manifest.json (or via a launch wrapper) add positional directory
paths to mcp_config.args after --stdio, e.g.
["${__dirname}/dist/index.js", "--stdio", "C:\\some\\dir"].
Restart Claude Desktop.
Call list_pdfs. Expected: allowedDirectories includes C:\some\dir.
Actual: allowedDirectories: [].
Suggested fix
In refreshRoots(), either (a) only clear when the new roots list is
non-empty, or (b) drop the clear entirely and rely on Set.add()'s dedup
behavior. Option (b) is safer because cowork-side root removals would
still need a separate mechanism (e.g., a roots/list_changed notification
that explicitly signals removal, not the current implicit
clear-and-replace).

Workaround applied locally
Commented out the allowedLocalDirs.clear() line. CLI-arg directories
now survive the refresh and display_pdf works on local paths again.
This loses the ability for the host to revoke a previously-granted root
via the empty-list path, but in practice cowork mounts are additive in
our usage pattern.

Activity

  1. sophymarine commented on Apr 27, 2026

    @sophymarine

    The unconditional clear() looks risky here. If Claude advertises roots but returns an empty list, preserving the pre-approved CLI-arg directories as a fallback would avoid turning a capability mismatch into a regression. I’d gate the clear on roots.length > 0, or merge roots into the existing allowlist instead of replacing it wholesale. That keeps local-file access stable when the host and server disagree about root propagation, and it still lets explicit user-approved roots win when they are present.

  2. uheira commented on Sep 24, 2026

    @uheira

    Confirming this on pdf-server 2.0.0 (Claude Desktop extension, Claude Desktop 2.7032.0, Windows 10 22H2). Directories from the extension settings are registered at startup (Registered local directory: I:\, ... C:\00), but after initialization display_pdf fails with Local file not in allowed list and Allowed dirs: is empty. Commenting out allowedLocalDirs.clear(); in refreshRoots() fixes it, as described above.

    There is also a second problem on Windows that makes the roots path fail even when the host sends non-empty roots. fileUrlToPath() only strips the scheme:

    function fileUrlToPath(fileUrl) {
      return decodeURIComponent(fileUrl.replace(/^(?:file|computer):\/\//, ""));
    }

    For a Windows file URL this leaves a leading slash before the drive letter, and path.resolve() then prepends the current drive:

    file:///C:/00  -> "/C:/00" -> path.resolve -> C:\C:\00 -> statSync: ENOENT
    file:///I:/    -> "/I:/"   -> path.resolve -> C:\I:    -> statSync: ENOENT
    

    refreshRoots() catches the statSync error and skips the root without logging anything, so on Windows every file:// root is dropped. Together with the unconditional clear(), the allowed list always ends up empty. From the logs I couldn't tell whether the host sends an empty list or Windows file URLs, but the result is the same either way. The same helper is also used for file:// arguments elsewhere in server.ts (e.g. display_pdf and save paths), so going by the code those would resolve the same way on Windows.

    Both issues are still present in examples/pdf-server/server.ts on main.

    Possible fix: use Node's url.fileURLToPath() for file:// URLs (it's already imported in server.ts for SERVER_FILE). It handles drive letters, percent-encoding and UNC paths:

    file:///C:/00                -> C:\00
    file:///C:/Users/me/My%20Docs -> C:\Users\me\My Docs
    file://server/share/dir      -> \\server\share\dir
    

    Also, only replace the CLI-arg directories when the new roots list yields at least one valid directory (option (a) in the issue), and log roots that are skipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions