GitHub - Wpnx330/pharos-cli: Go CLI for the PHAROS MCP package registry. Search, install, and manage MCP servers.

GitHub

34 min read Original article ↗

Pharos CLI

CI Go Report Card License: MIT GitHub stars

CLI Demo

A Go CLI tool for the PHAROS MCP server package registry.

Install

Linux / macOS / WSL / Git Bash:

curl -fsSL getpharos.dev/install | sh

Windows PowerShell (not cmd — curl | sh is not a cmd command):

irm https://getpharos.dev/install.ps1 | iex

Until /install.ps1 is live on the site, download the .exe from the latest release (pharos_2.0.0_windows_amd64.exe) and put it on your PATH.

From source:

go install github.com/Wpnx330/pharos-cli@latest
# or
go build -o pharos .

Full command reference: https://discoverpharos.dev/cli/docs

Homebrew & Scoop

macOS (Homebrew):

brew tap Wpnx330/pharos && brew install pharos

Windows (Scoop; two lines — PowerShell 5.1 has no &&):

scoop bucket add pharos https://github.com/Wpnx330/Scoop-Bucket
scoop install pharos

Commands

# Discovery
pharos search <query>          # Search the registry
pharos search <query> --transport http-sse --registry pharos -p 2
pharos info "<package-id>"     # Quote IDs with spaces or (

# Package lifecycle
pharos init                    # Scaffold a new pharos.json (interactive, includes dependency + tag prompts)
pharos init --yes              # Non-interactive (use defaults)
pharos package [dir]           # Package a directory into a tarball (like npm pack)
pharos publish [dir]           # Package + upload + publish to the registry
pharos unpublish <name>        # Hide a package version from search and lookup
pharos republish <name>        # Re-activate a previously unpublished version
pharos purge <name>            # Permanently remove a package version from the registry

# Local management
pharos install <name>          # Download and install a package (with recursive dependency resolution)
pharos install <name> --no-dep-config  # Install without writing MCP client configs for dependencies
pharos install <name> --idle-timeout 30  # Auto-unload after 30min idle (default: 60)
pharos install <name> --idle-timeout 0   # Never unload — always on
pharos install <name> --profile work     # Write only the profile's mapped clients + attach it
pharos list                    # List locally installed packages
pharos list --running          # Show only running servers (daemon-managed)
pharos start <name>            # Start a locally installed MCP server
pharos stop <name>             # Stop a running MCP server
pharos update [name]           # Check for and apply updates to installed servers — installs the new version and rewrites affected client configs
pharos update --all            # Update every server in pharos.lock
pharos update --dry-run        # Preview what an update would change without applying it
pharos update --check          # Same no-apply preview + repo/changelog links for git-hosted servers
pharos pin <name>              # Pin a server at its installed version (see "Version pinning")
pharos pin <name> <version>    # Install a specific version, then pin it
pharos unpin <name>            # Release the pin
pharos lock                    # Resolve dependencies and write ./pharos.lock
pharos import                  # Import existing MCP client configs into a pharos.lock
pharos import --adopt          # One-command onboarding: adopt every detected client config as the managed baseline (conflicts resolved interactively)
pharos remove <name>           # Remove a locally installed package
pharos remove <name> --force   # Remove even if other packages depend on it (not a confirm skip)
pharos try <name>              # Probe a server's live capabilities without wiring it (see "Try before you wire")
pharos try <name> --inspect    # Launch MCP Inspector against the server (needs npx)

# Profiles (contexts — see "Profiles (contexts)" below)
pharos profile create work --client cursor   # Map a named context to clients
pharos profile add work <server...>          # Attach already-installed servers
pharos profile ls --json                     # The context map (agent-readable)
pharos profile use work                      # Reconcile mapped clients to exactly this context (plan + confirm)
pharos profile rm work                       # Delete the profile (servers stay installed)

# Daemon (MCP server process supervisor)
pharos daemon start            # Start the daemon (backgrounds by default)
pharos daemon start --foreground  # Run in foreground (for debugging)
pharos daemon stop             # Stop daemon + unload all managed servers
pharos daemon restart          # Stop + start (convenience)
pharos daemon status           # Show daemon health + managed server table
pharos daemon log              # Show recent daemon log output (default 50 lines)
pharos daemon log -n 100       # Show last 100 lines of daemon log
pharos daemon autostart --on   # Enable autostart on boot
pharos daemon autostart --off  # Disable autostart on boot
pharos daemon autostart        # Show current autostart status
pharos budget                  # Idle-cost budget — resident processes, memory, unload suggestions

# Secure tunnel sharing (token-authed reverse proxy)
pharos expose web --addr :9500         # Share daemon-managed 'web' on port 9500 (token shown once)
pharos expose web --addr 127.0.0.1:9500  # Token-authed loopback-only share
pharos expose web --addr :9500 --ttl 2h  # Shorter lifetime (default 8h, max 24h)
pharos expose web --addr :9500 --background  # Detach after showing the token
pharos expose list             # Show exposes + liveness (live/stopped/expired)
pharos expose stop web         # Graceful stop (or clean up a stale entry)

# Auth
pharos login                   # GitHub OAuth login (opens browser)
pharos whoami                  # Show current authenticated user

# OAuth configuration
pharos oauth configure <name>  # Configure OAuth for a published MCP server
pharos oauth configure <name> \ # With all options
  --auth-url <url> --client-id <id> --scopes <scopes> --pkce

# System
pharos doctor                  # Diagnose CLI health — registry, servers, client configs
pharos audit                   # Scan installed servers for known vulnerabilities
pharos config <key> [value]    # Get or set configuration
pharos config add-client <id> --path <path>   # Register a custom MCP client
pharos config remove-client <id>
pharos config list-clients
pharos health                  # Check registry health
pharos version                 # Print CLI version

One-line list only. Full flags and examples: https://discoverpharos.dev/cli/docs. Command names come from cmd/*.go (Use:). If this README and the site disagree, the Go source wins.

Flags

  • --json — Output as JSON (search, info, health). Search includes transport, source_registry, nextCursor, total, boosted (always present, [] when none).
  • --limit / -n — Number of search results (default: 10)
  • --page / -p — Search page (1-based). Mapped to API cursor (page-1)*limit.
  • --registry — Search filter: mcp.io, mcp.so, pharos, smithery
  • --transport — Search filter: stdio, http-sse, streamable-http, sse, http
  • --version / -v — Install a specific version
  • --global / -g — Install system-wide
  • --token / -t — Auth token for publishing
  • --dry-run — Validate manifest without publishing
  • --yes — Skip interactive prompts (init)
  • --no-dep-config — Don't write MCP client configs for dependencies (install)
  • --force — Remove a package even if other packages depend on it (remove)
  • --frozen — Install strictly from lockfile; refuse if missing or mismatched (install)
  • --idle-timeout — Per-server minutes of inactivity before auto-unloading that server's HTTP/SSE process (default: 60, 0 = never unload). Set at install time; stored per-server in ~/.pharos/mcp.json (install)
  • --running — Show only running daemon-managed servers (list)
  • --diff — Compare pharos-managed server entries in client configs against the pharos.lock baseline; read-only drift report (doctor)
  • --timeout — Total probe budget for pharos try (Go duration, default 10s; each JSON-RPC request is also capped at 10s)
  • --inspect — Print and launch the npx MCP Inspector command for the server instead of probing (try)

Try before you wire

Installing a server shouldn't be a leap of faith. pharos try <name> spawns the server exactly as it is configured in ~/.pharos/mcp.json, speaks real MCP to it (initialize handshake over stdio JSON-RPC), and prints its live capabilities in about five seconds — no client config is touched:

$ pharos try echo-server
Probing echo-server…
✓ echo-server v1.2.3 — MCP 2025-06-18

  TOOLS (2)
    echo                     Echo back the provided message
    status                   Return server status
  RESOURCES (2): notes, file:///config.json
  PROMPTS (1): greeting
  • Honest failures: if the server won't start, fails the handshake, or hangs, try exits 1 and shows the server's actual stderr (last 10 lines) — not just "exit status 1". Unknown server: exit 2 with an install hint (pharos install <name> --sandbox probe-only installs are planned).
  • --json (PHAROS_JSON=1): a single stdout JSON document {server, caps: {protocolVersion, serverInfo, tools[], resources[], prompts[]}} — probe failures emit {server, errors, stderr_tail}; pre-flight failures (config unreadable, unknown server, non-stdio) emit the minimal {server, errors} document. Never prompts; progress lines go to stderr. Built for the agent-automation contract.
  • --timeout 30s: raise the total probe budget (default 10s).
  • --inspect: prints the exact npx -y @modelcontextprotocol/inspector … command for this server, then launches it in the foreground (honest error if npx isn't on PATH). --inspect --json only reports the command without spawning.

try probes stdio servers only. Env vars from the canonical config are passed through as-is (${secret:…} templates are not resolved in this wave).

Search Results

The search table shows the trust signals the registry already provides: OWNER (publisher namespace), CATEGORY (registry catalog category), and DOWNLOADS (30-day downloads, humanized — 1234 renders as 1.2k). A non-active version_status is flagged inline on the version, e.g. 1.2.3 (stale); active versions stay bare. Missing values render as —. --json carries the same fields (publisher, category, tools_count, version_status) per result.

Sponsored placements (W4.2 D1): when the registry has active boosts matching your query, a SPONSORED block prints above the organic table — same columns, max 2 rows, each name suffixed [boosted]. Sponsored entries never reorder organic results, and with no active boosts the output is unchanged. --json exposes them as a separate boosted array (each entry "sponsored": true); organic results are never mixed in.

Configuration

Config is stored at ~/.pharos/config.json:

pharos config registry https://getpharos.dev
pharos config token <your-token>

Credentials are stored at ~/.pharos/credentials.json after pharos login.

Manifest (pharos.json)

{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "description": "An MCP server for X",
  "license": "MIT",
  "transport": "stdio",
  "runtime": "python",
  "command": "python server.py",
  "capabilities": ["tools"],
  "homepage": "https://github.com/user/repo",
  "repository": "https://github.com/user/repo",
  "files": ["server.py", "lib/"],
  "tags": ["weather", "doppler", "humidity"]
}

Manifest fields

Field Required Description
name yes Package name (unscoped or @scope/name)
version yes Semantic version
description no Short description shown in search results
license no SPDX license identifier
transport yes stdio, http-sse, streamable-http, or http
runtime no Runtime hint: npx, uvx, docker, binary, python (auto-detected from command if omitted)
command no Explicit launch command (e.g. python -m src.server). Overrides runtime-based construction. Falls back to bin field for backwards compat
entrypoint no Alternative to command (Docker entrypoint)
capabilities yes MCP capability types: tools, resources, prompts, logging
files no Explicit file list to package (auto-detected if omitted). Directory entries (e.g. "src/") are packed recursively
homepage no Homepage URL
repository no Repository URL
dependencies no Array of {name, version} — semver constraints for recursive install
tags no Array of up to 3 text hashtags for discoverability (lowercase, alphanumeric + hyphens, max 20 chars each)

Publishing

The pharos publish command runs a 4-phase flow automatically:

  1. Get user — GET /v1/auth/me to determine your namespace
  2. Create package — POST /v1/packages (creates the package row; 409 = already exists, OK)
  3. Upload — POST /v1/uploads → PUT tarball bytes to presigned URL
  4. Publish — PUT /v1/packages/{name} with version, manifest, blob reference, integrity hash
pharos publish ./my-mcp-server

You can also package separately:

pharos package ./my-mcp-server    # creates my-mcp-server-1.0.0.tgz

Safe Config Writes

All MCP client config writes (install and remove) go through a safe write pattern:

  1. Write to temp file — changes go to <config>.pharos-tmp, never the original directly
  2. Validate — the temp file is read back and checked:
    • Non-empty (0 bytes = abort)
    • Parseable as the expected format (YAML for Hermes, JSON for others)
    • Size check: if the original was >200 bytes and the new file is <25% of it, abort (catches truncation/corruption)
  3. Atomic swap — rename temp over original (atomic on most filesystems)
  4. Cleanup — if any step fails, the temp file is deleted and the original is left untouched

This means a buggy write can never corrupt an existing config — the original is only replaced after the new version passes validation.

Install receipts

Every mutating command (install, remove, update) ends with a deterministic receipt of what changed: per-file before/after SHA-256 and per-client server changes (added / replaced / removed). Run with PHAROS_JSON=1 (or --json where available) and the receipt JSON is the only stdout output — machine-checkable and replayable:

{
  "command": "install",
  "package": "context7",
  "version": "1.0.0",
  "timestamp": "2026-09-02T12:00:00Z",
  "status": "ok",
  "files": [
    {
      "path": "/home/u/.config/mcp/mcp.json",
      "client": "Generic MCP",
      "action": "modified",
      "before_sha256": "8653057a…",
      "after_sha256": "76ba7142…"
    }
  ],
  "servers": [
    { "client": "Generic MCP", "name": "context7", "action": "added" }
  ]
}

Clients that are skipped record nothing (absence = untouched). update sets backup_path when a .bak generation was taken; install/update add a lockfile file entry, and install/remove add a canonical file entry for the Pharos canonical config (~/.pharos/mcp.json) whenever it was written. remove deliberately records no lockfile entry — receipts track client config files, and the removed version is carried on the receipt's top-level version field instead. Dependency installs record their own servers entries and file rows like the primary package does.

status is "ok", or "partial" when errors is non-empty. errors lists non-fatal failures (client config write failures, lockfile save failures) as strings naming the affected client or file; the itemized files/servers entries still list every side effect that did happen. In the human summary the same information prints as a ⚠ completed with N warnings section.

Unknown key preservation: For JSON-based clients (OpenCode, Cursor, Claude Desktop, Cline, Generic MCP), Pharos uses a map-based reader/writer that preserves all existing top-level keys. If your OpenCode config has model, theme, or tab_size settings, they survive Pharos installs and removes untouched.

Config drift detection

Safe writes guarantee pharos never corrupts a config — but nothing stopped a hand edit from drifting one out from under pharos, invisibly. pharos doctor --diff (read-only) compares every pharos-managed server entry in each client config against the baseline pharos recorded at install time (pharos.lock + ~/.pharos/mcp.json), and reports missing, modified (naming the field), or extra (unmanaged, hand-added — informational) findings:

  ✗  Config drift: Generic MCP     2 drift finding(s) in this config
        • server 'context7' modified: env.API_KEY: expected "abc", got "xyz"
        • server 'hand-added' is not managed by pharos (not in pharos.lock) — unmanaged (hand-added?)

Only compared fields (command, args, env, url, type) count; reformatting (key order, whitespace, empty containers) never counts as drift, and unknown user keys inside an entry are left alone. Numeric env/args spellings (PORT = 8080 vs "8080") are not drift, Grok remote headers are compared like env, and --client subset installs read as healthy for clients they were never written to (lockfile records which clients received each server). Clients pharos has never written to are skipped silently — including a client whose config file has been deleted outright (recreate it with pharos install; per-server MISSING findings are not emitted for a whole missing file). Under PHAROS_JSON=1 the findings appear on the drift checks in the doctor JSON report.

Version pinning

pharos pin <name> locks a server at its currently installed version so updates can't move it; pharos pin <name> <version> installs a specific version first, then pins it. pharos unpin <name> releases the pin.

  • Pinned servers are skipped by pharos update and pharos update --all — no registry call, no change. The summary table lists them with the pinned action and the pharos unpin hint.
  • pharos update --check (and --dry-run) still probe pinned servers and report what's available, marked pinned, so you can see what you're missing without applying anything.
  • Installing a different version of a pinned server (directly or through dependency resolution) moves the pin to that installed version — most-recent-install-wins.

Import & Adopt

pharos import --adopt is one-command onboarding: it reads every detected client config, dedupes servers by name, and writes them into the managed baseline (pharos.lock + ~/.pharos/mcp.json) so pharos becomes the source of truth with zero re-work. Adopt reads your client configs — it never rewrites them (except the explicit "use everywhere" conflict choice below). After a clean adopt (or one where every conflict used "use everywhere"), pharos doctor --diff reads every client as clean. If you resolved a conflict by picking one variant, clients keeping other variants will intentionally show as MODIFIED drift — that's the record of which variant won; resolve with "use everywhere" or edit to taste.

pharos import --adopt            # Adopt every detected client config as the managed baseline
pharos import --adopt --from cursor   # Adopt only Cursor's set (alias of --client cursor)
pharos import --adopt --dry-run  # Full report + conflict list, writes nothing
pharos import --adopt --yes      # Auto-resolve conflicts (first detected client's config wins)
pharos import --adopt --json     # Machine report; conflicts reported, never prompted
pharos import --client cursor    # Plain import (dependency-resolution oriented, no canonical write)
Flag Applies to Behavior
--adopt adopt mode Dedupe all detected client configs into pharos.lock + ~/.pharos/mcp.json. Servers unresolved in the registry still adopt as managed (version empty) — your config is the truth; registry resolution is best-effort version/integrity enrichment.
--from <id> / --client <id> both Import/adopt from one client only. --from is an alias of --client; setting both to different values is an error.
--dry-run --adopt Full adopt report — including conflict resolution — without writing the lockfile, canonical config, or any client file.
--yes --adopt Auto-resolve conflicts: the first detected client's config wins (deterministic Detect() order), marked conflict-auto-resolved. Also PHAROS_ASSUME_YES=1.
--json / PHAROS_JSON=1 both Pure stdout JSON, no prompts ever. Conflicting servers carry both configs in the report and are skipped (adopting the rest) unless --yes is also set.

Conflict resolution. The same server name with materially different configs across clients (compared with the exact same field rules doctor --diff uses — identical-except-cosmetic never conflicts, numeric env spellings don't false-conflict) is a conflict. Interactively, each conflict shows the variants side-by-side with a per-field diff and a prompt: pick 1-N to adopt that variant, u/u<N> to use it everywhere (the picked config is written to every client that has the server — that's the resolution promise), or s to skip. Non-interactive runs (PHAROS_NON_INTERACTIVE=1 without --yes) auto-skip conflicts and adopt the rest. Known edge: entries that deviate from pharos's own serialization shape (hand-written unsorted OpenCode env arrays, unquoted TOML booleans, redundant type fields) compare as different — same rules doctor --diff applies.

Known comparison edges. Adopt reuses doctor --diff's comparison rules verbatim, so it inherits that command's known edge cases: an entry that deviates from pharos's own serialization shape compares as different — a conflict during adopt, a MODIFIED finding in doctor --diff — never silently merged. The known edges:

  • Hand-written unsorted OpenCode env arrays (pharos serializes env sorted, so an unsorted hand-written array reads as a different entry)
  • Unquoted TOML boolean env values (DEBUG = true in Codex/Grok TOML vs "true" in pharos's serialization)
  • Redundant "type": "stdio" fields where pharos's serialization for that client omits the type (e.g. hand-added type on a Cursor stdio entry)
  • Multi-word command strings ("node server.js" as one string, where pharos records command + args separately) and bare-URL remote entries (url with no explicit type, where pharos's serialization always carries a typed remote shape)

Resolve any of these by picking the variant (or "use everywhere") and editing to taste — the comparison rules are never relaxed, because consistency with doctor --diff is the contract.

Exit codes: 0 = clean adopt (all conflicts resolved or none); 1 = any conflict skipped — agents can detect and re-run with --yes or prompt the user.

Report: found N servers across M clients; per-server rows {name, clients[], status, version} with status adopted | conflict-resolved | conflict-auto-resolved | conflict-skipped, always ending with Run 'pharos doctor --diff' to verify clean state.

Supported client formats

Client Config path Format
Hermes Agent ~/.hermes/config.yaml YAML (mcp_servers:)
Claude Desktop %APPDATA%/Claude/claude_desktop_config.json (also WSL ~/.config/Claude/ if present) JSON mcpServers. Stdio/local only (command/args/env). Remotes are skipped — official path is Settings → Connectors → Add custom connector. Install prints — skipped, never ✓.
Claude Code ~/.claude.json and Windows %USERPROFILE%\\.claude.json JSON top-level mcpServers (user scope). Remote {type:http,url} (required type). Stdio {type:stdio,command,...}. Detect only if the file exists. Never project .mcp.json.
Cursor ~/.cursor/mcp.json and Windows %USERPROFILE%\\.cursor\\mcp.json JSON mcpServers. Home-level only. --client cursor writes both. Remote {type,url}.
Cline ~/.../cline_mcp_settings.json (Linux + Windows via WSL2) JSON ({"mcpServers": {}})
OpenCode ~/.config/opencode/opencode.json JSON (`{"mcp": { "": { "type": "local"
VS Code (GitHub Copilot) ~/.copilot/mcp-config.json (all OS, WSL2 Windows) JSON ({"mcpServers": {}}). Remote {url, type:"http"}.
Windsurf ~/.codeium/windsurf/mcp_config.json (Linux), ~/Library/Application Support/Codeium/windsurf/ (macOS), %APPDATA%/Codeium/windsurf/ (Windows) JSON ({"mcpServers": {}}). Remote {url, type:"http"}.
Gemini CLI ~/.gemini/settings.json (all OS, WSL2 Windows) JSON ({"mcpServers": {}}). Remote {url, type:"http"}. Other settings keys preserved.
Amazon Q Developer ~/.aws/amazonq/mcp.json (all OS, WSL2 Windows) JSON ({"mcpServers": {}}). Remote {url, type:"http"}.
Roo Code ~/.../roo_mcp_settings.json (Linux + Windows via WSL2) JSON ({"mcpServers": {}}). Remote {url}.
Codex CLI ~/.codex/config.toml (all OS, WSL2 Windows) TOML ([mcp_servers.<name>]). Stdio: {command, args, env}. Remote: {url}. Other sections preserved.
Grok Build ~/.grok/config.toml (all OS, WSL2 Windows) TOML ([mcp_servers.<name>]). Stdio: {command, args, env}. Remote: {url, headers}.
Zed ~/.config/zed/settings.json (Linux), ~/Library/Application Support/Zed/settings.json (macOS), %APPDATA%/Zed/settings.json (Windows/WSL2) JSON ({"context_servers": {}}). Stdio: {command, args, env}. Remote: {url}. Other settings preserved.
Aider ~/.aider.conf.yml (all OS, WSL2 Windows) YAML (mcp-servers: list). Stdio only: {name, command, args, env}. Remote servers skipped.
Generic MCP ~/.config/mcp/mcp.json JSON ({"mcpServers": {}})

Microsoft Store (MSIX) Claude Desktop: Claude Desktop installed from the Microsoft Store is an MSIX package. Depending on app version, the Store build may materialize its config under the MSIX LocalCache path (%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\claude_desktop_config.json, probed per Windows user under WSL2 too) or the classic %APPDATA%\Claude\ path; pharos probes both, classic first, as two independent entries when both exist, so a dual install surfaces as two entries: Claude Desktop and Claude Desktop (Microsoft Store). A Store-installed Claude that has never been launched has no LocalCache\Roaming\Claude directory yet and is intentionally not detected — launch it once, then pharos install will offer to create its config.

Profiles (contexts)

A profile is a named context that maps a set of installed servers to clients: pharos profile create work --client cursor means "this is my Cursor context." Profiles are an orchestration layer over installs — pharos.lock and ~/.pharos/mcp.json (the canonical config) are untouched until you run pharos profile use, which reconciles the mapped clients to contain exactly the profile's servers. Switching contexts is one command, not config surgery:

pharos profile create work --client cursor          # map the context to Cursor
pharos profile create personal --client claude-desktop --inherit base
pharos profile add work Context7 github             # attach already-installed servers
pharos profile remove work github                   # detach (servers stay installed)
pharos profile ls --json                            # the context map, agent-readable
pharos profile use work --dry-run                   # preview the reconciliation plan
pharos profile use work                             # plan + "Apply? [y/N]"
pharos profile use work --yes                       # apply without prompting
pharos profile rm work                              # delete the profile; servers stay
pharos profile run work                             # daemon: load this set, idle the rest

State. ~/.pharos/profiles.json (pharos-internal, safe to delete — profiles are metadata): {"version":1,"profiles":{"work":{"inherits":[],"servers":["Context7"],"clients":["cursor"]}}}. pharos profile ls --json adds the resolved target_set per profile so agents can read the full context map.

Inheritance. Every profile implicitly inherits base — servers attached to base (pharos profile add base <server...>) stay in every context, so common servers are listed once. --inherit <profile> adds one explicit parent on top (single parent, cycles rejected). The target set profile use reconciles to is base ∪ inherited chain ∪ own, deduped base-first.

pharos profile use — safe by default. It computes the plan first: servers in the target set but missing from a mapped client are added from the canonical config (the exact entry shapes pharos install writes — re-derived through the same helpers doctor --diff verifies against); servers present in a mapped client but not in the target set are candidates for removal, labeled by where they're profiled. Servers attached to no profile are labeled unprofiled with the migration hint pharos profile add base <server...>. Non-mapped clients are never touched. Nothing is written until you confirm: the default prints the plan and prompts Apply? [y/N]; --yes (or PHAROS_ASSUME_YES=1) applies; --dry-run prints the plan and writes nothing — it is always a pure preview (exit 0), --strict gates real applies only; --json prints the plan with applied: true/false and never prompts — agents re-run with --yes after inspecting. --strict refuses to apply while unprofiled servers would be removed (exit 1) so they get classified deliberately first. Failed applies are never silent: rows that error keep their error, stay counted in changes, are listed under ✗ N change(s) FAILED: (human) / failed: N (JSON), and exit 1 when nothing applied. Target-set servers missing from the canonical config are reported as skipped rows plus a note (exit stays 0). Every apply also updates the pharos.lock per-client records (added to client X → X appended to the entry's Clients; removed → dropped), so doctor --diff stays honest afterwards.

Example transcript:

$ pharos profile use work
Profile "work" → 3 server(s) across 1 mapped client(s)
Target set:  Context7, github, shared-echo
  Generic MCP (~/.config/mcp/mcp.json)
    +  add Context7 (from canonical config)
    -  remove legacy-scraper — unprofiled (will be removed from this client)
    =  2 already in place
Hint: pharos profile add base legacy-scraper — keeps them in every context
Apply? [y/N] y
✓  Applied 2 change(s) across 1 mapped client(s).

Exit codes (profile use): 0 = applied (fully or partially — failures are listed either way), already in sync, or dry-run; 1 = the plan had changes but nothing was applied (declined, non-interactive/JSON without --yes, --strict unprofiled conflict, or every change failed); 2 = usage/validation errors (unknown profile, no mapped clients, corrupt profiles.json).

pharos profile rm confirms before deleting (--yes / PHAROS_ASSUME_YES=1 skip the prompt; PHAROS_NON_INTERACTIVE=1 without them aborts with guidance). With --json it never prompts: an unconfirmed run emits {deleted: false, reason} and exits 1; a confirmed one emits {deleted: "<name>", servers_kept: true}.

Install-time mapping. pharos install <name> --profile work writes the server only to the profile's mapped clients and attaches it to the profile in one step (dependencies too — otherwise a later profile use would remove them; --no-dep-config skips both the dependency config writes and their profile attach). It conflicts with --client, --select-clients, and --frozen (exit 2).

Daemon synergy. pharos profile run work asks the daemon to load the profile's target set and idle/stop every other daemon-managed server — context switching also saves memory. Requires the daemon (auto-started when possible).

Development

go test ./... -v -count=1
go vet ./...
go build .

Notable changes per release are recorded in CHANGELOG.md.

Dependency Resolution

The CLI supports recursive dependency resolution. When you install a package that declares dependencies, the CLI resolves them to concrete versions and installs them automatically.

Declaring dependencies in pharos.json

{
  "name": "my-server",
  "version": "1.0.0",
  "transport": "stdio",
  "runtime": "python",
  "command": "python server.py",
  "dependencies": [
    {"name": "other-server", "version": ">=1.0.0"},
    {"name": "utils-lib", "version": "^2.0.0"}
  ]
}

How it works

  1. pharos install <name> installs the primary package first
  2. If the manifest has a dependencies array, the CLI prints "Resolving dependencies..."
  3. Each dependency is resolved recursively (transitive deps included)
  4. Already-installed dependencies at the resolved version are skipped
  5. Version conflicts are resolved by choosing the higher version
  6. Circular dependencies are detected at publish time — the registry rejects the publish with an error. At install time, the CLI also detects cycles and skips them
  7. Each installed dependency gets a client config entry + lockfile entry (unless --no-dep-config is passed)

pharos init dependency prompts

During pharos init, after selecting a license, the CLI prompts for dependencies. Enter each dependency in one of these formats:

Input Stored as
name * (any version)
name@latest latest
name>=0.1.0 >=0.1.0
name=0.1.0 =0.1.0
name<=0.1.0 <=0.1.0
name^1.0.0 ^1.0.0

An empty line finishes dependency entry.

pharos lock

pharos lock    # Resolve all deps in pharos.json, write ./pharos.lock

Resolves dependencies and writes a lockfile at ./pharos.lock (per-project, like package-lock.json). The lockfile records the concrete version, transport, and registry URL for each resolved package.

pharos remove dependency protection

pharos remove <name>           # Remove a package (blocked if other packages depend on it)
pharos remove <name> --force   # Remove even if other packages depend on it

When you try to remove a package that is a required dependency of another installed package, the CLI blocks the removal and lists the dependent packages. Use --force to override.

Circular dependency detection at publish time

The registry rejects publishes that would create circular dependencies. If package A depends on B and package B depends on A, publishing the second package will be rejected with a CIRCULAR_DEPENDENCY error showing the cycle path. Forward references (dependencies not yet in the registry) are allowed — the cycle is caught on the second publish.

Version constraints

Constraint Meaning
1.0.0 Exact version
>=1.0.0 Minimum version
^1.0.0 Compatible (same major)
~1.0.0 Approximately (same minor)
* Any version

Tags

Tags are optional text hashtags that improve package discoverability. A manifest can declare up to 3 tags (lowercase, alphanumeric + hyphens, max 20 chars each).

Adding tags in pharos.json

{
  "name": "weather-server",
  "version": "1.0.0",
  "tags": ["weather", "doppler-radar", "humidity"]
}

Adding tags during pharos init

After the dependency prompt, pharos init prompts for tags:

Tags (up to 3, space or comma separated, Enter to skip):
  tags> weather doppler-radar humidity

Searching by tag

The registry search API supports a ?tag= query parameter to filter packages that have a matching tag on any version's capabilities:

GET /v1/search?tag=weather

Daemon (Process Supervisor)

The Pharos daemon is a background process supervisor for HTTP/SSE/streamable-http MCP servers. It provides JIT (just-in-time) loading — servers start on first request and auto-unload after configurable idle time. stdio servers are not managed (MCP clients handle those as child processes).

How it works

  1. pharos daemon start launches the daemon in the background by default (the CLI re-execs itself with an internal flag and returns immediately, printing the PID and log path). The daemon reads ~/.pharos/mcp.json and opens a local proxy listener (127.0.0.1) for each HTTP/SSE server.
  2. When a request arrives at a proxy port:
    • Server running? → Proxy it, update last-activity timestamp.
    • Server unloaded? → Start the backing process (JIT load), wait for it to be ready, then proxy.
  3. After idle_timeout minutes with no activity, the backing process is killed. The proxy listener stays alive — so the next request JIT-reloads it.
  4. pharos daemon stop gracefully terminates all managed servers and the daemon itself.
  5. pharos daemon restart stops then starts again (convenience — equivalent to stop + start).

Foreground mode (debugging)

By default pharos daemon start backgrounds and returns you to your shell. To run the daemon in the foreground — useful for debugging or when running under a service manager that expects the process to stay attached — use:

pharos daemon start --foreground

In foreground mode the daemon blocks the terminal until stopped (Ctrl-C or pharos daemon stop).

Daemon log

pharos daemon log           # Show last 50 lines of daemon log
pharos daemon log -n 100    # Show last 100 lines

The log file lives at ~/.pharos/daemon.log. The -n flag controls the number of trailing lines shown.

Idle timeout (per-server)

The idle timeout is a per-server setting, not a global daemon setting. Each installed server gets its own idleTimeout value recorded at install time. This is intentional: you may want a frequently-used server to stay always-on (0) while letting a rarely-used one unload after 30 minutes.

--idle-timeout JIT loading Auto-unload Behavior
60 (default) ✅ On ✅ On That server unloads after 60min idle, reloads on next request
30 ✅ On ✅ On That server unloads after 30min idle, reloads on next request
0 ❌ Off ❌ Off That server is always on (starts immediately, never unloads)

Each server's timeout is independent. A server installed with --idle-timeout 30 is unaffected by another server installed with --idle-timeout 0.

Config

Per-server idle timeout is stored in ~/.pharos/mcp.json:

{
  "servers": {
    "my-http-server": {
      "command": "node server.js",
      "transport": "http-sse",
      "idleTimeout": 60
    },
    "always-on-server": {
      "command": "python app.py",
      "transport": "http",
      "idleTimeout": 0
    }
  }
}

Daemon state (PID, running servers, ports, last activity) is persisted at ~/.pharos/daemon.json. Logs go to ~/.pharos/daemon.log.

Hot-reload (cross-platform)

The daemon can re-read ~/.pharos/mcp.json and reconcile — adding new servers and removing deleted ones — without a full restart. The trigger mechanism is platform-dependent:

Platform Primary trigger How
Linux / macOS SIGHUP kill -HUP <pid> (or pkill -HUP pharos)
Windows File-based Touch ~/.pharos/daemon.reload — the daemon polls for this file every 2s

All platforms also support the file trigger (~/.pharos/daemon.reload). On Unix it's redundant with SIGHUP but harmless, which means the same reload script works everywhere:

touch ~/.pharos/daemon.reload   # works on Linux, macOS, and Windows

Idle-cost budget (pharos budget)

Nobody manages the cost side of MCP fleets: every daemon-managed HTTP/SSE server holds a JIT-loaded backing process until its idle timeout unloads it. pharos budget aggregates that standing cost and suggests where it can be cut. Scope: daemon-managed resident servers only — plain stdio servers are launched on demand by your MCP client, and budget (which reads ~/.pharos/daemon.json) never sees them, so they are not aggregated here:

pharos budget          # human report
pharos budget --json   # full structured report

The report shows: the daemon itself (PID, uptime, RSS), a resident-process count (live backing processes + the daemon) with estimated memory, and a per-server table — each server's idle time against its own idle timeout with a BUDGET flag:

Flag Meaning
active inside its idle budget
over past its budget and still resident (due to unload)
idle past its budget, unloaded (the normal JIT end state)
always-on idleTimeout=0 — no auto-unload budget exists
never-used no recorded request

It ends with advisory suggestions — servers idle >30 days, resident always-on servers that haven't served a request in 7+ days, and a daemon holding zero servers — naming the real commands (pharos stop <name>, pharos daemon stop) and never applying anything. pharos budget is strictly read-only: it reads ~/.pharos/daemon.json plus OS process probes, writes nothing, prints no receipt. Memory figures are OS RSS estimates (labeled as such); a failed probe shows — and never fails the command. Residency is liveness-based (inherited from the daemon's liveness semantics) and estimate-grade too: a recycled PID that now belongs to an unrelated process can appear resident. When the daemon isn't running, that's a valid report: 0 resident processes, exit 0. Token metering is not implemented (no API exposes usage yet).

Under --json (or PHAROS_JSON=1) the report is a single document: daemon{pid, startedAt, uptimeMinutes} (omitted when not running), servers[]{name, pid?, port, startedAt?, lastActivity?, idleMinutes?, idleTimeoutMinutes, resident, memoryRSSBytes?} (? fields omitted when unknown, never null), totals{residentProcesses, estimatedMemoryBytes?}, and suggestions[]{code, message} with machine-stable codes (idle_servers, always_on_idle, daemon_no_servers).

Autostart on boot

The daemon can be configured to start automatically when you log in:

pharos daemon autostart --on   # Enable autostart
pharos daemon autostart --off  # Disable autostart
pharos daemon autostart        # Show current status

The underlying mechanism is platform-specific:

Platform Mechanism Unit / task
Linux systemd user unit ~/.config/systemd/user/pharos-daemon.service
macOS LaunchAgent plist ~/Library/LaunchAgents/dev.getpharos.daemon.plist
Windows Task Scheduler schtasks /create /tn PharosDaemon /sc onlogon

On Linux, make sure your user lingering is enabled (loginctl enable-linger $USER) if you want the daemon to survive when no session is active. On macOS, the LaunchAgent runs on login. On Windows, the scheduled task triggers at user logon.

Platform support

The daemon runs on all four primary platforms:

Platform Architecture Process management Hot-reload
Linux amd64 Unix process groups (Setpgid) SIGHUP + file trigger
macOS amd64 Unix process groups (Setpgid) SIGHUP + file trigger
macOS arm64 Unix process groups (Setpgid) SIGHUP + file trigger
Windows amd64 CREATE_NEW_PROCESS_GROUP, TerminateProcess File-based only (no SIGHUP)

Platform-specific code is isolated via Go build tags. On Windows, readProcessMemory returns 0 (there is no /proc filesystem) — this only affects memory reporting in daemon status, not server management.

Secure Tunnel Sharing (pharos expose)

pharos expose <name> publishes one daemon-managed HTTP/SSE server through a token-authed reverse proxy: a public listener you choose forwards to the daemon's loopback proxy listener for that server (127.0.0.1:<daemon proxy port>).

What it is NOT: this is not a public relay service, not WebRTC, and not a Cloudflare Tunnel integration. There is no third-party tunnel in the path — remote clients connect to your listener directly, and pharos never phones home or opens outbound tunnels. If you need NAT traversal, put the listener behind your own infrastructure.

Quick start

pharos daemon start                    # expose requires the daemon
pharos expose web --addr :9500         # the public bind is always explicit

The token is generated from crypto/rand (256-bit, base64url) and printed once with a ready-to-copy snippet:

✓  exposing web → 127.0.0.1:8421 on :9500

  Token (shown once; stored only as a SHA-256 hash in ~/.pharos/expose.json):
    pU3x…43-chars

  Remote clients:
    curl -H "Authorization: Bearer pU3x…" http://<host>:9500/mcp

Every request must present Authorization: Bearer <token>; the comparison is constant-time (crypto/subtle). Anything else — missing header, wrong scheme, malformed value, wrong token — gets a 401 JSON error ({"error":{"code":"unauthorized",…}}) and never reaches the backing server. The validated header is then stripped before proxying, so your public token is never propagated to the local server (whose logs stay free of the bearer token).

Security model

  • Default-deny auth. The auth gate sits in front of the proxy; no token, no proxying, full stop.
  • Explicit public bind. --addr is required — pharos never silently binds 0.0.0.0. Loopback-only sharing is valid and useful for same-host multi-user cases: --addr 127.0.0.1:9500.
  • Plaintext HTTP on the wire. The bearer token travels in cleartext on every request: a 127.0.0.1 bind keeps it on-host, but a LAN / 0.0.0.0 bind puts the token on the wire where anyone who can observe the traffic can read it. For remote sharing, front the listener with TLS (a reverse proxy) or reach it through an SSH tunnel.
  • Hash-only at rest. ~/.pharos/expose.json stores the SHA-256 hash, address, PID, and expiry — never the token. Lose the printed token and you re-run expose.
  • TTL enforced by the expose process. Default 8h, hard cap 24h (--ttl 2h); long-lived tunnels are a different feature. On expiry the listener closes and the process exits cleanly.
  • Isolation. Expose runs as its own process in front of the daemon; it never changes daemon code or state, and a crash of the expose process cannot affect the daemon or the backing server. The backing path stays loopback-only.
  • No token in logs. Background workers log a token fingerprint (first 8 chars of the SHA-256) only.

Lifecycle

pharos expose web --addr :9500              # foreground (Ctrl-C = clean stop)
pharos expose web --addr :9500 --background # detach; token shown first, PID confirmed ~5s
pharos expose list                          # NAME/ADDR/TARGET/PID/STATUS/EXPIRES
pharos expose stop web                      # graceful stop (10s grace), or stale-entry cleanup

State lives in ~/.pharos/expose.json; expose list --json reports {exposes: [{name, addr, port, backingPort, pid?, live, expired, expiresAt}]} with omitempty discipline (? fields omitted when unknown, never null; live/expired always present). Stops are cooperative and Windows-safe: expose stop writes ~/.pharos/expose.stop/<name> (the same file-based control pattern the daemon uses), the expose process notices within ~1s and shuts down cleanly. SIGINT/SIGTERM work too.

An expose requires the daemon to be running and managing <name> (checked read-only via daemon state — same precondition style as daemon status). Exposing works whether the backing server is loaded or JIT-unloaded: requests through the tunnel trigger the daemon's normal JIT load.

Notes

  • MCP client configs point at the public address with the Authorization: Bearer <token> header (streamable-http/http transports).
  • One expose per server name at a time; a second start for the same name exits 1 with a hint.
  • A taken listen address fails with exit 1 in the foreground only. With --background the parent exits 0 after the token handoff; a bind failure surfaces as a delayed not confirmed yet (port may be taken) warning on stderr (~5s later) and an ERROR: cannot listen ... line in ~/.pharos/expose.log.
  • --json start output is a single pure document: {name, addr, port, expiresAt, token} (the one-time handoff). All progress and errors go to stderr, per the W1.1 JSON-purity contract.
  • The token is passed to the detached --background worker via the PHAROS_EXPOSE_TOKEN environment variable; worker output goes to ~/.pharos/expose.log. (Environment variables are readable by processes running as the same user — e.g. via /proc/<pid>/environ on Linux — so on a shared host, prefer a foreground run or trust same-user accounts.)

MCP stdio server (pharos serve)

pharos serve turns Pharos into a Model Context Protocol stdio server: newline-delimited JSON-RPC 2.0 on stdin/stdout, so MCP clients (Claude Desktop, Cursor, Hermes, ...) can use Pharos directly as a tool provider. No SDK, no extra dependencies — the protocol core is hand-rolled in internal/mcpserver/ (the same framing discipline as the pharos try client in internal/mcpclient/).

Tools exposed:

Tool Arguments What it does
search query, limit? (1–50, default 10), transport? Registry search: name, version, description, 30-day downloads, transports, scorecard grade
info name Package details: publisher, license, versions, latest, transport, scorecard, and a ready-to-run pharos install hint
list_installed — Contents of pharos.lock (name, version, origin, pinned, clients) — read-only
install name, version? Opt-in via --allow-install; runs the same pipeline as pharos install (store, canonical config, detected MCP clients, pharos.lock)

The install tool is off by default. When the flag is absent it is omitted from tools/list, and a direct tools/call for it answers an isError result telling the client to restart with --allow-install. When enabled it honors PHAROS_REMOTE_ONLY like the CLI. Tool failures are isError:true tool results, never protocol errors and never a crash; unknown methods answer -32601, bad params -32602.

Wire it into an MCP client config:

{
  "mcpServers": {
    "pharos": {
      "command": "pharos",
      "args": ["serve"]
    }
  }
}

stdout carries only protocol frames (diagnostics go to stderr), the handshake answers initialize with protocolVersion 2024-11-05 and serverInfo {name: "pharos"}, and the server runs until the client closes stdin. This also lets registry indexers such as Glama introspect Pharos itself over stdio for listing and badge purposes.

Author

Built by Chris Wykel — reach me at chris@chriswykel.com.

License

MIT