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 getis its own auth check. If 1Password is locked,opreturns 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,npxare 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¶
- Create wrapper from the pattern above (use an existing simple wrapper as template)
- Store credentials in the appropriate vault:
scandora-automation— shared services, always accessiblescandora-dev-automation— dev-only infrastructurescandora-prd-automation— production (requires explicit token load)- Register in the right config:
- Needed everywhere →
~/.claude/mcp.json - Project-specific →
scandora.net/.mcp.json - Test:
bash scripts/new-mcp-wrapper.shdirectly 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