claude-multiacc 2.0.58 → 2.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +111 -3
  2. package/bin/claude +323 -84
  3. package/bin/claude-accounts +273 -42
  4. package/bin/codex +15 -0
  5. package/bin/codex-accounts +31 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +28 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +81 -9
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +131 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/model_catalog.json +404 -0
  59. package/lib/model_catalog.py +201 -0
  60. package/lib/model_match.py +104 -0
  61. package/lib/report.py +8 -5
  62. package/lib/session_settings.py +123 -0
  63. package/package.json +2 -2
  64. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  121. package/tests/fake_codex_tui.py +25 -0
  122. package/tests/fake_security.sh +24 -2
  123. package/tests/fixtures/cross_provider_cli.py +109 -0
  124. package/tests/run-tests.sh +1028 -23
  125. package/tests/test_autoresume.py +215 -8
  126. package/tests/test_autoresume_catalog.py +226 -0
  127. package/tests/test_autoresume_catalog_cli.py +185 -0
  128. package/tests/test_autoresume_codex_records.py +16 -0
  129. package/tests/test_autoresume_codex_trust.py +247 -0
  130. package/tests/test_autoresume_cross_guards.py +28 -0
  131. package/tests/test_autoresume_cross_tmux.py +27 -2
  132. package/tests/test_autoresume_cross_tokens.py +32 -2
  133. package/tests/test_autoresume_handoff_argv.py +120 -16
  134. package/tests/test_autoresume_handoff_origins.py +14 -0
  135. package/tests/test_autoresume_memory.py +1141 -0
  136. package/tests/test_autoresume_memory_tmux.py +94 -0
  137. package/tests/test_autoresume_probe.py +2 -1
  138. package/tests/test_autoresume_relaunch_settings.py +317 -0
  139. package/tests/test_autoresume_settings.py +135 -0
  140. package/tests/test_autoresume_sidechain.py +1518 -0
  141. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  142. package/tests/test_autoresume_trust_tmux.py +63 -0
  143. package/tests/test_claude_client_error.py +10 -7
  144. package/tests/test_claude_org_block.py +1 -0
  145. package/tests/test_claude_pool_prepare.py +4 -1
  146. package/tests/test_claude_reset.py +3 -0
  147. package/tests/test_keychain_nologin.py +340 -0
  148. package/tests/test_mcp_oauth.py +1414 -0
  149. package/tests/test_packaged_commands.py +41 -0
@@ -0,0 +1,1748 @@
1
+ #!/usr/bin/env python3
2
+ """MCP OAuth logins, shared by every pooled Claude account on this machine.
3
+
4
+ WHY (2026-10-19). Claude Code keeps an MCP server's OAuth login in the config dir's
5
+ credential store, next to its own login: ONE JSON document holding `claudeAiOauth` (the
6
+ Claude login) and `mcpOAuth` (MCP servers' OAuth state, keyed
7
+ `<serverName>|<sha256(JSON.stringify({type,url,headers}))[:16]>`). Under the pool every
8
+ launch runs in another config dir, so a server authenticated in one account said "needs
9
+ authentication" under every other account the shim picked: five separate higgsfield
10
+ grants sat in five accounts, and the rest held discovery-only stubs (`accessToken: ""`).
11
+
12
+ The client's store (Claude Code 2.1.303/2.1.304, read out of the binary):
13
+ * macOS: the login Keychain item `Claude Code-credentials-<sha256(dir)[:8]>` under
14
+ `$USER` (lib/keychain.py), with `<dir>/.credentials.json` (0600) as the fallback.
15
+ read() is the Keychain document when there is one, else the file. A session that
16
+ cannot open the keychain — ssh, tmux, background, `security` exit 36 — reads and
17
+ writes the FILE, and every session on the operator's Mac is one: there the file holds
18
+ `mcpOAuth` alone (the Claude login is the shim's setup token). Elsewhere: the file.
19
+ * every write takes a proper-lockfile lock — the DIRECTORY `<dir>/.storage-write.lock`,
20
+ stale after 15 s, its mtime refreshed by the holder — then re-reads, applies its one
21
+ change and writes the whole document back.
22
+ * an MCP entry: serverName, serverUrl, accessToken, refreshToken, expiresAt (epoch ms,
23
+ now + expires_in on every save — and NO expiresAt when the answer had no
24
+ expires_in), scope, issuer, clientId, clientSecret?, redirectUri, discoveryState
25
+ {authorizationServerUrl, authServerMetadataUrl, …}, stepUpScope?. tokens() re-reads
26
+ the store on every call and refreshes once the access token has 300 s or less left,
27
+ under a lock of ITS OWN config dir (`mcp-refresh-<key>.lock`); a refresh answered
28
+ without a refresh token keeps the old one.
29
+ * a refresh refused with invalid_grant blanks the entry (tokens gone, expiresAt 0) —
30
+ only while the store still holds the tokens that client was using. /mcp "Clear
31
+ authentication" and `claude mcp login` REVOKE the stored tokens at the authorization
32
+ server (RFC 7009, when its metadata names a revocation_endpoint), then delete or
33
+ blank the entry; a re-authentication revokes the tokens it replaced.
34
+
35
+ So the pool keeps one MACHINE-LOCAL store, ROOT/mcp-oauth.json (0600, NEVER synced or
36
+ fanned out: a grant belongs to this machine, and two machines refreshing one rotating
37
+ grant strand one of them — the codex auth.json rule), and four verbs work on it:
38
+
39
+ harvest every account's `mcpOAuth`, from its file and from its Keychain item when
40
+ this session can read it. An entry is a LOGIN when its access or refresh
41
+ token is a non-empty string. Per key the winner is the largest expiresAt
42
+ (the latest save) — a login saved without one ranks, and is kept, as if it
43
+ lived ASSUMED_LIFETIME_S from the harvest — ties going to one with a
44
+ refresh token, then to the store's own copy; a login the store marked
45
+ needs_reauth loses to any OTHER grant. Stubs never overwrite anything, and
46
+ tokens the pool's own refreshes replaced, or that `forget` or a clear
47
+ retired, are ignored wherever they turn up.
48
+ distribute the winner, into each account store that exists and lacks the key, holds a
49
+ stub, or holds a provably older login (an earlier expiresAt, or tokens the
50
+ pool replaced) — a login of another grant whose age cannot be compared
51
+ stays. Under the client's own lock, re-reading first, touching that one key
52
+ and nothing else — `claudeAiOauth` above all. A .credentials.json is
53
+ CREATED, holding `mcpOAuth` alone, only for an account that runs on its
54
+ portable token (a non-empty server.token, no `.expired` park, not a
55
+ symlinked dir) and has no file or link of that name: every reader counts a
56
+ document as a login only when it carries `claudeAiOauth` (bin/claude
57
+ file_login, lib/audit.py file_has_login), so that file costs the token
58
+ nothing — it is the very file the client writes itself once it meets an
59
+ OAuth server from a session that cannot open the Keychain — and it is all
60
+ such a client has to read MCP logins from. Every other account has a store
61
+ of its own already, or nothing to run on. A Keychain item is written only
62
+ when it EXISTS and this session can read it, then read back; an absent item
63
+ is never created (the client reads the file then, and a new item would hide
64
+ the file from every session that can open the keychain). A merged file
65
+ keeps its mtime: bin/claude and lib/audit.py read a .credentials.json that
66
+ carries a login and is newer than an `.expired` park as a fresh login and
67
+ lift the park, and an MCP merge is not one — so a parked account gets no
68
+ Keychain write (and no created file) either until it is back in the pool.
69
+ A login is shared only while the pool is its ONE refresher, ahead of every
70
+ client: clients refresh under their own config dir's lock, so accounts
71
+ holding one rotating grant would otherwise refresh it at once and strand
72
+ one (a server that detects reuse revokes the whole grant). So nothing is
73
+ shared with refresh off, without a refreshing `sync` in the last hour,
74
+ or for a login within the clients' own 5-minute window, refused by its
75
+ server, or failing to refresh so often that the next try comes too late.
76
+ refresh (sync only — one writer, under the pool lock) a winner with a refresh token
77
+ whose access token expires within its window — REFRESH_AHEAD (2 h), or half
78
+ its lifetime when that is shorter (never under 10 min, never twice in one
79
+ half-lifetime) — is refreshed at its authorization server, long before any
80
+ client's own 5-minute window. The rotated login is saved before anything
81
+ else, the tokens it replaced are remembered (digests) as superseded, and it
82
+ is distributed everywhere. A login with a copy in a Keychain item this run
83
+ cannot open is left to a run that can, until it is close to expiry.
84
+ forget a deliberate logout: the server's login leaves the store and every account
85
+ store, and the retired grants are remembered (digests — never tokens) so a
86
+ copy in a store this run could not reach is ignored and cleared later.
87
+ A store that held the pool's login and now shows the key gone or blanked
88
+ was cleared by its client — a revocation or a refusal at the server — so
89
+ that login is retired the same way, never merged back.
90
+
91
+ The shim (bin/claude mcp_oauth_ensure) runs `ensure` — harvest the files plus the picked
92
+ account's own Keychain item, distribute to that one account, no network — only when some
93
+ acct-*/.credentials.json is newer than ROOT/.mcp-oauth-seen (the harvest watermark) and
94
+ either the store exists or a file carries `mcpOAuth`, or when the store is newer than the
95
+ account's <dir>/.mcp-oauth-applied stamp. The store's mtime moves only when what the
96
+ accounts are given does (the logins and the retired grants — never bookkeeping), a whole
97
+ second at a time, and stamps copy it; the watermark is the newest file mtime the last
98
+ harvest saw, set a second early when that is the current second — so a write right behind
99
+ a harvest is never hidden from a one-second `test -nt` (bash 3.2).
100
+
101
+ Kill switches: CLAUDE_MULTIACC_MCP_OAUTH=0 (ensure and sync do nothing) and
102
+ CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0 (sync never refreshes — and so nothing is shared:
103
+ one shared login needs one refresher). status and forget are explicit operator commands
104
+ and always run. Nothing here prints or logs a token.
105
+
106
+ Run: mcp_oauth.py ensure --root R --account-dir D [--quiet] | sync --root R [--quiet]
107
+ [--force] | status --root R [--json] | forget --root R NAME [--quiet]
108
+ """
109
+ from __future__ import annotations
110
+
111
+ import argparse
112
+ import base64
113
+ import calendar
114
+ import contextlib
115
+ import copy
116
+ import errno
117
+ import fcntl
118
+ import glob
119
+ import hashlib
120
+ import http.client
121
+ import json
122
+ import os
123
+ import re
124
+ import signal
125
+ import sys
126
+ import tempfile
127
+ import threading
128
+ import time
129
+ import urllib.error
130
+ import urllib.parse
131
+ import urllib.request
132
+
133
+ # The install tree may be root-owned, read-only or an npm prefix: no __pycache__ in it.
134
+ sys.dont_write_bytecode = True
135
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
136
+ import keychain # noqa: E402 (the one place that knows the Keychain layout)
137
+
138
+ STORE_FILE = 'mcp-oauth.json'
139
+ SEEN_FILE = '.mcp-oauth-seen'
140
+ STAMP_FILE = '.mcp-oauth-applied'
141
+ POOL_LOCK = os.path.join('.locks', 'mcp-oauth.lock')
142
+ LIMITS_LOCK = os.path.join('.locks', 'limits')
143
+ CREDENTIALS_FILE = '.credentials.json'
144
+ CLIENT_LOCK = '.storage-write.lock'
145
+ CLIENT_LOCK_STALE_S = 15 # proper-lockfile's `stale`, as the client sets it
146
+ LIMITS_LOCK_FRESH_S = 120 # cmd_limits breaks an older one
147
+ REFRESH_AHEAD_S = 7200
148
+ MIN_WINDOW_S = 600 # a short-lived login is still refreshed this far ahead
149
+ CLIENT_REFRESH_S = 300 # the client refreshes on its own inside this window
150
+ SYNC_PERIOD_S = 900 # the scheduled claude limits pass that runs `sync`
151
+ URGENT_S = CLIENT_REFRESH_S + SYNC_PERIOD_S # the last sync before the clients' window
152
+ REFRESHER_FRESH_S = 3600 # ensure shares only behind a sync this recent
153
+ ASSUMED_LIFETIME_S = 3600 # a token answer without expires_in (as refresh_oauth)
154
+ SUPERSEDED_KEEP = 16
155
+ BACKOFF_FIRST_S = 900
156
+ BACKOFF_MAX_S = 21600
157
+ HTTP_TIMEOUT_S = 10
158
+ KEYCHAIN_HOLD_S = 21600
159
+ FORGOTTEN_KEEP_S = 90 * 86400
160
+ MAX_DOC_BYTES = 4 << 20
161
+ MAX_BODY_BYTES = 1 << 20
162
+ ENSURE_BUDGET_S = 5 # the shim's hot path: a backstop, never the normal case
163
+ ENSURE_SECURITY_TIMEOUT_S = 2
164
+ ENSURE_LOCK_WAIT_S = 0.5 # a busy lock costs this launch the merge, never a second
165
+ SYNC_BUDGET_S = 240
166
+ REFRESH_MIN_LEFT_S = 60 # never start a refresh the budget could cut in half
167
+ ACCT_ID = re.compile(r'^acct-\d{2,4}$')
168
+ OAUTH_ERROR = re.compile(r'^[a-z_]{1,64}$')
169
+ #: outcomes that leave an account to be retried at its next launch (no stamp)
170
+ TRANSIENT = frozenset({'busy', 'deferred', 'failed', 'error', 'parked'})
171
+ #: outcomes `sync` and `forget` report as "not reached"
172
+ UNREACHED = ('busy', 'deferred', 'failed', 'error', 'mismatch', 'held', 'corrupt',
173
+ 'unreadable', 'parked', 'locked')
174
+ USER_AGENT = 'claude-multiacc-mcp-oauth'
175
+
176
+
177
+ class _Budget(BaseException):
178
+ """The run's time budget ran out (a BaseException, so no `except Exception` eats it)."""
179
+
180
+
181
+ def _flag(name):
182
+ return os.environ.get(name, '1').strip().lower() not in ('0', 'false', 'no', 'off')
183
+
184
+
185
+ def enabled():
186
+ return _flag('CLAUDE_MULTIACC_MCP_OAUTH')
187
+
188
+
189
+ def refresh_enabled():
190
+ return _flag('CLAUDE_MULTIACC_MCP_OAUTH_REFRESH')
191
+
192
+
193
+ def refresh_ahead():
194
+ try:
195
+ value = int(os.environ.get('CLAUDE_MULTIACC_MCP_OAUTH_REFRESH_AHEAD', REFRESH_AHEAD_S))
196
+ except ValueError:
197
+ return REFRESH_AHEAD_S
198
+ return max(0, min(value, 7 * 86400))
199
+
200
+
201
+ # --------------------------------------------------------------------------- io
202
+
203
+ def _now_iso(t=None):
204
+ return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(time.time() if t is None else t))
205
+
206
+
207
+ def _epoch(iso):
208
+ """The epoch an _now_iso() stamp names, or None."""
209
+ try:
210
+ return calendar.timegm(time.strptime(iso, '%Y-%m-%dT%H:%M:%SZ'))
211
+ except (TypeError, ValueError, OverflowError):
212
+ return None
213
+
214
+
215
+ def _log(root, message):
216
+ try:
217
+ with open(os.path.join(root, 'ops.log'), 'a', encoding='utf-8') as handle:
218
+ handle.write(f'{_now_iso()} mcp-oauth {message}\n')
219
+ except OSError:
220
+ pass
221
+
222
+
223
+ def _scratch(directory, name, data, mode=0o600):
224
+ """A fsynced temp file beside ``name`` holding ``data`` (bytes): its path."""
225
+ handle, path = tempfile.mkstemp(prefix=f".{name.lstrip('.')}.", suffix='.tmp', dir=directory)
226
+ try:
227
+ with os.fdopen(handle, 'wb') as stream:
228
+ os.fchmod(stream.fileno(), mode)
229
+ stream.write(data)
230
+ stream.flush()
231
+ os.fsync(stream.fileno())
232
+ except BaseException:
233
+ with contextlib.suppress(OSError):
234
+ os.unlink(path)
235
+ raise
236
+ return path
237
+
238
+
239
+ def _write_atomic(path, text, mode=0o600):
240
+ scratch = _scratch(os.path.dirname(path) or '.', os.path.basename(path),
241
+ text.encode('utf-8'), mode)
242
+ try:
243
+ os.replace(scratch, path)
244
+ except BaseException:
245
+ with contextlib.suppress(OSError):
246
+ os.unlink(scratch)
247
+ raise
248
+
249
+
250
+ def _refuse_constant(name):
251
+ raise ValueError(f'{name} is not JSON')
252
+
253
+
254
+ def _parse_doc(raw):
255
+ """The credential document in ``raw`` (bytes), or None when it is not a JSON object.
256
+ NaN/Infinity are refused: the client's JSON.parse refuses them too."""
257
+ if raw is None or len(raw) > MAX_DOC_BYTES:
258
+ return None
259
+ try:
260
+ doc = json.loads(raw.decode('utf-8'), parse_constant=_refuse_constant)
261
+ except ValueError:
262
+ return None
263
+ return doc if isinstance(doc, dict) else None
264
+
265
+
266
+ def _mcp_map(doc):
267
+ """The document's `mcpOAuth` map ({} when absent), or None when it holds something
268
+ else — a store this module does not understand is never written."""
269
+ value = doc.get('mcpOAuth', {})
270
+ return value if isinstance(value, dict) else None
271
+
272
+
273
+ def _read_bytes(path):
274
+ """('ok', bytes) / ('absent', None) / ('error', None)."""
275
+ try:
276
+ with open(path, 'rb') as handle:
277
+ return 'ok', handle.read(MAX_DOC_BYTES + 1)
278
+ except FileNotFoundError:
279
+ return 'absent', None
280
+ except OSError:
281
+ return 'error', None
282
+
283
+
284
+ # ---------------------------------------------------------------------- entries
285
+
286
+ def is_login(entry):
287
+ """An MCP entry that can authenticate: an access or a refresh token is present."""
288
+ return isinstance(entry, dict) and any(
289
+ isinstance(entry.get(name), str) and entry.get(name)
290
+ for name in ('accessToken', 'refreshToken'))
291
+
292
+
293
+ def _number(value):
294
+ if isinstance(value, bool):
295
+ return None
296
+ if isinstance(value, str):
297
+ try:
298
+ value = float(value)
299
+ except ValueError:
300
+ return None
301
+ if isinstance(value, (int, float)) and value == value and abs(value) != float('inf'):
302
+ return value
303
+ return None
304
+
305
+
306
+ def _expires_ms(entry):
307
+ return _number(entry.get('expiresAt')) if isinstance(entry, dict) else None
308
+
309
+
310
+ def _has_refresh(entry):
311
+ token = entry.get('refreshToken') if isinstance(entry, dict) else None
312
+ return isinstance(token, str) and bool(token)
313
+
314
+
315
+ def _rank(entry):
316
+ expires = _expires_ms(entry)
317
+ return (expires if expires is not None else -1, _has_refresh(entry))
318
+
319
+
320
+ def _token(entry, name):
321
+ value = entry.get(name) if isinstance(entry, dict) else None
322
+ return value if isinstance(value, str) and value else None
323
+
324
+
325
+ def _same_tokens(a, b):
326
+ """Both logins carry exactly the same tokens: one save, wherever it sits (the store's
327
+ copy may add the expiry the client's lacked)."""
328
+ return is_login(a) and is_login(b) and all(
329
+ _token(a, name) == _token(b, name) for name in ('accessToken', 'refreshToken'))
330
+
331
+
332
+ def _same_grant(a, b):
333
+ if not isinstance(a, dict) or not isinstance(b, dict):
334
+ return False
335
+ if _has_refresh(a) and _has_refresh(b):
336
+ return a['refreshToken'] == b['refreshToken']
337
+ token = a.get('accessToken')
338
+ return isinstance(token, str) and bool(token) and token == b.get('accessToken')
339
+
340
+
341
+ def _digests(entry):
342
+ """Short sha256 digests of an entry's tokens: how the store remembers a token it
343
+ retired or replaced without keeping it."""
344
+ out = set()
345
+ for name in ('accessToken', 'refreshToken'):
346
+ value = entry.get(name) if isinstance(entry, dict) else None
347
+ if isinstance(value, str) and value:
348
+ out.add(hashlib.sha256(value.encode('utf-8')).hexdigest()[:24])
349
+ return out
350
+
351
+
352
+ def _superseded(rec, entry):
353
+ """``entry`` carries a token one of the pool's own refreshes replaced: an older copy
354
+ of ``rec``'s login, whatever its expiry says."""
355
+ return bool(rec) and bool(_digests(entry) & set(rec.get('superseded') or ()))
356
+
357
+
358
+ def _provably_older(have, entry):
359
+ """``have`` is a login older than ``entry`` by their expiries — never when one of
360
+ them has none: a login saved without an expiry may well be the newest."""
361
+ return _expires_ms(have) is not None and _expires_ms(entry) is not None \
362
+ and _rank(entry) > _rank(have)
363
+
364
+
365
+ def _server_name(key, entry):
366
+ name = entry.get('serverName') if isinstance(entry, dict) else None
367
+ return name if isinstance(name, str) and name else key.split('|', 1)[0]
368
+
369
+
370
+ def _sharing_off(rec, now):
371
+ """Why the pool must not share this login now, or None. A login shared into many
372
+ accounts needs ONE refresher — the pool, ahead of every client — or the clients,
373
+ each under its own config dir's lock, refresh one rotating grant at once."""
374
+ if rec.get('needs_reauth'):
375
+ return 'its authorization server refused the refresh token'
376
+ expires = _expires_ms(rec['entry'])
377
+ if expires is None:
378
+ return 'no expiry to refresh it ahead of'
379
+ if expires / 1000.0 - now <= CLIENT_REFRESH_S:
380
+ return 'expiring: the clients refresh it themselves now'
381
+ backoff = rec.get('refresh') or {}
382
+ if backoff.get('failures') and (_number(backoff.get('next_at')) or 0) + SYNC_PERIOD_S \
383
+ >= expires / 1000.0 - CLIENT_REFRESH_S:
384
+ return 'its refresh keeps failing and the next try comes too late'
385
+ return None
386
+
387
+
388
+ # ------------------------------------------------------------------------ store
389
+
390
+ class Store:
391
+ """ROOT/mcp-oauth.json: {"version": 1, "entries": {key: {"entry": <the client's
392
+ entry, its expiresAt stamped when it had none>, "source": acct, "store":
393
+ file|keychain, "harvested_at", "refreshed_at"?, "lifetime_s"?, "expiry_assumed"?,
394
+ "refresh"?: {failures, next_at, last_error, last_attempt_at}, "deferred"?,
395
+ "needs_reauth"?, "holders"?: ["acct-NN:file"|"acct-NN:keychain", …] (the stores
396
+ known to hold this very login), "superseded"?: [token digests its refreshes
397
+ replaced]}}, "forgotten": {key: {"at", "digests", "reason"}}, "holds": {acct:
398
+ {"until", "reason", "at"}}, "refresher": {"at", "refresh"} (the last sync's beat)}.
399
+ Its mtime is the shim's "the store changed" clock (see :meth:`save`)."""
400
+
401
+ def __init__(self, root):
402
+ self.root = root
403
+ self.path = os.path.join(root, STORE_FILE)
404
+ self.doc = {'version': 1, 'entries': {}, 'forgotten': {}, 'holds': {}}
405
+ self.exists = False
406
+ self.dirty = False
407
+ self.clock = 0
408
+ self.signature = None
409
+
410
+ @property
411
+ def entries(self):
412
+ return self.doc['entries']
413
+
414
+ @property
415
+ def forgotten(self):
416
+ return self.doc['forgotten']
417
+
418
+ def load(self, readonly=False):
419
+ try:
420
+ st = os.stat(self.path)
421
+ with open(self.path, encoding='utf-8') as handle:
422
+ doc = json.load(handle)
423
+ except FileNotFoundError:
424
+ return self
425
+ except ValueError:
426
+ if not readonly:
427
+ # A store this module cannot read is a cache it can rebuild from the
428
+ # accounts: keep the bad copy for a human and start over.
429
+ with contextlib.suppress(OSError):
430
+ os.replace(self.path, self.path + '.corrupt')
431
+ _log(self.root, f'{STORE_FILE} was not JSON; moved aside to {STORE_FILE}.corrupt')
432
+ self.dirty = True
433
+ return self
434
+ self.exists, self.clock = True, int(st.st_mtime)
435
+ if not isinstance(doc, dict):
436
+ self.dirty = True
437
+ return self
438
+ entries = doc.get('entries')
439
+ for key, rec in (entries.items() if isinstance(entries, dict) else ()):
440
+ if isinstance(key, str) and isinstance(rec, dict) and is_login(rec.get('entry')):
441
+ self.entries[key] = rec
442
+ else:
443
+ self.dirty = True
444
+ for name in ('forgotten', 'holds'):
445
+ value = doc.get(name)
446
+ if isinstance(value, dict):
447
+ self.doc[name] = {k: v for k, v in value.items()
448
+ if isinstance(k, str) and isinstance(v, dict)}
449
+ if isinstance(doc.get('refresher'), dict):
450
+ self.doc['refresher'] = doc['refresher']
451
+ self.signature = self._signature()
452
+ return self
453
+
454
+ def _signature(self):
455
+ """What the accounts are given: the logins and the retired grants. Only its change
456
+ moves the store's clock — holders, backoff, holds and the refresher's beat are
457
+ bookkeeping no account has to act on."""
458
+ doc = {'entries': {k: r.get('entry') for k, r in self.entries.items()},
459
+ 'forgotten': {k: sorted(v.get('digests') or ()) for k, v in self.forgotten.items()}}
460
+ return hashlib.sha256(json.dumps(doc, sort_keys=True).encode('utf-8')).hexdigest()
461
+
462
+ def forgotten_match(self, key, entry):
463
+ tomb = self.forgotten.get(key)
464
+ return bool(tomb) and bool(_digests(entry) & set(tomb.get('digests') or ()))
465
+
466
+ def held(self, account, now):
467
+ hold = self.doc['holds'].get(account)
468
+ return bool(hold) and (_number(hold.get('until')) or 0) > now
469
+
470
+ def hold(self, account, reason, now):
471
+ self.doc['holds'][account] = {'until': int(now + KEYCHAIN_HOLD_S), 'reason': reason,
472
+ 'at': _now_iso(now)}
473
+ self.dirty = True
474
+
475
+ def beat(self, now):
476
+ """`sync` is running: the refresher record that `ensure` shares behind."""
477
+ self.doc['refresher'] = {'at': int(now), 'refresh': refresh_enabled()}
478
+ self.dirty = True
479
+
480
+ def refresher_ok(self, now):
481
+ """A sync with refresh on ran within REFRESHER_FRESH_S: every due login will be
482
+ refreshed ahead of the clients, so `ensure` may share."""
483
+ beat = self.doc.get('refresher') or {}
484
+ at = _number(beat.get('at'))
485
+ return beat.get('refresh') is True and at is not None \
486
+ and -CLIENT_REFRESH_S <= now - at < REFRESHER_FRESH_S
487
+
488
+ def retire(self, key, reason, now, digests=()):
489
+ """Drop ``key``'s login and remember its tokens — and the older ones its
490
+ refreshes replaced — so no copy of it is ever shared again, and every copy is
491
+ cleared wherever a later run reaches it."""
492
+ rec = self.entries.pop(key, None)
493
+ digests = set(digests)
494
+ if rec:
495
+ digests |= _digests(rec['entry']) | set(rec.get('superseded') or ())
496
+ old = self.forgotten.get(key) or {}
497
+ self.forgotten[key] = {'at': _now_iso(now), 'reason': reason,
498
+ 'digests': sorted(digests | set(old.get('digests') or ()))}
499
+ self.dirty = True
500
+
501
+ def save(self):
502
+ """Write the store (0600, atomic). Its mtime is the shim's "the store changed"
503
+ clock: it moves — at least one whole second past the previous one, so a
504
+ one-second `test -nt` against the accounts' stamps still sees every change — only
505
+ when what the accounts are given changed; a bookkeeping save keeps it."""
506
+ now = time.time()
507
+ self.doc['holds'] = {k: v for k, v in self.doc['holds'].items()
508
+ if (_number(v.get('until')) or 0) > now}
509
+ self.doc['forgotten'] = {k: v for k, v in self.forgotten.items()
510
+ if (now - (_epoch(v.get('at')) or now)) < FORGOTTEN_KEEP_S}
511
+ self.doc['version'] = 1
512
+ self.doc['updated_at'] = _now_iso(now)
513
+ signature = self._signature()
514
+ _write_atomic(self.path, json.dumps(self.doc, indent=2) + '\n')
515
+ if self.exists and self.clock and signature == self.signature:
516
+ clock = self.clock
517
+ else:
518
+ clock = max(int(now), self.clock + 1)
519
+ os.utime(self.path, (clock, clock))
520
+ self.exists, self.dirty, self.clock, self.signature = True, False, clock, signature
521
+ return clock
522
+
523
+
524
+ class PoolLock:
525
+ """flock on ROOT/.locks/mcp-oauth.lock: one writer of the store at a time — and with
526
+ it ONE refresher. Released when the process exits, however it exits."""
527
+
528
+ def __init__(self, root, wait):
529
+ self.path = os.path.join(root, POOL_LOCK)
530
+ self.wait = wait
531
+ self.fd = None
532
+ self.held = False
533
+
534
+ def __enter__(self):
535
+ try:
536
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
537
+ self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
538
+ except OSError:
539
+ return self
540
+ deadline = time.monotonic() + self.wait
541
+ while True:
542
+ try:
543
+ fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
544
+ self.held = True
545
+ return self
546
+ except OSError as exc:
547
+ if exc.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES) \
548
+ or time.monotonic() >= deadline:
549
+ return self
550
+ time.sleep(0.05)
551
+
552
+ def __exit__(self, *_exc):
553
+ if self.fd is not None:
554
+ with contextlib.suppress(OSError):
555
+ os.close(self.fd)
556
+ self.fd = None
557
+ self.held = False
558
+ return False
559
+
560
+
561
+ class ClientLock:
562
+ """The client's own write lock, in proper-lockfile's layout: the DIRECTORY
563
+ <dir>/.storage-write.lock, stale once its mtime is CLIENT_LOCK_STALE_S old (a live
564
+ holder refreshes it well inside that). A held lock is waited on for ``wait`` seconds,
565
+ then the account is left for the next run; a stale one is taken over."""
566
+
567
+ def __init__(self, account_dir, wait):
568
+ self.path = os.path.join(account_dir, CLIENT_LOCK)
569
+ self.wait = wait
570
+ self.held = False
571
+
572
+ def acquire(self):
573
+ deadline = time.monotonic() + self.wait
574
+ while True:
575
+ try:
576
+ os.mkdir(self.path)
577
+ self.held = True
578
+ return True
579
+ except FileExistsError:
580
+ pass
581
+ except OSError:
582
+ return False
583
+ try:
584
+ stale = time.time() - os.stat(self.path).st_mtime > CLIENT_LOCK_STALE_S
585
+ except FileNotFoundError:
586
+ continue # released just now: take it
587
+ except OSError:
588
+ return False
589
+ if stale:
590
+ try:
591
+ os.rmdir(self.path) # its holder is gone
592
+ continue
593
+ except FileNotFoundError:
594
+ continue
595
+ except OSError:
596
+ pass # cannot clear it: wait like for any held lock
597
+ if time.monotonic() >= deadline:
598
+ return False
599
+ time.sleep(0.05)
600
+
601
+ def touch(self):
602
+ if self.held:
603
+ with contextlib.suppress(OSError):
604
+ os.utime(self.path)
605
+
606
+ def release(self):
607
+ if self.held:
608
+ with contextlib.suppress(OSError):
609
+ os.rmdir(self.path)
610
+ self.held = False
611
+
612
+
613
+ @contextlib.contextmanager
614
+ def _budget(seconds):
615
+ """Bound the whole run (the shim waits for `ensure`): past ``seconds`` it unwinds —
616
+ locks released by their own `finally` — and the caller exits 0."""
617
+ usable = seconds and hasattr(signal, 'setitimer') \
618
+ and threading.current_thread() is threading.main_thread()
619
+ if not usable:
620
+ yield
621
+ return
622
+
623
+ def on_alarm(_signum, _frame):
624
+ raise _Budget()
625
+
626
+ previous = signal.signal(signal.SIGALRM, on_alarm)
627
+ signal.setitimer(signal.ITIMER_REAL, seconds)
628
+ try:
629
+ yield
630
+ finally:
631
+ signal.setitimer(signal.ITIMER_REAL, 0)
632
+ signal.signal(signal.SIGALRM, previous)
633
+
634
+
635
+ def _limits_running(root):
636
+ """A `claude-accounts limits` pass holds its lock: it may be rotating a login right
637
+ now, and it writes without the client's lock."""
638
+ try:
639
+ return time.time() - os.stat(os.path.join(root, LIMITS_LOCK)).st_mtime \
640
+ < LIMITS_LOCK_FRESH_S
641
+ except OSError:
642
+ return False
643
+
644
+
645
+ # ---------------------------------------------------------------------- accounts
646
+
647
+ def account_dirs(root):
648
+ """[(id, dir)] of every manifest account whose dir exists, in id order — the accounts
649
+ the shim can pick. An unreadable manifest is no accounts (fail-open)."""
650
+ try:
651
+ with open(os.path.join(root, 'accounts.json'), encoding='utf-8') as handle:
652
+ doc = json.load(handle)
653
+ except (OSError, ValueError):
654
+ return []
655
+ listed = doc.get('accounts') if isinstance(doc, dict) else None
656
+ out = set()
657
+ for account in listed if isinstance(listed, list) else ():
658
+ aid = account.get('id') if isinstance(account, dict) else None
659
+ if isinstance(aid, str) and ACCT_ID.match(aid) and os.path.isdir(os.path.join(root, aid)):
660
+ out.add((aid, os.path.join(root, aid)))
661
+ return sorted(out)
662
+
663
+
664
+ def _read_file_store(account_dir):
665
+ status, raw = _read_bytes(os.path.join(account_dir, CREDENTIALS_FILE))
666
+ if status == 'absent':
667
+ return {'state': 'absent', 'doc': None, 'map': {}}
668
+ if status != 'ok':
669
+ return {'state': 'unreadable', 'doc': None, 'map': {}}
670
+ doc = _parse_doc(raw)
671
+ mapping = _mcp_map(doc) if doc is not None else None
672
+ if mapping is None:
673
+ # `mentions`: what the shim's grep sees in a file no JSON parser can read
674
+ return {'state': 'corrupt', 'doc': None, 'map': {}, 'mentions': b'"mcpOAuth"' in raw}
675
+ return {'state': 'ok', 'doc': doc, 'map': mapping}
676
+
677
+
678
+ def _read_keychain_store(keychain_dir, timeout):
679
+ state, doc = keychain.read_doc(keychain_dir, timeout=timeout)
680
+ if state != 'present':
681
+ return {'state': state, 'doc': None, 'map': {}}
682
+ mapping = _mcp_map(doc)
683
+ if mapping is None:
684
+ return {'state': 'corrupt', 'doc': None, 'map': {}}
685
+ return {'state': 'ok', 'doc': doc, 'map': mapping}
686
+
687
+
688
+ def _harvest(root, accounts, keychain_dirs, timeout):
689
+ """({id: {'file': seen, 'keychain': seen or None}}, {path: mtime_ns}). The mtimes are
690
+ of EVERY acct-*/.credentials.json (the shim's glob, retired dirs included) and are
691
+ taken BEFORE anything is read: a write that lands after the stat can only make the
692
+ next gate fire again, never hide."""
693
+ stats = {}
694
+ for path in glob.glob(os.path.join(glob.escape(root), 'acct-*', CREDENTIALS_FILE)):
695
+ with contextlib.suppress(OSError):
696
+ stats[path] = os.stat(path).st_mtime_ns
697
+ seen = {}
698
+ for aid, account_dir in accounts:
699
+ kdir = keychain_dirs.get(aid)
700
+ seen[aid] = {'file': _read_file_store(account_dir),
701
+ 'keychain': _read_keychain_store(kdir, timeout) if kdir else None}
702
+ return seen, stats
703
+
704
+
705
+ def _in_play(seen):
706
+ """Some account's client has written MCP OAuth state: the store is worth keeping."""
707
+ return any(s and s['state'] == 'ok' and 'mcpOAuth' in s['doc']
708
+ for stores in seen.values() for s in stores.values())
709
+
710
+
711
+ def _choose(store, seen, now):
712
+ """Fold the harvested logins into the store. Returns the keys whose winner changed."""
713
+ candidates = {}
714
+ for aid in sorted(seen):
715
+ for kind in ('file', 'keychain'):
716
+ s = seen[aid].get(kind)
717
+ if not s or s['state'] != 'ok':
718
+ continue
719
+ for key, entry in s['map'].items():
720
+ if isinstance(key, str) and key and is_login(entry) \
721
+ and not store.forgotten_match(key, entry) \
722
+ and not _superseded(store.entries.get(key), entry):
723
+ candidates.setdefault(key, []).append((entry, aid, kind))
724
+ # A login saved without an expiry (a token answer with no expires_in) is ranked — and
725
+ # kept — as if it lived ASSUMED_LIFETIME_S from now: never as older than every copy
726
+ # that has one, which put the old grant back over a rotated one.
727
+ stamp = int(now * 1000 + ASSUMED_LIFETIME_S * 1000)
728
+ changed = []
729
+ for key in sorted(candidates):
730
+ rec = store.entries.get(key)
731
+ current = rec['entry'] if rec else None
732
+ known_bad = bool(rec and rec.get('needs_reauth'))
733
+
734
+ def order(item, current=current, known_bad=known_bad):
735
+ entry, _aid, _kind, is_current, _assumed = item
736
+ refused = known_bad and _same_grant(entry, current)
737
+ return (not refused, _rank(entry), is_current)
738
+
739
+ items = []
740
+ for entry, aid, kind in candidates[key]:
741
+ if _same_tokens(entry, current):
742
+ continue # the store's own login, wherever it sits
743
+ assumed = _expires_ms(entry) is None
744
+ items.append((dict(entry, expiresAt=stamp) if assumed else entry,
745
+ aid, kind, False, assumed))
746
+ if current is not None:
747
+ items.append((current, None, None, True, False))
748
+ best = max(items, key=order) # the first of equals: lowest account id
749
+ if best[3]:
750
+ continue
751
+ new = {'entry': copy.deepcopy(best[0]), 'source': best[1], 'store': best[2],
752
+ 'harvested_at': _now_iso(now)}
753
+ if best[4]:
754
+ new['expiry_assumed'] = True
755
+ if rec and rec.get('superseded'):
756
+ new['superseded'] = rec['superseded'] # those tokens stay dead
757
+ store.entries[key] = new
758
+ store.dirty = True
759
+ changed.append(key)
760
+ return changed
761
+
762
+
763
+ def _note_holders(store, seen, now):
764
+ """Bookkeeping after a harvest: the stores holding each record's very login. A store
765
+ the record knew as a holder that now holds no login for the key — gone from a map it
766
+ still has, or blanked — was cleared by its own client: a refused refresh, /mcp "Clear
767
+ authentication", `claude mcp login`, each revoking or refused at the server first. So
768
+ that login is retired everywhere, as `forget` does — merging it back would only hand
769
+ every account a dead grant. Returns the retired keys."""
770
+ retired = []
771
+ for key in sorted(store.entries):
772
+ rec = store.entries[key]
773
+ before = set(rec.get('holders') or ())
774
+ holders = {h for h in before if h.split(':', 1)[0] in seen}
775
+ dropped = None
776
+ for aid in sorted(seen):
777
+ for kind in ('file', 'keychain'):
778
+ s = seen[aid].get(kind)
779
+ name = f'{aid}:{kind}'
780
+ if s is None or s['state'] not in ('ok', 'absent'):
781
+ continue # cannot tell from here: keep what we knew
782
+ have = s['map'].get(key)
783
+ if _same_tokens(have, rec['entry']):
784
+ holders.add(name)
785
+ elif name in holders:
786
+ holders.discard(name)
787
+ # a store that is gone, or rewritten without any MCP state, says
788
+ # nothing about the login
789
+ if s['state'] == 'ok' and 'mcpOAuth' in s['doc'] and not is_login(have):
790
+ dropped = dropped or name
791
+ if dropped:
792
+ store.retire(key, f'cleared in {dropped.replace(":", " ")}', now)
793
+ _log(store.root, f'{key}: the login was cleared in {dropped.replace(":", " ")} '
794
+ '(revoked or refused there) — retired in every account')
795
+ retired.append(key)
796
+ elif holders != before:
797
+ rec['holders'] = sorted(holders)
798
+ store.dirty = True
799
+ return retired
800
+
801
+
802
+ def _hold(store, aid, res):
803
+ """After a merge: the stores now holding a record's login."""
804
+ for kind, keys in res['held'].items():
805
+ for key in keys:
806
+ rec = store.entries.get(key)
807
+ if rec is not None:
808
+ holders = set(rec.get('holders') or ()) | {f'{aid}:{kind}'}
809
+ if holders != set(rec.get('holders') or ()):
810
+ rec['holders'] = sorted(holders)
811
+ store.dirty = True
812
+
813
+
814
+ def _plan(store, now, sharing):
815
+ """account map -> {key: entry to write, or None to drop}: what this account store
816
+ needs from the store. Retired logins are always dropped; logins are shared only with
817
+ ``sharing`` (this process may: the refresher runs) and while :func:`_sharing_off`
818
+ has no objection."""
819
+ def plan(mapping):
820
+ out = {}
821
+ for key, rec in store.entries.items():
822
+ entry = rec['entry']
823
+ have = mapping.get(key)
824
+ if not sharing or _sharing_off(rec, now) or _same_tokens(have, entry):
825
+ continue
826
+ if not is_login(have) or store.forgotten_match(key, have) \
827
+ or _superseded(rec, have) or _provably_older(have, entry):
828
+ out[key] = entry
829
+ for key in store.forgotten:
830
+ have = mapping.get(key)
831
+ if key not in out and is_login(have) and store.forgotten_match(key, have):
832
+ out[key] = None
833
+ return out
834
+ return plan
835
+
836
+
837
+ def _apply(doc, mapping, changes):
838
+ """``doc`` with ``changes`` merged into its `mcpOAuth` map; every other key — the
839
+ Claude login above all — exactly as it was, in its place."""
840
+ merged = dict(mapping)
841
+ for key, entry in changes.items():
842
+ if entry is None:
843
+ merged.pop(key, None)
844
+ else:
845
+ merged[key] = copy.deepcopy(entry)
846
+ out = dict(doc)
847
+ out['mcpOAuth'] = merged
848
+ return out
849
+
850
+
851
+ def _landed(mapping, changes):
852
+ return all((key not in mapping) if entry is None else (mapping.get(key) == entry)
853
+ for key, entry in changes.items())
854
+
855
+
856
+ def _may_create(account_dir):
857
+ """Whether a .credentials.json holding MCP state alone may be CREATED here: only for an
858
+ account that runs on its portable token (a non-empty server.token) — that client has
859
+ no other store to read MCP logins from in a session that cannot open the Keychain,
860
+ and writes this very file itself once it meets an OAuth server — never one parked
861
+ (`.expired`), an adopted dir (a symlink: ~/.claude itself), or a name that is taken,
862
+ a dangling link included. Every reader counts a document as a login only when it
863
+ carries `claudeAiOauth` (bin/claude file_login, lib/audit.py file_has_login), so the
864
+ file costs the token nothing; an account with a login has a store already, and one
865
+ with no credential at all gains nothing (`adopt` refuses a dir holding the file)."""
866
+ path = os.path.join(account_dir, CREDENTIALS_FILE)
867
+ try:
868
+ if os.path.islink(account_dir) or os.path.lexists(path) \
869
+ or os.path.lexists(os.path.join(account_dir, '.expired')):
870
+ return False
871
+ return os.path.getsize(os.path.join(account_dir, 'server.token')) > 0
872
+ except OSError:
873
+ return False
874
+
875
+
876
+ def _create_file(target, plan):
877
+ """The file a token account's client would write (MCP state alone), 0600, appearing
878
+ whole and only if the name is still free: (outcome, changes) — ('taken', {}) when a
879
+ client wrote one meanwhile (the caller merges into that instead)."""
880
+ changes = {k: v for k, v in plan({}).items() if v is not None}
881
+ if not changes:
882
+ return 'absent', {}
883
+ text = json.dumps(_apply({}, {}, changes), ensure_ascii=False, separators=(',', ':'))
884
+ scratch = _scratch(os.path.dirname(target), os.path.basename(target), text.encode('utf-8'))
885
+ try:
886
+ os.link(scratch, target)
887
+ except FileExistsError:
888
+ return 'taken', {}
889
+ except OSError:
890
+ return 'error', {}
891
+ finally:
892
+ with contextlib.suppress(OSError):
893
+ os.unlink(scratch)
894
+ return 'created', changes
895
+
896
+
897
+ def _merge_file(root, account_dir, plan, create=False):
898
+ """Under the client lock: (outcome, changes written). Into a file that exists; or,
899
+ with ``create`` (_may_create), a new one holding MCP state alone. Re-reads, merges,
900
+ writes a temp beside the file, checks the file did not change under it, replaces it,
901
+ and puts the old mtime back — the merge is not a login. A file holding a login is left
902
+ alone while a limits pass runs (defence in depth: its refresher writes under the
903
+ client's lock and re-reads first)."""
904
+ path = os.path.join(account_dir, CREDENTIALS_FILE)
905
+ target = os.path.realpath(path) if os.path.islink(path) else path
906
+
907
+ def snapshot():
908
+ status, raw = _read_bytes(target)
909
+ try:
910
+ st = os.stat(target) if status == 'ok' else None
911
+ except OSError:
912
+ return 'error', None, None
913
+ return status, raw, st
914
+
915
+ for _attempt in range(3):
916
+ status, raw, before = snapshot()
917
+ if status == 'absent':
918
+ if not create:
919
+ return 'absent', {}
920
+ outcome, changes = _create_file(target, plan)
921
+ if outcome == 'taken':
922
+ continue # the client wrote one meanwhile: merge into it
923
+ return outcome, changes
924
+ if status == 'error':
925
+ return 'unreadable', {}
926
+ doc = _parse_doc(raw)
927
+ mapping = _mcp_map(doc) if doc is not None else None
928
+ if mapping is None:
929
+ return 'corrupt', {}
930
+ changes = plan(mapping)
931
+ if not changes:
932
+ return 'current', {}
933
+ if 'claudeAiOauth' in doc and _limits_running(root):
934
+ return 'deferred', {}
935
+ text = json.dumps(_apply(doc, mapping, changes), ensure_ascii=False,
936
+ separators=(',', ':'))
937
+ scratch = _scratch(os.path.dirname(target), os.path.basename(target),
938
+ text.encode('utf-8'))
939
+ try:
940
+ now_status, now_raw, now_st = snapshot()
941
+ if (now_status, now_raw) != (status, raw) or now_st is None \
942
+ or now_st.st_mtime_ns != before.st_mtime_ns:
943
+ continue # changed under us: merge again from the new text
944
+ os.replace(scratch, target)
945
+ scratch = None
946
+ finally:
947
+ if scratch:
948
+ with contextlib.suppress(OSError):
949
+ os.unlink(scratch)
950
+ with contextlib.suppress(OSError):
951
+ os.utime(target, ns=(before.st_atime_ns, before.st_mtime_ns))
952
+ return 'written', changes
953
+ return 'busy', {}
954
+
955
+
956
+ def _merge_keychain(root, account_dir, plan, parked, timeout):
957
+ """Under the client lock: (outcome, changes written). Only an item that exists and
958
+ this session can read; written the client's way and read back — the Claude login must
959
+ be exactly what it was and every change must have landed, or it is a 'mismatch'."""
960
+ if parked:
961
+ return 'parked', {}
962
+ state, doc = keychain.read_doc(account_dir, timeout=timeout)
963
+ if state != 'present':
964
+ return state, {} # absent: never created; locked: not ours to open
965
+ mapping = _mcp_map(doc)
966
+ if mapping is None:
967
+ return 'corrupt', {}
968
+ changes = plan(mapping)
969
+ if not changes:
970
+ return 'current', {}
971
+ if 'claudeAiOauth' in doc and _limits_running(root):
972
+ return 'deferred', {}
973
+ if not keychain.write_doc(account_dir, _apply(doc, mapping, changes), timeout=timeout):
974
+ return 'failed', {}
975
+ state, back = keychain.read_doc(account_dir, timeout=timeout)
976
+ back_map = _mcp_map(back) if state == 'present' else None
977
+ if back_map is None or back.get('claudeAiOauth') != doc.get('claudeAiOauth') \
978
+ or not _landed(back_map, changes):
979
+ return 'mismatch', {}
980
+ return 'written', changes
981
+
982
+
983
+ def _distribute(root, store, aid, account_dir, seen, keychain_dir, plan, now,
984
+ wait, timeout):
985
+ """Merge into one account's stores: {'file', 'keychain', 'keys', 'held'} — the
986
+ outcome per store, the keys written to it and the ones it now holds a login for."""
987
+ out = {'file': 'current', 'keychain': None, 'keys': {}, 'held': {}}
988
+ # A Keychain write moves the item's stamp, and bin/claude lifts an `.expired` park on
989
+ # a Keychain login written after it: a parked account gets none.
990
+ parked = os.path.exists(os.path.join(account_dir, '.expired'))
991
+ fs, ks = seen['file'], seen.get('keychain')
992
+ file_todo = create = False
993
+ if fs['state'] == 'ok':
994
+ file_todo = bool(plan(fs['map']))
995
+ else:
996
+ out['file'] = fs['state']
997
+ # absent: created for a token account alone, and only with a login to share
998
+ if fs['state'] == 'absent' and _may_create(account_dir):
999
+ create = file_todo = any(v is not None for v in plan({}).values())
1000
+ kc_todo = False
1001
+ if ks is not None:
1002
+ out['keychain'] = 'current' if ks['state'] == 'ok' else ks['state']
1003
+ if ks['state'] == 'ok' and plan(ks['map']):
1004
+ if store.held(aid, now):
1005
+ out['keychain'] = 'held'
1006
+ else:
1007
+ kc_todo = True
1008
+ if not file_todo and not kc_todo:
1009
+ return out
1010
+ lock = ClientLock(account_dir, wait)
1011
+ if not lock.acquire():
1012
+ if file_todo:
1013
+ out['file'] = 'busy'
1014
+ if kc_todo:
1015
+ out['keychain'] = 'busy'
1016
+ return out
1017
+ written = {}
1018
+ try:
1019
+ if file_todo:
1020
+ out['file'], written['file'] = _merge_file(root, account_dir, plan, create)
1021
+ lock.touch()
1022
+ if kc_todo:
1023
+ out['keychain'], written['keychain'] = _merge_keychain(root, keychain_dir, plan,
1024
+ parked, timeout)
1025
+ except OSError:
1026
+ if file_todo and out['file'] in ('current', 'absent'):
1027
+ out['file'] = 'error'
1028
+ finally:
1029
+ lock.release()
1030
+ for kind, changes in written.items():
1031
+ if changes:
1032
+ out['keys'][kind] = sorted(changes)
1033
+ out['held'][kind] = sorted(k for k, v in changes.items() if v is not None)
1034
+ if out['keychain'] == 'mismatch':
1035
+ # Stop touching that item: whatever answered is not what was written.
1036
+ store.hold(aid, 'read-back mismatch', now)
1037
+ _log(root, f'{aid} keychain: read-back mismatch — left alone for {KEYCHAIN_HOLD_S // 3600}h')
1038
+ for kind in ('file', 'keychain'):
1039
+ if out['keys'].get(kind):
1040
+ verb = 'created with' if out[kind] == 'created' else 'merged'
1041
+ _log(root, f"{aid} {kind}: {verb} {','.join(out['keys'][kind])}")
1042
+ elif out[kind] in ('busy', 'deferred', 'failed', 'error'):
1043
+ _log(root, f'{aid} {kind}: not merged ({out[kind]}); the next run retries')
1044
+ return out
1045
+
1046
+
1047
+ def _stamp(account_dir, clock):
1048
+ path = os.path.join(account_dir, STAMP_FILE)
1049
+ try:
1050
+ if int(os.stat(path).st_mtime) == clock:
1051
+ return # already says so: no write every pass
1052
+ except OSError:
1053
+ pass
1054
+ try:
1055
+ with open(path, 'w', encoding='utf-8') as handle:
1056
+ handle.write(f'{clock}\n')
1057
+ os.utime(path, (clock, clock))
1058
+ except OSError:
1059
+ pass
1060
+
1061
+
1062
+ def _set_watermark(root, stats):
1063
+ """ROOT/.mcp-oauth-seen gets the newest .credentials.json mtime this run saw (merges
1064
+ keep a file's mtime) — a second early when that is the current second, so the next
1065
+ write cannot hide behind it."""
1066
+ ns = max(stats.values()) if stats else time.time_ns()
1067
+ if ns // 10**9 >= int(time.time()):
1068
+ ns = (ns // 10**9) * 10**9 - 10**6
1069
+ path = os.path.join(root, SEEN_FILE)
1070
+ try:
1071
+ with open(path, 'a', encoding='utf-8'):
1072
+ pass
1073
+ os.utime(path, ns=(ns, ns))
1074
+ except OSError:
1075
+ pass
1076
+
1077
+
1078
+ # ----------------------------------------------------------------------- refresh
1079
+
1080
+ def _url_ok(url):
1081
+ """https, or http to this machine (the test suite's loopback stub): a refresh token
1082
+ never travels in clear text across a network."""
1083
+ try:
1084
+ parts = urllib.parse.urlsplit(url)
1085
+ except ValueError:
1086
+ return False
1087
+ if parts.scheme == 'https' and parts.netloc:
1088
+ return True
1089
+ return parts.scheme == 'http' and (parts.hostname or '') in ('localhost', '127.0.0.1', '::1')
1090
+
1091
+
1092
+ def _metadata_urls(as_url, explicit=None):
1093
+ """RFC 8414 metadata (path inserted after the well-known prefix), then OpenID
1094
+ discovery — the order the MCP SDK tries them in; the discovered URL first."""
1095
+ urls = [explicit] if isinstance(explicit, str) and _url_ok(explicit) else []
1096
+ parts = urllib.parse.urlsplit(as_url)
1097
+ base = f'{parts.scheme}://{parts.netloc}'
1098
+ path = parts.path.rstrip('/')
1099
+ if path:
1100
+ urls += [f'{base}/.well-known/oauth-authorization-server{path}',
1101
+ f'{base}/.well-known/openid-configuration{path}',
1102
+ f'{base}{path}/.well-known/openid-configuration']
1103
+ else:
1104
+ urls += [f'{base}/.well-known/oauth-authorization-server',
1105
+ f'{base}/.well-known/openid-configuration']
1106
+ return list(dict.fromkeys(urls))
1107
+
1108
+
1109
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
1110
+ def redirect_request(self, *_args, **_kwargs):
1111
+ return None # a refresh token is never re-sent anywhere
1112
+
1113
+
1114
+ def _http(url, data=None, headers=None, follow=True):
1115
+ request = urllib.request.Request(url, data=data, method='POST' if data else 'GET',
1116
+ headers=dict({'Accept': 'application/json',
1117
+ 'User-Agent': USER_AGENT}, **(headers or {})))
1118
+ opener = urllib.request.build_opener() if follow else urllib.request.build_opener(_NoRedirect)
1119
+ try:
1120
+ with opener.open(request, timeout=HTTP_TIMEOUT_S) as response:
1121
+ return response.status, response.read(MAX_BODY_BYTES)
1122
+ except urllib.error.HTTPError as exc:
1123
+ try:
1124
+ body = exc.read(MAX_BODY_BYTES)
1125
+ except Exception: # noqa: BLE001 — an error body is a nicety
1126
+ body = b''
1127
+ return exc.code, body
1128
+
1129
+
1130
+ def _json_body(body):
1131
+ try:
1132
+ doc = json.loads((body or b'').decode('utf-8'))
1133
+ except ValueError:
1134
+ return None
1135
+ return doc if isinstance(doc, dict) else None
1136
+
1137
+
1138
+ def _as_metadata(as_url, explicit):
1139
+ """(metadata, None) or (None, error word)."""
1140
+ error = 'no authorization server metadata'
1141
+ for url in _metadata_urls(as_url, explicit):
1142
+ try:
1143
+ status, body = _http(url)
1144
+ except (OSError, ValueError, http.client.HTTPException) as exc:
1145
+ error = f'network ({type(exc).__name__})'
1146
+ continue
1147
+ doc = _json_body(body) if status == 200 else None
1148
+ if doc is not None and isinstance(doc.get('token_endpoint'), str):
1149
+ return doc, None
1150
+ return None, error
1151
+
1152
+
1153
+ def _refresh_entry(entry):
1154
+ """POST the refresh grant for ``entry``: (new entry, lifetime_s, None, False) on
1155
+ success, else (None, None, error, refused) — ``refused`` when the server rejected the
1156
+ grant itself (invalid_grant). The error is a short phrase, never a token. An answer
1157
+ without expires_in is given ASSUMED_LIFETIME_S (lifetime_s None): with no expiry the
1158
+ pool could never refresh it ahead of the clients, and would rank it below the very
1159
+ copies it replaced."""
1160
+ ds = entry.get('discoveryState') if isinstance(entry.get('discoveryState'), dict) else {}
1161
+ as_url = ds.get('authorizationServerUrl') or entry.get('issuer')
1162
+ if not isinstance(as_url, str) or not _url_ok(as_url):
1163
+ return None, None, 'no usable authorization server URL', False
1164
+ client_id = entry.get('clientId')
1165
+ if not isinstance(client_id, str) or not client_id:
1166
+ return None, None, 'no client id', False
1167
+ meta, error = _as_metadata(as_url, ds.get('authServerMetadataUrl'))
1168
+ if meta is None:
1169
+ return None, None, error, False
1170
+ token_url = meta['token_endpoint']
1171
+ if not _url_ok(token_url):
1172
+ return None, None, 'token endpoint is not https', False
1173
+ form = {'grant_type': 'refresh_token', 'refresh_token': entry['refreshToken']}
1174
+ headers = {'Content-Type': 'application/x-www-form-urlencoded'}
1175
+ secret = entry.get('clientSecret')
1176
+ methods = meta.get('token_endpoint_auth_methods_supported')
1177
+ methods = [m for m in methods if isinstance(m, str)] if isinstance(methods, list) else []
1178
+ if isinstance(secret, str) and secret and 'client_secret_basic' in methods \
1179
+ and 'client_secret_post' not in methods:
1180
+ pair = f"{urllib.parse.quote(client_id, safe='')}:{urllib.parse.quote(secret, safe='')}"
1181
+ headers['Authorization'] = 'Basic ' + base64.b64encode(pair.encode('utf-8')).decode('ascii')
1182
+ else:
1183
+ form['client_id'] = client_id
1184
+ if isinstance(secret, str) and secret:
1185
+ form['client_secret'] = secret
1186
+ resource = entry.get('serverUrl')
1187
+ if isinstance(resource, str) and resource:
1188
+ form['resource'] = resource # RFC 8707, as the MCP SDK sends it
1189
+ try:
1190
+ status, body = _http(token_url, urllib.parse.urlencode(form).encode('ascii'), headers,
1191
+ follow=False)
1192
+ except (OSError, ValueError, http.client.HTTPException) as exc:
1193
+ return None, None, f'network ({type(exc).__name__})', False
1194
+ doc = _json_body(body)
1195
+ if status != 200:
1196
+ code = doc.get('error') if doc else None
1197
+ code = code if isinstance(code, str) and OAUTH_ERROR.match(code) else ''
1198
+ return None, None, f'http {status}' + (f' {code}' if code else ''), code == 'invalid_grant'
1199
+ token = doc.get('access_token') if doc else None
1200
+ if not isinstance(token, str) or not token:
1201
+ return None, None, 'no access token in the answer', False
1202
+ new = copy.deepcopy(entry)
1203
+ new['accessToken'] = token
1204
+ rotated = doc.get('refresh_token')
1205
+ if isinstance(rotated, str) and rotated:
1206
+ new['refreshToken'] = rotated
1207
+ lifetime = _number(doc.get('expires_in'))
1208
+ reported = lifetime if lifetime is not None and lifetime > 0 else None
1209
+ new['expiresAt'] = int(time.time() * 1000 + (reported or ASSUMED_LIFETIME_S) * 1000)
1210
+ if isinstance(doc.get('scope'), str):
1211
+ new['scope'] = doc['scope']
1212
+ return new, reported, None, False
1213
+
1214
+
1215
+ def _refresh_window(rec, ahead):
1216
+ """How far ahead of its expiry a login is refreshed: ``ahead``, or half the
1217
+ lifetime its last refresh reported when that is shorter (never under MIN_WINDOW_S) —
1218
+ a 2 h window over a 1 h token refreshed it on every pass."""
1219
+ lifetime = _number(rec.get('lifetime_s'))
1220
+ if lifetime is None or lifetime <= 0:
1221
+ return ahead
1222
+ return min(ahead, max(MIN_WINDOW_S, lifetime / 2.0))
1223
+
1224
+
1225
+ def _refresh_due(rec, now, ahead, force):
1226
+ entry = rec['entry']
1227
+ if rec.get('needs_reauth') or not _has_refresh(entry):
1228
+ return False
1229
+ expires = _expires_ms(entry)
1230
+ if expires is None or expires / 1000.0 - now > _refresh_window(rec, ahead):
1231
+ return False
1232
+ lifetime = _number(rec.get('lifetime_s'))
1233
+ refreshed = _epoch(rec.get('refreshed_at'))
1234
+ if lifetime and refreshed is not None and 0 <= now - refreshed < lifetime / 2.0:
1235
+ return False # refreshed this half-lifetime already
1236
+ return force or (_number((rec.get('refresh') or {}).get('next_at')) or 0) <= now
1237
+
1238
+
1239
+ def _blind_holders(rec, seen):
1240
+ """The Keychain items known to hold ``rec``'s login that this run cannot open."""
1241
+ out = []
1242
+ for holder in rec.get('holders') or ():
1243
+ aid, _, kind = holder.partition(':')
1244
+ s = (seen.get(aid) or {}).get(kind) if kind == 'keychain' else None
1245
+ if kind == 'keychain' and (s is None or s['state'] != 'ok'):
1246
+ out.append(aid)
1247
+ return out
1248
+
1249
+
1250
+ def _refresh_all(root, store, seen, force, deadline):
1251
+ """Refresh every due login, saving the store after EACH answer: a rotated refresh
1252
+ token exists nowhere else until it is saved. {key: 'refreshed' | error | deferral}."""
1253
+ out = {}
1254
+ ahead = refresh_ahead()
1255
+ for key in sorted(store.entries):
1256
+ rec = store.entries[key]
1257
+ if not _refresh_due(rec, time.time(), ahead, force):
1258
+ continue
1259
+ # A copy in a Keychain item this run cannot open keeps the old refresh token, and
1260
+ # its client (a GUI session) would present it once rotated away: invalid_grant —
1261
+ # or, on a server that detects reuse, the whole grant revoked. A run that can
1262
+ # open the item (the scheduled pass) refreshes it and updates the item, until the
1263
+ # login is close enough to expiry that every client would refresh on its own.
1264
+ blind = _blind_holders(rec, seen)
1265
+ left = _expires_ms(rec['entry']) / 1000.0 - time.time()
1266
+ if blind and left > URGENT_S:
1267
+ why = f"a copy sits in {', '.join(blind)} keychain, which this run cannot open"
1268
+ if rec.get('deferred') != why:
1269
+ rec['deferred'] = why
1270
+ store.dirty = True
1271
+ _log(root, f'refresh {key}: deferred — {why}')
1272
+ out[key] = 'deferred'
1273
+ continue
1274
+ if deadline - time.monotonic() < REFRESH_MIN_LEFT_S:
1275
+ break
1276
+ old = rec['entry']
1277
+ new, lifetime, error, refused = _refresh_entry(old)
1278
+ now = time.time()
1279
+ rec.pop('deferred', None)
1280
+ if new is not None:
1281
+ replaced = sorted(_digests(old) - _digests(new))
1282
+ rec['superseded'] = (replaced + [d for d in rec.get('superseded') or ()
1283
+ if d not in replaced])[:SUPERSEDED_KEEP]
1284
+ rec['entry'] = new
1285
+ rec['refreshed_at'] = _now_iso(now)
1286
+ rec['lifetime_s'] = int(lifetime or ASSUMED_LIFETIME_S)
1287
+ if lifetime is None:
1288
+ rec['expiry_assumed'] = True
1289
+ else:
1290
+ rec.pop('expiry_assumed', None)
1291
+ rec['holders'] = [] # nothing holds the new login until it is shared
1292
+ rec.pop('refresh', None)
1293
+ rec.pop('needs_reauth', None)
1294
+ out[key] = 'refreshed'
1295
+ _log(root, f'refresh {key}: ok' + (' (no expires_in: assumed 1h)' if lifetime is None else ''))
1296
+ else:
1297
+ state = rec.setdefault('refresh', {})
1298
+ failures = int(_number(state.get('failures')) or 0) + 1
1299
+ state.update(failures=failures, last_error=error, last_attempt_at=_now_iso(now),
1300
+ next_at=int(now + min(BACKOFF_FIRST_S * 2 ** (failures - 1),
1301
+ BACKOFF_MAX_S)))
1302
+ if refused:
1303
+ rec['needs_reauth'] = True
1304
+ _log(root, f'refresh {key}: {error} — needs a new sign-in')
1305
+ else:
1306
+ _log(root, f"refresh {key}: {error}; retry after {_now_iso(state['next_at'])}")
1307
+ out[key] = error
1308
+ store.dirty = True
1309
+ store.save()
1310
+ return out
1311
+
1312
+
1313
+ # -------------------------------------------------------------------------- report
1314
+
1315
+ def _fmt_ms(ms):
1316
+ return time.strftime('%Y-%m-%d %H:%MZ', time.gmtime(ms / 1000.0))
1317
+
1318
+
1319
+ def _fmt_span(seconds):
1320
+ seconds = int(abs(seconds))
1321
+ days, rest = divmod(seconds, 86400)
1322
+ hours, rest = divmod(rest, 3600)
1323
+ minutes = rest // 60
1324
+ if days:
1325
+ return f'{days}d {hours}h'
1326
+ return f'{hours}h {minutes}m' if hours else f'{minutes}m'
1327
+
1328
+
1329
+ def _validity(entry, now):
1330
+ expires = _expires_ms(entry)
1331
+ if expires is None:
1332
+ return 'expiry unknown'
1333
+ left = expires / 1000.0 - now
1334
+ if left >= 0:
1335
+ return f'valid until {_fmt_ms(expires)} (in {_fmt_span(left)})'
1336
+ return f'access token EXPIRED {_fmt_span(left)} ago'
1337
+
1338
+
1339
+ def _account_state(store, key, s, kind):
1340
+ """One account store's view of one key, for status: missing / stub / fresh / stale /
1341
+ own / forgotten — or, for a store that is not there or could not be read, its own
1342
+ state ('absent': nothing to share into — a file is created for a setup-token account
1343
+ alone, a Keychain item never)."""
1344
+ if s['state'] != 'ok':
1345
+ return s['state']
1346
+ have = s['map'].get(key)
1347
+ if have is None:
1348
+ return 'missing'
1349
+ if not is_login(have):
1350
+ return 'stub'
1351
+ if store.forgotten_match(key, have):
1352
+ return 'forgotten'
1353
+ rec = store.entries.get(key)
1354
+ if rec is None or _same_tokens(have, rec['entry']):
1355
+ return 'fresh'
1356
+ if _superseded(rec, have) or _provably_older(have, rec['entry']):
1357
+ return 'stale'
1358
+ # another grant whose age the expiries cannot settle (or a newer one the next
1359
+ # harvest takes): the pool leaves it where it is
1360
+ return 'fresh' if _provably_older(rec['entry'], have) else 'own'
1361
+
1362
+
1363
+ def _status_doc(root, store, seen, now, paused):
1364
+ """``paused``: why no launch on this machine shares any login right now, or None."""
1365
+ keys = set(store.entries) | set(store.forgotten)
1366
+ for stores in seen.values():
1367
+ for s in stores.values():
1368
+ if s and s['state'] == 'ok':
1369
+ keys.update(k for k in s['map'] if isinstance(k, str) and k)
1370
+ rows = []
1371
+ for key in sorted(keys):
1372
+ rec = store.entries.get(key)
1373
+ entry = rec['entry'] if rec else None
1374
+ if entry is None:
1375
+ entry = next((s['map'][key] for stores in seen.values() for s in stores.values()
1376
+ if s and s['state'] == 'ok' and isinstance(s['map'].get(key), dict)),
1377
+ {})
1378
+ backoff = (rec or {}).get('refresh') or {}
1379
+ expires = _expires_ms(entry) if rec else None
1380
+ accounts = {}
1381
+ for aid in sorted(seen):
1382
+ states = {}
1383
+ for kind in ('file', 'keychain'):
1384
+ s = seen[aid].get(kind)
1385
+ if s is not None:
1386
+ states[kind] = _account_state(store, key, s, kind)
1387
+ accounts[aid] = states
1388
+ due_at = None
1389
+ if rec and _has_refresh(entry) and expires is not None and not rec.get('needs_reauth'):
1390
+ due_at = int(max(expires / 1000.0 - _refresh_window(rec, refresh_ahead()),
1391
+ _number(backoff.get('next_at')) or 0))
1392
+ tomb = store.forgotten.get(key) or {}
1393
+ rows.append({
1394
+ 'key': key, 'server_name': _server_name(key, entry),
1395
+ 'server_url': entry.get('serverUrl') if isinstance(entry.get('serverUrl'), str) else None,
1396
+ 'login': rec is not None,
1397
+ 'expires_at': int(expires) if expires is not None else None,
1398
+ 'expires_in_s': int(expires / 1000.0 - now) if expires is not None else None,
1399
+ 'expiry_assumed': bool((rec or {}).get('expiry_assumed')),
1400
+ 'lifetime_s': (rec or {}).get('lifetime_s'),
1401
+ 'has_refresh_token': bool(rec) and _has_refresh(entry),
1402
+ 'source': (rec or {}).get('source'), 'source_store': (rec or {}).get('store'),
1403
+ 'harvested_at': (rec or {}).get('harvested_at'),
1404
+ 'refreshed_at': (rec or {}).get('refreshed_at'),
1405
+ 'needs_reauth': bool((rec or {}).get('needs_reauth')),
1406
+ 'refresh_due_at': due_at,
1407
+ 'refresh_deferred': (rec or {}).get('deferred'),
1408
+ 'shared': bool(rec) and not paused and _sharing_off(rec, now) is None,
1409
+ 'not_shared_because': (paused or _sharing_off(rec, now)) if rec else None,
1410
+ 'retired': ({'at': tomb.get('at'), 'reason': tomb.get('reason')}
1411
+ if tomb and rec is None else None),
1412
+ 'backoff': {'failures': int(_number(backoff.get('failures')) or 0),
1413
+ 'next_at': _number(backoff.get('next_at')),
1414
+ 'last_error': backoff.get('last_error')},
1415
+ 'accounts': accounts})
1416
+ beat = store.doc.get('refresher') or {}
1417
+ return {'store': store.path, 'store_exists': store.exists, 'enabled': enabled(),
1418
+ 'refresh_enabled': refresh_enabled(), 'refresh_ahead_s': refresh_ahead(),
1419
+ 'keychain': keychain.enabled(), 'sharing_paused': paused,
1420
+ 'refresher': {'at': _number(beat.get('at')), 'refresh': beat.get('refresh')},
1421
+ 'keys': rows,
1422
+ 'forgotten': [{'key': k, 'at': v.get('at'), 'reason': v.get('reason')}
1423
+ for k, v in sorted(store.forgotten.items())],
1424
+ 'holds': [{'account': k, 'until': v.get('until'), 'reason': v.get('reason')}
1425
+ for k, v in sorted(store.doc['holds'].items())]}
1426
+
1427
+
1428
+ def _group(accounts, kind):
1429
+ groups = {}
1430
+ for aid, states in accounts.items():
1431
+ state = states.get(kind)
1432
+ if state is not None:
1433
+ groups.setdefault(state, []).append(aid)
1434
+ order = ('fresh', 'stale', 'own', 'stub', 'missing', 'forgotten', 'locked', 'absent')
1435
+ return ' · '.join(f"{state} {' '.join(ids)}" for state, ids in
1436
+ sorted(groups.items(), key=lambda kv: (order.index(kv[0])
1437
+ if kv[0] in order else 99, kv[0])))
1438
+
1439
+
1440
+ def _print_status(doc, now):
1441
+ print(f"MCP OAuth logins shared on this machine — {doc['store']}"
1442
+ + ('' if doc['enabled'] else ' [sharing OFF: CLAUDE_MULTIACC_MCP_OAUTH=0]')
1443
+ + ('' if doc['refresh_enabled'] else
1444
+ ' [refresh OFF: CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0 — nothing is shared]'))
1445
+ if doc['sharing_paused'] and doc['enabled'] and doc['refresh_enabled'] and doc['keys']:
1446
+ print(f" sharing paused: {doc['sharing_paused']} — every claude limits pass runs "
1447
+ 'one; claude-accounts mcp auth-sync runs one now')
1448
+ if not doc['keys']:
1449
+ print(' no MCP OAuth state in any account yet')
1450
+ for row in doc['keys']:
1451
+ print(f"{row['server_name']} {row['server_url'] or ''} [{row['key']}]".rstrip())
1452
+ if not row['login']:
1453
+ if row['retired']:
1454
+ print(f" login: none — retired {row['retired']['at']} ({row['retired']['reason']}); "
1455
+ 'sign in once in any account (/mcp) and every account gets it')
1456
+ else:
1457
+ print(' login: none yet — authenticate it once in any account (/mcp) and every '
1458
+ 'account gets it')
1459
+ else:
1460
+ entry = {'expiresAt': row['expires_at']}
1461
+ source = f"{row['source'] or '?'} ({row['source_store'] or '?'})"
1462
+ print(f" login: {_validity(entry, now)}"
1463
+ + (' [expiry assumed: the server sent none]' if row['expiry_assumed'] else '')
1464
+ + f" · refresh token: {'yes' if row['has_refresh_token'] else 'no'} · from {source} "
1465
+ f"{row['harvested_at'] or ''}".rstrip()
1466
+ + (f" · refreshed {row['refreshed_at']}" if row['refreshed_at'] else ''))
1467
+ back = row['backoff']
1468
+ if row['needs_reauth']:
1469
+ print(f" refresh: NEEDS RE-AUTH ({back['last_error'] or 'refused'}) — sign in "
1470
+ 'once in any account (/mcp)')
1471
+ elif back['failures']:
1472
+ print(f" refresh: {back['failures']} failure(s), last {back['last_error']}; "
1473
+ f"retry after {_now_iso(back['next_at'] or now)}")
1474
+ elif row['refresh_due_at'] is not None:
1475
+ print(f" refresh: due {_now_iso(row['refresh_due_at'])}"
1476
+ + (f" — deferred: {row['refresh_deferred']}" if row['refresh_deferred'] else ''))
1477
+ if row['not_shared_because']:
1478
+ print(f" NOT shared: {row['not_shared_because']} (each account keeps what it holds)")
1479
+ for kind, label in (('file', 'files'), ('keychain', 'keychain')):
1480
+ line = _group(row['accounts'], kind)
1481
+ if line:
1482
+ print(f' {label}: {line}')
1483
+ for hold in doc['holds']:
1484
+ print(f"hold: {hold['account']} keychain left alone until {_now_iso(hold['until'] or now)} "
1485
+ f"({hold['reason']})")
1486
+ live = {row['key'] for row in doc['keys'] if row['login']}
1487
+ for tomb in doc['forgotten']:
1488
+ if tomb['key'] in live: # an older grant retired, a new login since
1489
+ print(f"forgotten: an older login of {tomb['key']} at {tomb['at']}"
1490
+ + (f" ({tomb['reason']})" if tomb.get('reason') else ''))
1491
+
1492
+
1493
+ # -------------------------------------------------------------------------- verbs
1494
+
1495
+ def cmd_ensure(args):
1496
+ if not enabled():
1497
+ return 0
1498
+ account_dir = args.account_dir
1499
+ accounts = account_dirs(args.root)
1500
+ match = [aid for aid, d in accounts if os.path.normpath(d) == os.path.normpath(account_dir)]
1501
+ if not match:
1502
+ return 0
1503
+ aid = match[0]
1504
+ with _budget(ENSURE_BUDGET_S), PoolLock(args.root, wait=ENSURE_LOCK_WAIT_S) as lock:
1505
+ if not lock.held:
1506
+ return 0
1507
+ store = Store(args.root).load()
1508
+ # The Keychain item under the dir string the shim hands the client: its hash is
1509
+ # the item's name.
1510
+ kdirs = {aid: account_dir} if keychain.enabled() else {}
1511
+ seen, stats = _harvest(args.root, accounts, kdirs, ENSURE_SECURITY_TIMEOUT_S)
1512
+ if not store.exists and not _in_play(seen):
1513
+ # The shim's grep matched text no document holds (a torn file): note the look,
1514
+ # or every launch would start python again until some file changes.
1515
+ if any(stores['file'].get('mentions') for stores in seen.values()):
1516
+ _set_watermark(args.root, stats)
1517
+ return 0
1518
+ now = time.time()
1519
+ _choose(store, seen, now)
1520
+ _note_holders(store, seen, now)
1521
+ if store.dirty or not store.exists:
1522
+ store.save()
1523
+ # Shared only behind a refreshing sync: this process never refreshes.
1524
+ sharing = refresh_enabled() and store.refresher_ok(now)
1525
+ res = _distribute(args.root, store, aid, account_dir, seen[aid], kdirs.get(aid),
1526
+ _plan(store, now, sharing), now, wait=ENSURE_LOCK_WAIT_S,
1527
+ timeout=ENSURE_SECURITY_TIMEOUT_S)
1528
+ _hold(store, aid, res)
1529
+ if store.dirty:
1530
+ store.save()
1531
+ if res['file'] not in TRANSIENT and res['keychain'] not in TRANSIENT:
1532
+ _stamp(account_dir, store.clock)
1533
+ _set_watermark(args.root, stats)
1534
+ return 0
1535
+
1536
+
1537
+ def cmd_sync(args):
1538
+ quiet = args.quiet
1539
+ if not enabled():
1540
+ if not quiet:
1541
+ print('MCP OAuth sharing is off (CLAUDE_MULTIACC_MCP_OAUTH=0)')
1542
+ return 0
1543
+ deadline = time.monotonic() + SYNC_BUDGET_S
1544
+ with _budget(SYNC_BUDGET_S), PoolLock(args.root, wait=30.0) as lock:
1545
+ if not lock.held:
1546
+ if not quiet:
1547
+ print('another MCP OAuth sync holds the pool lock — skipped')
1548
+ return 0
1549
+ store = Store(args.root).load()
1550
+ accounts = account_dirs(args.root)
1551
+ kdirs = dict(accounts) if keychain.enabled() else {}
1552
+ seen, stats = _harvest(args.root, accounts, kdirs, keychain.TIMEOUT_S)
1553
+ if not store.exists and not _in_play(seen):
1554
+ if not quiet:
1555
+ print('no MCP OAuth state in any account yet — nothing to share')
1556
+ return 0
1557
+ now = time.time()
1558
+ store.beat(now)
1559
+ for key in _choose(store, seen, now):
1560
+ rec = store.entries[key]
1561
+ _log(args.root, f"{key}: login from {rec['source']} ({rec['store']})")
1562
+ retired = _note_holders(store, seen, now)
1563
+ if store.dirty or not store.exists:
1564
+ store.save()
1565
+ refreshed = {}
1566
+ if refresh_enabled():
1567
+ refreshed = _refresh_all(args.root, store, seen, args.force, deadline)
1568
+ now = time.time()
1569
+ plan = _plan(store, now, refresh_enabled())
1570
+ results = {}
1571
+ for aid, account_dir in accounts:
1572
+ results[aid] = _distribute(args.root, store, aid, account_dir, seen[aid],
1573
+ kdirs.get(aid), plan, now, wait=3.0,
1574
+ timeout=keychain.TIMEOUT_S)
1575
+ _hold(store, aid, results[aid])
1576
+ if store.dirty:
1577
+ store.save()
1578
+ for aid, account_dir in accounts:
1579
+ res = results[aid]
1580
+ if res['file'] not in TRANSIENT and res['keychain'] not in TRANSIENT:
1581
+ _stamp(account_dir, store.clock)
1582
+ _set_watermark(args.root, stats)
1583
+ if not quiet:
1584
+ _print_sync(store, results, refreshed, retired, now)
1585
+ return 0
1586
+
1587
+
1588
+ def _print_sync(store, results, refreshed, retired, now):
1589
+ if not store.entries and not retired:
1590
+ print('no MCP OAuth login in any account yet — authenticate a server once in any '
1591
+ 'account (/mcp) and every account gets it')
1592
+ for key in sorted(retired):
1593
+ tomb = store.forgotten.get(key) or {}
1594
+ print(f"{key.split('|', 1)[0]} [{key}]: login retired ({tomb.get('reason')}) — "
1595
+ 'sign in once in any account (/mcp) and every account gets the new one')
1596
+ for key in sorted(store.entries):
1597
+ rec = store.entries[key]
1598
+ written = sum(1 for r in results.values() for keys in r['held'].values() if key in keys)
1599
+ words = [f"login from {rec.get('source') or '?'} ({rec.get('store') or '?'}), "
1600
+ f"{_validity(rec['entry'], now)}"]
1601
+ outcome = refreshed.get(key)
1602
+ if outcome == 'refreshed':
1603
+ words.append('refreshed now')
1604
+ elif outcome == 'deferred':
1605
+ words.append(f"refresh deferred ({rec.get('deferred')})")
1606
+ elif outcome:
1607
+ words.append(f'refresh failed ({outcome})')
1608
+ if rec.get('needs_reauth'):
1609
+ words.append('NEEDS RE-AUTH — sign in once in any account (/mcp)')
1610
+ why = _sharing_off(rec, now) or (None if refresh_enabled() else
1611
+ 'refresh is off (CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0)')
1612
+ words.append(f'NOT shared: {why}' if why else f'{written} account store(s) updated')
1613
+ print(f"{_server_name(key, rec['entry'])} [{key}]: {'; '.join(words)}")
1614
+ missed = []
1615
+ for aid in sorted(results):
1616
+ for kind in ('file', 'keychain'):
1617
+ state = results[aid][kind]
1618
+ if state in UNREACHED:
1619
+ missed.append(f'{aid} {kind} ({state})')
1620
+ if missed:
1621
+ print(f"not reached: {', '.join(missed)}")
1622
+
1623
+
1624
+ def cmd_status(args):
1625
+ store = Store(args.root).load(readonly=True)
1626
+ accounts = account_dirs(args.root)
1627
+ kdirs = dict(accounts) if keychain.enabled() else {}
1628
+ seen, _stats = _harvest(args.root, accounts, kdirs, keychain.TIMEOUT_S)
1629
+ now = time.time()
1630
+ # What a launch would do now: share only behind a refreshing sync.
1631
+ beat = store.doc.get('refresher') or {}
1632
+ if not enabled():
1633
+ paused = 'sharing is off (CLAUDE_MULTIACC_MCP_OAUTH=0)'
1634
+ elif not refresh_enabled():
1635
+ paused = 'refresh is off (CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0)'
1636
+ elif not store.refresher_ok(now):
1637
+ last = _number(beat.get('at'))
1638
+ paused = ('no refreshing sync in the last hour ('
1639
+ + (f"last {_now_iso(last)}" + ('' if beat.get('refresh') else ', refresh off')
1640
+ if last else 'none yet') + ')')
1641
+ else:
1642
+ paused = None
1643
+ doc = _status_doc(args.root, store, seen, now, paused)
1644
+ if args.json:
1645
+ print(json.dumps(doc, indent=2))
1646
+ else:
1647
+ _print_status(doc, now)
1648
+ return 0
1649
+
1650
+
1651
+ def cmd_forget(args):
1652
+ name = args.name
1653
+ with PoolLock(args.root, wait=30.0) as lock:
1654
+ if not lock.held:
1655
+ print('another MCP OAuth sync holds the pool lock — try again', file=sys.stderr)
1656
+ return 1
1657
+ store = Store(args.root).load()
1658
+ accounts = account_dirs(args.root)
1659
+ kdirs = dict(accounts) if keychain.enabled() else {}
1660
+ seen, _stats = _harvest(args.root, accounts, kdirs, keychain.TIMEOUT_S)
1661
+ keys = {k for k, rec in store.entries.items() if _server_name(k, rec['entry']) == name}
1662
+ for stores in seen.values():
1663
+ for s in stores.values():
1664
+ if s and s['state'] == 'ok':
1665
+ keys.update(k for k, e in s['map'].items()
1666
+ if isinstance(k, str) and _server_name(k, e) == name)
1667
+ if not keys:
1668
+ print(f'no MCP OAuth state for {name} on this machine', file=sys.stderr)
1669
+ return 1
1670
+ now = time.time()
1671
+ for key in sorted(keys):
1672
+ digests = set()
1673
+ for stores in seen.values():
1674
+ for s in stores.values():
1675
+ if s and s['state'] == 'ok':
1676
+ digests |= _digests(s['map'].get(key))
1677
+ store.retire(key, 'auth-forget', now, digests)
1678
+ store.save()
1679
+ _log(args.root, f"forget {name}: {','.join(sorted(keys))}")
1680
+
1681
+ def plan(mapping):
1682
+ return {key: None for key in keys if key in mapping}
1683
+
1684
+ cleared, missed = 0, []
1685
+ for aid, account_dir in accounts:
1686
+ res = _distribute(args.root, store, aid, account_dir, seen[aid], kdirs.get(aid),
1687
+ plan, now, wait=3.0, timeout=keychain.TIMEOUT_S)
1688
+ cleared += len(res['keys'])
1689
+ for kind in ('file', 'keychain'):
1690
+ if res[kind] in UNREACHED:
1691
+ missed.append(f'{aid} {kind} ({res[kind]})')
1692
+ if store.dirty:
1693
+ store.save()
1694
+ if not args.quiet:
1695
+ print(f"forgot {name}: {len(keys)} key(s); cleared from {cleared} account store(s)")
1696
+ if missed:
1697
+ print(f"not reached: {', '.join(missed)} — the retired login is ignored there "
1698
+ 'and cleared by a later sync that can reach it')
1699
+ return 0
1700
+
1701
+
1702
+ def build_parser():
1703
+ parser = argparse.ArgumentParser(prog='mcp_oauth.py', description=__doc__.split('\n\n')[0])
1704
+ modes = parser.add_subparsers(dest='mode')
1705
+ p = modes.add_parser('ensure', help='shim: share into one account before exec')
1706
+ p.add_argument('--root', required=True)
1707
+ p.add_argument('--account-dir', required=True)
1708
+ p.add_argument('--quiet', action='store_true')
1709
+ p = modes.add_parser('sync', help='harvest, refresh what is due, share into every account')
1710
+ p.add_argument('--root', required=True)
1711
+ p.add_argument('--quiet', action='store_true')
1712
+ p.add_argument('--force', action='store_true', help='retry a refresh that is backing off')
1713
+ p = modes.add_parser('status', help='what the pool holds (no secrets)')
1714
+ p.add_argument('--root', required=True)
1715
+ p.add_argument('--json', action='store_true')
1716
+ p = modes.add_parser('forget', help="log a server out of every account on this machine")
1717
+ p.add_argument('--root', required=True)
1718
+ p.add_argument('name')
1719
+ p.add_argument('--quiet', action='store_true')
1720
+ return parser
1721
+
1722
+
1723
+ def main(argv=None):
1724
+ parser = build_parser()
1725
+ args = parser.parse_args(argv)
1726
+ if not args.mode:
1727
+ parser.error('a mode is required (ensure, sync, status, forget)')
1728
+ if not os.path.isdir(args.root):
1729
+ print(f'mcp-oauth: pool root {args.root} does not exist', file=sys.stderr)
1730
+ return 1
1731
+ verb = {'ensure': cmd_ensure, 'sync': cmd_sync, 'status': cmd_status,
1732
+ 'forget': cmd_forget}[args.mode]
1733
+ try:
1734
+ return verb(args)
1735
+ except _Budget:
1736
+ _log(args.root, f'{args.mode}: time budget ran out — the next run carries on')
1737
+ return 0
1738
+
1739
+
1740
+ if __name__ == '__main__':
1741
+ fail_open = len(sys.argv) > 1 and sys.argv[1] in ('ensure', 'sync')
1742
+ try:
1743
+ sys.exit(main())
1744
+ except Exception as error: # noqa: BLE001 — a traceback is never the shim's problem
1745
+ # The type only: a message could quote a path or a server answer, never a token,
1746
+ # but nothing here is worth that risk.
1747
+ print(f'mcp-oauth: {type(error).__name__}', file=sys.stderr)
1748
+ sys.exit(0 if fail_open else 1)