Skip to content

feat(mcp): scoped server config, project approval, and keychain storage - #44803

Open
savagelysubtle wants to merge 13 commits into
anomalyco:devfrom
savagelysubtle:agents-mcp-scopes
Open

feat(mcp): scoped server config, project approval, and keychain storage#44803
savagelysubtle wants to merge 13 commits into
anomalyco:devfrom
savagelysubtle:agents-mcp-scopes

Conversation

@savagelysubtle

@savagelysubtle savagelysubtle commented Aug 24, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Upgrades MCP server management, taking cues from Claude Code's config scopes and Gemini CLI's credential storage.

Main changes:

  • .agents/ project directory — discovered from cwd to the git root (plus ~/.agents), alongside .opencode. Holds opencode.json(c) for overrides and a dedicated mcp.json with the bare server map. mcpServers/servers wrappers are accepted too.
  • Vendor shorthand — MCP snippets from other tools' docs work as-is: bare url/httpUrl becomes a remote server, command + args + env becomes a local server. Explicit type still wins.
  • Scopesopencode mcp add --scope local|project|user. Local (default) stores per-project servers in ~/.local/share/opencode/mcp-local.json keyed by project root, so credentials stay out of repositories. Local entries replace same-named project/user definitions wholesale. mcp list shows each server's scope.
  • Project server approval — MCP servers defined in project-owned config are hashed at load and won't connect until opencode mcp approve <name>. Choices live in the data directory keyed by project root, never in the repo, so a cloned repo can't approve its own servers. Editing a definition re-requires approval. New needs_approval status shown in the TUI dialog and mcp list.
  • Keychain storage — OAuth tokens and client secrets go to the OS keychain (security / secret-tool) when available; the auth file keeps only non-secret metadata as an index. Legacy plaintext entries still load and migrate on next save. Failed keychain writes fall back to the 0600 file instead of losing credentials. OPENCODE_MCP_FORCE_FILE_STORAGE=1 forces the file.
  • OAuth hardening — callback server listens first and treats EADDRINUSE as another instance owning the port (fixes a check-then-listen race and a silent 5-minute hang when another app holds the port); mode-restricted writeJson writes through a pre-chmoded temp file so secrets never briefly exist with default permissions.

How did you verify your code works?

  • New config tests: .agents discovery, wrapper unwrapping, shorthand expansion, local-scope precedence (including the non-git worktree === "/" keying case), project-scope hashing
  • Full MCP suite passes with the keychain path active against a real secret-service daemon (this caught two real bugs: lookup stdout was disabled by stdio ignore, and keychain hits dropped serverUrl/oauthState/codeVerifier)
  • Concurrent-write serialization covered by the existing flock test
  • TUI suite (194 tests) green; typecheck clean in touched packages
  • Manually verified the full flow: add server per scope → needs_approval → approve → connects → edit → needs re-approval → revoke

Screenshots / recordings

TUI change: the MCP dialog now shows ⚠ Auth required / ⚠ Approval required footers and an authenticate action (enter or a). Happy to attach a recording if useful.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Based on my search results, I found one potentially related PR:

Related PR Found:

The current PR (#44803) is significantly more comprehensive, introducing the full scoped server configuration system with keychain storage, but #40125 may have addressed some foundational trust/approval concepts for MCP servers.

However, #40125 is an older PR and addresses only the trust configuration aspect, while #44803 is a major upgrade covering scoped configuration (local/project/user), vendor shorthand expansion, project approval with hashing, and keychain storage—these are largely complementary or superseding features rather than duplicates.

No other open duplicate PRs directly addressing this scope of work were found.

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

… file

writeJson created the target with umask permissions before chmodding,
briefly exposing secrets group/world-readable on multi-user systems.
Project-level configuration can now live in .agents/, discovered from cwd
up to the nearest git root plus ~/.agents for user-wide config. The
directory supports opencode.json(c) for general overrides and a dedicated
mcp.json holding MCP server definitions without the outer mcp wrapper.
.agents sources merge after root-level files so they win on conflicts;
root-level opencode.json continues to load as legacy.
MCP snippets copied from vendor docs (Claude Desktop, VS Code, Gemini CLI)
now work without hand-translating: bare url/httpUrl entries become remote
servers, and command string + args + env entries become local servers with
args appended to command and env mapped to environment. Explicit type
fields still win. .agents/mcp.json also accepts mcpServers/servers
wrappers.
- Listen on the OAuth callback port first and treat EADDRINUSE as another
  opencode instance owning it, removing the check-then-listen race and the
  silent five-minute hang when a foreign app squats the default port. The
  timeout error now points users at the callbackPort option.
- Route mode-restricted writeJson calls through a pre-chmoded temp file so
  MCP OAuth credentials never briefly exist with umask-default permissions.
The MCP dialog only offered enable/disable, leaving servers stuck in
needs_auth with no in-TUI path through the OAuth flow. Add an
authenticate action that starts the OAuth flow (browser opens, callback
awaited) and refreshes status on completion, and surface a warning
footer for needs_auth servers.
Dialog actions could not be triggered from the footer when bound to
return because dialog.select.submit matched first. Order action
bindings before the generic select bindings so dialog-specific actions
win, and bind MCP authenticate to enter (with a as mnemonic).
Add Claude Code-style MCP scopes. opencode mcp add now accepts
--scope local|project|user: local (default) stores the server in
~/.local/share/opencode/mcp-local.json keyed by project root so
credentials never live in the repository; project writes .agents/mcp.json;
user writes ~/.agents/mcp.json. Local entries replace same-named
project/user definitions wholesale. mcp list shows each server's scope.
Non-git projects key local scope by working directory since worktree
is / there.
Project-owned MCP definitions (project opencode.json and .agents/mcp.json)
are hashed at load time and will not connect until explicitly approved
with 'opencode mcp approve <name>'. Choices are stored in the data
directory keyed by project root, never in the repository, so a cloned
repo cannot approve its own servers. Editing a definition invalidates its
approval. Local-scope entries are user-private and exempt. Adds a
needs_approval status surfaced in the MCP dialog and mcp list.
MCP OAuth tokens and client secrets now go to the OS keychain (macOS
security, Linux secret-service via secret-tool) when available, matching
Gemini CLI's hybrid approach. The auth file keeps only non-secret
metadata as an enumeration index; legacy plaintext entries remain
readable and migrate into the keychain on their next save. If the
keychain write fails, credentials stay in the 0600 file rather than
being lost. OPENCODE_MCP_FORCE_FILE_STORAGE=1 forces file storage.
…e metadata

secret-tool lookups returned empty because stdio ignore disabled stdout
for input-less commands, so keychain reads never saw stored secrets.
Keychain hits now layer secret fields over the file entry instead of
replacing it, preserving serverUrl, codeVerifier, and oauthState.
The same project reached through a symlinked path produced a different
approval/local-scope key than the realpath, so approvals granted from
one launch path never matched sessions launched from the other (Cursor
workspaces open the symlink form). Canonicalize with realpath before
keying approvals, local-scope storage, and approve/revoke.
One project can be reachable through several equally-real paths
(symlinks, bind mounts) and realpath cannot unify bind mounts, so
approval and local-scope keys derived from the raw worktree string made
servers demand re-approval (or vanish) depending on launch path. Key
approvals, local-scope storage, and scope detection on the .git
directory's device+inode instead — identical across every path variant
for a git repo, falling back to the path string for non-git
directories.
@savagelysubtle

Copy link
Copy Markdown
Author

Two follow-up fixes pushed (rebased onto current dev):

  • Keychain lookups returned empty: secret-tool lookups were broken by stdio: "ignore" disabling stdout on input-less commands, and keychain hits replaced the whole stored entry instead of layering secret fields over file metadata. Found by running the test suite with a real secret-service daemon active.
  • Approval/local-scope keys now use the .git directory's device+inode instead of path strings: one project reachable through multiple equally-real paths (symlinks, bind mounts) previously demanded re-approval depending on launch path — realpath cannot unify bind mounts. Reproduced with a two-path bind-mounted repo; both launch paths now behave identically.

Also verified the vendor-shorthand + .agents/mcp.json flow end-to-end against live remote servers (Stripe/Supabase) with keychain-active credential storage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant