dsh-cc-mcp 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/client/index.js +1080 -0
- package/package.json +9 -4
- package/src/index.js +720 -27
- package/src/manage.js +362 -0
- package/src/register.js +10 -2
package/client/index.js
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-cc-mcp", factory: (require) => {
|
|
2
|
+
'use strict'
|
|
3
|
+
var module = { exports: {} }; var exports = module.exports;
|
|
4
|
+
|
|
5
|
+
// dsh-cc-mcp — browser half (the `/mcp` management panel).
|
|
6
|
+
//
|
|
7
|
+
// Plain DOM, no imports, no require, no build step, no top-level await: this
|
|
8
|
+
// file is a CLASSIC script that several bundles get concatenated into, so the
|
|
9
|
+
// single top-level statement above is this whole file and every declaration
|
|
10
|
+
// lives inside the factory.
|
|
11
|
+
//
|
|
12
|
+
// Transport: same-origin POSTs to the plugin's own host route (`/cc-mcp/*`,
|
|
13
|
+
// registered by the host half on the `webServer` service). There is no
|
|
14
|
+
// host→browser push, so the panel pulls (`state`) and refreshes after every
|
|
15
|
+
// mutation, and the session self-check polls `check` a bounded number of
|
|
16
|
+
// times.
|
|
17
|
+
|
|
18
|
+
var PLUGIN_ID = 'dsh-cc-mcp'
|
|
19
|
+
var CHANNEL = '/cc-mcp'
|
|
20
|
+
var STYLE_TAG_ID = 'dsh-cc-mcp/panel.css'
|
|
21
|
+
var CHECK_RETRY_DELAYS = [1000, 3000, 4000]
|
|
22
|
+
var TOAST_TTL_MS = 6000
|
|
23
|
+
var REFETCH_INTERVAL_MS = 700
|
|
24
|
+
var REFETCH_CAP_MS = 20000
|
|
25
|
+
|
|
26
|
+
// ─── module state ─────────────────────────────────────────────────────────
|
|
27
|
+
// Every one of these is written only from inside apply()/its deferred
|
|
28
|
+
// injections and cleared by the matching disposer, so an unload leaves no
|
|
29
|
+
// DOM node, timer, or captured caller behind.
|
|
30
|
+
|
|
31
|
+
var styleEl = null
|
|
32
|
+
var overlayRoot = null
|
|
33
|
+
var toastItems = null
|
|
34
|
+
var toastRoot = null
|
|
35
|
+
var panel = null
|
|
36
|
+
var sessionsScope = null
|
|
37
|
+
var sessionUnsub = null
|
|
38
|
+
var checkSessionId = null
|
|
39
|
+
var pendingCheck = null
|
|
40
|
+
var checkRetryTimer = null
|
|
41
|
+
var checkRetryIndex = 0
|
|
42
|
+
var bindSeq = 0
|
|
43
|
+
|
|
44
|
+
// ─── tiny helpers ─────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function warn(message, error) {
|
|
47
|
+
try {
|
|
48
|
+
var text = error === undefined ? message : message + ': ' + String((error && error.message) || error)
|
|
49
|
+
if (typeof console !== 'undefined' && console !== null && typeof console.warn === 'function') console.warn(text)
|
|
50
|
+
} catch (ignored) { /* logging must never break the caller */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isDom() {
|
|
54
|
+
return typeof document !== 'undefined' && document !== null && typeof document.createElement === 'function'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRecord(value) {
|
|
58
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Always a string — snapshot fields come off the wire and are not trusted. */
|
|
62
|
+
function str(value, fallback) {
|
|
63
|
+
if (typeof value === 'string' && value !== '') return value
|
|
64
|
+
if (typeof value === 'number' && isFinite(value)) return String(value)
|
|
65
|
+
return fallback === undefined ? '' : fallback
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function el(tag, className, text) {
|
|
69
|
+
var node = document.createElement(tag)
|
|
70
|
+
if (className) node.className = className
|
|
71
|
+
if (text !== undefined && text !== null && text !== '') node.textContent = text
|
|
72
|
+
return node
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clear(node) {
|
|
76
|
+
if (!node) return
|
|
77
|
+
while (node.firstChild) node.removeChild(node.firstChild)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── transport ────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function transportFailure(code, message) {
|
|
83
|
+
return { ok: false, error: { code: code, message: message, details: {} } }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toEnvelope(thrown) {
|
|
87
|
+
var message = (thrown && thrown.message) || thrown
|
|
88
|
+
return transportFailure('cc-mcp/transport', String(message === undefined || message === null ? 'transport failed' : message))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One panel call to the host's plugin-owned route. Never throws and never
|
|
93
|
+
* rejects: a missing transport, a failed request, or a malformed envelope all
|
|
94
|
+
* become the same `{ ok: false, error }` shape the views already render.
|
|
95
|
+
*/
|
|
96
|
+
function api(endpoint, payload) {
|
|
97
|
+
try {
|
|
98
|
+
if (typeof fetch !== 'function') {
|
|
99
|
+
return Promise.resolve(transportFailure('cc-mcp/no-transport', 'fetch is unavailable in this shell'))
|
|
100
|
+
}
|
|
101
|
+
return fetch(CHANNEL + '/' + endpoint, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'content-type': 'application/json', 'x-cc-mcp': '1' },
|
|
104
|
+
body: JSON.stringify(payload === undefined || payload === null ? {} : payload),
|
|
105
|
+
credentials: 'same-origin',
|
|
106
|
+
}).then(
|
|
107
|
+
function (response) {
|
|
108
|
+
return response.json().then(function (result) {
|
|
109
|
+
if (isRecord(result) && result.ok === true) return { ok: true, value: result.value }
|
|
110
|
+
if (isRecord(result) && result.ok === false && isRecord(result.error)) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: {
|
|
114
|
+
code: str(result.error.code, 'cc-mcp/internal'),
|
|
115
|
+
message: str(result.error.message, 'cc-mcp request failed'),
|
|
116
|
+
details: isRecord(result.error.details) ? result.error.details : {},
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return transportFailure('cc-mcp/envelope', 'malformed result envelope from ' + endpoint)
|
|
121
|
+
}, function () {
|
|
122
|
+
return transportFailure('cc-mcp/envelope', 'HTTP ' + response.status + ' without a JSON envelope')
|
|
123
|
+
})
|
|
124
|
+
},
|
|
125
|
+
toEnvelope,
|
|
126
|
+
)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return Promise.resolve(toEnvelope(error))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function codeOf(response) {
|
|
133
|
+
return (response && response.error && response.error.code) || 'cc-mcp/internal'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function errorOf(response, fallback) {
|
|
137
|
+
var message = response && response.error && response.error.message
|
|
138
|
+
return str(message, fallback || 'request failed')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function entryOf(response) {
|
|
142
|
+
var value = response && response.value
|
|
143
|
+
return isRecord(value) && isRecord(value.entry) ? value.entry : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function entryKey(entry) {
|
|
147
|
+
if (!isRecord(entry)) return ''
|
|
148
|
+
return str(entry.key) || (str(entry.scope) + ':' + str(entry.serverName))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isDangerCode(code) {
|
|
152
|
+
return String(code || '').indexOf('no-transport') !== -1 || String(code || '').indexOf('transport') !== -1
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── styles ───────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
var STYLES = [
|
|
158
|
+
'.ccmcp-overlay{position:fixed;inset:0;z-index:1100;display:flex;align-items:center;justify-content:center;padding:24px;pointer-events:none}',
|
|
159
|
+
'.ccmcp-backdrop{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1,rgb(0 0 0 / 24%));pointer-events:auto}',
|
|
160
|
+
'.ccmcp-panel{position:relative;pointer-events:auto;width:620px;max-width:100%;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;border-radius:16px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#0f1115);border:1px solid var(--dsw-alias-border-l1,rgb(0 0 0 / 10%));box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgb(0 0 0 / 18%));font-family:var(--dsw-font-family,system-ui,-apple-system,"Segoe UI",sans-serif);font-size:13px;line-height:20px}',
|
|
161
|
+
'.ccmcp-head{display:flex;align-items:flex-start;gap:12px;padding:16px 16px 12px;flex:0 0 auto}',
|
|
162
|
+
'.ccmcp-head-main{flex:1 1 auto;min-width:0}',
|
|
163
|
+
'.ccmcp-title{margin:0;font-size:15px;line-height:22px;font-weight:600;color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
164
|
+
'.ccmcp-subtitle{margin:2px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
165
|
+
'.ccmcp-head-actions{display:flex;align-items:center;gap:8px;flex:0 0 auto}',
|
|
166
|
+
'.ccmcp-iconbtn{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary,#61666b);width:28px;height:28px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;line-height:1}',
|
|
167
|
+
'.ccmcp-iconbtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72 / 6%));color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
168
|
+
'.ccmcp-backbtn{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary,#61666b);font:inherit;font-size:12px;padding:4px 8px;margin:0 0 0 -8px;border-radius:8px;cursor:pointer;align-self:flex-start}',
|
|
169
|
+
'.ccmcp-backbtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72 / 6%));color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
170
|
+
'.ccmcp-body{flex:1 1 auto;min-height:0;overflow:auto;padding:0 8px 8px}',
|
|
171
|
+
'.ccmcp-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;appearance:none;border:0;background:transparent;font:inherit;color:inherit;padding:8px;border-radius:10px;cursor:pointer}',
|
|
172
|
+
'.ccmcp-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72 / 6%))}',
|
|
173
|
+
'.ccmcp-glyph{flex:0 0 auto;width:28px;height:28px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;line-height:1;background:var(--dsw-alias-bg-module-platform,rgb(235 238 242));color:var(--dsw-alias-label-secondary,#61666b);text-transform:uppercase}',
|
|
174
|
+
'.ccmcp-name{flex:1 1 auto;min-width:0;font-weight:500;color:var(--dsw-alias-label-primary,#0f1115);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
175
|
+
'.ccmcp-type{flex:0 0 auto;font-size:12px;color:var(--dsw-alias-label-primary,#0f1115);white-space:nowrap}',
|
|
176
|
+
'.ccmcp-type-transport{color:var(--dsw-alias-label-secondary,#61666b)}',
|
|
177
|
+
'.ccmcp-status{flex:0 0 auto;display:flex;align-items:center;justify-content:flex-end;min-width:88px}',
|
|
178
|
+
'.ccmcp-status-muted{font-size:12px;color:var(--dsw-alias-label-secondary,#61666b)}',
|
|
179
|
+
'.ccmcp-btn{appearance:none;font:inherit;font-size:12px;line-height:16px;padding:4px 12px;border-radius:8px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,rgb(0 0 0 / 10%));background:var(--dsw-alias-button-elevated-fill,#fff);color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
180
|
+
'.ccmcp-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72 / 6%))}',
|
|
181
|
+
'.ccmcp-btn:disabled{cursor:default;opacity:.6}',
|
|
182
|
+
'.ccmcp-spinner{display:inline-block;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l2,rgb(0 0 0 / 10%));border-top-color:var(--dsw-alias-label-secondary,#61666b);animation:ccmcp-spin .7s linear infinite}',
|
|
183
|
+
'.ccmcp-spinner-lg{width:18px;height:18px;border-width:2px}',
|
|
184
|
+
'.ccmcp-spin-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary,#61666b);padding:2px 0 10px}',
|
|
185
|
+
'.ccmcp-detail-head{display:flex;align-items:center;gap:10px;width:100%}',
|
|
186
|
+
'.ccmcp-detail-head .ccmcp-name{font-size:14px}',
|
|
187
|
+
'.ccmcp-statusline{display:flex;align-items:center;gap:6px;padding:12px 16px 0;font-size:13px}',
|
|
188
|
+
'.ccmcp-statusline-ready{color:var(--dsw-alias-state-success-primary,#12b76a)}',
|
|
189
|
+
'.ccmcp-statusline-error{color:var(--dsw-alias-state-error-primary,#ec1313)}',
|
|
190
|
+
'.ccmcp-statusline-muted{color:var(--dsw-alias-label-secondary,#61666b)}',
|
|
191
|
+
'.ccmcp-statusline-detail{font-weight:400;color:var(--dsw-alias-label-secondary,#61666b);overflow-wrap:anywhere}',
|
|
192
|
+
'.ccmcp-source{margin:4px 0 0;padding:0 16px;font-size:12px;color:var(--dsw-alias-label-secondary,#61666b);overflow-wrap:anywhere}',
|
|
193
|
+
'.ccmcp-tools{margin:12px 16px 16px;border:1px solid var(--dsw-alias-border-l1,rgb(0 0 0 / 10%));border-radius:10px;overflow:hidden}',
|
|
194
|
+
'.ccmcp-tools-head{padding:8px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgb(0 0 0 / 10%));font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#61666b)}',
|
|
195
|
+
'.ccmcp-tools-body{max-height:280px;overflow:auto}',
|
|
196
|
+
'.ccmcp-tool{display:grid;grid-template-columns:minmax(0,1fr);gap:2px;padding:8px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgb(0 0 0 / 10%))}',
|
|
197
|
+
'.ccmcp-tool:last-child{border-bottom:0}',
|
|
198
|
+
'.ccmcp-tool-name{font-family:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);font-size:12px;color:var(--dsw-alias-label-primary,#0f1115);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
199
|
+
'.ccmcp-tool-desc{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}',
|
|
200
|
+
'.ccmcp-tools-muted .ccmcp-tool-name,.ccmcp-tools-muted .ccmcp-tool-desc{opacity:.55}',
|
|
201
|
+
'.ccmcp-empty{padding:32px 16px;text-align:center;color:var(--dsw-alias-label-secondary,#61666b)}',
|
|
202
|
+
'.ccmcp-empty-text{margin:0 0 10px}',
|
|
203
|
+
'.ccmcp-toast-root{position:fixed;right:16px;bottom:16px;z-index:1120;display:flex;flex-direction:column;gap:8px;max-width:360px;pointer-events:none}',
|
|
204
|
+
'.ccmcp-toast{pointer-events:auto;cursor:pointer;display:flex;flex-direction:column;gap:2px;padding:10px 12px;border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);border:1px solid var(--dsw-alias-border-l1,rgb(0 0 0 / 10%));box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgb(0 0 0 / 18%));font-family:var(--dsw-font-family,system-ui,-apple-system,"Segoe UI",sans-serif)}',
|
|
205
|
+
'.ccmcp-toast-title{font-size:12px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
206
|
+
'.ccmcp-toast-danger .ccmcp-toast-title{color:var(--dsw-alias-state-error-primary,#ec1313)}',
|
|
207
|
+
'.ccmcp-toast-body{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);overflow-wrap:anywhere}',
|
|
208
|
+
'@keyframes ccmcp-spin{to{transform:rotate(360deg)}}',
|
|
209
|
+
'@media (prefers-reduced-motion:reduce){.ccmcp-spinner{animation-duration:2s}}',
|
|
210
|
+
].join('')
|
|
211
|
+
|
|
212
|
+
function installStyles() {
|
|
213
|
+
if (!isDom()) return null
|
|
214
|
+
try {
|
|
215
|
+
var existing = document.querySelector('style[data-plugin-css="' + STYLE_TAG_ID + '"]')
|
|
216
|
+
if (existing) return existing
|
|
217
|
+
var tag = document.createElement('style')
|
|
218
|
+
tag.setAttribute('data-plugin', PLUGIN_ID)
|
|
219
|
+
tag.setAttribute('data-plugin-css', STYLE_TAG_ID)
|
|
220
|
+
tag.textContent = STYLES
|
|
221
|
+
document.head.appendChild(tag)
|
|
222
|
+
return tag
|
|
223
|
+
} catch (error) {
|
|
224
|
+
warn('style install failed', error)
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── toasts ───────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
function closeToast(item) {
|
|
232
|
+
try {
|
|
233
|
+
if (item && item.timer !== null) clearTimeout(item.timer)
|
|
234
|
+
if (item && item.node && item.node.parentNode) item.node.parentNode.removeChild(item.node)
|
|
235
|
+
if (toastItems && item) toastItems.delete(item)
|
|
236
|
+
if (toastItems && toastItems.size === 0 && toastRoot && toastRoot.parentNode) {
|
|
237
|
+
toastRoot.parentNode.removeChild(toastRoot)
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
warn('toast dismiss failed', error)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function ensureToastRoot() {
|
|
245
|
+
if (!isDom()) return null
|
|
246
|
+
if (toastRoot && toastRoot.parentNode) return toastRoot
|
|
247
|
+
toastRoot = el('div', 'ccmcp-toast-root')
|
|
248
|
+
toastRoot.setAttribute('role', 'status')
|
|
249
|
+
document.body.appendChild(toastRoot)
|
|
250
|
+
toastItems = new Set()
|
|
251
|
+
return toastRoot
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One bottom-right, auto-dismissing toast; click dismisses it early. */
|
|
255
|
+
function toast(title, body, tone) {
|
|
256
|
+
if (!isDom()) return null
|
|
257
|
+
try {
|
|
258
|
+
var root = ensureToastRoot()
|
|
259
|
+
if (!root || !toastItems) return null
|
|
260
|
+
var node = el('div', 'ccmcp-toast' + (tone === 'danger' ? ' ccmcp-toast-danger' : ''))
|
|
261
|
+
node.setAttribute('role', 'alert')
|
|
262
|
+
node.appendChild(el('div', 'ccmcp-toast-title', str(title, 'MCP')))
|
|
263
|
+
if (body) node.appendChild(el('div', 'ccmcp-toast-body', str(body)))
|
|
264
|
+
var item = { node: node, timer: null }
|
|
265
|
+
node.addEventListener('click', function () { closeToast(item) })
|
|
266
|
+
root.appendChild(node)
|
|
267
|
+
toastItems.add(item)
|
|
268
|
+
item.timer = setTimeout(function () { closeToast(item) }, TOAST_TTL_MS)
|
|
269
|
+
return item
|
|
270
|
+
} catch (error) {
|
|
271
|
+
warn('toast failed', error)
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toastFailure(serverName, error) {
|
|
277
|
+
toast('MCP server failed to connect', '\u201C' + str(serverName, 'unknown server') + '\u201D \u2014 ' + str(error, 'connect failed'), 'danger')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function toastFailures(failures) {
|
|
281
|
+
if (!Array.isArray(failures)) return
|
|
282
|
+
for (var i = 0; i < failures.length; i += 1) {
|
|
283
|
+
var failure = failures[i]
|
|
284
|
+
if (!isRecord(failure)) continue
|
|
285
|
+
toastFailure(failure.serverName, failure.error)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function clearToasts() {
|
|
290
|
+
try {
|
|
291
|
+
if (toastItems) {
|
|
292
|
+
var items = []
|
|
293
|
+
toastItems.forEach(function (item) { items.push(item) })
|
|
294
|
+
for (var i = 0; i < items.length; i += 1) closeToast(items[i])
|
|
295
|
+
}
|
|
296
|
+
toastItems = null
|
|
297
|
+
if (toastRoot && toastRoot.parentNode) toastRoot.parentNode.removeChild(toastRoot)
|
|
298
|
+
toastRoot = null
|
|
299
|
+
} catch (error) {
|
|
300
|
+
warn('toast cleanup failed', error)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── panel state ──────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
function closePanel() {
|
|
307
|
+
try {
|
|
308
|
+
if (panel && panel.refetchTimer !== null) clearTimeout(panel.refetchTimer)
|
|
309
|
+
if (overlayRoot && overlayRoot.parentNode) overlayRoot.parentNode.removeChild(overlayRoot)
|
|
310
|
+
overlayRoot = null
|
|
311
|
+
if (panel && panel.escape) {
|
|
312
|
+
document.removeEventListener('keydown', panel.escape, true)
|
|
313
|
+
panel.escape = null
|
|
314
|
+
}
|
|
315
|
+
panel = null
|
|
316
|
+
} catch (error) {
|
|
317
|
+
warn('panel close failed', error)
|
|
318
|
+
panel = null
|
|
319
|
+
overlayRoot = null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function setText(node, text) {
|
|
324
|
+
if (node && node.textContent !== text) node.textContent = text
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A fresh element for one render pass; render() swaps it into the slot. */
|
|
328
|
+
function toolRow(tool) {
|
|
329
|
+
var row = el('div', 'ccmcp-tool')
|
|
330
|
+
var rawName = isRecord(tool) ? (str(tool.name) || str(tool.rawName)) : ''
|
|
331
|
+
var name = el('div', 'ccmcp-tool-name', rawName)
|
|
332
|
+
if (rawName) name.title = rawName
|
|
333
|
+
row.appendChild(name)
|
|
334
|
+
var description = isRecord(tool) ? str(tool.description) : ''
|
|
335
|
+
if (description) {
|
|
336
|
+
var desc = el('p', 'ccmcp-tool-desc', description)
|
|
337
|
+
desc.title = description
|
|
338
|
+
row.appendChild(desc)
|
|
339
|
+
}
|
|
340
|
+
return row
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function toolList(entry) {
|
|
344
|
+
var tools = Array.isArray(entry.tools) ? entry.tools : []
|
|
345
|
+
var box = el('div', 'ccmcp-tools' + (entry.disabled === true ? ' ccmcp-tools-muted' : ''))
|
|
346
|
+
box.appendChild(el('div', 'ccmcp-tools-head', 'Tools (' + tools.length + ')'))
|
|
347
|
+
var body = el('div', 'ccmcp-tools-body')
|
|
348
|
+
if (tools.length === 0) {
|
|
349
|
+
body.appendChild(el('div', 'ccmcp-empty', 'This server reported no tools'))
|
|
350
|
+
} else {
|
|
351
|
+
for (var i = 0; i < tools.length; i += 1) body.appendChild(toolRow(tools[i]))
|
|
352
|
+
}
|
|
353
|
+
box.appendChild(body)
|
|
354
|
+
return box
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─── render ───────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
function render() {
|
|
360
|
+
var current = panel
|
|
361
|
+
if (!current || !current.body || !current.subtitle) return
|
|
362
|
+
try {
|
|
363
|
+
var snapshot = current.snapshot
|
|
364
|
+
if (current.view === 'detail') renderDetail(current, snapshot)
|
|
365
|
+
else renderList(current, snapshot)
|
|
366
|
+
} catch (error) {
|
|
367
|
+
warn('panel render failed', error)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A row the host has not settled yet keeps its spinner across re-renders. */
|
|
372
|
+
function pendingEntry(current, entry) {
|
|
373
|
+
return current.checkingKeys[entryKey(entry)] === true
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function renderList(current, snapshot) {
|
|
377
|
+
var servers = snapshot && Array.isArray(snapshot.servers) ? snapshot.servers : []
|
|
378
|
+
var known = snapshot ? snapshot.known !== false : false
|
|
379
|
+
var projectRoot = snapshot ? str(snapshot.projectRoot) : ''
|
|
380
|
+
setText(current.subtitle, current.loading ? 'Loading\u2026' : (projectRoot || 'No project root resolved'))
|
|
381
|
+
clear(current.body)
|
|
382
|
+
|
|
383
|
+
if (current.loading) {
|
|
384
|
+
current.body.appendChild(spinRow('Loading servers\u2026'))
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
if (!snapshot && current.error) {
|
|
388
|
+
current.body.appendChild(emptyState(current.error, true))
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
if (!known || servers.length === 0) {
|
|
392
|
+
current.body.appendChild(emptyState('No MCP servers configured for this project', true))
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
for (var i = 0; i < servers.length; i += 1) {
|
|
396
|
+
if (isRecord(servers[i])) current.body.appendChild(serverRow(current, servers[i]))
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function emptyState(text, retry) {
|
|
401
|
+
var box = el('div', 'ccmcp-empty')
|
|
402
|
+
box.appendChild(el('p', 'ccmcp-empty-text', text))
|
|
403
|
+
if (retry) {
|
|
404
|
+
var button = el('button', 'ccmcp-btn', 'Retry')
|
|
405
|
+
button.type = 'button'
|
|
406
|
+
button.setAttribute('data-role', 'retry')
|
|
407
|
+
button.addEventListener('click', function () { void refreshPanel() })
|
|
408
|
+
box.appendChild(button)
|
|
409
|
+
}
|
|
410
|
+
return box
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function spinRow(text) {
|
|
414
|
+
var row = el('div', 'ccmcp-spin-row')
|
|
415
|
+
row.appendChild(el('span', 'ccmcp-spinner'))
|
|
416
|
+
row.appendChild(el('span', null, text))
|
|
417
|
+
return row
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function markBusy(button, busy) {
|
|
421
|
+
clear(button)
|
|
422
|
+
if (busy) {
|
|
423
|
+
button.appendChild(el('span', 'ccmcp-spinner'))
|
|
424
|
+
button.setAttribute('aria-busy', 'true')
|
|
425
|
+
}
|
|
426
|
+
button.disabled = busy === true
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function scopeInfo(entry) {
|
|
430
|
+
var scope = str(entry.scope)
|
|
431
|
+
if (scope === 'plugin' || (!scope && str(entry.pluginName))) {
|
|
432
|
+
return { label: 'Plugin', plugin: str(entry.pluginName) || 'plugin' }
|
|
433
|
+
}
|
|
434
|
+
if (scope === 'project') return { label: 'Project', plugin: '' }
|
|
435
|
+
return { label: scope ? scope.charAt(0).toUpperCase() + scope.slice(1) : 'Server', plugin: '' }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function typeCell(entry) {
|
|
439
|
+
var cell = el('div', 'ccmcp-type')
|
|
440
|
+
var info = scopeInfo(entry)
|
|
441
|
+
cell.appendChild(el('span', null, info.label))
|
|
442
|
+
var transport = str(entry.transport, 'stdio')
|
|
443
|
+
cell.appendChild(el('span', 'ccmcp-type-transport', ' \u00B7 ' + transport))
|
|
444
|
+
return cell
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function serverRow(current, entry) {
|
|
448
|
+
var row = el('button', 'ccmcp-row')
|
|
449
|
+
row.type = 'button'
|
|
450
|
+
row.setAttribute('data-role', 'row')
|
|
451
|
+
row.setAttribute('data-key', entryKey(entry))
|
|
452
|
+
row.appendChild(el('span', 'ccmcp-glyph', firstLetter(entry.serverName)))
|
|
453
|
+
row.appendChild(el('span', 'ccmcp-name', str(entry.serverName, 'unnamed server')))
|
|
454
|
+
row.appendChild(typeCell(entry))
|
|
455
|
+
row.appendChild(statusCell(current, entry))
|
|
456
|
+
row.addEventListener('click', function () { openDetail(entryKey(entry)) })
|
|
457
|
+
return row
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function firstLetter(serverName) {
|
|
461
|
+
var name = str(serverName)
|
|
462
|
+
return name ? name.charAt(0) : '?'
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The status cell owns its own buttons, so a click here must not open the row. */
|
|
466
|
+
function statusCell(current, entry) {
|
|
467
|
+
var cell = el('div', 'ccmcp-status')
|
|
468
|
+
cell.setAttribute('data-role', 'status')
|
|
469
|
+
cell.addEventListener('click', function (event) { event.stopPropagation() })
|
|
470
|
+
|
|
471
|
+
if (pendingEntry(current, entry)) {
|
|
472
|
+
cell.appendChild(el('span', 'ccmcp-spinner'))
|
|
473
|
+
return cell
|
|
474
|
+
}
|
|
475
|
+
var status = str(entry.status, 'checking')
|
|
476
|
+
if (status === 'ready') {
|
|
477
|
+
var check = el('button', 'ccmcp-iconbtn')
|
|
478
|
+
check.type = 'button'
|
|
479
|
+
check.setAttribute('data-role', 'status-ready')
|
|
480
|
+
check.title = 'Re-check this server'
|
|
481
|
+
check.setAttribute('aria-label', 'Re-check ' + str(entry.serverName, 'server'))
|
|
482
|
+
check.textContent = '\u2713'
|
|
483
|
+
check.addEventListener('click', function (event) {
|
|
484
|
+
event.stopPropagation()
|
|
485
|
+
markBusy(check, true)
|
|
486
|
+
void reconnect(current, entryKey(entry), function () { markBusy(check, false) })
|
|
487
|
+
})
|
|
488
|
+
cell.appendChild(check)
|
|
489
|
+
return cell
|
|
490
|
+
}
|
|
491
|
+
if (status === 'error') {
|
|
492
|
+
var connect = el('button', 'ccmcp-btn', 'Connect')
|
|
493
|
+
connect.type = 'button'
|
|
494
|
+
connect.setAttribute('data-role', 'status-error')
|
|
495
|
+
connect.addEventListener('click', function (event) {
|
|
496
|
+
event.stopPropagation()
|
|
497
|
+
markBusy(connect, true)
|
|
498
|
+
void reconnect(current, entryKey(entry), function () {
|
|
499
|
+
markBusy(connect, false)
|
|
500
|
+
/* The panel re-renders with a fresh button, so only restore the label
|
|
501
|
+
* while this exact node is still the one on screen. */
|
|
502
|
+
if (connect.isConnected !== false) connect.textContent = 'Connect'
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
cell.appendChild(connect)
|
|
506
|
+
return cell
|
|
507
|
+
}
|
|
508
|
+
if (status === 'disabled') {
|
|
509
|
+
cell.appendChild(el('span', 'ccmcp-status-muted', 'Disabled'))
|
|
510
|
+
return cell
|
|
511
|
+
}
|
|
512
|
+
if (status === 'skipped') {
|
|
513
|
+
cell.appendChild(el('span', 'ccmcp-status-muted', 'Host'))
|
|
514
|
+
return cell
|
|
515
|
+
}
|
|
516
|
+
cell.appendChild(el('span', 'ccmcp-spinner'))
|
|
517
|
+
return cell
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function renderDetail(current, snapshot) {
|
|
521
|
+
var servers = snapshot && Array.isArray(snapshot.servers) ? snapshot.servers : []
|
|
522
|
+
var projectRoot = snapshot ? str(snapshot.projectRoot) : ''
|
|
523
|
+
setText(current.subtitle, projectRoot || 'No project root resolved')
|
|
524
|
+
var entry = null
|
|
525
|
+
for (var i = 0; i < servers.length; i += 1) {
|
|
526
|
+
if (entryKey(servers[i]) === current.selectedKey) { entry = servers[i]; break }
|
|
527
|
+
}
|
|
528
|
+
clear(current.body)
|
|
529
|
+
if (!entry) {
|
|
530
|
+
current.view = 'list'
|
|
531
|
+
renderList(current, snapshot)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
current.body.appendChild(detailHead(current, entry))
|
|
535
|
+
current.body.appendChild(statusLine(current, entry))
|
|
536
|
+
|
|
537
|
+
if (!current.checking) {
|
|
538
|
+
var info = scopeInfo(entry)
|
|
539
|
+
var source = str(entry.source)
|
|
540
|
+
var sourceLine
|
|
541
|
+
if (str(entry.scope) === 'host') {
|
|
542
|
+
/* A host row is the profile's own dsh-mcp-client instance: this plugin
|
|
543
|
+
* lists it and can hide it here, but the host owns the connection. */
|
|
544
|
+
sourceLine = source || 'Host MCP client row in the profile config'
|
|
545
|
+
} else if (info.plugin) {
|
|
546
|
+
sourceLine = 'Plugin ' + info.plugin + ' at ' + (source || 'unknown path')
|
|
547
|
+
} else {
|
|
548
|
+
sourceLine = 'Configured in ' + (source || 'an unknown source')
|
|
549
|
+
}
|
|
550
|
+
current.body.appendChild(el('p', 'ccmcp-source', sourceLine))
|
|
551
|
+
} else {
|
|
552
|
+
current.body.appendChild(spinRow('Checking\u2026'))
|
|
553
|
+
}
|
|
554
|
+
current.body.appendChild(toolList(entry))
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function detailHead(current, entry) {
|
|
558
|
+
var head = el('div', 'ccmcp-head')
|
|
559
|
+
head.style.flexDirection = 'column'
|
|
560
|
+
head.style.gap = '6px'
|
|
561
|
+
var back = el('button', 'ccmcp-backbtn', '\u2190 All servers')
|
|
562
|
+
back.type = 'button'
|
|
563
|
+
back.setAttribute('data-role', 'back')
|
|
564
|
+
back.addEventListener('click', function () {
|
|
565
|
+
if (panel !== current) return
|
|
566
|
+
current.view = 'list'
|
|
567
|
+
current.selectedKey = ''
|
|
568
|
+
render()
|
|
569
|
+
})
|
|
570
|
+
head.appendChild(back)
|
|
571
|
+
|
|
572
|
+
var row = el('div', 'ccmcp-detail-head')
|
|
573
|
+
var main = el('div', 'ccmcp-head-main')
|
|
574
|
+
var titleRow = el('div', 'ccmcp-detail-head')
|
|
575
|
+
titleRow.appendChild(el('span', 'ccmcp-glyph', firstLetter(entry.serverName)))
|
|
576
|
+
titleRow.appendChild(el('span', 'ccmcp-name', str(entry.serverName, 'unnamed server')))
|
|
577
|
+
titleRow.appendChild(el('span', 'ccmcp-type-transport', str(entry.transport, 'stdio')))
|
|
578
|
+
main.appendChild(titleRow)
|
|
579
|
+
row.appendChild(main)
|
|
580
|
+
row.appendChild(toggleButton(current, entry))
|
|
581
|
+
head.appendChild(row)
|
|
582
|
+
return head
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function toggleButton(current, entry) {
|
|
586
|
+
var actions = el('div', 'ccmcp-head-actions')
|
|
587
|
+
if (str(entry.status) === 'skipped') return actions
|
|
588
|
+
var disabled = entry.disabled === true
|
|
589
|
+
var button = el('button', 'ccmcp-btn', disabled ? 'Enable' : 'Disable')
|
|
590
|
+
button.type = 'button'
|
|
591
|
+
button.setAttribute('data-role', 'toggle')
|
|
592
|
+
button.addEventListener('click', function () {
|
|
593
|
+
markBusy(button, true)
|
|
594
|
+
void toggleServer(current, entryKey(entry), !disabled, function () {
|
|
595
|
+
markBusy(button, false)
|
|
596
|
+
if (button.isConnected !== false) button.textContent = disabled ? 'Enable' : 'Disable'
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
actions.appendChild(button)
|
|
600
|
+
return actions
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function statusLine(current, entry) {
|
|
604
|
+
var status = pendingEntry(current, entry) ? 'checking' : str(entry.status, 'checking')
|
|
605
|
+
var line = el('div', 'ccmcp-statusline')
|
|
606
|
+
line.setAttribute('data-role', 'statusline')
|
|
607
|
+
if (status === 'ready') {
|
|
608
|
+
line.className += ' ccmcp-statusline-ready'
|
|
609
|
+
var suffix = ''
|
|
610
|
+
if (str(entry.scope) === 'host') {
|
|
611
|
+
/* Say who holds the connection: the host's own row, or this plugin
|
|
612
|
+
* after Connect adopted a row that exposed nothing. */
|
|
613
|
+
suffix = entry.managed === true ? ' \u00B7 adopted for this workspace' : ' \u00B7 connected by the host'
|
|
614
|
+
}
|
|
615
|
+
line.appendChild(el('span', null, 'Connected \u00B7 ' + (Number(entry.toolCount) || 0) + ' tools' + suffix))
|
|
616
|
+
return line
|
|
617
|
+
}
|
|
618
|
+
if (status === 'error') {
|
|
619
|
+
line.className += ' ccmcp-statusline-error'
|
|
620
|
+
line.appendChild(el('span', null, 'Disconnected'))
|
|
621
|
+
var detail = str(entry.error, 'the server did not respond')
|
|
622
|
+
line.appendChild(el('span', 'ccmcp-statusline-detail', '\u2014 ' + detail))
|
|
623
|
+
return line
|
|
624
|
+
}
|
|
625
|
+
if (status === 'disabled') {
|
|
626
|
+
line.className += ' ccmcp-statusline-muted'
|
|
627
|
+
line.appendChild(el('span', null, 'Disabled \u2014 tools are not in the model context'))
|
|
628
|
+
return line
|
|
629
|
+
}
|
|
630
|
+
if (status === 'skipped') {
|
|
631
|
+
line.className += ' ccmcp-statusline-muted'
|
|
632
|
+
line.appendChild(el('span', null, 'Provided by a host MCP client'))
|
|
633
|
+
return line
|
|
634
|
+
}
|
|
635
|
+
line.className += ' ccmcp-statusline-muted'
|
|
636
|
+
line.appendChild(el('span', 'ccmcp-spinner'))
|
|
637
|
+
line.appendChild(el('span', null, 'Checking\u2026'))
|
|
638
|
+
return line
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ─── data access ──────────────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
function entryFor(key) {
|
|
644
|
+
if (!panel || !panel.entries) return null
|
|
645
|
+
return panel.entries[key] || null
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Overlay a live entry (post-connect / post-disable) onto the snapshot in place. */
|
|
649
|
+
function reshape(snapshot, overrides) {
|
|
650
|
+
if (!isRecord(snapshot)) return snapshot
|
|
651
|
+
var servers = Array.isArray(snapshot.servers) ? snapshot.servers.slice() : []
|
|
652
|
+
var index = {}
|
|
653
|
+
var i
|
|
654
|
+
for (i = 0; i < servers.length; i += 1) index[entryKey(servers[i])] = i
|
|
655
|
+
var keys = isRecord(overrides) ? Object.keys(overrides) : []
|
|
656
|
+
for (i = 0; i < keys.length; i += 1) {
|
|
657
|
+
var key = keys[i]
|
|
658
|
+
var entry = overrides[key]
|
|
659
|
+
var at = index[key]
|
|
660
|
+
if (at === undefined) {
|
|
661
|
+
servers.push(entry)
|
|
662
|
+
index[key] = servers.length - 1
|
|
663
|
+
} else {
|
|
664
|
+
servers[at] = entry
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
var next = {}
|
|
668
|
+
var names = Object.keys(snapshot)
|
|
669
|
+
for (i = 0; i < names.length; i += 1) next[names[i]] = snapshot[names[i]]
|
|
670
|
+
next.servers = servers
|
|
671
|
+
return next
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function fetchSnapshot() {
|
|
675
|
+
var current = panel
|
|
676
|
+
if (!current) return Promise.resolve(null)
|
|
677
|
+
return api('state', { sessionId: current.sessionId }).then(function (response) {
|
|
678
|
+
if (panel !== current) return null
|
|
679
|
+
if (response.ok !== true) {
|
|
680
|
+
current.snapshot = null
|
|
681
|
+
current.error = errorOf(response, 'Could not read MCP state')
|
|
682
|
+
current.loading = false
|
|
683
|
+
syncPending()
|
|
684
|
+
return null
|
|
685
|
+
}
|
|
686
|
+
current.snapshot = reshape(response.value, current.entries)
|
|
687
|
+
current.error = ''
|
|
688
|
+
current.loading = false
|
|
689
|
+
syncPending()
|
|
690
|
+
return current.snapshot
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* `state` never blocks, so the host's start-up self-check can surface rows as
|
|
696
|
+
* `checking`. While any row is in flight, refetch on a timer until the list
|
|
697
|
+
* settles — bounded, so a check that never finishes cannot poll forever.
|
|
698
|
+
*/
|
|
699
|
+
function syncPending() {
|
|
700
|
+
var current = panel
|
|
701
|
+
if (!current) return
|
|
702
|
+
var servers = current.snapshot && Array.isArray(current.snapshot.servers) ? current.snapshot.servers : []
|
|
703
|
+
var live = {}
|
|
704
|
+
for (var i = 0; i < servers.length; i += 1) {
|
|
705
|
+
var entry = servers[i]
|
|
706
|
+
var key = entryKey(entry)
|
|
707
|
+
if (key && str(entry.status) === 'checking') live[key] = true
|
|
708
|
+
}
|
|
709
|
+
var keys = Object.keys(current.checkingKeys)
|
|
710
|
+
for (i = 0; i < keys.length; i += 1) {
|
|
711
|
+
if (!live[keys[i]]) {
|
|
712
|
+
current.checkingKeys[keys[i]] = false
|
|
713
|
+
delete current.checkingKeys[keys[i]]
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
var liveKeys = Object.keys(live)
|
|
717
|
+
for (i = 0; i < liveKeys.length; i += 1) current.checkingKeys[liveKeys[i]] = true
|
|
718
|
+
|
|
719
|
+
if (liveKeys.length > 0) {
|
|
720
|
+
if (current.refetchUntil === 0) current.refetchUntil = Date.now() + REFETCH_CAP_MS
|
|
721
|
+
if (current.refetchTimer === null) {
|
|
722
|
+
current.refetchTimer = setTimeout(function () {
|
|
723
|
+
if (panel !== current) return
|
|
724
|
+
current.refetchTimer = null
|
|
725
|
+
if (Date.now() > current.refetchUntil) return
|
|
726
|
+
void refreshPanel()
|
|
727
|
+
}, REFETCH_INTERVAL_MS)
|
|
728
|
+
}
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
if (current.refetchTimer !== null) {
|
|
732
|
+
clearTimeout(current.refetchTimer)
|
|
733
|
+
current.refetchTimer = null
|
|
734
|
+
}
|
|
735
|
+
current.refetchUntil = 0
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Full refresh: re-pull the snapshot, then re-render whatever view is open. */
|
|
739
|
+
function refreshPanel() {
|
|
740
|
+
var current = panel
|
|
741
|
+
if (!current) return Promise.resolve(null)
|
|
742
|
+
return fetchSnapshot().then(function (snapshot) {
|
|
743
|
+
if (panel !== current) return snapshot
|
|
744
|
+
if (snapshot && current.snapshot) current.snapshot = reshape(current.snapshot, current.entries)
|
|
745
|
+
render()
|
|
746
|
+
return snapshot
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function recordEntry(entry) {
|
|
751
|
+
if (!panel || !isRecord(entry)) return
|
|
752
|
+
var key = entryKey(entry)
|
|
753
|
+
if (key) panel.entries[key] = entry
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function reconnect(current, key, done) {
|
|
757
|
+
if (panel !== current || !key) { if (done) done(); return Promise.resolve(null) }
|
|
758
|
+
var previous = entryFor(key)
|
|
759
|
+
current.checking = true
|
|
760
|
+
current.entries[key] = mergeEntry(previous, { status: 'checking', error: null })
|
|
761
|
+
render()
|
|
762
|
+
return api('connect', { sessionId: current.sessionId, key: key }).then(function (response) {
|
|
763
|
+
var entry = entryOf(response)
|
|
764
|
+
if (panel !== current) { if (done) done(); return null }
|
|
765
|
+
if (entry) {
|
|
766
|
+
recordEntry(entry)
|
|
767
|
+
if (str(entry.status) === 'error') toastFailure(entry.serverName, entry.error)
|
|
768
|
+
} else {
|
|
769
|
+
current.entries[key] = mergeEntry(previous, {
|
|
770
|
+
status: 'error',
|
|
771
|
+
error: errorOf(response, 'connect failed'),
|
|
772
|
+
})
|
|
773
|
+
toastFailure(previous && previous.serverName, errorOf(response, 'connect failed'))
|
|
774
|
+
}
|
|
775
|
+
current.checking = false
|
|
776
|
+
return refreshPanel().then(function () { if (done) done(); return entry })
|
|
777
|
+
}, function (error) {
|
|
778
|
+
if (panel === current) {
|
|
779
|
+
current.checking = false
|
|
780
|
+
current.entries[key] = mergeEntry(previous, { status: 'error', error: String((error && error.message) || error) })
|
|
781
|
+
}
|
|
782
|
+
toastFailure(previous && previous.serverName, (error && error.message) || error)
|
|
783
|
+
if (done) done()
|
|
784
|
+
return null
|
|
785
|
+
})
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function mergeEntry(previous, patch) {
|
|
789
|
+
var next = {}
|
|
790
|
+
var names = isRecord(previous) ? Object.keys(previous) : []
|
|
791
|
+
for (var i = 0; i < names.length; i += 1) next[names[i]] = previous[names[i]]
|
|
792
|
+
var keys = Object.keys(patch)
|
|
793
|
+
for (var j = 0; j < keys.length; j += 1) next[keys[j]] = patch[keys[j]]
|
|
794
|
+
return next
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function toggleServer(current, key, disabled, done) {
|
|
798
|
+
if (panel !== current || !key) { if (done) done(); return Promise.resolve(null) }
|
|
799
|
+
var previous = entryFor(key)
|
|
800
|
+
return api('disable', { sessionId: current.sessionId, key: key, disabled: disabled }).then(function (response) {
|
|
801
|
+
var entry = entryOf(response)
|
|
802
|
+
if (panel !== current) { if (done) done(); return null }
|
|
803
|
+
if (entry) {
|
|
804
|
+
recordEntry(entry)
|
|
805
|
+
} else {
|
|
806
|
+
toast('MCP server action failed', str(previous && previous.serverName, key) + ' \u2014 ' + errorOf(response, 'the request failed'), 'danger')
|
|
807
|
+
}
|
|
808
|
+
return refreshPanel().then(function () { if (done) done(); return entry })
|
|
809
|
+
}, function (error) {
|
|
810
|
+
toast('MCP server action failed', str(previous && previous.serverName, key) + ' \u2014 ' + String((error && error.message) || error), 'danger')
|
|
811
|
+
if (done) done()
|
|
812
|
+
return null
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ─── panel lifecycle ──────────────────────────────────────────────────────
|
|
817
|
+
|
|
818
|
+
function openPanel(sessionId) {
|
|
819
|
+
if (!isDom()) return
|
|
820
|
+
try {
|
|
821
|
+
closePanel()
|
|
822
|
+
var session = str(sessionId)
|
|
823
|
+
if (!session) {
|
|
824
|
+
toast('MCP panel unavailable', 'No conversation is open, so there is no project to inspect.', 'danger')
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
panel = {
|
|
828
|
+
sessionId: session,
|
|
829
|
+
view: 'list',
|
|
830
|
+
selectedKey: '',
|
|
831
|
+
snapshot: null,
|
|
832
|
+
error: '',
|
|
833
|
+
entries: {},
|
|
834
|
+
checkingKeys: {},
|
|
835
|
+
loading: true,
|
|
836
|
+
checking: false,
|
|
837
|
+
refetchTimer: null,
|
|
838
|
+
refetchUntil: 0,
|
|
839
|
+
escape: null,
|
|
840
|
+
body: null,
|
|
841
|
+
subtitle: null,
|
|
842
|
+
}
|
|
843
|
+
var current = panel
|
|
844
|
+
|
|
845
|
+
var overlay = el('div', 'ccmcp-overlay')
|
|
846
|
+
overlay.setAttribute('data-plugin', PLUGIN_ID)
|
|
847
|
+
var backdrop = el('div', 'ccmcp-backdrop')
|
|
848
|
+
backdrop.setAttribute('data-role', 'backdrop')
|
|
849
|
+
backdrop.addEventListener('click', function () { if (panel === current) closePanel() })
|
|
850
|
+
overlay.appendChild(backdrop)
|
|
851
|
+
|
|
852
|
+
var card = el('div', 'ccmcp-panel')
|
|
853
|
+
card.setAttribute('role', 'dialog')
|
|
854
|
+
card.setAttribute('aria-label', 'MCP servers')
|
|
855
|
+
|
|
856
|
+
var head = el('div', 'ccmcp-head')
|
|
857
|
+
var headMain = el('div', 'ccmcp-head-main')
|
|
858
|
+
headMain.appendChild(el('h2', 'ccmcp-title', 'MCP servers'))
|
|
859
|
+
var subtitle = el('p', 'ccmcp-subtitle', 'Loading\u2026')
|
|
860
|
+
headMain.appendChild(subtitle)
|
|
861
|
+
head.appendChild(headMain)
|
|
862
|
+
|
|
863
|
+
var headActions = el('div', 'ccmcp-head-actions')
|
|
864
|
+
var close = el('button', 'ccmcp-iconbtn', '\u2715')
|
|
865
|
+
close.type = 'button'
|
|
866
|
+
close.setAttribute('data-role', 'close')
|
|
867
|
+
close.setAttribute('aria-label', 'Close MCP servers')
|
|
868
|
+
close.addEventListener('click', function () { if (panel === current) closePanel() })
|
|
869
|
+
headActions.appendChild(close)
|
|
870
|
+
head.appendChild(headActions)
|
|
871
|
+
|
|
872
|
+
var body = el('div', 'ccmcp-body')
|
|
873
|
+
card.appendChild(head)
|
|
874
|
+
card.appendChild(body)
|
|
875
|
+
overlay.appendChild(card)
|
|
876
|
+
document.body.appendChild(overlay)
|
|
877
|
+
|
|
878
|
+
overlayRoot = overlay
|
|
879
|
+
current.body = body
|
|
880
|
+
current.subtitle = subtitle
|
|
881
|
+
|
|
882
|
+
/* Escape closes from anywhere in the page, and survives the conversation
|
|
883
|
+
* re-rendering: it is a document listener, not a component one. */
|
|
884
|
+
current.escape = function (event) {
|
|
885
|
+
if (event && event.key === 'Escape' && panel === current) {
|
|
886
|
+
event.stopPropagation()
|
|
887
|
+
closePanel()
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
document.addEventListener('keydown', current.escape, true)
|
|
891
|
+
|
|
892
|
+
render()
|
|
893
|
+
void refreshPanel()
|
|
894
|
+
} catch (error) {
|
|
895
|
+
warn('panel open failed', error)
|
|
896
|
+
closePanel()
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function openDetail(key) {
|
|
901
|
+
if (!panel || !key) return
|
|
902
|
+
panel.selectedKey = key
|
|
903
|
+
panel.view = 'detail'
|
|
904
|
+
render()
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ─── session self-check ───────────────────────────────────────────────────
|
|
908
|
+
|
|
909
|
+
function clearCheckRetry() {
|
|
910
|
+
if (checkRetryTimer !== null) {
|
|
911
|
+
clearTimeout(checkRetryTimer)
|
|
912
|
+
checkRetryTimer = null
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function scheduleCheck(sessionId, delay) {
|
|
917
|
+
clearCheckRetry()
|
|
918
|
+
checkRetryTimer = setTimeout(function () {
|
|
919
|
+
checkRetryTimer = null
|
|
920
|
+
runCheck(sessionId, false)
|
|
921
|
+
}, delay)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Fire-and-forget `GET /cc-mcp/check` for the current session. Bounded: at
|
|
926
|
+
* most a couple of retries while the host reports `checked: false`, and one
|
|
927
|
+
* pass per failure so a stuck check can never tight-loop.
|
|
928
|
+
*/
|
|
929
|
+
function runCheck(sessionId, isRetry) {
|
|
930
|
+
var id = str(sessionId)
|
|
931
|
+
if (!id) return
|
|
932
|
+
if (!isRetry && checkSessionId === id) return
|
|
933
|
+
if (pendingCheck === id) return
|
|
934
|
+
checkSessionId = id
|
|
935
|
+
pendingCheck = id
|
|
936
|
+
api('check', { sessionId: id }).then(function (response) {
|
|
937
|
+
if (pendingCheck === id) pendingCheck = null
|
|
938
|
+
if (checkSessionId !== id) return
|
|
939
|
+
if (response.ok !== true) {
|
|
940
|
+
if (isRetry) warn('self-check failed: ' + errorOf(response, codeOf(response)))
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
var value = isRecord(response.value) ? response.value : {}
|
|
944
|
+
if (value.checked === true) {
|
|
945
|
+
toastFailures(value.failures)
|
|
946
|
+
checkRetryIndex = 0
|
|
947
|
+
return
|
|
948
|
+
}
|
|
949
|
+
if (checkRetryIndex < CHECK_RETRY_DELAYS.length) {
|
|
950
|
+
scheduleCheck(id, CHECK_RETRY_DELAYS[checkRetryIndex])
|
|
951
|
+
checkRetryIndex += 1
|
|
952
|
+
}
|
|
953
|
+
}, function () {
|
|
954
|
+
if (pendingCheck === id) pendingCheck = null
|
|
955
|
+
checkRetryIndex = 0
|
|
956
|
+
})
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function bindSessionsInject() {
|
|
960
|
+
if (sessionUnsub) return
|
|
961
|
+
if (!sessionsScope || !sessionsScope.sessions || !sessionsScope.sessions.list) return
|
|
962
|
+
var list = sessionsScope.sessions.list
|
|
963
|
+
if (typeof list.subscribe !== 'function' || typeof list.getSnapshot !== 'function') return
|
|
964
|
+
var last = null
|
|
965
|
+
var observer = function () {
|
|
966
|
+
var snapshot = null
|
|
967
|
+
try { snapshot = list.getSnapshot() } catch (error) { warn('session snapshot read failed', error); return }
|
|
968
|
+
var current = isRecord(snapshot) ? str(snapshot.current) : ''
|
|
969
|
+
if (current === last) return
|
|
970
|
+
last = current
|
|
971
|
+
checkRetryIndex = 0
|
|
972
|
+
clearCheckRetry()
|
|
973
|
+
if (current) runCheck(current, false)
|
|
974
|
+
}
|
|
975
|
+
sessionUnsub = list.subscribe(observer)
|
|
976
|
+
observer()
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ─── plugin ───────────────────────────────────────────────────────────────
|
|
980
|
+
|
|
981
|
+
function injectCmds(ctx) {
|
|
982
|
+
if (!ctx || typeof ctx.inject !== 'function') return
|
|
983
|
+
ctx.inject(['commandUi'], function (scope) {
|
|
984
|
+
try {
|
|
985
|
+
/* Resolve the service eagerly: if the scope cannot see it, the deferred
|
|
986
|
+
* injection firing is a no-op rather than a half-registered decoration. */
|
|
987
|
+
var commandUi = scope.commandUi
|
|
988
|
+
if (!commandUi || typeof commandUi.decorate !== 'function') return
|
|
989
|
+
scope.effect(function () {
|
|
990
|
+
return commandUi.decorate({
|
|
991
|
+
name: 'mcp',
|
|
992
|
+
available: function () { return true },
|
|
993
|
+
ui: {
|
|
994
|
+
kind: 'action',
|
|
995
|
+
run: function (session) {
|
|
996
|
+
try {
|
|
997
|
+
openPanel(session && session.sessionId)
|
|
998
|
+
} catch (error) {
|
|
999
|
+
warn('/mcp decoration failed', error)
|
|
1000
|
+
}
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
})
|
|
1004
|
+
}, 'cc-mcp: /mcp')
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
warn('/mcp decoration failed', error)
|
|
1007
|
+
}
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function injectSessions(ctx) {
|
|
1012
|
+
var seq = ++bindSeq
|
|
1013
|
+
if (!ctx || typeof ctx.inject !== 'function') return
|
|
1014
|
+
ctx.inject(['sessions'], function (scope) {
|
|
1015
|
+
try {
|
|
1016
|
+
sessionsScope = scope
|
|
1017
|
+
var applied = scope.effect(function () {
|
|
1018
|
+
bindSessionsInject()
|
|
1019
|
+
return function () {
|
|
1020
|
+
sessionsScope = null
|
|
1021
|
+
if (sessionUnsub) {
|
|
1022
|
+
try { sessionUnsub() } catch (error) { warn('session unsubscribe failed', error) }
|
|
1023
|
+
sessionUnsub = null
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}, 'cc-mcp: session self-check')
|
|
1027
|
+
/* Belt and braces: if the plugin was disposed while this registration
|
|
1028
|
+
* was still in flight, cordis suppresses the effect — release the
|
|
1029
|
+
* subscription ourselves rather than leave it dangling. */
|
|
1030
|
+
if (seq !== bindSeq && typeof applied === 'function') {
|
|
1031
|
+
try { applied() } catch (error) { warn('session self-check teardown failed', error) }
|
|
1032
|
+
}
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
warn('session self-check unavailable', error)
|
|
1035
|
+
}
|
|
1036
|
+
})
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function apply(ctx) {
|
|
1040
|
+
try {
|
|
1041
|
+
styleEl = installStyles()
|
|
1042
|
+
if (ctx && typeof ctx.effect === 'function') {
|
|
1043
|
+
ctx.effect(function () {
|
|
1044
|
+
return function () {
|
|
1045
|
+
try { closePanel() } catch (error) { warn('panel teardown failed', error) }
|
|
1046
|
+
try { clearToasts() } catch (error) { warn('toast teardown failed', error) }
|
|
1047
|
+
clearCheckRetry()
|
|
1048
|
+
bindSeq += 1
|
|
1049
|
+
if (sessionUnsub) {
|
|
1050
|
+
try { sessionUnsub() } catch (error) { warn('session unsubscribe failed', error) }
|
|
1051
|
+
}
|
|
1052
|
+
sessionUnsub = null
|
|
1053
|
+
sessionsScope = null
|
|
1054
|
+
checkSessionId = null
|
|
1055
|
+
pendingCheck = null
|
|
1056
|
+
checkRetryIndex = 0
|
|
1057
|
+
if (styleEl && styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
|
|
1058
|
+
styleEl = null
|
|
1059
|
+
}
|
|
1060
|
+
}, 'cc-mcp: panel lifecycle')
|
|
1061
|
+
}
|
|
1062
|
+
injectCmds(ctx)
|
|
1063
|
+
injectSessions(ctx)
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
/* apply() must never throw: a throw fails the fibre and the whole GUI
|
|
1066
|
+
* page boot. */
|
|
1067
|
+
warn('apply failed', error)
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
module.exports = {
|
|
1072
|
+
name: 'dsh-cc-mcp-client',
|
|
1073
|
+
inject: [],
|
|
1074
|
+
apply: apply,
|
|
1075
|
+
/* Programmatic face of the same transport the panel uses; every call
|
|
1076
|
+
* resolves to a result envelope and never throws. */
|
|
1077
|
+
api: api,
|
|
1078
|
+
}
|
|
1079
|
+
return module.exports
|
|
1080
|
+
} });
|