Skip to content

MCP Server Authentication Architecture

Design Principles

  • No service accounts on luna. Luna is a developer workstation — the human is the identity. 1Password desktop app integration is the correct auth mechanism: biometric-gated, session-scoped, no long-lived tokens at rest.
  • No explicit auth checks in wrappers. op item get is its own auth check. If 1Password is locked, op returns a clear error. No preamble needed.
  • PATH bootstrap, not auth bootstrap. GUI-launched apps (Claude Desktop) inherit a bare system PATH. Wrappers fix PATH so op, docker, uvx, npx are found. That is the only boilerplate each wrapper needs.

How It Works

User unlocks 1Password (Touch ID, once per session)
1Password desktop app exposes Unix socket
Claude Code launches wrapper script via mcp.json / .mcp.json
Wrapper fixes PATH → op-cache.sh (cache hit: <50ms, miss: op item get)
Credential injected as env var → exec MCP server

All MCP wrappers and GitHub scripts route through scripts/1password/op-cache.sh, which caches credentials in /tmp/.op_cache_<uid>_<sha256> (mode 0600, 8-hour TTL). After the first call for a given item+vault+field combination — whether from an MCP wrapper, a skill script, or a manual invocation — all subsequent callers return the cached value without touching 1Password.

This works from any process in the macOS user session — terminals, GUI apps, Claude Desktop, Cowork — as long as the 1Password desktop app is unlocked. Between reboot and first Touch ID, op item get fails with a clear error; this is correct behavior.

Configuration Levels

User-level (~/.claude/mcp.json) — available in every project

Server Wrapper Vault Item Scopes
github scripts/github-mcp-wrapper.sh scandora-automation github_pat_mcp_claude repo + project + read:org

GitHub is registered at user-level because it's needed in every Claude Code session, including blank projects and non-scandora.net directories.

Project-level (scandora.net/.mcp.json) — scandora.net work only

Server Wrapper Vault Item(s)
opnsense scripts/opnsense-mcp-wrapper.sh dev/prd-automation opnsense_api_key_*
grafana-dumbo scripts/grafana-mcp-wrapper.sh scandora-automation dumbo_grafana_service_token
grafana-ha scripts/grafana-mcp-wrapper.sh scandora-automation grafana_service_token_ha_owl
postgres-dumbo scripts/postgres-mcp-wrapper.sh scandora-automation cloud_sql_scandora_postgres_user_joe
gitlab scripts/gitlab-mcp-wrapper.sh scandora-automation gitlab_mcp_api_token
homeassistant scripts/homeassistant-mcp-wrapper.sh scandora-automation homeassistant_mcp_server

Wrapper Script Pattern

Most wrappers follow this minimal pattern:

#!/usr/bin/env bash
set -euo pipefail

# Fix PATH for GUI-launched apps (Claude Desktop doesn't source ~/.zshrc)
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/Users/joe/.local/bin:$PATH"

# Retrieve credential via caching helper — fails naturally if 1Password is locked
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
TOKEN=$("$SCRIPT_DIR/1password/op-cache.sh" "item_name" "vault-name" "credential" 2>/dev/null)

if [ -z "$TOKEN" ]; then
    echo "Error: Could not retrieve credential from 1Password" >&2
    exit 1
fi

export SERVICE_TOKEN="$TOKEN"
exec mcp-server-command

The empty-string check catches both "1Password locked" and "item not found" — op item get redirects its own error to /dev/null so the wrapper provides a clear diagnostic.

Cache invalidation (after credential rotation)

After rotating a credential in 1Password, clear its cache entry:

# GitHub PAT
scripts/1password/op-cache.sh github_pat_mcp_claude scandora-automation credential --invalidate

# Any other credential
scripts/1password/op-cache.sh <item> <vault> <field> --invalidate

Production Credentials (OPNsense)

OPNsense production targets (owl, blue) require credentials from scandora-prd-automation, which is gated by Touch ID per-use (not auto-unlocked by the desktop app session).

# Load prod token before starting Claude Code for infrastructure work
source scripts/1password/load-prod-token.sh

# Set target
export OPNSENSE_TARGET=owl  # or blue

claude

Troubleshooting

"Could not retrieve credential from 1Password"

1Password is locked. Unlock it (Touch ID via the menu bar app) and retry. Do not run op signin — that's the CLI session pattern, not needed on luna.

MCP server shows as disconnected immediately

Test the wrapper directly:

bash scripts/github-mcp-wrapper.sh
# Should hang waiting for stdio (that means auth succeeded)
# Ctrl+C to exit

Wrong PATH (op, docker, uvx not found)

All wrappers export /opt/homebrew/bin at the top. If a binary is installed elsewhere, add its path to the PATH line in the specific wrapper.

OPNsense dev target unreachable

Requires SSH IAP tunnel via dev-up.sh. The wrapper target defaults to dev and expects https://localhost:8443.

Adding New MCP Servers

  1. Create wrapper from the pattern above (use an existing simple wrapper as template)
  2. Store credentials in the appropriate vault:
  3. scandora-automation — shared services, always accessible
  4. scandora-dev-automation — dev-only infrastructure
  5. scandora-prd-automation — production (requires explicit token load)
  6. Register in the right config:
  7. Needed everywhere → ~/.claude/mcp.json
  8. Project-specific → scandora.net/.mcp.json
  9. Test: bash scripts/new-mcp-wrapper.sh directly before adding to config

Reference

  • Wrapper scripts: scripts/*-mcp-wrapper.sh
  • User-level config: ~/.claude/mcp.json
  • Project config: .mcp.json (repo root)
  • Vault architecture: CLAUDE.md → Credentials & Secrets
  • Reboot validation: docs/operations/REBOOT-CHECKLIST.md