@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.
- package/.github/workflows/build.yml +60 -8
- package/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/desktop/README.md +39 -0
- package/desktop/loading/index.html +36 -0
- package/desktop/src-tauri/Cargo.lock +4301 -0
- package/desktop/src-tauri/Cargo.toml +23 -0
- package/desktop/src-tauri/binaries/.gitkeep +0 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/capabilities/default.json +7 -0
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/lib.rs +210 -0
- package/desktop/src-tauri/src/main.rs +6 -0
- package/desktop/src-tauri/tauri.conf.json +48 -0
- package/dist/assets/index-CeN54A2N.css +1 -0
- package/dist/assets/index-fhIO9a7_.js +318 -0
- package/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +2 -1
- package/server/README.md +2 -2
- package/server/daemon.mjs +138 -2
- package/server/lib/mcp-oauth.mjs +219 -0
- package/server/lib/mcp-oauth.test.mjs +175 -0
- package/server/lib/mcp.mjs +899 -0
- package/server/lib/mcp.test.mjs +46 -0
- package/server/lib/store.mjs +22 -0
- package/server/lib/tools.mjs +59 -1
- package/src/App.tsx +2 -0
- package/src/agent/tools.ts +6 -5
- package/src/components/CommandPalette.tsx +14 -1
- package/src/components/Composer.tsx +22 -38
- package/src/components/DiffView.tsx +98 -0
- package/src/components/Logo.tsx +1 -1
- package/src/components/McpPanel.tsx +687 -0
- package/src/components/MessageItem.tsx +62 -13
- package/src/components/ModelDeck.tsx +661 -0
- package/src/components/SessionsTab.tsx +206 -0
- package/src/components/SettingsModal.tsx +146 -71
- package/src/components/Sidebar.tsx +79 -24
- package/src/components/Splash.tsx +1 -1
- package/src/components/ToolCallCard.tsx +29 -7
- package/src/components/WorkspacePanel.tsx +16 -1
- package/src/core/kernel.ts +2 -0
- package/src/core/mcp-tools.ts +63 -0
- package/src/core/plugins/tools/fs.ts +24 -3
- package/src/core/services/agent-loop.ts +3 -1
- package/src/core/services/approval.ts +2 -1
- package/src/core/services/titles.ts +135 -0
- package/src/core/services/tools.ts +11 -8
- package/src/core/types.ts +4 -0
- package/src/data/providers.ts +1 -1
- package/src/index.css +97 -1
- package/src/lib/daemon.ts +38 -0
- package/src/lib/diff.ts +62 -0
- package/src/lib/mcp-config.test.mjs +47 -0
- package/src/lib/mcp-config.ts +203 -0
- package/src/lib/permissions.ts +6 -6
- package/src/store/chats.ts +9 -1
- package/src/store/mcp.ts +94 -0
- package/src/store/settings.ts +10 -7
- package/src/store/ui.ts +17 -0
- package/src/store/workspace.ts +26 -0
- package/src/types.ts +63 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-BmN7pBji.css +0 -1
- package/dist/assets/index-DgNDjkOz.js +0 -301
- 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
|
|
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
|
|
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-
|
|
35
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
|
|
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
|
+
}
|