openmausbot 0.1.90 → 0.1.92

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 (177) hide show
  1. package/dist/assets/DesktopViewer-Do6lBa4C.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DUyXd0kL.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
  3. package/dist/assets/{arc-q73qM-At.js → arc-DIYc7Ymx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-D33O3Wur.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DLsJeoXC.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DnaljZnu.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
  7. package/dist/assets/channel-Dtk5Mtic.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-Da0h2i79.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-CfzHi54f.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-LjE4d9Jf.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DY-UGmvc.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BqeGhyhP.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BYa9EBAg.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-vTrDtWyb.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-ic5tXadM.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-DvRCY-c6.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-DVbmgz_L.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-vdUs2n57.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-BvLGlV3U.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-C4GcJQDt.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-BMSaHpt_.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-D5b9UBhC.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CIKLlB7Y.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BzXb-7k7.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-HsTvJ7uT.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-Db9ySNbQ.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-D6BKbpqY.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-Bq067L0t.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-DhaD7fNJ.js → elk-276RUBZZ-v6bj852W.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-fwIhfz36.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-DygIyu0V.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-DPYfSO96.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-DhkHeCle.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
  34. package/dist/assets/index-Cdmjrl28.css +1 -0
  35. package/dist/assets/{index-G1PHKGyh.js → index-DGBaEXrM.js} +1 -1
  36. package/dist/assets/index-DR7YPMkW.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-DstBLxME.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-CrIsGgch.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-CYypGDbB.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DGjPt8E_.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
  41. package/dist/assets/{linear-Dwc3Hrru.js → linear-CmOWzX0P.js} +1 -1
  42. package/dist/assets/{mermaid.core-Dc4OePBL.js → mermaid.core-CF1zwoQ-.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DV-vepj-.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-lx5Kj-n5.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-Bq7Vmm15.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-DeqeqOBj.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-CXwRQzSC.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-8tAeoB88.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-BG5zjGWU.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DKF3iLcw.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-B-y1qUgT.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-MkzPgxAc.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DnbaOEwH.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-Bb4xG_Bt.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-JgkR9pFf.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-CIPIrAYe.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-BjCLuUvJ.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DKgiaYxT.js → xychartDiagram-PMCCYNJV-Dc_fL4mk.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist/novnc-NOTICE.txt +118 -0
  62. package/dist-server/cloud-home-start.js +128 -15
  63. package/dist-server/container-mcp.js +277 -45
  64. package/dist-server/drivers/agents-proxy.js +16 -4
  65. package/dist-server/enterprise/server/index.js +5 -1
  66. package/dist-server/index.js +46703 -39955
  67. package/dist-server/local-computer-proxy.js +128 -7
  68. package/dist-server/mcp-gate.js +11 -5
  69. package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
  70. package/dist-server/openmausbot.js +4099 -1217
  71. package/dist-server/pair-cli.js +4099 -1217
  72. package/dist-server/server/boat.js +67 -22
  73. package/dist-server/server/bot-overview.js +7 -4
  74. package/dist-server/server/browser-engine.js +10 -1
  75. package/dist-server/server/browser-live.js +46 -2
  76. package/dist-server/server/browser-runtime.js +74 -8
  77. package/dist-server/server/browser-tool-shape.js +6 -4
  78. package/dist-server/server/chief-of-staff.js +1 -1
  79. package/dist-server/server/cloud-home-start.js +136 -26
  80. package/dist-server/server/cloud-home.js +64 -5
  81. package/dist-server/server/cloud-lending.js +167 -0
  82. package/dist-server/server/cloud-move-http.js +268 -0
  83. package/dist-server/server/cloud-move.js +418 -0
  84. package/dist-server/server/cloud-owner.js +196 -0
  85. package/dist-server/server/cloud-secrets-boot.js +6 -0
  86. package/dist-server/server/cloud-secrets.js +65 -0
  87. package/dist-server/server/config.js +215 -17
  88. package/dist-server/server/container-computer.js +3 -2
  89. package/dist-server/server/decider/index.js +238 -0
  90. package/dist-server/server/decider/jev.js +183 -0
  91. package/dist-server/server/decider/log.js +62 -0
  92. package/dist-server/server/decider/relay.js +52 -0
  93. package/dist-server/server/decider/room-routing.js +88 -0
  94. package/dist-server/server/decider/types.js +5 -0
  95. package/dist-server/server/desktop-viewer-targets.js +26 -0
  96. package/dist-server/server/drivers/acp/core.js +167 -21
  97. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  98. package/dist-server/server/drivers/agents-catalog.js +22 -6
  99. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  100. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  101. package/dist-server/server/drivers/antigravity.js +21 -0
  102. package/dist-server/server/drivers/boatagent.js +20 -12
  103. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  104. package/dist-server/server/drivers/claude.js +313 -34
  105. package/dist-server/server/drivers/codex.js +228 -18
  106. package/dist-server/server/drivers/grok.js +2 -2
  107. package/dist-server/server/drivers/mistral.js +4 -3
  108. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  109. package/dist-server/server/drivers/openai-chat.js +41 -3
  110. package/dist-server/server/drivers/openai-compat.js +73 -23
  111. package/dist-server/server/engine-install.js +46 -12
  112. package/dist-server/server/env-path.js +15 -3
  113. package/dist-server/server/group-goal-run.js +6 -4
  114. package/dist-server/server/harness/registry.js +2 -2
  115. package/dist-server/server/harness-capabilities.js +2 -0
  116. package/dist-server/server/included-services.js +70 -0
  117. package/dist-server/server/index.js +1753 -263
  118. package/dist-server/server/lending-memory.js +479 -0
  119. package/dist-server/server/local-vm-idle.js +24 -6
  120. package/dist-server/server/managed-desktop.js +3 -0
  121. package/dist-server/server/mcp-bridge.js +5 -3
  122. package/dist-server/server/mcp-http.js +6 -3
  123. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  124. package/dist-server/server/mcp-oauth-store.js +70 -0
  125. package/dist-server/server/mcp-oauth.js +550 -0
  126. package/dist-server/server/mcp-probe.js +14 -1
  127. package/dist-server/server/mcp-registry.js +43 -0
  128. package/dist-server/server/memory-upkeep.js +94 -84
  129. package/dist-server/server/model-catalog/catalog.js +374 -0
  130. package/dist-server/server/model-catalog/presets.js +176 -0
  131. package/dist-server/server/model-catalog/trim.js +219 -0
  132. package/dist-server/server/model-context-window.js +4 -4
  133. package/dist-server/server/package-export.js +17 -10
  134. package/dist-server/server/package-import.js +6 -3
  135. package/dist-server/server/package-parts.js +3 -1
  136. package/dist-server/server/provider-key-check.js +4 -2
  137. package/dist-server/server/recent-work.js +1 -1
  138. package/dist-server/server/request-auth.js +25 -0
  139. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  140. package/dist-server/server/routes/bot-memory.js +31 -6
  141. package/dist-server/server/routes/decider.js +19 -0
  142. package/dist-server/server/routes/desktop-viewer.js +194 -0
  143. package/dist-server/server/routine-requests.js +1 -1
  144. package/dist-server/server/routines.js +1 -1
  145. package/dist-server/server/sessions.js +56 -9
  146. package/dist-server/server/shared-computers.js +116 -30
  147. package/dist-server/server/skills.js +25 -10
  148. package/dist-server/server/store.js +79 -124
  149. package/dist-server/server/surface.js +13 -0
  150. package/dist-server/server/system-prompt.js +11 -0
  151. package/dist-server/server/team-backup.js +6 -2
  152. package/dist-server/server/team-manifest.js +11 -3
  153. package/dist-server/server/thread-events.js +2 -0
  154. package/dist-server/server/thread-starters.js +33 -0
  155. package/dist-server/server/thread-title.js +46 -0
  156. package/dist-server/server/tts/elevenlabs.js +11 -6
  157. package/dist-server/server/tts/fish.js +4 -5
  158. package/dist-server/server/tts/index.js +20 -11
  159. package/dist-server/server/vps-computer.js +79 -12
  160. package/dist-server/server/webhooks.js +18 -1
  161. package/dist-server/server/workspace-backup-policy.js +25 -3
  162. package/dist-server/server/workspace-backup.js +28 -8
  163. package/dist-server/server/workspace.js +65 -15
  164. package/dist-server/shared/cloud-home.js +4 -0
  165. package/dist-server/shared/package-format.js +30 -4
  166. package/dist-server/shared/team-backup.js +20 -3
  167. package/dist-server/shared/workspace-backup-client.js +1 -0
  168. package/dist-server/src/lib/citations.js +177 -0
  169. package/dist-server/src/lib/composer-attachments.js +7 -1
  170. package/dist-server/vps-container-mcp.js +278 -45
  171. package/dist-server/workspace-backup.worker.js +184 -38
  172. package/enterprise/server/index.js +5 -1
  173. package/package.json +1 -1
  174. package/dist/assets/channel-BCuyHjFV.js +0 -1
  175. package/dist/assets/index-CRvo-DS2.css +0 -1
  176. package/dist/assets/index-CWYGlA63.js +0 -588
  177. package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +0 -8
@@ -0,0 +1,550 @@
1
+ // OAuth sign-in for URL MCP servers, the way MCP clients do it: discover
2
+ // the authorization server from the MCP server's 401, sign in as the app
3
+ // the person registered for this server or else register as a public
4
+ // client when the server allows it, run PKCE through the person's browser
5
+ // with a loopback callback (or a URL pasted from another computer), then
6
+ // keep the tokens fresh.
7
+ // Engines never see this: they get an ordinary Authorization header.
8
+ //
9
+ // The loopback listener and callback checks follow
10
+ // drivers/chatgpt-plan-auth.ts: one pending sign-in per server, a state
11
+ // compared in constant time, single-valued parameters, and a listener that
12
+ // lives only until the sign-in settles.
13
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
14
+ import { createServer } from "node:http";
15
+ import { discoverMcpAuth } from "./mcp-oauth-discovery.js";
16
+ import { McpOAuthStore } from "./mcp-oauth-store.js";
17
+ export class McpOAuthError extends Error {
18
+ code;
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.name = "McpOAuthError";
22
+ this.code = code;
23
+ }
24
+ }
25
+ const DEFAULT_LIFETIME_MS = 5 * 60_000;
26
+ /** Refresh a token this close to expiry, so a turn never starts on one
27
+ * that lapses mid-way. */
28
+ const REFRESH_MARGIN_MS = 2 * 60_000;
29
+ const REQUEST_TIMEOUT_MS = 10_000;
30
+ const CALLBACK_PATH = "/mcp-oauth/callback";
31
+ /** complete()'s answer for a flow that ended while its code was spent. */
32
+ const DISCARDED = "discarded";
33
+ export class McpSignInError extends Error {
34
+ status;
35
+ constructor(message, status) {
36
+ super(message);
37
+ this.status = status;
38
+ this.name = "McpSignInError";
39
+ }
40
+ }
41
+ /** A port derived from the server URL: the same redirect URI on every
42
+ * attempt, so a registered client can be reused instead of re-registered. */
43
+ function preferredPort(url) {
44
+ let hash = 0x811c9dc5;
45
+ for (const char of url) {
46
+ hash ^= char.charCodeAt(0);
47
+ hash = Math.imul(hash, 0x01000193) >>> 0;
48
+ }
49
+ return 20_000 + (hash % 20_000);
50
+ }
51
+ /** The redirect URI a sign-in for this server uses, to register with an
52
+ * app made in advance for it. */
53
+ export function mcpOAuthRedirectUri(url) {
54
+ return `http://127.0.0.1:${preferredPort(url)}${CALLBACK_PATH}`;
55
+ }
56
+ /** RFC 8414 §2: an absent list means client_secret_basic. */
57
+ function tokenAuthMethod(meta, secret) {
58
+ if (!secret)
59
+ return "none";
60
+ const supported = meta.tokenEndpointAuthMethods;
61
+ return !supported || supported.includes("client_secret_basic") || !supported.includes("client_secret_post")
62
+ ? "client_secret_basic"
63
+ : "client_secret_post";
64
+ }
65
+ /** RFC 6749 §2.3.1: Basic credentials are form-encoded first. */
66
+ const formEncoded = (value) => new URLSearchParams({ v: value }).toString().slice(2);
67
+ function listen(server, port) {
68
+ return new Promise((resolve, reject) => {
69
+ server.once("error", reject);
70
+ server.listen(port, "127.0.0.1", () => {
71
+ server.off("error", reject);
72
+ resolve();
73
+ });
74
+ });
75
+ }
76
+ function sameSecret(actual, expected) {
77
+ return Buffer.byteLength(actual) === Buffer.byteLength(expected) && timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
78
+ }
79
+ async function postForm(url, form, client) {
80
+ const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
81
+ const fields = { ...form };
82
+ if (client?.method === "client_secret_basic" && client.secret) {
83
+ headers.authorization = `Basic ${Buffer.from(`${formEncoded(client.id)}:${formEncoded(client.secret)}`).toString("base64")}`;
84
+ }
85
+ else if (client) {
86
+ fields.client_id = client.id;
87
+ if (client.method === "client_secret_post" && client.secret)
88
+ fields.client_secret = client.secret;
89
+ }
90
+ const response = await fetch(url, {
91
+ method: "POST",
92
+ headers,
93
+ body: new URLSearchParams(fields).toString(),
94
+ redirect: "error",
95
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
96
+ });
97
+ let body = {};
98
+ try {
99
+ const parsed = await response.json();
100
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
101
+ body = parsed;
102
+ }
103
+ catch {
104
+ // an empty or non-JSON body is judged by its status alone
105
+ }
106
+ return { status: response.status, body };
107
+ }
108
+ function tokensFrom(body, previous) {
109
+ if (typeof body.access_token !== "string" || !body.access_token)
110
+ return null;
111
+ const refresh = typeof body.refresh_token === "string" && body.refresh_token ? body.refresh_token : previous?.refresh;
112
+ return {
113
+ access: body.access_token,
114
+ ...(refresh ? { refresh } : {}),
115
+ ...(typeof body.expires_in === "number" && body.expires_in > 0 ? { expiresAt: Date.now() + body.expires_in * 1000 } : {}),
116
+ ...(typeof body.scope === "string" ? { scope: body.scope } : {}),
117
+ };
118
+ }
119
+ export class McpOAuthManager {
120
+ store;
121
+ lifetimeMs;
122
+ isOwnerLive;
123
+ flows = new Map();
124
+ starting = new Map();
125
+ refreshing = new Map();
126
+ clients;
127
+ /** `clients` reads the app registered for a server from its config, at
128
+ * the moment it is needed: its secret is never copied into this store. */
129
+ constructor(options) {
130
+ this.store = new McpOAuthStore(options.file);
131
+ this.isOwnerLive = options.isOwnerLive ?? (() => true);
132
+ this.lifetimeMs = options.lifetimeMs ?? DEFAULT_LIFETIME_MS;
133
+ this.clients = options.clients ?? (() => undefined);
134
+ }
135
+ /** How to authenticate as the client a record was signed in with; null
136
+ * when its app (or the app's secret) is no longer configured. */
137
+ recordClient(name, record) {
138
+ if (!record.clientId)
139
+ return null;
140
+ if (!record.tokenAuth)
141
+ return { id: record.clientId, method: "none" };
142
+ const configured = this.clients(name, record.url);
143
+ return configured?.clientId === record.clientId && configured.clientSecret
144
+ ? { id: record.clientId, secret: configured.clientSecret, method: record.tokenAuth }
145
+ : null;
146
+ }
147
+ authState(name, url) {
148
+ const record = this.store.get(name, url);
149
+ if (!record)
150
+ return "none";
151
+ return record.state === "signed-in" && record.tokens ? "signed-in" : "needs-sign-in";
152
+ }
153
+ /** The server answered 401 asking for OAuth: stop mounting it and show
154
+ * Sign in. Any tokens it had are no longer accepted, so they go. */
155
+ markNeedsSignIn(name, url) {
156
+ const { tokens: _dropped, ...rest } = this.store.get(name, url) ?? { url };
157
+ this.store.put(name, { ...rest, url, state: "needs-sign-in" });
158
+ }
159
+ start(name, url, wwwAuthenticate, owner = "loopback") {
160
+ const current = this.flows.get(name);
161
+ if (current?.status.phase === "waiting" && !this.isOwnerLive(current.owner)) {
162
+ this.finish(current, "cancelled", "Your session ended. Start again.");
163
+ }
164
+ if (current?.status.phase === "waiting") {
165
+ if (current.owner !== owner)
166
+ return Promise.reject(new McpSignInError("A sign-in is already in progress in another browser.", 409));
167
+ if (current.url === url)
168
+ return Promise.resolve({ ...current.status });
169
+ }
170
+ const pending = this.starting.get(name);
171
+ if (pending) {
172
+ if (pending.owner !== owner || pending.url !== url || pending.cancelled) {
173
+ return Promise.reject(new McpSignInError("A sign-in is still starting. Try again shortly.", 409));
174
+ }
175
+ return pending.promise;
176
+ }
177
+ const starting = { owner, url, cancelled: false, promise: undefined };
178
+ starting.promise = this.begin(name, url, starting, wwwAuthenticate).finally(() => this.starting.delete(name));
179
+ this.starting.set(name, starting);
180
+ return starting.promise;
181
+ }
182
+ status(name, flowId, owner = "loopback") {
183
+ const flow = this.flows.get(name);
184
+ return flow && flow.owner === owner && flow.status.flowId === flowId ? { ...flow.status } : undefined;
185
+ }
186
+ cancelFlow(name, owner, flowId) {
187
+ const flow = this.flows.get(name);
188
+ if (!flow || flow.owner !== owner || (flowId && flow.status.flowId !== flowId)) {
189
+ throw new McpSignInError("This sign-in is no longer available in this browser. Start again.", 404);
190
+ }
191
+ this.finish(flow, "cancelled", "Sign-in cancelled.");
192
+ }
193
+ cancel(name) {
194
+ const pending = this.starting.get(name);
195
+ if (pending)
196
+ pending.cancelled = true;
197
+ const flow = this.flows.get(name);
198
+ if (flow)
199
+ this.finish(flow, "cancelled", "Sign-in cancelled.");
200
+ }
201
+ revokeOwner(owner) {
202
+ for (const pending of this.starting.values())
203
+ if (pending.owner === owner)
204
+ pending.cancelled = true;
205
+ for (const flow of this.flows.values()) {
206
+ if (flow.owner === owner)
207
+ this.finish(flow, "cancelled", "Sign-in cancelled because your session ended.");
208
+ }
209
+ }
210
+ /** Parse the pasted URL locally; never request a user-supplied address. */
211
+ async completeCallback(name, flowId, callbackUrl, owner = "loopback") {
212
+ const flow = this.flows.get(name);
213
+ if (!flow || flow.owner !== owner || flow.status.flowId !== flowId) {
214
+ throw new McpSignInError("This sign-in is no longer available in this browser. Start again.", 404);
215
+ }
216
+ let callback;
217
+ try {
218
+ if (callbackUrl.length > 16_384)
219
+ throw new Error("length");
220
+ callback = new URL(callbackUrl);
221
+ }
222
+ catch {
223
+ throw new McpSignInError("Paste the complete redirect URL from your browser's address bar.", 400);
224
+ }
225
+ await this.acceptCallback(flow, callback);
226
+ return { ...flow.status };
227
+ }
228
+ /** Revoke with the server when it offers that (best effort), then forget. */
229
+ async signOut(name, url) {
230
+ this.cancel(name);
231
+ const record = this.store.get(name, url);
232
+ const token = record?.tokens?.refresh ?? record?.tokens?.access;
233
+ if (record?.revocationEndpoint && token) {
234
+ await postForm(record.revocationEndpoint, { token }, this.recordClient(name, record) ?? undefined).catch(() => undefined);
235
+ }
236
+ this.store.delete(name);
237
+ }
238
+ /** Drop everything for a server that was removed or pointed elsewhere. */
239
+ forget(name) {
240
+ this.cancel(name);
241
+ this.flows.delete(name);
242
+ this.store.delete(name);
243
+ }
244
+ /** A usable access token for this server at this URL, refreshed when it
245
+ * is close to expiry; null when the server needs a (new) sign-in. */
246
+ accessToken(name, url) {
247
+ const record = this.store.get(name, url);
248
+ if (!record || record.state !== "signed-in" || !record.tokens)
249
+ return Promise.resolve(null);
250
+ const { tokens } = record;
251
+ if (!tokens.expiresAt || tokens.expiresAt > Date.now() + REFRESH_MARGIN_MS)
252
+ return Promise.resolve(tokens.access);
253
+ const pending = this.refreshing.get(name);
254
+ if (pending)
255
+ return pending;
256
+ const refreshed = this.refresh(name, record).finally(() => this.refreshing.delete(name));
257
+ this.refreshing.set(name, refreshed);
258
+ return refreshed;
259
+ }
260
+ dispose() {
261
+ for (const pending of this.starting.values())
262
+ pending.cancelled = true;
263
+ for (const flow of this.flows.values())
264
+ this.finish(flow, "cancelled", "Sign-in cancelled.");
265
+ this.flows.clear();
266
+ }
267
+ async refresh(name, record) {
268
+ const tokens = record.tokens;
269
+ const stillValid = tokens.expiresAt !== undefined && tokens.expiresAt > Date.now();
270
+ const client = this.recordClient(name, record);
271
+ if (!tokens.refresh || !record.tokenEndpoint || !client) {
272
+ if (stillValid)
273
+ return tokens.access;
274
+ this.markNeedsSignIn(name, record.url);
275
+ return null;
276
+ }
277
+ let answer;
278
+ try {
279
+ answer = await postForm(record.tokenEndpoint, {
280
+ grant_type: "refresh_token",
281
+ refresh_token: tokens.refresh,
282
+ resource: record.url,
283
+ }, client);
284
+ }
285
+ catch {
286
+ // offline or the server is down: keep what still works, retry next turn
287
+ return stillValid ? tokens.access : null;
288
+ }
289
+ const next = answer.status >= 200 && answer.status < 300 ? tokensFrom(answer.body, tokens) : null;
290
+ if (!next) {
291
+ // Only a refused grant or client means the sign-in is over; a rate
292
+ // limit, a proxy page or an outage keeps what still works.
293
+ const dead = (answer.status === 400 || answer.status === 401)
294
+ && (answer.body.error === "invalid_grant" || answer.body.error === "invalid_client");
295
+ if (!dead)
296
+ return stillValid ? tokens.access : null;
297
+ this.markNeedsSignIn(name, record.url);
298
+ return null;
299
+ }
300
+ // the entry may have been removed or re-pointed while this was in flight
301
+ if (!this.store.get(name, record.url))
302
+ return null;
303
+ this.store.put(name, { ...record, state: "signed-in", tokens: next });
304
+ return next.access;
305
+ }
306
+ /** The WWW-Authenticate challenge of an unauthenticated request, if any. */
307
+ async challenge(url) {
308
+ try {
309
+ const response = await fetch(url, {
310
+ method: "POST",
311
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
312
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "OpenMausBot", version: "1" } } }),
313
+ redirect: "error",
314
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
315
+ });
316
+ void response.body?.cancel().catch(() => { });
317
+ return response.status === 401 ? response.headers.get("www-authenticate") : null;
318
+ }
319
+ catch {
320
+ return null;
321
+ }
322
+ }
323
+ /** A fresh public client per sign-in. Sign-ins are rare, and a cached id
324
+ * goes stale when an authorization server prunes idle clients, after
325
+ * which every sign-in would fail at its authorize page. */
326
+ async register(meta, redirectUri) {
327
+ if (!meta.registrationEndpoint) {
328
+ throw new McpOAuthError("no-registration", "This server only signs in apps registered with it in advance. Edit the server and add that app's client ID under Sign-in app.");
329
+ }
330
+ const response = await fetch(meta.registrationEndpoint, {
331
+ method: "POST",
332
+ headers: { "content-type": "application/json", accept: "application/json" },
333
+ body: JSON.stringify({
334
+ client_name: "OpenMausBot",
335
+ redirect_uris: [redirectUri],
336
+ grant_types: ["authorization_code", "refresh_token"],
337
+ response_types: ["code"],
338
+ token_endpoint_auth_method: "none",
339
+ }),
340
+ redirect: "error",
341
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
342
+ });
343
+ const body = await response.json().catch(() => ({}));
344
+ if (!response.ok || typeof body.client_id !== "string" || !body.client_id) {
345
+ throw new Error("This server would not register OpenMausBot for sign-in. Try again later.");
346
+ }
347
+ return body.client_id;
348
+ }
349
+ async begin(name, url, starting, wwwAuthenticate) {
350
+ const previous = this.flows.get(name);
351
+ if (previous)
352
+ this.finish(previous, "cancelled", "Sign-in cancelled.");
353
+ const meta = await discoverMcpAuth(url, wwwAuthenticate ?? await this.challenge(url));
354
+ if (!meta)
355
+ throw new McpOAuthError("not-oauth", "This server does not offer an OAuth sign-in. Add its token as a header instead.");
356
+ const registered = this.clients(name, url);
357
+ const server = createServer();
358
+ try {
359
+ await listen(server, preferredPort(url));
360
+ }
361
+ catch {
362
+ // An app registered in advance was given this exact redirect URI.
363
+ if (registered) {
364
+ throw new McpOAuthError("port-busy", `Port ${preferredPort(url)} on this computer is in use, and sign-ins for this server's app return there. Close what is using it and try again.`);
365
+ }
366
+ await listen(server, 0);
367
+ }
368
+ try {
369
+ const address = server.address();
370
+ if (!address || typeof address === "string")
371
+ throw new Error("The sign-in listener could not start.");
372
+ const redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
373
+ const client = registered
374
+ ? { id: registered.clientId, ...(registered.clientSecret ? { secret: registered.clientSecret } : {}), method: tokenAuthMethod(meta, registered.clientSecret) }
375
+ : { id: await this.register(meta, redirectUri), method: "none" };
376
+ const scopes = registered?.scopes?.length ? registered.scopes : meta.scopes;
377
+ if (starting.cancelled || !this.isOwnerLive(starting.owner))
378
+ throw new McpSignInError("Sign-in cancelled because the session or server changed.", 409);
379
+ const state = randomBytes(32).toString("base64url");
380
+ const verifier = randomBytes(32).toString("base64url");
381
+ const authorize = new URL(meta.authorizationEndpoint);
382
+ authorize.searchParams.set("response_type", "code");
383
+ authorize.searchParams.set("client_id", client.id);
384
+ authorize.searchParams.set("redirect_uri", redirectUri);
385
+ authorize.searchParams.set("state", state);
386
+ authorize.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url"));
387
+ authorize.searchParams.set("code_challenge_method", "S256");
388
+ authorize.searchParams.set("resource", meta.resource);
389
+ if (scopes?.length)
390
+ authorize.searchParams.set("scope", scopes.join(" "));
391
+ const status = {
392
+ phase: "waiting",
393
+ flowId: randomUUID(),
394
+ authorizationUrl: authorize.toString(),
395
+ expiresAt: new Date(Date.now() + this.lifetimeMs).toISOString(),
396
+ };
397
+ const flow = {
398
+ owner: starting.owner, name, url, status, state, verifier, redirectUri, client, meta, server, consumed: false,
399
+ expiry: setTimeout(() => this.finish(flow, "expired", "Sign-in expired. Start again."), this.lifetimeMs),
400
+ };
401
+ flow.expiry.unref();
402
+ server.on("request", (request, response) => void this.callback(flow, request, response));
403
+ this.flows.set(name, flow);
404
+ return { ...status };
405
+ }
406
+ catch (error) {
407
+ server.close();
408
+ throw error;
409
+ }
410
+ }
411
+ async callback(flow, request, response) {
412
+ response.setHeader("Cache-Control", "no-store");
413
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
414
+ response.setHeader("Referrer-Policy", "no-referrer");
415
+ // A kept-alive browser connection would carry the next sign-in's
416
+ // callback to this listener after it has closed to new connections.
417
+ response.setHeader("Connection", "close");
418
+ let callback;
419
+ try {
420
+ const expected = new URL(flow.redirectUri);
421
+ if (request.headers.host !== expected.host || !request.url?.startsWith("/"))
422
+ throw new Error("host");
423
+ callback = new URL(request.url, flow.redirectUri);
424
+ }
425
+ catch {
426
+ response.writeHead(400).end("Invalid sign-in callback. Return to OpenMausBot and try again.");
427
+ return;
428
+ }
429
+ try {
430
+ if (request.method !== "GET")
431
+ throw new McpSignInError("Invalid sign-in callback.", 400);
432
+ await this.acceptCallback(flow, callback);
433
+ if (flow.status.phase === "failed") {
434
+ response.writeHead(400).end(`${flow.status.message} You can close this tab.`);
435
+ }
436
+ else {
437
+ response.end("Signed in. You can close this tab and return to OpenMausBot.");
438
+ }
439
+ }
440
+ catch (error) {
441
+ response.writeHead(error instanceof McpSignInError ? error.status : 400)
442
+ .end("Invalid or expired sign-in. Return to OpenMausBot and try again.");
443
+ }
444
+ }
445
+ /** Both the listener and authenticated paste-back use the same one-shot checks. */
446
+ async acceptCallback(flow, callback) {
447
+ if (!this.isOwnerLive(flow.owner))
448
+ this.finish(flow, "cancelled", "Your session ended. Start again.");
449
+ if (flow.status.phase === "waiting" && !flow.consumed && Date.now() >= Date.parse(flow.status.expiresAt)) {
450
+ this.finish(flow, "expired", "Sign-in expired. Start again.");
451
+ }
452
+ if (flow.consumed || flow.status.phase !== "waiting" || this.flows.get(flow.name) !== flow) {
453
+ throw new McpSignInError("This sign-in has ended or is already being completed. Start again if needed.", 409);
454
+ }
455
+ const expected = new URL(flow.redirectUri);
456
+ const params = callback.searchParams;
457
+ if (callback.origin !== expected.origin || callback.pathname !== expected.pathname
458
+ || callback.username || callback.password || callback.hash
459
+ || [...params.keys()].some((key) => params.getAll(key).length !== 1)
460
+ || !sameSecret(params.get("state") ?? "", flow.state)
461
+ || (params.has("iss") && params.get("iss") !== flow.meta.issuer)
462
+ || !((params.get("code") && !params.has("error") && params.get("code").length <= 4096)
463
+ || (params.get("error") && !params.has("code")))) {
464
+ throw new McpSignInError("This redirect URL does not belong to the current sign-in. Copy the complete URL and try again.", 400);
465
+ }
466
+ flow.consumed = true;
467
+ clearTimeout(flow.expiry);
468
+ const failure = await this.complete(flow, params);
469
+ if (failure === DISCARDED)
470
+ throw new McpSignInError("This sign-in was cancelled. Start again.", 409);
471
+ this.finish(flow, failure ? "failed" : "succeeded", failure ?? undefined);
472
+ }
473
+ /** Spend the code; the error message on failure, null on success. */
474
+ async complete(flow, params) {
475
+ if (params.has("error"))
476
+ return "Sign-in was not approved.";
477
+ const code = params.get("code");
478
+ if (!code || code.length > 4096)
479
+ return "The sign-in did not return a code. Start again.";
480
+ try {
481
+ const answer = await postForm(flow.meta.tokenEndpoint, {
482
+ grant_type: "authorization_code",
483
+ code,
484
+ redirect_uri: flow.redirectUri,
485
+ code_verifier: flow.verifier,
486
+ resource: flow.meta.resource,
487
+ }, flow.client);
488
+ const tokens = answer.status >= 200 && answer.status < 300 ? tokensFrom(answer.body) : null;
489
+ if (!tokens)
490
+ return "The server did not accept this sign-in. Start again.";
491
+ // Cancelled, signed out or removed while the code was being spent:
492
+ // hand the tokens back rather than resurrect a sign-in.
493
+ if (!this.isOwnerLive(flow.owner))
494
+ this.finish(flow, "cancelled", "Your session ended. Start again.");
495
+ if (this.flows.get(flow.name) !== flow || flow.status.phase !== "waiting") {
496
+ if (flow.meta.revocationEndpoint) {
497
+ void postForm(flow.meta.revocationEndpoint, { token: tokens.refresh ?? tokens.access }, flow.client).catch(() => undefined);
498
+ }
499
+ return DISCARDED;
500
+ }
501
+ this.store.put(flow.name, {
502
+ url: flow.url,
503
+ state: "signed-in",
504
+ issuer: flow.meta.issuer,
505
+ clientId: flow.client.id,
506
+ ...(flow.client.method !== "none" ? { tokenAuth: flow.client.method } : {}),
507
+ redirectUri: flow.redirectUri,
508
+ tokenEndpoint: flow.meta.tokenEndpoint,
509
+ ...(flow.meta.revocationEndpoint ? { revocationEndpoint: flow.meta.revocationEndpoint } : {}),
510
+ tokens,
511
+ });
512
+ return null;
513
+ }
514
+ catch {
515
+ return "Could not reach the sign-in server. Start again.";
516
+ }
517
+ }
518
+ finish(flow, phase, message) {
519
+ if (flow.status.phase !== "waiting")
520
+ return;
521
+ clearTimeout(flow.expiry);
522
+ flow.server.close();
523
+ flow.server.closeIdleConnections();
524
+ flow.status = { ...flow.status, phase, authorizationUrl: null, ...(message ? { message } : {}) };
525
+ }
526
+ }
527
+ const isUrlServer = (server) => "url" in server;
528
+ /** The servers a turn may mount: a URL server waiting for sign-in would
529
+ * only answer 401 mid-turn, so it stays out until someone signs in. */
530
+ export function withoutPendingSignIn(servers, manager) {
531
+ return Object.fromEntries(Object.entries(servers).filter(([name, server]) => !isUrlServer(server) || manager.authState(name, server.url) !== "needs-sign-in"));
532
+ }
533
+ /** Give each signed-in URL server a fresh bearer token, replacing any
534
+ * Authorization header it was configured with. A server whose token can
535
+ * no longer be had is left out. The input is not modified. */
536
+ export async function withMcpSignIn(servers, manager) {
537
+ const out = {};
538
+ for (const [name, server] of Object.entries(withoutPendingSignIn(servers, manager))) {
539
+ if (!isUrlServer(server) || manager.authState(name, server.url) !== "signed-in") {
540
+ out[name] = server;
541
+ continue;
542
+ }
543
+ const token = await manager.accessToken(name, server.url);
544
+ if (!token)
545
+ continue;
546
+ const headers = Object.fromEntries(Object.entries(server.headers).filter(([key]) => key.toLowerCase() !== "authorization"));
547
+ out[name] = { ...server, headers: { ...headers, Authorization: `Bearer ${token}` } };
548
+ }
549
+ return out;
550
+ }
@@ -2,8 +2,10 @@ import { augmentedPath } from "./env-path.js";
2
2
  import { PROVIDER_CREDENTIAL_ENV, stripWorkspaceCredentialEnv, } from "./config.js";
3
3
  import { createLineSplitter } from "./mcp-bridge.js";
4
4
  import { McpHttpError, RemoteMcpClient } from "./mcp-http.js";
5
+ import { discoverMcpAuth } from "./mcp-oauth-discovery.js";
5
6
  import { isRemoteMcpServer } from "./mcp-registry.js";
6
7
  import { killCliTree, spawnCli } from "./procs.js";
8
+ export const SIGN_IN_REQUIRED = "This server needs you to sign in.";
7
9
  const MAX_STDOUT_BYTES = 1_048_576;
8
10
  const MAX_TOOLS = 100;
9
11
  const DEFAULT_TIMEOUT_MS = 8_000;
@@ -76,13 +78,24 @@ async function probeRemoteMcpServer(server, timeoutMs, signal) {
76
78
  const tools = result && typeof result === "object" ? result.tools : undefined;
77
79
  if (!Array.isArray(tools))
78
80
  return { ok: false, error: "The server did not return a valid MCP tools list." };
79
- return { ok: true, tools: publicTools(tools, server.headers) };
81
+ const secrets = server.oauth?.clientSecret ? { ...server.headers, "oauth.clientSecret": server.oauth.clientSecret } : server.headers;
82
+ return { ok: true, tools: publicTools(tools, secrets) };
80
83
  }
81
84
  catch (error) {
82
85
  if (signal?.aborted)
83
86
  return { ok: false, error: publicProbeError("cancelled") };
84
87
  if (timeout.aborted)
85
88
  return { ok: false, error: publicProbeError("timeout") };
89
+ // A 401 that names an OAuth sign-in is not a wrong address or header:
90
+ // the person has to sign in. Anything else keeps the plain status.
91
+ if (error instanceof McpHttpError && error.status === 401) {
92
+ const discovery = AbortSignal.timeout(5_000);
93
+ const meta = await discoverMcpAuth(server.url, error.wwwAuthenticate ?? null, {
94
+ signal: signal ? AbortSignal.any([signal, discovery]) : discovery,
95
+ }).catch(() => null);
96
+ if (meta)
97
+ return { ok: false, auth: "required", error: SIGN_IN_REQUIRED };
98
+ }
86
99
  if (error instanceof McpHttpError && error.kind === "status") {
87
100
  return { ok: false, error: `The server answered HTTP ${error.status}. Check the address and headers.` };
88
101
  }
@@ -71,16 +71,52 @@ const stdioEntrySchema = z.object({
71
71
  const stdioMutationSchema = stdioEntrySchema.extend({
72
72
  env: z.record(z.string(), z.union([z.string().max(16_384), z.literal(true)])).optional(),
73
73
  });
74
+ /** RFC 6749 §A: a client id is visible ASCII; a scope is one token without
75
+ * spaces, quotes or backslashes. */
76
+ const CLIENT_ID = /^[\x20-\x7e]+$/;
77
+ const SCOPE = /^[\x21\x23-\x5b\x5d-\x7e]+$/;
78
+ const CLIENT_SECRET_MAX = 4_096;
79
+ const oauthClientSchema = z.object({
80
+ clientId: z.string().trim().min(1, "Enter the client ID of the app registered with this server's sign-in provider.").max(512)
81
+ .regex(CLIENT_ID, "A client ID is printable text on one line."),
82
+ clientSecret: z.string().min(1).max(CLIENT_SECRET_MAX).regex(CLIENT_ID, "A client secret is printable text on one line.").optional(),
83
+ scopes: z.array(z.string().max(256).regex(SCOPE, "Scopes are single words, like offline_access or api://my-app/mcp.read.")).max(32).optional(),
84
+ }).strict();
74
85
  const remoteEntrySchema = z.object({
75
86
  /** Streamable HTTP unless the entry says the older SSE transport. */
76
87
  type: z.enum(["http", "sse"]).optional(),
77
88
  url: z.string().trim().min(1).max(2_048),
78
89
  headers: z.record(z.string(), z.string().max(16_384)).optional(),
90
+ oauth: oauthClientSchema.optional(),
79
91
  enabled: z.boolean().optional(),
80
92
  }).strict();
81
93
  const remoteMutationSchema = remoteEntrySchema.extend({
82
94
  headers: z.record(z.string(), z.union([z.string().max(16_384), z.literal(true)])).optional(),
95
+ oauth: oauthClientSchema.extend({
96
+ clientSecret: z.union([z.string().min(1).max(CLIENT_SECRET_MAX).regex(CLIENT_ID, "A client secret is printable text on one line."), z.literal(true)]).optional(),
97
+ }).optional(),
83
98
  });
99
+ /** The sign-in app for a mutation: a `true` secret keeps the saved one, but
100
+ * only for the same client ID — a secret never moves to another app. */
101
+ function resolveOAuthClient(incoming, saved) {
102
+ if (!incoming)
103
+ return { ok: true, value: undefined };
104
+ let clientSecret;
105
+ if (incoming.clientSecret === true) {
106
+ if (!saved?.clientSecret || saved.clientId !== incoming.clientId) {
107
+ return { ok: false, error: "No client secret is saved for this client ID. Enter it again, or leave it out for an app without one." };
108
+ }
109
+ clientSecret = saved.clientSecret;
110
+ }
111
+ else {
112
+ clientSecret = incoming.clientSecret;
113
+ }
114
+ const scopes = [...new Set(incoming.scopes ?? [])];
115
+ return {
116
+ ok: true,
117
+ value: { clientId: incoming.clientId, ...(clientSecret ? { clientSecret } : {}), ...(scopes.length ? { scopes } : {}) },
118
+ };
119
+ }
84
120
  /** Which shape an entry means to be. `url` decides, as it does for every
85
121
  * client that reads these blocks; a `type` of http/sse without a url is
86
122
  * still a remote entry, so its error talks about the address. */
@@ -159,12 +195,16 @@ function parseRemote(raw, mutation, existing) {
159
195
  const invalidHeader = Object.entries(headers.values).map(([name, value]) => headerError(name, value)).find((error) => error !== null);
160
196
  if (invalidHeader)
161
197
  return { ok: false, error: invalidHeader };
198
+ const oauth = resolveOAuthClient(parsed.data.oauth, existing && isRemoteMcpServer(existing) ? existing.oauth : undefined);
199
+ if (!oauth.ok)
200
+ return oauth;
162
201
  return {
163
202
  ok: true,
164
203
  server: {
165
204
  type: parsed.data.type ?? "http",
166
205
  url: parsed.data.url,
167
206
  headers: headers.values,
207
+ ...(oauth.value ? { oauth: oauth.value } : {}),
168
208
  enabled: enabledFor(parsed.data.enabled, mutation, existing),
169
209
  },
170
210
  };
@@ -204,6 +244,9 @@ export function listMcpServers(raw) {
204
244
  url: server.url,
205
245
  headerKeys: Object.keys(server.headers).sort(),
206
246
  enabled: server.enabled,
247
+ ...(server.oauth ? {
248
+ oauth: { clientId: server.oauth.clientId, scopes: server.oauth.scopes ?? [], clientSecretConfigured: Boolean(server.oauth.clientSecret) },
249
+ } : {}),
207
250
  }];
208
251
  }
209
252
  return [{