@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/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-egG5Bgfo.js"></script>
35
- <link rel="stylesheet" crossorigin href="./assets/index-DdI1_iPD.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.1.0",
4
+ "version": "3.1.1",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opensol": "./server/opensol.mjs"
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,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) => ({ '&': '&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
+
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
+ })