dsh-as-mcp-server 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +93 -2
- package/cordis.patch.yml +14 -0
- package/dsh/bridge-regression.test.mjs +110 -0
- package/dsh/index.js +304 -0
- package/dsh/mcp-test.js +146 -0
- package/dsh/mcp.js +318 -0
- package/package.json +53 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-mcp-bridge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-mcp-bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Exposes DSH as an MCP server**, so any MCP client can watch what the agent is doing and steer it mid-flight.
|
|
4
|
+
|
|
5
|
+
Two agents on one workspace normally means two agents overwriting each other. This bridge gives them a shared protocol instead: what the other side is thinking, what it's touching, who owns the floor, and a way to interrupt.
|
|
6
|
+
|
|
7
|
+
## What the other client gets
|
|
8
|
+
|
|
9
|
+
| Capability | Mechanism |
|
|
10
|
+
|---|---|
|
|
11
|
+
| **See the agent think** | LLM stream deltas → `notifications/dsh/thinking` (200 ms throttle) |
|
|
12
|
+
| **See what it touches** | tool calls → `notifications/dsh/tool`; `write`/`edit` targets → `notifications/dsh/file` |
|
|
13
|
+
| **Share intent** | `dsh_set_intent` / `dsh_get_intent`, auto-injected into DSH's system prompt every turn |
|
|
14
|
+
| **One writer at a time** | `dsh_lock_acquire` / `dsh_lock_release` floor lock — while held, DSH's `write`/`edit`/`git` are **physically denied** |
|
|
15
|
+
| **Interrupt the current turn** | `dsh_inject_alert` → injected through `agent/pre-step` into the running step |
|
|
16
|
+
| **Delegate a review** | `dsh_review_request` → inbox on disk (`inbox/<id>.json`), result written back to `inbox/done/` |
|
|
17
|
+
|
|
18
|
+
## The 8 MCP tools
|
|
19
|
+
|
|
20
|
+
`dsh_get_status` · `dsh_get_intent` · `dsh_set_intent` · `dsh_lock_acquire` · `dsh_lock_release` · `dsh_lock_status` · `dsh_inject_alert` · `dsh_review_request`
|
|
21
|
+
|
|
22
|
+
## Notifications (server → client, SSE)
|
|
23
|
+
|
|
24
|
+
`notifications/dsh/status` · `thinking` · `tool` · `file` · `intent` · `lock` · `alert-queued` · `review`
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
dsh plugin --profile web add dsh-as-mcp-server
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then **add the bridge to your MCP client** — any client speaking Streamable HTTP works (WorkBuddy, Claude Desktop with an MCP-HTTP adapter, Cursor, Cline, …):
|
|
33
|
+
|
|
34
|
+
```jsonc
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"dsh-bridge": {
|
|
38
|
+
"type": "streamableHttp",
|
|
39
|
+
"url": "http://127.0.0.1:51630/mcp",
|
|
40
|
+
"timeout": 60000
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Restart DSH. The log should show `[dsh-mcp-bridge] MCP server 就绪: http://127.0.0.1:51630/mcp`.
|
|
47
|
+
|
|
48
|
+
## Self-test without installing
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
# terminal A — start a demo server on 51999
|
|
52
|
+
node dsh/mcp.js 51999
|
|
53
|
+
|
|
54
|
+
# terminal B — handshake, all 8 tools, floor lock, SSE stream
|
|
55
|
+
node dsh/mcp-test.js http://127.0.0.1:51999/mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Expected: 13 checks pass, ending with `全部通过 ✅`.
|
|
59
|
+
|
|
60
|
+
There is also a unit-level regression suite that runs with no server:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
node dsh/bridge-regression.test.mjs # 13 pass / 0 fail
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
`~/.dsh-mcp-bridge/config.json` (all optional):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"port": 51630,
|
|
73
|
+
"host": "127.0.0.1",
|
|
74
|
+
"token": "",
|
|
75
|
+
"lockTtlMs": 300000,
|
|
76
|
+
"denyTools": ["write", "edit", "git"],
|
|
77
|
+
"alertInjection": true,
|
|
78
|
+
"inboxRoot": ""
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`inboxRoot` defaults to `~/.dsh-mcp-bridge/inbox`.
|
|
83
|
+
|
|
84
|
+
## Honest boundaries
|
|
85
|
+
|
|
86
|
+
- **Loopback only by default.** Binds `127.0.0.1`; a `token` is supported but empty by default. Do not expose the port.
|
|
87
|
+
- **The lock is enforced on the DSH side only.** While held, DSH's `write`/`edit`/`git` are denied at `tools/pre-execute`. The remote client is trusted to honour the lock by convention — this is a coordination primitive, not a security boundary between mutually hostile parties.
|
|
88
|
+
- **Alert injection is best-effort.** It goes through `agent/pre-step`. If the current step cannot be injected, the alert stays queued and lands on the next step.
|
|
89
|
+
- **One Server + transport per client.** That is an MCP SDK constraint; multiple clients (a recorder and an agent, say) each get their own. A reconnecting client must use a new `sessionId`.
|
|
90
|
+
- **The thinking stream forwards text deltas only**, and tool arguments are truncated to their first 120 characters — this is a visibility channel, not a full transcript tap.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# dsh bundle layer: 把自己挂进配置树。
|
|
2
|
+
# name 必须是本包的包名(package.json 里的 "name"),加载器据此解析模块。
|
|
3
|
+
# config 是初始值;之后可用 ~/.dsh-mcp-bridge/config.json 覆盖,重启不丢。
|
|
4
|
+
- insert:
|
|
5
|
+
- id: dsh-mcp-bridge
|
|
6
|
+
name: 'dsh-as-mcp-server'
|
|
7
|
+
config:
|
|
8
|
+
port: 51630 # MCP server 端口(避开 50551/51608/51615/62428)
|
|
9
|
+
host: '127.0.0.1' # 只监听本机
|
|
10
|
+
token: '' # 可选 Bearer token;空 = 本机免鉴权
|
|
11
|
+
lockTtlMs: 300000 # 地板锁默认有效期(毫秒),防死锁兜底
|
|
12
|
+
denyTools: ['write', 'edit', 'git'] # 持锁期间拒绝 DSH 的写入工具
|
|
13
|
+
alertInjection: true # 提醒是否插进当前轮(false 则只进收件箱)
|
|
14
|
+
inboxRoot: '' # 审查/提醒收件箱;空 = ~/.dsh-mcp-bridge/inbox
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// dsh/bridge-regression.test.mjs —— 桥插件 2 个已知 bug 的回归测试
|
|
2
|
+
//
|
|
3
|
+
// 背景(复盘分锅 2026-08-22):
|
|
4
|
+
// Bug A: systemPrompt 变量名 'MCP 客户端Intent' 违反 cordis 规则 /^[a-z][a-z0-9_]*$/(驼峰)
|
|
5
|
+
// → 插件加载抛错 → 51630 起不来(连接器崩)
|
|
6
|
+
// Bug B: llm/stream 监听器写成 async 函数,返回 Promise<AsyncGenerator>
|
|
7
|
+
// → DSH 内部 yield* 报 "(intermediate value) is not async iterable"(红字崩)
|
|
8
|
+
//
|
|
9
|
+
// 方法:mock ctx 跑真实 apply()(真实插件代码 + 真实 mcp.js/SDK,测试端口 51997)。
|
|
10
|
+
// 正向断言修复后的行为;负向断言旧 bug 形态确实失败(留档)。
|
|
11
|
+
// 运行:node dsh/bridge-regression.test.mjs (退出码 0=全过)
|
|
12
|
+
|
|
13
|
+
import { apply } from './index.js'
|
|
14
|
+
|
|
15
|
+
const CORDIS_VAR_NAME_RULE = /^[a-z][a-z0-9_]*$/
|
|
16
|
+
|
|
17
|
+
// ---- mock ctx:捕获事件监听与 systemPrompt 注册,并模拟 cordis 变量名校验 ----
|
|
18
|
+
function makeMockCtx() {
|
|
19
|
+
const captured = { listeners: {}, variable: null }
|
|
20
|
+
const ctx = {
|
|
21
|
+
on(name, fn) { captured.listeners[name] = fn },
|
|
22
|
+
get(name) {
|
|
23
|
+
if (name === 'systemPrompt') {
|
|
24
|
+
return {
|
|
25
|
+
section: () => () => {},
|
|
26
|
+
variable: (vname, provider) => {
|
|
27
|
+
// 模拟 cordis 规则:非法名抛错(旧版 'MCP 客户端Intent' 会在这里炸)
|
|
28
|
+
if (!CORDIS_VAR_NAME_RULE.test(vname)) {
|
|
29
|
+
throw new Error(`invalid variable name "${vname}" (must match ${CORDIS_VAR_NAME_RULE})`)
|
|
30
|
+
}
|
|
31
|
+
captured.variable = { name: vname, provider }
|
|
32
|
+
return () => {}
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return undefined
|
|
37
|
+
},
|
|
38
|
+
effect(fn) {
|
|
39
|
+
const disposer = fn()
|
|
40
|
+
return typeof disposer === 'function' ? disposer : () => {}
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
return { ctx, captured }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function* chunksOf(...items) {
|
|
47
|
+
for (const it of items) yield it
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let passed = 0
|
|
51
|
+
let failed = 0
|
|
52
|
+
function ok(name, cond, extra = '') {
|
|
53
|
+
if (cond) { passed++; console.log(` ✅ ${name}${extra ? `(${extra})` : ''}`) }
|
|
54
|
+
else { failed++; console.log(` ❌ ${name}${extra ? `(${extra})` : ''}`) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- 单实例:一次 apply(测试端口),全部断言共用同一插件实例 ----
|
|
58
|
+
const { ctx, captured } = makeMockCtx()
|
|
59
|
+
let applyThrew = null
|
|
60
|
+
try {
|
|
61
|
+
apply(ctx, { port: 51997, host: '127.0.0.1' })
|
|
62
|
+
} catch (e) {
|
|
63
|
+
applyThrew = e
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log('=== T1: apply() 正常加载(Bug A 正向回归)===')
|
|
67
|
+
ok('apply 未抛错(变量名已改 snake_case,加载不再崩)', applyThrew === null, applyThrew ? String(applyThrew) : '')
|
|
68
|
+
ok('注册的变量名是 bridge_intent', captured.variable?.name === 'bridge_intent', captured.variable?.name)
|
|
69
|
+
ok('变量名符合 cordis 规则', captured.variable ? CORDIS_VAR_NAME_RULE.test(captured.variable.name) : false)
|
|
70
|
+
|
|
71
|
+
console.log('=== T2: 旧驼峰名确实违反规则(Bug A 负向留档)===')
|
|
72
|
+
ok('"MCP 客户端Intent" 不匹配规则(当初崩溃的原因)', !CORDIS_VAR_NAME_RULE.test('MCP 客户端Intent'))
|
|
73
|
+
ok('"bridge_intent" 匹配规则', CORDIS_VAR_NAME_RULE.test('bridge_intent'))
|
|
74
|
+
|
|
75
|
+
console.log('=== T3: 意图 provider 不崩、无意图时返回 undefined ===')
|
|
76
|
+
let value = 'UNSET'
|
|
77
|
+
let providerThrew = null
|
|
78
|
+
try { value = captured.variable.provider() } catch (e) { providerThrew = e }
|
|
79
|
+
ok('provider 可调用且未抛错', providerThrew === null, providerThrew ? String(providerThrew) : '')
|
|
80
|
+
ok('无意图时返回 undefined', value === undefined)
|
|
81
|
+
|
|
82
|
+
console.log('=== T4: llm/stream 监听器返回可直接迭代的 async iterable(Bug B 正向回归)===')
|
|
83
|
+
const listener = captured.listeners['llm/stream']
|
|
84
|
+
ok('llm/stream 监听器已注册', typeof listener === 'function')
|
|
85
|
+
if (typeof listener !== 'function') process.exit(1)
|
|
86
|
+
const result = listener({}, () => Promise.resolve(chunksOf({ text: 'he' }, { text: 'llo' })))
|
|
87
|
+
ok('返回值不是 Promise(旧 bug 形态)', !(result instanceof Promise))
|
|
88
|
+
ok('返回值是 async iterable', result !== null && typeof result[Symbol.asyncIterator] === 'function')
|
|
89
|
+
const out = []
|
|
90
|
+
for await (const c of result) out.push(c)
|
|
91
|
+
ok('for await 正常消费且 chunk 原样透传', out.length === 2 && out[0]?.text === 'he' && out[1]?.text === 'llo', `透传=${out.map((c) => c.text).join('')}`)
|
|
92
|
+
|
|
93
|
+
console.log('=== T5: 旧 async 包装形态确实失败(Bug B 负向留档)===')
|
|
94
|
+
const oldBuggy = async (_o, next) => {
|
|
95
|
+
const inner = await next()
|
|
96
|
+
return (async function* () { yield* inner })() // ← 旧代码:async 函数返回 Promise<AsyncGenerator>
|
|
97
|
+
}
|
|
98
|
+
const buggyResult = oldBuggy({}, () => Promise.resolve(chunksOf({ text: 'x' })))
|
|
99
|
+
ok('旧形态返回的是 Promise(问题所在)', buggyResult instanceof Promise)
|
|
100
|
+
let iterFailed = false
|
|
101
|
+
try {
|
|
102
|
+
for await (const _ of buggyResult) { /* 预期抛错 */ }
|
|
103
|
+
} catch {
|
|
104
|
+
iterFailed = true
|
|
105
|
+
}
|
|
106
|
+
ok('旧形态 for await 必然失败(yield* not async iterable)', iterFailed)
|
|
107
|
+
|
|
108
|
+
console.log('\n========================================')
|
|
109
|
+
console.log(`回归测试:${passed} 通过 · ${failed} 失败`)
|
|
110
|
+
process.exit(failed === 0 ? 0 : 1)
|
package/dsh/index.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// dsh-mcp-bridge —— DSH ↔ MCP 实时会诊桥(MCP server 版)
|
|
2
|
+
//
|
|
3
|
+
// 会诊室机制:
|
|
4
|
+
// 思维流 llm/stream 瀑布 → notifications/dsh/thinking (他实时看到你在想什么)
|
|
5
|
+
// 动作流 tools/result 事件 → notifications/dsh/tool (在做什么)
|
|
6
|
+
// 文件流 write/edit 工具结果 → notifications/dsh/file (在对什么进行改动)
|
|
7
|
+
// 状态流 agent/status / agent/error → notifications/dsh/status
|
|
8
|
+
// 共享意图 dsh_get_intent / dsh_set_intent;每轮 system prompt 自动注入 DSH 侧
|
|
9
|
+
// 地板锁 dsh_lock_acquire / release;tools/pre-execute 门禁拒绝持锁方的写入工具
|
|
10
|
+
// 中断提醒 dsh_inject_alert → agent/pre-step 瀑布插进当前轮消息流
|
|
11
|
+
// 审查委托 dsh_review_request → 写入 MCP 客户端-bridge 收件箱(wb_inbox 可接),落盘后实时通知
|
|
12
|
+
//
|
|
13
|
+
// 可拆卸:所有监听 ctx.on、MCP server 生命周期由 ctx.effect 持有,卸载自动清理。
|
|
14
|
+
// 不冲突:工具名 dsh_* 全局唯一;不替换任何既有能力。
|
|
15
|
+
// 安全:默认只监听 127.0.0.1;token 可选;不执行外部传入的命令;锁有过期兜底。
|
|
16
|
+
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
20
|
+
import { createMcpBridge, buildBridgeTools } from './mcp.js'
|
|
21
|
+
|
|
22
|
+
export const name = 'dsh-mcp-bridge'
|
|
23
|
+
export const inject = ['tools']
|
|
24
|
+
|
|
25
|
+
const STORE_PATH = join(homedir(), '.dsh-mcp-bridge', 'config.json')
|
|
26
|
+
|
|
27
|
+
function defaults() {
|
|
28
|
+
return {
|
|
29
|
+
port: 51630,
|
|
30
|
+
host: '127.0.0.1',
|
|
31
|
+
token: '',
|
|
32
|
+
lockTtlMs: 300000,
|
|
33
|
+
denyTools: ['write', 'edit', 'git'],
|
|
34
|
+
alertInjection: true,
|
|
35
|
+
inboxRoot: '',
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadState(patchConfig) {
|
|
40
|
+
const state = Object.assign(defaults(), patchConfig || {})
|
|
41
|
+
try {
|
|
42
|
+
// 兼容编辑器写入的 UTF-8 BOM
|
|
43
|
+
const saved = JSON.parse(readFileSync(STORE_PATH, 'utf8').replace(/^\uFEFF/, ''))
|
|
44
|
+
if (saved && typeof saved === 'object') Object.assign(state, saved)
|
|
45
|
+
} catch {
|
|
46
|
+
// 无落盘覆盖
|
|
47
|
+
}
|
|
48
|
+
return state
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeStr(v, max) {
|
|
52
|
+
return typeof v === 'string' ? v.slice(0, max) : ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function apply(ctx, config = {}) {
|
|
56
|
+
const state = loadState(config)
|
|
57
|
+
const inboxRoot = (state.inboxRoot && String(state.inboxRoot).trim()) || join(homedir(), '.dsh-mcp-bridge', 'inbox')
|
|
58
|
+
|
|
59
|
+
// ---- 共享状态(MCP 工具 + 事件共同读写) ----
|
|
60
|
+
const shared = {
|
|
61
|
+
status: 'idle',
|
|
62
|
+
current: '',
|
|
63
|
+
turn: 0,
|
|
64
|
+
lastTool: null,
|
|
65
|
+
problems: [],
|
|
66
|
+
intent: null,
|
|
67
|
+
lock: null,
|
|
68
|
+
alerts: [],
|
|
69
|
+
seq: 0,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let bridge = null
|
|
73
|
+
const notify = (method, params) => {
|
|
74
|
+
if (bridge) bridge.notify(method, params)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- 收件箱写入(审查委托 / 提醒兜底) ----
|
|
78
|
+
const ensureInbox = () => mkdirSync(inboxRoot, { recursive: true })
|
|
79
|
+
const safeId = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, '_')
|
|
80
|
+
|
|
81
|
+
const writeInboxAlert = (alert) => {
|
|
82
|
+
try {
|
|
83
|
+
ensureInbox()
|
|
84
|
+
// 按内容幂等:同 severity+message 只落盘一个文件,避免 UUID 每次都新导致收件箱堆积
|
|
85
|
+
const key = safeId(`${alert.severity}:${alert.message}`)
|
|
86
|
+
const path = join(inboxRoot, `alert-${key}.json`)
|
|
87
|
+
if (existsSync(path)) return
|
|
88
|
+
writeFileSync(path, `${JSON.stringify({
|
|
89
|
+
id: `alert-${key}`,
|
|
90
|
+
from: 'mcp-client',
|
|
91
|
+
type: 'alert',
|
|
92
|
+
title: `[${alert.severity}] ${alert.message}`,
|
|
93
|
+
createdAt: alert.at,
|
|
94
|
+
standards: ['阅读此提醒并酌情处理'],
|
|
95
|
+
}, null, 2)}\n`)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(`[dsh-mcp-bridge] 提醒落盘失败: ${String(error?.message ?? error)}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const writeReviewManifest = (args) => {
|
|
102
|
+
const id = typeof args.id === 'string' && args.id.trim() ? safeId(args.id) : `review-${Date.now().toString(36)}`
|
|
103
|
+
const manifest = {
|
|
104
|
+
id,
|
|
105
|
+
from: 'mcp-client',
|
|
106
|
+
type: 'review',
|
|
107
|
+
title: safeStr(args.title, 300),
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(args.targets)) manifest.targets = args.targets.map((t) => String(t)).slice(0, 50)
|
|
111
|
+
if (Array.isArray(args.standards)) manifest.standards = args.standards.map((s) => String(s)).slice(0, 20)
|
|
112
|
+
if (typeof args.testCmd === 'string' && args.testCmd.trim()) manifest.testCmd = args.testCmd.trim().slice(0, 500)
|
|
113
|
+
ensureInbox()
|
|
114
|
+
writeFileSync(join(inboxRoot, `${id}.json`), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
115
|
+
// H6 修复:实时通知 MCP 客户端「有新审查任务」,无需轮询文件
|
|
116
|
+
notify('notifications/dsh/review', manifest)
|
|
117
|
+
return manifest
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- MCP 工具 ----
|
|
121
|
+
const tools = buildBridgeTools(shared, { notify, writeInboxAlert, writeReviewManifest }, { lockTtlMs: state.lockTtlMs })
|
|
122
|
+
|
|
123
|
+
// ---- 事件监听(全部防御式:异常不外抛,只取叶子字段) ----
|
|
124
|
+
ctx.on('agent/status', (payload) => {
|
|
125
|
+
try {
|
|
126
|
+
const p = payload || {}
|
|
127
|
+
if (typeof p.status === 'string') {
|
|
128
|
+
shared.status = p.status === 'running' ? 'working' : 'idle'
|
|
129
|
+
if (shared.status === 'idle') shared.current = ''
|
|
130
|
+
notify('notifications/dsh/status', {
|
|
131
|
+
status: shared.status, current: shared.current, turn: shared.turn,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error(`[dsh-mcp-bridge] agent/status: ${String(error?.message ?? error)}`)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
ctx.on('agent/error', (payload) => {
|
|
140
|
+
try {
|
|
141
|
+
const p = payload || {}
|
|
142
|
+
shared.problems.unshift({
|
|
143
|
+
at: new Date().toISOString(),
|
|
144
|
+
message: `第 ${p.turn ?? '?'} 轮出错: ${safeStr(String(p.error ?? 'unknown'), 300)}`,
|
|
145
|
+
})
|
|
146
|
+
if (shared.problems.length > 20) shared.problems.length = 20
|
|
147
|
+
notify('notifications/dsh/status', {
|
|
148
|
+
status: shared.status, current: shared.current, turn: shared.turn,
|
|
149
|
+
problems: shared.problems.slice(-3),
|
|
150
|
+
})
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error(`[dsh-mcp-bridge] agent/error: ${String(error?.message ?? error)}`)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
ctx.on('tools/result', (exec, result) => {
|
|
157
|
+
try {
|
|
158
|
+
if (!exec || typeof exec.name !== 'string') return
|
|
159
|
+
const argHint = (() => {
|
|
160
|
+
try { return JSON.stringify(exec.arguments ?? null).slice(0, 120) } catch { return '' }
|
|
161
|
+
})()
|
|
162
|
+
shared.lastTool = { name: exec.name, argHint }
|
|
163
|
+
shared.turn += 1
|
|
164
|
+
const isError = !!(result && result.isError)
|
|
165
|
+
// 文件改动流:write/edit 这类工具结果即"正在改动的目标"
|
|
166
|
+
if (exec.name === 'write' || exec.name === 'edit') {
|
|
167
|
+
notify('notifications/dsh/file', { action: exec.name, argHint, at: new Date().toISOString() })
|
|
168
|
+
}
|
|
169
|
+
notify('notifications/dsh/tool', { name: exec.name, argHint, isError, turn: shared.turn })
|
|
170
|
+
if (isError) {
|
|
171
|
+
shared.problems.unshift({ at: new Date().toISOString(), message: `工具 ${exec.name} 失败` })
|
|
172
|
+
if (shared.problems.length > 20) shared.problems.length = 20
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error(`[dsh-mcp-bridge] tools/result: ${String(error?.message ?? error)}`)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// 思维流:截获每个流式 chunk,节流转发(绝不阻塞/改写原流)
|
|
180
|
+
// 注意:llm/stream 事件的中间件必须直接返回 async iterable,不能是 async 函数返回 Promise<AsyncGenerator>,
|
|
181
|
+
// 否则 DSH 内部 yield* 会报 "(intermediate value) is not async iterable"。
|
|
182
|
+
ctx.on('llm/stream', (options, next) => {
|
|
183
|
+
return (async function* forwardStream() {
|
|
184
|
+
const inner = await next()
|
|
185
|
+
let buf = ''
|
|
186
|
+
let lastFlush = Date.now()
|
|
187
|
+
const flush = () => {
|
|
188
|
+
if (!buf) return
|
|
189
|
+
const text = buf
|
|
190
|
+
buf = ''
|
|
191
|
+
lastFlush = Date.now()
|
|
192
|
+
try { notify('notifications/dsh/thinking', { text, at: new Date().toISOString() }) } catch { /* 忽略 */ }
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
for await (const chunk of inner) {
|
|
196
|
+
let t = ''
|
|
197
|
+
if (chunk && typeof chunk === 'object') {
|
|
198
|
+
if (typeof chunk.text === 'string') t = chunk.text
|
|
199
|
+
else if (typeof chunk.delta === 'string') t = chunk.delta
|
|
200
|
+
else if (typeof chunk.content === 'string') t = chunk.content
|
|
201
|
+
else if (chunk.content && Array.isArray(chunk.content)) {
|
|
202
|
+
t = chunk.content.map((b) => (b && typeof b.text === 'string' ? b.text : '')).join('')
|
|
203
|
+
}
|
|
204
|
+
} else if (typeof chunk === 'string') {
|
|
205
|
+
t = chunk
|
|
206
|
+
}
|
|
207
|
+
if (t) {
|
|
208
|
+
buf += t
|
|
209
|
+
if (Date.now() - lastFlush >= 200) flush()
|
|
210
|
+
}
|
|
211
|
+
yield chunk
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
flush()
|
|
215
|
+
}
|
|
216
|
+
})()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// 地板锁门禁:MCP 客户端持锁期间,拒绝 DSH 的写入工具
|
|
220
|
+
const denySet = new Set(state.denyTools)
|
|
221
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
222
|
+
try {
|
|
223
|
+
const locked = shared.lock !== null && shared.lock.expiresAt > Date.now()
|
|
224
|
+
if (locked && shared.lock.owner !== 'dsh' && exec && typeof exec.name === 'string' && denySet.has(exec.name)) {
|
|
225
|
+
return {
|
|
226
|
+
kind: 'deny',
|
|
227
|
+
reason: `工作区正被 ${shared.lock.owner} 锁定(地板锁),写入工具 ${exec.name} 被拒绝。请等锁释放,或请对方用 dsh_lock_release 释放。`,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.error(`[dsh-mcp-bridge] tools/pre-execute: ${String(error?.message ?? error)}`)
|
|
232
|
+
}
|
|
233
|
+
return next()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// 中断提醒注入:MCP 客户端 的提醒在下一模型步进入当前轮
|
|
237
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
238
|
+
const decision = await next()
|
|
239
|
+
try {
|
|
240
|
+
if (decision && decision.kind === 'enter' && shared.alerts.length && Array.isArray(decision.messages)) {
|
|
241
|
+
const alert = shared.alerts.shift()
|
|
242
|
+
// 伪装成标准用户消息,避免前端渲染层对 source.kind!=='user' 的消息缺渲染分支而白屏
|
|
243
|
+
const msg = {
|
|
244
|
+
id: alert.id, // 直接用 UUID(绝对唯一),去掉 `||` 短路,避免回退到可碰撞的固定 id
|
|
245
|
+
role: 'user',
|
|
246
|
+
content: [{ type: 'text', text: `[${alert.severity}] MCP 客户端 实时提醒:${alert.message}` }],
|
|
247
|
+
source: { kind: 'user', plugin: 'dsh-mcp-bridge', clientTimeZone: 'Asia/Shanghai' },
|
|
248
|
+
}
|
|
249
|
+
return { ...decision, messages: [...decision.messages, msg] }
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.error(`[dsh-mcp-bridge] agent/pre-step: ${String(error?.message ?? error)}`)
|
|
253
|
+
if (shared.alerts.length) shared.alerts.unshift(shared.alerts.pop())
|
|
254
|
+
}
|
|
255
|
+
return decision
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// ---- 启动 MCP server(effect 持有生命周期) ----
|
|
259
|
+
let closed = false
|
|
260
|
+
ctx.effect(() => {
|
|
261
|
+
createMcpBridge({ tools }, { port: state.port, host: state.host, token: state.token })
|
|
262
|
+
.then((b) => {
|
|
263
|
+
if (closed) {
|
|
264
|
+
b.close().catch(() => {})
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
bridge = b
|
|
268
|
+
console.log(`[dsh-mcp-bridge] MCP server 就绪: ${b.url}(在此接入 MCP 客户端)`)
|
|
269
|
+
notify('notifications/dsh/status', {
|
|
270
|
+
status: shared.status, current: '桥已就绪', turn: shared.turn, bridgeUrl: b.url,
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
.catch((error) => {
|
|
274
|
+
console.error(`[dsh-mcp-bridge] MCP server 启动失败: ${String(error?.message ?? error)}`)
|
|
275
|
+
})
|
|
276
|
+
return () => {
|
|
277
|
+
closed = true
|
|
278
|
+
const b = bridge
|
|
279
|
+
bridge = null
|
|
280
|
+
if (b) b.close().catch(() => {})
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// ---- 引导段 + 共享意图注入(每轮 system prompt 自动带上,DSH 侧"听得见开会") ----
|
|
285
|
+
const sp = ctx.get('systemPrompt')
|
|
286
|
+
if (sp !== undefined) {
|
|
287
|
+
ctx.effect(() => sp.section({
|
|
288
|
+
name: 'tool:dsh-mcp-bridge',
|
|
289
|
+
order: 103,
|
|
290
|
+
text: 'MCP 客户端正实时旁观你(MCP 桥):思考/工具调用/文件改动都会流式可见。'
|
|
291
|
+
+ '若 write/edit/git 被拒绝,说明 MCP 客户端正持地板锁操作工作区——等锁释放或请对方释放后再写。'
|
|
292
|
+
+ 'MCP 客户端的实时提醒会以用户消息直接插进当前轮;审查委托走收件箱(wb_inbox 查收)。',
|
|
293
|
+
}))
|
|
294
|
+
// 共享意图注入:每轮 system prompt 自动带上 MCP 客户端 写入的意图(DSH 侧"听得见开会")
|
|
295
|
+
ctx.effect(() => sp.variable('bridge_intent', () => {
|
|
296
|
+
try {
|
|
297
|
+
if (shared.intent && shared.intent.text) {
|
|
298
|
+
return `当前共享用户意图(MCP 会诊室):${shared.intent.text}(by ${shared.intent.by})`
|
|
299
|
+
}
|
|
300
|
+
} catch { /* 忽略 */ }
|
|
301
|
+
return undefined
|
|
302
|
+
}))
|
|
303
|
+
}
|
|
304
|
+
}
|
package/dsh/mcp-test.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// dsh/mcp-test.js —— 自测客户端:走完 MCP 握手 + 工具调用 + SSE 通知流
|
|
2
|
+
// 用法:node dsh/mcp-test.js [baseUrl] (默认 http://127.0.0.1:51999/mcp)
|
|
3
|
+
|
|
4
|
+
const base = process.argv[2] || 'http://127.0.0.1:51999/mcp'
|
|
5
|
+
|
|
6
|
+
let sessionId = null
|
|
7
|
+
let failures = 0
|
|
8
|
+
const ok = (label, cond, extra = '') => {
|
|
9
|
+
const mark = cond ? '✅' : '❌'
|
|
10
|
+
if (!cond) failures++
|
|
11
|
+
console.log(`${mark} ${label}${cond && extra ? `(${extra})` : ''}`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function post(method, params, id) {
|
|
15
|
+
const body = { jsonrpc: '2.0', id: id ?? Date.now(), method }
|
|
16
|
+
if (params !== undefined) body.params = params
|
|
17
|
+
const res = await fetch(base, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: {
|
|
20
|
+
'content-type': 'application/json',
|
|
21
|
+
accept: 'application/json, text/event-stream',
|
|
22
|
+
...(sessionId ? { 'mcp-session-id': sessionId } : {}),
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
})
|
|
26
|
+
const sid = res.headers.get('mcp-session-id')
|
|
27
|
+
if (sid) sessionId = sid
|
|
28
|
+
const text = await res.text()
|
|
29
|
+
if (res.status !== 200) throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`)
|
|
30
|
+
const isSse = (res.headers.get('content-type') || '').includes('text/event-stream')
|
|
31
|
+
if (isSse) {
|
|
32
|
+
const m = text.match(/data:\s*(\{.*\})/s)
|
|
33
|
+
return m ? JSON.parse(m[1]) : null
|
|
34
|
+
}
|
|
35
|
+
return JSON.parse(text)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function openStreamAndWait(expectMethod, timeoutMs) {
|
|
39
|
+
const ac = new AbortController()
|
|
40
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(base, {
|
|
43
|
+
method: 'GET',
|
|
44
|
+
headers: { accept: 'text/event-stream', ...(sessionId ? { 'mcp-session-id': sessionId } : {}) },
|
|
45
|
+
signal: ac.signal,
|
|
46
|
+
})
|
|
47
|
+
const reader = res.body.getReader()
|
|
48
|
+
const decoder = new TextDecoder()
|
|
49
|
+
let buf = ''
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read()
|
|
52
|
+
if (done) break
|
|
53
|
+
buf += decoder.decode(value, { stream: true })
|
|
54
|
+
const lines = buf.split('\n')
|
|
55
|
+
buf = lines.pop()
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const m = line.match(/^data:\s*(\{.*\})/)
|
|
58
|
+
if (!m) continue
|
|
59
|
+
try {
|
|
60
|
+
const evt = JSON.parse(m[1])
|
|
61
|
+
if (evt.method === expectMethod) {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
ac.abort()
|
|
64
|
+
return evt
|
|
65
|
+
}
|
|
66
|
+
} catch { /* 忽略坏行 */ }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
} finally {
|
|
71
|
+
clearTimeout(timer)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function main() {
|
|
76
|
+
// 1. 握手
|
|
77
|
+
const init = await post('initialize', {
|
|
78
|
+
protocolVersion: '2024-11-05',
|
|
79
|
+
capabilities: {},
|
|
80
|
+
clientInfo: { name: 'dsh-mcp-test', version: '1.0.0' },
|
|
81
|
+
})
|
|
82
|
+
ok('initialize', init && init.result && !!init.result.serverInfo, `server=${init.result?.serverInfo?.name}`)
|
|
83
|
+
await post('notifications/initialized')
|
|
84
|
+
|
|
85
|
+
// 2. 工具列表
|
|
86
|
+
const list = await post('tools/list')
|
|
87
|
+
const names = (list?.result?.tools || []).map((t) => t.name)
|
|
88
|
+
ok('tools/list 含全部 8 个工具', ['dsh_get_status','dsh_get_intent','dsh_set_intent','dsh_lock_acquire','dsh_lock_release','dsh_lock_status','dsh_inject_alert','dsh_review_request'].every((n) => names.includes(n)), names.join(','))
|
|
89
|
+
|
|
90
|
+
const call = async (name, args) => {
|
|
91
|
+
const r = await post('tools/call', { name, arguments: args || {} })
|
|
92
|
+
const text = r?.result?.content?.[0]?.text || ''
|
|
93
|
+
let parsed = null
|
|
94
|
+
try { parsed = JSON.parse(text) } catch { /* 非 JSON */ }
|
|
95
|
+
return { raw: r, text, parsed }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. get_status
|
|
99
|
+
let c = await call('dsh_get_status')
|
|
100
|
+
ok('dsh_get_status', c.parsed && typeof c.parsed.status === 'string')
|
|
101
|
+
|
|
102
|
+
// 4. set_intent(触发通知)
|
|
103
|
+
c = await call('dsh_set_intent', { text: '测试共享意图:双方围绕 X 协作', by: 'test-client' })
|
|
104
|
+
ok('dsh_set_intent', c.parsed && c.parsed.ok === true && c.parsed.intent.text.includes('测试共享意图'))
|
|
105
|
+
|
|
106
|
+
// 5. get_intent
|
|
107
|
+
c = await call('dsh_get_intent')
|
|
108
|
+
ok('dsh_get_intent', c.parsed && c.parsed.intent && c.parsed.intent.by === 'test-client')
|
|
109
|
+
|
|
110
|
+
// 6. 地板锁:获取 → 状态 → 冲突拒绝 → 释放
|
|
111
|
+
c = await call('dsh_lock_acquire', { owner: 'mcp-client', ttlMs: 60000 })
|
|
112
|
+
ok('dsh_lock_acquire', c.parsed && c.parsed.ok === true && c.parsed.lock.owner === 'mcp-client')
|
|
113
|
+
c = await call('dsh_lock_acquire', { owner: 'other' })
|
|
114
|
+
ok('锁冲突被拒', c.parsed && c.parsed.ok === false)
|
|
115
|
+
c = await call('dsh_lock_status')
|
|
116
|
+
ok('dsh_lock_status 持锁', c.parsed && c.parsed.held === true)
|
|
117
|
+
c = await call('dsh_lock_release', { owner: 'mcp-client' })
|
|
118
|
+
ok('dsh_lock_release', c.parsed && c.parsed.ok === true)
|
|
119
|
+
c = await call('dsh_lock_status')
|
|
120
|
+
ok('释放后未持锁', c.parsed && c.parsed.held === false)
|
|
121
|
+
|
|
122
|
+
// 7. inject_alert
|
|
123
|
+
c = await call('dsh_inject_alert', { message: '测试实时提醒', severity: 'warning' })
|
|
124
|
+
ok('dsh_inject_alert', c.parsed && c.parsed.ok === true && c.parsed.queued >= 1)
|
|
125
|
+
|
|
126
|
+
// 8. review_request
|
|
127
|
+
c = await call('dsh_review_request', { title: '审查测试文件', targets: ['F:/test/a.js'], standards: ['无密钥'] })
|
|
128
|
+
ok('dsh_review_request', c.parsed && c.parsed.ok === true && !!c.parsed.manifest.id)
|
|
129
|
+
|
|
130
|
+
// 9. SSE 通知流:开流 → 并行触发 set_intent → 应收 notifications/dsh/intent
|
|
131
|
+
const trigger = (async () => {
|
|
132
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
133
|
+
await call('dsh_set_intent', { text: 'SSE 测试意图' })
|
|
134
|
+
})()
|
|
135
|
+
const evt = await openStreamAndWait('notifications/dsh/intent', 5000)
|
|
136
|
+
await trigger
|
|
137
|
+
ok('SSE 通知流收到 notifications/dsh/intent', !!evt && !!evt.params && String(evt.params.text).includes('SSE 测试'))
|
|
138
|
+
|
|
139
|
+
console.log(failures === 0 ? '\n全部通过 ✅' : `\n${failures} 项失败 ❌`)
|
|
140
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
main().catch((error) => {
|
|
144
|
+
console.error('测试失败:', error.message)
|
|
145
|
+
process.exit(1)
|
|
146
|
+
})
|
package/dsh/mcp.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// dsh/mcp.js —— DSH ↔ MCP 实时会诊桥的 MCP server(Streamable HTTP,多会话:支持记录器 + MCP 客户端 并存)
|
|
2
|
+
//
|
|
3
|
+
// 对外暴露工具(MCP 客户端调):
|
|
4
|
+
// dsh_get_status / dsh_get_intent / dsh_set_intent —— 实时状态 + 共享用户意图(开会)
|
|
5
|
+
// dsh_lock_acquire / dsh_lock_release / dsh_lock_status —— 地板锁(单位时间只许一个智能体动工作区)
|
|
6
|
+
// dsh_inject_alert —— 中断提醒(插进 DSH 正在进行的这一轮)
|
|
7
|
+
// dsh_review_request —— 审查委托(写入收件箱,wb_inbox 可接)
|
|
8
|
+
//
|
|
9
|
+
// 事件通知(server → client,实时流):
|
|
10
|
+
// notifications/dsh/status notifications/dsh/tool notifications/dsh/file
|
|
11
|
+
// notifications/dsh/thinking notifications/dsh/intent notifications/dsh/lock
|
|
12
|
+
// notifications/dsh/alert-queued notifications/dsh/review
|
|
13
|
+
//
|
|
14
|
+
// 多会话实现要点(实测修复):
|
|
15
|
+
// SDK 的 Server 只能 connect 一个 transport,且单个 StreamableHTTPServerTransport 只服务一个会话,
|
|
16
|
+
// 所以「每会话独立 Server+transport」才支持多个客户端并存;notify 遍历所有会话广播。
|
|
17
|
+
// 客户端重连必须换新 sessionId(旧 id 会 400 死循环)。
|
|
18
|
+
//
|
|
19
|
+
// 零第三方依赖:SDK 由插件 node_modules junction 提供;支持 node dsh/mcp.js [port] 独立自测。
|
|
20
|
+
|
|
21
|
+
import { randomUUID } from 'node:crypto'
|
|
22
|
+
import { createServer } from 'node:http'
|
|
23
|
+
import { resolve } from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
26
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
27
|
+
import {
|
|
28
|
+
ListToolsRequestSchema,
|
|
29
|
+
CallToolRequestSchema,
|
|
30
|
+
McpError,
|
|
31
|
+
ErrorCode,
|
|
32
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
33
|
+
|
|
34
|
+
export function buildBridgeTools(shared, hooks, opts = {}) {
|
|
35
|
+
const lockTtlMs = Number.isFinite(opts.lockTtlMs) ? opts.lockTtlMs : 300000
|
|
36
|
+
const { notify, writeInboxAlert, writeReviewManifest } = hooks
|
|
37
|
+
const lockValid = () => shared.lock !== null && shared.lock.expiresAt > Date.now()
|
|
38
|
+
const getStatus = () => ({
|
|
39
|
+
status: shared.status,
|
|
40
|
+
current: shared.current,
|
|
41
|
+
turn: shared.turn,
|
|
42
|
+
lastTool: shared.lastTool,
|
|
43
|
+
problems: (shared.problems || []).slice(-10),
|
|
44
|
+
intent: shared.intent,
|
|
45
|
+
lock: lockValid() ? shared.lock : null,
|
|
46
|
+
alertQueue: (shared.alerts || []).length,
|
|
47
|
+
})
|
|
48
|
+
return {
|
|
49
|
+
dsh_get_status: {
|
|
50
|
+
name: 'dsh_get_status',
|
|
51
|
+
description: '读取 DSH 当前实时状态:status/current/turn/lastTool/problems/intent/lock/alertQueue',
|
|
52
|
+
properties: {},
|
|
53
|
+
required: [],
|
|
54
|
+
async execute() { return getStatus() },
|
|
55
|
+
},
|
|
56
|
+
dsh_get_intent: {
|
|
57
|
+
name: 'dsh_get_intent',
|
|
58
|
+
description: '读取共享用户意图(会议议程):双方实时读到同一份 { text, by, at }',
|
|
59
|
+
properties: {},
|
|
60
|
+
required: [],
|
|
61
|
+
async execute() { return { intent: shared.intent } },
|
|
62
|
+
},
|
|
63
|
+
dsh_set_intent: {
|
|
64
|
+
name: 'dsh_set_intent',
|
|
65
|
+
description: '写入/更新共享用户意图(如「用户要 X,请双方围绕 X 配合」)。text 必填,by 标识写入方',
|
|
66
|
+
properties: {
|
|
67
|
+
text: { type: 'string', description: '意图内容(≤2000 字)' },
|
|
68
|
+
by: { type: 'string', description: '写入方标识,默认 MCP 客户端' },
|
|
69
|
+
},
|
|
70
|
+
required: ['text'],
|
|
71
|
+
async execute(args) {
|
|
72
|
+
shared.intent = {
|
|
73
|
+
text: String(args.text).slice(0, 2000),
|
|
74
|
+
by: typeof args.by === 'string' && args.by.trim() ? args.by.slice(0, 50) : 'mcp-client',
|
|
75
|
+
at: new Date().toISOString(),
|
|
76
|
+
}
|
|
77
|
+
notify('notifications/dsh/intent', shared.intent)
|
|
78
|
+
return { ok: true, intent: shared.intent }
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
dsh_lock_acquire: {
|
|
82
|
+
name: 'dsh_lock_acquire',
|
|
83
|
+
description: '获取地板锁:持锁期间 DSH 的写入工具(write/edit/git)会被拒绝,实现单位时间单智能体动工作区',
|
|
84
|
+
properties: {
|
|
85
|
+
owner: { type: 'string', description: '持锁方标识,如 MCP 客户端 或 dsh' },
|
|
86
|
+
ttlMs: { type: 'number', description: '锁有效期(毫秒),默认 300000' },
|
|
87
|
+
},
|
|
88
|
+
required: ['owner'],
|
|
89
|
+
async execute(args) {
|
|
90
|
+
// H4 修复:owner 消毒——缺省/空串直接报错,避免变成字符串 "undefined" 导致释放比对 mismatch
|
|
91
|
+
const ownerRaw = args && typeof args.owner === 'string' ? args.owner.trim() : ''
|
|
92
|
+
if (!ownerRaw) throw new Error('dsh_lock_acquire: owner 必填(如 MCP 客户端 或 dsh)')
|
|
93
|
+
const owner = ownerRaw.slice(0, 50)
|
|
94
|
+
if (lockValid() && shared.lock.owner !== owner) {
|
|
95
|
+
return { ok: false, reason: `工作区已被 ${shared.lock.owner} 锁定`, lock: shared.lock }
|
|
96
|
+
}
|
|
97
|
+
shared.lock = {
|
|
98
|
+
owner,
|
|
99
|
+
at: new Date().toISOString(),
|
|
100
|
+
expiresAt: Date.now() + (Number(args.ttlMs) > 0 ? Number(args.ttlMs) : lockTtlMs),
|
|
101
|
+
}
|
|
102
|
+
notify('notifications/dsh/lock', shared.lock)
|
|
103
|
+
return { ok: true, lock: shared.lock }
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
dsh_lock_release: {
|
|
107
|
+
name: 'dsh_lock_release',
|
|
108
|
+
description: '释放地板锁(仅持锁方自己可释放;锁有过期时间兜底)',
|
|
109
|
+
properties: {
|
|
110
|
+
owner: { type: 'string', description: '持锁方标识' },
|
|
111
|
+
},
|
|
112
|
+
required: ['owner'],
|
|
113
|
+
async execute(args) {
|
|
114
|
+
const ownerRaw = args && typeof args.owner === 'string' ? args.owner.trim() : ''
|
|
115
|
+
if (!ownerRaw) throw new Error('dsh_lock_release: owner 必填')
|
|
116
|
+
const owner = ownerRaw.slice(0, 50)
|
|
117
|
+
if (!lockValid()) return { ok: false, reason: '锁未被持有(可能已过期)' }
|
|
118
|
+
if (shared.lock.owner !== owner) {
|
|
119
|
+
return { ok: false, reason: `只有持锁方 ${shared.lock.owner} 能释放` }
|
|
120
|
+
}
|
|
121
|
+
shared.lock = null
|
|
122
|
+
notify('notifications/dsh/lock', null)
|
|
123
|
+
return { ok: true }
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
dsh_lock_status: {
|
|
127
|
+
name: 'dsh_lock_status',
|
|
128
|
+
description: '查看地板锁当前状态',
|
|
129
|
+
properties: {},
|
|
130
|
+
required: [],
|
|
131
|
+
async execute() { return { held: lockValid(), lock: shared.lock } },
|
|
132
|
+
},
|
|
133
|
+
dsh_inject_alert: {
|
|
134
|
+
name: 'dsh_inject_alert',
|
|
135
|
+
description: '向 DSH 当前正在进行的这一轮注入提醒(经 pre-step 瀑布插进消息流)。若注入被禁用则写入收件箱',
|
|
136
|
+
properties: {
|
|
137
|
+
message: { type: 'string', description: '提醒内容(≤1000 字)' },
|
|
138
|
+
severity: { type: 'string', enum: ['info', 'warning', 'error'], description: '级别,默认 info' },
|
|
139
|
+
},
|
|
140
|
+
required: ['message'],
|
|
141
|
+
async execute(args) {
|
|
142
|
+
const message = String(args.message).slice(0, 1000)
|
|
143
|
+
const severity = args.severity || 'info'
|
|
144
|
+
// 幂等:同 message+severity 已在未消费队列则跳过,避免同一提醒反复刷屏
|
|
145
|
+
// (呼应 8/22 复盘「不要重复投递同一 alert」)
|
|
146
|
+
const existing = shared.alerts.find((a) => a.message === message && a.severity === severity)
|
|
147
|
+
if (existing) {
|
|
148
|
+
notify('notifications/dsh/alert-queued', existing)
|
|
149
|
+
return { ok: true, alert: existing, queued: shared.alerts.length, deduplicated: true }
|
|
150
|
+
}
|
|
151
|
+
const alert = {
|
|
152
|
+
id: randomUUID(), // 全局唯一,跨重启不撞历史 id,根治 alert-1 撞车
|
|
153
|
+
message,
|
|
154
|
+
severity,
|
|
155
|
+
at: new Date().toISOString(),
|
|
156
|
+
}
|
|
157
|
+
shared.alerts.push(alert)
|
|
158
|
+
notify('notifications/dsh/alert-queued', alert)
|
|
159
|
+
if (typeof writeInboxAlert === 'function') writeInboxAlert(alert)
|
|
160
|
+
return { ok: true, alert, queued: shared.alerts.length }
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
dsh_review_request: {
|
|
164
|
+
name: 'dsh_review_request',
|
|
165
|
+
description: '委托审查:写入收件箱(inbox/<id>.json),DSH 的 wb_inbox 工具会在下一轮领走并按 standards 审查 targets,结果回写 inbox/done/',
|
|
166
|
+
properties: {
|
|
167
|
+
id: { type: 'string', description: '任务编号(可选,缺省自动生成)' },
|
|
168
|
+
title: { type: 'string', description: '审查标题(必填)' },
|
|
169
|
+
targets: { type: 'array', items: { type: 'string' }, description: '要审查的文件路径列表' },
|
|
170
|
+
standards: { type: 'array', items: { type: 'string' }, description: '审查标准列表' },
|
|
171
|
+
testCmd: { type: 'string', description: '建议测试命令(DSH 不替你执行)' },
|
|
172
|
+
},
|
|
173
|
+
required: ['title'],
|
|
174
|
+
async execute(args) {
|
|
175
|
+
const manifest = writeReviewManifest(args)
|
|
176
|
+
return { ok: true, manifest }
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function createMcpBridge(handlers, opts = {}, onReady) {
|
|
183
|
+
const { port = 51630, host = '127.0.0.1', token = '' } = opts
|
|
184
|
+
|
|
185
|
+
const sessions = new Map() // sessionId -> { server, transport }
|
|
186
|
+
|
|
187
|
+
const buildServer = () => {
|
|
188
|
+
const server = new Server(
|
|
189
|
+
{ name: 'dsh-mcp-bridge', version: '0.1.1' },
|
|
190
|
+
{ capabilities: { tools: {} } },
|
|
191
|
+
)
|
|
192
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
193
|
+
tools: Object.values(handlers.tools).map((t) => ({
|
|
194
|
+
name: t.name,
|
|
195
|
+
description: t.description || '',
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: t.properties || {},
|
|
199
|
+
required: t.required || [],
|
|
200
|
+
},
|
|
201
|
+
})),
|
|
202
|
+
}))
|
|
203
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
204
|
+
const tool = handlers.tools[request.params.name]
|
|
205
|
+
if (!tool) throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`)
|
|
206
|
+
try {
|
|
207
|
+
const result = await tool.execute(request.params.arguments ?? {})
|
|
208
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
209
|
+
return { content: [{ type: 'text', text }] }
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: 'text', text: `错误: ${String(error?.message ?? error)}` }],
|
|
213
|
+
isError: true,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
return server
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const httpServer = createServer(async (req, res) => {
|
|
221
|
+
if (token) {
|
|
222
|
+
const auth = req.headers.authorization || ''
|
|
223
|
+
if (auth !== `Bearer ${token}`) {
|
|
224
|
+
res.writeHead(401, { 'content-type': 'application/json' })
|
|
225
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sessionId = req.headers['mcp-session-id']
|
|
231
|
+
const method = (req.method || '').toUpperCase()
|
|
232
|
+
|
|
233
|
+
// 已有会话 → 复用其 transport(POST/GET/DELETE 统一走这里)
|
|
234
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
235
|
+
sessions.get(sessionId).transport.handleRequest(req, res).catch((error) => {
|
|
236
|
+
console.error(`[dsh-mcp-bridge] 请求处理失败: ${String(error?.message ?? error)}`)
|
|
237
|
+
if (!res.headersSent) {
|
|
238
|
+
res.writeHead(500, { 'content-type': 'text/plain' })
|
|
239
|
+
res.end('internal error')
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 新会话:仅允许通过 POST 的 initialize 建立 → 新建独立 Server + transport
|
|
246
|
+
if (!sessionId && method === 'POST') {
|
|
247
|
+
const server = buildServer()
|
|
248
|
+
const transport = new StreamableHTTPServerTransport({
|
|
249
|
+
sessionIdGenerator: () => randomUUID(),
|
|
250
|
+
onsessioninitialized: (sid) => sessions.set(sid, { server, transport }),
|
|
251
|
+
})
|
|
252
|
+
transport.onclose = () => { sessions.delete(transport.sessionId) }
|
|
253
|
+
await server.connect(transport)
|
|
254
|
+
transport.handleRequest(req, res).catch((error) => {
|
|
255
|
+
console.error(`[dsh-mcp-bridge] 新会话处理失败: ${String(error?.message ?? error)}`)
|
|
256
|
+
if (!res.headersSent) {
|
|
257
|
+
res.writeHead(500, { 'content-type': 'text/plain' })
|
|
258
|
+
res.end('internal error')
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 无会话且无 initialize → 拒绝
|
|
265
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
266
|
+
res.end(JSON.stringify({
|
|
267
|
+
jsonrpc: '2.0',
|
|
268
|
+
error: { code: -32000, message: 'Bad Request: unknown or missing Mcp-Session-Id' },
|
|
269
|
+
id: null,
|
|
270
|
+
}))
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
await new Promise((resolveListen, reject) => {
|
|
274
|
+
httpServer.once('error', reject)
|
|
275
|
+
httpServer.listen(port, host, resolveListen)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const bridge = {
|
|
279
|
+
url: `http://${host}:${port}/mcp`,
|
|
280
|
+
notify(method, params) {
|
|
281
|
+
for (const { server } of sessions.values()) {
|
|
282
|
+
try { server.notification({ method, params }) } catch { /* 该会话已无流时忽略 */ }
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
async close() {
|
|
286
|
+
for (const { transport } of sessions.values()) {
|
|
287
|
+
try { await transport.close() } catch { /* 已关闭 */ }
|
|
288
|
+
}
|
|
289
|
+
await new Promise((r) => httpServer.close(r))
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
if (typeof onReady === 'function') onReady(bridge)
|
|
293
|
+
return bridge
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- 独立自测模式:node dsh/mcp.js [port] ----
|
|
297
|
+
const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
298
|
+
if (isCli) {
|
|
299
|
+
const port = Number(process.argv[2] || 51999)
|
|
300
|
+
const shared = {
|
|
301
|
+
status: 'working', current: '演示模式', turn: 0, lastTool: null,
|
|
302
|
+
problems: [], intent: null, lock: null, alerts: [], seq: 0,
|
|
303
|
+
}
|
|
304
|
+
let emit = () => {}
|
|
305
|
+
const tools = buildBridgeTools(shared, {
|
|
306
|
+
notify: (m, p) => emit(m, p),
|
|
307
|
+
writeInboxAlert: (alert) => { console.log(`[demo] 提醒入队: ${alert.message}`) },
|
|
308
|
+
writeReviewManifest: (args) => ({ ...args, id: args.id || 'demo-review', note: 'demo 模式不落盘' }),
|
|
309
|
+
})
|
|
310
|
+
createMcpBridge({ tools }, { port, host: '127.0.0.1' }, (b) => {
|
|
311
|
+
emit = b.notify
|
|
312
|
+
console.log(`[demo] MCP bridge 就绪: ${b.url}`)
|
|
313
|
+
console.log(`[demo] 用 node dsh/mcp-test.js ${b.url} 自测`)
|
|
314
|
+
}).catch((error) => {
|
|
315
|
+
console.error('启动失败:', error)
|
|
316
|
+
process.exit(1)
|
|
317
|
+
})
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-as-mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Exposes DSH as an MCP server over Streamable HTTP: streaming thinking/tool/file notifications, shared intent, a floor lock that physically denies DSH writes while held, mid-turn alert injection, and review delegation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dsh/index.js",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"dsh",
|
|
9
|
+
"dsh-plugin",
|
|
10
|
+
"mcp",
|
|
11
|
+
"mcp-server",
|
|
12
|
+
"deepseek-harness",
|
|
13
|
+
"streamable-http",
|
|
14
|
+
"cordis"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/qian-dsh/dsh-as-mcp-server.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/qian-dsh/dsh-as-mcp-server#readme",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.19"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dsh",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./dsh/index.js",
|
|
33
|
+
"./dsh": "./dsh/index.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@deepseek-ai/cordis": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"registry": "https://registry.npmjs.org/"
|
|
54
|
+
}
|
|
55
|
+
}
|