@bibobonking/opensol 3.1.0 → 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/CHANGELOG.md +4 -1
- package/README.md +2 -2
- package/desktop/src-tauri/Cargo.toml +1 -1
- 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 +1 -1
- package/server/README.md +2 -2
- package/server/daemon.mjs +74 -0
- package/server/lib/mcp-oauth.mjs +219 -0
- package/server/lib/mcp-oauth.test.mjs +175 -0
- package/server/lib/mcp.mjs +404 -37
- package/src/App.tsx +2 -0
- package/src/components/CommandPalette.tsx +14 -1
- package/src/components/Logo.tsx +1 -1
- package/src/components/McpPanel.tsx +687 -0
- package/src/components/ModelDeck.tsx +12 -3
- package/src/components/SettingsModal.tsx +43 -5
- package/src/components/Sidebar.tsx +2 -1
- package/src/components/Splash.tsx +1 -1
- package/src/data/providers.ts +1 -1
- package/src/lib/daemon.ts +10 -1
- package/src/lib/mcp-config.test.mjs +47 -0
- package/src/lib/mcp-config.ts +203 -0
- package/src/store/mcp.ts +28 -1
- package/src/store/settings.ts +1 -1
- package/src/store/ui.ts +4 -0
- package/src/types.ts +14 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-DdI1_iPD.css +0 -1
- package/dist/assets/index-egG5Bgfo.js +0 -312
- package/src/components/McpTab.tsx +0 -337
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
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,6 +18,7 @@ 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,
|
|
@@ -545,12 +546,53 @@ async function handlePreview(req, res, rawPath) {
|
|
|
545
546
|
streamFile(req, res, target.absolute, info.size, { 'Cache-Control': 'no-store' })
|
|
546
547
|
}
|
|
547
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
|
+
|
|
548
584
|
async function route(req, res) {
|
|
549
585
|
const rawPath = (req.url ?? '/').split('?')[0]
|
|
550
586
|
if (rawPath.startsWith('/preview/')) {
|
|
551
587
|
await handlePreview(req, res, rawPath)
|
|
552
588
|
return
|
|
553
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
|
+
}
|
|
554
596
|
guardRequest(req)
|
|
555
597
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
|
556
598
|
const pathname = url.pathname
|
|
@@ -785,6 +827,11 @@ async function route(req, res) {
|
|
|
785
827
|
return config
|
|
786
828
|
})
|
|
787
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
|
+
}
|
|
788
835
|
await mcp.setConfigs(configs)
|
|
789
836
|
for (const config of configs) {
|
|
790
837
|
const runtime = mcp.runtime.get(config.id)
|
|
@@ -805,6 +852,19 @@ async function route(req, res) {
|
|
|
805
852
|
return
|
|
806
853
|
}
|
|
807
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
|
+
|
|
808
868
|
if (pathname === '/api/mcp/call' && method === 'POST') {
|
|
809
869
|
const body = await readJson(req, 4 * 1024 * 1024)
|
|
810
870
|
const result = await mcp.callTool(String(body.server ?? ''), String(body.tool ?? ''), body.args ?? {})
|
|
@@ -1047,6 +1107,20 @@ async function findPort(start) {
|
|
|
1047
1107
|
export async function startServer({ port: requested = 4174, open = false } = {}) {
|
|
1048
1108
|
await mkdir(DATA_DIR, { recursive: true, mode: 0o700 })
|
|
1049
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
|
+
}
|
|
1050
1124
|
await mcp.setConfigs(await getMcpServers())
|
|
1051
1125
|
void mcp.connectAll().catch(() => undefined)
|
|
1052
1126
|
port = requested === 0 ? await findPort(4174) : requested
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import http from 'node:http'
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import { McpManager, sanitizeServerConfig } from './mcp.mjs'
|
|
6
|
+
|
|
7
|
+
function readBody(req) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
let data = ''
|
|
10
|
+
req.on('data', (chunk) => (data += chunk))
|
|
11
|
+
req.on('end', () => resolve(data))
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One fake host: OAuth endpoints, a Streamable HTTP MCP endpoint and a legacy SSE endpoint. */
|
|
16
|
+
async function fakeServer() {
|
|
17
|
+
const seen = { registrations: 0, refreshes: 0, challenge: '', code: 'code-1' }
|
|
18
|
+
const sseClients = []
|
|
19
|
+
let accessToken = 'access-1'
|
|
20
|
+
const rpc = (message) => {
|
|
21
|
+
if (message.method === 'initialize') return { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'fake', version: '1' } }
|
|
22
|
+
if (message.method === 'tools/list') return { tools: [{ name: 'hello', description: 'Say hi', inputSchema: { type: 'object', properties: {} } }] }
|
|
23
|
+
if (message.method === 'tools/call') return { content: [{ type: 'text', text: 'hi from fake' }] }
|
|
24
|
+
return {}
|
|
25
|
+
}
|
|
26
|
+
const server = http.createServer(async (req, res) => {
|
|
27
|
+
const base = `http://127.0.0.1:${server.address().port}`
|
|
28
|
+
const url = new URL(req.url, base)
|
|
29
|
+
const send = (status, body, headers = {}) => {
|
|
30
|
+
res.writeHead(status, { 'Content-Type': 'application/json', ...headers })
|
|
31
|
+
res.end(JSON.stringify(body))
|
|
32
|
+
}
|
|
33
|
+
if (url.pathname === '/.well-known/oauth-protected-resource/mcp') return send(200, { resource: `${base}/mcp`, authorization_servers: [base] })
|
|
34
|
+
if (url.pathname === '/.well-known/oauth-authorization-server') {
|
|
35
|
+
return send(200, {
|
|
36
|
+
issuer: base,
|
|
37
|
+
authorization_endpoint: `${base}/authorize`,
|
|
38
|
+
token_endpoint: `${base}/token`,
|
|
39
|
+
registration_endpoint: `${base}/register`,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
if (url.pathname === '/register') {
|
|
43
|
+
seen.registrations += 1
|
|
44
|
+
const body = JSON.parse(await readBody(req))
|
|
45
|
+
assert.equal(body.token_endpoint_auth_method, 'none')
|
|
46
|
+
return send(201, { client_id: 'client-1' })
|
|
47
|
+
}
|
|
48
|
+
if (url.pathname === '/token') {
|
|
49
|
+
const form = new URLSearchParams(await readBody(req))
|
|
50
|
+
if (form.get('grant_type') === 'authorization_code') {
|
|
51
|
+
const expected = createHash('sha256').update(form.get('code_verifier')).digest('base64url')
|
|
52
|
+
if (form.get('code') !== seen.code || expected !== seen.challenge) return send(400, { error: 'invalid_grant' })
|
|
53
|
+
return send(200, { access_token: 'access-1', refresh_token: 'refresh-1', expires_in: 3600 })
|
|
54
|
+
}
|
|
55
|
+
if (form.get('grant_type') === 'refresh_token' && form.get('refresh_token') === 'refresh-1') {
|
|
56
|
+
seen.refreshes += 1
|
|
57
|
+
accessToken = 'access-2'
|
|
58
|
+
return send(200, { access_token: 'access-2', expires_in: 3600 })
|
|
59
|
+
}
|
|
60
|
+
return send(400, { error: 'invalid_grant' })
|
|
61
|
+
}
|
|
62
|
+
if (url.pathname === '/mcp' && req.method === 'POST') {
|
|
63
|
+
if (req.headers.authorization !== `Bearer ${accessToken}`) {
|
|
64
|
+
return send(401, { error: 'unauthorized' }, { 'WWW-Authenticate': `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"` })
|
|
65
|
+
}
|
|
66
|
+
const message = JSON.parse(await readBody(req))
|
|
67
|
+
if (message.id === undefined) {
|
|
68
|
+
res.writeHead(202)
|
|
69
|
+
return res.end()
|
|
70
|
+
}
|
|
71
|
+
return send(200, { jsonrpc: '2.0', id: message.id, result: rpc(message) })
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname === '/sse' && req.method === 'GET') {
|
|
74
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' })
|
|
75
|
+
res.write('event: endpoint\ndata: /messages?session=1\n\n')
|
|
76
|
+
sseClients.push(res)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (url.pathname === '/messages' && req.method === 'POST') {
|
|
80
|
+
const message = JSON.parse(await readBody(req))
|
|
81
|
+
res.writeHead(202)
|
|
82
|
+
res.end('Accepted')
|
|
83
|
+
if (message.id !== undefined) {
|
|
84
|
+
sseClients.at(-1)?.write(`event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: rpc(message) })}\n\n`)
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
send(404, {})
|
|
89
|
+
})
|
|
90
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
91
|
+
const base = `http://127.0.0.1:${server.address().port}`
|
|
92
|
+
const close = () => {
|
|
93
|
+
for (const client of sseClients) client.end()
|
|
94
|
+
server.closeAllConnections()
|
|
95
|
+
return new Promise((resolve) => server.close(resolve))
|
|
96
|
+
}
|
|
97
|
+
return { base, seen, close, expireAccess: () => (accessToken = 'rotated-away') }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test('OAuth: discovery, dynamic registration, PKCE sign-in and token refresh', async () => {
|
|
101
|
+
const fake = await fakeServer()
|
|
102
|
+
const manager = new McpManager()
|
|
103
|
+
const config = sanitizeServerConfig({ name: 'Secure', transport: 'http', url: `${fake.base}/mcp`, oauth: { enabled: true } })
|
|
104
|
+
await manager.setConfigs([config])
|
|
105
|
+
|
|
106
|
+
let view = await manager.connect(config.id)
|
|
107
|
+
assert.equal(view.status, 'needs_auth')
|
|
108
|
+
assert.equal(view.oauth.authorized, false)
|
|
109
|
+
|
|
110
|
+
const redirectUri = 'http://127.0.0.1:4174/oauth/mcp/callback'
|
|
111
|
+
const { url } = await manager.startAuth(config.id, redirectUri)
|
|
112
|
+
const authorize = new URL(url)
|
|
113
|
+
assert.equal(authorize.pathname, '/authorize')
|
|
114
|
+
assert.equal(authorize.searchParams.get('client_id'), 'client-1')
|
|
115
|
+
assert.equal(authorize.searchParams.get('code_challenge_method'), 'S256')
|
|
116
|
+
assert.equal(authorize.searchParams.get('resource'), `${fake.base}/mcp`)
|
|
117
|
+
fake.seen.challenge = authorize.searchParams.get('code_challenge')
|
|
118
|
+
|
|
119
|
+
await assert.rejects(() => manager.finishAuth('wrong-state', 'code-1'), /expired|already used/)
|
|
120
|
+
await manager.finishAuth(authorize.searchParams.get('state'), 'code-1')
|
|
121
|
+
view = manager.list()[0]
|
|
122
|
+
assert.equal(view.status, 'connected', view.error)
|
|
123
|
+
assert.equal(view.oauth.authorized, true)
|
|
124
|
+
assert.equal(view.tools[0].name, 'hello')
|
|
125
|
+
|
|
126
|
+
// The access token stops working: the manager refreshes once and retries the call.
|
|
127
|
+
fake.expireAccess()
|
|
128
|
+
const result = await manager.callTool(config.id, 'hello', {})
|
|
129
|
+
assert.deepEqual(result, { text: 'hi from fake', isError: false })
|
|
130
|
+
assert.equal(fake.seen.refreshes, 1)
|
|
131
|
+
|
|
132
|
+
// A second sign-in reuses the registered client.
|
|
133
|
+
await manager.startAuth(config.id, redirectUri)
|
|
134
|
+
assert.equal(fake.seen.registrations, 1)
|
|
135
|
+
|
|
136
|
+
await manager.signOut(config.id)
|
|
137
|
+
assert.equal(manager.list()[0].status, 'needs_auth')
|
|
138
|
+
await manager.shutdown()
|
|
139
|
+
await fake.close()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('a 401 without OAuth explains how to fix it', async () => {
|
|
143
|
+
const fake = await fakeServer()
|
|
144
|
+
const manager = new McpManager()
|
|
145
|
+
const config = sanitizeServerConfig({ name: 'Plain', transport: 'http', url: `${fake.base}/mcp` })
|
|
146
|
+
await manager.setConfigs([config])
|
|
147
|
+
const view = await manager.connect(config.id)
|
|
148
|
+
assert.equal(view.status, 'error')
|
|
149
|
+
assert.match(view.error, /401/)
|
|
150
|
+
await fake.close()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('legacy HTTP+SSE servers connect and answer tool calls', async () => {
|
|
154
|
+
const fake = await fakeServer()
|
|
155
|
+
const manager = new McpManager()
|
|
156
|
+
const config = sanitizeServerConfig({ name: 'Legacy', transport: 'sse', url: `${fake.base}/sse` })
|
|
157
|
+
assert.equal(config.transport, 'sse')
|
|
158
|
+
await manager.setConfigs([config])
|
|
159
|
+
const view = await manager.connect(config.id)
|
|
160
|
+
assert.equal(view.status, 'connected', view.error)
|
|
161
|
+
assert.equal(view.tools[0].name, 'hello')
|
|
162
|
+
assert.deepEqual(await manager.callTool(config.id, 'hello', {}), { text: 'hi from fake', isError: false })
|
|
163
|
+
await manager.shutdown()
|
|
164
|
+
await fake.close()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('OAuth client secrets are masked and kept on save', () => {
|
|
168
|
+
const config = sanitizeServerConfig({ name: 'x', transport: 'http', url: 'https://example.com/mcp', oauth: { enabled: true, clientId: 'abc', clientSecret: 's3cret' } })
|
|
169
|
+
const manager = new McpManager()
|
|
170
|
+
manager.configs.set(config.id, config)
|
|
171
|
+
const view = manager.list()[0]
|
|
172
|
+
assert.notEqual(view.oauth.clientSecret, 's3cret')
|
|
173
|
+
const again = sanitizeServerConfig(view, config)
|
|
174
|
+
assert.equal(again.oauth.clientSecret, 's3cret')
|
|
175
|
+
})
|