Files
sherlock/docs/architecture.md
T
amacocian 28dcd5b8e6
CI / build (push) Failing after 1s
Grafana MCP
2026-06-13 11:43:26 +02:00

5.6 KiB

Architecture

Condensed from ../plan.md. Read this first; jump to the plan for the longer rationale.

One-paragraph summary

Sherlock is a per-user credential broker + agent-CLI wrapper that runs on the operator's workstation. It owns a single Authentik session (persisted in the OS keyring), exchanges it for per-service tokens on demand, injects those tokens as environment variables into thin stdio MCP servers, and then execs the agent CLI of your choice (Copilot, Claude Code, …) with the right MCP config. No long-lived service tokens live on disk in the clear, and the agent never sees a credential it isn't supposed to.

Diagram

flowchart TD
    user["user shell"] -->|`sherlock copilot`| sh[sherlock CLI]

    sh -->|syscall.Exec| agent[copilot / claude CLI]
    sh -.->|writes MCP config| mcpfile[(XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json)]
    agent -.->|reads| mcpfile

    agent -->|stdio| giteamcp[gitea-mcp]
    agent -->|stdio| grafanamcp[grafana-mcp]
    agent -->|stdio| minifluxmcp[miniflux-mcp]

    giteamcp -->|authn.Ensure| wallet[(OS keyring<br/>wallet: one TokenSet per service)]
    grafanamcp -->|authn.Ensure| wallet
    minifluxmcp -->|authn.Ensure| wallet
    sh -->|status / logout| wallet

    wallet -->|OAuth PKCE flow on miss<br/>refresh on stale<br/>flock-serialised| authentik[Authentik<br/>gitea provider · grafana provider ·<br/>miniflux provider · …]

Each MCP authenticates against the Authentik provider for its own service, lazily, the first time it runs. The wallet caches the result; subsequent runs are silent until the refresh window opens. There is no master session.

Components

Component Lives at Owns
sherlock (CLI) cmd/sherlock/ The only binary the operator runs. status, logout [<service>], run, agent-name aliases (copilot, claude, …). At spawn: looks up the agent in internal/agent/, renders the per-session MCP config, execs the agent. There is no sherlock login — see auth-model.md.
internal/agent/ One Go file per supported CLI (copilot.go, claude.go, …), each registering itself via init(). Shared exec/env helpers live alongside. See agents.md.
internal/authn/ OAuth/OIDC primitives + the high-level Ensure(ctx, store, service, cfg, opts) that every MCP calls at startup. See auth-model.md.
internal/keyring/ OS keyring wallet, service-keyed: Get / Set / Clear / List per service. Open() is the only constructor; it probes the keyring and fails fast. See storage.md.
internal/mcp/ Per-format MCP-config renderers (VS Code shape for Copilot, .mcp.json shape for Claude Code).
internal/xdg/ Resolves $XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json and the refresh-lock path.
cmd/gitea-mcp/ (Phase 2) First per-service MCP. Reads service config from ~/.config/sherlock/services.d/gitea.toml, calls authn.Ensure(store, "gitea", cfg, opts) at startup, then serves MCP requests.
cmd/gssh-mcp/ (Phase 3) Thin HTTP+WS client to the existing gssh server. No local certs. See gssh-integration.md.
cmd/grafana-mcp/ Imports upstream mcp-grafana as a Go package and serves its read-only tools in-process; injects a sherlock-renewed OAuth bearer per request. Requires Grafana [auth.jwt]. See grafana-mcp.md.
cmd/sherlock-mcp/ (Phase 4) The only interactive MCP. Exposes auth.list_sessions(), auth.revoke(service), etc., so the agent can query/manage the wallet through tool calls.

Why there is no daemon, and no sherlock login

The Phase 1 design had a separate sherlock-broker daemon (forked-child, UDS RPC, PID-file flock, idle timer). It was removed in the post-Phase-1 refactor — Decisions #9 (JSON-over-newline RPC) and #10 (forked-child broker) are both superseded. Then sherlock login itself was removed in a follow-up refactor, because preemptive authentication never matched the actual topology.

Reasoning:

  • No daemon needed. The OS keyring is already the single source of truth across processes. Cross-process refresh races are solved by an exclusive flock() on $XDG_RUNTIME_DIR/sherlock.refresh.lock with the canonical "take lock → re-read → maybe refresh" pattern in internal/authn/refresh.go.
  • No master session. Caddy + each downstream service have their own Authentik OAuth providers with their own audiences, scopes, and consent screens. A single master token cannot satisfy all of them. Per-service tokens are the right granularity, and per-service tokens are best fetched on demand by the MCP that needs them.
  • No sherlock login step. Pre-authenticating to N services up-front for the case where the user only ends up using one of them is wasted browser flows. Lazy-on-first-use is the right default; the wallet caches the rest.

What we lose: a small amount of latency overhead per refresh (libsecret D-Bus round-trip ~1ms) and the ability to handle a mid-session OAuth pop-up without a user click. What we gain: no daemon lifecycle, no PID files, no IPC protocol, no idle timers, no preemptive-login state machine, and "is sherlock-broker still running" stops being a debugging path.

Boundaries

  • Sherlock does not federate identities — that's Authentik's job.
  • Sherlock does not issue SSH certificates — gssh already does that internally when it authenticates a WebSocket session. See gssh-integration.md.
  • Sherlock does not know what tools an MCP exposes — that's the MCP's job. Sherlock only ensures the MCP has the credential it needs.