Skip to content

fix: server.json audit corrections + OCI ownership label#56

Merged
aliasunder merged 11 commits into
mainfrom
claude/server-json-audit-fixes
May 20, 2026
Merged

fix: server.json audit corrections + OCI ownership label#56
aliasunder merged 11 commits into
mainfrom
claude/server-json-audit-fixes

Conversation

@aliasunder
Copy link
Copy Markdown
Owner

@aliasunder aliasunder commented May 20, 2026

Summary

Follow-up to #55. A thorough audit of server.json against the 2025-12-11 MCP server schema, the env vars the code actually reads, the published Docker image, the GitHub repo metadata, and all four compose files.

The guiding realization: the MCP Registry path consumes server.json only — the compose files are never involved. A client pulls the OCI image and builds a docker run purely from server.json's runtimeArguments + environmentVariables + transport. So server.json must be self-sufficient, and container-internal implementation details must live in the image (Dockerfile ENV), not be exposed as phantom config knobs.

Critical fixes (the published entry would not work)

  1. Dockerfile — OCI ownership label. mcp-publisher publish verifies image ownership via LABEL io.modelcontextprotocol.server.name, which must match name in server.json. Was missing.
  2. packages[0].identifierghcr.io/aliasunder/vault-cortexghcr.io/aliasunder/vault-mcp. The published image is vault-mcp (see deploy.yml:22 + all compose files); the vault-cortex image never existed, so the old entry pointed at a 404.
  3. repository.id"aliasunder/vault-cortex""1226067541" (the numeric GitHub repo ID the schema requires).

Container internals moved to the Dockerfile (removed from server.json)

These four are hardcoded in every compose file — never user-tunable — so they're implementation details, not knobs. Each was overloaded or latently buggy when exposed in environmentVariables:

  • VAULT_PATH — was both the user's host path (in -v) and the container path (env var). Now baked ENV VAULT_PATH=/vault; the only VAULT_PATH a user sees is the host side of the -v {VAULT_PATH}:/vault:rw bind mount.
  • PORT — was the host port (-p {PORT}:8000) AND the container listen port (env var). Overriding it desynced them (-p 9000:8000 while the server moved to 9000 → connection refused). Now baked ENV PORT=8000; PORT survives only as the host-side mapping variable.
  • HOST — overriding to 127.0.0.1 made the server unreachable through the port mapping. Now baked ENV HOST=0.0.0.0.
  • INDEX_DB_PATH — hardcoded /data/index.db everywhere. Now baked ENV INDEX_DB_PATH=/data/index.db.

Final Dockerfile runtime ENV: NODE_ENV=production PORT=8000 HOST=0.0.0.0 VAULT_PATH=/vault INDEX_DB_PATH=/data/index.db.

server.json now describes the real consumer experience

  • transport.headers — declares Authorization: Bearer {MCP_AUTH_TOKEN} so clients know what to send to /mcp.
  • runtimeHint: "docker" + runtimeArguments-p {PORT}:8000, -v {VAULT_PATH}:/vault:rw (bind mount, the user's vault), and -v vault-cortex-data:/data (named volume — persists search index, OAuth token DB, and logs across restarts, matching deploy/local's mcp_data:/data).
  • PUBLIC_URLisRequired: false with default: "http://localhost:8000" (the server .required()s it; the default lets the local quickstart work without manual override).
  • Env-var list trimmed to the 10 genuinely user-tunable values, each with the correct default sourced from the code/compose: MCP_AUTH_TOKEN, PUBLIC_URL, MEMORY_DIR, TZ, LOG_LEVEL, LOG_DIR, LOG_RETENTION_DAYS, PROTECTED_PATHS, ORPHAN_EXCLUDE_FOLDERS, SERVICE_DOCUMENTATION_URL.
  • LOG_DIR — no default (opt-in), matching the deploy/local change below. Logs go to stdout unless the user sets a path.
  • Description — dropped the misleading "Remote" prefix → MCP server for Obsidian vaults — search, memory, link graph, 23 tools, OAuth-protected. (87 chars, under the 100 max). "Remote" implied a hosted URL we don't offer; the package describes a local docker run.

Compose + .env.example consistency

  • All four compose files now document the runtime-derived smart defaults inline (PROTECTED_PATHS, ORPHAN_EXCLUDE_FOLDERS, SERVICE_DOCUMENTATION_URL) instead of pointing at the README.
  • LOG_DIR made user-overridable in deploy/local (${LOG_DIR:-} — opt-in, file logging off by default for a quick local trial) and deploy/remote (${LOG_DIR:-/data/logs} — on by default for an unattended server). Documented in both .env.example files. Root docker-compose.yml left hardcoded (its .env.example already documents LOG_DIR as intentionally fixed for the Lightsail deploy).
  • Adopter compose comment — added the upstream-fix tracker hint to deploy/remote's init-config-perms comment, mirroring the root file.

Validation

  • jq schema-shape check: all required fields present at every level
  • Description: 94 → 87 chars (under the 100 max)
  • All 10 declared env vars verified to be env.get(NAME) / env.NAME reads in src/; the 4 removed ones (VAULT_PATH, PORT, HOST, INDEX_DB_PATH) are baked into the Dockerfile ENV
  • All four compose files re-parsed as valid YAML
  • 496 / 496 tests pass; lint + tsc --noEmit clean; prettier clean

⚠️ Hard gate before registry submission: cut v0.15.4 first

server.json on this branch only works against an image built from this branch's Dockerfile. The released v0.15.3 image bakes only PORT + HOST — not VAULT_PATH/INDEX_DB_PATH. Since VAULT_PATH is .required() (server.ts:35) and is no longer in server.json, a container spawned from this server.json against the v0.15.3 image would crash on startup.

Required order:

  1. Merge this PR
  2. Cut v0.15.4 via Manual Release — rebuilds the image with the new Dockerfile ENV and bumps packages[0].version0.15.4
  3. Then mcp-publisher publish — now the registry references the v0.15.4 image that has the bakes

Sources:

claude added 11 commits May 20, 2026 00:04
Full audit of server.json against the 2025-12-11 MCP server schema, the
actual env vars the code reads, the published Docker image, and GitHub
repo metadata. Eight fixes:

Critical (the existing entry would not work as published):
1. Dockerfile: add `LABEL io.modelcontextprotocol.server.name=...` —
   required for `mcp-publisher publish` ownership verification against
   the OCI image manifest.
2. packages[0].identifier: ghcr.io/aliasunder/vault-cortex →
   ghcr.io/aliasunder/vault-mcp. The published image is vault-mcp
   (see .github/workflows/deploy.yml + all docker-compose files);
   the vault-cortex image does not exist.
3. repository.id: "aliasunder/vault-cortex" → "1226067541". Schema
   requires the numeric GitHub repo ID (from
   `gh api repos/aliasunder/vault-cortex --jq '.id'`), not the slug.

High-impact gaps (consumers couldn't auto-configure):
4. packages[0].transport.headers: declare
   `Authorization: Bearer {MCP_AUTH_TOKEN}` so clients know what to
   send on the wire to /mcp. Previously the server required bearer
   auth but no header declaration existed.
5. packages[0].runtimeHint + runtimeArguments: declare `docker` plus
   `-p {PORT}:8000` and `-v {HOST_VAULT_PATH}:/vault` so clients can
   construct a valid `docker run` from server.json alone.

Env-var inaccuracies:
6. PUBLIC_URL: keep isRequired: false but add `default:
   http://localhost:8000`. Server crashes on startup without
   PUBLIC_URL (see src/vault-mcp/server.ts:36 — `.required()`);
   the default lets the local quickstart work without manual override.
7. Add eight missing env vars the server actually reads — verified
   by grepping `env.get(...)` / `env.X` across `src/`: PORT, HOST,
   INDEX_DB_PATH, LOG_DIR, LOG_RETENTION_DAYS, PROTECTED_PATHS,
   ORPHAN_EXCLUDE_FOLDERS, SERVICE_DOCUMENTATION_URL. All optional
   with defaults sourced from the code where they exist.
8. VAULT_PATH description clarified to distinguish the container
   path (/vault, declared here) from the host path (declared via
   the -v runtime argument's HOST_VAULT_PATH variable).

Out of scope: icons (waits for ^icon-400), remotes (no public
hosted service), URL templating against PORT in transport.url
(done — already templated as {PORT}).

Verification: jq schema-shape check passes (all required fields
present); description 94 chars (under 100 max); identifier matches
the actual published image; repository.id matches GitHub API;
all 14 declared env vars exist in the source. 496/496 tests pass,
lint + tsc clean.

After merge, cut v0.15.4 to publish the labeled image — only then
can `mcp-publisher publish` succeed against the MCP Registry.
The word was ambiguous and misleading for the registry entry:

- Transport sense ("HTTP-based MCP server, not stdio") — true, but
  redundant: OAuth-protected already implies HTTP.
- Deployment sense ("hosted somewhere far away") — false for the
  package as published. The package describes a `docker run` on the
  consumer's own machine; no public hosted URL is offered.

A registry consumer reading "Remote MCP server" reasonably expects a
hosted URL they could plug into Claude Desktop. We don't provide one
(the personal Lightsail deploy is not a public multi-tenant service).
Dropping "Remote" makes the description match what the package
actually delivers.

87 chars (was 94, still under the 100-char schema max).
Mirrors the comment in the repo root's docker-compose.yml so adopters
self-hosting from deploy/remote/ also see the upstream tracker link
and know the workaround is temporary.
VAULT_PATH was overloaded — appearing as both the user-facing host path
in runtimeArguments (as HOST_VAULT_PATH) and the container env var in
environmentVariables (default "/vault"). Two knobs for what users
should see as one concept.

Aligned with the README + deploy/local/.env.example convention: users
only see VAULT_PATH meaning "where your vault lives on this machine."

- Dockerfile: bake VAULT_PATH=/vault into the runtime ENV, alongside
  PORT/HOST/NODE_ENV. Container always has it; no need to expose as
  a configurable knob.
- server.json: drop VAULT_PATH from environmentVariables (now a
  Dockerfile internal). Rename HOST_VAULT_PATH → VAULT_PATH in
  runtimeArguments so the only VAULT_PATH a user sees means the
  same thing it means in their .env.
- Compose files: untouched. Their explicit `VAULT_PATH: /vault` env
  lines are now redundant no-ops but harmless.
Both vars are hardcoded in all compose files but had no default in
server.json, so a `docker run` constructed from server.json alone
would diverge from the compose user experience:

- INDEX_DB_PATH: compose sets /data/index.db; without it the code
  falls back to /data/search.db (different filename). Documented
  default /data/index.db to match.
- LOG_DIR: compose sets /data/logs to enable persistent file logging;
  without it the file sink is off entirely (stdout only). Documented
  default /data/logs so registry-spawned containers log to disk like
  compose deployments do. Noted that an empty string disables it.

Traced all 13 env vars against the compose files + src/: every
optional var with a static default now declares it. PROTECTED_PATHS
and ORPHAN_EXCLUDE_FOLDERS intentionally have no `default` field —
their defaults derive from MEMORY_DIR at runtime and can't be a
static string, so they stay documented in the description.
PROTECTED_PATHS / ORPHAN_EXCLUDE_FOLDERS / SERVICE_DOCUMENTATION_URL
have runtime-derived smart defaults (config.ts) that previously were
only discoverable via the README. Stated them inline in all four
compose files so operators editing them see the actual defaults:

- PROTECTED_PATHS           = "<MEMORY_DIR>, Daily Notes"
- ORPHAN_EXCLUDE_FOLDERS    = "Daily Notes, Templates, <MEMORY_DIR>"
- SERVICE_DOCUMENTATION_URL = https://github.com/aliasunder/vault-cortex

Root pair (docker-compose.yml, docker-compose.local.yml) keep the
active empty-default form; deploy pair (deploy/local, deploy/remote)
keep the commented-out form. Comments adapted to each.
Both were exposed as user-tunable container env vars while ALSO being
used as the host side of the port mapping / bind — the same host-vs-
container overload as the earlier VAULT_PATH fix.

PORT appeared in three places: environmentVariables (container listen
port), runtimeArguments `-p {PORT}:8000` (host port), and transport.url
(host port). Overriding it desynced them — e.g. PORT=9000 makes the
server listen on 9000 inside the container while `-p 9000:8000` still
maps host 9000 to container 8000 → connection refused. HOST had the
same latent break (127.0.0.1 would make the server unreachable through
the port mapping).

The container always listens on 0.0.0.0:8000 — already baked into the
Dockerfile ENV. Removing both from environmentVariables leaves PORT
only as the host-side variable in `-p {PORT}:8000` + the transport URL,
so overriding it now works correctly. Every remaining env var is a
value the server actually reads with no runtime-mechanics conflict.
LOG_DIR was hardcoded (LOG_DIR: /data/logs) in the deploy/local and
deploy/remote compose files, so users couldn't override it even though
server.json advertises it as optional with that default. Switched both
to ${LOG_DIR:-/data/logs} and documented it in the matching
.env.example files (set empty to disable file logging).

Root docker-compose.yml left as-is — its .env.example already documents
LOG_DIR as intentionally fixed for the Lightsail deploy.
deploy/local defaulted LOG_DIR to /data/logs, turning on persistent
file logging for every local user. A local user just trying vault-cortex
out doesn't necessarily want log files accumulating — stdout is enough.

Switched deploy/local to ${LOG_DIR:-} (empty default = file logging off;
set a path in .env to opt in). deploy/remote keeps the /data/logs default
since an unattended server benefits from persistent logs. Updated the
deploy/local .env.example wording to match (opt-in, not default-on).
…parity

The registry path spawns containers purely from server.json's
runtimeArguments — the compose files aren't involved — so a
registry-spawned container previously had no /data volume: OAuth tokens,
search index, and logs were all ephemeral, lost on restart.

Brought server.json to parity with the deploy/local compose experience:
- Add `-v vault-cortex-data:/data` named volume so OAuth sessions, the
  search index, and any logs persist across restarts (matches
  deploy/local's mcp_data:/data).
- Make the vault mount explicitly :rw (matches deploy/local).
- Drop LOG_DIR's default so file logging is opt-in (matches the
  deploy/local ${LOG_DIR:-} change — local users don't get log files
  unless they set LOG_DIR=/data/logs).
INDEX_DB_PATH is hardcoded (INDEX_DB_PATH: /data/index.db) in all four
compose files — never user-overridable — so it's a container-internal
implementation detail, not a config knob. Same category as VAULT_PATH,
PORT, and HOST.

Baked INDEX_DB_PATH=/data/index.db into the Dockerfile ENV (keeps the
registry-spawned container on the same filename as the compose flow,
rather than the code's /data/search.db fallback) and removed it from
server.json environmentVariables. The remaining 10 declared env vars
are all genuinely user-tunable.
@aliasunder aliasunder merged commit f0362d8 into main May 20, 2026
5 checks passed
@aliasunder aliasunder deleted the claude/server-json-audit-fixes branch May 20, 2026 02:05
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