Summary
In any environment without a working OS/D-Bus secret-service (e.g. a headless Docker container — no gnome-keyring, no org.freedesktop.secrets), adding any server — even a plain Streamable HTTP server with no OAuth config — causes every subsequent GET /api/servers to fail with:
Failed to read server list: Couldn't access platform storage: PermissionDenied
Environment
@modelcontextprotocol/inspector@2.0.0
- Reproduced in Docker, run with
DANGEROUSLY_OMIT_AUTH=true, DANGEROUSLY_BIND_ALL_INTERFACES=true — no D-Bus/secret-service daemon present, as is typical for minimal/CI containers.
Steps to Reproduce
1. Build a minimal container
Create a Dockerfile:
FROM node:24-alpine
RUN npm install -g @modelcontextprotocol/inspector@2.0.0
ENV HOST=0.0.0.0 \
DANGEROUSLY_BIND_ALL_INTERFACES=true \
DANGEROUSLY_OMIT_AUTH=true \
MCP_AUTO_OPEN_ENABLED=false \
CLIENT_PORT=6274
EXPOSE 6274
CMD ["mcp-inspector", "--web"]
Build and run it:
docker build -t inspector-repro .
docker run --rm -d -p 6274:6274 --name inspector-repro inspector-repro
Confirm it's up by opening http://localhost:6274 in your browser (HTTP, not HTTPS) — you should see the Inspector UI with an empty server list.
2. Add any server via the UI
- Click Add Server → Add Manually.
- Give it any name, e.g.
test-server.
- Set transport to Streamable HTTP.
- Set the URL to any value, e.g.
https://example.com/mcp.
- Save/submit the server.
Expected: the server list loads normally and shows test-server.
Actual: the server list fails to load. The underlying GET /api/servers request (visible in browser dev tools) returns:
HTTP/1.1 500 Internal Server Error
{"error":"Failed to read server list: Couldn't access platform storage: PermissionDenied\n\nCaused by:\n PermissionDenied"}
Root Cause
expectedSecretFields() unconditionally includes the OAuth-client-secret field for every server, even ones with no OAuth config at all. This means rehydrateConfig() → readKeychainEntriesFor() → secretStore.get() runs a keychain lookup for every server on every GET /api/servers, regardless of whether that server could possibly have a stored secret.
- In
KeyringSecretStore.get(), new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)) is constructed outside the surrounding try/catch — only the subsequent await entry.getPassword() is guarded.
- When there's no secret-service backend available,
@napi-rs/keyring's AsyncEntry constructor throws synchronously, so it bypasses the KeychainUnavailableError-specific graceful-degradation path that already exists elsewhere in the codebase (e.g. in migratePlaintextSecrets()), and instead surfaces as a raw, unclassified error → generic 500 in the /api/servers route handler.
Summary
In any environment without a working OS/D-Bus secret-service (e.g. a headless Docker container — no
gnome-keyring, noorg.freedesktop.secrets), adding any server — even a plain Streamable HTTP server with no OAuth config — causes every subsequentGET /api/serversto fail with:Environment
@modelcontextprotocol/inspector@2.0.0DANGEROUSLY_OMIT_AUTH=true,DANGEROUSLY_BIND_ALL_INTERFACES=true— no D-Bus/secret-service daemon present, as is typical for minimal/CI containers.Steps to Reproduce
1. Build a minimal container
Create a
Dockerfile:Build and run it:
docker build -t inspector-repro . docker run --rm -d -p 6274:6274 --name inspector-repro inspector-reproConfirm it's up by opening http://localhost:6274 in your browser (HTTP, not HTTPS) — you should see the Inspector UI with an empty server list.
2. Add any server via the UI
test-server.https://example.com/mcp.Expected: the server list loads normally and shows
test-server.Actual: the server list fails to load. The underlying
GET /api/serversrequest (visible in browser dev tools) returns:Root Cause
expectedSecretFields()unconditionally includes the OAuth-client-secret field for every server, even ones with no OAuth config at all. This meansrehydrateConfig()→readKeychainEntriesFor()→secretStore.get()runs a keychain lookup for every server on everyGET /api/servers, regardless of whether that server could possibly have a stored secret.KeyringSecretStore.get(),new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field))is constructed outside the surrounding try/catch — only the subsequentawait entry.getPassword()is guarded.@napi-rs/keyring'sAsyncEntryconstructor throws synchronously, so it bypasses theKeychainUnavailableError-specific graceful-degradation path that already exists elsewhere in the codebase (e.g. inmigratePlaintextSecrets()), and instead surfaces as a raw, unclassified error → generic 500 in the/api/serversroute handler.