sub-bridge 1.0.0
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/.cursor/commands/mcp-only.md +1 -0
- package/.github/workflows/npm-publish.yml +33 -0
- package/.github/workflows/pages.yml +40 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +8 -0
- package/DEVELOPMENT.md +31 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/api/index.ts +12 -0
- package/bun.lock +102 -0
- package/dist/auth/oauth-flow.d.ts +24 -0
- package/dist/auth/oauth-flow.d.ts.map +1 -0
- package/dist/auth/oauth-flow.js +184 -0
- package/dist/auth/oauth-flow.js.map +1 -0
- package/dist/auth/oauth-manager.d.ts +13 -0
- package/dist/auth/oauth-manager.d.ts.map +1 -0
- package/dist/auth/oauth-manager.js +25 -0
- package/dist/auth/oauth-manager.js.map +1 -0
- package/dist/auth/provider.d.ts +42 -0
- package/dist/auth/provider.d.ts.map +1 -0
- package/dist/auth/provider.js +270 -0
- package/dist/auth/provider.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +91 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp/proxy.d.ts +16 -0
- package/dist/mcp/proxy.d.ts.map +1 -0
- package/dist/mcp/proxy.js +85 -0
- package/dist/mcp/proxy.js.map +1 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +50 -0
- package/dist/mcp.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +149 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +6 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +808 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/tunnels.d.ts +7 -0
- package/dist/routes/tunnels.d.ts.map +1 -0
- package/dist/routes/tunnels.js +44 -0
- package/dist/routes/tunnels.js.map +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +157 -0
- package/dist/server.js.map +1 -0
- package/dist/tunnel/providers/cloudflare.d.ts +9 -0
- package/dist/tunnel/providers/cloudflare.d.ts.map +1 -0
- package/dist/tunnel/providers/cloudflare.js +47 -0
- package/dist/tunnel/providers/cloudflare.js.map +1 -0
- package/dist/tunnel/providers/index.d.ts +4 -0
- package/dist/tunnel/providers/index.d.ts.map +1 -0
- package/dist/tunnel/providers/index.js +13 -0
- package/dist/tunnel/providers/index.js.map +1 -0
- package/dist/tunnel/providers/ngrok.d.ts +10 -0
- package/dist/tunnel/providers/ngrok.d.ts.map +1 -0
- package/dist/tunnel/providers/ngrok.js +52 -0
- package/dist/tunnel/providers/ngrok.js.map +1 -0
- package/dist/tunnel/providers/tailscale.d.ts +10 -0
- package/dist/tunnel/providers/tailscale.d.ts.map +1 -0
- package/dist/tunnel/providers/tailscale.js +48 -0
- package/dist/tunnel/providers/tailscale.js.map +1 -0
- package/dist/tunnel/registry.d.ts +14 -0
- package/dist/tunnel/registry.d.ts.map +1 -0
- package/dist/tunnel/registry.js +86 -0
- package/dist/tunnel/registry.js.map +1 -0
- package/dist/tunnel/types.d.ts +26 -0
- package/dist/tunnel/types.d.ts.map +1 -0
- package/dist/tunnel/types.js +6 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/tunnel/utils.d.ts +18 -0
- package/dist/tunnel/utils.d.ts.map +1 -0
- package/dist/tunnel/utils.js +57 -0
- package/dist/tunnel/utils.js.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/anthropic-to-openai-converter.d.ts +103 -0
- package/dist/utils/anthropic-to-openai-converter.d.ts.map +1 -0
- package/dist/utils/anthropic-to-openai-converter.js +376 -0
- package/dist/utils/anthropic-to-openai-converter.js.map +1 -0
- package/dist/utils/chat-to-responses.d.ts +59 -0
- package/dist/utils/chat-to-responses.d.ts.map +1 -0
- package/dist/utils/chat-to-responses.js +395 -0
- package/dist/utils/chat-to-responses.js.map +1 -0
- package/dist/utils/chatgpt-instructions.d.ts +3 -0
- package/dist/utils/chatgpt-instructions.d.ts.map +1 -0
- package/dist/utils/chatgpt-instructions.js +12 -0
- package/dist/utils/chatgpt-instructions.js.map +1 -0
- package/dist/utils/cli-args.d.ts +3 -0
- package/dist/utils/cli-args.d.ts.map +1 -0
- package/dist/utils/cli-args.js +10 -0
- package/dist/utils/cli-args.js.map +1 -0
- package/dist/utils/cors-bypass.d.ts +4 -0
- package/dist/utils/cors-bypass.d.ts.map +1 -0
- package/dist/utils/cors-bypass.js +30 -0
- package/dist/utils/cors-bypass.js.map +1 -0
- package/dist/utils/cursor-byok-bypass.d.ts +37 -0
- package/dist/utils/cursor-byok-bypass.d.ts.map +1 -0
- package/dist/utils/cursor-byok-bypass.js +53 -0
- package/dist/utils/cursor-byok-bypass.js.map +1 -0
- package/dist/utils/logger.d.ts +19 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +192 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/port.d.ts +27 -0
- package/dist/utils/port.d.ts.map +1 -0
- package/dist/utils/port.js +78 -0
- package/dist/utils/port.js.map +1 -0
- package/dist/utils/setup-instructions.d.ts +10 -0
- package/dist/utils/setup-instructions.d.ts.map +1 -0
- package/dist/utils/setup-instructions.js +49 -0
- package/dist/utils/setup-instructions.js.map +1 -0
- package/env.example +25 -0
- package/index.html +992 -0
- package/package.json +57 -0
- package/public/.nojekyll +0 -0
- package/public/assets/chat.png +0 -0
- package/public/assets/demo.gif +0 -0
- package/public/assets/demo.mp4 +0 -0
- package/public/assets/setup.png +0 -0
- package/public/assets/ui.png +0 -0
- package/public/index.html +292 -0
- package/release-please-config.json +10 -0
- package/src/auth/provider.ts +412 -0
- package/src/cli.ts +97 -0
- package/src/mcp/proxy.ts +64 -0
- package/src/mcp.ts +56 -0
- package/src/oauth/authorize.ts +270 -0
- package/src/oauth/crypto.ts +198 -0
- package/src/oauth/dcr.ts +129 -0
- package/src/oauth/metadata.ts +40 -0
- package/src/oauth/token.ts +173 -0
- package/src/routes/auth.ts +149 -0
- package/src/routes/chat.ts +983 -0
- package/src/routes/oauth.ts +220 -0
- package/src/routes/tunnels.ts +45 -0
- package/src/server.ts +204 -0
- package/src/tunnel/providers/cloudflare.ts +50 -0
- package/src/tunnel/providers/index.ts +7 -0
- package/src/tunnel/providers/ngrok.ts +56 -0
- package/src/tunnel/providers/tailscale.ts +50 -0
- package/src/tunnel/registry.ts +96 -0
- package/src/tunnel/types.ts +32 -0
- package/src/tunnel/utils.ts +59 -0
- package/src/types.ts +55 -0
- package/src/utils/anthropic-to-openai-converter.ts +578 -0
- package/src/utils/chat-to-responses.ts +512 -0
- package/src/utils/chatgpt-instructions.ts +7 -0
- package/src/utils/cli-args.ts +8 -0
- package/src/utils/cors-bypass.ts +39 -0
- package/src/utils/cursor-byok-bypass.ts +56 -0
- package/src/utils/logger.ts +174 -0
- package/src/utils/port.ts +99 -0
- package/src/utils/setup-instructions.ts +59 -0
- package/tsconfig.json +22 -0
- package/vercel.json +20 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
export interface AuthSession {
|
|
10
|
+
authUrl: string
|
|
11
|
+
userCode?: string // For device flow (OpenAI)
|
|
12
|
+
interval?: number // Polling interval in seconds (OpenAI)
|
|
13
|
+
sessionId: string
|
|
14
|
+
expiresAt: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TokenResult {
|
|
18
|
+
accessToken: string
|
|
19
|
+
refreshToken?: string
|
|
20
|
+
expiresIn?: number
|
|
21
|
+
accountId?: string
|
|
22
|
+
email?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AuthProvider {
|
|
26
|
+
id: string
|
|
27
|
+
name: string
|
|
28
|
+
startAuth(): Promise<AuthSession>
|
|
29
|
+
completeAuth(input: string, sessionId: string): Promise<TokenResult>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Claude Provider - OAuth + PKCE
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
const CLAUDE_CLIENT_ID =
|
|
37
|
+
process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
38
|
+
const CLAUDE_REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback'
|
|
39
|
+
const CLAUDE_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'
|
|
40
|
+
|
|
41
|
+
export class ClaudeProvider implements AuthProvider {
|
|
42
|
+
id = 'claude'
|
|
43
|
+
name = 'Claude'
|
|
44
|
+
|
|
45
|
+
async startAuth(): Promise<AuthSession> {
|
|
46
|
+
const verifier = crypto.randomBytes(32).toString('base64url')
|
|
47
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
|
|
48
|
+
|
|
49
|
+
const authUrl = new URL('https://claude.ai/oauth/authorize')
|
|
50
|
+
authUrl.searchParams.set('code', 'true')
|
|
51
|
+
authUrl.searchParams.set('client_id', CLAUDE_CLIENT_ID)
|
|
52
|
+
authUrl.searchParams.set('response_type', 'code')
|
|
53
|
+
authUrl.searchParams.set('redirect_uri', CLAUDE_REDIRECT_URI)
|
|
54
|
+
authUrl.searchParams.set('scope', 'org:create_api_key user:profile user:inference')
|
|
55
|
+
authUrl.searchParams.set('code_challenge', challenge)
|
|
56
|
+
authUrl.searchParams.set('code_challenge_method', 'S256')
|
|
57
|
+
authUrl.searchParams.set('state', verifier)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
authUrl: authUrl.toString(),
|
|
61
|
+
sessionId: verifier,
|
|
62
|
+
expiresAt: Date.now() + 15 * 60 * 1000,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async completeAuth(codeInput: string, sessionId: string): Promise<TokenResult> {
|
|
67
|
+
// Parse CODE#STATE format
|
|
68
|
+
const parts = codeInput.trim().split('#')
|
|
69
|
+
const code = parts[0]
|
|
70
|
+
const state = parts[1] || sessionId
|
|
71
|
+
|
|
72
|
+
const response = await fetch(CLAUDE_TOKEN_URL, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
code,
|
|
77
|
+
state,
|
|
78
|
+
grant_type: 'authorization_code',
|
|
79
|
+
client_id: CLAUDE_CLIENT_ID,
|
|
80
|
+
redirect_uri: CLAUDE_REDIRECT_URI,
|
|
81
|
+
code_verifier: sessionId,
|
|
82
|
+
}),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const error = await response.text()
|
|
87
|
+
throw new Error(`Claude token exchange failed: ${error}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const data = (await response.json()) as {
|
|
91
|
+
access_token: string
|
|
92
|
+
refresh_token: string
|
|
93
|
+
expires_in: number
|
|
94
|
+
account?: {
|
|
95
|
+
uuid: string
|
|
96
|
+
email_address: string
|
|
97
|
+
}
|
|
98
|
+
organization?: {
|
|
99
|
+
uuid: string
|
|
100
|
+
name: string
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
accessToken: data.access_token,
|
|
106
|
+
refreshToken: data.refresh_token,
|
|
107
|
+
expiresIn: data.expires_in,
|
|
108
|
+
email: data.account?.email_address,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function refreshClaudeToken(refreshToken: string): Promise<TokenResult> {
|
|
114
|
+
const response = await fetch(CLAUDE_TOKEN_URL, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
grant_type: 'refresh_token',
|
|
119
|
+
client_id: CLAUDE_CLIENT_ID,
|
|
120
|
+
refresh_token: refreshToken,
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
const error = await response.text()
|
|
126
|
+
throw new Error(`Claude token refresh failed: ${error}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const data = (await response.json()) as {
|
|
130
|
+
access_token: string
|
|
131
|
+
refresh_token?: string
|
|
132
|
+
expires_in?: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
accessToken: data.access_token,
|
|
137
|
+
refreshToken: data.refresh_token,
|
|
138
|
+
expiresIn: data.expires_in,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// OpenAI Provider - Device Code Flow
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
const OPENAI_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
|
147
|
+
const OPENAI_AUTH_BASE_URL = 'https://auth.openai.com'
|
|
148
|
+
const OPENAI_DEVICE_AUTH_BASE_URL = `${OPENAI_AUTH_BASE_URL}/api/accounts`
|
|
149
|
+
const OPENAI_DEVICE_CODE_URL = `${OPENAI_DEVICE_AUTH_BASE_URL}/deviceauth/usercode`
|
|
150
|
+
const OPENAI_DEVICE_POLL_URL = `${OPENAI_DEVICE_AUTH_BASE_URL}/deviceauth/token`
|
|
151
|
+
const OPENAI_DEVICE_REDIRECT_URI = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`
|
|
152
|
+
const OPENAI_TOKEN_URL = `${OPENAI_AUTH_BASE_URL}/oauth/token`
|
|
153
|
+
const OPENAI_USER_AUTH_URL = `${OPENAI_AUTH_BASE_URL}/codex/device`
|
|
154
|
+
const OPENAI_OAUTH_SCOPE = process.env.OPENAI_OAUTH_SCOPE || 'model.request'
|
|
155
|
+
const OPENAI_DEVICE_HEADERS = {
|
|
156
|
+
'Content-Type': 'application/json',
|
|
157
|
+
'User-Agent': 'reqwest/0.12.24',
|
|
158
|
+
}
|
|
159
|
+
const OPENAI_TOKEN_HEADERS = {
|
|
160
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
161
|
+
'User-Agent': 'reqwest/0.12.24',
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseJwtPayload(token: string): Record<string, unknown> | null {
|
|
165
|
+
const parts = token.split('.')
|
|
166
|
+
if (parts.length < 2) return null
|
|
167
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
|
168
|
+
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)
|
|
169
|
+
try {
|
|
170
|
+
const decoded = Buffer.from(padded, 'base64').toString('utf8')
|
|
171
|
+
return JSON.parse(decoded) as Record<string, unknown>
|
|
172
|
+
} catch {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function extractChatGptAccountId(idToken: string | undefined, accessToken?: string): string | undefined {
|
|
178
|
+
const tokens = [idToken, accessToken].filter((value): value is string => Boolean(value))
|
|
179
|
+
for (const token of tokens) {
|
|
180
|
+
const payload = parseJwtPayload(token)
|
|
181
|
+
if (!payload) continue
|
|
182
|
+
const direct = payload?.chatgpt_account_id
|
|
183
|
+
if (typeof direct === 'string' && direct.trim()) return direct
|
|
184
|
+
const authClaim = payload?.['https://api.openai.com/auth']
|
|
185
|
+
const nested = (authClaim as Record<string, unknown> | undefined)?.chatgpt_account_id
|
|
186
|
+
if (typeof nested === 'string' && nested.trim()) return nested
|
|
187
|
+
}
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function extractEmailFromIdToken(idToken: string | undefined): string | undefined {
|
|
192
|
+
if (!idToken) return undefined
|
|
193
|
+
const payload = parseJwtPayload(idToken)
|
|
194
|
+
if (!payload) return undefined
|
|
195
|
+
const email = payload?.email
|
|
196
|
+
if (typeof email === 'string' && email.trim()) return email
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const execFileAsync = promisify(execFile)
|
|
201
|
+
|
|
202
|
+
type HttpResult = {
|
|
203
|
+
status: number
|
|
204
|
+
ok: boolean
|
|
205
|
+
text: string
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseCurlResult(stdout: string): HttpResult {
|
|
209
|
+
const marker = '\n__STATUS__:'
|
|
210
|
+
const idx = stdout.lastIndexOf(marker)
|
|
211
|
+
if (idx === -1) {
|
|
212
|
+
return { status: 0, ok: false, text: stdout }
|
|
213
|
+
}
|
|
214
|
+
const body = stdout.slice(0, idx)
|
|
215
|
+
const statusText = stdout.slice(idx + marker.length).trim()
|
|
216
|
+
const status = parseInt(statusText, 10)
|
|
217
|
+
return { status, ok: status >= 200 && status < 300, text: body }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function curlPost(url: string, contentType: string, body: string): Promise<HttpResult> {
|
|
221
|
+
const args = [
|
|
222
|
+
'-sS',
|
|
223
|
+
'-X',
|
|
224
|
+
'POST',
|
|
225
|
+
url,
|
|
226
|
+
'-H',
|
|
227
|
+
`Content-Type: ${contentType}`,
|
|
228
|
+
'-H',
|
|
229
|
+
'User-Agent: reqwest/0.12.24',
|
|
230
|
+
'-d',
|
|
231
|
+
body,
|
|
232
|
+
'-w',
|
|
233
|
+
'\n__STATUS__:%{http_code}',
|
|
234
|
+
]
|
|
235
|
+
const { stdout } = await execFileAsync('curl', args, { maxBuffer: 5 * 1024 * 1024 })
|
|
236
|
+
return parseCurlResult(stdout)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function postWithFallback(
|
|
240
|
+
url: string,
|
|
241
|
+
headers: Record<string, string>,
|
|
242
|
+
body: string,
|
|
243
|
+
contentType: string,
|
|
244
|
+
): Promise<HttpResult> {
|
|
245
|
+
const response = await fetch(url, { method: 'POST', headers, body })
|
|
246
|
+
const text = await response.text()
|
|
247
|
+
if (response.ok) {
|
|
248
|
+
return { status: response.status, ok: true, text }
|
|
249
|
+
}
|
|
250
|
+
if (text.includes('cdn-cgi/challenge-platform')) {
|
|
251
|
+
try {
|
|
252
|
+
return await curlPost(url, contentType, body)
|
|
253
|
+
} catch {
|
|
254
|
+
return { status: response.status, ok: false, text }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { status: response.status, ok: false, text }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeOpenAIError(status: number, body: string) {
|
|
261
|
+
const trimmed = body.trim()
|
|
262
|
+
if (!trimmed) return `HTTP ${status}`
|
|
263
|
+
if (trimmed.startsWith('<') || trimmed.includes('cdn-cgi/challenge-platform')) {
|
|
264
|
+
return `OpenAI auth endpoint returned a Cloudflare challenge (HTTP ${status}).`
|
|
265
|
+
}
|
|
266
|
+
return trimmed
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface DeviceCodeResponse {
|
|
270
|
+
device_auth_id: string
|
|
271
|
+
user_code: string
|
|
272
|
+
interval: number | string
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface DevicePollResponse {
|
|
276
|
+
authorization_code: string
|
|
277
|
+
code_challenge: string
|
|
278
|
+
code_verifier: string
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Store active device sessions for polling
|
|
282
|
+
const deviceSessions = new Map<
|
|
283
|
+
string,
|
|
284
|
+
{
|
|
285
|
+
deviceAuthId: string
|
|
286
|
+
userCode: string
|
|
287
|
+
interval: number
|
|
288
|
+
createdAt: number
|
|
289
|
+
}
|
|
290
|
+
>()
|
|
291
|
+
|
|
292
|
+
export class OpenAIProvider implements AuthProvider {
|
|
293
|
+
id = 'openai'
|
|
294
|
+
name = 'ChatGPT'
|
|
295
|
+
|
|
296
|
+
getAuthUrl(): string {
|
|
297
|
+
return OPENAI_USER_AUTH_URL
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async startAuth(): Promise<AuthSession> {
|
|
301
|
+
const body = JSON.stringify({ client_id: OPENAI_CLIENT_ID, scope: OPENAI_OAUTH_SCOPE })
|
|
302
|
+
const response = await postWithFallback(
|
|
303
|
+
OPENAI_DEVICE_CODE_URL,
|
|
304
|
+
OPENAI_DEVICE_HEADERS,
|
|
305
|
+
body,
|
|
306
|
+
'application/json',
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
throw new Error(`Failed to get device code: ${normalizeOpenAIError(response.status, response.text)}`)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const data = JSON.parse(response.text) as DeviceCodeResponse
|
|
314
|
+
const sessionId = data.device_auth_id
|
|
315
|
+
const interval = typeof data.interval === 'string' ? parseInt(data.interval, 10) : data.interval
|
|
316
|
+
const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 5
|
|
317
|
+
|
|
318
|
+
// Store session for polling
|
|
319
|
+
deviceSessions.set(sessionId, {
|
|
320
|
+
deviceAuthId: data.device_auth_id,
|
|
321
|
+
userCode: data.user_code,
|
|
322
|
+
interval: safeInterval,
|
|
323
|
+
createdAt: Date.now(),
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
authUrl: OPENAI_USER_AUTH_URL,
|
|
328
|
+
userCode: data.user_code,
|
|
329
|
+
interval: safeInterval,
|
|
330
|
+
sessionId,
|
|
331
|
+
expiresAt: Date.now() + 15 * 60 * 1000,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async completeAuth(_input: string, sessionId: string): Promise<TokenResult> {
|
|
336
|
+
const session = deviceSessions.get(sessionId)
|
|
337
|
+
if (!session) {
|
|
338
|
+
throw new Error('Session not found or expired')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Poll for authorization
|
|
342
|
+
const pollBody = JSON.stringify({
|
|
343
|
+
device_auth_id: session.deviceAuthId,
|
|
344
|
+
user_code: session.userCode,
|
|
345
|
+
})
|
|
346
|
+
const pollResponse = await postWithFallback(
|
|
347
|
+
OPENAI_DEVICE_POLL_URL,
|
|
348
|
+
OPENAI_DEVICE_HEADERS,
|
|
349
|
+
pollBody,
|
|
350
|
+
'application/json',
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if (!pollResponse.ok) {
|
|
354
|
+
if (pollResponse.status === 403 || pollResponse.status === 404 || pollResponse.status === 429) {
|
|
355
|
+
throw new Error('PENDING') // User hasn't authorized yet
|
|
356
|
+
}
|
|
357
|
+
throw new Error(`Poll failed: ${normalizeOpenAIError(pollResponse.status, pollResponse.text)}`)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const pollData = JSON.parse(pollResponse.text) as DevicePollResponse
|
|
361
|
+
|
|
362
|
+
// Exchange authorization code for tokens
|
|
363
|
+
const tokenBody = new URLSearchParams({
|
|
364
|
+
grant_type: 'authorization_code',
|
|
365
|
+
client_id: OPENAI_CLIENT_ID,
|
|
366
|
+
code: pollData.authorization_code,
|
|
367
|
+
code_verifier: pollData.code_verifier,
|
|
368
|
+
redirect_uri: OPENAI_DEVICE_REDIRECT_URI,
|
|
369
|
+
scope: OPENAI_OAUTH_SCOPE,
|
|
370
|
+
}).toString()
|
|
371
|
+
const tokenResponse = await postWithFallback(
|
|
372
|
+
OPENAI_TOKEN_URL,
|
|
373
|
+
OPENAI_TOKEN_HEADERS,
|
|
374
|
+
tokenBody,
|
|
375
|
+
'application/x-www-form-urlencoded',
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if (!tokenResponse.ok) {
|
|
379
|
+
throw new Error(`Token exchange failed: ${normalizeOpenAIError(tokenResponse.status, tokenResponse.text)}`)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const tokenData = JSON.parse(tokenResponse.text) as {
|
|
383
|
+
access_token: string
|
|
384
|
+
refresh_token: string
|
|
385
|
+
id_token: string
|
|
386
|
+
expires_in: number
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const accountId = extractChatGptAccountId(tokenData.id_token, tokenData.access_token)
|
|
390
|
+
const email = extractEmailFromIdToken(tokenData.id_token)
|
|
391
|
+
|
|
392
|
+
// Clean up session
|
|
393
|
+
deviceSessions.delete(sessionId)
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
accessToken: tokenData.access_token,
|
|
397
|
+
refreshToken: tokenData.refresh_token,
|
|
398
|
+
expiresIn: tokenData.expires_in,
|
|
399
|
+
accountId,
|
|
400
|
+
email,
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Get session info for polling UI
|
|
405
|
+
getSession(sessionId: string) {
|
|
406
|
+
return deviceSessions.get(sessionId)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Export singleton instances
|
|
411
|
+
export const claudeProvider = new ClaudeProvider()
|
|
412
|
+
export const openaiProvider = new OpenAIProvider()
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sub Bridge CLI
|
|
4
|
+
*
|
|
5
|
+
* Main entry point that can run in different modes:
|
|
6
|
+
* - Default: Starts HTTP server + MCP server (original behavior)
|
|
7
|
+
* - --server-only: Just HTTP server
|
|
8
|
+
* - --mcp-only: Just MCP proxy (discovers/starts HTTP server)
|
|
9
|
+
*
|
|
10
|
+
* For development, use the separate entry points:
|
|
11
|
+
* - npm run dev:server - HTTP server with hot reload
|
|
12
|
+
* - npm run dev:mcp - MCP proxy (connects to existing server or starts inline)
|
|
13
|
+
*/
|
|
14
|
+
import { program } from 'commander'
|
|
15
|
+
import chalk from 'chalk'
|
|
16
|
+
import { startServer, type ServerConfig } from './server'
|
|
17
|
+
import { findPort } from './utils/port'
|
|
18
|
+
import { startMcpServer } from './mcp/proxy'
|
|
19
|
+
import { addSharedOptions } from './utils/cli-args'
|
|
20
|
+
|
|
21
|
+
const log = (...args: Parameters<typeof console.error>) => console.error(...args)
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const prog = program
|
|
25
|
+
.name('sub-bridge')
|
|
26
|
+
.description('MCP bridge for ChatGPT Pro, Claude Max, etc. in Cursor')
|
|
27
|
+
|
|
28
|
+
addSharedOptions(prog)
|
|
29
|
+
.option('--server-only', 'Run HTTP server only (no MCP)')
|
|
30
|
+
.option('--mcp-only', 'Run MCP proxy only (discovers existing server)')
|
|
31
|
+
.parse()
|
|
32
|
+
|
|
33
|
+
const opts = program.opts()
|
|
34
|
+
|
|
35
|
+
const config: ServerConfig = {
|
|
36
|
+
port: opts.port ? parseInt(opts.port, 10) : undefined,
|
|
37
|
+
tunnelUrl: opts.tunnel,
|
|
38
|
+
verbose: opts.verbose || false,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// MCP-only mode: just run the thin proxy
|
|
42
|
+
if (opts.mcpOnly) {
|
|
43
|
+
const discovery = await findPort(config.port)
|
|
44
|
+
|
|
45
|
+
if (discovery.hasServer) {
|
|
46
|
+
log(`[sub-bridge] Found existing HTTP server on port ${discovery.port}`)
|
|
47
|
+
await startMcpServer(discovery.port, log)
|
|
48
|
+
} else {
|
|
49
|
+
log(`[sub-bridge] No HTTP server found, starting inline on port ${discovery.port}`)
|
|
50
|
+
const server = await startServer({ ...config, port: discovery.port })
|
|
51
|
+
await startMcpServer(server.port, log)
|
|
52
|
+
}
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Server-only mode: just run the HTTP server
|
|
57
|
+
if (opts.serverOnly) {
|
|
58
|
+
await startServer(config)
|
|
59
|
+
log(chalk.dim(' Press Ctrl+C to stop'))
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Default mode: start HTTP server + MCP (original behavior)
|
|
64
|
+
const server = await startServer(config)
|
|
65
|
+
|
|
66
|
+
printSetupInstructions(server.publicUrl)
|
|
67
|
+
|
|
68
|
+
await startMcpServer(server.port, log)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printSetupInstructions(publicUrl: string) {
|
|
72
|
+
log()
|
|
73
|
+
log(chalk.bold.yellow(' Setup in Cursor:'))
|
|
74
|
+
log(chalk.dim(' ─────────────────────────────────────'))
|
|
75
|
+
log()
|
|
76
|
+
log(chalk.dim(' 1. Command Palette (Cmd+Shift+P / Ctrl+Shift+P)'))
|
|
77
|
+
log(chalk.dim(' 2. Search "Cursor Settings" → Open'))
|
|
78
|
+
log(chalk.dim(' 3. Navigate: Models → API Keys (expand)'))
|
|
79
|
+
log(chalk.dim(' 4. Enable "OpenAI API Key" toggle'))
|
|
80
|
+
log(chalk.dim(' 5. Set API key with routing:'))
|
|
81
|
+
log(chalk.dim(' '), chalk.cyan('o3=opus-4.5,o3-mini=sonnet-4.5:sk-ant-xxx'))
|
|
82
|
+
log(chalk.dim(' 6. Enable "Override OpenAI Base URL" toggle'))
|
|
83
|
+
log(chalk.dim(' 7. Set Base URL:'), chalk.cyan.bold(`${publicUrl}/v1`))
|
|
84
|
+
log()
|
|
85
|
+
log(chalk.dim(' Getting API keys:'))
|
|
86
|
+
log(chalk.dim(' • Open'), chalk.cyan(publicUrl), chalk.dim('in your external browser and click Login buttons'))
|
|
87
|
+
log(chalk.dim(' • Or use Claude Code CLI:'), chalk.cyan('claude setup-token'))
|
|
88
|
+
log(chalk.dim(' • Or use Codex CLI:'), chalk.cyan('codex login'))
|
|
89
|
+
log()
|
|
90
|
+
log(chalk.dim(' Use MCP tool: "get_status" to get URL anytime'))
|
|
91
|
+
log()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main().catch((error) => {
|
|
95
|
+
log('[sub-bridge] Fatal error:', error)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
})
|
package/src/mcp/proxy.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared MCP proxy functionality
|
|
3
|
+
*/
|
|
4
|
+
import { buildStatusText } from '../utils/setup-instructions'
|
|
5
|
+
|
|
6
|
+
export interface McpToolResult {
|
|
7
|
+
[x: string]: unknown
|
|
8
|
+
content: Array<{ type: 'text'; text: string }>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Call a tool on the HTTP server
|
|
13
|
+
*/
|
|
14
|
+
export async function callTool(port: number, name: string, args: Record<string, unknown> = {}): Promise<McpToolResult> {
|
|
15
|
+
const localUrl = `http://localhost:${port}`
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(`${localUrl}/mcp/tools/${name}`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'content-type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ arguments: args }),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
const error = await response.text()
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: 'text' as const, text: `Error calling tool ${name}: ${error}` }],
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return response.json() as Promise<McpToolResult>
|
|
32
|
+
} catch {
|
|
33
|
+
// Server not reachable - return helpful setup instructions
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: 'text' as const, text: buildStatusText({ mode: 'proxy', baseUrl: localUrl }) }],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Start MCP server and register tools that forward to HTTP server
|
|
42
|
+
*/
|
|
43
|
+
export async function startMcpServer(serverPort: number, log: (...args: any[]) => void) {
|
|
44
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
|
|
45
|
+
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js')
|
|
46
|
+
|
|
47
|
+
const server = new McpServer({
|
|
48
|
+
name: 'Sub Bridge',
|
|
49
|
+
version: '2.1.0',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Register tools that forward to HTTP server
|
|
53
|
+
server.tool(
|
|
54
|
+
'get_status',
|
|
55
|
+
'Get Sub Bridge status and login URL for Claude/ChatGPT authentication',
|
|
56
|
+
{},
|
|
57
|
+
async () => callTool(serverPort, 'get_status'),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Start MCP transport
|
|
61
|
+
const transport = new StdioServerTransport()
|
|
62
|
+
await server.connect(transport)
|
|
63
|
+
log('[mcp] MCP server started, connected to HTTP server on port', serverPort)
|
|
64
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Thin MCP Proxy Entry Point
|
|
4
|
+
*
|
|
5
|
+
* This is the entry point for Cursor's MCP integration.
|
|
6
|
+
* It discovers or starts an HTTP server and forwards tool calls to it.
|
|
7
|
+
*
|
|
8
|
+
* Dev mode: Developer runs HTTP server separately with `tsx watch src/server.ts`
|
|
9
|
+
* Production: MCP proxy starts HTTP server inline if not found
|
|
10
|
+
*/
|
|
11
|
+
import { program } from 'commander'
|
|
12
|
+
import { findPort } from './utils/port'
|
|
13
|
+
import { startServer, type ServerConfig } from './server'
|
|
14
|
+
import { startMcpServer } from './mcp/proxy'
|
|
15
|
+
import { addSharedOptions } from './utils/cli-args'
|
|
16
|
+
|
|
17
|
+
const log = (...args: Parameters<typeof console.error>) => console.error(...args)
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
// Parse CLI arguments
|
|
21
|
+
addSharedOptions(program).parse()
|
|
22
|
+
const opts = program.opts()
|
|
23
|
+
|
|
24
|
+
const cliPort = opts.port ? parseInt(opts.port, 10) : undefined
|
|
25
|
+
const tunnelUrl = opts.tunnel || process.env.TUNNEL_URL
|
|
26
|
+
const verbose = opts.verbose || process.env.VERBOSE === 'true'
|
|
27
|
+
|
|
28
|
+
// Discover existing server or find a free port
|
|
29
|
+
const discovery = await findPort(cliPort)
|
|
30
|
+
|
|
31
|
+
let serverPort = discovery.port
|
|
32
|
+
|
|
33
|
+
if (discovery.hasServer) {
|
|
34
|
+
log(`[mcp] Found existing HTTP server on port ${serverPort}`)
|
|
35
|
+
} else {
|
|
36
|
+
log(`[mcp] No HTTP server found, starting inline on port ${serverPort}`)
|
|
37
|
+
|
|
38
|
+
// Start HTTP server inline (production mode)
|
|
39
|
+
const config: ServerConfig = {
|
|
40
|
+
port: serverPort,
|
|
41
|
+
tunnelUrl,
|
|
42
|
+
verbose,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const server = await startServer(config)
|
|
46
|
+
serverPort = server.port
|
|
47
|
+
log(`[mcp] HTTP server started on port ${serverPort}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await startMcpServer(serverPort, log)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch((error) => {
|
|
54
|
+
log('[mcp] Fatal error:', error)
|
|
55
|
+
process.exit(1)
|
|
56
|
+
})
|