dsh-bili-scroll 0.1.5 → 0.1.6
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/AGENTS.md +55 -50
- package/README.md +147 -137
- package/client.js +555 -522
- package/cordis.patch.yml +20 -20
- package/index.js +274 -54
- package/lib/login.mjs +103 -103
- package/lib/mcp.mjs +119 -115
- package/lib/state.mjs +151 -100
- package/lib/verify.mjs +179 -186
- package/package.json +72 -75
- package/prompts/duty.md +139 -124
- package/selfcheck.mjs +119 -0
- package/vendor/bilibili-mcp/mcp_server.py +1415 -1364
- package/vendor/bilibili-mcp/requirements.txt +8 -8
package/lib/login.mjs
CHANGED
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/login.mjs —— 扫码登录(直接在 JS 里走 B站 passport 接口)
|
|
3
|
-
* ============================================================================
|
|
4
|
-
* 为什么不用它自带的 `bili_login`:那个把码存成 PNG 就返回,**字段全是空**(踩过一次,见 lesson)。
|
|
5
|
-
* 我们按实测跑通的那条来:
|
|
6
|
-
* ① GET /x/passport-login/web/qrcode/generate?source=main-fe-header → data.url + data.qrcode_key
|
|
7
|
-
* ② 手机扫 → 轮询 /x/passport-login/web/qrcode/poll?qrcode_key=…
|
|
8
|
-
* data.code:86101 未扫 / 86090 已扫待确认 / 0 成功 / 86038 过期
|
|
9
|
-
* ③ ⭐ **凭证在轮询响应的 `Set-Cookie` 头里**(成功时那条 `data.url` 里没有 SESSDATA,
|
|
10
|
-
* 那是别的域的游戏票 —— 别在 url 里找)
|
|
11
|
-
* ④ 写成 MCP 认识的四个字段:sessdata / bili_jct / buvid3 / dedeuserid
|
|
12
|
-
*
|
|
13
|
-
* 二维码图片:交给 venv 里的 Python `qrcode` 画(ASCII 的 URL,走 `-c` 安全;
|
|
14
|
-
* 没装就退回"只给 URL",前端会提示先跑「建 venv 装依赖」)。
|
|
15
|
-
* ============================================================================
|
|
16
|
-
*/
|
|
17
|
-
import { spawn } from 'node:child_process'
|
|
18
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
19
|
-
import { join } from 'node:path'
|
|
20
|
-
|
|
21
|
-
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
|
|
22
|
-
const GEN = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header'
|
|
23
|
-
const pollUrl = (key) => 'https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=' + encodeURIComponent(key) + '&source=main-fe-header'
|
|
24
|
-
const SPI = 'https://api.bilibili.com/x/frontend/finger/spi'
|
|
25
|
-
|
|
26
|
-
/** 从 Set-Cookie 数组里抠出我们要的几个 */
|
|
27
|
-
function pickCookies(list) {
|
|
28
|
-
const jar = {}
|
|
29
|
-
const want = { SESSDATA: 'sessdata', bili_jct: 'bili_jct', DedeUserID: 'dedeuserid', buvid3: 'buvid3' }
|
|
30
|
-
for (const raw of list ?? []) {
|
|
31
|
-
const first = String(raw).split(';')[0] || ''
|
|
32
|
-
const i = first.indexOf('=')
|
|
33
|
-
if (i <= 0) continue
|
|
34
|
-
const name = first.slice(0, i).trim()
|
|
35
|
-
const value = first.slice(i + 1).trim()
|
|
36
|
-
if (want[name]) jar[want[name]] = value
|
|
37
|
-
}
|
|
38
|
-
return jar
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 拿不到 buvid3 时补一张"指纹"(非致命,失败就当空) */
|
|
42
|
-
async function ensureBuvid3(jar) {
|
|
43
|
-
if (jar.buvid3) return jar
|
|
44
|
-
try {
|
|
45
|
-
const r = await fetch(SPI, { headers: { 'User-Agent': UA } })
|
|
46
|
-
const j = await r.json()
|
|
47
|
-
const v = j?.data?.b_3
|
|
48
|
-
if (v) jar.buvid3 = v
|
|
49
|
-
} catch { /* 拿不到就算了 */ }
|
|
50
|
-
return jar
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function loginStart() {
|
|
54
|
-
const r = await fetch(GEN, { headers: { 'User-Agent': UA } })
|
|
55
|
-
const j = await r.json()
|
|
56
|
-
const data = j?.data
|
|
57
|
-
if (!data?.qrcode_key || !data?.url) throw new Error(`生成二维码失败:${j?.message || '返回里没有 qrcode_key'}`)
|
|
58
|
-
return { key: data.qrcode_key, url: data.url }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** 轮询一次。state: waiting / scanned / ok / expired / unknown */
|
|
62
|
-
export async function loginPoll(key) {
|
|
63
|
-
const res = await fetch(pollUrl(key), { headers: { 'User-Agent': UA }, redirect: 'manual' })
|
|
64
|
-
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
65
|
-
let body = {}
|
|
66
|
-
try { body = await res.json() } catch { /* poll 有时返回空体 */ }
|
|
67
|
-
const code = body?.data?.code
|
|
68
|
-
if (code === 0) {
|
|
69
|
-
const jar = await ensureBuvid3(pickCookies(setCookies))
|
|
70
|
-
if (!jar.sessdata) return { state: 'unknown', code, message: '登录成功了,但响应里没有 SESSDATA —— 把这次的结果记下来再排查' }
|
|
71
|
-
return { state: 'ok', code, credential: jar }
|
|
72
|
-
}
|
|
73
|
-
const map = { 86101: 'waiting', 86090: 'scanned', 86038: 'expired' }
|
|
74
|
-
return { state: map[code] ?? 'unknown', code, message: body?.message || '' }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** 把凭证写进 MCP 读的那个文件(家目录里的 bili_credential.json) */
|
|
78
|
-
export function saveCredential(stateDir, credential) {
|
|
79
|
-
mkdirSync(stateDir, { recursive: true })
|
|
80
|
-
const file = join(stateDir, 'bili_credential.json')
|
|
81
|
-
writeFileSync(file, JSON.stringify({
|
|
82
|
-
sessdata: credential.sessdata ?? '',
|
|
83
|
-
bili_jct: credential.bili_jct ?? '',
|
|
84
|
-
buvid3: credential.buvid3 || '',
|
|
85
|
-
dedeuserid: credential.dedeuserid ?? '',
|
|
86
|
-
}, null, 2), 'utf8')
|
|
87
|
-
return file
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** 用 Python 的 qrcode 画一张 PNG
|
|
91
|
-
export function renderQrPng(pythonPath, stateDir, url) {
|
|
92
|
-
return new Promise((resolve) => {
|
|
93
|
-
const png = join(stateDir, 'qrcode_login.png')
|
|
94
|
-
const code = 'import sys, qrcode; qrcode.make(sys.argv[1]).save(sys.argv[2])'
|
|
95
|
-
let child
|
|
96
|
-
try {
|
|
97
|
-
child = spawn(pythonPath || 'python', ['-c', code, url, png], { cwd: stateDir, windowsHide: true })
|
|
98
|
-
} catch { return resolve(null) }
|
|
99
|
-
const done = () => { try { resolve(existsSync(png) ? png : null) } catch { resolve(null) } }
|
|
100
|
-
child.on('exit', (c) => { if (c === 0) done(); else resolve(null) })
|
|
101
|
-
child.on('error', () => resolve(null))
|
|
102
|
-
})
|
|
103
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* lib/login.mjs —— 扫码登录(直接在 JS 里走 B站 passport 接口)
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 为什么不用它自带的 `bili_login`:那个把码存成 PNG 就返回,**字段全是空**(踩过一次,见 lesson)。
|
|
5
|
+
* 我们按实测跑通的那条来:
|
|
6
|
+
* ① GET /x/passport-login/web/qrcode/generate?source=main-fe-header → data.url + data.qrcode_key
|
|
7
|
+
* ② 手机扫 → 轮询 /x/passport-login/web/qrcode/poll?qrcode_key=…
|
|
8
|
+
* data.code:86101 未扫 / 86090 已扫待确认 / 0 成功 / 86038 过期
|
|
9
|
+
* ③ ⭐ **凭证在轮询响应的 `Set-Cookie` 头里**(成功时那条 `data.url` 里没有 SESSDATA,
|
|
10
|
+
* 那是别的域的游戏票 —— 别在 url 里找)
|
|
11
|
+
* ④ 写成 MCP 认识的四个字段:sessdata / bili_jct / buvid3 / dedeuserid
|
|
12
|
+
*
|
|
13
|
+
* 二维码图片:交给 venv 里的 Python `qrcode` 画(ASCII 的 URL,走 `-c` 安全;
|
|
14
|
+
* 没装就退回"只给 URL",前端会提示先跑「建 venv 装依赖」)。
|
|
15
|
+
* ============================================================================
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
|
|
22
|
+
const GEN = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header'
|
|
23
|
+
const pollUrl = (key) => 'https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=' + encodeURIComponent(key) + '&source=main-fe-header'
|
|
24
|
+
const SPI = 'https://api.bilibili.com/x/frontend/finger/spi'
|
|
25
|
+
|
|
26
|
+
/** 从 Set-Cookie 数组里抠出我们要的几个 */
|
|
27
|
+
function pickCookies(list) {
|
|
28
|
+
const jar = {}
|
|
29
|
+
const want = { SESSDATA: 'sessdata', bili_jct: 'bili_jct', DedeUserID: 'dedeuserid', buvid3: 'buvid3' }
|
|
30
|
+
for (const raw of list ?? []) {
|
|
31
|
+
const first = String(raw).split(';')[0] || ''
|
|
32
|
+
const i = first.indexOf('=')
|
|
33
|
+
if (i <= 0) continue
|
|
34
|
+
const name = first.slice(0, i).trim()
|
|
35
|
+
const value = first.slice(i + 1).trim()
|
|
36
|
+
if (want[name]) jar[want[name]] = value
|
|
37
|
+
}
|
|
38
|
+
return jar
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 拿不到 buvid3 时补一张"指纹"(非致命,失败就当空) */
|
|
42
|
+
async function ensureBuvid3(jar) {
|
|
43
|
+
if (jar.buvid3) return jar
|
|
44
|
+
try {
|
|
45
|
+
const r = await fetch(SPI, { headers: { 'User-Agent': UA } })
|
|
46
|
+
const j = await r.json()
|
|
47
|
+
const v = j?.data?.b_3
|
|
48
|
+
if (v) jar.buvid3 = v
|
|
49
|
+
} catch { /* 拿不到就算了 */ }
|
|
50
|
+
return jar
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function loginStart() {
|
|
54
|
+
const r = await fetch(GEN, { headers: { 'User-Agent': UA } })
|
|
55
|
+
const j = await r.json()
|
|
56
|
+
const data = j?.data
|
|
57
|
+
if (!data?.qrcode_key || !data?.url) throw new Error(`生成二维码失败:${j?.message || '返回里没有 qrcode_key'}`)
|
|
58
|
+
return { key: data.qrcode_key, url: data.url }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 轮询一次。state: waiting / scanned / ok / expired / unknown */
|
|
62
|
+
export async function loginPoll(key) {
|
|
63
|
+
const res = await fetch(pollUrl(key), { headers: { 'User-Agent': UA }, redirect: 'manual' })
|
|
64
|
+
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
65
|
+
let body = {}
|
|
66
|
+
try { body = await res.json() } catch { /* poll 有时返回空体 */ }
|
|
67
|
+
const code = body?.data?.code
|
|
68
|
+
if (code === 0) {
|
|
69
|
+
const jar = await ensureBuvid3(pickCookies(setCookies))
|
|
70
|
+
if (!jar.sessdata) return { state: 'unknown', code, message: '登录成功了,但响应里没有 SESSDATA —— 把这次的结果记下来再排查' }
|
|
71
|
+
return { state: 'ok', code, credential: jar }
|
|
72
|
+
}
|
|
73
|
+
const map = { 86101: 'waiting', 86090: 'scanned', 86038: 'expired' }
|
|
74
|
+
return { state: map[code] ?? 'unknown', code, message: body?.message || '' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 把凭证写进 MCP 读的那个文件(家目录里的 bili_credential.json) */
|
|
78
|
+
export function saveCredential(stateDir, credential) {
|
|
79
|
+
mkdirSync(stateDir, { recursive: true })
|
|
80
|
+
const file = join(stateDir, 'bili_credential.json')
|
|
81
|
+
writeFileSync(file, JSON.stringify({
|
|
82
|
+
sessdata: credential.sessdata ?? '',
|
|
83
|
+
bili_jct: credential.bili_jct ?? '',
|
|
84
|
+
buvid3: credential.buvid3 || '',
|
|
85
|
+
dedeuserid: credential.dedeuserid ?? '',
|
|
86
|
+
}, null, 2), 'utf8')
|
|
87
|
+
return file
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 用 Python 的 qrcode 画一张 PNG,返回**文件路径**(拿不到就返回 null) */
|
|
91
|
+
export function renderQrPng(pythonPath, stateDir, url) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const png = join(stateDir, 'qrcode_login.png')
|
|
94
|
+
const code = 'import sys, qrcode; qrcode.make(sys.argv[1]).save(sys.argv[2])'
|
|
95
|
+
let child
|
|
96
|
+
try {
|
|
97
|
+
child = spawn(pythonPath || 'python', ['-c', code, url, png], { cwd: stateDir, windowsHide: true })
|
|
98
|
+
} catch { return resolve(null) }
|
|
99
|
+
const done = () => { try { resolve(existsSync(png) ? png : null) } catch { resolve(null) } }
|
|
100
|
+
child.on('exit', (c) => { if (c === 0) done(); else resolve(null) })
|
|
101
|
+
child.on('error', () => resolve(null))
|
|
102
|
+
})
|
|
103
|
+
}
|
package/lib/mcp.mjs
CHANGED
|
@@ -1,115 +1,119 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/mcp.mjs —— 一个极小的 MCP stdio 客户端(只做我们需要的三件事)
|
|
3
|
-
* ============================================================================
|
|
4
|
-
* 为什么不用 `@deepseek-ai/dsh-mcp-client`:那是个"把 MCP 挂成 dsh 工具"的完整插件,
|
|
5
|
-
* 而我们要的是**插件内部自己起这个 Python MCP**(用户选了 A 方案:装完就能用,
|
|
6
|
-
* Python 路径和登录都在设置页里,不用去改 profile 的 patch)。
|
|
7
|
-
*
|
|
8
|
-
* 协议就三句话:`initialize` → `notifications/initialized` → `tools/call`,
|
|
9
|
-
* stdio 上是**一行一个 JSON-RPC**。
|
|
10
|
-
* ============================================================================
|
|
11
|
-
*/
|
|
12
|
-
import { spawn } from 'node:child_process'
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.
|
|
21
|
-
this.
|
|
22
|
-
this.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
this.proc.
|
|
43
|
-
this.proc.on('
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
try { this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
reject
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
clearTimeout(timer);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
1
|
+
/**
|
|
2
|
+
* lib/mcp.mjs —— 一个极小的 MCP stdio 客户端(只做我们需要的三件事)
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 为什么不用 `@deepseek-ai/dsh-mcp-client`:那是个"把 MCP 挂成 dsh 工具"的完整插件,
|
|
5
|
+
* 而我们要的是**插件内部自己起这个 Python MCP**(用户选了 A 方案:装完就能用,
|
|
6
|
+
* Python 路径和登录都在设置页里,不用去改 profile 的 patch)。
|
|
7
|
+
*
|
|
8
|
+
* 协议就三句话:`initialize` → `notifications/initialized` → `tools/call`,
|
|
9
|
+
* stdio 上是**一行一个 JSON-RPC**。
|
|
10
|
+
* ============================================================================
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
|
|
15
|
+
// 报给对端的身份:版本号**从 package.json 读**,别手写(手写的那份永远追不上发版)
|
|
16
|
+
const PKG = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) } catch { return {} } })()
|
|
17
|
+
|
|
18
|
+
export class McpClient {
|
|
19
|
+
constructor({ command, args = [], cwd, env, log = () => {} }) {
|
|
20
|
+
this.cfg = { command, args, cwd, env, log }
|
|
21
|
+
this.proc = null
|
|
22
|
+
this.buf = ''
|
|
23
|
+
this.nextId = 1
|
|
24
|
+
this.pending = new Map()
|
|
25
|
+
this.stderr = ''
|
|
26
|
+
this.starting = null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get running() { return Boolean(this.proc) }
|
|
30
|
+
|
|
31
|
+
async start() {
|
|
32
|
+
if (this.proc) return
|
|
33
|
+
if (this.starting) return this.starting
|
|
34
|
+
this.starting = (async () => {
|
|
35
|
+
const { command, args, cwd, env, log } = this.cfg
|
|
36
|
+
this.proc = spawn(command, args, {
|
|
37
|
+
cwd,
|
|
38
|
+
env: { ...process.env, ...(env || {}) },
|
|
39
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
})
|
|
42
|
+
this.proc.stdout.setEncoding('utf8')
|
|
43
|
+
this.proc.stdout.on('data', (chunk) => this.#onData(chunk))
|
|
44
|
+
this.proc.stderr.setEncoding('utf8')
|
|
45
|
+
this.proc.stderr.on('data', (c) => { this.stderr = (this.stderr + c).slice(-4000) })
|
|
46
|
+
this.proc.on('error', (e) => log(`MCP 子进程启动失败:${e?.message ?? e}`))
|
|
47
|
+
this.proc.on('exit', (code) => {
|
|
48
|
+
log(`MCP 子进程退出(code=${code})`)
|
|
49
|
+
for (const [, p] of this.pending) p.reject(new Error(`MCP 子进程退出(code=${code})${this.stderr ? ':' + this.stderr.slice(-300) : ''}`))
|
|
50
|
+
this.pending.clear()
|
|
51
|
+
this.proc = null
|
|
52
|
+
})
|
|
53
|
+
await this.#request('initialize', {
|
|
54
|
+
protocolVersion: '2024-11-05',
|
|
55
|
+
capabilities: {},
|
|
56
|
+
clientInfo: { name: PKG.name || 'dsh-bili-scroll', version: PKG.version || '0.0.0' },
|
|
57
|
+
})
|
|
58
|
+
this.#notify('notifications/initialized', {})
|
|
59
|
+
log('MCP 已就绪')
|
|
60
|
+
})()
|
|
61
|
+
try { await this.starting } finally { this.starting = null }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stop() {
|
|
65
|
+
try { this.proc?.kill() } catch { /* 已经没了 */ }
|
|
66
|
+
this.proc = null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 调一个 MCP 工具:能解析成 JSON 就返回对象,否则返回字符串 */
|
|
70
|
+
async call(name, args = {}, { timeoutMs = 180000 } = {}) {
|
|
71
|
+
await this.start()
|
|
72
|
+
const result = await this.#request('tools/call', { name, arguments: args }, timeoutMs)
|
|
73
|
+
const text = (result?.content ?? []).map((c) => c?.text ?? '').join('\n').trim()
|
|
74
|
+
if (result?.isError) throw new Error(text || `MCP 工具 ${name} 报错`)
|
|
75
|
+
try { return text ? JSON.parse(text) : {} } catch { return text }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#onData(chunk) {
|
|
79
|
+
this.buf += chunk
|
|
80
|
+
let idx
|
|
81
|
+
while ((idx = this.buf.indexOf('\n')) >= 0) {
|
|
82
|
+
const line = this.buf.slice(0, idx).trim()
|
|
83
|
+
this.buf = this.buf.slice(idx + 1)
|
|
84
|
+
if (!line) continue
|
|
85
|
+
let msg
|
|
86
|
+
try { msg = JSON.parse(line) } catch { continue } // 非 JSON 行(有些库会往 stdout 打日志)忽略
|
|
87
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
88
|
+
const p = this.pending.get(msg.id)
|
|
89
|
+
this.pending.delete(msg.id)
|
|
90
|
+
if (msg.error) p.reject(new Error(msg.error?.message ? String(msg.error.message) : JSON.stringify(msg.error)))
|
|
91
|
+
else p.resolve(msg.result)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#send(obj) {
|
|
97
|
+
if (!this.proc?.stdin?.writable) throw new Error('MCP 子进程不在运行')
|
|
98
|
+
this.proc.stdin.write(JSON.stringify(obj) + '\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#notify(method, params) { this.#send({ jsonrpc: '2.0', method, params }) }
|
|
102
|
+
|
|
103
|
+
#request(method, params, timeoutMs = 60000) {
|
|
104
|
+
const id = this.nextId++
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
this.pending.delete(id)
|
|
108
|
+
reject(new Error(`MCP ${method} 超时(${timeoutMs}ms)${this.stderr ? ':' + this.stderr.slice(-200) : ''}`))
|
|
109
|
+
}, timeoutMs)
|
|
110
|
+
this.pending.set(id, {
|
|
111
|
+
resolve: (v) => { clearTimeout(timer); resolve(v) },
|
|
112
|
+
reject: (e) => { clearTimeout(timer); reject(e) },
|
|
113
|
+
})
|
|
114
|
+
try { this.#send({ jsonrpc: '2.0', id, method, params }) } catch (e) {
|
|
115
|
+
clearTimeout(timer); this.pending.delete(id); reject(e)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|