Skip to content

Honor XDG Base Directory Spec for config, vault, cache, and daemon paths #155

Description

@alexminza

Summary

mcporter resolves its config home and OAuth credential vault path through os.homedir() + '/.mcporter/' with no env-var or CLI override. This makes mcporter hard to deploy in containers and other environments where $HOME is shared across tenants or is on an ephemeral filesystem — the vault must live on a persisted/per-tenant volume, but mcporter doesn't expose a hook to point it there.

Proposal: honor the freedesktop.org XDG Base Directory Spec for config, data, cache, and state paths — read from $XDG_CONFIG_HOME/mcporter/, $XDG_DATA_HOME/mcporter/, $XDG_CACHE_HOME/mcporter/, and $XDG_STATE_HOME/mcporter/ when those env vars are set, fall back to today's ~/.mcporter/ when unset.

What's actually written under ~/.mcporter/

A source audit of main @ 324fb7a found seven distinct files written from five code paths, and eight separate occurrences of the literal os.homedir() + '.mcporter' with no shared helper. Categorical XDG kinds (per the spec) determine the right env var for each:

File Code site XDG kind Proposed XDG path
credentials.json (OAuth vault) src/oauth-vault.ts:27 data $XDG_DATA_HOME/mcporter/credentials.json
<server>/schema.json (per-server schema cache) src/schema-cache.ts:15 cache $XDG_CACHE_HOME/mcporter/<server>/schema.json
<server>/... (legacy per-server token caches, read-only fallback for compat) src/oauth-persistence.ts:242,277 data $XDG_DATA_HOME/mcporter/<server>/
mcporter.json[c] (add --scope home write target) src/cli/config/add.ts:92 config $XDG_CONFIG_HOME/mcporter/mcporter.json
mcporter.json[c] (home discovery candidates) src/config/path-discovery.ts:58-60, src/cli/config/shared.ts:130-131 config $XDG_CONFIG_HOME/mcporter/mcporter.json[c]
daemon/daemon-<key>.{sock,json,log} src/daemon/paths.ts:10 state $XDG_STATE_HOME/mcporter/daemon/...

This codebase already has the right patterns — just not applied here

Two existing patterns make the proposed change consistent with current code, not foreign to it:

  1. mcporter already honors XDG_CONFIG_HOME — for importing third-party MCP server configs at src/config/imports/paths.ts:39-40,84-85,107-114. Cursor and opencode imports check process.env.XDG_CONFIG_HOME before falling back to ~/.config/. The proposal is to extend that same pattern from third-party imports to mcporter's own state.

  2. Category-specific override env vars already existsrc/daemon/paths.ts:6-11 accepts MCPORTER_DAEMON_DIR to redirect the daemon directory specifically. The XDG approach generalizes this pattern across all categories using standard env vars instead of one bespoke override per kind.

Why the existing knobs don't cover the use case

The config doc lists three redirection mechanisms (docs/config.md); none redirect the vault:

Knob What it redirects Vault?
MCPORTER_CONFIG env var Config file only ("only that file is used—no merging") No
--config <path> flag Same as above No
--root <dir> flag Project root for import discovery No
tokenCacheDir per-server config field Token cache for that one server Partial — must be set per server, doesn't move the centralized vault
MCPORTER_DAEMON_DIR env var Daemon socket / metadata / log dir No — daemon files only

There's no process.env.HOME indirection that would let an operator set HOME for the mcporter process specifically — HOME belongs to the parent agent (in our case, OpenClaw, which spawns mcporter as a subprocess).

Why XDG over a bespoke MCPORTER_HOME

  1. Standardized. Every Linux operator, container image, and packager already knows what XDG_CONFIG_HOME / XDG_DATA_HOME / XDG_STATE_HOME mean — no new tool-specific env var to discover and document.

  2. Categorical separation. XDG splits state by kind, not by tool:

    Spec var Default What goes there mcporter use
    XDG_CONFIG_HOME ~/.config User config files mcporter.json[c]
    XDG_DATA_HOME ~/.local/share Durable user data credentials.json (OAuth vault)
    XDG_STATE_HOME ~/.local/state Logs, history, transient state Daemon socket / metadata / log
    XDG_CACHE_HOME ~/.cache Regenerable Per-server schema caches

    This matters in deployments: operators routinely place cache on tmpfs/local-SSD, data on backed-up volumes, and config under version control. XDG keeps each on its appropriate medium; a single tool-specific override would collapse them all into one bucket.

  3. Composes with multi-tool environments. mcporter isn't the only Node-based CLI in many container images. If the operator already sets XDG vars at the container level for other tools, mcporter joining the convention means zero additional config — the same env vars cover everything.

  4. Better defaults. Today's ~/.mcporter/ is a non-standard "dotfile in $HOME" that's increasingly discouraged on Linux (clutters $HOME, doesn't separate by kind). Moving to ~/.config/mcporter/ + ~/.local/share/mcporter/ aligns with where Linux users expect to find tool state.

Backwards compatibility

The standard pattern (adopted by gh, pnpm, direnv, yt-dlp, and many other Node/Go/Python CLIs) is:

  1. Honor XDG when explicitly set. If $XDG_DATA_HOME (or $XDG_CONFIG_HOME, etc.) is non-empty, use $XDG_DATA_HOME/mcporter/ (etc.).
  2. Fall back to legacy ~/.mcporter/ when XDG vars are unset, so existing users see no change.
  3. (Optional, follow-up release) auto-detect an existing ~/.mcporter/ even when XDG vars are set, and either migrate-on-first-run or read-from-legacy-write-to-XDG, with a one-line stderr notice.

Step (1) alone is enough to unblock containerized/multi-tenant deployments, and is a fully backwards-compatible change — single-user dev environments are unaffected because XDG vars aren't set by default.

The vault file format ({ version: 1, entries: Record<VaultKey, VaultEntry> } at src/oauth-vault.ts:21-23) doesn't need to change — only the path it lives at.

MCPORTER_DAEMON_DIR should keep working as-is (it's already category-specific and operators may rely on it); when both are set, an explicit MCPORTER_DAEMON_DIR should win over XDG_STATE_HOME.

Use case

We pre-install mcporter in a container image that hosts a long-running OpenClaw agent on Cloudflare Sandbox Containers. The container image is shared across multiple tenant instances; per-tenant state (including OAuth tokens for MCP servers the agent has authenticated against) is persisted to a per-instance R2 bucket via an rclone sync loop scoped to a single state directory. The ~/.mcporter/ path lives outside that state directory, so without XDG support:

  1. Tokens persisted by mcporter on instance A are lost on the next container replacement (container filesystem is ephemeral).
  2. If HOME were shared, tokens from instance A would leak into instance B (cross-tenant credential leak).

A filesystem symlink ($HOME/.mcporter → /persisted/state/mcporter) works as a workaround today (verified safe — expandHome at src/env.ts:8-20 and src/config/imports/paths-utils.ts:11-19 uses pure string substitution, no realpath), but it's fragile and breaks if mcporter ever switches to realpath-style resolution. With XDG support we'd set XDG_DATA_HOME=/persisted/state once at the container level and the vault would land there automatically — no symlink, no per-tool override, the same env vars covering every other XDG-aware tool in the image.

Minimal implementation sketch

Replace the eight os.homedir() + '.mcporter' literals with a single helper:

// src/paths.ts (new)
import os from 'node:os';
import path from 'node:path';

export type XdgKind = 'config' | 'data' | 'state' | 'cache';

const XDG_VARS: Record<XdgKind, string> = {
  config: 'XDG_CONFIG_HOME',
  data:   'XDG_DATA_HOME',
  state:  'XDG_STATE_HOME',
  cache:  'XDG_CACHE_HOME',
};

// Returns the directory mcporter writes for a given XDG kind. When the
// matching XDG_*_HOME var is set and non-empty, returns $XDG/mcporter; otherwise
// falls back to the legacy ~/.mcporter/ (preserves single-user dev behavior).
export function mcporterDir(kind: XdgKind): string {
  const value = process.env[XDG_VARS[kind]];
  if (value && value.trim() !== '') {
    return path.join(value, 'mcporter');
  }
  return path.join(os.homedir(), '.mcporter');
}

Then at each existing call site:

// src/oauth-vault.ts:27
return path.join(mcporterDir('data'), 'credentials.json');

// src/schema-cache.ts:15
return definition.tokenCacheDir ?? path.join(mcporterDir('cache'), definition.name);

// src/oauth-persistence.ts:242,277 (legacy fallback — keep using os.homedir())
const legacyDir = path.join(os.homedir(), '.mcporter', definition.name);

// src/cli/config/add.ts:92
return path.join(mcporterDir('config'), 'mcporter.json');

// src/config/path-discovery.ts:58-59 and src/cli/config/shared.ts:130-131
const base = mcporterDir('config');

// src/daemon/paths.ts:10 (preserve MCPORTER_DAEMON_DIR override precedence)
return path.join(mcporterDir('state'), 'daemon');

Note: the legacy migration code in oauth-persistence.ts (the legacyDir = path.join(os.homedir(), '.mcporter', definition.name) lookup) keeps using os.homedir() directly — ~/.mcporter/ remains the right place to find legacy files even after the new default moves to XDG paths.

Related issues

  • #139 — also reports headless/server deployment friction (can't see OAuth URL); same broader category of "mcporter assumes interactive single-user environment."
  • #58 — long-running daemons; another use case where stable, redirectable on-disk state matters.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions