mixdog 1.2.1 → 1.2.2

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 (233) hide show
  1. package/package.json +1 -1
  2. package/scripts/gate-local.mjs +19 -3
  3. package/scripts/model-catalog-audit.mjs +1 -15
  4. package/src/agents/reviewer/AGENT.md +3 -3
  5. package/src/agents/reviewer/agent.json +1 -1
  6. package/src/defaults/shipped-history.json +1 -0
  7. package/src/repl/slash-commands.mjs +16 -1
  8. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
  10. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
  11. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
  12. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
  14. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
  15. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
  16. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
  17. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
  18. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
  19. package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
  20. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
  21. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
  23. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
  25. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
  27. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
  29. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
  31. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
  38. package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
  43. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  44. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
  45. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
  47. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
  48. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
  49. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
  50. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
  52. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
  53. package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
  54. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
  55. package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
  56. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
  57. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
  58. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
  59. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
  60. package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
  63. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
  65. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
  66. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
  67. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  70. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
  71. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
  72. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
  73. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
  74. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
  75. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
  80. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
  81. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
  82. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
  83. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
  89. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
  90. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
  92. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
  93. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
  94. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
  96. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
  97. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
  98. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
  99. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
  100. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
  101. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
  102. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
  103. package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
  104. package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
  105. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
  106. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
  107. package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
  108. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
  109. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
  110. package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
  111. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
  113. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
  114. package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
  115. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
  116. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
  117. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
  118. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
  119. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
  120. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
  121. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
  122. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
  123. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
  124. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
  125. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
  126. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
  127. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
  128. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
  130. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
  131. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
  132. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
  133. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
  134. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
  137. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
  140. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  141. package/src/runtime/memory/lib/pg/process.mjs +34 -24
  142. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
  143. package/src/runtime/office/README.md +1 -1
  144. package/src/runtime/office/shared/values.mjs +0 -5
  145. package/src/runtime/shared/background-tasks.mjs +92 -14
  146. package/src/runtime/shared/err-text.mjs +25 -0
  147. package/src/runtime/shared/error-presentation.mjs +159 -19
  148. package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
  149. package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
  150. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
  151. package/src/runtime/shared/llm/model-catalog.mjs +53 -40
  152. package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
  153. package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
  154. package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
  155. package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
  156. package/src/runtime/shared/model-display.mjs +13 -0
  157. package/src/runtime/shared/quota-resume-store.mjs +11 -0
  158. package/src/runtime/shared/session-baseline-store.mjs +960 -0
  159. package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
  160. package/src/runtime/shared/task-notification-envelope.mjs +2 -2
  161. package/src/runtime/shared/turn-changes.mjs +447 -219
  162. package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
  163. package/src/runtime/shared/turn-snapshot.mjs +170 -48
  164. package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
  165. package/src/runtime/shared/worktrees/contract.mjs +0 -24
  166. package/src/runtime/shared/worktrees/test-support.mjs +19 -4
  167. package/src/session-runtime/boot/apis.mjs +6 -0
  168. package/src/session-runtime/boot/infrastructure.mjs +6 -0
  169. package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
  170. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  171. package/src/session-runtime/mcp-glue/connect.mjs +29 -9
  172. package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
  173. package/src/session-runtime/mcp-glue.mjs +3 -0
  174. package/src/session-runtime/mcp-oauth-login.mjs +174 -0
  175. package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
  176. package/src/session-runtime/model-route/set-route.mjs +83 -15
  177. package/src/session-runtime/model-route/web-search-route.mjs +1 -1
  178. package/src/session-runtime/native-web-search/routes.mjs +22 -3
  179. package/src/session-runtime/provider-models/model-meta.mjs +12 -3
  180. package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
  181. package/src/session-runtime/quick-web-search-models.mjs +13 -38
  182. package/src/session-runtime/resource-extension-sync.mjs +4 -0
  183. package/src/session-runtime/resource-mcp-api.mjs +98 -2
  184. package/src/session-runtime/runtime-review-api.mjs +13 -0
  185. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
  186. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
  187. package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
  188. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
  189. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
  190. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
  191. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
  192. package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
  193. package/src/session-runtime/settings-compaction-api.mjs +1 -0
  194. package/src/standalone/daemon-drain-policy.mjs +8 -0
  195. package/src/standalone/daemon-telemetry.mjs +55 -0
  196. package/src/standalone/daemon.mjs +3 -7
  197. package/src/standalone/session-protocol.mjs +7 -0
  198. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
  199. package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
  200. package/src/standalone/session-service/agent-tree.mjs +12 -0
  201. package/src/standalone/session-service/quota-resume.mjs +8 -0
  202. package/src/standalone/session-service/retention/busy.mjs +15 -1
  203. package/src/standalone/session-service-api.mjs +5 -0
  204. package/src/standalone/session-service.mjs +1 -0
  205. package/src/tui/app/app-view.jsx +1 -0
  206. package/src/tui/app/create-app-pickers.mjs +4 -0
  207. package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
  208. package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
  209. package/src/tui/app/extension-pickers.mjs +2 -1
  210. package/src/tui/app/model-options.mjs +11 -16
  211. package/src/tui/app/slash-commands.mjs +7 -2
  212. package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
  213. package/src/tui/components/StatusLine.jsx +2 -0
  214. package/src/tui/components/status-line/local-line.mjs +5 -2
  215. package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
  216. package/src/tui/dist/index.mjs +816 -43
  217. package/src/tui/session/quota-resume.mjs +46 -14
  218. package/src/tui/session/session-api/commands.mjs +33 -24
  219. package/src/tui/session/session-api/extensions.mjs +22 -0
  220. package/src/tui/session/session-api/integrations.mjs +7 -0
  221. package/src/tui/session/session-api/lifecycle.mjs +5 -1
  222. package/src/tui/session/session-api/routes.mjs +71 -7
  223. package/src/tui/session/session-api/settings.mjs +5 -14
  224. package/src/tui/session/session-flow/drain.mjs +7 -1
  225. package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
  226. package/src/tui/session/turn.mjs +38 -15
  227. package/src/tui/statusline-ansi-bridge.mjs +1 -1
  228. package/src/ui/statusline-agents.mjs +15 -4
  229. package/src/ui/statusline.mjs +7 -3
  230. package/src/ui/streaming-markdown-heal.mjs +34 -3
  231. package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
  232. package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
  233. package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
@@ -24,13 +24,22 @@ import { currentToolExecutionOwner } from '../../../shared/tool-execution-owner.
24
24
  import { positiveInt } from '../../../shared/numbers.mjs';
25
25
  import {
26
26
  providerSafeMcpToolName,
27
+ resolveMcpOAuthUrl,
27
28
  resolveMcpRequestTimeouts,
28
29
  resolveMcpTransportKind,
29
30
  scrubMcpConnectionMessage,
31
+ scrubMcpSecrets,
30
32
  } from './client-config.mjs';
31
33
  import { createLazyOutputValidator } from './lazy-output-validator.mjs';
32
- import { appendMcpStderrTail, createMcpTransport } from './client-transport.mjs';
33
- import { listAllMcpTools, runBoundedHandshake } from './client-handshake.mjs';
34
+ import { appendMcpStderrTail, createMcpTransport, hasConfiguredMcpAuth } from './client-transport.mjs';
35
+ import { listAllMcpTools, MCP_CONNECT_CANCELLED, runBoundedHandshake } from './client-handshake.mjs';
36
+ import {
37
+ clearMcpOAuth,
38
+ hasStoredMcpOAuthTokens,
39
+ isMcpOAuthReauthenticationRequired,
40
+ MCP_OAUTH_CALLBACK_NOT_PENDING,
41
+ } from './oauth.mjs';
42
+ export { probeMcpServerAuth } from './oauth-probe.mjs';
34
43
 
35
44
  // Config interpretation (transport kind, URL policy, env expansion, headers,
36
45
  // timeouts, log scrubbing) lives in client-config.mjs; re-exported so every
@@ -103,6 +112,20 @@ function mcpLog(line) {
103
112
  if (process.env.MIXDOG_QUIET_MCP_LOG) return;
104
113
  process.stderr.write(line);
105
114
  }
115
+ // OAuth tokens belong to the live connection, not to its config: a server may
116
+ // echo the request (Authorization included) into an error body or result.
117
+ function oauthSecrets(...transports) {
118
+ return transports.flatMap((transport) => transport?.oauth?.secrets() || []);
119
+ }
120
+ function scrubMcpOAuthError(error, cfg, secrets) {
121
+ if (secrets.length === 0) return error;
122
+ if (!(error instanceof Error)) return new Error(scrubMcpConnectionMessage(String(error), cfg, secrets));
123
+ error.message = scrubMcpConnectionMessage(error.message, cfg, secrets);
124
+ if (typeof error.stack === 'string') error.stack = scrubMcpConnectionMessage(error.stack, cfg, secrets);
125
+ // A JSON-RPC error carries the server's own payload (McpError.data).
126
+ if (error.data !== undefined) error.data = scrubMcpSecrets(error.data, secrets);
127
+ return error;
128
+ }
106
129
 
107
130
  async function loadMcpSdk() {
108
131
  mcpSdkPromise ??= Promise.all([
@@ -148,14 +171,16 @@ export async function connectMcpServers(config, options = {}) {
148
171
  });
149
172
  // Connect all servers in PARALLEL: a slow/hung server (bounded by its
150
173
  // per-server startup timeout) must never delay the others' handshakes.
151
- const settled = await Promise.allSettled(entries.map(([name, cfg]) => connectServer(name, cfg, scopeId, genAtStart)));
174
+ const settled = await Promise.allSettled(
175
+ entries.map(([name, cfg]) => connectServer(name, cfg, scopeId, genAtStart, options))
176
+ );
152
177
  settled.forEach((res, i) => {
153
178
  if (res.status !== 'rejected') return;
154
179
  const [name, cfg] = entries[i];
155
180
  const rawMessage = res.reason instanceof Error ? res.reason.message : String(res.reason);
156
181
  const msg = scrubMcpConnectionMessage(rawMessage, cfg);
157
182
  mcpLog(`[mcp-client] Failed to connect "${name}": ${msg}\n`);
158
- failures.push({ name, msg });
183
+ failures.push({ name, msg, ...(res.reason?.authRequired ? { authRequired: true } : {}) });
159
184
  });
160
185
  if (failures.length > 0) {
161
186
  const detail = failures.map((f) => `${f.name}: ${f.msg}`).join('; ');
@@ -330,7 +355,7 @@ export function getMcpServerInstructionsMap(scopeId = DEFAULT_MCP_SCOPE_ID) {
330
355
  // shutdown generation captured BEFORE the caller queued/closed anything: a
331
356
  // disconnectAll() landing during the close (or the connect) bumps it, and the
332
357
  // replacement must then never register (no resurrection after shutdown).
333
- async function replaceServerConnection(scopeId, name, current, cfg, genAtStart) {
358
+ async function replaceServerConnection(scopeId, name, current, cfg, genAtStart, options = {}) {
334
359
  const registryKey = mcpServerRegistryKey(scopeId, name);
335
360
  if (current) {
336
361
  await _closeServer(current);
@@ -343,10 +368,12 @@ async function replaceServerConnection(scopeId, name, current, cfg, genAtStart)
343
368
  try {
344
369
  // connectServer re-checks the same generation after loading the SDK and
345
370
  // again right before registering, so a shutdown mid-handshake also aborts.
346
- await connectServer(name, cfg, scopeId, genAtStart);
371
+ await connectServer(name, cfg, scopeId, genAtStart, options);
347
372
  } catch (reason) {
348
373
  const rawMessage = reason instanceof Error ? reason.message : String(reason);
349
- throw new Error(scrubMcpConnectionMessage(rawMessage, cfg));
374
+ const error = new Error(scrubMcpConnectionMessage(rawMessage, cfg));
375
+ if (reason?.authRequired) error.authRequired = true;
376
+ throw error;
350
377
  }
351
378
  }
352
379
 
@@ -356,6 +383,7 @@ async function replaceServerConnection(scopeId, name, current, cfg, genAtStart)
356
383
  // connect per server is ever running (no doubly registered transports).
357
384
  async function runExclusiveServerOp(registryKey, task) {
358
385
  for (let pending = reconnects.wait(registryKey); pending; pending = reconnects.wait(registryKey)) {
386
+ // Only waiting for the reconnect to settle; its failure belongs to its caller.
359
387
  await pending.catch(() => {});
360
388
  }
361
389
  return reconnects.run(registryKey, task);
@@ -396,7 +424,7 @@ async function reconnectMcpServer(scopeId, serverName, failedServer) {
396
424
  * rejects (credential-scrubbed message) when the connect fails, leaving the
397
425
  * server unregistered.
398
426
  */
399
- export async function reconnectSingleMcpServer(scopeId, name, cfg = undefined) {
427
+ export async function reconnectSingleMcpServer(scopeId, name, cfg = undefined, options = {}) {
400
428
  const normalized = normalizeMcpScopeId(scopeId);
401
429
  _knownMcpScopes.add(normalized);
402
430
  const registryKey = mcpServerRegistryKey(normalized, name);
@@ -415,7 +443,7 @@ export async function reconnectSingleMcpServer(scopeId, name, cfg = undefined) {
415
443
  closeCallAdmission(normalized, name, `MCP ${name} disabled`);
416
444
  return false;
417
445
  }
418
- await replaceServerConnection(normalized, name, current, nextCfg, genAtStart);
446
+ await replaceServerConnection(normalized, name, current, nextCfg, genAtStart, options);
419
447
  return true;
420
448
  });
421
449
  }
@@ -438,96 +466,101 @@ export async function executeMcpTool(name, args, options = {}) {
438
466
  definition?.mcpOperation
439
467
  ? _callMcpFeatureWithTimeout(target, definition.mcpOperation, args, callSignal)
440
468
  : _callToolWithTimeout(target, toolName, args, callSignal);
469
+ let retryServer = null;
470
+ const secrets = () => oauthSecrets(server.transport, retryServer?.transport);
441
471
  const gate = callAdmissionFor(server);
442
- return gate.run(
443
- options?.ownerKey || currentToolExecutionOwner(),
444
- async () => {
445
- // The gate stops forwarding the caller's abort once this task is
446
- // admitted, so the task itself observes the signal for its whole run.
447
- if (callSignal?.aborted) {
448
- throw mcpAbortError(
449
- callSignal,
450
- `MCP tool call aborted before dispatch (server="${serverName}", tool="${toolName}")`
451
- );
452
- }
453
- let result;
454
- try {
455
- result = await dispatch(server);
456
- } catch (firstErr) {
457
- const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr);
458
- if (isMcpCallAbortError(firstErr) || callSignal?.aborted) {
459
- // A cancelled call is not a transport failure: reconnecting and
460
- // replaying it would duplicate the side effect the caller just
461
- // walked away from.
462
- mcpLog(`[mcp-client] Tool call aborted by caller for "${serverName}/${toolName}".\n`);
463
- throw firstErr;
464
- }
465
- if (isMcpToolCallTimeoutError(firstErr)) {
466
- mcpLog(`[mcp-client] Tool call timed out; skipping reconnect retry for "${serverName}/${toolName}".\n`);
467
- throw firstErr;
468
- }
469
- const serverDead = server.liveness?.alive === false;
470
- if (!serverDead && isMcpServerResponseError(firstErr)) {
471
- // The server answered (JSON-RPC error): it is alive — nothing to
472
- // reconnect, and the call already ran its course.
473
- throw firstErr;
474
- }
475
- // Only a failure that proves the request never left this process
476
- // may be replayed; anything else may already have run server-side.
477
- const undelivered = isMcpUndeliveredError(firstErr);
478
- const deadTail = serverDead ? server.stderrTail?.() : '';
479
- mcpLog(`[mcp-client] Tool call failed, attempting shared reconnect...\n`);
480
- let retryServer;
481
- try {
482
- // The reconnect is SHARED (singleflight): the race only stops
483
- // THIS caller from waiting on it — the reconnect keeps running
484
- // for its other waiters. Without this, an abort mid-reconnect
485
- // could not settle the call and the admission slot stayed held
486
- // until the reconnect finished, or forever if it hung.
487
- retryServer = await raceMcpAbort(
488
- reconnectMcpServer(scopeId, serverName, server),
489
- callSignal,
490
- `MCP tool call aborted during reconnect (server="${serverName}", tool="${toolName}")`
491
- );
492
- } catch (reconnectErr) {
493
- if (isMcpCallAbortError(reconnectErr) || callSignal?.aborted) throw reconnectErr;
494
- const reconnectMsg = reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr);
495
- throw new Error(
496
- appendMcpStderrTail(`Tool call failed: ${firstMsg}; reconnect also failed: ${reconnectMsg}`, deadTail)
497
- );
498
- }
499
- if (!undelivered) {
500
- throw new Error(
501
- appendMcpStderrTail(
502
- `Tool call failed: ${firstMsg}; MCP server "${serverName}" was reconnected for the next call, but this call was not replayed because the request may already have reached the server`,
503
- deadTail
504
- )
505
- );
506
- }
507
- // A reconnect that outlived the caller's abort must not dispatch the
508
- // retry: that would run the side effect after cancellation.
472
+ return gate
473
+ .run(
474
+ options?.ownerKey || currentToolExecutionOwner(),
475
+ async () => {
476
+ // The gate stops forwarding the caller's abort once this task is
477
+ // admitted, so the task itself observes the signal for its whole run.
509
478
  if (callSignal?.aborted) {
510
479
  throw mcpAbortError(
511
480
  callSignal,
512
- `MCP tool call aborted after reconnect (server="${serverName}", tool="${toolName}")`
481
+ `MCP tool call aborted before dispatch (server="${serverName}", tool="${toolName}")`
513
482
  );
514
483
  }
484
+ let result;
515
485
  try {
516
- result = await dispatch(retryServer);
517
- } catch (retryErr) {
518
- if (isMcpCallAbortError(retryErr) || callSignal?.aborted) throw retryErr;
519
- const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
520
- throw new Error(`Tool call failed: ${firstMsg}; retry after reconnect also failed: ${retryMsg}`);
486
+ result = await dispatch(server);
487
+ } catch (firstErr) {
488
+ const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr);
489
+ if (isMcpCallAbortError(firstErr) || callSignal?.aborted) {
490
+ // A cancelled call is not a transport failure: reconnecting and
491
+ // replaying it would duplicate the side effect the caller just
492
+ // walked away from.
493
+ mcpLog(`[mcp-client] Tool call aborted by caller for "${serverName}/${toolName}".\n`);
494
+ throw firstErr;
495
+ }
496
+ if (isMcpToolCallTimeoutError(firstErr)) {
497
+ mcpLog(`[mcp-client] Tool call timed out; skipping reconnect retry for "${serverName}/${toolName}".\n`);
498
+ throw firstErr;
499
+ }
500
+ const serverDead = server.liveness?.alive === false;
501
+ if (!serverDead && isMcpServerResponseError(firstErr)) {
502
+ // The server answered (JSON-RPC error): it is alive — nothing to
503
+ // reconnect, and the call already ran its course.
504
+ throw firstErr;
505
+ }
506
+ // Only a failure that proves the request never left this process
507
+ // may be replayed; anything else may already have run server-side.
508
+ const undelivered = isMcpUndeliveredError(firstErr);
509
+ const deadTail = serverDead ? server.stderrTail?.() : '';
510
+ mcpLog(`[mcp-client] Tool call failed, attempting shared reconnect...\n`);
511
+ try {
512
+ // The reconnect is SHARED (singleflight): the race only stops
513
+ // THIS caller from waiting on it — the reconnect keeps running
514
+ // for its other waiters. Without this, an abort mid-reconnect
515
+ // could not settle the call and the admission slot stayed held
516
+ // until the reconnect finished, or forever if it hung.
517
+ retryServer = await raceMcpAbort(
518
+ reconnectMcpServer(scopeId, serverName, server),
519
+ callSignal,
520
+ `MCP tool call aborted during reconnect (server="${serverName}", tool="${toolName}")`
521
+ );
522
+ } catch (reconnectErr) {
523
+ if (isMcpCallAbortError(reconnectErr) || callSignal?.aborted) throw reconnectErr;
524
+ const reconnectMsg = reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr);
525
+ throw new Error(
526
+ appendMcpStderrTail(`Tool call failed: ${firstMsg}; reconnect also failed: ${reconnectMsg}`, deadTail)
527
+ );
528
+ }
529
+ if (!undelivered) {
530
+ throw new Error(
531
+ appendMcpStderrTail(
532
+ `Tool call failed: ${firstMsg}; MCP server "${serverName}" was reconnected for the next call, but this call was not replayed because the request may already have reached the server`,
533
+ deadTail
534
+ )
535
+ );
536
+ }
537
+ // A reconnect that outlived the caller's abort must not dispatch the
538
+ // retry: that would run the side effect after cancellation.
539
+ if (callSignal?.aborted) {
540
+ throw mcpAbortError(
541
+ callSignal,
542
+ `MCP tool call aborted after reconnect (server="${serverName}", tool="${toolName}")`
543
+ );
544
+ }
545
+ try {
546
+ result = await dispatch(retryServer);
547
+ } catch (retryErr) {
548
+ if (isMcpCallAbortError(retryErr) || callSignal?.aborted) throw retryErr;
549
+ const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
550
+ throw new Error(`Tool call failed: ${firstMsg}; retry after reconnect also failed: ${retryMsg}`);
551
+ }
521
552
  }
553
+ const normalized = normalizeToolEnvelope(await normalizeMcpToolResult(scrubMcpSecrets(result, secrets())));
554
+ const text = capMcpResult(normalized.result);
555
+ return normalized.explicitSuccess ? makeToolEnvelope(text, [], { explicitSuccess: true }) : text;
556
+ },
557
+ {
558
+ signal: callSignal,
522
559
  }
523
- const normalized = normalizeToolEnvelope(await normalizeMcpToolResult(result));
524
- const text = capMcpResult(normalized.result);
525
- return normalized.explicitSuccess ? makeToolEnvelope(text, [], { explicitSuccess: true }) : text;
526
- },
527
- {
528
- signal: callSignal,
529
- }
530
- );
560
+ )
561
+ .catch((error) => {
562
+ throw scrubMcpOAuthError(error, server.cfg, secrets());
563
+ });
531
564
  }
532
565
 
533
566
  const MCP_FEATURE_REQUESTS = {
@@ -903,6 +936,7 @@ export function raceMcpAbort(promise, signal, message) {
903
936
  if (!signal) return promise;
904
937
  allowConcurrentAbortListeners(signal);
905
938
  if (signal.aborted) {
939
+ // The abort wins; the abandoned promise's late rejection must not go unhandled.
906
940
  void Promise.resolve(promise).catch(() => {});
907
941
  return Promise.reject(mcpAbortError(signal, message));
908
942
  }
@@ -1090,6 +1124,7 @@ export async function disconnectAll(options = undefined) {
1090
1124
  /* ignore */
1091
1125
  }
1092
1126
  try {
1127
+ entry.transport.oauth?.close();
1093
1128
  void entry.client.close().catch(() => {
1094
1129
  /* ignore */
1095
1130
  });
@@ -1118,6 +1153,9 @@ export async function disconnectAll(options = undefined) {
1118
1153
  export async function disconnectMcpServer(name, options = {}) {
1119
1154
  const scopeId = normalizeMcpScopeId(options);
1120
1155
  const registryKey = mcpServerRegistryKey(scopeId, name);
1156
+ // An exclusive reconnect may currently be waiting for human authorization.
1157
+ // Cancel before queueing the disconnect behind that operation.
1158
+ cancelMcpLogin(name, options);
1121
1159
  return runExclusiveServerOp(registryKey, async () => {
1122
1160
  const server = servers.get(registryKey);
1123
1161
  if (!server) return false;
@@ -1132,6 +1170,73 @@ export async function disconnectMcpServer(name, options = {}) {
1132
1170
  return true;
1133
1171
  });
1134
1172
  }
1173
+ /**
1174
+ * Cancel the interactive OAuth login of a connect of `name` still in its
1175
+ * handshake; that connect then fails. Callers that queue behind the connect
1176
+ * (disable, remove) call this first so they never wait out the login.
1177
+ */
1178
+ export function cancelMcpLogin(name, options = {}) {
1179
+ const scopeId = normalizeMcpScopeId(options);
1180
+ for (const entry of _pendingConnects) {
1181
+ if (entry.scopeId === scopeId && entry.name === name) entry.transport.oauth?.close();
1182
+ }
1183
+ }
1184
+ /**
1185
+ * Answer the interactive OAuth login of a connect of `name` still in its
1186
+ * handshake with the redirect URL pasted from the browser (remote / headless
1187
+ * sign-in); that connect then proceeds as after the loopback callback. Throws
1188
+ * a fixed message, never the pasted URL, for an invalid paste (the login stays
1189
+ * pending) or when no login is waiting.
1190
+ */
1191
+ export function completeMcpLogin(name, callbackUrl, options = {}) {
1192
+ const scopeId = normalizeMcpScopeId(options);
1193
+ for (const entry of _pendingConnects) {
1194
+ if (entry.scopeId === scopeId && entry.name === name && entry.transport.oauth?.loginStarted) {
1195
+ entry.transport.oauth.completeCallback(callbackUrl);
1196
+ return;
1197
+ }
1198
+ }
1199
+ throw new Error(MCP_OAUTH_CALLBACK_NOT_PENDING);
1200
+ }
1201
+ /** Whether a connect of `name` still in its handshake is waiting for an
1202
+ * interactive (browser) OAuth login. */
1203
+ export function isMcpLoginPending(name, options = {}) {
1204
+ const scopeId = normalizeMcpScopeId(options);
1205
+ for (const entry of _pendingConnects) {
1206
+ if (entry.scopeId === scopeId && entry.name === name && entry.transport.oauth?.loginStarted) return true;
1207
+ }
1208
+ return false;
1209
+ }
1210
+ /**
1211
+ * Stored OAuth sign-in state of an HTTP/SSE server without configured
1212
+ * authentication: `{ signedIn }` (tokens stored for its exact URL), or null
1213
+ * for servers that never use MCP OAuth.
1214
+ */
1215
+ export function getMcpServerOAuthState(cfg) {
1216
+ let url;
1217
+ try {
1218
+ url = resolveMcpOAuthUrl(cfg);
1219
+ } catch {
1220
+ return null;
1221
+ }
1222
+ if (!url || hasConfiguredMcpAuth(cfg)) return null;
1223
+ return { signedIn: hasStoredMcpOAuthTokens(url) };
1224
+ }
1225
+ /**
1226
+ * Log out of an HTTP/SSE server URL: delete the OAuth tokens and client
1227
+ * registration stored for it, shared by every server configured with that
1228
+ * exact URL, and drop them from live connections.
1229
+ */
1230
+ export async function clearMcpServerOAuth(cfg) {
1231
+ let url;
1232
+ try {
1233
+ url = resolveMcpOAuthUrl(cfg);
1234
+ } catch {
1235
+ // A config without a valid remote URL never connected, so never logged in.
1236
+ return;
1237
+ }
1238
+ if (url) await clearMcpOAuth({ url });
1239
+ }
1135
1240
  /**
1136
1241
  * Close a single server: for stdio transports first shut down the full child
1137
1242
  * process tree (close stdin -> grace -> tree kill) so wrapper chains such as
@@ -1143,6 +1248,7 @@ async function _closeServer(server) {
1143
1248
  // A deliberate close is not a crash: suppress the unexpected-close report.
1144
1249
  if (server?.liveness) server.liveness.closing = true;
1145
1250
  const transport = server?.transport;
1251
+ transport?.oauth?.close();
1146
1252
  // Live stdio transports expose the spawned ChildProcess on _process.
1147
1253
  if (transport?._process) {
1148
1254
  try {
@@ -1165,7 +1271,8 @@ async function connectServer(
1165
1271
  name,
1166
1272
  cfg,
1167
1273
  scopeId = DEFAULT_MCP_SCOPE_ID,
1168
- genAtStart = currentConnectAbortGeneration(scopeId)
1274
+ genAtStart = currentConnectAbortGeneration(scopeId),
1275
+ options = {}
1169
1276
  ) {
1170
1277
  scopeId = normalizeMcpScopeId(scopeId);
1171
1278
  _knownMcpScopes.add(scopeId);
@@ -1182,8 +1289,14 @@ async function connectServer(
1182
1289
  sdk,
1183
1290
  log: mcpLog,
1184
1291
  strictEnv: cfg?._mixdogStrictEnv === true,
1292
+ // Only an explicit sign-in (beginMcpOAuthLogin) is interactive; it may
1293
+ // supply its own browser launcher to capture the authorization URL.
1294
+ oauthOptions: {
1295
+ interactive: options.interactive === true,
1296
+ ...(typeof options.openBrowser === 'function' ? { openBrowser: options.openBrowser } : {}),
1297
+ },
1185
1298
  });
1186
- const liveness = trackMcpLiveness(client, name, cfg, stderrTail);
1299
+ const liveness = trackMcpLiveness(client, name, cfg, stderrTail, transport);
1187
1300
  const pending = { scopeId, name, client, transport };
1188
1301
  _pendingConnects.add(pending);
1189
1302
  try {
@@ -1237,6 +1350,22 @@ async function connectServer(
1237
1350
  }
1238
1351
  _invalidateMcpToolFieldMemo();
1239
1352
  mcpLog(`[mcp] connected: ${tools.length} tools — ${tools.map((t) => t.name).join(', ')}\n`);
1353
+ } catch (error) {
1354
+ await _closeServer({ client, transport, liveness });
1355
+ const scrubbed = scrubMcpOAuthError(error, cfg, oauthSecrets(transport));
1356
+ // A server that only needs an OAuth sign-in is not a broken server: the
1357
+ // sign-in was required, or an explicit one did not complete (cancelled,
1358
+ // denied, timed out or a failed exchange after it started, or cancelled
1359
+ // before it could). A failure after the sign-in obtained tokens is real.
1360
+ const signInIncomplete =
1361
+ transport.oauth &&
1362
+ (isMcpOAuthReauthenticationRequired(error) ||
1363
+ (transport.oauth.loginStarted && !transport.oauth.loginCompleted) ||
1364
+ (transport.oauth.interactive && error?.code === MCP_CONNECT_CANCELLED));
1365
+ if (signInIncomplete && scrubbed && typeof scrubbed === 'object') {
1366
+ scrubbed.authRequired = true;
1367
+ }
1368
+ throw scrubbed;
1240
1369
  } finally {
1241
1370
  _pendingConnects.delete(pending);
1242
1371
  }
@@ -1249,7 +1378,7 @@ async function connectServer(
1249
1378
  * records the last error (parse/send hiccups do not mean the server died).
1250
1379
  * Handlers already installed on the client are chained, not replaced.
1251
1380
  */
1252
- function trackMcpLiveness(client, name, cfg, stderrTail) {
1381
+ function trackMcpLiveness(client, name, cfg, stderrTail, transport) {
1253
1382
  const liveness = { alive: true, closing: false, closedAt: 0, lastError: '' };
1254
1383
  const priorClose = client.onclose;
1255
1384
  const priorError = client.onerror;
@@ -1263,7 +1392,11 @@ function trackMcpLiveness(client, name, cfg, stderrTail) {
1263
1392
  if (typeof priorClose === 'function') priorClose.apply(client, args);
1264
1393
  };
1265
1394
  client.onerror = (error, ...rest) => {
1266
- liveness.lastError = scrubMcpConnectionMessage(error instanceof Error ? error.message : String(error), cfg);
1395
+ liveness.lastError = scrubMcpConnectionMessage(
1396
+ error instanceof Error ? error.message : String(error),
1397
+ cfg,
1398
+ oauthSecrets(transport)
1399
+ );
1267
1400
  if (typeof priorError === 'function') priorError.call(client, error, ...rest);
1268
1401
  };
1269
1402
  return liveness;
@@ -1316,6 +1449,7 @@ export function setMcpScopeRoot(scopeId, root) {
1316
1449
  if (server.cfg?._mixdogRootsFollowScope !== true) continue;
1317
1450
  Promise.resolve()
1318
1451
  .then(() => server.client?.sendRootsListChanged?.())
1452
+ // A server that rejects the roots notification keeps its previous roots.
1319
1453
  .catch(() => {});
1320
1454
  }
1321
1455
  }
@@ -1383,7 +1517,7 @@ async function runToolRefresh(registryKey, live, name, cfg, capabilities) {
1383
1517
  _invalidateMcpToolFieldMemo();
1384
1518
  } catch (error) {
1385
1519
  mcpLog(
1386
- `[mcp-client] Failed to refresh tools for "${name}": ${scrubMcpConnectionMessage(error?.message || error, cfg)}\n`
1520
+ `[mcp-client] Failed to refresh tools for "${name}": ${scrubMcpConnectionMessage(error?.message || error, cfg, oauthSecrets(live.transport))}\n`
1387
1521
  );
1388
1522
  }
1389
1523
  }
@@ -1419,7 +1553,7 @@ export function _registerMcpServerForTest(scopeId, name, rawTools = [], options
1419
1553
  capabilities,
1420
1554
  liveness: { alive: true, closing: false, closedAt: 0, lastError: '' },
1421
1555
  stderrTail: typeof options.stderrTail === 'function' ? options.stderrTail : () => '',
1422
- transport: null,
1556
+ transport: options.transport || null,
1423
1557
  client: {
1424
1558
  callTool:
1425
1559
  typeof options.callTool === 'function'
@@ -0,0 +1,103 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { OAUTH_PAGE_CONTENT_TYPE, oauthErrorHtml, oauthSuccessHtml } from '../providers/lib/oauth-page.mjs';
3
+
4
+ /** Upper bound of a pasted redirect URL (`completeMcpLogin`). */
5
+ export const MCP_OAUTH_CALLBACK_MAX_BYTES = 64 * 1024;
6
+ // Fixed messages: a pasted URL carries the authorization code and is never
7
+ // echoed into an error, a log or a status.
8
+ const INVALID_CALLBACK_PASTE =
9
+ 'The pasted sign-in URL is invalid. Paste the full address the browser was redirected to after signing in.';
10
+ export const MCP_OAUTH_CALLBACK_NOT_PENDING = 'MCP OAuth sign-in is not waiting for a callback';
11
+ const sameState = (supplied, expected) => {
12
+ const a = Buffer.from(supplied || '');
13
+ const b = Buffer.from(expected || '');
14
+ return b.length > 0 && a.length === b.length && timingSafeEqual(a, b);
15
+ };
16
+
17
+ /**
18
+ * The outcome of a redirect URL pasted from the browser for a pending login
19
+ * whose redirect URI is `redirectUrl` and state `state`: `{ code }`, or
20
+ * `{ denied: true }` for an authorization error. Throws a fixed message (never
21
+ * the pasted text) for anything that is not exactly that login's callback.
22
+ */
23
+ export function parsePastedMcpOAuthCallback(pasted, { redirectUrl, state }) {
24
+ const invalid = () => new Error(INVALID_CALLBACK_PASTE);
25
+ if (typeof pasted !== 'string' || Buffer.byteLength(pasted) > MCP_OAUTH_CALLBACK_MAX_BYTES) throw invalid();
26
+ const raw = pasted.trim();
27
+ let target;
28
+ let expected;
29
+ try {
30
+ target = new URL(raw);
31
+ expected = new URL(redirectUrl);
32
+ } catch {
33
+ throw invalid();
34
+ }
35
+ if (
36
+ raw.includes('#') ||
37
+ target.username ||
38
+ target.password ||
39
+ target.origin !== expected.origin ||
40
+ target.pathname !== expected.pathname
41
+ )
42
+ throw invalid();
43
+ const seen = new Set();
44
+ for (const [key, value] of target.searchParams) {
45
+ if (seen.has(key)) throw invalid();
46
+ seen.add(key);
47
+ // The redirect URI's own parameters come back unchanged; any others an
48
+ // authorization server adds (e.g. Google's scope/authuser/prompt) are
49
+ // ignored, as in Codex's parse_callback_url.
50
+ if (expected.searchParams.has(key) && expected.searchParams.get(key) !== value) throw invalid();
51
+ }
52
+ for (const key of expected.searchParams.keys()) if (!seen.has(key)) throw invalid();
53
+ if (!sameState(target.searchParams.get('state'), state)) throw invalid();
54
+ if (seen.has('code') === seen.has('error')) throw invalid();
55
+ if (seen.has('error')) return { denied: true };
56
+ const code = target.searchParams.get('code');
57
+ if (!code) throw invalid();
58
+ return { code };
59
+ }
60
+
61
+ // Answers a request to the loopback redirect URI: only a GET of the exact
62
+ // callback path and host, carrying exactly the pending state (compared in
63
+ // constant time) and either one code or an error, finishes the login.
64
+ export function answerOAuthCallback(req, res, { redirectUrl, state, finish, serverName }) {
65
+ const page = (status, html) => {
66
+ res.writeHead(status, {
67
+ 'Content-Type': OAUTH_PAGE_CONTENT_TYPE,
68
+ 'Cache-Control': 'no-store',
69
+ 'Referrer-Policy': 'no-referrer',
70
+ });
71
+ res.end(html);
72
+ };
73
+ const invalid = () =>
74
+ page(400, oauthErrorHtml('This sign-in link is invalid or has expired.', 'Start the sign-in again from Mixdog.'));
75
+ let target;
76
+ try {
77
+ target = new URL(req.url || '/', redirectUrl);
78
+ } catch {
79
+ invalid();
80
+ return;
81
+ }
82
+ // `finish` is absent once the login was answered (e.g. by a pasted URL).
83
+ const valid =
84
+ typeof finish === 'function' &&
85
+ req.method === 'GET' &&
86
+ req.headers.host === new URL(redirectUrl).host &&
87
+ target.pathname === '/oauth/callback' &&
88
+ target.searchParams.getAll('state').length === 1 &&
89
+ sameState(target.searchParams.get('state'), state);
90
+ if (!valid || (!target.searchParams.has('error') && target.searchParams.getAll('code').length !== 1)) {
91
+ invalid();
92
+ return;
93
+ }
94
+ const server = serverName || 'the MCP server';
95
+ if (target.searchParams.has('error')) {
96
+ // The authorization server's error text is never reflected.
97
+ page(400, oauthErrorHtml(`Sign-in to ${server} was not completed.`, 'Start the sign-in again from Mixdog.'));
98
+ finish(undefined);
99
+ return;
100
+ }
101
+ page(200, oauthSuccessHtml(server));
102
+ finish(target.searchParams.get('code'));
103
+ }