dsh-mcp-pill 0.2.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 +40 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +900 -0
- package/lib/config.js +54 -0
- package/lib/index.js +285 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-mcp-pill 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
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# dsh-mcp-pill
|
|
2
|
+
|
|
3
|
+
## English
|
|
4
|
+
|
|
5
|
+
**Current release: 0.2.2** — Remote settings are capability-detected and optional, so older DSH RC hosts continue to start the plugin.
|
|
6
|
+
|
|
7
|
+
A lifecycle-safe MCP connection status pill for DeepSeek Harness Web. It exposes loopback-fenced status/toggle RPC, an official Settings card, and a composer-seat pill that stays hidden until enabled. DSH 0.1.2+ fine-grained `remote.settings` is preferred; older RC hosts use the legacy connection API.
|
|
8
|
+
|
|
9
|
+
## 中文
|
|
10
|
+
|
|
11
|
+
用于 DeepSeek Harness Web 的生命周期安全 MCP 连接状态胶囊。提供本机同源 RPC、官方设置卡片和输入框状态胶囊;优先使用 DSH 0.1.2+ 的 `remote.settings`,旧版 RC 自动回退到 connection API。
|
|
12
|
+
|
|
13
|
+
Global MCP connection status pill for the DSH web UI — official bundle form
|
|
14
|
+
(host RPC + `__ModuleLoader__` client, no tapIndex injection).
|
|
15
|
+
|
|
16
|
+
- `GET /api/mcp-pill/status` — JSON status of every configured MCP connection
|
|
17
|
+
plus `pill.enabled`, the hot host mirror of the visibility toggle.
|
|
18
|
+
- `POST /api/mcp-pill/set` — `{ id, enabled }` toggles a connection via the
|
|
19
|
+
patch file's `disabled` marker (loader HMR applies it).
|
|
20
|
+
- The pill is hidden by DEFAULT. An official-style expandable Settings Card
|
|
21
|
+
(`settings.plugin.item` / key `mcp-pill`) owns one switch,
|
|
22
|
+
「显示状态胶囊」(`pill.enabled`, default `false`); while it is off the pill
|
|
23
|
+
never mounts visibly, and toggling it takes effect within one status poll
|
|
24
|
+
(instantly after a save in the same tab).
|
|
25
|
+
- The pill snaps to one of the chat input's four corners (drag to switch);
|
|
26
|
+
the anchor is remembered in `localStorage` (`dsh.mcpPill.anchor`).
|
|
27
|
+
- The pill mounts inside the composer seat (same stacking level as the input
|
|
28
|
+
box) at a normal `z-index`, so DSH web popups (modal / menu / toast) can
|
|
29
|
+
cover it instead of being hidden behind it.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
Add to the profile's `package.json` dependencies (`link:` for local dev) and
|
|
34
|
+
to `dsh.profile.bundles`, then `pnpm install` and restart `dsh web`.
|
|
35
|
+
|
|
36
|
+
## Config
|
|
37
|
+
|
|
38
|
+
The mounting row (in this package's `cordis.patch.yml`) passes
|
|
39
|
+
`config.patchFile` — the `cordis.patch.yml` holding the `dsh-mcp-client` rows,
|
|
40
|
+
resolved against the profile working directory (default `<cwd>/cordis.patch.yml`).
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# dsh-mcp-pill bundle patch
|
|
2
|
+
#
|
|
3
|
+
# Mounts the host half (RPC) + client half (web bundle). The `patchFile` config
|
|
4
|
+
# tells the host which cordis.patch.yml holds the dsh-mcp-client rows; it is
|
|
5
|
+
# resolved against the profile working directory (dsh web runs from the profile).
|
|
6
|
+
- insert:
|
|
7
|
+
- id: mcp-pill
|
|
8
|
+
name: 'dsh-mcp-pill'
|
|
9
|
+
config:
|
|
10
|
+
patchFile: 'cordis.patch.yml'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
// dsh-mcp-pill — client half (official __ModuleLoader__ web bundle)
|
|
2
|
+
//
|
|
3
|
+
// Browser-only bundle: consumed by the DSH web loader (window.__ModuleLoader__).
|
|
4
|
+
// Not importable in Node. Renders the MCP status pill + panel; the pill snaps
|
|
5
|
+
// to one of the chat input's four corners (drag to switch), position is
|
|
6
|
+
// remembered in localStorage.
|
|
7
|
+
//
|
|
8
|
+
// The pill is hidden by DEFAULT: an official-style expandable Settings Card
|
|
9
|
+
// under settings.plugin.item / key mcp-pill owns the「显示状态胶囊」switch
|
|
10
|
+
// (pill.enabled, default false), and the polled /api/mcp-pill/status payload
|
|
11
|
+
// (`pill.enabled`, hot host mirror) decides whether the pill shows at all.
|
|
12
|
+
//
|
|
13
|
+
// Lifecycle: every DOM node, observer, timer, rAF and listener created by
|
|
14
|
+
// startPill() is registered with the idempotent disposer returned through
|
|
15
|
+
// ctx.effect(), so stop / update / HMR release everything — including a drag
|
|
16
|
+
// that is still in flight — and a later apply re-mounts from scratch.
|
|
17
|
+
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: 'dsh-mcp-pill',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
const module = { exports: {} }
|
|
22
|
+
const exports = module.exports
|
|
23
|
+
const React = require('react')
|
|
24
|
+
const e = React.createElement
|
|
25
|
+
|
|
26
|
+
const NS = 'mcp-pill'
|
|
27
|
+
const API = '/api/mcp-pill'
|
|
28
|
+
// Dynamic polling intervals (ms): active when any MCP enabled, idle when all disabled
|
|
29
|
+
const POLL_INTERVALS = Object.freeze({
|
|
30
|
+
ACTIVE: 3000, // When at least one MCP is enabled
|
|
31
|
+
IDLE: 10000, // When all MCPs are disabled
|
|
32
|
+
ERROR: 5000, // When last fetch failed
|
|
33
|
+
})
|
|
34
|
+
const ENSURE_MS = 2000
|
|
35
|
+
const ROOT_ID = 'dsh-mcp-pill-root'
|
|
36
|
+
const ANCHOR_KEY = 'dsh.mcpPill.anchor'
|
|
37
|
+
const ANCHORS = Object.freeze(['tl', 'tr', 'bl', 'br'])
|
|
38
|
+
|
|
39
|
+
// Mounted flag owned by the plugin lifecycle: set when the pill starts and
|
|
40
|
+
// reset by the idempotent disposer, so a later apply (update / HMR / re-run)
|
|
41
|
+
// mounts a fresh pill instead of being blocked by a stale flag.
|
|
42
|
+
let pillMounted = false
|
|
43
|
+
let pillDispose = null
|
|
44
|
+
|
|
45
|
+
// ── settings card (official-style, same chrome as the ADAPT card) ───────
|
|
46
|
+
|
|
47
|
+
const FIELDS = [
|
|
48
|
+
{ path: ['pill', 'enabled'], kind: 'bool', label: '显示状态胶囊', hint: '默认关闭。开启后 MCP 连接状态胶囊显示在输入框旁;拖动胶囊可吸附四角。' },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
function isPlainObject(v) {
|
|
52
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getAt(obj, path) {
|
|
56
|
+
let cur = obj
|
|
57
|
+
for (const key of path) {
|
|
58
|
+
if (!isPlainObject(cur) || !(key in cur)) return undefined
|
|
59
|
+
cur = cur[key]
|
|
60
|
+
}
|
|
61
|
+
return cur
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatValue(field, value) {
|
|
65
|
+
if (field.kind === 'bool') return value ? 'true' : 'false'
|
|
66
|
+
return ''
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseValue(field, text) {
|
|
70
|
+
if (field.kind === 'bool') return text === true || text === 'true'
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fieldKey(path) {
|
|
75
|
+
return path.join('.')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Official PluginCard chrome cannot be imported by an out-of-repo plugin
|
|
79
|
+
// (bundle purity). Recreate the same disclosure card so MCP Pill sits in
|
|
80
|
+
// the Plugins list as one expandable <li> beside Shell / Agent loop.
|
|
81
|
+
const CARD_CSS_ID = 'dsh-mcp-pill/plugin-card'
|
|
82
|
+
const CARD_CSS = [
|
|
83
|
+
'.dmpCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}',
|
|
84
|
+
'.dmpCard:hover{border-color:var(--dsw-alias-label-dimmed)}',
|
|
85
|
+
'.dmpCardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}',
|
|
86
|
+
'.dmpHeader{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}',
|
|
87
|
+
'.dmpHeader:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
|
|
88
|
+
'.dmpHeadText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}',
|
|
89
|
+
'.dmpName{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
|
|
90
|
+
'.dmpDescription{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
|
|
91
|
+
'.dmpChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}',
|
|
92
|
+
'.dmpChevronOpen{transform:rotate(180deg)}',
|
|
93
|
+
'.dmpBody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}',
|
|
94
|
+
'.dmpReadOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}',
|
|
95
|
+
'.dmpPending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}',
|
|
96
|
+
'.dmpFooter{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}',
|
|
97
|
+
'.dmpFailed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}',
|
|
98
|
+
'.dmpDiscard,.dmpSave{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}',
|
|
99
|
+
'.dmpDiscard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}',
|
|
100
|
+
'.dmpDiscard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}',
|
|
101
|
+
'.dmpSave{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}',
|
|
102
|
+
'.dmpDiscard:disabled,.dmpSave:disabled{opacity:.4;cursor:default}',
|
|
103
|
+
'.dmpDiscard:focus-visible,.dmpSave:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
|
|
104
|
+
'.dmpField{flex-direction:column;gap:6px;padding:12px 0;display:flex}',
|
|
105
|
+
'.dmpField+.dmpField{border-top:1px solid var(--dsw-alias-border-l2)}',
|
|
106
|
+
'.dmpFieldHead{align-items:center;gap:8px;display:flex}',
|
|
107
|
+
'.dmpLabel{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}',
|
|
108
|
+
'.dmpBadges{align-items:center;gap:8px;display:inline-flex}',
|
|
109
|
+
'.dmpBadge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}',
|
|
110
|
+
'.dmpReset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}',
|
|
111
|
+
'.dmpReset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
|
|
112
|
+
'.dmpHint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}',
|
|
113
|
+
'.dmpSwitch{appearance:none;width:36px;height:20px;margin:0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-3);position:relative;cursor:pointer;flex:none}',
|
|
114
|
+
'.dmpSwitch::after{content:"";width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-label-tertiary);position:absolute;top:2px;left:2px;transition:transform .16s,background .16s}',
|
|
115
|
+
'.dmpSwitch:checked{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}',
|
|
116
|
+
'.dmpSwitch:checked::after{background:var(--dsw-alias-bg-layer-3);transform:translateX(16px)}',
|
|
117
|
+
'.dmpSwitch:disabled{opacity:.4;cursor:default}',
|
|
118
|
+
'.dmpSwitch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
|
|
119
|
+
].join('')
|
|
120
|
+
|
|
121
|
+
function ensureCardStyles() {
|
|
122
|
+
if (typeof document === 'undefined') return
|
|
123
|
+
if (document.querySelector('style[data-plugin-css=' + JSON.stringify(CARD_CSS_ID) + ']')) return
|
|
124
|
+
const tag = document.createElement('style')
|
|
125
|
+
tag.dataset.plugin = 'dsh-mcp-pill'
|
|
126
|
+
tag.dataset.pluginCss = CARD_CSS_ID
|
|
127
|
+
tag.textContent = CARD_CSS
|
|
128
|
+
document.head.appendChild(tag)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Idempotent inverse of ensureCardStyles(): removed on stop / update /
|
|
132
|
+
// HMR so no style tag is left behind; a later render re-creates it.
|
|
133
|
+
function removeCardStyles() {
|
|
134
|
+
if (typeof document === 'undefined') return
|
|
135
|
+
const tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(CARD_CSS_ID) + ']')
|
|
136
|
+
if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Chevron(props) {
|
|
140
|
+
return e('svg', {
|
|
141
|
+
width: 14,
|
|
142
|
+
height: 14,
|
|
143
|
+
className: props.className,
|
|
144
|
+
viewBox: '0 0 14 14',
|
|
145
|
+
fill: 'none',
|
|
146
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
147
|
+
'aria-hidden': true,
|
|
148
|
+
}, e('path', {
|
|
149
|
+
d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z',
|
|
150
|
+
fill: 'currentColor',
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function FieldRow(props) {
|
|
155
|
+
const head = [
|
|
156
|
+
e('label', { key: 'lab', className: 'dmpLabel', htmlFor: props.id }, props.label),
|
|
157
|
+
]
|
|
158
|
+
if (props.overridden) {
|
|
159
|
+
head.push(e('span', { key: 'badges', className: 'dmpBadges' },
|
|
160
|
+
e('span', { className: 'dmpBadge' }, '已覆盖'),
|
|
161
|
+
e('button', {
|
|
162
|
+
type: 'button',
|
|
163
|
+
className: 'dmpReset',
|
|
164
|
+
disabled: props.disabled,
|
|
165
|
+
onClick: props.onReset,
|
|
166
|
+
}, '恢复默认'),
|
|
167
|
+
))
|
|
168
|
+
}
|
|
169
|
+
return e('div', { className: 'dmpField' },
|
|
170
|
+
e('div', { className: 'dmpFieldHead' }, head),
|
|
171
|
+
e('input', {
|
|
172
|
+
id: props.id,
|
|
173
|
+
className: 'dmpSwitch',
|
|
174
|
+
type: 'checkbox',
|
|
175
|
+
checked: props.text === 'true',
|
|
176
|
+
disabled: props.disabled,
|
|
177
|
+
onChange: (ev) => props.onEdit(ev.target.checked ? 'true' : 'false'),
|
|
178
|
+
}),
|
|
179
|
+
e('p', { className: 'dmpHint' }, props.hint || null),
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function SettingsCard(props) {
|
|
184
|
+
ensureCardStyles()
|
|
185
|
+
const scope = props.scope
|
|
186
|
+
const api = props.api
|
|
187
|
+
const [tick, setTick] = React.useState(0)
|
|
188
|
+
const [open, setOpen] = React.useState(false)
|
|
189
|
+
const [staged, setStaged] = React.useState({})
|
|
190
|
+
const [saving, setSaving] = React.useState(false)
|
|
191
|
+
const [failed, setFailed] = React.useState(false)
|
|
192
|
+
|
|
193
|
+
React.useEffect(() => {
|
|
194
|
+
if (!scope || typeof scope.subscribe !== 'function') return undefined
|
|
195
|
+
return scope.subscribe(() => setTick((n) => n + 1))
|
|
196
|
+
}, [scope])
|
|
197
|
+
|
|
198
|
+
const snap = scope && typeof scope.getSnapshot === 'function'
|
|
199
|
+
? scope.getSnapshot()
|
|
200
|
+
: { status: 'unavailable', value: undefined, base: undefined, user: undefined, revision: undefined, writable: false }
|
|
201
|
+
|
|
202
|
+
const available = snap.status === 'ready'
|
|
203
|
+
const writable = !!snap.writable
|
|
204
|
+
const value = snap.value || {}
|
|
205
|
+
const base = snap.base || {}
|
|
206
|
+
const user = snap.user || {}
|
|
207
|
+
|
|
208
|
+
const plan = []
|
|
209
|
+
for (const field of FIELDS) {
|
|
210
|
+
const key = fieldKey(field.path)
|
|
211
|
+
const draft = staged[key]
|
|
212
|
+
if (!draft) continue
|
|
213
|
+
if (draft.clear) {
|
|
214
|
+
if (getAt(user, field.path) !== undefined) plan.push({ op: 'unset', path: field.path })
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
const parsed = parseValue(field, draft.text)
|
|
218
|
+
if (formatValue(field, getAt(value, field.path)) === formatValue(field, parsed)) continue
|
|
219
|
+
plan.push({ op: 'set', path: field.path, value: parsed })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const dirty = plan.length > 0
|
|
223
|
+
const blocked = !dirty || saving
|
|
224
|
+
|
|
225
|
+
function stage(field, next) {
|
|
226
|
+
setFailed(false)
|
|
227
|
+
setStaged((prev) => Object.assign({}, prev, { [fieldKey(field.path)]: next }))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function discard() {
|
|
231
|
+
if (!dirty && !failed) return
|
|
232
|
+
setStaged({})
|
|
233
|
+
setFailed(false)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function save() {
|
|
237
|
+
if (!api || !api.settings || saving || !dirty || !writable) return
|
|
238
|
+
setSaving(true)
|
|
239
|
+
setFailed(false)
|
|
240
|
+
try {
|
|
241
|
+
const ops = plan.map((item) => (
|
|
242
|
+
item.op === 'unset'
|
|
243
|
+
? { op: 'unset', path: item.path }
|
|
244
|
+
: { op: 'set', path: item.path, value: item.value }
|
|
245
|
+
))
|
|
246
|
+
const payload = { ns: NS, ops }
|
|
247
|
+
if (snap.revision !== undefined) payload.expectedRevision = snap.revision
|
|
248
|
+
const response = await api.settings.mutate(payload)
|
|
249
|
+
const ok = !!(response && response.result && response.result.ok)
|
|
250
|
+
if (ok) setStaged({})
|
|
251
|
+
else setFailed(true)
|
|
252
|
+
} catch (_) {
|
|
253
|
+
setFailed(true)
|
|
254
|
+
}
|
|
255
|
+
setSaving(false)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
void tick
|
|
259
|
+
if (!available) return null
|
|
260
|
+
|
|
261
|
+
const fields = FIELDS.map((field) => {
|
|
262
|
+
const key = fieldKey(field.path)
|
|
263
|
+
const draft = staged[key]
|
|
264
|
+
const current = getAt(value, field.path)
|
|
265
|
+
const stored = getAt(user, field.path) !== undefined
|
|
266
|
+
const overridden = draft
|
|
267
|
+
? !draft.clear
|
|
268
|
+
: stored
|
|
269
|
+
const text = draft ? draft.text : formatValue(field, current)
|
|
270
|
+
return e(FieldRow, {
|
|
271
|
+
key,
|
|
272
|
+
id: 'plugin-config-mcp-pill-' + key.replace(/\./g, '-'),
|
|
273
|
+
kind: field.kind,
|
|
274
|
+
label: field.label,
|
|
275
|
+
hint: field.hint,
|
|
276
|
+
text,
|
|
277
|
+
overridden,
|
|
278
|
+
disabled: !writable || saving,
|
|
279
|
+
onEdit: (next) => stage(field, { text: next, clear: false }),
|
|
280
|
+
onReset: () => stage(field, { text: formatValue(field, getAt(base, field.path)), clear: true }),
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const body = open ? e('div', { className: 'dmpBody' },
|
|
285
|
+
writable ? null : e('p', { className: 'dmpReadOnly', role: 'status' }, '本部署的设置为只读。'),
|
|
286
|
+
fields,
|
|
287
|
+
e('div', { className: 'dmpFooter' },
|
|
288
|
+
failed ? e('p', { className: 'dmpFailed', role: 'status' }, '本部署没有接受这些值,已保留供你修改。') : null,
|
|
289
|
+
e('button', {
|
|
290
|
+
type: 'button',
|
|
291
|
+
className: 'dmpDiscard',
|
|
292
|
+
disabled: !dirty || saving,
|
|
293
|
+
onClick: discard,
|
|
294
|
+
}, '放弃修改'),
|
|
295
|
+
e('button', {
|
|
296
|
+
type: 'button',
|
|
297
|
+
className: 'dmpSave',
|
|
298
|
+
disabled: blocked || !writable,
|
|
299
|
+
onClick: save,
|
|
300
|
+
}, saving ? '保存中…' : '保存'),
|
|
301
|
+
),
|
|
302
|
+
) : null
|
|
303
|
+
|
|
304
|
+
return e('li', { className: open ? 'dmpCard dmpCardOpen' : 'dmpCard' },
|
|
305
|
+
e('button', {
|
|
306
|
+
type: 'button',
|
|
307
|
+
className: 'dmpHeader',
|
|
308
|
+
'aria-expanded': open,
|
|
309
|
+
'aria-label': (open ? '收起设置' : '展开设置') + ': MCP Pill',
|
|
310
|
+
onClick: () => setOpen(!open),
|
|
311
|
+
},
|
|
312
|
+
e('span', { className: 'dmpHeadText' },
|
|
313
|
+
e('span', { className: 'dmpName' }, 'MCP Pill'),
|
|
314
|
+
e('span', { className: 'dmpDescription' }, 'MCP 连接状态胶囊。默认隐藏,开启后显示在输入框旁。'),
|
|
315
|
+
),
|
|
316
|
+
dirty ? e('span', { className: 'dmpPending' }, '未保存') : null,
|
|
317
|
+
e(Chevron, { className: open ? 'dmpChevron dmpChevronOpen' : 'dmpChevron' }),
|
|
318
|
+
),
|
|
319
|
+
body,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── composer pill ───────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
function startPill(scope) {
|
|
326
|
+
if (typeof document === 'undefined' || !document.documentElement) return
|
|
327
|
+
if (pillMounted) {
|
|
328
|
+
// Previous mount leaked (disposal was skipped). Dispose it first, then
|
|
329
|
+
// mount again: every apply must end with exactly one live pill.
|
|
330
|
+
const previous = pillDispose
|
|
331
|
+
pillDispose = null
|
|
332
|
+
if (typeof previous === 'function') previous()
|
|
333
|
+
return startPill()
|
|
334
|
+
}
|
|
335
|
+
pillMounted = true
|
|
336
|
+
|
|
337
|
+
// Idempotent re-mount: drop any root a previous bundle left behind
|
|
338
|
+
// before creating a fresh one.
|
|
339
|
+
const stale = document.getElementById(ROOT_ID)
|
|
340
|
+
if (stale && stale.parentNode) stale.parentNode.removeChild(stale)
|
|
341
|
+
|
|
342
|
+
const cleanups = []
|
|
343
|
+
const track = (fn) => cleanups.push(fn)
|
|
344
|
+
|
|
345
|
+
let disposed = false
|
|
346
|
+
let root = null
|
|
347
|
+
let dragCleanup = null
|
|
348
|
+
|
|
349
|
+
// Mount target is ONLY the composer seat. There is deliberately no
|
|
350
|
+
// document.body fallback (and no composer-card-parent fallback): the pill
|
|
351
|
+
// must stay inside the composer stacking context so DSH overlays can
|
|
352
|
+
// cover it. While the seat has not appeared yet the root stays detached,
|
|
353
|
+
// so no off-page pill floats over the UI; the document observer below
|
|
354
|
+
// mounts it as soon as [data-composer-seat] exists.
|
|
355
|
+
function findSeat() {
|
|
356
|
+
return document.querySelector('[data-composer-seat]')
|
|
357
|
+
}
|
|
358
|
+
function ensureMounted() {
|
|
359
|
+
const seat = findSeat()
|
|
360
|
+
if (!seat) return false
|
|
361
|
+
if (root.parentNode !== seat || !root.isConnected) seat.appendChild(root)
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
root = document.createElement('div')
|
|
366
|
+
root.id = ROOT_ID
|
|
367
|
+
// Hidden by default (pill.enabled defaults to false): the visibility
|
|
368
|
+
// gate below reveals it only once /status reports pill.enabled === true.
|
|
369
|
+
root.style.cssText = 'position:fixed;z-index:1;display:none;font-family:ui-sans-serif,system-ui,sans-serif;'
|
|
370
|
+
|
|
371
|
+
const shadow = root.attachShadow({ mode: 'open' })
|
|
372
|
+
shadow.innerHTML = '<style>' + [
|
|
373
|
+
':host{all:initial}',
|
|
374
|
+
'.pill{display:flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--dsw-alias-border-l1,#444);border-radius:999px;background:var(--dsw-alias-bg-layer-1,#222);color:var(--dsw-alias-label-primary,#eee);font-size:12px;cursor:grab;box-shadow:0 2px 8px rgba(0,0,0,.25);user-select:none;touch-action:none}',
|
|
375
|
+
'.pill:active{cursor:grabbing}',
|
|
376
|
+
'.pill:hover{border-color:var(--dsw-alias-brand-primary,#4a9eff)}',
|
|
377
|
+
'.dot{width:8px;height:8px;border-radius:50%;display:inline-block}',
|
|
378
|
+
'.dot.ok{background:var(--dsw-alias-state-success-primary,#3fb950)}',
|
|
379
|
+
'.dot.busy{background:var(--dsw-alias-state-warn-primary,#d29922)}',
|
|
380
|
+
'.dot.off{background:var(--dsw-alias-label-secondary,#999)}',
|
|
381
|
+
'.panel{position:fixed;right:0;bottom:calc(100% + 8px);width:320px;max-height:70vh;overflow:auto;display:none;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l1,#444);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#222);color:var(--dsw-alias-label-primary,#eee);font-size:12px;box-shadow:0 6px 24px rgba(0,0,0,.35)}',
|
|
382
|
+
'.panel.open{display:flex}',
|
|
383
|
+
'.head{display:flex;align-items:center;gap:8px}',
|
|
384
|
+
'.head b{font-size:13px}',
|
|
385
|
+
'.head .count{margin-left:auto;color:var(--dsw-alias-label-secondary,#999);font-size:11px}',
|
|
386
|
+
'.err{color:var(--dsw-alias-state-error-primary,#f85149);font-size:11px;word-break:break-all}',
|
|
387
|
+
'.entry{border:1px solid var(--dsw-alias-border-l1,#444);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:4px}',
|
|
388
|
+
'.row{display:flex;align-items:center;gap:6px}',
|
|
389
|
+
'.srv{font-family:ui-monospace,Consolas,monospace;font-weight:600}',
|
|
390
|
+
'.meta{color:var(--dsw-alias-label-secondary,#999);font-size:11px}',
|
|
391
|
+
'.btn{margin-left:auto;padding:2px 10px;border:1px solid var(--dsw-alias-border-l2,#666);border-radius:999px;background:transparent;color:var(--dsw-alias-label-primary,#eee);font-size:11px;cursor:pointer}',
|
|
392
|
+
'.btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#4a9eff);color:var(--dsw-alias-brand-primary,#4a9eff)}',
|
|
393
|
+
'.btn:disabled{opacity:.5;cursor:default}',
|
|
394
|
+
'.tools{display:flex;flex-wrap:wrap;gap:4px}',
|
|
395
|
+
'.tool{font-family:ui-monospace,Consolas,monospace;font-size:10px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-2,#333);color:var(--dsw-alias-label-secondary,#bbb)}',
|
|
396
|
+
'.empty{color:var(--dsw-alias-label-secondary,#999);font-size:11px;padding:2px 0}',
|
|
397
|
+
'.close{margin-left:auto;border:none;background:transparent;color:var(--dsw-alias-label-secondary,#999);cursor:pointer;font-size:13px;padding:0 2px}',
|
|
398
|
+
'.close:hover{color:var(--dsw-alias-label-primary,#eee)}'
|
|
399
|
+
].join('') + '</style>'
|
|
400
|
+
|
|
401
|
+
const state = { entries: [], error: null, busyId: null, open: false }
|
|
402
|
+
|
|
403
|
+
// ── poll scheduling ────────────────────────────────────────────────────
|
|
404
|
+
// One interval is armed for the whole pill life. After every refresh it
|
|
405
|
+
// is re-armed with the interval that fits the latest entries/error
|
|
406
|
+
// state (slower while everything is disabled, faster again after a
|
|
407
|
+
// toggle or while errors persist), and skipped when the interval did
|
|
408
|
+
// not change so the cadence stays stable. Every status/toggle fetch is
|
|
409
|
+
// bounded by FETCH_TIMEOUT_MS so a hung request can neither stall the
|
|
410
|
+
// poll nor wedge a button; in-flight controllers live in `inFlight` and
|
|
411
|
+
// disposePill aborts them all.
|
|
412
|
+
const FETCH_TIMEOUT_MS = 10000
|
|
413
|
+
let pollTimer = null
|
|
414
|
+
let armedMs = null
|
|
415
|
+
const inFlight = new Set()
|
|
416
|
+
|
|
417
|
+
function nextPollMs() {
|
|
418
|
+
if (state.error) return POLL_INTERVALS.ERROR
|
|
419
|
+
return state.entries.some((e) => e.enabled)
|
|
420
|
+
? POLL_INTERVALS.ACTIVE
|
|
421
|
+
: POLL_INTERVALS.IDLE
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function armPoll(ms) {
|
|
425
|
+
if (disposed) return
|
|
426
|
+
if (ms === armedMs && pollTimer !== null) return // unchanged — keep cadence
|
|
427
|
+
if (pollTimer !== null) clearInterval(pollTimer)
|
|
428
|
+
armedMs = ms
|
|
429
|
+
pollTimer = setInterval(refresh, ms)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function boundedFetch(url, init) {
|
|
433
|
+
const controller = new AbortController()
|
|
434
|
+
const timer = setTimeout(function () { controller.abort() }, FETCH_TIMEOUT_MS)
|
|
435
|
+
inFlight.add(controller)
|
|
436
|
+
try {
|
|
437
|
+
return await fetch(url, Object.assign({}, init, { signal: controller.signal }))
|
|
438
|
+
} finally {
|
|
439
|
+
clearTimeout(timer)
|
|
440
|
+
inFlight.delete(controller)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const pill = document.createElement('button')
|
|
445
|
+
pill.className = 'pill'
|
|
446
|
+
pill.title = 'MCP 连接状态(点击查看工具;拖动可吸附到输入框四角)'
|
|
447
|
+
const pillDot = document.createElement('span')
|
|
448
|
+
pillDot.className = 'dot off'
|
|
449
|
+
const pillText = document.createElement('span')
|
|
450
|
+
pillText.textContent = 'MCP'
|
|
451
|
+
pill.append(pillDot, pillText)
|
|
452
|
+
|
|
453
|
+
const cluster = document.createElement('div')
|
|
454
|
+
cluster.style.cssText = 'display:flex;align-items:center'
|
|
455
|
+
cluster.append(pill)
|
|
456
|
+
|
|
457
|
+
const panel = document.createElement('div')
|
|
458
|
+
panel.className = 'panel'
|
|
459
|
+
const head = document.createElement('div')
|
|
460
|
+
head.className = 'head'
|
|
461
|
+
const title = document.createElement('b')
|
|
462
|
+
title.textContent = 'MCP 连接'
|
|
463
|
+
const count = document.createElement('span')
|
|
464
|
+
count.className = 'count'
|
|
465
|
+
const closeBtn = document.createElement('button')
|
|
466
|
+
closeBtn.className = 'close'
|
|
467
|
+
closeBtn.textContent = '✕'
|
|
468
|
+
head.append(title, count, closeBtn)
|
|
469
|
+
const list = document.createElement('div')
|
|
470
|
+
list.style.cssText = 'display:flex;flex-direction:column;gap:8px'
|
|
471
|
+
panel.append(head, list)
|
|
472
|
+
|
|
473
|
+
shadow.append(cluster, panel)
|
|
474
|
+
|
|
475
|
+
// ── composer-anchored: snaps to one of the input's four corners ────
|
|
476
|
+
// [data-composer-card] is the input card, [data-composer-seat] its
|
|
477
|
+
// dock. Drag to the quadrant you want and it snaps to that corner;
|
|
478
|
+
// the pill only exists while the seat does, so it never floats on
|
|
479
|
+
// the body when the composer is gone.
|
|
480
|
+
|
|
481
|
+
let anchor = 'br'
|
|
482
|
+
try {
|
|
483
|
+
const saved = localStorage.getItem(ANCHOR_KEY)
|
|
484
|
+
if (saved === 'left') anchor = 'bl' // legacy value
|
|
485
|
+
else if (saved === 'right') anchor = 'br' // legacy value
|
|
486
|
+
else if (ANCHORS.indexOf(saved) !== -1) anchor = saved
|
|
487
|
+
} catch (_) {}
|
|
488
|
+
|
|
489
|
+
root.style.right = '16px'
|
|
490
|
+
root.style.bottom = '16px'
|
|
491
|
+
|
|
492
|
+
let lastX = null
|
|
493
|
+
let lastY = null
|
|
494
|
+
let dragging = false
|
|
495
|
+
|
|
496
|
+
function findComposer() {
|
|
497
|
+
return document.querySelector('[data-composer-card]') || findSeat() || null
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function place() {
|
|
501
|
+
if (dragging || !root.isConnected) return // mid-drag or not mounted yet
|
|
502
|
+
const seat = findComposer()
|
|
503
|
+
if (!seat) return
|
|
504
|
+
const r = seat.getBoundingClientRect()
|
|
505
|
+
const pr = cluster.getBoundingClientRect()
|
|
506
|
+
const gap = 8
|
|
507
|
+
const leftSide = anchor === 'tl' || anchor === 'bl'
|
|
508
|
+
const topSide = anchor === 'tl' || anchor === 'tr'
|
|
509
|
+
let x = leftSide ? r.left - pr.width - gap : r.right + gap
|
|
510
|
+
let y = topSide ? r.top - pr.height - gap : r.bottom + gap
|
|
511
|
+
x = Math.max(4, Math.min(window.innerWidth - pr.width - 4, x))
|
|
512
|
+
y = Math.max(4, Math.min(window.innerHeight - pr.height - 4, y))
|
|
513
|
+
if (x === lastX && y === lastY) return
|
|
514
|
+
lastX = x
|
|
515
|
+
lastY = y
|
|
516
|
+
root.style.left = x + 'px'
|
|
517
|
+
root.style.top = y + 'px'
|
|
518
|
+
root.style.right = 'auto'
|
|
519
|
+
root.style.bottom = 'auto'
|
|
520
|
+
if (panel.classList.contains('open')) placePanel()
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let rafId = 0
|
|
524
|
+
let rafPending = false
|
|
525
|
+
function schedulePlace() {
|
|
526
|
+
if (disposed || rafPending) return
|
|
527
|
+
rafPending = true
|
|
528
|
+
rafId = requestAnimationFrame(function () {
|
|
529
|
+
rafPending = false
|
|
530
|
+
rafId = 0
|
|
531
|
+
place()
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Wait for [data-composer-seat]: observe the whole document so the pill
|
|
536
|
+
// mounts whenever the seat first appears and follows later re-inserts.
|
|
537
|
+
const domObserver = new MutationObserver(function () {
|
|
538
|
+
if (ensureMounted()) schedulePlace()
|
|
539
|
+
})
|
|
540
|
+
domObserver.observe(document.documentElement, { childList: true, subtree: true })
|
|
541
|
+
track(function () { domObserver.disconnect() })
|
|
542
|
+
|
|
543
|
+
// Follow the composer: size changes (multiline input), window resizes,
|
|
544
|
+
// and any scroll container moving it.
|
|
545
|
+
window.addEventListener('scroll', schedulePlace, { capture: true, passive: true })
|
|
546
|
+
track(function () { window.removeEventListener('scroll', schedulePlace, { capture: true }) })
|
|
547
|
+
|
|
548
|
+
window.addEventListener('resize', schedulePlace)
|
|
549
|
+
track(function () { window.removeEventListener('resize', schedulePlace) })
|
|
550
|
+
|
|
551
|
+
const seatRo = new ResizeObserver(schedulePlace)
|
|
552
|
+
let seatObserved = null
|
|
553
|
+
function watchSeat() {
|
|
554
|
+
const seat = findComposer()
|
|
555
|
+
if (seat && seat !== seatObserved) {
|
|
556
|
+
if (seatObserved) seatRo.unobserve(seatObserved)
|
|
557
|
+
seatRo.observe(seat)
|
|
558
|
+
seatObserved = seat
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
watchSeat()
|
|
562
|
+
track(function () { seatRo.disconnect() })
|
|
563
|
+
|
|
564
|
+
const seatTimer = setInterval(function () {
|
|
565
|
+
ensureMounted() // React may have re-rendered the composer seat away
|
|
566
|
+
watchSeat()
|
|
567
|
+
place()
|
|
568
|
+
}, ENSURE_MS)
|
|
569
|
+
track(function () { clearInterval(seatTimer) })
|
|
570
|
+
|
|
571
|
+
// Drag to switch corners: while dragging the pill follows the pointer;
|
|
572
|
+
// on release it snaps to the corner matching the quadrant the pill
|
|
573
|
+
// center landed in (left/right × top/bottom) and remembers it.
|
|
574
|
+
let lastDragAt = 0
|
|
575
|
+
function onPointerDown(ev) {
|
|
576
|
+
if (ev.button !== 0 || disposed) return
|
|
577
|
+
ev.preventDefault()
|
|
578
|
+
dragging = true
|
|
579
|
+
panel.classList.remove('open')
|
|
580
|
+
const startX = ev.clientX
|
|
581
|
+
const startY = ev.clientY
|
|
582
|
+
const startLeft = root.getBoundingClientRect().left
|
|
583
|
+
const startTop = root.getBoundingClientRect().top
|
|
584
|
+
let moved = false
|
|
585
|
+
const prevSelect = document.body.style.userSelect
|
|
586
|
+
document.body.style.userSelect = 'none'
|
|
587
|
+
function onMove(mv) {
|
|
588
|
+
const dx = mv.clientX - startX
|
|
589
|
+
const dy = mv.clientY - startY
|
|
590
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true
|
|
591
|
+
root.style.left = (startLeft + dx) + 'px'
|
|
592
|
+
root.style.top = (startTop + dy) + 'px'
|
|
593
|
+
root.style.right = 'auto'
|
|
594
|
+
root.style.bottom = 'auto'
|
|
595
|
+
}
|
|
596
|
+
function onUp() {
|
|
597
|
+
dragCleanup = null
|
|
598
|
+
document.removeEventListener('pointermove', onMove)
|
|
599
|
+
document.removeEventListener('pointerup', onUp)
|
|
600
|
+
document.body.style.userSelect = prevSelect
|
|
601
|
+
dragging = false
|
|
602
|
+
if (!moved) return
|
|
603
|
+
lastDragAt = Date.now()
|
|
604
|
+
const rect = root.getBoundingClientRect()
|
|
605
|
+
const cx = rect.left + rect.width / 2
|
|
606
|
+
const cy = rect.top + rect.height / 2
|
|
607
|
+
const seat = findComposer()
|
|
608
|
+
if (seat) {
|
|
609
|
+
const r = seat.getBoundingClientRect()
|
|
610
|
+
const leftSide = cx < r.left + r.width / 2
|
|
611
|
+
const topSide = cy < r.top + r.height / 2
|
|
612
|
+
anchor = (topSide ? 't' : 'b') + (leftSide ? 'l' : 'r')
|
|
613
|
+
try { localStorage.setItem(ANCHOR_KEY, anchor) } catch (_) {}
|
|
614
|
+
}
|
|
615
|
+
lastX = null
|
|
616
|
+
lastY = null
|
|
617
|
+
place()
|
|
618
|
+
}
|
|
619
|
+
// Disposal during an active drag must still release the document-level
|
|
620
|
+
// listeners and restore the body selection style.
|
|
621
|
+
dragCleanup = function () {
|
|
622
|
+
document.removeEventListener('pointermove', onMove)
|
|
623
|
+
document.removeEventListener('pointerup', onUp)
|
|
624
|
+
document.body.style.userSelect = prevSelect
|
|
625
|
+
dragging = false
|
|
626
|
+
}
|
|
627
|
+
document.addEventListener('pointermove', onMove)
|
|
628
|
+
document.addEventListener('pointerup', onUp)
|
|
629
|
+
}
|
|
630
|
+
cluster.addEventListener('pointerdown', onPointerDown)
|
|
631
|
+
track(function () { cluster.removeEventListener('pointerdown', onPointerDown) })
|
|
632
|
+
|
|
633
|
+
function placePanel() {
|
|
634
|
+
// The panel is positioned in VIEWPORT coordinates so it follows the
|
|
635
|
+
// pill wherever it sits.
|
|
636
|
+
panel.style.position = 'fixed'
|
|
637
|
+
const r = cluster.getBoundingClientRect()
|
|
638
|
+
const pw = panel.offsetWidth || 320
|
|
639
|
+
const ph = panel.offsetHeight || 320
|
|
640
|
+
let left = r.left
|
|
641
|
+
const maxLeft = Math.max(4, window.innerWidth - pw - 4)
|
|
642
|
+
if (left > maxLeft) left = maxLeft
|
|
643
|
+
if (left < 4) left = 4
|
|
644
|
+
const below = window.innerHeight - r.bottom - 8 >= ph || r.top < ph + 8
|
|
645
|
+
if (below) {
|
|
646
|
+
panel.style.left = left + 'px'
|
|
647
|
+
panel.style.top = (r.bottom + 8) + 'px'
|
|
648
|
+
panel.style.right = 'auto'
|
|
649
|
+
panel.style.bottom = 'auto'
|
|
650
|
+
} else {
|
|
651
|
+
panel.style.left = left + 'px'
|
|
652
|
+
panel.style.top = 'auto'
|
|
653
|
+
panel.style.right = 'auto'
|
|
654
|
+
panel.style.bottom = (window.innerHeight - r.top + 8) + 'px'
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function statusDot(entry) {
|
|
659
|
+
return entry.connected ? 'ok' : entry.enabled ? 'busy' : 'off'
|
|
660
|
+
}
|
|
661
|
+
function statusText(entry) {
|
|
662
|
+
if (!entry.enabled) return '已断开'
|
|
663
|
+
return entry.connected ? (entry.toolCount + ' 工具') : '已掉线'
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function render() {
|
|
667
|
+
const connected = state.entries.filter((e) => e.connected).length
|
|
668
|
+
pillDot.className = 'dot ' + (connected > 0 ? 'ok' : state.entries.some((e) => e.enabled) ? 'busy' : 'off')
|
|
669
|
+
pillText.textContent = 'MCP ' + connected + '/' + state.entries.length
|
|
670
|
+
count.textContent = state.entries.length + ' 个'
|
|
671
|
+
list.textContent = ''
|
|
672
|
+
if (state.error) {
|
|
673
|
+
const err = document.createElement('div')
|
|
674
|
+
err.className = 'err'
|
|
675
|
+
err.textContent = state.error
|
|
676
|
+
list.append(err)
|
|
677
|
+
}
|
|
678
|
+
if (!state.entries.length) {
|
|
679
|
+
const empty = document.createElement('div')
|
|
680
|
+
empty.className = 'empty'
|
|
681
|
+
empty.textContent = '未配置 MCP 服务器(cordis.patch.yml)'
|
|
682
|
+
list.append(empty)
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
for (const e of state.entries) {
|
|
686
|
+
const entry = document.createElement('div')
|
|
687
|
+
entry.className = 'entry'
|
|
688
|
+
|
|
689
|
+
const row = document.createElement('div')
|
|
690
|
+
row.className = 'row'
|
|
691
|
+
const dot = document.createElement('span')
|
|
692
|
+
dot.className = 'dot ' + statusDot(e)
|
|
693
|
+
const srv = document.createElement('span')
|
|
694
|
+
srv.className = 'srv'
|
|
695
|
+
srv.textContent = e.serverName
|
|
696
|
+
const meta = document.createElement('span')
|
|
697
|
+
meta.className = 'meta'
|
|
698
|
+
meta.textContent = (e.transport || '') + ' · ' + statusText(e)
|
|
699
|
+
const btn = document.createElement('button')
|
|
700
|
+
btn.className = 'btn'
|
|
701
|
+
btn.textContent = e.enabled ? (e.connected ? '断开' : '重连') : '连接'
|
|
702
|
+
btn.disabled = state.busyId === e.id
|
|
703
|
+
btn.addEventListener('click', () => toggle(e, e.enabled && !e.connected))
|
|
704
|
+
row.append(dot, srv, meta, btn)
|
|
705
|
+
entry.append(row)
|
|
706
|
+
|
|
707
|
+
if (e.connected && e.tools && e.tools.length) {
|
|
708
|
+
const tools = document.createElement('div')
|
|
709
|
+
tools.className = 'tools'
|
|
710
|
+
for (const t of e.tools) {
|
|
711
|
+
const chip = document.createElement('span')
|
|
712
|
+
chip.className = 'tool'
|
|
713
|
+
chip.textContent = t
|
|
714
|
+
chip.title = t
|
|
715
|
+
tools.append(chip)
|
|
716
|
+
}
|
|
717
|
+
entry.append(tools)
|
|
718
|
+
}
|
|
719
|
+
list.append(entry)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function refresh() {
|
|
724
|
+
try {
|
|
725
|
+
const res = await boundedFetch(API + '/status', { cache: 'no-store' })
|
|
726
|
+
const data = await res.json()
|
|
727
|
+
if (data && data.ok) {
|
|
728
|
+
state.entries = data.entries || []
|
|
729
|
+
state.error = null
|
|
730
|
+
noteStatusData(data)
|
|
731
|
+
} else {
|
|
732
|
+
state.error = (data && data.error) || '状态获取失败'
|
|
733
|
+
}
|
|
734
|
+
} catch (err) {
|
|
735
|
+
state.error = (err && err.name === 'AbortError')
|
|
736
|
+
? '状态获取超时'
|
|
737
|
+
: String((err && err.message) || err)
|
|
738
|
+
}
|
|
739
|
+
render()
|
|
740
|
+
// Re-arm with the interval matching the latest state. armPoll is a
|
|
741
|
+
// no-op once disposed, so an in-flight refresh cannot revive the poll.
|
|
742
|
+
armPoll(nextPollMs())
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function toggle(entry, restart) {
|
|
746
|
+
state.busyId = entry.id
|
|
747
|
+
state.error = null
|
|
748
|
+
render()
|
|
749
|
+
try {
|
|
750
|
+
const body = restart
|
|
751
|
+
? { id: entry.id, enabled: true, restart: true }
|
|
752
|
+
: { id: entry.id, enabled: !entry.enabled }
|
|
753
|
+
const res = await boundedFetch(API + '/set', {
|
|
754
|
+
method: 'POST',
|
|
755
|
+
headers: { 'Content-Type': 'application/json' },
|
|
756
|
+
body: JSON.stringify(body),
|
|
757
|
+
})
|
|
758
|
+
const data = await res.json()
|
|
759
|
+
if (!(data && data.ok)) state.error = (data && data.error) || '操作失败'
|
|
760
|
+
} catch (err) {
|
|
761
|
+
state.error = (err && err.name === 'AbortError')
|
|
762
|
+
? '操作超时'
|
|
763
|
+
: String((err && err.message) || err)
|
|
764
|
+
}
|
|
765
|
+
state.busyId = null
|
|
766
|
+
await refresh()
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function onPillClick() {
|
|
770
|
+
if (Date.now() - lastDragAt < 350) return // a drag just happened — not a click
|
|
771
|
+
state.open = !state.open
|
|
772
|
+
panel.classList.toggle('open', state.open)
|
|
773
|
+
if (state.open) {
|
|
774
|
+
placePanel()
|
|
775
|
+
refresh()
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
function onCloseClick() {
|
|
779
|
+
state.open = false
|
|
780
|
+
panel.classList.remove('open')
|
|
781
|
+
}
|
|
782
|
+
pill.addEventListener('click', onPillClick)
|
|
783
|
+
track(function () { pill.removeEventListener('click', onPillClick) })
|
|
784
|
+
closeBtn.addEventListener('click', onCloseClick)
|
|
785
|
+
track(function () { closeBtn.removeEventListener('click', onCloseClick) })
|
|
786
|
+
|
|
787
|
+
refresh()
|
|
788
|
+
|
|
789
|
+
// Arm the poll once and register a single tracked cleanup: the closure
|
|
790
|
+
// reads the live pollTimer binding, so dispose always clears the timer
|
|
791
|
+
// that is currently armed, no matter how often it was re-armed.
|
|
792
|
+
armPoll(POLL_INTERVALS.ACTIVE)
|
|
793
|
+
track(function () {
|
|
794
|
+
if (pollTimer !== null) clearInterval(pollTimer)
|
|
795
|
+
pollTimer = null
|
|
796
|
+
armedMs = null
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
// ── visibility gate: 设置 → 插件 → MCP Pill「显示状态胶囊」 ────────────
|
|
800
|
+
// Default OFF: the root is created display:none and only the polled
|
|
801
|
+
// /status payload (hot host mirror of pill.enabled) may reveal it.
|
|
802
|
+
// Placed after every binding above is initialized because
|
|
803
|
+
// applyVisibility() can call schedulePlace().
|
|
804
|
+
let pillVisible = false
|
|
805
|
+
|
|
806
|
+
function applyVisibility() {
|
|
807
|
+
if (!root) return
|
|
808
|
+
root.style.display = pillVisible ? '' : 'none'
|
|
809
|
+
if (!pillVisible) {
|
|
810
|
+
if (panel.classList.contains('open')) panel.classList.remove('open')
|
|
811
|
+
} else {
|
|
812
|
+
schedulePlace() // cluster rect was 0 while hidden — reposition now
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function noteStatusData(data) {
|
|
817
|
+
const show = !!(data && data.pill && data.pill.enabled === true)
|
|
818
|
+
if (show === pillVisible) return
|
|
819
|
+
pillVisible = show
|
|
820
|
+
applyVisibility()
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (scope && typeof scope.subscribe === 'function') {
|
|
824
|
+
// A settings save reaches the host mirror immediately; re-fetch right
|
|
825
|
+
// away so the pill flips without waiting for the next poll. The
|
|
826
|
+
// dynamic poll below remains the safety net (and covers other tabs).
|
|
827
|
+
const unsubscribe = scope.subscribe(function () { refresh() })
|
|
828
|
+
track(function () { unsubscribe() })
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Mount immediately when the seat already exists; otherwise the document
|
|
832
|
+
// observer mounts the pill as soon as [data-composer-seat] appears.
|
|
833
|
+
ensureMounted()
|
|
834
|
+
place()
|
|
835
|
+
|
|
836
|
+
function disposePill() {
|
|
837
|
+
if (disposed) return
|
|
838
|
+
disposed = true
|
|
839
|
+
pillMounted = false
|
|
840
|
+
for (const controller of inFlight) {
|
|
841
|
+
try { controller.abort() } catch (_) {}
|
|
842
|
+
}
|
|
843
|
+
inFlight.clear()
|
|
844
|
+
if (rafPending && rafId !== 0) cancelAnimationFrame(rafId)
|
|
845
|
+
rafPending = false
|
|
846
|
+
rafId = 0
|
|
847
|
+
if (dragCleanup) {
|
|
848
|
+
const cleanupDrag = dragCleanup
|
|
849
|
+
dragCleanup = null
|
|
850
|
+
cleanupDrag()
|
|
851
|
+
}
|
|
852
|
+
for (let i = cleanups.length - 1; i >= 0; i--) {
|
|
853
|
+
try { cleanups[i]() } catch (_) {}
|
|
854
|
+
}
|
|
855
|
+
cleanups.length = 0
|
|
856
|
+
if (root && root.parentNode) root.parentNode.removeChild(root)
|
|
857
|
+
root = null
|
|
858
|
+
pillDispose = null
|
|
859
|
+
}
|
|
860
|
+
pillDispose = disposePill
|
|
861
|
+
return disposePill
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function apply(ctx) {
|
|
865
|
+
if (typeof document === 'undefined') return
|
|
866
|
+
|
|
867
|
+
// Settings card first: pill mounting waits for the composer seat
|
|
868
|
+
// asynchronously and must never delay or break the Settings Slot.
|
|
869
|
+
const scope = ctx.settingsScope.bind({ namespace: NS })
|
|
870
|
+
// Compatibility layer: DSH 0.1.2 exposes fine-grained remote settings;
|
|
871
|
+
// older RC builds keep the legacy connection API.
|
|
872
|
+
const remote = ctx.get('remote')
|
|
873
|
+
const api = remote && remote.settings
|
|
874
|
+
? { settings: { mutate: (payload) => remote.settings.mutate(payload.ns, payload.ops, payload.expectedRevision).then((result) => ({ result })) } }
|
|
875
|
+
: (ctx.get('connection') && ctx.get('connection').api)
|
|
876
|
+
const disposeSlot = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
877
|
+
name: 'settings.plugin.item',
|
|
878
|
+
key: NS,
|
|
879
|
+
label: 'MCP Pill',
|
|
880
|
+
}, function McpPillCard() {
|
|
881
|
+
return e(SettingsCard, { scope, api })
|
|
882
|
+
}))
|
|
883
|
+
|
|
884
|
+
// Pill: fully lifecycle-owned as before, but created hidden — the gate
|
|
885
|
+
// inside startPill reveals it only while /status reports pill.enabled
|
|
886
|
+
// === true (default off).
|
|
887
|
+
ctx.effect(() => startPill(scope), 'dsh-mcp-pill: composer pill')
|
|
888
|
+
|
|
889
|
+
// Plugin-card style tag: created lazily by SettingsCard renders and
|
|
890
|
+
// removed here so no style is left behind after stop / update.
|
|
891
|
+
ctx.effect(() => () => removeCardStyles(), 'dsh-mcp-pill: plugin card style')
|
|
892
|
+
|
|
893
|
+
return disposeSlot
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
exports.apply = apply
|
|
897
|
+
exports.inject = ['slots', 'settingsScope', 'connection']
|
|
898
|
+
return module.exports
|
|
899
|
+
},
|
|
900
|
+
})
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Shared config contract for dsh-mcp-pill.
|
|
2
|
+
// Nested runtime shape stays the same as the historical Settings namespace.
|
|
3
|
+
// Official Settings Card writes the same nested section through path mutate.
|
|
4
|
+
|
|
5
|
+
export const SETTINGS_NS = 'mcp-pill'
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_SETTINGS = {
|
|
8
|
+
pill: {
|
|
9
|
+
enabled: false,
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isPlainObject(v) {
|
|
14
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function cloneSettings(config) {
|
|
18
|
+
return JSON.parse(JSON.stringify(config || DEFAULT_SETTINGS))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateSettings(raw) {
|
|
22
|
+
if (!isPlainObject(raw)) return { ok: false, errors: ['settings must be a JSON object'] }
|
|
23
|
+
|
|
24
|
+
const errors = []
|
|
25
|
+
const allowedKeys = ['pill']
|
|
26
|
+
for (const key of Object.keys(raw)) {
|
|
27
|
+
if (!allowedKeys.includes(key)) {
|
|
28
|
+
errors.push('unknown top-level key: ' + key)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const d = DEFAULT_SETTINGS
|
|
33
|
+
const pillRaw = isPlainObject(raw.pill) ? raw.pill : {}
|
|
34
|
+
if (raw.pill !== undefined && !isPlainObject(raw.pill)) {
|
|
35
|
+
errors.push('pill must be an object')
|
|
36
|
+
}
|
|
37
|
+
for (const key of Object.keys(pillRaw)) {
|
|
38
|
+
if (key !== 'enabled') errors.push('unknown pill key: ' + key)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const pill = {
|
|
42
|
+
enabled: checkBool(errors, 'pill.enabled', pillRaw.enabled, d.pill.enabled),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
46
|
+
return { ok: true, config: { pill } }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function checkBool(errors, path, value, fallback) {
|
|
50
|
+
if (value === undefined) return fallback
|
|
51
|
+
if (typeof value === 'boolean') return value
|
|
52
|
+
errors.push(path + ' must be a boolean')
|
|
53
|
+
return fallback
|
|
54
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// dsh-mcp-pill — host half (official bundle form)
|
|
2
|
+
//
|
|
3
|
+
// Serves the JSON RPC for the client half:
|
|
4
|
+
// GET /api/mcp-pill/status -> JSON status of every configured MCP connection
|
|
5
|
+
// plus `pill.enabled` (the visibility toggle)
|
|
6
|
+
// POST /api/mcp-pill/set -> { id, enabled } toggles a connection via the
|
|
7
|
+
// patch file's `disabled` marker (loader HMR applies it)
|
|
8
|
+
//
|
|
9
|
+
// The visibility of the pill itself is owned by the official settings service:
|
|
10
|
+
// this half registers the `mcp-pill` namespace ({ pill: { enabled: false } })
|
|
11
|
+
// and mirrors the resolved value into every /status response, so the client
|
|
12
|
+
// half can follow it with its existing poll loop. Default is OFF.
|
|
13
|
+
//
|
|
14
|
+
// The client half (lib/client.js) is a __ModuleLoader__ web bundle — no
|
|
15
|
+
// tapIndex, no page-level <script> injection.
|
|
16
|
+
//
|
|
17
|
+
// Config (from the mounting row):
|
|
18
|
+
// patchFile -> the cordis.patch.yml to parse MCP entries from.
|
|
19
|
+
// Default: <profile>/cordis.patch.yml.
|
|
20
|
+
//
|
|
21
|
+
// Resolution: the web process's cwd is NOT the profile directory — it is the
|
|
22
|
+
// shell it was launched from (often the home dir). The authoritative anchor
|
|
23
|
+
// is ctx.baseUrl (the profile dir, set by the boot include), so relative
|
|
24
|
+
// patchFile values resolve against it first, falling back to process.cwd()
|
|
25
|
+
// for standalone boots that load this plugin outside a profile.
|
|
26
|
+
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
30
|
+
import {
|
|
31
|
+
SETTINGS_NS,
|
|
32
|
+
DEFAULT_SETTINGS,
|
|
33
|
+
isPlainObject,
|
|
34
|
+
cloneSettings,
|
|
35
|
+
validateSettings,
|
|
36
|
+
} from './config.js'
|
|
37
|
+
|
|
38
|
+
export const name = 'dsh-mcp-pill'
|
|
39
|
+
export const inject = ['webServer', 'fs', 'tools']
|
|
40
|
+
|
|
41
|
+
const MCP_NAME = '@deepseek-ai/dsh-mcp-client'
|
|
42
|
+
|
|
43
|
+
function resolvePatchFile(ctx, config) {
|
|
44
|
+
const rel = (config && config.patchFile) ? String(config.patchFile) : 'cordis.patch.yml'
|
|
45
|
+
if (path.isAbsolute(rel)) return rel
|
|
46
|
+
try {
|
|
47
|
+
if (ctx && ctx.baseUrl) return fileURLToPath(new URL(rel, ctx.baseUrl))
|
|
48
|
+
} catch (_) { /* not a URL — fall through to cwd */ }
|
|
49
|
+
return path.resolve(process.cwd(), rel)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseEntries(text) {
|
|
53
|
+
const entries = []
|
|
54
|
+
const topBlocks = text.split(/\n(?=- )/)
|
|
55
|
+
for (const top of topBlocks) {
|
|
56
|
+
const rows = top.split(/\n(?= {4}- )/)
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
if (!row.includes(MCP_NAME)) continue
|
|
59
|
+
const idMatch = row.match(/(?:^|\n)\s*- id:\s*([^\s]+)/)
|
|
60
|
+
const serverName = (row.match(/serverName:\s*([^\s]+)/) || [])[1]
|
|
61
|
+
const transport = (row.match(/transport:\s*([^\s]+)/) || [])[1]
|
|
62
|
+
const disabled = /disabled:\s*true/.test(row)
|
|
63
|
+
if (idMatch) entries.push({ id: idMatch[1], serverName, transport, disabled })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return entries
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function setEntryDisabled(text, id, disabled) {
|
|
70
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
71
|
+
return { ok: false, error: '非法条目 id' }
|
|
72
|
+
}
|
|
73
|
+
// 全行锚定:- id: <id> 独占一行,避免前缀碰撞(如 id=mcp 误中 mcp-deveco)
|
|
74
|
+
const re = new RegExp('(^|\\n)\\s*- id:\\s*' + id + '\\s*(\\n|$)')
|
|
75
|
+
const m = re.exec(text)
|
|
76
|
+
if (!m) return { ok: false, error: 'patch 文件中未找到条目 ' + id }
|
|
77
|
+
const start = m.index + m[0].indexOf('- id:')
|
|
78
|
+
const lineStart = text.lastIndexOf('\n', start) + 1
|
|
79
|
+
const lineEnd = text.indexOf('\n', start)
|
|
80
|
+
const rest = text.slice(lineEnd + 1)
|
|
81
|
+
const nextRow = rest.search(/\n {4}- |\n- /)
|
|
82
|
+
const blockEnd = nextRow < 0 ? text.length : lineEnd + 1 + nextRow
|
|
83
|
+
const block = text.slice(lineStart, blockEnd)
|
|
84
|
+
if (!block.includes(MCP_NAME)) return { ok: false, error: '条目 ' + id + ' 不是 dsh-mcp-client 条目' }
|
|
85
|
+
if (disabled) {
|
|
86
|
+
if (/disabled:\s*true/.test(block)) return { ok: true, changed: false }
|
|
87
|
+
const insertAt = text.indexOf('\n', start) + 1
|
|
88
|
+
return { ok: true, changed: true, next: text.slice(0, insertAt) + ' disabled: true\n' + text.slice(insertAt) }
|
|
89
|
+
}
|
|
90
|
+
const m2 = block.match(/\n[ \t]*disabled:\s*true/)
|
|
91
|
+
if (!m2) return { ok: true, changed: false }
|
|
92
|
+
const at = lineStart + m2.index
|
|
93
|
+
return { ok: true, changed: true, next: text.slice(0, at) + text.slice(at + m2[0].length) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function apply(ctx, config) {
|
|
97
|
+
const ws = ctx.webServer
|
|
98
|
+
if (!ws) return
|
|
99
|
+
|
|
100
|
+
const patchFile = resolvePatchFile(ctx, config)
|
|
101
|
+
|
|
102
|
+
// Visibility toggle state, mirrored from the official settings service.
|
|
103
|
+
// Defaults to hidden; the client follows /status, so no direct coupling.
|
|
104
|
+
const pillState = { enabled: false }
|
|
105
|
+
|
|
106
|
+
function readPillEnabled(scope) {
|
|
107
|
+
try {
|
|
108
|
+
const value = scope.get()
|
|
109
|
+
return !!(isPlainObject(value) && isPlainObject(value.pill) && value.pill.enabled === true)
|
|
110
|
+
} catch (_) {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ctx.inject(['settings'], (sctx) => {
|
|
116
|
+
try {
|
|
117
|
+
const schema = Schema.object({
|
|
118
|
+
pill: Schema.object({
|
|
119
|
+
enabled: Schema.boolean().default(DEFAULT_SETTINGS.pill.enabled),
|
|
120
|
+
}).default(cloneSettings(DEFAULT_SETTINGS.pill)),
|
|
121
|
+
})
|
|
122
|
+
const scope = sctx.settings.register(SETTINGS_NS, schema, {
|
|
123
|
+
base: cloneSettings(DEFAULT_SETTINGS),
|
|
124
|
+
applies: 'live',
|
|
125
|
+
// The official settings service treats a throw as rejection and
|
|
126
|
+
// discards the return value, so translate the { ok, errors } contract
|
|
127
|
+
// into the throw contract here.
|
|
128
|
+
validate: (value) => {
|
|
129
|
+
const validated = validateSettings(value)
|
|
130
|
+
if (!validated.ok) throw new Error((validated.errors || []).join('; '))
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
pillState.enabled = readPillEnabled(scope)
|
|
134
|
+
sctx.effect(() => scope.watch(() => {
|
|
135
|
+
pillState.enabled = readPillEnabled(scope)
|
|
136
|
+
}), 'dsh-mcp-pill: settings watch')
|
|
137
|
+
sctx.effect(() => () => {
|
|
138
|
+
pillState.enabled = false
|
|
139
|
+
}, 'dsh-mcp-pill: settings fallback')
|
|
140
|
+
} catch (_) {
|
|
141
|
+
// Settings stay optional: without the service the pill remains hidden
|
|
142
|
+
// (default off) and the MCP rows keep working through the patch file.
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
function json(res, code, data) {
|
|
147
|
+
const body = JSON.stringify(data)
|
|
148
|
+
res.writeHead(code, {
|
|
149
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
150
|
+
'Cache-Control': 'no-store',
|
|
151
|
+
})
|
|
152
|
+
res.end(body)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readBody(req) {
|
|
156
|
+
const chunks = []
|
|
157
|
+
let total = 0
|
|
158
|
+
for await (const chunk of req) {
|
|
159
|
+
total += chunk.length
|
|
160
|
+
if (total > 8192) throw Object.assign(new Error('request body too large'), { status: 413 })
|
|
161
|
+
chunks.push(chunk)
|
|
162
|
+
}
|
|
163
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Loopback-only web server: allow same-origin browser POSTs (Origin matches
|
|
167
|
+
// our own Host) and local tooling without an Origin header; reject foreign
|
|
168
|
+
// Origins outright as CSRF protection.
|
|
169
|
+
function originAllowed(req) {
|
|
170
|
+
const origin = req.headers.origin
|
|
171
|
+
if (!origin) return true // non-browser local caller (curl / MCP tooling)
|
|
172
|
+
const host = req.headers.host || ''
|
|
173
|
+
const base = /^https?:\/\/([^/]+)/i.exec(origin)
|
|
174
|
+
if (!base) return false
|
|
175
|
+
return base[1] === host
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// DNS-rebinding fence: the web server only binds loopback, so a legitimate
|
|
179
|
+
// request's Host header must name the loopback host. A rebinding attack
|
|
180
|
+
// resolves an attacker domain to 127.0.0.1 and sends Host: attacker.com.
|
|
181
|
+
function hostAllowed(req) {
|
|
182
|
+
let host = (req.headers.host || '').split(':')[0].toLowerCase()
|
|
183
|
+
host = host.replace(/^\[|\]$/g, '')
|
|
184
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function status() {
|
|
188
|
+
let target, text
|
|
189
|
+
try {
|
|
190
|
+
target = await ctx.fs.resolve(patchFile)
|
|
191
|
+
text = await ctx.fs.readText(target)
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const msg = String((err && err.message) || err)
|
|
194
|
+
if (/not found|ENOENT|no such file/i.test(msg)) {
|
|
195
|
+
// The patch file does not exist — nothing to report, not an error.
|
|
196
|
+
return { ok: true, patchFile, warning: msg, pill: { enabled: pillState.enabled }, entries: [] }
|
|
197
|
+
}
|
|
198
|
+
throw err
|
|
199
|
+
}
|
|
200
|
+
const entries = parseEntries(text)
|
|
201
|
+
const schemas = ctx.tools.schemas()
|
|
202
|
+
const byServer = {}
|
|
203
|
+
for (const s of schemas) {
|
|
204
|
+
const m = /^mcp__([A-Za-z0-9_-]+)__/.exec(s.name || '')
|
|
205
|
+
if (m) {
|
|
206
|
+
if (!byServer[m[1]]) byServer[m[1]] = []
|
|
207
|
+
byServer[m[1]].push(s.name)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
patchFile,
|
|
213
|
+
pill: { enabled: pillState.enabled },
|
|
214
|
+
entries: entries.map((e) => ({
|
|
215
|
+
id: e.id,
|
|
216
|
+
serverName: e.serverName || e.id,
|
|
217
|
+
transport: e.transport || 'unknown',
|
|
218
|
+
enabled: !e.disabled,
|
|
219
|
+
connected: !e.disabled && (byServer[e.serverName] || []).length > 0,
|
|
220
|
+
toolCount: (byServer[e.serverName] || []).length,
|
|
221
|
+
tools: (byServer[e.serverName] || []).slice(),
|
|
222
|
+
})),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ctx.effect(() => ws.register({
|
|
227
|
+
kind: 'exact',
|
|
228
|
+
path: '/api/mcp-pill/status',
|
|
229
|
+
handler: async (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
json(res, 200, await status())
|
|
232
|
+
} catch (err) {
|
|
233
|
+
json(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
}), 'dsh-mcp-pill: status route')
|
|
237
|
+
|
|
238
|
+
ctx.effect(() => ws.register({
|
|
239
|
+
kind: 'exact',
|
|
240
|
+
path: '/api/mcp-pill/set',
|
|
241
|
+
handler: async (req, res) => {
|
|
242
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'POST required' })
|
|
243
|
+
if (!hostAllowed(req)) return json(res, 403, { ok: false, error: 'host not allowed' })
|
|
244
|
+
if (!originAllowed(req)) return json(res, 403, { ok: false, error: 'origin not allowed' })
|
|
245
|
+
try {
|
|
246
|
+
let body
|
|
247
|
+
try {
|
|
248
|
+
body = JSON.parse((await readBody(req)) || '{}')
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (err && err.status === 413) return json(res, 413, { ok: false, error: 'request body too large' })
|
|
251
|
+
return json(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
252
|
+
}
|
|
253
|
+
const id = body && body.id
|
|
254
|
+
const enabled = !!(body && body.enabled)
|
|
255
|
+
const restart = !!(body && body.restart)
|
|
256
|
+
if (!id) return json(res, 400, { ok: false, error: '缺少 id 参数' })
|
|
257
|
+
const target = await ctx.fs.resolve(patchFile)
|
|
258
|
+
const text = await ctx.fs.readText(target)
|
|
259
|
+
if (restart) {
|
|
260
|
+
// restart: 断开并立即重新挂载该连接(先置 disabled,再恢复)
|
|
261
|
+
const off = setEntryDisabled(text, id, true)
|
|
262
|
+
if (!off.ok) return json(res, 404, off)
|
|
263
|
+
const offText = off.changed ? off.next : text
|
|
264
|
+
if (off.changed) await ctx.fs.writeText(target, offText)
|
|
265
|
+
await new Promise((r2) => setTimeout(r2, 150))
|
|
266
|
+
const on = setEntryDisabled(offText, id, false)
|
|
267
|
+
if (!on.ok) return json(res, 404, on)
|
|
268
|
+
if (on.changed) await ctx.fs.writeText(target, on.next)
|
|
269
|
+
return json(res, 200, { ok: true, id, enabled: true, changed: true, restarted: true })
|
|
270
|
+
}
|
|
271
|
+
const r = setEntryDisabled(text, id, !enabled)
|
|
272
|
+
if (!r.ok) return json(res, 404, r)
|
|
273
|
+
if (r.changed) await ctx.fs.writeText(target, r.next)
|
|
274
|
+
json(res, 200, { ok: true, id, enabled, changed: !!r.changed })
|
|
275
|
+
} catch (err) {
|
|
276
|
+
json(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
}), 'dsh-mcp-pill: set route')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Re-export the config contract for backward compatibility (the same public
|
|
283
|
+
// names as before the config.js split). isPlainObject/cloneSettings stay
|
|
284
|
+
// module-private, mirroring dsh-tool-adapt's narrower index surface.
|
|
285
|
+
export { SETTINGS_NS, DEFAULT_SETTINGS, validateSettings }
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-mcp-pill",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "EN: Lifecycle-safe MCP status pill and Settings card for DeepSeek Harness Web. ZH: 面向 DeepSeek Harness Web 的生命周期安全 MCP 状态胶囊与设置卡片。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./config": "./lib/config.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib/",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
24
|
+
},
|
|
25
|
+
"dsh": {
|
|
26
|
+
"bundle": {
|
|
27
|
+
"patch": "./cordis.patch.yml"
|
|
28
|
+
},
|
|
29
|
+
"client": {
|
|
30
|
+
"platform": "web",
|
|
31
|
+
"inject": ["@deepseek-ai/dsh-api-remotes"]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"repository": { "type": "git", "url": "git+https://github.com/YrracOwl/dsh-mcp-pill.git" },
|
|
35
|
+
"homepage": "https://github.com/YrracOwl/dsh-mcp-pill#readme",
|
|
36
|
+
"bugs": { "url": "https://github.com/YrracOwl/dsh-mcp-pill/issues" },
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
}
|
|
41
|
+
}
|