A Go CLI tool for the PHAROS MCP server package registry.
Install
Linux / macOS / WSL / Git Bash:
curl -fsSL getpharos.dev/install | shWindows 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 pharosWindows (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 includestransport,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 forpharos try(Go duration, default10s; 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,
tryexits 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> --sandboxprobe-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 (default10s).--inspect: prints the exactnpx -y @modelcontextprotocol/inspector …command for this server, then launches it in the foreground (honest error ifnpxisn't on PATH).--inspect --jsononly 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:
- Get user —
GET /v1/auth/meto determine your namespace - Create package —
POST /v1/packages(creates the package row; 409 = already exists, OK) - Upload —
POST /v1/uploads→ PUT tarball bytes to presigned URL - 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.tgzSafe Config Writes
All MCP client config writes (install and remove) go through a safe write pattern:
- Write to temp file — changes go to
<config>.pharos-tmp, never the original directly - 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)
- Atomic swap —
renametemp over original (atomic on most filesystems) - 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 updateandpharos update --all— no registry call, no change. The summary table lists them with thepinnedaction and thepharos unpinhint. pharos update --check(and--dry-run) still probe pinned servers and report what's available, markedpinned, 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
envarrays (pharos serializes env sorted, so an unsorted hand-written array reads as a different entry) - Unquoted TOML boolean env values (
DEBUG = truein 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
commandstrings ("node server.js"as one string, where pharos recordscommand+argsseparately) and bare-URL remote entries (urlwith 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
pharos install <name>installs the primary package first- If the manifest has a
dependenciesarray, the CLI prints "Resolving dependencies..." - Each dependency is resolved recursively (transitive deps included)
- Already-installed dependencies at the resolved version are skipped
- Version conflicts are resolved by choosing the higher version
- 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
- Each installed dependency gets a client config entry + lockfile entry (unless
--no-dep-configis 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.lockResolves 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
pharos daemon startlaunches 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.jsonand opens a local proxy listener (127.0.0.1) for each HTTP/SSE server.- 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.
- After
idle_timeoutminutes with no activity, the backing process is killed. The proxy listener stays alive — so the next request JIT-reloads it. pharos daemon stopgracefully terminates all managed servers and the daemon itself.pharos daemon restartstops then starts again (convenience — equivalent tostop+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.
--addris required — pharos never silently binds0.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.1bind keeps it on-host, but a LAN /0.0.0.0bind 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.jsonstores 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
--backgroundthe parent exits 0 after the token handoff; a bind failure surfaces as a delayednot confirmed yet (port may be taken)warning on stderr (~5s later) and anERROR: cannot listen ...line in~/.pharos/expose.log. --jsonstart 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
--backgroundworker via thePHAROS_EXPOSE_TOKENenvironment variable; worker output goes to~/.pharos/expose.log. (Environment variables are readable by processes running as the same user — e.g. via/proc/<pid>/environon 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

