@dahawa/hawa-cli-analysis 1.0.4
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/.tools.json +7 -0
- package/.vscode/launch.json +27 -0
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/_uclaude.js +165 -0
- package/anthropic-transformer.js +986 -0
- package/api-anthropic.js +279 -0
- package/api-openai.js +539 -0
- package/claude/claude-openai-proxy.js +305 -0
- package/claude/claude-proxy.js +341 -0
- package/clogger-openai.js +190 -0
- package/clogger.js +318 -0
- package/codex/mcp-client.js +556 -0
- package/codex/mcpclient.js +118 -0
- package/codex/mcpserver.js +374 -0
- package/codex/mcpserverproxy.js +144 -0
- package/codex/test.js +30 -0
- package/config.js +105 -0
- package/index.js +0 -0
- package/logger-manager.js +85 -0
- package/logger.js +112 -0
- package/mcp/claude-mcpproxy-launcher.js +5 -0
- package/mcp_oauth_tokens.js +40 -0
- package/package.json +36 -0
- package/port-manager.js +80 -0
- package/simple-transform-example.js +213 -0
- package/tests/test-lazy-load.js +36 -0
- package/tests/test.js +30 -0
- package/tests/test_mcp_proxy.js +51 -0
- package/tests/test_supabase_mcp.js +42 -0
- package/uclaude.js +221 -0
- package/ucodex-proxy.js +173 -0
- package/ucodex.js +129 -0
- package/untils.js +261 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth-transparent MCP Client (Node.js, JavaScript)
|
|
3
|
+
*
|
|
4
|
+
* Goals:
|
|
5
|
+
* - Same high-level interface as `@modelcontextprotocol/sdk/client`'s Client:
|
|
6
|
+
* - connect(transport)
|
|
7
|
+
* - listPrompts()
|
|
8
|
+
* - getPrompt({ name, arguments })
|
|
9
|
+
* - listResources()
|
|
10
|
+
* - readResource({ uri })
|
|
11
|
+
* - listTools()
|
|
12
|
+
* - callTool({ name, arguments })
|
|
13
|
+
* - No dependency on `@modelcontextprotocol/sdk/client`.
|
|
14
|
+
* - Adds automatic OAuth 2.0 (Authorization Code + PKCE) sign-in.
|
|
15
|
+
* The client detects missing/expired auth and performs the flow transparently.
|
|
16
|
+
* - Supports OAuth metadata discovery (RFC 8414) and Dynamic Client Registration (RFC 7591).
|
|
17
|
+
*
|
|
18
|
+
* Transport supported here:
|
|
19
|
+
* - Streamable HTTP (JSON-RPC 2.0 over HTTP). Stdio can be added by implementing
|
|
20
|
+
* a Transport with a `send(method, params)` function and passing into Client.connect().
|
|
21
|
+
*
|
|
22
|
+
* Usage Example (Cloudflare Radar, auto discovery + auto registration):
|
|
23
|
+
* import { Client, StreamableHTTPClientTransport } from './mcp-client.js'
|
|
24
|
+
*
|
|
25
|
+
* const issuer = 'https://radar.mcp.cloudflare.com'
|
|
26
|
+
* const transport = new StreamableHTTPClientTransport(`${issuer}/mcp`, {
|
|
27
|
+
* oauth: {
|
|
28
|
+
* issuer, // auto-discovery of /.well-known/oauth-authorization-server
|
|
29
|
+
* // discoveryUrl: `${issuer}/.well-known/oauth-authorization-server`, // optional explicit
|
|
30
|
+
* // Do not provide clientId => dynamic client registration to /register
|
|
31
|
+
* scopes: ['openid','profile','email','offline_access'],
|
|
32
|
+
* redirectUri: 'http://127.0.0.1:53175/callback',
|
|
33
|
+
* tokenStorePath: '.mcp_oauth_tokens.json',
|
|
34
|
+
* clientName: 'demo-auto-reg',
|
|
35
|
+
* debug: true, // enable helpful logs
|
|
36
|
+
* authTimeoutMs: 180000 // 3 minutes timeout to avoid hanging
|
|
37
|
+
* }
|
|
38
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* const client = new Client({ name: 'radar-demo', version: '1.0.0' })
|
|
41
|
+
* await client.connect(transport)
|
|
42
|
+
* console.log(await client.listTools())
|
|
43
|
+
* // const result = await client.callTool({ name: 'some_tool', arguments: {} })
|
|
44
|
+
* // console.log(result)
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createServer } from 'node:http'
|
|
48
|
+
import { randomBytes, createHash } from 'node:crypto'
|
|
49
|
+
import { spawn } from 'node:child_process'
|
|
50
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
51
|
+
import { URL, URLSearchParams } from 'node:url'
|
|
52
|
+
import LogManager from "../logger-manager.js";
|
|
53
|
+
const logger = LogManager.getSystemLogger();
|
|
54
|
+
|
|
55
|
+
/** Utility: open system browser cross-platform */
|
|
56
|
+
function openInBrowser(url) {
|
|
57
|
+
const href = typeof url === 'string' ? url : url.href
|
|
58
|
+
const platform = process.platform
|
|
59
|
+
logger.debug('[oauth] opening browser at:' + platform , href);
|
|
60
|
+
try {
|
|
61
|
+
if (platform === 'darwin') {
|
|
62
|
+
spawn('open', [href], { stdio: 'ignore', detached: true }).unref()
|
|
63
|
+
} else if (platform === 'win32') {
|
|
64
|
+
spawn('cmd', ['/c', 'start', '""', href.replace(/&/g, '^&')], { stdio: 'ignore', detached: true }).unref()
|
|
65
|
+
} else {
|
|
66
|
+
const candidates = ['xdg-open', 'x-www-browser', 'gnome-open', 'kde-open']
|
|
67
|
+
spawn(candidates[0], [href], { stdio: 'ignore', detached: true }).unref()
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
logger.warn('[oauth] failed to open browser automatically. Please open this URL manually:', href)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Simple JSON file token store */
|
|
75
|
+
class TokenStore {
|
|
76
|
+
constructor(filePath = '.mcp_oauth_tokens.json') {
|
|
77
|
+
this.filePath = filePath
|
|
78
|
+
this.cache = existsSync(filePath) ? JSON.parse(readFileSync(filePath, 'utf-8')) : {}
|
|
79
|
+
}
|
|
80
|
+
get(key) { return this.cache[key] }
|
|
81
|
+
set(key, value) { this.cache[key] = value; writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2)) }
|
|
82
|
+
delete(key) { delete this.cache[key]; writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2)) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** OAuth2 PKCE helper with Discovery + Dynamic Client Registration */
|
|
86
|
+
class OAuthManager {
|
|
87
|
+
constructor({ issuer, discoveryUrl, authorizationUrl, tokenUrl, registrationUrl, clientId, scopes = [], redirectUri, tokenStorePath, clientName = 'mcp-client-js', debug = true, authTimeoutMs = 180000 }) {
|
|
88
|
+
// Config (some may be discovered later)
|
|
89
|
+
this.issuer = issuer ? new URL(issuer).origin : undefined
|
|
90
|
+
this.discoveryUrl = discoveryUrl ? new URL(discoveryUrl) : undefined
|
|
91
|
+
this.authorizationUrl = authorizationUrl ? new URL(authorizationUrl) : undefined
|
|
92
|
+
this.tokenUrl = tokenUrl ? new URL(tokenUrl) : undefined
|
|
93
|
+
this.registrationUrl = registrationUrl ? new URL(registrationUrl) : undefined
|
|
94
|
+
this.clientId = clientId
|
|
95
|
+
this.clientName = clientName
|
|
96
|
+
this.scopes = scopes
|
|
97
|
+
this.redirectUri = new URL(redirectUri)
|
|
98
|
+
this.store = new TokenStore(tokenStorePath)
|
|
99
|
+
this.debug = !!debug
|
|
100
|
+
this.authTimeoutMs = authTimeoutMs || 180000
|
|
101
|
+
|
|
102
|
+
// distinct keys so rotating server metadata doesn't clobber tokens
|
|
103
|
+
this.metaKey = `meta|${this.issuer || this.authorizationUrl?.origin || this.tokenUrl?.origin}`
|
|
104
|
+
this.storeKey = `tokens|${this.tokenUrl?.origin || this.issuer}|${this.clientId || 'auto'}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Ensure we have server metadata (RFC 8414) and endpoints. */
|
|
108
|
+
async discoverIfNeeded() {
|
|
109
|
+
let meta = this.store.get(this.metaKey)
|
|
110
|
+
if (!this.authorizationUrl || !this.tokenUrl || !this.registrationUrl) {
|
|
111
|
+
// Try in-memory meta first
|
|
112
|
+
if (!meta) {
|
|
113
|
+
let base
|
|
114
|
+
if (this.discoveryUrl) {
|
|
115
|
+
base = this.discoveryUrl
|
|
116
|
+
} else if (this.issuer) {
|
|
117
|
+
base = new URL(`${this.issuer}/.well-known/oauth-authorization-server`)
|
|
118
|
+
} else if (this.authorizationUrl) {
|
|
119
|
+
base = new URL(`${this.authorizationUrl.origin}/.well-known/oauth-authorization-server`)
|
|
120
|
+
} else if (this.tokenUrl) {
|
|
121
|
+
base = new URL(`${this.tokenUrl.origin}/.well-known/oauth-authorization-server`)
|
|
122
|
+
} else {
|
|
123
|
+
throw new Error('OAuth discovery requires issuer, discoveryUrl, authorizationUrl, or tokenUrl')
|
|
124
|
+
}
|
|
125
|
+
const resp = await fetch(base)
|
|
126
|
+
if (!resp.ok) throw new Error(`OAuth discovery failed: ${resp.status}`)
|
|
127
|
+
meta = await resp.json()
|
|
128
|
+
this.store.set(this.metaKey, meta)
|
|
129
|
+
if (this.debug) logger.debug('[oauth] discovery metadata:', meta)
|
|
130
|
+
}
|
|
131
|
+
if (!this.authorizationUrl && meta.authorization_endpoint) this.authorizationUrl = new URL(meta.authorization_endpoint)
|
|
132
|
+
if (!this.tokenUrl && meta.token_endpoint) this.tokenUrl = new URL(meta.token_endpoint)
|
|
133
|
+
if (!this.registrationUrl && meta.registration_endpoint) this.registrationUrl = new URL(meta.registration_endpoint)
|
|
134
|
+
|
|
135
|
+
if (this.debug) {
|
|
136
|
+
logger.debug('[oauth] discovered endpoints:', {
|
|
137
|
+
authorization_endpoint: this.authorizationUrl?.href,
|
|
138
|
+
token_endpoint: this.tokenUrl?.href,
|
|
139
|
+
registration_endpoint: this.registrationUrl?.href || '(none)'
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 从缓存中加载 client_id
|
|
146
|
+
*/
|
|
147
|
+
async loadClientId() {
|
|
148
|
+
const regKey = `reg|${this.authorizationUrl.origin}|${this.redirectUri.href}`
|
|
149
|
+
const record = this.store.get(regKey)
|
|
150
|
+
if (record) {
|
|
151
|
+
logger.debug('[oauth] loaded client_id from cache:', record.client_id)
|
|
152
|
+
return record.client_id
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
/** Ensure we have a client_id, using Dynamic Client Registration (RFC 7591) if needed. */
|
|
159
|
+
async ensureClientId() {
|
|
160
|
+
await this.discoverIfNeeded();
|
|
161
|
+
if (this.clientId) {
|
|
162
|
+
this.storeKey = `tokens|${this.tokenUrl.origin}|${this.clientId}`
|
|
163
|
+
return this.clientId
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.clientId = await this.loadClientId();
|
|
167
|
+
if(this.clientId){
|
|
168
|
+
this.storeKey = `tokens|${this.tokenUrl.origin}|${this.clientId}`
|
|
169
|
+
return this.clientId
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!this.registrationUrl) {
|
|
173
|
+
if (this.debug) logger.warn('[oauth] no registration_endpoint; dynamic registration disabled. You must set oauth.clientId explicitly.')
|
|
174
|
+
throw new Error('Server does not advertise dynamic client registration; please supply oauth.clientId')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const body = {
|
|
178
|
+
client_name: this.clientName,
|
|
179
|
+
application_type: 'native', // desktop/cli style public client
|
|
180
|
+
redirect_uris: [this.redirectUri.href],
|
|
181
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
182
|
+
response_types: ['code'],
|
|
183
|
+
token_endpoint_auth_method: 'none', // PKCE public client
|
|
184
|
+
scope: this.scopes.join(' '),
|
|
185
|
+
}
|
|
186
|
+
const resp = await fetch(this.registrationUrl, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: { 'Content-Type': 'application/json' },
|
|
189
|
+
body: JSON.stringify(body)
|
|
190
|
+
})
|
|
191
|
+
if (!resp.ok) {
|
|
192
|
+
logger.error('[oauth] client registration failed:', await resp.text())
|
|
193
|
+
throw new Error(`Client registration failed: ${resp.status}`)
|
|
194
|
+
}
|
|
195
|
+
const json = await resp.json()
|
|
196
|
+
this.clientId = json.client_id
|
|
197
|
+
const regKey = `reg|${this.authorizationUrl.origin}|${this.redirectUri.href}`
|
|
198
|
+
this.store.set(regKey, { client_id: this.clientId, obtained_at: Date.now() })
|
|
199
|
+
this.storeKey = `tokens|${this.tokenUrl.origin}|${this.clientId}`
|
|
200
|
+
if (this.debug) logger.debug('[oauth] dynamic client registered:', this.clientId)
|
|
201
|
+
return this.clientId
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Return a valid access token (refresh if needed, or run full flow). */
|
|
205
|
+
async getAccessToken() {
|
|
206
|
+
await this.discoverIfNeeded()
|
|
207
|
+
await this.ensureClientId().catch((e) => { throw e })
|
|
208
|
+
const record = this.store.get(this.storeKey)
|
|
209
|
+
if (record) {
|
|
210
|
+
const { access_token, expires_at, refresh_token } = record
|
|
211
|
+
// 检查访问令牌是否有效
|
|
212
|
+
if (expires_at && Date.now() < expires_at - 30_000) {
|
|
213
|
+
if (this.debug) logger.debug('[oauth] reuse cached token (valid)')
|
|
214
|
+
return access_token
|
|
215
|
+
}
|
|
216
|
+
// 如果访问令牌已过期,尝试使用刷新令牌获取新令牌
|
|
217
|
+
if (refresh_token) {
|
|
218
|
+
try {
|
|
219
|
+
if (this.debug) logger.debug('[oauth] refreshing access token...')
|
|
220
|
+
return await this.refresh(refresh_token)
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (this.debug) logger.warn('[oauth] refresh failed, falling back to full auth:', err?.message)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return await this.runAuthCodeFlow()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async refresh(refresh_token) {
|
|
230
|
+
const body = new URLSearchParams({
|
|
231
|
+
grant_type: 'refresh_token',
|
|
232
|
+
refresh_token,
|
|
233
|
+
client_id: this.clientId,
|
|
234
|
+
})
|
|
235
|
+
const resp = await fetch(this.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body })
|
|
236
|
+
if (!resp.ok) throw new Error(`OAuth refresh failed: ${resp.status}`)
|
|
237
|
+
const json = await resp.json()
|
|
238
|
+
this.persistTokens(json)
|
|
239
|
+
if (this.debug) logger.debug('[oauth] refresh ok; expires_in:', json.expires_in)
|
|
240
|
+
return json.access_token
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Full PKCE flow with loopback listener on redirectUri. */
|
|
244
|
+
async runAuthCodeFlow() {
|
|
245
|
+
const { verifier, challenge } = this.generatePkce()
|
|
246
|
+
const state = randomBytes(16).toString('hex')
|
|
247
|
+
|
|
248
|
+
// Start a tiny loopback server to capture the code
|
|
249
|
+
const { server, codePromise } = this.listenForCode(state)
|
|
250
|
+
|
|
251
|
+
const authUrl = new URL(this.authorizationUrl)
|
|
252
|
+
authUrl.searchParams.set('response_type', 'code')
|
|
253
|
+
authUrl.searchParams.set('client_id', this.clientId)
|
|
254
|
+
authUrl.searchParams.set('redirect_uri', this.redirectUri.href)
|
|
255
|
+
authUrl.searchParams.set('scope', this.scopes.join(' '))
|
|
256
|
+
authUrl.searchParams.set('code_challenge', challenge)
|
|
257
|
+
authUrl.searchParams.set('code_challenge_method', 'S256')
|
|
258
|
+
authUrl.searchParams.set('state', state)
|
|
259
|
+
|
|
260
|
+
if (this.debug) logger.debug('[oauth] opening authorize URL:', authUrl.href)
|
|
261
|
+
//这里只需要打开授权地址,随后本地回调服务器会接收授权码并继续 PKCE 流程获取访问令牌
|
|
262
|
+
openInBrowser(authUrl)
|
|
263
|
+
|
|
264
|
+
// timeout guard to avoid hanging forever
|
|
265
|
+
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error('OAuth authorize timed out')), this.authTimeoutMs))
|
|
266
|
+
const { code, recvState } = await Promise.race([codePromise, timeout])
|
|
267
|
+
|
|
268
|
+
server.close()
|
|
269
|
+
if (recvState !== state) throw new Error('OAuth state mismatch')
|
|
270
|
+
|
|
271
|
+
const body = new URLSearchParams({
|
|
272
|
+
grant_type: 'authorization_code',
|
|
273
|
+
code,
|
|
274
|
+
redirect_uri: this.redirectUri.href,
|
|
275
|
+
client_id: this.clientId,
|
|
276
|
+
code_verifier: verifier,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
//这里获取失败
|
|
280
|
+
const resp = await fetch(this.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body })
|
|
281
|
+
if (!resp.ok) {
|
|
282
|
+
logger.error('[oauth] token exchange failed url:', this.tokenUrl + "");
|
|
283
|
+
logger.error('[oauth] token exchange failed body:', body + "");
|
|
284
|
+
logger.error('[oauth] token exchange failed response:', await resp.text());
|
|
285
|
+
throw new Error(`OAuth token exchange failed: ${resp.status}`);
|
|
286
|
+
}
|
|
287
|
+
const json = await resp.json()
|
|
288
|
+
this.persistTokens(json)
|
|
289
|
+
if (this.debug) logger.debug('[oauth] auth ok; expires_in:', json.expires_in)
|
|
290
|
+
return json.access_token
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
generatePkce() {
|
|
294
|
+
const verifier = randomBytes(32).toString('base64url')
|
|
295
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
|
296
|
+
return { verifier, challenge }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
listenForCode(expectedState) {
|
|
300
|
+
const url = new URL(this.redirectUri)
|
|
301
|
+
const port = Number(url.port || 80)
|
|
302
|
+
const pathname = url.pathname
|
|
303
|
+
|
|
304
|
+
let resolve, reject
|
|
305
|
+
const codePromise = new Promise((res, rej) => { resolve = res; reject = rej })
|
|
306
|
+
|
|
307
|
+
const server = createServer((req, res) => {
|
|
308
|
+
try {
|
|
309
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end('Method Not Allowed'); return }
|
|
310
|
+
const reqUrl = new URL(req.url, `${url.protocol}//${url.host}`)
|
|
311
|
+
if (reqUrl.pathname !== pathname) { res.statusCode = 404; res.end('Not Found'); return }
|
|
312
|
+
const code = reqUrl.searchParams.get('code')
|
|
313
|
+
const state = reqUrl.searchParams.get('state')
|
|
314
|
+
|
|
315
|
+
logger.debug('[oauth] received code:', code);
|
|
316
|
+
logger.debug('[oauth] received state:', state);
|
|
317
|
+
|
|
318
|
+
if (!code) { res.statusCode = 400; res.end('Missing code'); return }
|
|
319
|
+
res.statusCode = 200
|
|
320
|
+
res.end('<html><body>Authentication complete. You may close this window.<script>window.close()</script></body></html>');
|
|
321
|
+
resolve({ code, recvState: state })
|
|
322
|
+
} catch (err) {
|
|
323
|
+
reject(err)
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
server.listen(port, '127.0.0.1')
|
|
327
|
+
return { server, codePromise }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
persistTokens(json) {
|
|
331
|
+
const expires_at = json.expires_in ? Date.now() + (json.expires_in * 1000) : undefined
|
|
332
|
+
const toStore = { ...json, expires_at }
|
|
333
|
+
this.store.set(this.storeKey, toStore)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Minimal Streamable HTTP client transport (JSON-RPC 2.0 over HTTP with sessions)
|
|
339
|
+
*
|
|
340
|
+
* Server must support MCP Streamable HTTP endpoint. This transport manages:
|
|
341
|
+
* - Session header (Mcp-Session-Id) from the init response
|
|
342
|
+
* - OAuth bearer token acquisition/refresh via OAuthManager when provided
|
|
343
|
+
*/
|
|
344
|
+
export class StreamableHTTPClientTransport {
|
|
345
|
+
constructor(baseUrl, { oauth } = {}) {
|
|
346
|
+
this.baseUrl = new URL(baseUrl)
|
|
347
|
+
this.oauth = oauth ? new OAuthManager(oauth) : null
|
|
348
|
+
this.sessionId = null
|
|
349
|
+
this.seq = 0
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 在文件顶部或类里工具函数处加上:
|
|
353
|
+
async readJSONorSSE(resp) {
|
|
354
|
+
const ct = (resp.headers.get('content-type') || '').toLowerCase()
|
|
355
|
+
if (ct.startsWith('application/json')) {
|
|
356
|
+
return await resp.json()
|
|
357
|
+
}
|
|
358
|
+
if (!ct.startsWith('text/event-stream')) {
|
|
359
|
+
// 兜底:不是 JSON 也不是 SSE,就按文本丢错
|
|
360
|
+
const text = await resp.text().catch(() => '')
|
|
361
|
+
logger.debug(`Unsupported content-type: ${ct}. Body: ${text}`);
|
|
362
|
+
return {result: {}, jsonrpc: '2.0'}
|
|
363
|
+
//throw new Error(`Unsupported content-type: ${ct}. Body: ${text}`)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// === 解析 SSE ===
|
|
367
|
+
const reader = resp.body.getReader()
|
|
368
|
+
const decoder = new TextDecoder()
|
|
369
|
+
let buffer = ''
|
|
370
|
+
let lastJson = null
|
|
371
|
+
|
|
372
|
+
const flushEvent = (evt) => {
|
|
373
|
+
// evt 可能包含多行 data:,把它们拼起来
|
|
374
|
+
const payload = (evt.data || []).join('\n')
|
|
375
|
+
if (!payload) return
|
|
376
|
+
try {
|
|
377
|
+
const obj = JSON.parse(payload)
|
|
378
|
+
// 只关心 JSON-RPC 完整消息;中间进度事件可按需处理
|
|
379
|
+
if (obj?.jsonrpc === '2.0' && (obj.result || obj.error || obj.id !== undefined)) {
|
|
380
|
+
lastJson = obj
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// 不是 JSON 的心跳/注释,忽略
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let evt = { event: null, data: [] } // 当前事件累积器
|
|
388
|
+
|
|
389
|
+
while (true) {
|
|
390
|
+
const { done, value } = await reader.read()
|
|
391
|
+
if (done) break
|
|
392
|
+
buffer += decoder.decode(value, { stream: true })
|
|
393
|
+
|
|
394
|
+
// SSE 以 \n 分隔;空行表示一个事件结束
|
|
395
|
+
let idx
|
|
396
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
397
|
+
const lineRaw = buffer.slice(0, idx)
|
|
398
|
+
buffer = buffer.slice(idx + 1)
|
|
399
|
+
const line = lineRaw.replace(/\r$/, '')
|
|
400
|
+
|
|
401
|
+
if (line === '') { // 事件结束
|
|
402
|
+
flushEvent(evt)
|
|
403
|
+
evt = { event: null, data: [] }
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
if (line.startsWith(':')) { // 注释/心跳
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
if (line.startsWith('event:')) {
|
|
410
|
+
evt.event = line.slice(6).trim()
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
if (line.startsWith('data:')) {
|
|
414
|
+
evt.data.push(line.slice(5).trimStart())
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
// 可选:处理 id:/retry: 等字段,这里用不到
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!lastJson) {
|
|
422
|
+
throw new Error('SSE stream ended without a JSON-RPC message')
|
|
423
|
+
}
|
|
424
|
+
return lastJson
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
/** Low-level send of a JSON-RPC request. */
|
|
430
|
+
async send(method, params) {
|
|
431
|
+
const id = ++this.seq
|
|
432
|
+
const body = { jsonrpc: '2.0', id, method, params }
|
|
433
|
+
//不需要 id 并且没有返回
|
|
434
|
+
if(method == "notifications/initialized"){
|
|
435
|
+
delete body.id;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const headers = { 'Content-Type': 'application/json' , 'Accept': 'application/json, text/event-stream' }
|
|
439
|
+
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId
|
|
440
|
+
|
|
441
|
+
// Try attach OAuth if configured
|
|
442
|
+
if (this.oauth) {
|
|
443
|
+
const token = await this.oauth.getAccessToken().catch((e) => {
|
|
444
|
+
logger.warn('[oauth] getAccessToken failed:', e?.message)
|
|
445
|
+
return null
|
|
446
|
+
})
|
|
447
|
+
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
//logger.debug('[oauth] sending request:', JSON.stringify({ method, params, headers , body }, null, 2))
|
|
451
|
+
|
|
452
|
+
let resp = await fetch(this.baseUrl, { method: 'POST', headers, body: JSON.stringify(body) })
|
|
453
|
+
|
|
454
|
+
// If unauthorized and OAuth is configured, try once to re-auth then retry
|
|
455
|
+
if (resp.status === 401 && this.oauth) {
|
|
456
|
+
const token = await this.oauth.runAuthCodeFlow().catch((e) => {
|
|
457
|
+
logger.warn('[oauth] re-auth failed:', e?.message)
|
|
458
|
+
return null
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
if (!token) {
|
|
462
|
+
const text = await resp.text().catch(() => '')
|
|
463
|
+
throw new Error(`HTTP 401 Unauthorized. Body: ${text}`)
|
|
464
|
+
}
|
|
465
|
+
headers['Authorization'] = `Bearer ${token}`
|
|
466
|
+
resp = await fetch(this.baseUrl, { method: 'POST', headers, body: JSON.stringify(body) })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!resp.ok) {
|
|
470
|
+
const text = await resp.text().catch(() => '')
|
|
471
|
+
throw new Error(`HTTP ${resp.status} ${resp.statusText} ${text}`)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Capture session id if provided
|
|
475
|
+
const sid = resp.headers.get('Mcp-Session-Id')
|
|
476
|
+
if (sid) this.sessionId = sid
|
|
477
|
+
|
|
478
|
+
// 这里返回可能是 text/event-stream 流式返回
|
|
479
|
+
|
|
480
|
+
//logger.debug('[oauth] token exchange response:', await resp.clone().text());
|
|
481
|
+
//const json = await resp.json()
|
|
482
|
+
const json = await this.readJSONorSSE(resp);
|
|
483
|
+
|
|
484
|
+
//logger.debug('[oauth] token exchange response:', json);
|
|
485
|
+
if (json.error) {
|
|
486
|
+
const e = new Error(json.error.message || 'RPC Error')
|
|
487
|
+
e.code = json.error.code
|
|
488
|
+
e.data = json.error.data
|
|
489
|
+
throw e
|
|
490
|
+
}
|
|
491
|
+
return json.result
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* High-level MCP Client (SDK-compatible surface)
|
|
497
|
+
*/
|
|
498
|
+
export class Client {
|
|
499
|
+
constructor({ name, version } = {}) {
|
|
500
|
+
this.name = name || 'mcp-client-js'
|
|
501
|
+
this.version = version || '0.0.0'
|
|
502
|
+
this.connected = false
|
|
503
|
+
this.transport = null
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async connect(transport) {
|
|
507
|
+
this.transport = transport
|
|
508
|
+
// Initialize per MCP spec
|
|
509
|
+
const init = await this.transport.send('initialize', {
|
|
510
|
+
protocolVersion: '2025-05-15', // pick a modern version; adjust if your server differs
|
|
511
|
+
capabilities: {
|
|
512
|
+
prompts: {}, resources: {}, tools: {}, sampling: {},
|
|
513
|
+
},
|
|
514
|
+
clientInfo: { name: this.name, version: this.version }
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
this._initializeInfo = init;
|
|
518
|
+
|
|
519
|
+
// Acknowledge ready
|
|
520
|
+
await this.transport.send('notifications/initialized', {})
|
|
521
|
+
this.connected = true
|
|
522
|
+
this.serverInfo = init?.serverInfo
|
|
523
|
+
this.capabilities = init?.capabilities
|
|
524
|
+
return init
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// === Prompts ===
|
|
528
|
+
async listPrompts() {
|
|
529
|
+
return await this.transport.send('prompts/list', {})
|
|
530
|
+
}
|
|
531
|
+
async getPrompt({ name, arguments: args = {} }) {
|
|
532
|
+
return await this.transport.send('prompts/get', { name, arguments: args })
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// === Resources ===
|
|
536
|
+
async listResources() {
|
|
537
|
+
return await this.transport.send('resources/list', {})
|
|
538
|
+
}
|
|
539
|
+
async readResource({ uri }) {
|
|
540
|
+
return await this.transport.send('resources/read', { uri })
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// === Tools ===
|
|
544
|
+
async listTools() {
|
|
545
|
+
return await this.transport.send('tools/list', {})
|
|
546
|
+
}
|
|
547
|
+
async callTool({ name, arguments: args = {} }) {
|
|
548
|
+
return await this.transport.send('tools/call', { name, arguments: args })
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// === Convenience ===
|
|
552
|
+
get isConnected() { return this.connected }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Optional: default export
|
|
556
|
+
export default { Client, StreamableHTTPClientTransport }
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { getPipePath } from '../untils.js';
|
|
3
|
+
import LogManager from '../logger-manager.js';
|
|
4
|
+
const logger = LogManager.getSystemLogger();
|
|
5
|
+
|
|
6
|
+
const PIPE_PATH = await getPipePath();
|
|
7
|
+
logger.debug("JsonRpcClient PIPE_PATH:" + PIPE_PATH);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 使用文件协议通信的 JSON-RPC 客户端
|
|
11
|
+
*/
|
|
12
|
+
export default class JsonRpcClient {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.socket = net.createConnection(PIPE_PATH);
|
|
15
|
+
this.nextId = 1;
|
|
16
|
+
this.pending = new Map();
|
|
17
|
+
this.connectionError = null;
|
|
18
|
+
this.connected = false;
|
|
19
|
+
this.connectionTimeout = 5000; // 5秒连接超时
|
|
20
|
+
let buf = '';
|
|
21
|
+
|
|
22
|
+
// 连接成功
|
|
23
|
+
this.socket.on('connect', () => {
|
|
24
|
+
logger.info('JSON-RPC client connected to', PIPE_PATH);
|
|
25
|
+
this.connected = true;
|
|
26
|
+
this.connectionError = null;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 连接错误
|
|
30
|
+
this.socket.on('error', (e) => {
|
|
31
|
+
logger.error('JSON-RPC client connection error:', e.message);
|
|
32
|
+
this.connectionError = e;
|
|
33
|
+
this.connected = false;
|
|
34
|
+
for (const [, p] of this.pending) p.reject(e);
|
|
35
|
+
this.pending.clear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 连接关闭
|
|
39
|
+
this.socket.on('close', () => {
|
|
40
|
+
logger.info('JSON-RPC client connection closed');
|
|
41
|
+
let con = this.connected;
|
|
42
|
+
this.connected = false;
|
|
43
|
+
if(con){
|
|
44
|
+
this.connectionError = new Error('Connection closed');
|
|
45
|
+
//如果根本没有链接过,应该是链接失败
|
|
46
|
+
}else{
|
|
47
|
+
this.connectionError = new Error('Connection closed ,请检查服务是否启动');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// 数据接收处理
|
|
53
|
+
this.socket.on('data', (chunk) => {
|
|
54
|
+
buf += chunk;
|
|
55
|
+
for (let i = buf.indexOf('\n'); i >= 0; i = buf.indexOf('\n')) {
|
|
56
|
+
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
59
|
+
const p = this.pending.get(msg.id);
|
|
60
|
+
if (p) {
|
|
61
|
+
this.pending.delete(msg.id);
|
|
62
|
+
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 等待连接建立或超时
|
|
70
|
+
async waitForConnection() {
|
|
71
|
+
if (this.connected) return;
|
|
72
|
+
if (this.connectionError) throw this.connectionError;
|
|
73
|
+
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const timeout = setTimeout(() => {
|
|
76
|
+
reject(new Error(`Connection timeout: Server not responding at ${PIPE_PATH}`));
|
|
77
|
+
}, this.connectionTimeout);
|
|
78
|
+
|
|
79
|
+
this.socket.once('connect', () => {
|
|
80
|
+
clearTimeout(timeout);
|
|
81
|
+
resolve();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.socket.once('error', (err) => {
|
|
85
|
+
clearTimeout(timeout);
|
|
86
|
+
reject(new Error(`Connection failed: ${err.message}`));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async call(method, params) {
|
|
92
|
+
// 检查连接状态
|
|
93
|
+
await this.waitForConnection();
|
|
94
|
+
|
|
95
|
+
const id = this.nextId++;
|
|
96
|
+
const req = { jsonrpc: '2.0', id, method, params };
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
this.pending.set(id, { resolve, reject });
|
|
99
|
+
this.socket.write(JSON.stringify(req) + '\n');
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
// demo
|
|
106
|
+
(async () => {
|
|
107
|
+
const cli = new JsonRpcClient(PIPE_PATH);
|
|
108
|
+
try {
|
|
109
|
+
logger.debug('ping =>', await cli.call('ping'));
|
|
110
|
+
logger.debug('echo =>', await cli.call('echo', 'hello'));
|
|
111
|
+
logger.debug('add =>', await cli.call('add', [1, 2]));
|
|
112
|
+
} catch (e) {
|
|
113
|
+
logger.error('RPC error:', e.message);
|
|
114
|
+
} finally {
|
|
115
|
+
setTimeout(() => process.exit(0), 200);
|
|
116
|
+
}
|
|
117
|
+
})();
|
|
118
|
+
*/
|