Entscheidung
ADR 0009: Local profiles and the OS keychain
Diese Seite gibt es nur auf Englisch.
Auf dieser Seite
- Status: Accepted
- Date: 2026-11-09
Context#
A sevDesk API token has full access to its account, no scopes and no expiry. Over stdio the server got it in one of two ways, both plain text:
SEVDESK_API_TOKENin the MCP client's configuration (claude_desktop_config.json,.mcp.json, Claude Code's settings). Those files are synced, backed up, kept in dotfiles repositories and shown when people share their screen to get help.SEVDESK_API_TOKEN_FILE, which moves the token into another plain-text file.
People who keep more than one sevDesk account (their own company and a client, a trial account), or a read-only and a bookkeeping setup of the same account, also repeat a whole block of variables in every client entry and can easily pair the wrong token with the wrong tiers.
The remote mode has its own answers (OAuth with a consent page, Docker secrets), and a container has no keychain. This decision is about local installs only.
Decision#
The OS keychain is a third token source, over stdio only. SEVDESK_API_TOKEN_KEYCHAIN=<label>
reads the token stored under that label (src/safety/keychain.ts). Exactly one of
SEVDESK_API_TOKEN, SEVDESK_API_TOKEN_FILE and SEVDESK_API_TOKEN_KEYCHAIN may be set.
| Platform | Read | Store |
|---|---|---|
| macOS | /usr/bin/security find-generic-password -w | security -i, the command on stdin |
| Linux and other | secret-tool lookup (libsecret) | secret-tool store, the token on stdin |
| Windows | not supported in 1.0 | (pointer to SEVDESK_API_TOKEN_FILE or the .mcpb bundle) |
- Fixed service, label only. Every item lives under the service
mcp-server-sevdesk; the configuration names only the label (the item's account), which must start with a letter or digit and contain at most 64 letters, digits,.,_or-. A hostile configuration can therefore not make the server read some other keychain item (a GitHub token, a password) and send it to a customSEVDESK_BASE_URL. A label that looks like a token is refused and never echoed. - The helper runs without a shell, from an argument array. When storing, the token travels on
the helper's stdin, never in its arguments, which other local users can see in the process list.
security -iparses that stdin line itself, so only tokens of[A-Za-z0-9_-]are stored (sevDesk tokens are 32 hex characters) and nothing needs quoting. - Output is never echoed. stdout is capped at 4 KiB, stderr is discarded, the helper is killed
after 15 s at startup (120 s for the CLI commands, where a person may have to answer a dialog),
and errors are fixed texts chosen by exit code (
securityexits with the low byte of the OSStatus: 44 not found, 128 denied, 36 locked without a dialog). The value read is checked like any token. - CLI.
token set <label>asks for the token with hidden input on a terminal, or reads it from stdin, never from the command line. It checks the token with sevDesk (GET /SevClient) first and stores nothing sevDesk rejects; when sevDesk cannot be reached it stores the token with a warning. It reads the item back to confirm and prints only the label and the account id.token check <label>reads the item and checks it with sevDesk;token delete <label>removes it.
Profiles: named setups in one file, picked with SEVDESK_PROFILE
(src/accounts/profiles.ts, src/accounts/local-config.ts).
- Location:
SEVDESK_PROFILES_PATH(absolute), else$XDG_CONFIG_HOME/mcp-server-sevdesk/profiles.jsonor~/.config/mcp-server-sevdesk/profiles.json(also on macOS), on Windows%APPDATA%\mcp-server-sevdesk\profiles.json. The server reads it only whenSEVDESK_PROFILEis set. - Format: strict JSON, at most 64 KiB,
{"version": 1, "profiles": {"<name>": {…}}}, unknown keys refused. A profile's keys aretoken,allow,readOnly,dryRun,confirm,toolsets,fileDirs,outputDir,auditLog,baseUrl,rateLimitandtimeoutMs. - Tokens by reference only.
tokenis{"keychain": "<label>"},{"file": "/absolute/path"}or{"env": "SEVDESK_TOKEN_<NAME>"}. A token written into the file is refused and never echoed;envmay only nameSEVDESK_TOKEN_*variables, so a profile cannot send an unrelated secret from the environment to sevDesk or to a custom base URL. - A profile is an overlay of the same variables. Each key sets its
SEVDESK_*variable, and the result goes through the unchangedreadSettings/loadConfig. A profile can express nothing the environment cannot, and every check (https base URL, the confirmation opt-out, absolute folders) applies unchanged. Problems name the profile key they came from. - Conflicts are errors. A variable set both in the environment and by the profile is a
configuration error naming both, so nobody has to remember which one wins. The one exception
tightens:
SEVDESK_READ_ONLY=1andSEVDESK_DRY_RUN=1in the environment always win. An unknown profile name lists the available ones. - Trust (POSIX). A profile can widen tiers and folders, so the file and its folder (as written and after resolving symlinks, e.g. a dotfiles checkout) must belong to the user or root and must not be writable by group or others; otherwise the file is refused. A profile's token file that others can read gets a warning only. Windows is not checked.
profiles listshows each profile's token source (keychain:work,file:/…,env:SEVDESK_TOKEN_…), tiers and toolsets, never a token. The startup log names the profile in use.
HTTP mode refuses SEVDESK_PROFILE, SEVDESK_PROFILES_PATH and SEVDESK_API_TOKEN_KEYCHAIN:
a server keeps one source of configuration, its environment.
Consequences#
- Good: the token no longer has to sit in a client's configuration file or in a plain-text file.
- Good: several accounts or setups from one client configuration, each with its own tiers, folders and audit log, switched with one variable.
- Good: profiles add no new settings semantics: the same validation, the same messages, and the environment cannot be silently overridden.
- Bad: the keychain protects the token at rest, not against code that already runs as the same
user. On macOS the item trusts
/usr/bin/security, which created it, so any process of the user can read it through that tool without a dialog (as with other command-line tools that use the keychain). - Bad: Linux needs
secret-tooland a running, unlocked Secret Service (GNOME Keyring, KWallet); headless machines should keep usingSEVDESK_API_TOKEN_FILE. - Bad: no keychain on Windows in 1.0 (planned for 1.x with the Credential Manager), and the profiles file is not permission-checked there.
- Bad: every stdio start with a keychain label spawns a helper, and a locked keychain can hold the start for up to 15 s.
- Bad: a client that always sets a variable (for example a plugin that defaults
SEVDESK_ALLOW) conflicts with profiles that set it too; such clients must leave unset variables empty.
Alternatives considered#
- A native keychain module (
keytar, now archived, or newer N-API bindings). Rejected: native binaries per platform and Node version, and more supply-chain surface for one lookup; the command-line helpers ship with the OS. - Passing the token in the helper's arguments (
security add-generic-password -w <token>). Rejected: visible in the process list while the helper runs. - Letting the configuration name any keychain service or item. Rejected: a hostile configuration could read unrelated secrets and send them to a custom base URL.
- Tokens inside the profiles file. Rejected: that is a second plain-text token file.
- TOML, YAML or JSON with comments. Rejected: each needs a parser dependency; strict JSON is enough for a dozen keys.
- Silent precedence (profile over environment, or the reverse). Rejected: a client that sets
SEVDESK_ALLOWwould silently widen or narrow a profile. An explicit error is clearer; only the safe direction (read-only, dry-run) is allowed without one. - An encrypted token file with a passphrase. Rejected: the passphrase needs a safe place of its own, which is the keychain.