@bibobonking/opensol 3.0.3 → 3.1.1

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 (83) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +2 -2
  4. package/desktop/README.md +39 -0
  5. package/desktop/loading/index.html +36 -0
  6. package/desktop/src-tauri/Cargo.lock +4301 -0
  7. package/desktop/src-tauri/Cargo.toml +23 -0
  8. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  9. package/desktop/src-tauri/build.rs +3 -0
  10. package/desktop/src-tauri/capabilities/default.json +7 -0
  11. package/desktop/src-tauri/icons/128x128.png +0 -0
  12. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  13. package/desktop/src-tauri/icons/32x32.png +0 -0
  14. package/desktop/src-tauri/icons/64x64.png +0 -0
  15. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  23. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  24. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  25. package/desktop/src-tauri/icons/icon.icns +0 -0
  26. package/desktop/src-tauri/icons/icon.ico +0 -0
  27. package/desktop/src-tauri/icons/icon.png +0 -0
  28. package/desktop/src-tauri/src/lib.rs +210 -0
  29. package/desktop/src-tauri/src/main.rs +6 -0
  30. package/desktop/src-tauri/tauri.conf.json +48 -0
  31. package/dist/assets/index-CeN54A2N.css +1 -0
  32. package/dist/assets/index-fhIO9a7_.js +318 -0
  33. package/dist/index.html +4 -4
  34. package/index.html +2 -2
  35. package/package.json +2 -1
  36. package/server/README.md +2 -2
  37. package/server/daemon.mjs +138 -2
  38. package/server/lib/mcp-oauth.mjs +219 -0
  39. package/server/lib/mcp-oauth.test.mjs +175 -0
  40. package/server/lib/mcp.mjs +899 -0
  41. package/server/lib/mcp.test.mjs +46 -0
  42. package/server/lib/store.mjs +22 -0
  43. package/server/lib/tools.mjs +59 -1
  44. package/src/App.tsx +2 -0
  45. package/src/agent/tools.ts +6 -5
  46. package/src/components/CommandPalette.tsx +14 -1
  47. package/src/components/Composer.tsx +22 -38
  48. package/src/components/DiffView.tsx +98 -0
  49. package/src/components/Logo.tsx +1 -1
  50. package/src/components/McpPanel.tsx +687 -0
  51. package/src/components/MessageItem.tsx +62 -13
  52. package/src/components/ModelDeck.tsx +661 -0
  53. package/src/components/SessionsTab.tsx +206 -0
  54. package/src/components/SettingsModal.tsx +146 -71
  55. package/src/components/Sidebar.tsx +79 -24
  56. package/src/components/Splash.tsx +1 -1
  57. package/src/components/ToolCallCard.tsx +29 -7
  58. package/src/components/WorkspacePanel.tsx +16 -1
  59. package/src/core/kernel.ts +2 -0
  60. package/src/core/mcp-tools.ts +63 -0
  61. package/src/core/plugins/tools/fs.ts +24 -3
  62. package/src/core/services/agent-loop.ts +3 -1
  63. package/src/core/services/approval.ts +2 -1
  64. package/src/core/services/titles.ts +135 -0
  65. package/src/core/services/tools.ts +11 -8
  66. package/src/core/types.ts +4 -0
  67. package/src/data/providers.ts +1 -1
  68. package/src/index.css +97 -1
  69. package/src/lib/daemon.ts +38 -0
  70. package/src/lib/diff.ts +62 -0
  71. package/src/lib/mcp-config.test.mjs +47 -0
  72. package/src/lib/mcp-config.ts +203 -0
  73. package/src/lib/permissions.ts +6 -6
  74. package/src/store/chats.ts +9 -1
  75. package/src/store/mcp.ts +94 -0
  76. package/src/store/settings.ts +10 -7
  77. package/src/store/ui.ts +17 -0
  78. package/src/store/workspace.ts +26 -0
  79. package/src/types.ts +63 -2
  80. package/tsconfig.tsbuildinfo +1 -1
  81. package/dist/assets/index-BmN7pBji.css +0 -1
  82. package/dist/assets/index-DgNDjkOz.js +0 -301
  83. package/src/components/ModelPicker.tsx +0 -487
package/dist/index.html CHANGED
@@ -6,9 +6,9 @@
6
6
  <meta name="theme-color" content="#ffffff" />
7
7
  <meta
8
8
  name="description"
9
- content="OpenSol by OpenSoft is a local-first coding agent. Work in a real folder, run commands, edit files and deploy with your own model keys. Keys stay encrypted on your machine."
9
+ content="OpenSol by OpenSoftX is a local-first coding agent. Work in a real folder, run commands, edit files and deploy with your own model keys. Keys stay encrypted on your machine."
10
10
  />
11
- <title>OpenSol — Local coding agent by OpenSoft</title>
11
+ <title>OpenSol — Local coding agent by OpenSoftX</title>
12
12
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -31,8 +31,8 @@
31
31
  if (DARK.includes(theme)) root.classList.add('dark')
32
32
  } catch {}
33
33
  </script>
34
- <script type="module" crossorigin src="./assets/index-DgNDjkOz.js"></script>
35
- <link rel="stylesheet" crossorigin href="./assets/index-BmN7pBji.css">
34
+ <script type="module" crossorigin src="./assets/index-fhIO9a7_.js"></script>
35
+ <link rel="stylesheet" crossorigin href="./assets/index-CeN54A2N.css">
36
36
  </head>
37
37
  <body>
38
38
  <div id="root"><div class="splash" id="boot-splash" aria-hidden="true">
package/index.html CHANGED
@@ -6,9 +6,9 @@
6
6
  <meta name="theme-color" content="#ffffff" />
7
7
  <meta
8
8
  name="description"
9
- content="OpenSol by OpenSoft is a local-first coding agent. Work in a real folder, run commands, edit files and deploy with your own model keys. Keys stay encrypted on your machine."
9
+ content="OpenSol by OpenSoftX is a local-first coding agent. Work in a real folder, run commands, edit files and deploy with your own model keys. Keys stay encrypted on your machine."
10
10
  />
11
- <title>OpenSol — Local coding agent by OpenSoft</title>
11
+ <title>OpenSol — Local coding agent by OpenSoftX</title>
12
12
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bibobonking/opensol",
3
3
  "private": false,
4
- "version": "3.0.3",
4
+ "version": "3.1.1",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opensol": "./server/opensol.mjs"
@@ -9,6 +9,7 @@
9
9
  "scripts": {
10
10
  "dev": "node server/opensol.mjs start && vite",
11
11
  "build": "tsc -b && vite build",
12
+ "artifacts": "node scripts/pull-artifacts.mjs",
12
13
  "preview": "vite preview",
13
14
  "typecheck": "tsc -b --pretty",
14
15
  "test": "node --test",
package/server/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenSol
2
2
 
3
- A local-first AI coding agent by OpenSoft. OpenSol pairs a warm React chat interface with a zero-dependency Node daemon that owns the real workspace, shell, git, search, deploy and provider credentials on your machine.
3
+ A local-first AI coding agent by OpenSoftX. OpenSol pairs a warm React chat interface with a zero-dependency Node daemon that owns the real workspace, shell, git, search, deploy and provider credentials on your machine.
4
4
 
5
5
  The daemon binds to `127.0.0.1` only. It serves the prebuilt app from `dist/` and stores everything under `~/.maxcode`. API keys and hosting tokens are encrypted with AES-256-GCM using a local `master.key`; the browser only ever receives a masked hint.
6
6
 
@@ -61,7 +61,7 @@ OpenCode free-tier models only work inside OpenCode itself, so they are hidden h
61
61
  - `secrets.enc` and `master.key` are created with mode `600`.
62
62
  - All API routes require the local session cookie and a CSRF header; Host and Origin are checked.
63
63
  - The daemon rejects code execution outside the selected workspace except for an explicit, existing deploy folder.
64
- - No telemetry or OpenSoft-hosted proxy. Provider requests go directly from the daemon to the provider you configured.
64
+ - No telemetry or OpenSoftX-hosted proxy. Provider requests go directly from the daemon to the provider you configured.
65
65
 
66
66
  ## Stack
67
67
 
package/server/daemon.mjs CHANGED
@@ -18,16 +18,20 @@ import {
18
18
  streamFile,
19
19
  } from './lib/http.mjs'
20
20
  import {
21
+ saveSecrets,
21
22
  addWorkspace,
22
23
  DATA_DIR,
23
24
  dataPaths,
24
25
  forgetWorkspace,
26
+ getMcpServers,
25
27
  loadSecretsSafe,
26
28
  loadState,
27
29
  migrateLegacy,
28
30
  resetAll,
31
+ removeWorkspace,
29
32
  resetSecrets,
30
33
  saveChats,
34
+ saveMcpServers,
31
35
  saveSettings,
32
36
  saveState,
33
37
  secretSummary,
@@ -75,10 +79,11 @@ import {
75
79
  setPluginSafe,
76
80
  uninstallPlugin,
77
81
  } from './lib/plugins.mjs'
82
+ import { mcp, sanitizeServerConfig } from './lib/mcp.mjs'
78
83
 
79
84
  const HERE = path.dirname(fileURLToPath(import.meta.url))
80
85
  const DIST = path.resolve(HERE, '..', 'dist')
81
- const VERSION = '1.0.0'
86
+ const VERSION = '3.1.0'
82
87
  const SESSION_COOKIE = 'maxcode_session'
83
88
  const CSRF_HEADER = 'x-maxcode-csrf'
84
89
  const SESSION_TTL = 14 * 24 * 60 * 60 * 1000
@@ -220,6 +225,12 @@ function sanitizeSettings(patch, current = {}) {
220
225
  // Plugin themes are named by their plugin, so accept any bounded theme slug.
221
226
  if (typeof source.theme === 'string' && /^[a-z0-9-]{1,64}$/.test(source.theme)) next.theme = source.theme
222
227
  if (source.mode === 'build' || source.mode === 'plan') next.mode = source.mode
228
+ if (typeof source.aiSessionTitles === 'boolean') next.aiSessionTitles = source.aiSessionTitles
229
+ if (typeof source.autoCollapseWork === 'boolean') next.autoCollapseWork = source.autoCollapseWork
230
+ if (source.workDetail === 'concise' || source.workDetail === 'detailed') next.workDetail = source.workDetail
231
+ if (typeof source.chatFontSize === 'number' && Number.isFinite(source.chatFontSize)) {
232
+ next.chatFontSize = Math.max(12, Math.min(20, Math.round(source.chatFontSize)))
233
+ }
223
234
 
224
235
  if (source.providers && typeof source.providers === 'object') {
225
236
  const providers = { ...(current.providers ?? {}) }
@@ -535,12 +546,53 @@ async function handlePreview(req, res, rawPath) {
535
546
  streamFile(req, res, target.absolute, info.size, { 'Cache-Control': 'no-store' })
536
547
  }
537
548
 
549
+ function oauthResultPage(ok, message) {
550
+ const safe = String(message).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
551
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OpenSol · MCP sign-in</title><style>body{margin:0;min-height:100dvh;display:grid;place-items:center;font:15px system-ui,sans-serif;background:#faf9f5;color:#1f1e1d}main{max-width:360px;padding:32px;text-align:center}h1{font-size:20px;margin:0 0 8px}p{margin:0;color:#6b6a68;line-height:1.5}.dot{width:44px;height:44px;border-radius:50%;margin:0 auto 16px;display:grid;place-items:center;color:#fff;font-size:22px;background:${ok ? '#2f9e6b' : '#c4443a'}}@media(prefers-color-scheme:dark){body{background:#141413;color:#f2f1ee}p{color:#a5a39e}}</style></head><body><main><div class="dot">${ok ? '✓' : '!'}</div><h1>${ok ? 'Signed in' : 'Sign-in failed'}</h1><p>${safe}</p></main><script>try{var c=new BroadcastChannel('opensol-mcp-oauth');c.postMessage({ok:${ok ? 'true' : 'false'}});c.close()}catch(e){}${ok ? 'setTimeout(function(){window.close()},900)' : ''}</script></body></html>`
552
+ }
553
+
554
+ async function handleMcpOAuthCallback(req, res, url) {
555
+ const hostHeader = String(req.headers.host ?? '').toLowerCase()
556
+ if (!hostHeader || !allowedHosts().has(hostHeader)) {
557
+ throw new HttpError(403, 'OpenSol only answers requests addressed to localhost.', 'bad_host')
558
+ }
559
+ const send = (status, ok, message) => {
560
+ const body = Buffer.from(oauthResultPage(ok, message))
561
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': body.length, 'Cache-Control': 'no-store' })
562
+ res.end(body)
563
+ }
564
+ const state = url.searchParams.get('state') ?? ''
565
+ const providerError = url.searchParams.get('error')
566
+ if (providerError) {
567
+ mcp.pendingAuth.take(state)
568
+ send(400, false, url.searchParams.get('error_description') || providerError)
569
+ return
570
+ }
571
+ const code = url.searchParams.get('code') ?? ''
572
+ if (!state || !code) {
573
+ send(400, false, 'The authorization server did not return a code.')
574
+ return
575
+ }
576
+ try {
577
+ const config = await mcp.finishAuth(state, code)
578
+ send(200, true, `${config.name} is connected. You can close this window.`)
579
+ } catch (error) {
580
+ send(error instanceof HttpError ? error.status : 500, false, error instanceof Error ? error.message : 'Sign-in failed.')
581
+ }
582
+ }
583
+
538
584
  async function route(req, res) {
539
585
  const rawPath = (req.url ?? '/').split('?')[0]
540
586
  if (rawPath.startsWith('/preview/')) {
541
587
  await handlePreview(req, res, rawPath)
542
588
  return
543
589
  }
590
+ if (rawPath === '/oauth/mcp/callback' && req.method === 'GET') {
591
+ // The browser arrives here from the authorization server, so Sec-Fetch-Site is cross-site.
592
+ // The one-time `state` created by an authenticated request is the CSRF protection.
593
+ await handleMcpOAuthCallback(req, res, new URL(req.url ?? '/', `http://${req.headers.host}`))
594
+ return
595
+ }
544
596
  guardRequest(req)
545
597
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
546
598
  const pathname = url.pathname
@@ -747,13 +799,79 @@ async function route(req, res) {
747
799
  return
748
800
  }
749
801
  if (method === 'DELETE') {
750
- await forgetWorkspace()
802
+ const target = url.searchParams.get('path')
803
+ if (target) await removeWorkspace(target)
804
+ else await forgetWorkspace()
751
805
  json(res, 200, await workspacePayload())
752
806
  return
753
807
  }
754
808
  throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
755
809
  }
756
810
 
811
+ if (pathname === '/api/mcp' && method === 'GET') {
812
+ json(res, 200, { servers: mcp.list() })
813
+ return
814
+ }
815
+
816
+ if (pathname === '/api/mcp' && method === 'PUT') {
817
+ const body = await readJson(req, 512 * 1024)
818
+ const incoming = Array.isArray(body.servers) ? body.servers.slice(0, 30) : []
819
+ const previous = new Map((await getMcpServers()).map((item) => [item.id, item]))
820
+ const seen = new Set()
821
+ const configs = incoming.map((item) => {
822
+ let config = sanitizeServerConfig(item, previous.get(item?.id))
823
+ let suffix = 2
824
+ const base = config.id
825
+ while (seen.has(config.id)) config = { ...config, id: `${base}-${suffix++}` }
826
+ seen.add(config.id)
827
+ return config
828
+ })
829
+ await saveMcpServers(configs)
830
+ // Tokens belong to one server URL: drop them when the server is removed, moved, or OAuth is turned off.
831
+ for (const [id, old] of previous) {
832
+ const next = configs.find((config) => config.id === id)
833
+ if (!next || next.url !== old.url || !next.oauth?.enabled) await mcp.authStore.delete(id).catch(() => undefined)
834
+ }
835
+ await mcp.setConfigs(configs)
836
+ for (const config of configs) {
837
+ const runtime = mcp.runtime.get(config.id)
838
+ if (config.enabled && (!runtime || runtime.status === 'disconnected')) void mcp.connect(config.id)
839
+ if (!config.enabled && runtime?.status === 'connected') await mcp.disconnect(config.id)
840
+ }
841
+ json(res, 200, { servers: mcp.list() })
842
+ return
843
+ }
844
+
845
+ const mcpRoute = /^\/api\/mcp\/([a-z0-9][a-z0-9-]{0,39})\/(connect|disconnect|refresh)$/.exec(pathname)
846
+ if (mcpRoute && method === 'POST') {
847
+ const [, id, action] = mcpRoute
848
+ if (action === 'connect') await mcp.connect(id)
849
+ else if (action === 'disconnect') await mcp.disconnect(id)
850
+ else await mcp.refresh(id)
851
+ json(res, 200, { servers: mcp.list() })
852
+ return
853
+ }
854
+
855
+ const mcpAuthRoute = /^\/api\/mcp\/([a-z0-9][a-z0-9-]{0,39})\/(authorize|logout)$/.exec(pathname)
856
+ if (mcpAuthRoute && method === 'POST') {
857
+ const [, id, action] = mcpAuthRoute
858
+ if (action === 'authorize') {
859
+ const redirectUri = `http://${req.headers.host}/oauth/mcp/callback`
860
+ json(res, 200, await mcp.startAuth(id, redirectUri))
861
+ } else {
862
+ await mcp.signOut(id)
863
+ json(res, 200, { servers: mcp.list() })
864
+ }
865
+ return
866
+ }
867
+
868
+ if (pathname === '/api/mcp/call' && method === 'POST') {
869
+ const body = await readJson(req, 4 * 1024 * 1024)
870
+ const result = await mcp.callTool(String(body.server ?? ''), String(body.tool ?? ''), body.args ?? {})
871
+ json(res, 200, { result })
872
+ return
873
+ }
874
+
757
875
  if (pathname === '/api/workspaces/browse') {
758
876
  if (method === 'GET') {
759
877
  json(res, 200, await browseDirectories(url.searchParams.get('path') ?? ''))
@@ -988,6 +1106,23 @@ async function findPort(start) {
988
1106
 
989
1107
  export async function startServer({ port: requested = 4174, open = false } = {}) {
990
1108
  await mkdir(DATA_DIR, { recursive: true, mode: 0o700 })
1109
+ mcp.getCwd = async () => (await workspaceRoot().catch(() => '')) || os.homedir()
1110
+ // OAuth clients and tokens live in the encrypted secrets file, never in state.json.
1111
+ mcp.authStore = {
1112
+ get: async (id) => (await loadSecrets()).mcpOAuth?.[id] ?? null,
1113
+ set: async (id, value) => {
1114
+ const secrets = await loadSecrets()
1115
+ await saveSecrets({ ...secrets, mcpOAuth: { ...(secrets.mcpOAuth ?? {}), [id]: value } })
1116
+ },
1117
+ delete: async (id) => {
1118
+ const secrets = await loadSecrets()
1119
+ if (!secrets.mcpOAuth?.[id]) return
1120
+ const { [id]: _removed, ...rest } = secrets.mcpOAuth
1121
+ await saveSecrets({ ...secrets, mcpOAuth: rest })
1122
+ },
1123
+ }
1124
+ await mcp.setConfigs(await getMcpServers())
1125
+ void mcp.connectAll().catch(() => undefined)
991
1126
  port = requested === 0 ? await findPort(4174) : requested
992
1127
  const server = http.createServer((req, res) => {
993
1128
  handler(req, res)
@@ -1008,6 +1143,7 @@ export async function startServer({ port: requested = 4174, open = false } = {})
1008
1143
  const shutdown = async (signal) => {
1009
1144
  console.log(`[opensol] ${signal} received, shutting down.`)
1010
1145
  server.close()
1146
+ await mcp.shutdown().catch(() => undefined)
1011
1147
  await rm(pidFile, { force: true }).catch(() => undefined)
1012
1148
  process.exit(0)
1013
1149
  }
@@ -0,0 +1,219 @@
1
+ // OAuth 2.1 for remote MCP servers: protected-resource + authorization-server discovery,
2
+ // dynamic client registration (RFC 7591), PKCE (S256) and token refresh. Zero dependencies.
3
+ // Tokens never reach the browser; the daemon keeps them in the encrypted secrets file.
4
+ import { createHash, randomBytes } from 'node:crypto'
5
+
6
+ const FETCH_TIMEOUT_MS = 15_000
7
+ const PENDING_TTL_MS = 10 * 60_000
8
+
9
+ export class AuthRequiredError extends Error {
10
+ constructor(wwwAuthenticate = '') {
11
+ super('This MCP server needs you to sign in.')
12
+ this.name = 'AuthRequiredError'
13
+ this.wwwAuthenticate = wwwAuthenticate
14
+ }
15
+ }
16
+
17
+ const b64url = (buffer) => buffer.toString('base64url')
18
+
19
+ async function fetchJson(url, init = {}) {
20
+ const response = await fetch(url, {
21
+ ...init,
22
+ headers: { Accept: 'application/json', ...(init.headers ?? {}) },
23
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
24
+ })
25
+ const text = await response.text()
26
+ let body = null
27
+ try {
28
+ body = text ? JSON.parse(text) : null
29
+ } catch {
30
+ body = null
31
+ }
32
+ return { response, body, text }
33
+ }
34
+
35
+ function assertHttp(value, label) {
36
+ let parsed
37
+ try {
38
+ parsed = new URL(value)
39
+ } catch {
40
+ throw new Error(`${label} is not a valid URL.`)
41
+ }
42
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(`${label} must use http or https.`)
43
+ return parsed
44
+ }
45
+
46
+ /** Reads `resource_metadata="…"` from a WWW-Authenticate header. */
47
+ export function parseResourceMetadataUrl(header) {
48
+ const match = /resource_metadata="?([^",\s]+)"?/i.exec(header ?? '')
49
+ return match ? match[1] : ''
50
+ }
51
+
52
+ function wellKnownCandidates(base, name) {
53
+ const parsed = new URL(base)
54
+ const out = []
55
+ const pathPart = parsed.pathname.replace(/\/+$/, '')
56
+ if (pathPart) out.push(`${parsed.origin}/.well-known/${name}${pathPart}`)
57
+ out.push(`${parsed.origin}/.well-known/${name}`)
58
+ return out
59
+ }
60
+
61
+ async function firstJson(urls, accept = (body) => body && typeof body === 'object') {
62
+ for (const url of urls) {
63
+ try {
64
+ const { response, body } = await fetchJson(url)
65
+ if (response.ok && accept(body)) return body
66
+ } catch {
67
+ // Try the next candidate.
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ /** Finds the authorization server metadata that protects an MCP server URL. */
74
+ export async function discover(serverUrl, wwwAuthenticate = '') {
75
+ assertHttp(serverUrl, 'The MCP server URL')
76
+ const hinted = parseResourceMetadataUrl(wwwAuthenticate)
77
+ const resourceUrls = [...(hinted ? [hinted] : []), ...wellKnownCandidates(serverUrl, 'oauth-protected-resource')]
78
+ const resource = await firstJson(resourceUrls, (body) => body && Array.isArray(body.authorization_servers))
79
+ const issuer = resource?.authorization_servers?.[0] || new URL(serverUrl).origin
80
+ assertHttp(issuer, 'The authorization server')
81
+ const metadata =
82
+ (await firstJson(
83
+ [
84
+ ...wellKnownCandidates(issuer, 'oauth-authorization-server'),
85
+ ...wellKnownCandidates(issuer, 'openid-configuration'),
86
+ `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`,
87
+ ],
88
+ (body) => body && typeof body.authorization_endpoint === 'string' && typeof body.token_endpoint === 'string',
89
+ )) ?? null
90
+ if (metadata) return { ...metadata, resource: resource?.resource || serverUrl }
91
+ // Servers without metadata fall back to the conventional endpoints on the issuer origin.
92
+ const origin = new URL(issuer).origin
93
+ return {
94
+ issuer: origin,
95
+ authorization_endpoint: `${origin}/authorize`,
96
+ token_endpoint: `${origin}/token`,
97
+ registration_endpoint: `${origin}/register`,
98
+ resource: resource?.resource || serverUrl,
99
+ fallback: true,
100
+ }
101
+ }
102
+
103
+ export async function registerClient(metadata, redirectUri, clientName) {
104
+ if (!metadata.registration_endpoint) {
105
+ throw new Error('This server does not support automatic sign-in setup. Enter an OAuth client ID in the server settings.')
106
+ }
107
+ const { response, body, text } = await fetchJson(metadata.registration_endpoint, {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({
111
+ client_name: clientName,
112
+ redirect_uris: [redirectUri],
113
+ grant_types: ['authorization_code', 'refresh_token'],
114
+ response_types: ['code'],
115
+ token_endpoint_auth_method: 'none',
116
+ }),
117
+ })
118
+ if (!response.ok || !body?.client_id) {
119
+ throw new Error(`Client registration failed (${response.status}). ${(body?.error_description || text || '').slice(0, 200)}`.trim())
120
+ }
121
+ return { clientId: String(body.client_id), clientSecret: body.client_secret ? String(body.client_secret) : '' }
122
+ }
123
+
124
+ export function createPkce() {
125
+ const verifier = b64url(randomBytes(48))
126
+ const challenge = b64url(createHash('sha256').update(verifier).digest())
127
+ return { verifier, challenge }
128
+ }
129
+
130
+ export function buildAuthorizationUrl({ metadata, clientId, redirectUri, state, challenge, scope, resource }) {
131
+ const url = new URL(metadata.authorization_endpoint)
132
+ url.searchParams.set('response_type', 'code')
133
+ url.searchParams.set('client_id', clientId)
134
+ url.searchParams.set('redirect_uri', redirectUri)
135
+ url.searchParams.set('state', state)
136
+ url.searchParams.set('code_challenge', challenge)
137
+ url.searchParams.set('code_challenge_method', 'S256')
138
+ if (scope) url.searchParams.set('scope', scope)
139
+ if (resource) url.searchParams.set('resource', resource)
140
+ return url.toString()
141
+ }
142
+
143
+ async function tokenRequest(metadata, client, params, resource) {
144
+ const form = new URLSearchParams({ ...params, client_id: client.clientId })
145
+ if (resource) form.set('resource', resource)
146
+ const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }
147
+ if (client.clientSecret) {
148
+ const methods = metadata.token_endpoint_auth_methods_supported
149
+ if (Array.isArray(methods) && !methods.includes('client_secret_post') && methods.includes('client_secret_basic')) {
150
+ headers.Authorization = `Basic ${Buffer.from(`${client.clientId}:${client.clientSecret}`).toString('base64')}`
151
+ } else {
152
+ form.set('client_secret', client.clientSecret)
153
+ }
154
+ }
155
+ const { response, body, text } = await fetchJson(metadata.token_endpoint, { method: 'POST', headers, body: form.toString() })
156
+ if (!response.ok || !body?.access_token) {
157
+ const detail = body?.error_description || body?.error || text || ''
158
+ const error = new Error(`Token request failed (${response.status}). ${String(detail).slice(0, 200)}`.trim())
159
+ error.status = response.status
160
+ error.oauthError = body?.error
161
+ throw error
162
+ }
163
+ return normalizeTokens(body)
164
+ }
165
+
166
+ function normalizeTokens(body, previous) {
167
+ const expiresIn = Number(body.expires_in)
168
+ return {
169
+ accessToken: String(body.access_token),
170
+ refreshToken: body.refresh_token ? String(body.refresh_token) : previous?.refreshToken || '',
171
+ tokenType: body.token_type ? String(body.token_type) : 'Bearer',
172
+ scope: typeof body.scope === 'string' ? body.scope : previous?.scope || '',
173
+ expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1000 : 0,
174
+ }
175
+ }
176
+
177
+ export function exchangeCode({ metadata, client, code, verifier, redirectUri, resource }) {
178
+ return tokenRequest(metadata, client, {
179
+ grant_type: 'authorization_code',
180
+ code,
181
+ code_verifier: verifier,
182
+ redirect_uri: redirectUri,
183
+ }, resource)
184
+ }
185
+
186
+ export async function refreshTokens({ metadata, client, tokens, resource }) {
187
+ if (!tokens?.refreshToken) throw new Error('No refresh token; sign in again.')
188
+ const next = await tokenRequest(metadata, client, { grant_type: 'refresh_token', refresh_token: tokens.refreshToken }, resource)
189
+ return { ...next, refreshToken: next.refreshToken || tokens.refreshToken }
190
+ }
191
+
192
+ export const tokenIsFresh = (tokens, skewMs = 60_000) =>
193
+ Boolean(tokens?.accessToken) && (!tokens.expiresAt || tokens.expiresAt - skewMs > Date.now())
194
+
195
+ /** In-flight authorization requests keyed by the OAuth `state` value. */
196
+ export class PendingAuthorizations {
197
+ constructor() {
198
+ this.map = new Map()
199
+ }
200
+
201
+ create(entry) {
202
+ this.#purge()
203
+ const state = b64url(randomBytes(24))
204
+ this.map.set(state, { ...entry, createdAt: Date.now() })
205
+ return state
206
+ }
207
+
208
+ take(state) {
209
+ this.#purge()
210
+ const entry = this.map.get(state)
211
+ if (entry) this.map.delete(state)
212
+ return entry ?? null
213
+ }
214
+
215
+ #purge() {
216
+ const cutoff = Date.now() - PENDING_TTL_MS
217
+ for (const [key, value] of this.map) if (value.createdAt < cutoff) this.map.delete(key)
218
+ }
219
+ }