Bumps __version__ and pyproject to 1.4.0 and rolls CHANGELOG's [Unreleased]
section into [1.4.0] - 2026-08-13, opening a fresh [Unreleased]. No behavior
change; this is the release-staging PR. After merge, push the v1.4.0 tag to
trigger release.yml.
- paths-ignore '**/*.md' on push/pull_request so a CHANGELOG/README-only PR
doesn't spin up the (self-hosted, sometimes-offline) Windows test job.
- timeout-minutes on lint (10) / test (15) / catalog-signature (10) so a job
that hangs mid-run fails instead of hanging forever.
Note: timeout-minutes counts from job start, so it does not rescue a job stuck
'Waiting to run' when the Windows runner is offline — paths-ignore covers the
docs case; code PRs still need the runner up.
Starts a per-release changelog. The Unreleased section captures everything
merged since v1.3.0 (ssh-mcp v2 support, sidecar detect/edit, version pin,
permission pre-flight, hot-reload, light theme, visible update checker,
move-to-env, cross-client phase 1) with the ssh-mcp-is-breaking-upstream /
BCC-is-additive distinction called out. Past releases backfilled from tags.
Reached via a new "Edit config…" button beside the sidecar advisory,
shown whenever the selected server has a resolvable sidecar (even before
the file exists, so it can be created from BCC).
SidecarEditorDialog — a minimal, reviewable first pass:
- A section picker (from core.toml_sections), defaulting to [server].
- Pick-lists for the schema enum fields (auth/approvalMode/role) and a
range-bounded spinner for port — a layperson can't type auth="sshkey".
- A raw-TOML editor showing the full file: the always-available fallback
for anything the form doesn't model.
- Save applies ONLY the fields the user changed, surgically on top of the
raw text (core.update_toml), so comments/unknown keys/other sections
round-trip; validates the changed managed values; writes through
core.write_sidecar (atomic + backup + chmod 0600). Never touches
apply_servers. On success it re-runs the read-only advisories (the same
path #101's watcher uses) so "args inert"/permission advisories update
live, and confirms the backup + 0600.
All decision logic is in bcc_core (unit-tested); this is thin wiring,
smoke-tested headlessly (QT_QPA_PLATFORM=offscreen): dialog build,
section detection, changed-field diff, surgical save with comment
preserved, 0600 applied, and Edit-button visibility (shown for ssh-mcp,
hidden for plain stdio + remote).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pure, fully-tested half of the in-app sidecar editor. No new runtime
dependency: CI's test job installs only `pytest cryptography` (not
requirements.txt) and the catalog-signature job imports bcc_core with only
cryptography, and the workflow is off-limits — so a top-level TOML import is
impossible and any pip TOML dep would leave this code untested/red on CI.
- read_toml_section() — lenient, section-aware scalar reader (string/int/
float/bool); values it can't confidently decode are omitted (the writer
preserves them regardless).
- toml_sections() — ordered profile/section groups for the editor's picker.
- set_toml_value() / update_toml() — SURGICAL writer: rewrites only the one
key it's asked to, so comments, formatting, ordering and unknown keys/tables
round-trip untouched (strictly safer than parse->dict->reserialize, which
tomli-w wouldn't comment-preserve either). CRLF-preserving; correct scalar
quoting/escaping; creates a missing section; deletes on value=None.
- validate_sidecar_values() — enum/port validation against ServerSpec.schema
for the MANAGED fields only; unknown keys pass through (preserved, never a
save-blocker) — reconciles "reject out-of-enum" with "preserve unknown keys".
- write_sidecar() — atomic temp-write + os.replace + rotating backup (reuses
the extracted _atomic_write_text + _make_backup) then chmod 0600/0700 via
#93's fix_permissions. Never routes through apply_servers (cardinal rule).
- _make_backup() generalised to the file's own suffix (JSON naming unchanged),
so a .toml sidecar and a .json config keep separate backup pools.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detection (#91/#93) previously ran only at profile load / server
selection, so a config.toml created or chmod-ed while BCC was running
stayed invisible until a restart. Make the view react on its own.
Core (pure, unit-tested — CI has no PySide6):
- sidecar_watch_paths(): the external paths worth watching for a server
— the resolved sidecar file, its directory (so create/delete and
atomic-rename replaces register), and the wrong-path/doc file — order-
stable and de-duplicated.
- sidecar_state_fingerprint(): a hashable snapshot folding the #91
sidecar status and #93 permission status, so the GUI can tell whether
the *observable* state actually changed and skip a redundant refresh.
- sidecar_state_changed(): explicit, named equality for that decision.
GUI (thin wiring, smoke-tested headlessly):
- QFileSystemWatcher over the selection's sidecar path(s) + BCC's own
loaded config; debounced (300 ms) so a burst of writes doesn't thrash.
- Re-arm on every event: an atomic-rename replace drops the inode from
the watcher, so wanted paths are re-added before the next check.
- Focus-in fallback via changeEvent(ActivationChange) — always works
where watchers miss (atomic replaces, not-yet-created files).
- recheck_advisories() only recomputes warning labels from the form +
filesystem; it never touches field values, so a live reload cannot
clobber unsaved edits.
- An external edit to BCC's own config surfaces a non-destructive
Reload banner (never a silent overwrite); confirm-on-dirty reuses the
existing load path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
permission_status/fix_permissions wrap paths in the running OS's Path class, so
on the windows-latest CI job str(p) uses backslashes and the fixture dict lookups
miss — the same portability trap fixed for #91. Normalise with Path(p).as_posix()
in the four affected lambdas. Pure test fix; production code unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ssh-mcp refuses to start if its config is group/world-readable (mode & 0o077 →
throws, requiring dir 0700 / file 0600). A GUI user has no idea what chmod 600
means — they just get a dead server. This checks it and offers a one-click fix.
Core (pure, POSIX-only, injectable stat/chmod so tests never touch a real file):
- permission_status(path): the ssh-mcp rule — any group/other bit set (mode & 0o077)
is not-ok; file must be 0600, its dir 0700. Returns None on Windows (modes don't
apply) or when the file is absent (nothing to pre-flight). Plain-language problems
naming the offending octal mode.
- fix_permissions(path): chmod file → 0600, dir → 0700. No-op on Windows; reports
an OSError instead of raising.
- sidecar_permission_warnings() / sidecar_permission_fix_target(): tie the check to
#91's sidecar path resolution so it knows WHICH file to inspect. Mirror the
sidecar-warnings shape.
GUI: a warning label + "Fix permissions" button in the stdio editor (mirrors the
removed-flag surface), shown only when the sidecar exists and is too open. Hidden
on Windows and for non-sidecar servers. Smoke-tested headlessly.
pytest green (529 passed), ruff + format clean. Closes#93. Part of epic #94.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CI matrix includes a windows-latest job where str(Path("/Users/…")) renders
with backslashes, so the platform-parameterised sidecar-path assertions failed
there (macOS/Linux passed). Compare .as_posix() instead — separator-normalised
and portable — and note why. Pure test fix; no production-code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`npx -y ssh-mcp` resolves *latest* on every launch — in one session ssh-mcp went
v1 → v2 and the tool set changed under a running agent, mid-task, with no warning.
This detects that and turns it into a comprehensible pin/upgrade prompt.
Core (pure, no network — reuses parse_version / is_newer_version / the catalog
spec parsers; the resolved-version lookup is fully injectable for tests):
- server_package_spec / server_package_name — the npm spec an npx-style server runs.
- is_unpinned_spec — bare name or dist-tag (@latest/@next) is unpinned; an exact
numeric version is pinned.
- resolved_npx_version — reads the local ~/.npm/_npx cache (highest version wins),
degrades to None cleanly. NO network. find/read injectable.
- pin_spec_transform — rewrite the spec to name@version (mirrors pin_command_path's
(new_data, note) contract). No-op when already pinned / bad version / not npx.
- version_drift_note — "moved X → Y since you pinned" via numeric is_newer_version.
- version_status — the badge's high-level dict (unpinned / pinned_version /
resolved_version / can_pin / drift). A _UNSET sentinel lets callers force an
explicit resolved=None ("unknown") vs. omitting it to do the local lookup.
GUI: a version badge row under the dependency status (mirrors that surface) with a
one-click "Pin to <version>" button, shown only for npx servers; a drift note when
a pinned version has been overtaken. Smoke-tested headlessly.
pytest green (520 passed), ruff + format clean. Closes#92. Part of epic #94.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ssh-mcp v2 reads a TOML sidecar and only falls back to CLI args when that file
is ABSENT — so BCC's managed --host/--user args can be completely inert while
the real config lives in a file BCC never looks at. This adds read-only
truth-telling for that (no sidecar writing).
Core (pure, fully injectable platform/environ/home/exists/read for testing):
- sidecar_path(spec): VERIFIED per-platform TOML location from the package source,
NOT the README (macOS → ~/Library/Application Support/ssh-mcp, Windows → %APPDATA%,
else → ${XDG_CONFIG_HOME:-~/.config}). sidecar_doc_path() is the README path.
- sidecar_status(): resolves exists / has_managed_args / args_inert / wrong_path.
- sidecar_warnings(): mirrors removed_flag_warnings' shape. Reports:
* precedence — "these arguments are inert; the server reads <real path>"
* wrong-path — a TOML at the README path the server never actually reads
* #11 credential scoping — an unprefixed SSH_MCP_PASSWORD shared across 2+
profiles in a multi-profile sidecar (count_toml_profiles is a documented
3.10-safe heuristic; unscoped_credential_warning gates on it).
GUI: a read-only advisory label in the stdio editor (mirrors the removed-flag
label; no fix button — editing the sidecar is a separate deliberate action).
Uses the real platform/env/filesystem so it reflects this machine.
pytest green, ruff + format clean. Closes#91. Part of epic #94.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce the server-package axis (orthogonal to ClientSpec): a frozen
ServerSpec dataclass + SERVER_SPECS registry that the sidecar (#91),
version-pin (#92) and permission (#93) work all hang off.
- ServerSpec carries env_flags / removed_flags / drift_flags / sidecar_paths
/ sidecar_doc_path / schema. FLAG_ENV_MIGRATIONS is now a derived view of
the registry, so every existing reader and the migration functions keep the
exact shape #89 shipped — the migration LOGIC is unchanged, only the DATA grew.
- resolve_server_spec(data) is the ServerSpec entry point; detect_migratable_package
is a thin name-only wrapper over it (behaviour identical).
- Add the verified #4 follow-ups: --sudoPassword AND --suPassword now auto-migrate
to SSH_MCP_SUDO_PASSWORD (two flags → one var; existing no-clobber handles it).
--disableSudo stays warn-only (sudo is now a role/policy, no env replacement).
- Add drift_warnings(): --maxChars=none changed meaning (v1 silently capped at
5000 chars). Warn-only, no auto-fix; matches inline and separate arg forms.
- Seed ssh-mcp's verified per-platform sidecar TOML paths and zod enums
(auth/approvalMode/role/port) as data for later issues.
Pure core + tests, no GUI. One #89 test (sudoPassword now migratable) updated to
reflect the intended data growth, with a comment citing the verified facts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ssh-mcp v2 removed --password from the command line and reads
SSH_MCP_PASSWORD instead, so an old config crashes on startup. Add a
data-driven FLAG_ENV_MIGRATIONS registry plus detect_migratable_package,
migrate_removed_flags and removed_flag_warnings in bcc_core, and a
'Fix: move to environment variables' one-click action + warning in the
stdio server editor, with a matching main-window lint line. The literal
value lands in env{} (the only form Claude Desktop honours). 14 tests.
Field testing showed the feature was doing the right thing but describing it
wrong, and left the moved variable invisible. Reworked per that feedback:
Naming. "Move to environment variable" read like "move into the Environment
variables table"; it actually creates a ${VAR} reference to the shell/OS
environment. Renamed the action to "Replace with a ${VAR} reference (out of
file)…" everywhere, and the dialog now says plainly the secret goes to your
shell environment -- not this file, not the table below.
Visibility (the real gap). A lone ${SECRET_KEY} with nothing saying whether
it's wired up isn't much better than a mystery. New Variables… button opens
ReferencedVarsDialog: every ${VAR} the loaded server references, each with
✓ set / ✓ default / ✗ not set and the exact export/setx line to set it.
Backed by pure core.referenced_env_vars (dedupes across fields, resolves
against a given environment or a default).
Two actions on an args secret, because the user may want either:
* "Replace with a ${VAR} reference (out of file)…" -- secret leaves the
file (needs a client that expands refs; disabled with reason on Desktop).
* "Move into Environment variables (kept in this config)…" -- relocates the
arg into the env block where it's visible and editable. Works on any
client (no ${VAR} needed). core.move_arg_to_env_block drops the flag+value
and sets env[VAR]; the dialog warns it changes how the server launches.
Both args actions run through ServerEditor (moving into env touches args AND
the env table), which reloads the form from the transformed data.
Tests: +10 core (referenced_env_vars dedupe/status/default; move_arg_to_env_block
flag+value removal, bare positional, None on bad target). 488 passed, ruff
clean. New dialogs/menus are GUI, untestable in CI as before.
Refs #83
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Two things surfaced testing the GUI:
1. Args rows showed the secret warning but no move action -- the args
editor is a free-text widget, not a table, and was deliberately left out
of the first cut. Wired it up: ArgsEdit gains a context menu that offers
"Move to environment variable…" on exactly the args that look like a
credential. New pure core: secret_arg_indices (which args are secrets,
mirroring args_secret_warning per-index) and suggested_env_var_for_arg
(default var name from the preceding flag -- `--api-key <secret>` ->
API_KEY, else SECRET). The move replaces that one arg line with ${VAR}
and copies the secret to the clipboard, same contract as the tables.
2. On a Claude Desktop profile (or any non-expanding client) the menu showed
NOTHING, so it read as broken. Now a real stored secret always shows the
item -- enabled on a client that expands references, or disabled with the
reason ("unavailable for Claude Desktop -- it doesn't expand ${VAR}") so
the gate is visible rather than silent. Applies to env, headers and args.
Not a change: after converting, env_ref_warnings still notes a variable that
isn't set in the environment. That's #82's advisory doing its job -- the user
runs the export line the dialog handed them; auto-adding a ':-default' would
bake a fallback back into the config and defeat moving the secret out.
Tests: +5 core (secret_arg_indices for token/flag-value/embedded-URL/
reference-excluded, suggested_env_var_for_arg with and without a flag).
483 passed, ruff clean. GUI wiring (context menus) remains untestable in CI.
Refs #83
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Follow-up to #76/#82: BCC warns when a config holds a raw credential and
points at ${VAR}, but gave no way to make the change. This adds the
one-click conversion, right-click a secret row in the env or headers table.
The value is about to leave the file, so the action's real job is handing
the secret back before it does:
- Core (pure, tested): sanitize_env_var_name (key -> legal upper-case shell
name; 'api-key' -> API_KEY, '2fa' -> _2FA, non-ASCII/empty handled),
shell_export_lines (the exact export/setx line, POSIX single-quoted
safely), move_value_to_env_ref (data in -> new data out, replaces one
env/header/args value with ${VAR}, returns the removed secret; never
mutates the input; None if the target is missing, non-string, or already a
reference), can_move_value_to_env_ref (offer only a real stored secret, not
already a ref, AND only on a client that expands references -- offering it
on Claude Desktop would author a config that reaches the server as literal
${VAR}, the exact failure #76 exists to prevent), and is_env_var_set (skip
the ceremony when the variable already looks set).
- GUI: KeyValueTable gains a context menu gated on can_move_value_to_env_ref
(so it never appears on a non-secret row or a Claude Desktop profile).
MoveToEnvDialog lets the user name the variable (defaulting to the
sanitised key), shows the platform-appropriate shell line live, notes when
the variable already looks set, and on accept copies the secret to the
clipboard before the cell is replaced with the reference. Wired through
ServerEditor.set_profile_provider so the tables know which client is loaded.
Scope note: env and headers rows for now. The core already handles args by
index; wiring the args editor (a free-text widget, not a table) is a small
follow-up, deliberately not bundled here.
Tests: +15 core (name sanitisation incl. non-ASCII/leading-digit/empty,
POSIX quote safety, the gate across secret/non-secret/already-ref/
non-expanding-client, env+headers+args rewrite, input-not-mutated,
missing/non-string/already-ref -> None, is_env_var_set). 478 passed, ruff
clean. GUI is untestable in CI (no PySide6); the decision logic all lives in
bcc_core and is tested there.
Closes#83
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Two repos both named "app" no longer both render as "Project: app": disambiguate_project_labels widens colliding labels toward the root (Project: work/app vs personal/app), discover_project_configs now requires a .mcp.json that parses to a dict (skipping arrays/garbage that only failed on open), and the full path goes in the combo tooltip. Pure core + 6 tests; the only GUI change is one setItemData line. 463 passed, ruff clean, CI green incl. Windows.
Closes#74
discover_project_configs labelled every project by basename alone, so
~/work/app/.mcp.json and ~/personal/app/.mcp.json both read as
"Project: app". Paths dedupe correctly, so both profiles existed -- they
were just indistinguishable in the picker, and picking the wrong one meant
editing, backing up, and writing the wrong repo's config. api/web/app/
server/client as repo names make this common.
- disambiguate_project_labels(dirs): pure, testable. Labels stay
"Project: <name>" until a basename collides, then only the colliding
ones widen toward the root one component at a time
("Project: work/app" vs "Project: personal/app"), widening further if the
parent also collides. The common no-collision case is unchanged.
- Full path goes in the combo item's ToolTipRole, so a profile is always
verifiable by hover regardless of label.
- Tightened the "is this a project config?" check: the docstring claimed a
top-level object but the code only checked is_file(), so a .mcp.json that
was a JSON array or garbage still became a profile and only failed on
open. It now must parse (strict) to a dict, else it's skipped.
Tests: +6 (no-collision basename, colliding widen, deeper widen, and
discover_project_configs disambiguation + skipping array/garbage/missing).
463 passed, ruff clean.
Closes#74
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Phase 1 of cross-client support: introduce the ClientSpec adapter and route Claude Desktop + Claude Code through it with no behavior change. Parameterizes servers_key/disabled_key/discovery and the entry translation seam; extract_servers/apply_servers/_server_sections/external_change_summary default to Claude's layout so no-spec calls are byte-identical. Verified: 458 tests + a synthetic non-mcpServers client proving the seam generalizes, CI green on all platforms incl. Windows, and the live GUI confirmed loading/saving identically. requirements.txt now declares cryptography as the runtime dep it always was.
Refs #5
bcc_core imports cryptography at module load (catalog signature
verification), so it's required to even start the app -- but
requirements.txt listed only PySide6, so a from-source run died with
ModuleNotFoundError: No module named 'cryptography'. The frozen release
builds were unaffected because PyInstaller follows the import, which is
why this never surfaced until someone ran the GUI from source to review
this branch.
Move cryptography into requirements.txt (runtime) and re-label it in
requirements-dev.txt as runtime rather than test-only; the version pin is
unchanged (>=42.0).
Refs #5
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Cross-client support (Cursor / Windsurf / VS Code) was blocked on Claude's
layout being hard-coded throughout the code: servers always under the literal
"mcpServers" key, a fixed set of Claude file locations, and "which client is
this?" answered by sniffing a filename. Adding a client that differs on any of
those axes meant chasing those assumptions through a dozen sites.
This is phase 1 of #5: the keystone refactor, with NO behaviour change. It adds
a ClientSpec adapter that captures the three things that vary across clients --
the top-level servers key, the config discovery paths, and the per-server value
shape -- plus the capability flags that were previously computed inline from a
filename (does the client expand ${VAR}? can we offer Restart?).
- ClientSpec (frozen dataclass): servers_key, disabled_key, config_filename,
expands_env_refs, supports_restart, and entry_to_internal/entry_from_internal
-- the per-server translation seam, identity for any mcpServers-shaped client,
the single point a differently-shaped client (VS Code's type/inputs form)
overrides.
- CLAUDE_DESKTOP and CLAUDE_CODE specs; both use mcpServers + the existing
parking key, so their translation is the identity and nothing changes for
today's users. resolve_client(path) reproduces the old filename rule exactly;
each Profile now carries its resolved .client.
- extract_servers / apply_servers / _server_sections / external_change_summary
take an optional spec and default to Claude's layout, so every existing call
site and test that omits a spec is byte-for-byte unchanged. The cardinal rule
now generalises: apply_servers only ever writes the client's own two keys,
parameterised rather than hard-coded.
- profile_targets_claude_desktop and client_expands_env_refs are now thin reads
off the profile's spec -- one source of truth for client identity instead of
scattered filename checks -- with identical answers.
- GUI: the load, Copy-to, save and stale-merge paths pass the profile's spec
into the core calls. Mechanical; no logic moved into bcc.py (which CI can't
test -- no PySide6).
Tests: +14. Existing suite unchanged and green (behaviour preservation). A
synthetic non-mcpServers spec ("servers" key, a different disabled key, a
per-server `type` field) exercises the whole pipeline -- extract, apply,
masking, external-change diff -- proving the seam actually generalises before
any real client depends on it. 458 passed, 1 skipped; ruff clean.
Refs #5
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Adds the authoring + safety layer for ${VAR} references: expand_env_refs preview, per-client gating via profile_targets_claude_desktop (Claude Code expands natively, Desktop does not), and fixes two backwards behaviours (masking hid placeholders; args_secret_warning fired on the recommended fix). BCC never expands on write. 444 tests, ruff clean.
Closes#76
The blocker on this issue was whether BCC or the client does the expanding.
Answer, from Anthropic's docs: Claude Code expands ${VAR} and
${VAR:-default} itself, in command, args, env, url and headers, for both
project .mcp.json and user-scope ~/.claude.json. Claude Desktop has no
documented support.
So this is a per-client capability, not a global one, and BCC does NOT
expand on write: resolving a reference into the file would put the secret
back on disk -- the whole thing the user is avoiding -- and would defeat a
feature the client already implements correctly. BCC authors, validates and
warns; expand_env_refs exists to preview what the client will do.
Semantics mirror the documented ones exactly, including the unusual bit:
an unset variable with no default is left as literal ${VAR} text rather
than blanked, because that is what Claude Code passes through.
Gating uses the existing profile_targets_claude_desktop(), so a config that
is correct under Claude Code and broken under Desktop is reported against
whichever profile is actually loaded. The two warnings are worded
differently on purpose -- 'this client will never expand these' is a
different problem from 'this variable looks unset here'.
Two existing behaviours were backwards for this feature and are fixed:
- Secret masking hid placeholders. is_secret_key('API_KEY') is true, so
${API_KEY} rendered as dots -- making a reference indistinguishable from
a stored credential, which is the one distinction that makes the feature
worth adopting. should_mask_value() now skips references, in the table
delegate, _redact_server_data and redact_args alike.
- args_secret_warning fired on placeholders. Moving a token into ${VAR} is
the recommended fix for that warning; continuing to warn punished the
fix. It now skips references while still flagging a real secret that
follows one.
Real secrets are still masked everywhere they were before -- asserted, not
assumed.
Refs #76
Three conflicts, two of them semantic rather than textual:
- bcc.py QSS: this branch added the noticeBanner rules using the old
module-level constants ({MUTED}, {ACCENT}); main had since moved the
stylesheet onto palette slots ({p.muted}). Took main's form and
translated the notice rules into it -- picking either side wholesale
would have either dropped the banner styling or reintroduced globals
that test_stylesheet_builder_has_no_hardcoded_colours now forbids.
- bcc.py methods: both sides appended to MainWindow (update-notice
handlers vs theme handlers). Additive, kept both.
- tests/test_core.py: the usual EOF append. Kept both blocks.
_build_menu_bar auto-merged cleanly (View menu above, Help menu below);
verified both are present with their menu roles intact.
Verified: 272 test functions = 265 (main) + 7 (this branch), no
duplicates; 421 passed, ruff clean.
Union conflict at the end of tests/test_core.py -- both branches appended a
test block. Kept both, main's #72/#73 block first. Verified: 265 test
functions = 238 baseline + 15 (#77) + 12 (theming), no duplicates.
Reported from the field: running v1.2 against a repo with v1.3.0 published
gave no prompt, and there appeared to be no way to check manually. The
checker itself works; it was invisible, for two reasons.
#78 -- the notice was written to the shared status label, which 21 other
call sites rewrite. The check runs off-thread and lands a second or two
after launch, right as the user starts clicking, so the next selection or
refresh wiped it. Exactly the bug fixed for the MSIX warning in #35, which
got a persistent banner; that fix was never carried to the update notice.
Adds NoticeBanner: a persistent, dismissible notice carrying its own action
button. It's a shared widget rather than a second bespoke banner, so the
next thing needing the user's attention doesn't reach for the status bar
again. (The MSIX banner still uses its own QLabel -- migrating it is a
follow-up, deliberately not bundled with a bug fix.)
#79 -- the only 'Check for updates' affordance was a button inside the
About dialog, which is not where anyone looks. Worse, the About action was
created without a menu role, and Qt auto-assigns AboutRole to actions whose
text begins with 'About', relocating it into the macOS application menu --
so the notice's own hint, 'Help > About to view it', pointed at a menu that
on macOS doesn't contain the item.
Help now has its own 'Check for updates...' item with an explicit
ApplicationSpecificRole, and the About action states its AboutRole rather
than inheriting it invisibly. The menu-driven check is never throttled and
always reports back -- the user asked, so silence would read as broken.
The decision and the wording live in core.update_notice() because the test
suite has no PySide6 (CI installs pytest + cryptography only), so anything
in bcc.py is untestable. A test asserts the notice text names no menu path,
which is what went stale here in the first place.
Closes#78Closes#79
BCC has always been dark-only -- BG #1b1d23, hardcoded at import time, with
no light option and no awareness of the desktop's appearance. On a light
desktop it matches nothing else on screen and there was no way to change it.
Adds a Palette value type in bcc_core with DARK (byte-identical to the
colours v1.3.0 shipped) and a new LIGHT, plus resolve_theme(setting,
system_is_dark) so the decision is testable without a Qt app. View > Theme
offers Match system / Light / Dark, persisted in QSettings under ui/theme,
defaulting to following the system.
The light palette's semantic colours are deliberately not the dark ones
lightened: #4ade80 sits near 1.7:1 against white. They are darkened to clear
WCAG AA, and a contrast test enforces >= 4.5:1 for every text colour against
its surface in both palettes so nobody harmonises them back later.
Three near-black literals were baked into the stylesheet (#1a1205 on-accent
text, #202229 disabled table, #16181d diagnostics pane). Fine with one theme,
invisible breakage with two -- each now has a palette slot, and a test
asserts build_stylesheet contains no hex literals at all.
The ~20 inline setStyleSheet(f"color: {MUTED}") call sites are left alone:
apply_palette rebinds the module-level colour names, and an f-string resolves
its names when it runs, so each call site picks up the new colour on its next
render. Switching theme reapplies the global QSS and re-renders the
inline-styled widgets, so nothing is left dark-on-light.
Refs #75
Two silent-failure bugs found auditing the v1.3.0 features.
#72 -- extract_servers called dict() on every server value, so a config
that was valid JSON but held a non-object server ("foo": "oops", a
number, a list, null) raised on load. The strict parse succeeded, so the
repair path never saw it, and the call sat outside the load try/except:
an unhandled traceback with the window half-swapped to the new profile.
It also made the #54 schema lint unreachable for the most likely
hand-edit mistake -- the load died before the linter ran.
Malformed values are now preserved verbatim on ServerEntry.raw (behind a
NO_RAW sentinel, since a literal JSON null is itself a malformed entry
worth keeping) and written back untouched on Save, so nothing is silently
deleted. lint_servers names the offending entry instead.
#73 -- the stale-file "Merge & save" path reloaded the file from disk and
re-applied the user's servers, but apply_servers only writes mcpServers
and _disabledMcpServers. Named server sets live under _bccServerSets in
the same file, so a set saved that session was dropped from disk and then
from memory, with no warning, on the path the user picks because it
sounds like the safe one.
BCC-owned keys are now declared in BCC_OWNED_KEYS and carried across by
carry_owned_keys, which reports genuinely contested keys so the status
line can say so. Deliberately one-directional: a key absent locally is
left alone on disk, because 'user deleted their last set' and 'another
machine just added sets' are indistinguishable and deleting someone
else's data is the worse failure.
Closes#72Closes#73
Finding 1: can_sign() returned True on an empty changeset, and _on_sign
compared the reviewed blob against a hardcoded "main" rather than the ref
actually reviewed — so the PR path could never sign, and the main-vs-main
path unlocked Sign with zero entries acknowledged. That is how commit b08cf21
signed 19 entries nobody reviewed. can_sign now refuses an empty diff, checks
has_blocking_risk itself instead of trusting a GUI checkbox, and resolves the
TOCTOU pin from the reviewed ref via a pure, testable sign_precondition().
Finding 5: the catalog key and the release key are now separate. The release
key lives in CI and signs checksums; the catalog key stays offline and signs
what users execute. A CI compromise gets the former, not the latter.
Findings 2/3/4/6/7. config.env now gets the deny-list, ASCII, secret and
empty-or-placeholder checks that args always had; version pinning is enforced
at runtime, not only in the maintainer tool; the CI gate pins the expected
pubkey instead of trusting the one in the PR it is reviewing; resolve_catalog
anchors its cap to the bundled version and prefers bundled on ties; catalog
ids are constrained.
Tests rewritten: the old fixtures asserted the unpinned form validates clean
and that config.env passes through verbatim — they enshrined two of the bugs.
Fixes findings 2, 3, 4, 6, 7 from the issue #68 adversarial review.
- Finding 2: config.env was type-checked only. Add CATALOG_DENIED_ENV_KEYS
(case-insensitive) for interpreter/loader-override keys (NODE_OPTIONS,
PYTHONPATH, LD_PRELOAD, ...), apply the ASCII check and the existing
secret-value check to env keys/values, and require env values to be
empty or a single <PLACEHOLDER> token.
- Finding 3: version pinning was only checked by catalog_review.py (which
never runs on the signing path per finding 1). Move enforcement into
_validate_catalog_config: npm/uvx specs must carry @version or ==version
(scoped names handled), docker images must have an explicit non-latest
tag. Only the first plausible package-spec token is checked, so flags,
<PLACEHOLDER>s, and docker subcommands/flags don't trip it. All 19 real
catalog entries still validate clean.
- Finding 4: the CI catalog-signature gate imported bcc_core from the PR
branch and trusted whatever CATALOG_PUBKEYS said there, so a PR changing
both catalog.json and CATALOG_PUBKEYS (with a matching signature) went
green. ci.yml now hardcodes the expected base64 pubkey and asserts
bcc_core.CATALOG_PUBKEYS matches it before verifying the signature.
NOTE: the maintainer is planning to rotate this key -- update
EXPECTED_CATALOG_PUBKEY_B64 in ci.yml as its own reviewed change when
that happens, never bundled with a catalog content change.
- Finding 6: resolve_catalog's anti-rollback/anti-freeze guards sat behind
`if best_version >= 0`, so the first verified candidate was accepted
unconditionally and the anti-freeze anchor drifted with each accepted
candidate instead of staying fixed. The cap is now measured against the
bundled catalog's version specifically (the trust anchor baked into the
binary), regardless of evaluation order; bundled wins version ties; and
a new pure `floor` parameter lets a future caller pass a persisted
accepted-version floor.
- Finding 7: catalog id is now constrained to ^[a-z0-9][a-z0-9._-]{0,63}$.
Tests: fixed _minimal_catalog to use a pinned package (was enshrining
finding 3), rewrote the env-passthrough test to prove the validation
boundary instead of asserting env passes through unchecked, and
reordered test_resolve_catalog_rejects_absurd_version_jump so it
actually exercises the first-candidate path. Added positive/negative
tests for every new rule. Manually verified each new check by commenting
it out and confirming the guarding test goes red, then restoring it.
Fixes#68 findings 1 and 5.
Finding 1 -- the review gate signed without reviewing anything:
- ReviewWindow._on_sign hardcoded "main" as the TOCTOU comparison ref, so
any PR review (where _on_load pins the PR head's blob SHA) could never
sign; the only working path was main-vs-itself, whose empty diff made
can_sign() vacuously True (set() <= set()). Commit b08cf21 signed 19
entries through exactly that path with zero of them reviewed.
- can_sign() now refuses an empty changeset outright, and itself checks
has_blocking_risk() across every changed entry rather than trusting the
GUI to have disabled a checkbox.
- ReviewSession now carries loaded_ref (the exact ref reviewed); a new pure
sign_precondition(session, resolve_blob_sha) resolves the TOCTOU SHA from
that ref, never a hardcoded "main". _on_load's retry path re-diffs
instead of re-pinning the same stale SHA, so a blob-mismatch refusal
can't loop forever.
- source="main" now diffs against the last catalog a maintainer actually
SIGNED (walking catalog.json's git history until a version verifies
against the current .sig), not against itself.
- Replaced the theatre-only test_no_acknowledge_all_function_exists (only
asserted no function was *named* acknowledge_all) with a test that also
exercises the real gate. Added can_sign/sign_precondition coverage for
the empty-diff, blocking-risk, and ref-resolution seams -- each verified
to fail when its guard is removed.
Finding 5 -- the catalog key and release key were the same CI-resident key:
- scripts/sign_checksums.py gets its own RELEASE_PUBKEYS (separate from
bcc_core.CATALOG_PUBKEYS) and a verify_checksums_against_any() helper.
- release.yml's signing-smoke-test now verifies RELEASE_SIGNING_KEY against
RELEASE_PUBKEYS only -- it no longer imports bcc_core/CATALOG_PUBKEYS at
all, so this workflow can never compare a CI secret against the
catalog's root of trust.
- catalog_console.py: keygen/show-seed-b64 gain --release, with separate
keychain/file storage per key kind. show-seed-b64 refuses to run without
--release, so the catalog seed can't be exported to a CI secret by habit.
- README documents both keys' trust properties and the asymmetry: a CI
compromise burns the release key, never the catalog key.
The maintainer must rotate the catalog key (it was CI-resident, so treat it
as burned for catalog use) and generate a fresh release key -- see the PR
description for the exact steps. No key is generated or committed here.
Two gaps closed now that a real key exists.
1. CI 'Catalog signature' job (the #61 gate): every push/PR verifies
data/catalog.json against data/catalog.json.sig using the public key in
bcc_core, and runs validate_catalog. The threat model here is not an
outsider pushing to the repo -- it is merging a friendly-looking PR
without really reading it. A contributor can change catalog.json but
cannot produce a matching signature, so a blindly-merged PR now lands as
a red build within a minute instead of quietly riding into the next
release. Public-key only; no secret involved.
2. release.yml 'Signing key smoke test' (workflow_dispatch only): the
Publish job is gated on a tag, so a manual run never exercised signing --
a wrong or missing RELEASE_SIGNING_KEY would first surface during a real
release. This signs a throwaway manifest with the secret and verifies it
against the public key compiled into bcc_core, proving the two halves of
the keypair actually match. Publishes nothing.
Adds the Ed25519 public key generated by the Catalog Console (#62),
replacing the b"\x00"*32 placeholder, and imports base64 (the key line
referenced it without the import, so bcc_core failed to load at all).
Verified end to end against the signature the Console pushed in b08cf21:
signature verifies, catalog validates clean, resolve_catalog accepts the
bundled copy (19 servers), and a single-byte tamper is rejected.
Maintainer-only PySide6 tool: semantic per-entry diff of data/catalog.json,
blocking risk predicates (non-empty env values, command allowlist, non-ASCII
homoglyphs, unpinned packages, URL domain changes), automatic off-thread
registry lookups (publisher/age/downloads/near-neighbour), acknowledge-gated
signing with a pinned git blob SHA (refuses to sign bytes that changed since
review), and payload+signature emitted in a single commit.
Never shipped to users — excluded from bcc.spec, with a test asserting it.
Review feedback: making the registry lookup an on-demand 'Check registry'
button was a deviation from the design intent, not a style choice. The
registry lookup is the one check a human reviewer genuinely cannot do by
eye -- it's what caught firecrawl-mcp's unrelated npm publisher in the
seed data. Gating it behind a button makes it optional, and an optional
check is the one a tired maintainer skips at 11pm -- exactly the failure
mode this tool exists to defend against. The friction belongs on
approval, never on information.
EntryCard now kicks off its registry lookup automatically at construction
time (i.e. as soon as _render_cards() builds the cards for a loaded
review), one RegistryLookupWorker (QThread) per changed entry, all
starting concurrently as the cards are built. Nothing about the lookup's
pure logic changed -- catalog_review.lookup_registry_info was already
fail-soft (RegistryInfo(available=False) on any fetch problem, never an
exception) and can_sign() never depended on registry state, so a dead
registry still cannot gate review or signing.
Renamed the button 'Check registry' -> 'Re-check' and kept it wired to
the same _run_registry_lookup(), for manually retrying a failed/unavailable
lookup. Label copy now reads loading... while a lookup is in flight (was
'not checked yet.' / 'checking...'), matching the loading -> result |
unavailable per-entry states.
No change to acknowledge-gating or the TOCTOU blob-SHA pin in
catalog_review.py. ruff check/format clean; full suite still 321 passed,
1 pre-existing unrelated skip -- catalog_review.py (the tested pure-logic
module) is untouched, only catalog_console.py's GUI wiring moved from
button-triggered to auto-triggered.
Adds a separate PySide6 tool (catalog_console.py) that reviews proposed
changes to data/catalog.json and signs the approved result. Never shipped
to users, never in the release bundle -- maintainer runs it from source.
Pure, GUI-free logic lives in a new catalog_review.py (kept out of both
the GUI and bcc_core.py to avoid merge conflicts on the latter):
- diff_catalogs(old, new) -> list[EntryChange]: semantic (per-entry)
diff, not a text diff, with per-field before/after values.
- Six independent risk predicates, each unit-tested: non-empty
env_required value, command outside bcc_core.CATALOG_ALLOWED_COMMANDS
(imported, not redefined), non-ASCII code points in id/command/args
(rendered with escapes -- homoglyph/RTL-override defence), unpinned
npm/docker package references, URL domain changes (lookalike-domain
swap defence), brand-new entries flagged for extra scrutiny.
- ReviewSession + can_sign(): the Sign button stays disabled until every
changed entry is individually acknowledged -- no "acknowledge all"
shortcut exists, and a comment in the code says never to add one.
- TOCTOU fix (adversarial review on #62): the git blob SHA of
data/catalog.json is pinned when review begins; can_sign() refuses to
sign if the current blob differs, forcing a re-review. The Console
re-fetches the blob SHA immediately before signing and enforces this.
- catalog_signing_message() imports bcc_core's domain-separation prefix
(_CATALOG_SIG_DOMAIN) rather than retyping it, so the Console's
signatures and bcc_core.verify_catalog_signature can't drift apart --
proven by a round-trip test (sign here, verify via bcc_core).
- encrypt_private_key/decrypt_private_key: the signing key is never
stored plaintext (scrypt + AES-256-GCM at rest, OS keychain via the
optional keyring package if available, else an encrypted file under
$HOME outside the repo).
- Registry lookup (lookup_registry_info + injected Fetcher): the network
call is kept out of this module for offline testability;
catalog_console.py supplies npm/PyPI HTTP fetchers. Fails soft --
network down means "unavailable", never a block on review.
near_neighbor_ids() flags edit-distance <=2 typosquat candidates
against existing catalog ids.
catalog_console.py wires the above into a Qt GUI: Load (open PRs
touching data/catalog.json via the Gitea REST API, or main) -> Review
(one EntryCard per changed entry, command/args rendered visually
dominant, risk findings colour-coded, per-card registry-lookup button
running off the UI thread like bcc.py's ConnTester/SpawnTester) -> Sign
(re-checks the pinned blob SHA, prompts for the key passphrase, writes
data/catalog.json + data/catalog.json.sig and commits+pushes BOTH in a
single commit -- so main is never red between a catalog merge and its
signature). Every attacker-controlled string renders through a
plain_label() helper that both escapes HTML and forces Qt.PlainText, so
a script/image payload in a description/notes/URL can't render as
markup. Also provides keygen (generates + stores an encrypted keypair,
prints the base64 public key) and show-seed-b64 (prints the base64
private seed for the RELEASE_SIGNING_KEY CI secret) CLI subcommands.
Excluded from the release bundle: bcc.spec's Analysis() only ever starts
from bcc.py, and tests/test_catalog_console_packaging.py asserts neither
new file is named anywhere in bcc.spec and that bcc.py never imports
either module.
Tests: 67 new (62 in test_catalog_review.py, 5 in
test_catalog_console_packaging.py) covering diff_catalogs, every risk
predicate individually, the acknowledge-gating + TOCTOU can_sign()
logic, the sign/verify round-trip against bcc_core, key encryption
(including wrong-passphrase and corrupted-blob rejection),
edit-distance/near-neighbour matching, and registry-lookup fail-soft
behaviour. Full suite: 321 passed, 1 pre-existing unrelated skip. ruff
check and ruff format --check both clean. catalog_console.py (Qt/GUI)
could not be executed in the sandbox this was developed in (no system
EGL/GL libraries available for PySide6) -- it was syntax-checked
(py_compile) and lint/format-checked but not smoke-tested; see PR body
for what AJ should verify.
Closes#62
Publish SHA-256 checksums for every release artifact and sign them with a
domain-separated Ed25519 signature. Skips signing (loudly) if the key secret
is absent rather than failing the release. README documents verification and
states the limit plainly: this proves the file is the one we published; it
does not remove Gatekeeper/SmartScreen warnings.
- Pin every basic-tier entry to an exact published version (npm @x.y.z,
uvx @x.y.z, docker :tag). Unpinned npx -y <pkg> means a package
compromised AFTER we ship auto-upgrades into every user; a pin bounds
supply-chain compromise to versions we actually reviewed.
- Drop firecrawl from the seed (19 entries). npm publish rights are held
solely by hello_sideguide/sideguide.dev, which has no visible
relationship to firecrawl.dev, while the package is presented as
official. Publisher identity we cannot tie to the vendor is exactly
what this catalog must not execute on a user's machine. Retained in the
research pool pending confirmation.
- postgres: ship --access-mode=restricted, not unrestricted. A curated
catalog must not default to handing an LLM write access to your DB.
- Add last_release (ISO date, from the live registry) so the UI can show
freshness; postgres-mcp and mcp-obsidian are both ~14mo stale.
Phase 1 of the MCP server catalog: pure, GUI-free core functions plus the
seed data/catalog.json (20 servers). No GUI wiring in this PR -- bcc.py is
untouched; a follow-up PR adds the picker dialog.
- load_catalog(): strict json.loads ONLY. The lenient repair pipeline
(repair_json_text / parse_pasted_json*) is never used on catalog bytes,
by design and by comment, so a signature always authenticates exactly
what gets parsed.
- validate_catalog(): rejects the whole file (not per-entry) on: bad
schema/version types, missing tier-appropriate fields (basic needs
config.command+args, link-only needs docs_url and no config), a
command allowlist (npx/uvx/docker/node/python/python3 only), -e/--eval/-c
denial for node/python, --privileged and root/$HOME volume-mount denial
for docker, non-empty env_required values (hard rejection -- secrets
never ship in the catalog), secret-looking args (reuses
_TOKEN_PREFIXES/_is_secret_value rather than reimplementing), non-https
URL fields, and non-ASCII code points in id/command/args (homoglyph
defence).
- verify_catalog_signature(): Ed25519 via the cryptography package,
domain-separated message (the literal prefix "bcc-catalog-v1|" + raw
bytes), accepts a match against any key in CATALOG_PUBKEYS
(rotation-ready), never raises.
- resolve_catalog(): picks the highest version among bundled/cached/remote
candidates that EACH independently pass verify + validate -- the bundled
catalog gets no implicit trust, closing the hole where an unsigned
payload merged to main would win on being local. Anti-rollback (never
regress below the best verified candidate already in hand) and
anti-freeze (reject a jump of more than 1000 versions) built in.
- catalog_entry_to_paste_json() / config_has_unfilled_placeholders(): small
pure helpers the future GUI dialog will use to feed a catalog pick into
the existing paste-import path and to gate Save on unfilled placeholder
tokens.
data/catalog.json: the provided 20-server seed, with a signed_at field
added at the top level (lives inside the signed payload once real signing
lands in #62). Wired into bcc.spec's PyInstaller datas so it bundles into
the frozen app.
Security requirements from the issue, and where they landed:
- Catalog bytes never touch the lenient JSON repair path -- enforced by
load_catalog()'s strict json.loads and a comment warning against wiring
it in later.
- env_required values are a hard rejection when non-empty, not a warning.
- Secret-looking args are rejected at validation time, reusing the
existing secret-detection helpers instead of duplicating them.
- Non-ASCII id/command/args rejected (typosquat/homoglyph defence).
- URL fields restricted to https://.
- Ed25519 signature verification is domain-separated and never raises.
- The bundled catalog is verified at runtime exactly like remote/cached --
no implicit trust for being local.
- Anti-rollback and anti-freeze bounds on resolve_catalog's version
comparison.
Tests: 42 new tests added to tests/test_core.py (full suite: 239 passed,
1 pre-existing unrelated skip). ruff check and ruff format --check both
clean.
Publish SHA256SUMS for every release archive and sign it with a
detached Ed25519 signature (SHA256SUMS.sig), since paid code signing
(macOS Developer ID, Windows Authenticode) and Sigstore keyless (needs
a Fulcio-trusted OIDC issuer; self-hosted Gitea isn't one) are both
out of budget/scope.
- scripts/sign_checksums.py: dependency-light (cryptography only)
helper to hash a directory of files into a sha256sum(1)-compatible
SHA256SUMS manifest, sign it (domain-separated: b"bcc-release-v1|"
+ raw manifest bytes), and verify a signature. CLI has generate/
sign/verify subcommands; verify doubles as the check path.
- tests/test_checksums.py: 15 unit + CLI-subprocess tests covering
hashing, manifest formatting, sign/verify roundtrip, tamper
detection, wrong-key rejection, domain-separation, and the
no-key-provided failure path (must error, never write an empty/
bogus .sig).
- .github/workflows/release.yml: Publish Release job now checks out
the repo, flattens build artifacts, generates SHA256SUMS, and signs
it from the RELEASE_SIGNING_KEY secret (base64 raw Ed25519 seed) if
present. If the secret is absent, the release still publishes with
a loud ::warning:: and no .sig — it never fails the release or
publishes a bogus signature.
- README.md: new 'Verifying your download' section with the (still
placeholder) public key, sha256sum -c / Get-FileHash commands, and
an explicit statement that this does not remove Gatekeeper/
SmartScreen warnings.
- requirements-dev.txt / ci.yml: add cryptography as a dev/test
dependency for the new script and its tests.
Touches no files from bcc_core.py / tests/test_core.py /
pyproject.toml / bcc.spec to avoid colliding with concurrent work on
those files.
Sets live in the config under _bccServerSets (bcc-owned, ignored by
Claude, travels with the file). Apply enables exactly the set's members
and parks the rest; vanished members are reported, not fatal. GUI row:
set combo + Apply + Save set… + delete.
Closes#52
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First release actually shipping the v1.2.0 feature set (the v1.2.0
tag's release run was cancelled and produced no assets) plus the
audit fixes #32-#40 and the Windows process-tree kill (#13).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Popen.kill() only terminated the direct child, so runner-style commands
(npx -> node -> server) leaked the real server process on every Windows
spawn test. taskkill /PID <pid> /T /F walks the descendant tree. Also
sets CREATE_NO_WINDOW on the spawned test process so the windowed exe
doesn't flash a console per test.
Closes#13
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
python3 is not a command on a stock Windows install; caught by the new
windows-latest CI job. Probe 'python' there and accept warn (found on
augmented PATH) as proof of resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The status bar is rewritten on every action, so the appended MSIX
warning vanished on first interaction. A dedicated warn banner under
the top bar stays visible.
Closes#35
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The self-hosted Windows runner's PowerShell execution policy rejects
setup-python's install script, so Windows mirrors release.yml: py -3.12
+ venv (the version the release binaries ship with). Linux keeps the
full 3.10/3.12/3.13 setup-python matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Linux (and any non-desktop platform): refuse instead of 'pkill claude',
which substring-matched running Claude Code CLI sessions and relaunched
the CLI, not a desktop app. New restart_supported() gates the button.
- macOS: wait (<=5s) for the old instance to exit before 'open -a Claude'
so the relaunch can't re-activate the dying process. Runs off the UI
thread via a RestartWorker.
- Windows: verify the Start-menu shortcut exists BEFORE taskkill, so an
MSIX/Store install is never killed without a relaunch path.
Closes#33
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closing the About dialog mid-check GC'd the dialog and the running
QThread with it -> 'QThread: Destroyed while thread is still running'.
A class-level keepalive set now holds each worker until finished;
stale results to a destroyed receiver are dropped by Qt.
Closes#32
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All network calls are monkeypatched (urllib.request.urlopen) — no live
network in tests. Covers older/newer/equal comparisons, v-prefix,
differing-length tuples, malformed input on both sides, and
fetch_latest_release success/timeout/network-failure/malformed-response
paths.
- Add a real QMenuBar (Help -> About...).
- AboutDialog: app icon, name, version (core.__version__), and links to the
repo/issues/license opened via QDesktopServices.openUrl (system browser,
never in-app navigation). macOS unsigned-app note.
- "Check for updates" button + UpdateCheckWorker (QThread) runs
core.fetch_latest_release() off the UI thread; shows "up to date" or
"vX.Y.Z available" with a button to open the releases page. No binary
download, ever.
- Optional quiet startup auto-check, throttled to once/day via a QSettings
timestamp, off-thread, silent on failure, toggle lives in the About dialog.
Mocks subprocess.run/Popen and patches sys.platform per-OS branch (darwin,
win32, linux) -- never actually kills or launches anything. Covers: correct
command sequence per platform, pkill/taskkill exiting non-zero (nothing to
kill) is NOT treated as failure, a failed relaunch IS reported as failure,
and profile_targets_claude_desktop() correctly distinguishes Claude Desktop
configs from Claude Code / legacy settings.json profiles.
Includes the required regression case: rewrite a file with different
content, force the original mtime back via os.utime, and assert the
fingerprint still differs (because size changed).
MainWindow._loaded_mtime -> _loaded_stat (core.ConfigStat), populated
via core.config_fingerprint() at load/save time. The save-path stale
check now compares the full fingerprint (mtime AND size) instead of
bare mtime equality.
After a successful save, shows a 'Restart Claude Desktop' button next to
the status line. Only shown when the saved profile targets Claude Desktop
(core.profile_targets_claude_desktop) -- never for Claude Code, which has
no GUI process to bounce. Clicking it calls core.restart_claude_desktop()
and reports success/failure. The button hides again on further edits or
when switching/reloading profiles.
Two prior attempts to fix this line via a literal or -escaped
non-breaking space both silently reverted back to a no-op regular-space
replace during transcription. Using chr(0xA0) instead removes any
non-ASCII or backslash-escape character from the source line entirely.
Platform-abstracted restart: macOS uses pkill -x Claude + open -a Claude,
Windows uses taskkill + relaunch via the Start-menu shortcut, Linux uses
pkill claude + a detached Popen relaunch. Not finding a running process is
not an error -- only a failed relaunch is reported as failure.
Also adds profile_targets_claude_desktop() to distinguish Claude Desktop
profiles (claude_desktop_config.json) from Claude Code profiles, used to
gate the restart button in the GUI.
- Add `__version__ = "1.1.0"` as the single source of truth (matches pyproject.toml).
- Add parse_version()/is_newer_version() for numeric (never lexical) version
comparison, handling v-prefix, pre-release suffixes, and malformed input.
- Add fetch_latest_release(): reads the public Gitea releases API
(anonymous, no token) and returns {version, url} or None on any failure.
Never downloads or touches a binary — metadata only.
The previous commit accidentally normalized the non-breaking space
(U+00A0) in _normalize_unicode's replace() call to a regular space
during a copy/paste, turning that replace() into a no-op. Use an
explicit escape instead of the literal character so it can't
be silently corrupted again.
Bare mtime equality can miss a concurrent external write that lands
within the filesystem's mtime resolution (same-second writes), or
where the writer restores the original mtime. Add config_fingerprint()
returning a (mtime, size) ConfigStat pair; the stale-file check in
bcc.py now compares both fields instead of mtime alone. config_mtime()
is kept as-is (still used/tested independently).
The crashed/exited/stderr spawn tests used 0.3-0.5s timeouts. On a slow CI
runner, interpreter startup can exceed that, so the process is still starting
when the timeout fires, gets killed, and is misclassified "ok" (still running)
instead of "crashed"/"exited". Bump those three to 2.0s. The two "ok" paths
(sleep-60, stdin-block) stay at 0.3s since timing out there means success.
Adds `args_secret_warning(data)` to bcc_core — returns a warning string
when any arg positional value looks like a raw credential (token prefix,
value following a secret-named flag, or URL with embedded user:pass like
postgres://user:pass@host). `--flag=value` inline forms are intentionally
skipped (the flag name already labels the value).
Adds `secret_warn` QLabel in ServerEditor's stdio page; shown/hidden by
`_check_args()` on every field change, and cleared on deselect or
stdio→remote type switch. Non-blocking — save path is not touched.
Closes#1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Records the file mtime at load time; before write_config() fires, re-checks
it. If it changed (e.g. `claude mcp add`, a second BCC window, or Claude
itself writing ~/.claude.json), StaleDialog prompts with the changed top-level
key names and a masked server-section diff. "Merge & save" applies the user's
in-memory server edits on top of the current on-disk file (preserving external
non-server changes); "Overwrite anyway" proceeds as before.
- bcc_core: config_mtime(), external_change_summary(), _server_sections()
helper extracted from backup_diff for reuse
- bcc.py: StaleDialog, MainWindow._loaded_mtime tracked through load/save
- tests: 7 new tests (config_mtime, external_change_summary variants, AC merge test)
Closes#4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The diff preview in RestoreDialog was serializing the full config dict,
exposing secret env values and token args in cleartext on a pasteable surface.
- Add _redact_server_data / _redact_servers_block helpers that apply
redact_args to args and mask env values for is_secret_key() keys
- Rewrite backup_diff to compare only {mcpServers, _disabledMcpServers}
sections (sanitized), not the whole file — also avoids double-serializing
multi-MB ~/.claude.json for a servers-only diff
- Add clarifying comment in _restore_from_backup about why full_config
is the right base after confirm-discard
- Add test: backup_diff with secret args/env → MASK in output, raw values absent
- Add test: restore_backup restores _disabledMcpServers correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add list_backups / backup_label / backup_diff / restore_backup to bcc_core,
RestoreDialog to bcc.py, and a "Restore…" button in the profile top bar.
Restore is selective: only mcpServers and _disabledMcpServers are replaced;
all other keys in the config (history, project state) are preserved verbatim.
Goes through write_config() so a pre-restore backup is always created first.
Closes#3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs caught in supervisor review:
1. Stderr was silently dropped when the Details panel was closed during a
crash. _on_spawn_done appended to diag_text only when the panel was
already open, and refresh_dependency() clobbered that text on the next
field change anyway.
Fix: stash the result in self._last_spawn; _full_diag_text() appends
the stderr section whenever diag text is generated; _on_spawn_done
auto-opens the panel on non-ok outcomes (same pattern as the existing
auto_open for missing commands).
2. _drain stopped reading once _STDERR_CAP (4 KB) was reached. A process
that writes more than 4 KB then blocked on a full pipe buffer, never
exited, and was misclassified as "ok" instead of "crashed".
Fix: drain to EOF unconditionally; keep only the first _STDERR_CAP bytes.
Regression test: 64 KB stderr + exit(3) → outcome "crashed".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add spawn_test() to bcc_core — spawns a stdio server for up to 3 s,
captures stderr, and reports ok/exited/crashed/not_found. Key design
decisions driven by real MCP server behaviour:
- stdin=PIPE (never written): servers block on JSON-RPC input and stay
alive, so "still running after timeout" reliably signals a healthy
start. stdin=DEVNULL would send EOF, causing well-behaved servers to
exit 0 and be misclassified as "exited".
- Command resolved via shutil.which(augmented_path()) before Popen so
subprocess PATH resolution is unambiguous across platforms.
- start_new_session=True on POSIX + os.killpg on timeout: kills the
whole process group, not just the launcher (npx, uvx), which would
otherwise orphan the actual node/python grandchild process.
- stdout=DEVNULL: draining a PIPE we don't read would deadlock at ~64 KB.
- stderr drained in a daemon thread, capped at 4 KB.
GUI: SpawnTester(QThread) wraps spawn_test; "Test launch" button in
ServerEditor dep row (stdio only, visible when command resolves ok/warn).
Result colours match the existing dep-status palette (green/amber/red).
Stderr appended to the diagnostics panel if it is open.
7 new unit tests cover all outcomes and the stdin-open regression guard.
Ran python bcc.py locally: button appears for stdio servers whose command
resolves, is hidden for remote servers and missing-command servers.
Closes#2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Env/header values whose key looks secret (TOKEN, API_KEY, PASSWORD,
AUTH, ...) render as •••••••• via a display-only delegate; a
'Show secrets' toggle reveals them. Underlying data, editing, and
save are untouched.
- The Add dialog switches the value field to password echo when the
key name looks secret.
- Copy-diagnostics now redacts secrets from args (--token <v>,
--api-key=<v>, and well-known token prefixes like ghp_/sk-/xoxb-),
since those reports get pasted into public bug reports. Env and
header values were already omitted from diagnostics.
Claude Code stores user-scope MCP servers in ~/.claude.json (what
'claude mcp add' writes); ~/.claude/settings.json is for permissions
and hooks and rejects an mcpServers key with a schema error, so BCC
was reading (and writing) servers where Claude Code never looks.
If servers are found parked in settings.json, that file is still
listed as 'Claude Code (legacy settings.json)' so they can be copied
into the real config via Copy to. Docs updated; verified against
docs.claude.com/en/docs/claude-code/settings.
'-apple-system' is a web convention, not a real font family — Qt scans
every installed font trying to resolve it (the 'Populating font family
aliases' warning at startup). Qt already defaults to the native system
UI font on each platform, so don't name UI fonts at all. The diag
panel's 'SF Mono' (not system-installed on macOS) becomes Menlo.
- Label now explains the model with an example: a flag and its value go
on separate lines. Placeholder shows the common uv pattern.
- split_suspicious_args() flags lines that contain whitespace plus a
dash-prefixed token ('--directory /path') — legit single args with
spaces ('My Documents') are never touched. Quotes are respected.
- The editor shows a warning under the args box with a 'Fix: split onto
separate lines' button; the fix goes through the undo stack.
If a config file on disk fails strict JSON parsing, BCC now runs it
through the same repair pipeline as pasted snippets and shows a dialog
listing the parse error, each fix it would apply, and a preview of the
resulting file. The user chooses: Repair & load (marks the profile
dirty; the file is only rewritten on Save, after the broken original
is backed up) or Cancel. Unsalvageable files keep the old error path.
repair_config_file() in bcc_core never writes to disk itself.
The one-arg-per-line model read as odd text wrapping — a path on its
own line looked like a wrapped continuation of the previous argument.
A line-number gutter makes each argument visibly its own item.
ArgsEdit also owns the NoWrap setting now.
- Cell editors in env/header tables got the global QLineEdit style
(6px vertical padding, 7px radius) crammed into a short row, clipping
the text to an unreadable sliver. Give table editors a compact flat
style and bump default row height to 34px.
- Editor select-all now uses dim-orange/white selection colors inside
cells for contrast against the dark field.
- Arguments box no longer soft-wraps: one arg per line means a wrapped
path looks like two args. Long lines scroll horizontally instead.
Editing a table cell select-alls its text; without an explicit
selection-color the highlighted text rendered near-invisible against
the accent-orange selection background. Applies to all line edits,
text areas, and combo boxes.
- Horizontal splitter between the server list and the editor panel
- Vertical splitter between the Active and Disabled tables (drops the
fixed 170px cap on the disabled list)
- Editor fields (args, env/header tables) now grow with the window
instead of being pinned to fixed max heights
- Splitter positions and window geometry persist across launches via
QSettings; handles highlight on hover/drag
Pasted config snippets no longer have to be valid JSON. repair_json_text()
auto-fixes markdown fences, surrounding prose, // /* */ # comments,
trailing and missing commas, smart quotes, single quotes, unquoted keys,
Python/JS literals, and unclosed braces. parse_pasted_json_verbose()
reports every repair applied; the paste dialog now parses as you type
and previews exactly what will be added and what was fixed.
Also includes ruff lint fixes and formatting across bcc.py/bcc_core.py.
Pillow's ICO saver stores all sizes as PNG-compressed ("Vista icon"
format). PyInstaller's Windows resource-updater cannot embed
PNG-compressed entries for small sizes and silently falls back to its
default gear icon.
Fix build_icons.py to write the ICO manually: BMP DIB for sizes ≤ 128 px,
PNG only for the 256 px entry (where Windows Explorer expects PNG).
Regenerate icons/app.ico with the new code.
Also set upx=False in bcc.spec for the Windows/Linux EXE; UPX is another
known cause of icon resources being stripped from PE files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 22:42:02 -04:00
28 changed files with 16140 additions and 376 deletions
echo "::warning::RELEASE_SIGNING_KEY secret is not set — this release is being published WITHOUT a signed SHA256SUMS.sig. Generate the RELEASE key (python catalog_console.py keygen --release) and add its seed (python catalog_console.py show-seed-b64 --release) as this secret before the next tag."
- name:Create GitHub Release
- name:Create GitHub Release
uses:softprops/action-gh-release@v2
uses:softprops/action-gh-release@v2
with:
with:
@@ -119,7 +270,9 @@ jobs:
draft:false
draft:false
prerelease:false
prerelease:false
generate_release_notes:false
generate_release_notes:false
files:artifacts/**/*
files:|
artifacts/**/*
release-files/SHA256SUMS*
body:|
body:|
## Better Claude Config ${{ github.ref_name }}
## Better Claude Config ${{ github.ref_name }}
@@ -139,5 +292,8 @@ jobs:
xattr -cr /Applications/BetterClaudeConfig.app
xattr -cr /Applications/BetterClaudeConfig.app
```
```
### Verifying your download
Every release includes `SHA256SUMS` (and, when the signing key is configured, a detached `SHA256SUMS.sig`). See [Verifying your download](https://git.avezzano.io/the_og/better-claude-config#verifying-your-download) in the README for commands. This proves you got the file we published — it does not remove Gatekeeper/SmartScreen warnings.
### Requirements
### Requirements
No Python installation needed — the app is self-contained.
No Python installation needed — the app is self-contained.
Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Tooling config (ruff, pytest, project metadata) lives in `pyproject.toml`. CI (`.github/workflows/ci.yml`) runs lint + tests on every push/PR; releases build on tag push (`release.yml`).
## Architecture
## Architecture
@@ -27,9 +31,11 @@ The codebase is split into two layers:
**`bcc_core.py`** — All logic with no GUI imports. Contains:
**`bcc_core.py`** — All logic with no GUI imports. Contains:
-`Profile` / `ServerEntry` dataclasses (the data model)
-`Profile` / `ServerEntry` dataclasses (the data model)
-`discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude/settings.json` (Claude Code, cross-platform)
-`ClientSpec` (issue #5, cross-client) — one adapter object per MCP host capturing everything client-specific: the top-level `servers_key` (Claude uses `mcpServers`; VS Code will use `servers`), the parking `disabled_key`, config `config_filename`, the capability flags (`expands_env_refs`, `supports_restart`), and a per-server `entry_to_internal`/`entry_from_internal` translation pair (identity for Claude; the seam a differently-shaped client overrides). `CLAUDE_DESKTOP` and `CLAUDE_CODE` are the two shipped specs; `resolve_client(path)` picks one by filename, and each `Profile` carries its resolved `client`. The read/write/diff functions take an optional `spec` and default to Claude's layout, so a call with no spec is unchanged.
-`discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude.json` (Claude Code user scope — what `claude mcp add` writes). `~/.claude/settings.json` is NOT a server config (it rejects `mcpServers` with a schema error) and is only surfaced, labelled legacy, if servers are found parked in it. Project-scope `.mcp.json` files can be opened via Add config…
-`load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
-`load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
-`parse_pasted_json()` — accepts three JSON shapes (full config, inner map, or bare server object)
-`parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//``/* */``#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog)
-`check_dependency()` / `diagnostics_text()` / `pin_command_path()` — PATH resolution logic; distinguishes "found on normal PATH" (ok) vs "found only on augmented PATH" (warn) vs "not found" (missing). Uses an `lru_cache`-memoized `augmented_path()` that extends the inherited PATH with common runtime locations (nvm, homebrew, cargo, volta, etc.)
-`check_dependency()` / `diagnostics_text()` / `pin_command_path()` — PATH resolution logic; distinguishes "found on normal PATH" (ok) vs "found only on augmented PATH" (warn) vs "not found" (missing). Uses an `lru_cache`-memoized `augmented_path()` that extends the inherited PATH with common runtime locations (nvm, homebrew, cargo, volta, etc.)
-`test_remote()` — synchronous HTTP reachability check, intended to run off the UI thread
-`test_remote()` — synchronous HTTP reachability check, intended to run off the UI thread
@@ -39,7 +45,7 @@ The codebase is split into two layers:
-`KeyValueTable` — reusable widget for env vars and headers
-`KeyValueTable` — reusable widget for env vars and headers
-`ConnTester(QThread)` — background thread for remote reachability tests
-`ConnTester(QThread)` — background thread for remote reachability tests
**The cardinal rule**: `apply_servers()` only ever writes to `mcpServers` and `_disabledMcpServers`. All other keys in the user's config are preserved verbatim and in their original order.
**The cardinal rule**: `apply_servers()` only ever writes the two keys the target client's servers live under — by default `mcpServers` and `_disabledMcpServers`, or whatever the profile's `ClientSpec` declares (`servers_key` + `disabled_key`). All other keys in the user's config are preserved verbatim and in their original order. The rule generalises across clients precisely because it is parameterised by the spec rather than hard-coded.
Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition.
Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition.
| 5 | 8 | Duplicate-name conflict on paste/import | P1 | VERIFY FIRST: check `paste_json`/`dropEvent` in bcc.py for silent overwrite; file findings on the issue before coding |
| 6 | 5 | Cross-client support (Cursor/Windsurf/VS Code) | P1 | VS Code uses `servers` not `mcpServers` — needs a read/write adapter, not just paths |
| 7 | 6 | In-app MCP log viewer | P1 | Platform log paths in the doc |
| 8 | 7 | Windows MSIX path detection | P1 | Can't test locally on macOS — unit-test the detection logic, note that in the PR |
| 9 | 9 | Restart Claude Desktop button | P1 | Platform-specific process handling; scope to Desktop only |
| 10 | 10 | Server catalog / one-click add | P2 | Design pass first — post a proposal as an issue comment before building |
## Workflow rules
- **Branch per issue** off `main`: `feat/<issue-number>-short-slug` (or
`fix/`). One issue per PR. Reference the issue in the PR description
# PR #87 — "Move to environment variable" (#83): manual test checklist
The logic is covered by 15 unit tests in CI; what CI **can't** exercise is the GUI (no PySide6). This checklist is only the parts a human needs to click. Should take ~10 minutes.
## Setup
```bash
cd ~/Documents/Claude/Projects/BetterClaudeConfig/better-claude-config
Pick a **Claude Code** profile (e.g. `~/.claude.json`) that has, or add, a server with an env value that looks like a secret — e.g. `env: { "API_KEY": "ghp_test123" }`. (You can use a throwaway value; nothing is sent anywhere.)
## The checklist
### Gating — where the action appears
- [ ] Right-click the **value cell** of a secret env row (`API_KEY`) on a **Claude Code** profile → a **"Move to environment variable…"** item appears.
- [ ] Right-click a **non-secret** row (e.g. `REGION` = `us-east-1`) → the item does **not** appear.
- [ ] Right-click a row whose value is already a reference (`${API_KEY}`) → the item does **not** appear.
- [ ] Switch to a **Claude Desktop** profile (a `claude_desktop_config.json`), right-click the same kind of secret row → the item does **not** appear. (Desktop doesn't expand `${VAR}`, so offering it would break the config — this is the important gate.)
### The dialog
- [ ] Trigger the action → dialog opens with **Variable** pre-filled from the key, sanitized to a legal shell name (e.g. `api-key` → `API_KEY`).
- [ ] Edit the variable name → the shown **shell line updates live** and matches your platform (`export VAR='…'` on macOS/Linux, `setx VAR "…"` on Windows), with the other platform shown in parentheses.
- [ ] If you type a variable name that **is already set** in your shell environment, the green "already looks set" note appears; if not, it's hidden.
- [ ]**Cancel** → nothing changes (value still the raw secret, no dirty state).
### The conversion
- [ ]**Move && copy secret** → the cell now shows the reference `${VAR}` (visible, **not** masked to dots), and the window goes dirty (Save enabled).
- [ ] Paste from your clipboard somewhere → it's the **original secret value** (handed back before removal).
- [ ] The reference value is **not** flagged as a secret warning anymore (it's the recommended state).
### Headers + persistence
- [ ] Repeat on a **remote server's Headers** table (e.g. an `Authorization` header) → same behavior.
- [ ]**Save**, then open the config file on disk in a text editor → the servers block holds `${VAR}`, and the **plaintext secret is gone** from the file.
- [ ] Re-open the profile in BCC → the row still shows `${VAR}` (round-trips).
### Undo (nice-to-have)
- [ ] After a conversion, **Ctrl+Z / Cmd+Z** restores the previous value.
## Known scope (not bugs)
- **Args rows** are out of scope for this PR — the core supports them, but the args editor is a free-text widget, so wiring that UI is a deliberate follow-up. Right-clicking args won't offer the action yet.
- The "already set" check reads **BCC's** environment, which may differ from the client's — it's advisory, worded that way.
## If anything's off
Tell me which checkbox failed and what you saw; I'll fix on the branch and re-push. If everything passes, approve/merge #87 (or tell me to merge it).
@@ -19,6 +19,103 @@ Pre-built self-contained binaries are attached to every [GitHub Release](../../r
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
## Verifying your download
BCC isn't code-signed — there's no budget for a paid certificate (macOS
Developer ID, Windows Authenticode). Instead, every release publishes a
`SHA256SUMS` file listing the checksum of each archive, detached-signed with
Ed25519 as `SHA256SUMS.sig`. Both are attached to the release alongside the
binaries.
**What this proves:** the file you downloaded is byte-for-byte what we
published, and the manifest itself was signed by our release key.
**What this does NOT do:** it does not make the binary "safe," and it does
**not** remove the macOS Gatekeeper or Windows SmartScreen warning — those
are only suppressed by a paid OS-vendor certificate, which this project
doesn't have. Verifying checksums is about detecting tampering in transit or
on a mirror, not about vouching for the software.
This manifest is signed with BCC's **release key**, which is a different
key from the one that signs the MCP server catalog — see
[Signing keys](#signing-keys) below for why, and for the public key value
to use with `--pubkey-b64` below.
### macOS / Linux
```bash
# From inside the folder you downloaded the release files into:
sha256sum -c SHA256SUMS
```
If your `sha256sum` complains about missing files, download `SHA256SUMS`
into the same directory as the archive you downloaded — it lists every
platform's archive, and only the one(s) present will be checked.
To also verify the manifest's signature (optional, requires Python +
`pip install cryptography` and a checkout of this repo):
```bash
python3 scripts/sign_checksums.py verify \
--sums SHA256SUMS --sig SHA256SUMS.sig \
--pubkey-b64 "<the release public key from Signing keys, below>"
Compare the printed hash (case-insensitively) against the matching line in
`SHA256SUMS`.
### If a release has no `SHA256SUMS.sig`
The signing key is a repo secret that has to be configured manually; if a
release is missing the `.sig` file, the checksums themselves are still
valid and safe to check against — the release workflow only skips signing,
never checksum generation.
## Signing keys
BCC uses **two separate Ed25519 keypairs**, deliberately never the same
key, because they protect different things and live in different places:
| | Catalog key | Release key |
|---|---|---|
| Signs | `data/catalog.json` (the MCP server catalog every user's app trusts) | `SHA256SUMS` (the checksum manifest for release binaries) |
| Verified by | `bcc_core.CATALOG_PUBKEYS` | `scripts/sign_checksums.RELEASE_PUBKEYS` |
| Lives | Offline, passphrase-encrypted, maintainer's machine only (OS keychain or an encrypted file outside the repo — see the [Catalog Console](#files), issue #62) | A Gitea Actions repo secret, `RELEASE_SIGNING_KEY` — **intentionally CI-resident** |
-`scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
-`scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
-`scripts/sign_checksums.py` — generates and Ed25519-signs the release `SHA256SUMS` manifest (see [Verifying your download](#verifying-your-download)).
-`catalog_console.py` / `catalog_review.py` — **maintainer-only**, never shipped to users (excluded from `bcc.spec`; see `tests/test_catalog_console_packaging.py`). The Catalog Console: review + sign `data/catalog.json`, and generate/manage both signing keys (`keygen`, `keygen --release`) — see [Signing keys](#signing-keys).
"notes":"Part of the official modelcontextprotocol/servers reference monorepo (star count is for the whole repo). Clients that support MCP 'roots' can also grant directories dynamically instead of via args.",
"last_release":"2026-07-10"
},
{
"id":"fetch",
"display":"Fetch",
"description":"Fetches a URL and converts the page to clean markdown so Claude can read web content.",
"notes":"Can access local/internal IPs, so treat as a mild security risk on untrusted networks. Add '--ignore-robots-txt' or '--user-agent=...' as extra args if needed.",
"last_release":"2026-07-10"
},
{
"id":"memory",
"display":"Memory",
"description":"Gives Claude a persistent knowledge-graph memory that survives across conversations.",
"notes":"The old '@modelcontextprotocol/server-github' npm package is discontinued (deprecated April 2025). GitHub now ships a Docker-based local server (requires Docker installed/running) plus a hosted remote server at https://api.githubcopilot.com/mcp/ that supports OAuth or PAT auth without Docker."
},
{
"id":"playwright",
"display":"Playwright",
"description":"Lets Claude drive a real browser (click, type, navigate, screenshot) using Playwright's accessibility-tree snapshots.",
"notes":"Maintained by the Playwright team at Microsoft. Add '--isolated' for a throwaway profile, or '--browser firefox|webkit|msedge' to change engine. A persistent browser profile is used by default so logins carry over between sessions.",
"last_release":"2026-07-09"
},
{
"id":"chrome-devtools",
"display":"Chrome DevTools",
"description":"Lets Claude control Chrome and use real DevTools features: performance traces, network inspection, console logs, screenshots.",
"notes":"Maintained by the Google Chrome DevTools team; only officially supports Google Chrome / Chrome for Testing. Exposes the browser's content to the MCP client, so avoid sensitive sites while connected. Add '--slim --headless' for a minimal 3-tool basic-automation mode.",
"last_release":"2026-07-03"
},
{
"id":"postgres",
"display":"Postgres MCP Pro",
"description":"Lets Claude query, inspect schema, and analyze/tune performance of a PostgreSQL database.",
"notes":"The official reference Postgres server was archived by the MCP team; this community server (Crystal DBA) is the most capable/most-referenced replacement, adding index tuning and EXPLAIN-plan analysis. Use --access-mode=restricted for read-only/production use. Docker image also available (crystaldba/postgres-mcp). Catalog ships --access-mode=restricted (read-only); switch to unrestricted yourself if you want writes.",
"last_release":"2025-05-16"
},
{
"id":"n8n",
"display":"n8n",
"description":"Build, validate, and deploy n8n workflows with full node documentation for the AI.",
"notes":"Set MCP_MODE=stdio (required for Claude Desktop, prevents debug logs from breaking the protocol). N8N_API_URL/N8N_API_KEY are optional — without them you still get full node documentation, validation, and template search; with them you get live workflow create/update/execute against your own n8n instance. A hosted free-tier alternative exists at dashboard.n8n-mcp.com.",
"last_release":"2026-07-09"
},
{
"id":"notion",
"display":"Notion",
"description":"Read, search, and edit Notion pages, databases, and comments from your AI assistant.",
"notes":"Notion is prioritizing its hosted remote MCP (OAuth, https://mcp.notion.com/mcp) and may eventually sunset this local package, but the stdio server still works today and is the simplest way to get a static config with an internal-integration token.",
"last_release":"2026-06-22"
},
{
"id":"obsidian",
"display":"Obsidian",
"description":"Read, search, and edit notes in your Obsidian vault.",
"notes":"Requires the Obsidian Local REST API community plugin installed and enabled in Obsidian; copy the API key from the plugin settings. OBSIDIAN_HOST defaults to 127.0.0.1 and OBSIDIAN_PORT to 27124 if omitted.",
"last_release":"2025-04-01"
},
{
"id":"brave-search",
"display":"Brave Search",
"description":"Search the web, news, images, and videos using Brave's independent search index.",
"notes":"Official Brave server; replaced the old archived modelcontextprotocol/servers brave-search entry (now in modelcontextprotocol/servers-archived). Get an API key from the Brave Search API dashboard.",
"last_release":"2026-06-15"
},
{
"id":"tavily",
"display":"Tavily",
"description":"AI-optimized web search, extract, map, and crawl API built for LLM agents.",
"notes":"Tavily also offers a hosted remote MCP endpoint (mcp.tavily.com) with OAuth as an alternative to running the local npx server.",
"last_release":"2026-07-10"
},
{
"id":"home-assistant",
"display":"Home Assistant",
"description":"Control smart home devices, query states, and troubleshoot automations in Home Assistant.",
"category":"smart-home",
"homepage":"https://github.com/voska/hass-mcp",
"stars":308,
"official":false,
"setup":"basic",
"config":{
"command":"docker",
"args":[
"run",
"-i",
"--rm",
"-e",
"HA_URL",
"-e",
"HA_TOKEN",
"voska/hass-mcp:0.5.0"
]
},
"placeholders":{},
"env_required":{
"HA_URL":"",
"HA_TOKEN":""
},
"docs_url":"https://github.com/voska/hass-mcp",
"notes":"HA_URL example: http://homeassistant.local:8123 (use http://host.docker.internal:8123 if HA runs in Docker on the same machine). HA_TOKEN is a Home Assistant long-lived access token from your profile page. A more actively developed alternative is the community 'HA-MCP' integration (homeassistant-ai/ha-mcp, ~3.9k stars), but it installs inside Home Assistant itself via HACS rather than as an external stdio process, so it doesn't fit this catalog's launch-line format."
},
{
"id":"kubernetes",
"display":"Kubernetes",
"description":"Lets Claude inspect and manage Kubernetes/OpenShift resources — pods, deployments, logs, Helm releases — using your local kubeconfig.",
"notes":"Not an official Kubernetes SIG project, but a Go-native (no kubectl dependency) implementation maintained under the 'containers' GitHub org (Podman/Red Hat-adjacent) that's widely regarded as the most capable K8s MCP server, supporting Kubernetes and OpenShift. Uses your existing ~/.kube/config automatically; add --read-only to prevent writes.",
"last_release":"2026-07-10"
},
{
"id":"aws-api-mcp-server",
"display":"AWS API MCP Server (AWS Labs)",
"description":"Lets your AI assistant run AWS CLI commands to inspect and manage AWS resources across virtually every AWS service.",
"notes":"AWS credentials are NOT set in this MCP config — configure them beforehand via `aws configure` (or set AWS_API_MCP_PROFILE_NAME to pick a named profile) so boto3's standard credential chain can find them. Optional env vars: AWS_REGION (default us-east-1), READ_OPERATIONS_ONLY=true to block all mutating AWS calls. AWS notes this server is being superseded by a newer unified AWS MCP server referenced in their agent-toolkit docs.",
"last_release":"2026-06-25"
},
{
"id":"grafana",
"display":"Grafana",
"description":"Query dashboards, datasources, alerts and incidents in Grafana from your AI assistant.",
"notes":"Slack's own MCP server went GA Feb 17, 2026 (streamable HTTP at https://mcp.slack.com/mcp, OAuth). No stdio one-liner is published because it's a hosted, permissioned connector. A well-known community alternative, korotovsky/slack-mcp-server (~1.6k GitHub stars, MIT, not an official Slack product), supports stdio/SSE/HTTP with bot or browser-session tokens and no app-install requirement if a stdio option is preferred."
c.parse_pasted_json_verbose("this is just prose with no json at all")
deftest_junk_object_still_rejected():
withpytest.raises(ValueError):
c.parse_pasted_json_verbose('{"junk": 5}')
deftest_empty_raises():
withpytest.raises(ValueError):
c.parse_pasted_json_verbose("")
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.