dsh-plugin-mcp-apps 0.0.0-stage → 0.5.7
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 +139 -2
- package/cordis.patch.yml +9 -0
- package/lib/auth.js +61 -0
- package/lib/client.js +1069 -0
- package/lib/index.js +706 -0
- package/lib/oauth.js +239 -0
- package/package.json +83 -4
- package/scripts/install-remote.sh +50 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-plugin-mcp-apps",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let React = require("react");
|
|
8
|
+
|
|
9
|
+
//#region constants
|
|
10
|
+
const UI_PROTOCOL = "2026-01-26";
|
|
11
|
+
const MCP_MIME = "text/html;profile=mcp-app";
|
|
12
|
+
const API_BASE = "/api/mcp-apps";
|
|
13
|
+
const HANDSHAKE_TIMEOUT_MS = 6000;
|
|
14
|
+
/** Tab type this plugin registers in the right Sidebar. */
|
|
15
|
+
const WORKBENCH_ID = "dsh-plugin-mcp-apps";
|
|
16
|
+
const WORKBENCH_KIND = "mcp-app";
|
|
17
|
+
/** Left-sidebar page this plugin registers, directly below 技能 (order 2). */
|
|
18
|
+
const PANEL_ID = "mcp-apps";
|
|
19
|
+
const PANEL_ORDER = 3;
|
|
20
|
+
const PANEL_TITLE = "连接应用";
|
|
21
|
+
/** A settled call older than this predates the page load. */
|
|
22
|
+
const LIVE_SLACK_MS = 5000;
|
|
23
|
+
//#endregion
|
|
24
|
+
|
|
25
|
+
//#region host API
|
|
26
|
+
/** Call the host connector API and unwrap its `{ok, ...}` envelope. */
|
|
27
|
+
async function api(path, init) {
|
|
28
|
+
const options = init ? { ...init } : {};
|
|
29
|
+
options.headers = { "content-type": "application/json", ...(options.headers || {}) };
|
|
30
|
+
const response = await fetch(API_BASE + path, options);
|
|
31
|
+
const text = await response.text();
|
|
32
|
+
let body = null;
|
|
33
|
+
try {
|
|
34
|
+
body = text ? JSON.parse(text) : null;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw new Error("宿主返回了非 JSON 响应:" + String(text).slice(0, 120));
|
|
37
|
+
}
|
|
38
|
+
if (!response.ok) throw new Error((body && body.error) || "HTTP " + response.status);
|
|
39
|
+
if (body && body.ok === false) throw new Error(body.error || "操作失败");
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Raw JSON-RPC to one connection, executed host-side with its auth applied. */
|
|
44
|
+
function mcpRpc(connectionId, method, params) {
|
|
45
|
+
return api("/rpc", {
|
|
46
|
+
method: "POST",
|
|
47
|
+
body: JSON.stringify({ connectionId, method, params: params || {} })
|
|
48
|
+
}).then((body) => body.result);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Model-facing name of one tool: mirrors the host's `mcp__<id>__<raw>` contract. */
|
|
52
|
+
function publicToolName(connectionId, raw) {
|
|
53
|
+
const joined = "mcp__" + connectionId + "__" + raw;
|
|
54
|
+
const normalized = joined.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
55
|
+
if (normalized === joined && normalized.length <= 64) return normalized;
|
|
56
|
+
return normalized.slice(0, 64);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
|
|
60
|
+
//#region shared state
|
|
61
|
+
/** Public tool name -> connection + resource metadata, filled from `/state`. */
|
|
62
|
+
const toolMeta = new Map();
|
|
63
|
+
const subscribers = new Set();
|
|
64
|
+
const state = { connections: [], loading: true, error: "", loadedAt: 0 };
|
|
65
|
+
|
|
66
|
+
function notify() {
|
|
67
|
+
for (const listener of subscribers) {
|
|
68
|
+
try {
|
|
69
|
+
listener();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
/* a dead component */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Re-read every connection (and their tool catalogs) from the host. */
|
|
77
|
+
let refreshing = null;
|
|
78
|
+
function refreshState() {
|
|
79
|
+
if (refreshing) return refreshing;
|
|
80
|
+
refreshing = api("/state")
|
|
81
|
+
.then((body) => {
|
|
82
|
+
state.connections = body.connections || [];
|
|
83
|
+
state.error = "";
|
|
84
|
+
state.loading = false;
|
|
85
|
+
state.loadedAt = Date.now();
|
|
86
|
+
toolMeta.clear();
|
|
87
|
+
for (const connection of state.connections) {
|
|
88
|
+
for (const tool of connection.tools || []) {
|
|
89
|
+
toolMeta.set(publicToolName(connection.id, tool.name), {
|
|
90
|
+
connectionId: connection.id,
|
|
91
|
+
connectionName: connection.name,
|
|
92
|
+
rawName: tool.name,
|
|
93
|
+
description: tool.description || "",
|
|
94
|
+
resourceUri: tool.resourceUri || ""
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
syncClaims();
|
|
99
|
+
notify();
|
|
100
|
+
return state;
|
|
101
|
+
})
|
|
102
|
+
.catch((error) => {
|
|
103
|
+
state.loading = false;
|
|
104
|
+
state.error = error && error.message ? error.message : String(error);
|
|
105
|
+
notify();
|
|
106
|
+
return state;
|
|
107
|
+
})
|
|
108
|
+
.finally(() => {
|
|
109
|
+
refreshing = null;
|
|
110
|
+
});
|
|
111
|
+
return refreshing;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function useStore() {
|
|
115
|
+
const [, setTick] = React.useState(0);
|
|
116
|
+
React.useEffect(() => {
|
|
117
|
+
const listener = () => setTick((value) => value + 1);
|
|
118
|
+
subscribers.add(listener);
|
|
119
|
+
if (state.loadedAt === 0) refreshState();
|
|
120
|
+
return () => subscribers.delete(listener);
|
|
121
|
+
}, []);
|
|
122
|
+
return state;
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
|
|
126
|
+
//#region styles
|
|
127
|
+
const h = React.createElement;
|
|
128
|
+
const cardStyle = {
|
|
129
|
+
border: "1px solid var(--dsh-border-subtle, rgba(127,127,127,.28))",
|
|
130
|
+
borderRadius: "12px",
|
|
131
|
+
padding: "14px",
|
|
132
|
+
display: "grid",
|
|
133
|
+
gap: "8px",
|
|
134
|
+
background: "var(--dsh-surface-raised, transparent)"
|
|
135
|
+
};
|
|
136
|
+
const headerRow = { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" };
|
|
137
|
+
const titleStyle = { fontSize: "14px", fontWeight: 600 };
|
|
138
|
+
const subtitle = { fontSize: "12px", opacity: 0.7 };
|
|
139
|
+
const monoStyle = {
|
|
140
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
141
|
+
fontSize: "11px",
|
|
142
|
+
padding: "1px 5px",
|
|
143
|
+
borderRadius: "4px",
|
|
144
|
+
background: "var(--dsh-surface-sunken, rgba(127,127,127,.14))",
|
|
145
|
+
wordBreak: "break-all"
|
|
146
|
+
};
|
|
147
|
+
const inputStyle = {
|
|
148
|
+
width: "100%",
|
|
149
|
+
minHeight: "34px",
|
|
150
|
+
padding: "4px 8px",
|
|
151
|
+
borderRadius: "8px",
|
|
152
|
+
border: "1px solid var(--dsh-border-subtle, rgba(127,127,127,.35))",
|
|
153
|
+
background: "transparent",
|
|
154
|
+
color: "inherit",
|
|
155
|
+
font: "inherit",
|
|
156
|
+
boxSizing: "border-box"
|
|
157
|
+
};
|
|
158
|
+
const buttonStyle = {
|
|
159
|
+
minHeight: "32px",
|
|
160
|
+
padding: "0 14px",
|
|
161
|
+
border: "0",
|
|
162
|
+
borderRadius: "8px",
|
|
163
|
+
background: "var(--dsh-accent, #2f6feb)",
|
|
164
|
+
color: "#fff",
|
|
165
|
+
font: "inherit",
|
|
166
|
+
fontWeight: 600,
|
|
167
|
+
cursor: "pointer"
|
|
168
|
+
};
|
|
169
|
+
const ghostButton = {
|
|
170
|
+
minHeight: "28px",
|
|
171
|
+
padding: "0 10px",
|
|
172
|
+
border: "1px solid var(--dsh-border-subtle, rgba(127,127,127,.35))",
|
|
173
|
+
borderRadius: "8px",
|
|
174
|
+
background: "transparent",
|
|
175
|
+
color: "inherit",
|
|
176
|
+
font: "inherit",
|
|
177
|
+
fontSize: "12px",
|
|
178
|
+
cursor: "pointer"
|
|
179
|
+
};
|
|
180
|
+
const dangerButton = { ...ghostButton, color: "var(--dsh-danger, #d0453f)" };
|
|
181
|
+
const labelStyle = { fontSize: "12px", opacity: 0.75, marginBottom: "2px", display: "block" };
|
|
182
|
+
const errorText = { fontSize: "12px", color: "var(--dsh-danger, #d0453f)", lineHeight: "18px", wordBreak: "break-word" };
|
|
183
|
+
const noteBox = {
|
|
184
|
+
fontSize: "12px",
|
|
185
|
+
lineHeight: "18px",
|
|
186
|
+
padding: "8px 10px",
|
|
187
|
+
borderRadius: "8px",
|
|
188
|
+
background: "var(--dsh-surface-sunken, rgba(127,127,127,.08))",
|
|
189
|
+
whiteSpace: "pre-wrap",
|
|
190
|
+
wordBreak: "break-word"
|
|
191
|
+
};
|
|
192
|
+
const STATUS_COLORS = { ready: "#2f9e5f", connecting: "#d8a01a", error: "#d0453f", needs_auth: "#d8a01a", disabled: "rgba(127,127,127,.55)" };
|
|
193
|
+
const STATUS_TEXT = { ready: "已连接", connecting: "连接中", error: "连接失败", needs_auth: "需要授权", disabled: "已关闭" };
|
|
194
|
+
//#endregion
|
|
195
|
+
|
|
196
|
+
//#region connection page
|
|
197
|
+
function Field(props) {
|
|
198
|
+
return h(
|
|
199
|
+
"div",
|
|
200
|
+
null,
|
|
201
|
+
h("label", { style: labelStyle }, props.label),
|
|
202
|
+
props.kind === "select"
|
|
203
|
+
? h(
|
|
204
|
+
"select",
|
|
205
|
+
{ value: props.value, onChange: (event) => props.onChange(event.target.value), style: inputStyle },
|
|
206
|
+
(props.options || []).map((option) => h("option", { key: option.value, value: option.value }, option.label))
|
|
207
|
+
)
|
|
208
|
+
: h("input", {
|
|
209
|
+
value: props.value,
|
|
210
|
+
placeholder: props.placeholder || "",
|
|
211
|
+
onChange: (event) => props.onChange(event.target.value),
|
|
212
|
+
style: inputStyle,
|
|
213
|
+
type: props.type || "text"
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function TextArea(props) {
|
|
219
|
+
return h("textarea", {
|
|
220
|
+
value: props.value,
|
|
221
|
+
placeholder: props.placeholder || "",
|
|
222
|
+
onChange: (event) => props.onChange(event.target.value),
|
|
223
|
+
style: { ...inputStyle, minHeight: "140px", padding: "8px", resize: "vertical", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: "12px" }
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Form for one connection: transport, target, auth parameters. */
|
|
228
|
+
function ConnectionEditor(props) {
|
|
229
|
+
const connection = props.connection;
|
|
230
|
+
const isNew = !connection;
|
|
231
|
+
const [draft, setDraft] = React.useState(() =>
|
|
232
|
+
isNew
|
|
233
|
+
? { name: "", transport: "http", url: "", command: "", argsText: "", envText: "", cwd: "", note: "", enabled: false, authMode: "none", scopes: "", clientMode: "auto", clientId: "", clientSecret: "", params: [] }
|
|
234
|
+
: {
|
|
235
|
+
id: connection.id,
|
|
236
|
+
name: connection.name,
|
|
237
|
+
transport: connection.transport,
|
|
238
|
+
url: connection.url || "",
|
|
239
|
+
command: connection.command || "",
|
|
240
|
+
argsText: (connection.args || []).join("\n"),
|
|
241
|
+
envText: Object.entries(connection.env || {}).map(([key, value]) => key + "=" + value).join("\n"),
|
|
242
|
+
cwd: connection.cwd || "",
|
|
243
|
+
note: connection.note || "",
|
|
244
|
+
enabled: connection.enabled,
|
|
245
|
+
authMode: (connection.auth && connection.auth.mode) || "none",
|
|
246
|
+
scopes: (connection.auth && connection.auth.scopes) || "",
|
|
247
|
+
clientMode: (connection.auth && connection.auth.clientMode) || "auto",
|
|
248
|
+
clientId: (connection.auth && connection.auth.clientId) || "",
|
|
249
|
+
clientSecret: "",
|
|
250
|
+
params: ((connection.auth && connection.auth.params) || []).map((param) => ({ in: param.in, name: param.name, value: "" }))
|
|
251
|
+
}
|
|
252
|
+
);
|
|
253
|
+
const [busy, setBusy] = React.useState("");
|
|
254
|
+
const [error, setError] = React.useState("");
|
|
255
|
+
const [testResult, setTestResult] = React.useState(null);
|
|
256
|
+
const set = (patch) => setDraft((current) => ({ ...current, ...patch }));
|
|
257
|
+
|
|
258
|
+
const payload = () => ({
|
|
259
|
+
record: {
|
|
260
|
+
id: draft.id,
|
|
261
|
+
name: draft.name.trim() || draft.command || draft.url || "未命名",
|
|
262
|
+
enabled: draft.enabled,
|
|
263
|
+
transport: draft.transport,
|
|
264
|
+
url: draft.url.trim(),
|
|
265
|
+
command: draft.command.trim(),
|
|
266
|
+
args: draft.argsText.split("\n").map((line) => line.trim()).filter((line) => line !== ""),
|
|
267
|
+
env: Object.fromEntries(
|
|
268
|
+
draft.envText
|
|
269
|
+
.split("\n")
|
|
270
|
+
.map((line) => line.trim())
|
|
271
|
+
.filter((line) => line !== "" && line.indexOf("=") > 0)
|
|
272
|
+
.map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)])
|
|
273
|
+
),
|
|
274
|
+
cwd: draft.cwd.trim(),
|
|
275
|
+
auth: {
|
|
276
|
+
mode: draft.authMode,
|
|
277
|
+
scopes: draft.authMode === "oauth" ? draft.scopes.trim() : "",
|
|
278
|
+
clientMode: draft.authMode === "oauth" ? draft.clientMode : "auto",
|
|
279
|
+
clientId: draft.authMode === "oauth" ? draft.clientId.trim() : "",
|
|
280
|
+
clientSecret: draft.authMode === "oauth" ? draft.clientSecret : undefined,
|
|
281
|
+
params: draft.authMode === "api" ? draft.params : []
|
|
282
|
+
},
|
|
283
|
+
note: draft.note
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const save = async () => {
|
|
288
|
+
setBusy("save");
|
|
289
|
+
setError("");
|
|
290
|
+
try {
|
|
291
|
+
const body = payload();
|
|
292
|
+
if (isNew) await api("/connections", { method: "POST", body: JSON.stringify(body) });
|
|
293
|
+
else await api("/connections/" + encodeURIComponent(draft.id), { method: "PUT", body: JSON.stringify(body) });
|
|
294
|
+
await refreshState();
|
|
295
|
+
props.onDone();
|
|
296
|
+
} catch (error) {
|
|
297
|
+
setError(error.message || String(error));
|
|
298
|
+
} finally {
|
|
299
|
+
setBusy("");
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const test = async () => {
|
|
304
|
+
setBusy("test");
|
|
305
|
+
setError("");
|
|
306
|
+
setTestResult(null);
|
|
307
|
+
try {
|
|
308
|
+
// The probe needs the host to hold the record (and its secret), so save first.
|
|
309
|
+
let id = draft.id;
|
|
310
|
+
if (isNew) {
|
|
311
|
+
const created = await api("/connections", { method: "POST", body: JSON.stringify(payload()) });
|
|
312
|
+
id = created.connection.id;
|
|
313
|
+
set({ id });
|
|
314
|
+
} else {
|
|
315
|
+
await api("/connections/" + encodeURIComponent(id), { method: "PUT", body: JSON.stringify(payload()) });
|
|
316
|
+
}
|
|
317
|
+
setTestResult(await api("/connections/" + encodeURIComponent(id) + "/test", { method: "POST", body: "{}" }));
|
|
318
|
+
await refreshState();
|
|
319
|
+
} catch (error) {
|
|
320
|
+
setError(error.message || String(error));
|
|
321
|
+
} finally {
|
|
322
|
+
setBusy("");
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return h(
|
|
327
|
+
"div",
|
|
328
|
+
{ style: { ...cardStyle, gap: "10px", borderColor: "var(--dsh-accent, #2f6feb)" } },
|
|
329
|
+
h("div", { style: { ...titleStyle, fontSize: "13px" } }, isNew ? "添加 MCP 服务器" : "编辑:" + connection.name),
|
|
330
|
+
h(Field, { label: "名称(同一台机器内唯一)", value: draft.name, placeholder: "例如 notion", onChange: (value) => set({ name: value }) }),
|
|
331
|
+
h(
|
|
332
|
+
"div",
|
|
333
|
+
null,
|
|
334
|
+
h("label", { style: labelStyle }, "传输方式"),
|
|
335
|
+
h(
|
|
336
|
+
"select",
|
|
337
|
+
{
|
|
338
|
+
value: draft.transport,
|
|
339
|
+
onChange: (event) => set({ transport: event.target.value }),
|
|
340
|
+
style: { ...inputStyle, minHeight: "32px" }
|
|
341
|
+
},
|
|
342
|
+
h("option", { value: "http" }, "Streamable HTTP"),
|
|
343
|
+
h("option", { value: "sse" }, "SSE"),
|
|
344
|
+
h("option", { value: "stdio" }, "stdio(本地进程)")
|
|
345
|
+
)
|
|
346
|
+
),
|
|
347
|
+
draft.transport === "stdio"
|
|
348
|
+
? h(
|
|
349
|
+
"div",
|
|
350
|
+
{ style: { display: "grid", gap: "8px" } },
|
|
351
|
+
h(Field, { label: "命令", value: draft.command, placeholder: "npx", onChange: (value) => set({ command: value }) }),
|
|
352
|
+
h(Field, { label: "参数(每行一个)", value: draft.argsText, placeholder: "-y\n@modelcontextprotocol/server-everything", onChange: (value) => set({ argsText: value }) }),
|
|
353
|
+
h(Field, { label: "环境变量(每行 KEY=VALUE)", value: draft.envText, placeholder: "API_KEY=xxx", onChange: (value) => set({ envText: value }) }),
|
|
354
|
+
h(Field, { label: "工作目录(可选)", value: draft.cwd, placeholder: "/opt/mcp", onChange: (value) => set({ cwd: value }) })
|
|
355
|
+
)
|
|
356
|
+
: h(Field, { label: "服务器地址", value: draft.url, placeholder: "https://mcp.example.com/mcp", onChange: (value) => set({ url: value }) }),
|
|
357
|
+
h(
|
|
358
|
+
"div",
|
|
359
|
+
null,
|
|
360
|
+
h("label", { style: labelStyle }, "鉴权"),
|
|
361
|
+
h(
|
|
362
|
+
"select",
|
|
363
|
+
{
|
|
364
|
+
value: draft.authMode,
|
|
365
|
+
onChange: (event) => set({ authMode: event.target.value }),
|
|
366
|
+
style: { ...inputStyle, minHeight: "32px" }
|
|
367
|
+
},
|
|
368
|
+
h("option", { value: "none" }, "无鉴权"),
|
|
369
|
+
h("option", { value: "api" }, "API 参数(header / query / body)"),
|
|
370
|
+
h("option", { value: "oauth" }, "OAuth 2.0(浏览器授权)")
|
|
371
|
+
)
|
|
372
|
+
),
|
|
373
|
+
draft.authMode === "api"
|
|
374
|
+
? h(
|
|
375
|
+
"div",
|
|
376
|
+
{ style: { display: "grid", gap: "6px" } },
|
|
377
|
+
draft.params.length === 0 ? h("div", { style: subtitle }, "还没有参数,点下面添加。") : null,
|
|
378
|
+
draft.params.map((param, index) =>
|
|
379
|
+
h(
|
|
380
|
+
"div",
|
|
381
|
+
{ key: index, style: { display: "grid", gridTemplateColumns: "92px 1fr 1fr auto", gap: "6px", alignItems: "center" } },
|
|
382
|
+
h(
|
|
383
|
+
"select",
|
|
384
|
+
{
|
|
385
|
+
value: param.in,
|
|
386
|
+
onChange: (event) => {
|
|
387
|
+
const next = draft.params.slice();
|
|
388
|
+
next[index] = { ...param, in: event.target.value };
|
|
389
|
+
set({ params: next });
|
|
390
|
+
},
|
|
391
|
+
style: { ...inputStyle, minHeight: "30px" }
|
|
392
|
+
},
|
|
393
|
+
h("option", { value: "header" }, "Header"),
|
|
394
|
+
h("option", { value: "query" }, "Query"),
|
|
395
|
+
h("option", { value: "body" }, "Body")
|
|
396
|
+
),
|
|
397
|
+
h("input", {
|
|
398
|
+
value: param.name,
|
|
399
|
+
placeholder: "参数名",
|
|
400
|
+
onChange: (event) => {
|
|
401
|
+
const next = draft.params.slice();
|
|
402
|
+
next[index] = { ...param, name: event.target.value };
|
|
403
|
+
set({ params: next });
|
|
404
|
+
},
|
|
405
|
+
style: inputStyle
|
|
406
|
+
}),
|
|
407
|
+
h("input", {
|
|
408
|
+
value: param.value,
|
|
409
|
+
placeholder: "参数值",
|
|
410
|
+
type: "password",
|
|
411
|
+
onChange: (event) => {
|
|
412
|
+
const next = draft.params.slice();
|
|
413
|
+
next[index] = { ...param, value: event.target.value };
|
|
414
|
+
set({ params: next });
|
|
415
|
+
},
|
|
416
|
+
style: inputStyle
|
|
417
|
+
}),
|
|
418
|
+
h(
|
|
419
|
+
"button",
|
|
420
|
+
{
|
|
421
|
+
type: "button",
|
|
422
|
+
style: ghostButton,
|
|
423
|
+
onClick: () => set({ params: draft.params.filter((_, at) => at !== index) })
|
|
424
|
+
},
|
|
425
|
+
"删除"
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
),
|
|
429
|
+
h(
|
|
430
|
+
"button",
|
|
431
|
+
{ type: "button", style: ghostButton, onClick: () => set({ params: draft.params.concat([{ in: "header", name: "", value: "" }]) }) },
|
|
432
|
+
"添加鉴权参数"
|
|
433
|
+
),
|
|
434
|
+
h("div", { style: subtitle }, "已有参数保存后值以 •••• 显示,留空表示沿用旧值。")
|
|
435
|
+
)
|
|
436
|
+
: null,
|
|
437
|
+
draft.authMode === "oauth"
|
|
438
|
+
? h(
|
|
439
|
+
"div",
|
|
440
|
+
{ style: { display: "grid", gap: "6px" } },
|
|
441
|
+
h(Field, { label: "权限范围 scope(可选,空格分隔)", value: draft.scopes, placeholder: "例如 mcp:tools", onChange: (value) => set({ scopes: value }) }),
|
|
442
|
+
h(Field, {
|
|
443
|
+
label: "客户端注册方式",
|
|
444
|
+
kind: "select",
|
|
445
|
+
value: draft.clientMode,
|
|
446
|
+
options: [
|
|
447
|
+
{ value: "auto", label: "自动注册(DCR,推荐)" },
|
|
448
|
+
{ value: "cimd", label: "CIMD:用本页地址作为 client_id" },
|
|
449
|
+
{ value: "manual", label: "手填 client_id / client_secret" }
|
|
450
|
+
],
|
|
451
|
+
onChange: (value) => set({ clientMode: value })
|
|
452
|
+
}),
|
|
453
|
+
draft.clientMode === "cimd"
|
|
454
|
+
? h("div", { style: subtitle }, "client_id 将使用 " + window.location.origin + "/api/mcp-apps/oauth/client-metadata.json(需 HTTPS 访问;服务器需支持 CIMD)")
|
|
455
|
+
: null,
|
|
456
|
+
draft.clientMode === "manual"
|
|
457
|
+
? h(Field, { label: "client_id", value: draft.clientId, placeholder: "服务器预先分配的 client id", onChange: (value) => set({ clientId: value }) })
|
|
458
|
+
: null,
|
|
459
|
+
draft.clientMode === "manual"
|
|
460
|
+
? h(Field, { label: "client_secret(留空沿用已保存的值)", value: draft.clientSecret, type: "password", placeholder: "公共客户端留空", onChange: (value) => set({ clientSecret: value }) })
|
|
461
|
+
: null,
|
|
462
|
+
h(
|
|
463
|
+
"div",
|
|
464
|
+
{ style: subtitle },
|
|
465
|
+
"保存后在该连接卡片上点「授权登录」:浏览器跳转到服务器的授权页,回调地址是本页同源 /api/mcp-apps/oauth/callback,令牌只存在宿主进程。"
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
: null,
|
|
469
|
+
h(Field, { label: "备注(可选)", value: draft.note, onChange: (value) => set({ note: value }) }),
|
|
470
|
+
h(
|
|
471
|
+
"label",
|
|
472
|
+
{ style: { display: "flex", gap: "8px", alignItems: "center", fontSize: "12px" } },
|
|
473
|
+
h("input", { type: "checkbox", checked: draft.enabled, onChange: (event) => set({ enabled: event.target.checked }) }),
|
|
474
|
+
"保存后立即启用(关闭时不会向模型暴露它的工具)"
|
|
475
|
+
),
|
|
476
|
+
error ? h("div", { style: errorText }, error) : null,
|
|
477
|
+
testResult
|
|
478
|
+
? testResult.ok
|
|
479
|
+
? h(
|
|
480
|
+
"div",
|
|
481
|
+
{ style: noteBox },
|
|
482
|
+
"连接成功,发现 " + (testResult.tools || []).length + " 个工具:",
|
|
483
|
+
h(
|
|
484
|
+
"div",
|
|
485
|
+
{ style: { marginTop: "4px", display: "grid", gap: "2px" } },
|
|
486
|
+
(testResult.tools || []).slice(0, 40).map((tool) =>
|
|
487
|
+
h("div", { key: tool.name }, h("code", { style: monoStyle }, tool.name), tool.resourceUri ? h("span", { style: subtitle }, " ui://") : null)
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
: h("div", { style: errorText }, "连接失败:" + testResult.error)
|
|
492
|
+
: null,
|
|
493
|
+
h(
|
|
494
|
+
"div",
|
|
495
|
+
{ style: { display: "flex", gap: "8px", flexWrap: "wrap" } },
|
|
496
|
+
h("button", { type: "button", style: buttonStyle, disabled: busy !== "", onClick: save }, busy === "save" ? "保存中…" : isNew ? "添加并保存" : "保存"),
|
|
497
|
+
h("button", { type: "button", style: ghostButton, disabled: busy !== "", onClick: test }, busy === "test" ? "测试中…" : "测试连接"),
|
|
498
|
+
h("button", { type: "button", style: ghostButton, onClick: props.onDone }, "取消")
|
|
499
|
+
)
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** One connection card in the list. */
|
|
504
|
+
function ConnectionCard(props) {
|
|
505
|
+
const connection = props.connection;
|
|
506
|
+
const [busy, setBusy] = React.useState("");
|
|
507
|
+
const [error, setError] = React.useState("");
|
|
508
|
+
const run = async (label, action) => {
|
|
509
|
+
setBusy(label);
|
|
510
|
+
setError("");
|
|
511
|
+
try {
|
|
512
|
+
await action();
|
|
513
|
+
await refreshState();
|
|
514
|
+
} catch (error) {
|
|
515
|
+
setError(error.message || String(error));
|
|
516
|
+
} finally {
|
|
517
|
+
setBusy("");
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
const status = connection.status || (connection.enabled ? "connecting" : "disabled");
|
|
521
|
+
const oauthMode = connection.auth && connection.auth.mode === "oauth";
|
|
522
|
+
const authorized = !!(connection.auth && connection.auth.oauth && connection.auth.oauth.authorized);
|
|
523
|
+
const authorize = async () => {
|
|
524
|
+
setBusy("auth");
|
|
525
|
+
setError("");
|
|
526
|
+
try {
|
|
527
|
+
const body = await api("/connections/" + encodeURIComponent(connection.id) + "/oauth-start", { method: "POST", body: "{}" });
|
|
528
|
+
if (!body.ok) throw new Error(body.error || "发起授权失败");
|
|
529
|
+
if (body.status === "AUTHORIZED") {
|
|
530
|
+
await refreshState();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const popup = window.open(body.authorizationUrl, "_blank", "width=560,height=680");
|
|
534
|
+
if (!popup) {
|
|
535
|
+
setError("浏览器拦住了授权窗口,请允许弹窗后重试,或手动打开:" + body.authorizationUrl);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// The callback lands in that window; poll until the host holds tokens.
|
|
539
|
+
for (let attempt = 0; attempt < 90; attempt++) {
|
|
540
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
541
|
+
await refreshState();
|
|
542
|
+
const current = state.connections.find((candidate) => candidate.id === connection.id);
|
|
543
|
+
if (current && current.auth && current.auth.oauth && current.auth.oauth.authorized) return;
|
|
544
|
+
if (popup.closed && attempt > 2) return;
|
|
545
|
+
}
|
|
546
|
+
} catch (error) {
|
|
547
|
+
setError(error.message || String(error));
|
|
548
|
+
} finally {
|
|
549
|
+
setBusy("");
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
return h(
|
|
553
|
+
"div",
|
|
554
|
+
{ style: cardStyle },
|
|
555
|
+
h(
|
|
556
|
+
"div",
|
|
557
|
+
{ style: headerRow },
|
|
558
|
+
h("span", { style: { width: "8px", height: "8px", borderRadius: "50%", background: STATUS_COLORS[status] || STATUS_COLORS.disabled, display: "inline-block" } }),
|
|
559
|
+
h("span", { style: titleStyle }, connection.name),
|
|
560
|
+
h("span", { style: subtitle }, STATUS_TEXT[status] || status),
|
|
561
|
+
h("span", { style: { ...subtitle, marginLeft: "auto" } }, connection.enabled ? connection.toolCount + " 个工具" : "工具未暴露")
|
|
562
|
+
),
|
|
563
|
+
h(
|
|
564
|
+
"div",
|
|
565
|
+
{ style: { ...subtitle, display: "flex", gap: "8px", flexWrap: "wrap", alignItems: "center" } },
|
|
566
|
+
h("code", { style: monoStyle }, connection.transport === "stdio" ? connection.command : connection.url || "(未填写地址)"),
|
|
567
|
+
connection.auth && connection.auth.mode === "api"
|
|
568
|
+
? h("span", null, "鉴权 " + (connection.auth.params || []).filter((param) => param.hasValue).length + " 项")
|
|
569
|
+
: connection.auth && connection.auth.mode === "oauth"
|
|
570
|
+
? h("span", null, "OAuth 2.0" + (connection.auth.clientMode === "cimd" ? " · CIMD" : connection.auth.clientMode === "manual" ? " · 手填 client" : "") + " · " + (connection.auth.oauth && connection.auth.oauth.authorized ? "已授权" : "未授权") + (connection.auth.scopes ? " · " + connection.auth.scopes : ""))
|
|
571
|
+
: h("span", null, "无鉴权")
|
|
572
|
+
),
|
|
573
|
+
connection.note ? h("div", { style: { ...subtitle, lineHeight: "18px" } }, connection.note) : null,
|
|
574
|
+
error ? h("div", { style: errorText }, error) : null,
|
|
575
|
+
error === "" && connection.status === "error" && connection.error ? h("div", { style: errorText }, connection.error) : null,
|
|
576
|
+
h(
|
|
577
|
+
"div",
|
|
578
|
+
{ style: { display: "flex", gap: "8px", flexWrap: "wrap" } },
|
|
579
|
+
h("button", {
|
|
580
|
+
type: "button",
|
|
581
|
+
style: connection.enabled ? ghostButton : buttonStyle,
|
|
582
|
+
disabled: busy !== "",
|
|
583
|
+
onClick: () =>
|
|
584
|
+
run("toggle", () =>
|
|
585
|
+
api("/connections/" + encodeURIComponent(connection.id) + "/enabled", {
|
|
586
|
+
method: "POST",
|
|
587
|
+
body: JSON.stringify({ enabled: !connection.enabled })
|
|
588
|
+
})
|
|
589
|
+
)
|
|
590
|
+
}, busy === "toggle" ? "处理中…" : connection.enabled ? "关闭(不暴露工具)" : "启用"),
|
|
591
|
+
h("button", { type: "button", style: ghostButton, onClick: () => props.onEdit(connection) }, "编辑"),
|
|
592
|
+
h("button", {
|
|
593
|
+
type: "button",
|
|
594
|
+
style: ghostButton,
|
|
595
|
+
disabled: busy !== "",
|
|
596
|
+
onClick: () => run("test", () => api("/connections/" + encodeURIComponent(connection.id) + "/test", { method: "POST", body: "{}" }))
|
|
597
|
+
}, busy === "test" ? "测试中…" : "测试"),
|
|
598
|
+
oauthMode
|
|
599
|
+
? h("button", { type: "button", style: authorized ? ghostButton : buttonStyle, disabled: busy !== "", onClick: authorize }, busy === "auth" ? "等待授权…" : authorized ? "重新授权" : "授权登录")
|
|
600
|
+
: null,
|
|
601
|
+
oauthMode && authorized
|
|
602
|
+
? h("button", {
|
|
603
|
+
type: "button",
|
|
604
|
+
style: ghostButton,
|
|
605
|
+
disabled: busy !== "",
|
|
606
|
+
onClick: () => run("oauth-reset", () => api("/connections/" + encodeURIComponent(connection.id) + "/oauth-reset", { method: "POST", body: "{}" }))
|
|
607
|
+
}, "清除授权")
|
|
608
|
+
: null,
|
|
609
|
+
h("button", {
|
|
610
|
+
type: "button",
|
|
611
|
+
style: dangerButton,
|
|
612
|
+
disabled: busy !== "",
|
|
613
|
+
onClick: () => run("delete", () => api("/connections/" + encodeURIComponent(connection.id), { method: "DELETE" }))
|
|
614
|
+
}, "删除")
|
|
615
|
+
)
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** The 「连接应用」 page: list + add form + servers.json import. */
|
|
620
|
+
function ConnectAppsPage() {
|
|
621
|
+
const view = useStore();
|
|
622
|
+
const [editing, setEditing] = React.useState(null);
|
|
623
|
+
const [creating, setCreating] = React.useState(false);
|
|
624
|
+
const [importing, setImporting] = React.useState(false);
|
|
625
|
+
const [importText, setImportText] = React.useState("");
|
|
626
|
+
const [importError, setImportError] = React.useState("");
|
|
627
|
+
const [busy, setBusy] = React.useState(false);
|
|
628
|
+
|
|
629
|
+
React.useEffect(() => {
|
|
630
|
+
const onMessage = (event) => {
|
|
631
|
+
if (event && event.data === "mcp-apps-oauth-done") void refreshState();
|
|
632
|
+
};
|
|
633
|
+
window.addEventListener("message", onMessage);
|
|
634
|
+
return () => window.removeEventListener("message", onMessage);
|
|
635
|
+
}, []);
|
|
636
|
+
|
|
637
|
+
const doImport = async () => {
|
|
638
|
+
setBusy(true);
|
|
639
|
+
setImportError("");
|
|
640
|
+
try {
|
|
641
|
+
const body = await api("/import", { method: "POST", body: JSON.stringify({ json: importText }) });
|
|
642
|
+
setImporting(false);
|
|
643
|
+
setImportText("");
|
|
644
|
+
await refreshState();
|
|
645
|
+
void body;
|
|
646
|
+
} catch (error) {
|
|
647
|
+
setImportError(error.message || String(error));
|
|
648
|
+
} finally {
|
|
649
|
+
setBusy(false);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
return h(
|
|
654
|
+
"div",
|
|
655
|
+
{ style: { padding: "18px", display: "grid", gap: "14px", maxWidth: "920px" } },
|
|
656
|
+
h(
|
|
657
|
+
"div",
|
|
658
|
+
null,
|
|
659
|
+
h("div", { style: { fontSize: "18px", fontWeight: 600 } }, PANEL_TITLE),
|
|
660
|
+
h("div", { style: { ...subtitle, marginTop: "4px", lineHeight: "18px" } }, "接入任意 MCP 服务器(HTTP / SSE / stdio)。关闭的连接不会把工具交给模型;带 ui:// 资源的工具会在右侧工作台渲染 MCP Apps。")
|
|
661
|
+
),
|
|
662
|
+
h(
|
|
663
|
+
"div",
|
|
664
|
+
{ style: { display: "flex", gap: "8px", flexWrap: "wrap" } },
|
|
665
|
+
h("button", { type: "button", style: buttonStyle, onClick: () => { setCreating(true); setEditing(null); setImporting(false); } }, "添加 MCP 服务器"),
|
|
666
|
+
h("button", { type: "button", style: ghostButton, onClick: () => { setImporting(true); setCreating(false); setEditing(null); } }, "导入 servers.json"),
|
|
667
|
+
h("button", { type: "button", style: ghostButton, onClick: () => void refreshState() }, "刷新"),
|
|
668
|
+
view.error ? h("span", { style: errorText }, view.error) : null
|
|
669
|
+
),
|
|
670
|
+
importing
|
|
671
|
+
? h(
|
|
672
|
+
"div",
|
|
673
|
+
{ style: { ...cardStyle, gap: "8px" } },
|
|
674
|
+
h("div", { style: titleStyle }, "粘贴 Claude Code 的 mcpServers 配置"),
|
|
675
|
+
h(TextArea, {
|
|
676
|
+
value: importText,
|
|
677
|
+
placeholder: '{\n "mcpServers": {\n "notion": { "type": "http", "url": "https://mcp.notion.com/mcp", "headers": { "Authorization": "Bearer xxx" } },\n "local": { "command": "npx", "args": ["-y", "some-mcp"] }\n }\n}',
|
|
678
|
+
onChange: setImportText
|
|
679
|
+
}),
|
|
680
|
+
importError ? h("div", { style: errorText }, importError) : null,
|
|
681
|
+
h(
|
|
682
|
+
"div",
|
|
683
|
+
{ style: { display: "flex", gap: "8px" } },
|
|
684
|
+
h("button", { type: "button", style: buttonStyle, disabled: busy || importText.trim() === "", onClick: doImport }, busy ? "导入中…" : "导入"),
|
|
685
|
+
h("button", { type: "button", style: ghostButton, onClick: () => setImporting(false) }, "取消")
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
: null,
|
|
689
|
+
creating ? h(ConnectionEditor, { onDone: () => setCreating(false) }) : null,
|
|
690
|
+
editing ? h(ConnectionEditor, { connection: editing, onDone: () => setEditing(null) }) : null,
|
|
691
|
+
view.loading
|
|
692
|
+
? h("div", { style: subtitle }, "读取连接列表…")
|
|
693
|
+
: view.connections.length === 0
|
|
694
|
+
? h("div", { style: noteBox }, "还没有连接。点「添加 MCP 服务器」或「导入 servers.json」。")
|
|
695
|
+
: view.connections.map((connection) =>
|
|
696
|
+
h(ConnectionCard, { key: connection.id, connection, onEdit: (target) => { setEditing(target); setCreating(false); setImporting(false); } })
|
|
697
|
+
)
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
|
|
702
|
+
//#region workbench app
|
|
703
|
+
/** The tool a freshly-opened App reads for its first snapshot. */
|
|
704
|
+
function snapshotToolFor(connectionId, currentRaw) {
|
|
705
|
+
const connection = state.connections.find((candidate) => candidate.id === connectionId);
|
|
706
|
+
const tools = (connection && connection.tools) || [];
|
|
707
|
+
const readLike = /(^|_)(overview|summary|status|list|get)(_|$)/i;
|
|
708
|
+
const pick = tools.find((tool) => readLike.test(tool.name) && tool.name !== currentRaw) || tools.find((tool) => readLike.test(tool.name));
|
|
709
|
+
if (!pick) return null;
|
|
710
|
+
return { name: pick.name, arguments: {} };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Whether the App is still waiting for its frame to report a handshake. */
|
|
714
|
+
function AppCard(props) {
|
|
715
|
+
const toolName = props.toolName || "";
|
|
716
|
+
const minHeight = props.minHeight || 420;
|
|
717
|
+
const meta = toolMeta.get(toolName);
|
|
718
|
+
const connectionId = meta ? meta.connectionId : props.connectionId || "";
|
|
719
|
+
const [html, setHtml] = React.useState(null);
|
|
720
|
+
const [status, setStatus] = React.useState("idle");
|
|
721
|
+
const [error, setError] = React.useState("");
|
|
722
|
+
const [height, setHeight] = React.useState(minHeight);
|
|
723
|
+
const [attempt, setAttempt] = React.useState(0);
|
|
724
|
+
const frameRef = React.useRef(null);
|
|
725
|
+
const handshakeRef = React.useRef(false);
|
|
726
|
+
const handshakeTimer = React.useRef(null);
|
|
727
|
+
|
|
728
|
+
React.useEffect(() => {
|
|
729
|
+
if (!meta || !meta.resourceUri || !connectionId) return undefined;
|
|
730
|
+
let cancelled = false;
|
|
731
|
+
setStatus("loading");
|
|
732
|
+
setError("");
|
|
733
|
+
setHtml(null);
|
|
734
|
+
handshakeRef.current = false;
|
|
735
|
+
mcpRpc(connectionId, "resources/read", { uri: meta.resourceUri })
|
|
736
|
+
.then((result) => {
|
|
737
|
+
const contents = (result && result.contents) || [];
|
|
738
|
+
const page = contents.find((item) => item && typeof item.text === "string");
|
|
739
|
+
if (!page) throw new Error("resources/read 未返回 HTML:" + meta.resourceUri);
|
|
740
|
+
if (cancelled) return;
|
|
741
|
+
setHtml(page.text);
|
|
742
|
+
setStatus("ready");
|
|
743
|
+
if (handshakeTimer.current) clearTimeout(handshakeTimer.current);
|
|
744
|
+
handshakeTimer.current = setTimeout(() => {
|
|
745
|
+
if (cancelled || handshakeRef.current) return;
|
|
746
|
+
setError("界面已加载但未握手:浏览器可能阻止了页面脚本");
|
|
747
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
748
|
+
})
|
|
749
|
+
.catch((error) => {
|
|
750
|
+
if (cancelled) return;
|
|
751
|
+
setStatus("error");
|
|
752
|
+
setError(error && error.message ? error.message : String(error));
|
|
753
|
+
});
|
|
754
|
+
return () => {
|
|
755
|
+
cancelled = true;
|
|
756
|
+
if (handshakeTimer.current) {
|
|
757
|
+
clearTimeout(handshakeTimer.current);
|
|
758
|
+
handshakeTimer.current = null;
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
}, [connectionId, meta && meta.resourceUri, attempt]);
|
|
762
|
+
|
|
763
|
+
React.useEffect(() => {
|
|
764
|
+
const post = (message) => {
|
|
765
|
+
const frame = frameRef.current;
|
|
766
|
+
if (!frame || !frame.contentWindow) return;
|
|
767
|
+
try {
|
|
768
|
+
frame.contentWindow.postMessage(message, "*");
|
|
769
|
+
} catch (error) {
|
|
770
|
+
/* frame gone */
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const onMessage = (event) => {
|
|
775
|
+
const frame = frameRef.current;
|
|
776
|
+
if (!frame || event.source !== frame.contentWindow) return;
|
|
777
|
+
const data = event.data;
|
|
778
|
+
if (!data || data.jsonrpc !== "2.0") return;
|
|
779
|
+
if (typeof data.method === "string" && data.id !== undefined && data.id !== null) {
|
|
780
|
+
if (data.method === "ui/initialize") {
|
|
781
|
+
post({
|
|
782
|
+
jsonrpc: "2.0",
|
|
783
|
+
id: data.id,
|
|
784
|
+
result: {
|
|
785
|
+
protocolVersion: UI_PROTOCOL,
|
|
786
|
+
hostInfo: { name: "dsh-plugin-mcp-apps", version: "0.5.7" },
|
|
787
|
+
hostCapabilities: {},
|
|
788
|
+
hostContext: { theme: "light", displayMode: "inline" }
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (data.method === "ui/resource-teardown") {
|
|
794
|
+
post({ jsonrpc: "2.0", id: data.id, result: {} });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (data.method === "ui/open-link" || data.method === "ui/notifications/open-link") {
|
|
798
|
+
const url = data.params && data.params.url;
|
|
799
|
+
if (url) window.open(url, "_blank", "noopener");
|
|
800
|
+
if (data.id !== undefined && data.id !== null) post({ jsonrpc: "2.0", id: data.id, result: {} });
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (!connectionId) {
|
|
804
|
+
post({ jsonrpc: "2.0", id: data.id, error: { code: -32000, message: "连接未就绪" } });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
mcpRpc(connectionId, data.method, data.params)
|
|
808
|
+
.then((result) => post({ jsonrpc: "2.0", id: data.id, result }))
|
|
809
|
+
.catch((error) => post({ jsonrpc: "2.0", id: data.id, error: { code: -32000, message: error && error.message ? error.message : String(error) } }));
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (data.method === "ui/notifications/initialized") {
|
|
813
|
+
handshakeRef.current = true;
|
|
814
|
+
if (handshakeTimer.current) {
|
|
815
|
+
clearTimeout(handshakeTimer.current);
|
|
816
|
+
handshakeTimer.current = null;
|
|
817
|
+
}
|
|
818
|
+
setError("");
|
|
819
|
+
const snapshot = snapshotToolFor(connectionId, meta ? meta.rawName : "");
|
|
820
|
+
if (!snapshot || !connectionId) return;
|
|
821
|
+
mcpRpc(connectionId, "tools/call", { name: snapshot.name, arguments: snapshot.arguments })
|
|
822
|
+
.then((result) => post({ jsonrpc: "2.0", method: "ui/notifications/tool-result", params: result }))
|
|
823
|
+
.catch((error) => setError(error && error.message ? error.message : String(error)));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (data.method === "ui/notifications/size-changed") {
|
|
827
|
+
const next = data.params && data.params.height;
|
|
828
|
+
if (typeof next === "number" && next > 160) setHeight(Math.min(Math.round(next) + 8, 3200));
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
window.addEventListener("message", onMessage);
|
|
833
|
+
return () => window.removeEventListener("message", onMessage);
|
|
834
|
+
}, [connectionId, meta && meta.rawName]);
|
|
835
|
+
|
|
836
|
+
if (!meta || !meta.resourceUri) {
|
|
837
|
+
return h("div", { style: noteBox }, "该工具没有声明 ui:// 资源,无法渲染 MCP Apps。");
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return h(
|
|
841
|
+
"div",
|
|
842
|
+
{ style: { border: "1px solid var(--dsh-border-subtle, rgba(127,127,127,.28))", borderRadius: "12px", overflow: "hidden" } },
|
|
843
|
+
error ? h("div", { style: { ...errorText, padding: "10px 12px" } }, error) : null,
|
|
844
|
+
status === "error" ? h("div", { style: { padding: "0 12px 12px" } }, h("button", { type: "button", style: buttonStyle, onClick: () => setAttempt((value) => value + 1) }, "重试")) : null,
|
|
845
|
+
!html ? h("div", { style: { padding: "10px 12px", ...subtitle } }, status === "loading" ? "加载 MCP App…" : "准备中…") : null,
|
|
846
|
+
html
|
|
847
|
+
? h("iframe", {
|
|
848
|
+
ref: frameRef,
|
|
849
|
+
title: "MCP App: " + toolName,
|
|
850
|
+
sandbox: "allow-scripts allow-forms",
|
|
851
|
+
srcDoc: html,
|
|
852
|
+
style: { display: "block", width: "100%", border: "0", background: "transparent", height: height + "px" }
|
|
853
|
+
})
|
|
854
|
+
: null
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Chat-side row: hands the interface to the right Sidebar, one line left behind. */
|
|
859
|
+
function WorkbenchRow(props) {
|
|
860
|
+
const phase = props.phase;
|
|
861
|
+
const toolName = props.toolName || (props.block && props.block.name) || "";
|
|
862
|
+
const callId = props.callId;
|
|
863
|
+
const live = startedCalls.has(callId);
|
|
864
|
+
const seen = openState.get(callId);
|
|
865
|
+
const [view, setView] = React.useState(seen || "idle");
|
|
866
|
+
const [message, setMessage] = React.useState("");
|
|
867
|
+
const meta = toolMeta.get(toolName);
|
|
868
|
+
|
|
869
|
+
const attempt = React.useCallback(() => {
|
|
870
|
+
const result = openWorkbench(toolName, callId);
|
|
871
|
+
setView(result.ok ? "opened" : "failed");
|
|
872
|
+
setMessage(result.ok ? "" : result.message || "");
|
|
873
|
+
}, [toolName, callId]);
|
|
874
|
+
|
|
875
|
+
React.useEffect(() => {
|
|
876
|
+
if (phase === "result") return;
|
|
877
|
+
startedCalls.add(callId);
|
|
878
|
+
}, [phase, callId]);
|
|
879
|
+
|
|
880
|
+
React.useEffect(() => {
|
|
881
|
+
if (phase !== "result" || !live) return;
|
|
882
|
+
const known = openState.get(callId);
|
|
883
|
+
if (known) {
|
|
884
|
+
setView(known);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
attempt();
|
|
888
|
+
}, [phase, callId, live, attempt]);
|
|
889
|
+
|
|
890
|
+
if (phase !== "result") {
|
|
891
|
+
return h("div", { style: noteBox }, "工作台准备中…");
|
|
892
|
+
}
|
|
893
|
+
if (view === "idle") {
|
|
894
|
+
return h(
|
|
895
|
+
"div",
|
|
896
|
+
{ style: { ...noteBox, display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" } },
|
|
897
|
+
h("span", null, "历史调用"),
|
|
898
|
+
h("button", { type: "button", style: ghostButton, onClick: attempt }, "在右侧打开")
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
if (view === "opened") {
|
|
902
|
+
return h(
|
|
903
|
+
"div",
|
|
904
|
+
{ style: { ...noteBox, display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" } },
|
|
905
|
+
h("span", { style: { fontWeight: 600 } }, "【已开启工作台】"),
|
|
906
|
+
h("span", { style: subtitle }, "结果与操作都在右侧工作台"),
|
|
907
|
+
h("button", { type: "button", style: ghostButton, onClick: attempt }, "在右侧打开")
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
// Right column unavailable: render the App inline instead of losing it.
|
|
911
|
+
const text = resultText(props.block);
|
|
912
|
+
return h(
|
|
913
|
+
"div",
|
|
914
|
+
{ style: { display: "grid", gap: "8px" } },
|
|
915
|
+
h(
|
|
916
|
+
"div",
|
|
917
|
+
{ style: { ...errorText, padding: "0" } },
|
|
918
|
+
h("div", null, "工作台未能打开,改为在对话内渲染。"),
|
|
919
|
+
message ? h("div", { style: { opacity: 0.75 } }, "原因:" + message) : null,
|
|
920
|
+
meta && meta.resourceUri ? h("div", { style: { marginTop: "4px" } }, "MCP 资源地址:", h("code", { style: monoStyle }, meta.resourceUri)) : null
|
|
921
|
+
),
|
|
922
|
+
h(AppCard, { toolName, minHeight: 420 }),
|
|
923
|
+
text ? h("pre", { style: { ...noteBox, maxHeight: "220px", overflow: "auto" } }, text) : null
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Body of the right Sidebar's workbench tab. */
|
|
928
|
+
function WorkbenchBody(props) {
|
|
929
|
+
const useTabInfo = props.useTabInfo;
|
|
930
|
+
const info = typeof useTabInfo === "function" ? useTabInfo() : null;
|
|
931
|
+
const tab = info && info.tab;
|
|
932
|
+
const navigation = (tab && tab.navigation) || {};
|
|
933
|
+
const params = navigation.params || {};
|
|
934
|
+
const revision = typeof navigation.revision === "number" ? navigation.revision : 0;
|
|
935
|
+
const toolName = typeof params.toolName === "string" ? params.toolName : "";
|
|
936
|
+
const meta = toolMeta.get(toolName);
|
|
937
|
+
return h(
|
|
938
|
+
"div",
|
|
939
|
+
{ style: { height: "100%", overflow: "auto", padding: "8px" } },
|
|
940
|
+
h("div", { style: { ...subtitle, padding: "0 4px 8px" } }, meta ? meta.connectionName + " · " + meta.rawName : "未识别的工具"),
|
|
941
|
+
h(AppCard, { key: revision, toolName, minHeight: 560 })
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Convert a settled tool result into the text fallback under an inline App. */
|
|
946
|
+
function resultText(block) {
|
|
947
|
+
const content = block && block.content;
|
|
948
|
+
if (!Array.isArray(content)) return "";
|
|
949
|
+
const parts = [];
|
|
950
|
+
for (const item of content) {
|
|
951
|
+
if (item && item.type === "text" && typeof item.text === "string") parts.push(item.text);
|
|
952
|
+
}
|
|
953
|
+
return parts.join("\n").trim();
|
|
954
|
+
}
|
|
955
|
+
//#endregion
|
|
956
|
+
|
|
957
|
+
//#region workbench routing
|
|
958
|
+
/** Client root context, captured so a tool row can open the tab. */
|
|
959
|
+
let shell = null;
|
|
960
|
+
/** Injected scope carrying the right Sidebar services. */
|
|
961
|
+
let rightScope = null;
|
|
962
|
+
/** Calls whose running row was rendered after this bundle loaded (i.e. live). */
|
|
963
|
+
const startedCalls = new Set();
|
|
964
|
+
const openState = new Map();
|
|
965
|
+
/** Registered tool rows, so discovery can add and remove them. */
|
|
966
|
+
const claims = new Map();
|
|
967
|
+
|
|
968
|
+
/** Open (or reveal) this Session's single workbench tab. */
|
|
969
|
+
function openWorkbench(toolName, callId) {
|
|
970
|
+
try {
|
|
971
|
+
const right = (rightScope && rightScope.sidebarRight) || (shell && shell.sidebarRight);
|
|
972
|
+
if (!right) throw new Error("右侧栏服务未加载");
|
|
973
|
+
right.openTab(WORKBENCH_KIND, { params: { toolName, callId } });
|
|
974
|
+
if (typeof right.isExpanded === "function" && typeof right.toggleExpanded === "function" && !right.isExpanded()) right.toggleExpanded();
|
|
975
|
+
openState.set(callId, "opened");
|
|
976
|
+
return { ok: true };
|
|
977
|
+
} catch (error) {
|
|
978
|
+
const message = error && error.message ? error.message : String(error);
|
|
979
|
+
openState.set(callId, "failed");
|
|
980
|
+
return { ok: false, message };
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Register a chat row for every connected tool that advertises a `ui://`
|
|
986
|
+
* resource. Called whenever `/state` refreshes; rows for vanished tools are
|
|
987
|
+
* released so a disabled connection stops rendering in the transcript.
|
|
988
|
+
*/
|
|
989
|
+
function syncClaims() {
|
|
990
|
+
if (!shell) return;
|
|
991
|
+
const wanted = new Set();
|
|
992
|
+
for (const [publicName, meta] of toolMeta) if (meta.resourceUri) wanted.add(publicName);
|
|
993
|
+
for (const [publicName, dispose] of claims) {
|
|
994
|
+
if (wanted.has(publicName)) continue;
|
|
995
|
+
try {
|
|
996
|
+
dispose();
|
|
997
|
+
} catch (error) {
|
|
998
|
+
/* already released */
|
|
999
|
+
}
|
|
1000
|
+
claims.delete(publicName);
|
|
1001
|
+
}
|
|
1002
|
+
for (const publicName of wanted) {
|
|
1003
|
+
if (claims.has(publicName)) continue;
|
|
1004
|
+
try {
|
|
1005
|
+
claims.set(publicName, shell.slots.register({ name: "tool.call.toolview", key: publicName }, WorkbenchRow));
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
/* duplicate key */
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
//#endregion
|
|
1012
|
+
|
|
1013
|
+
const inject = ["slots"];
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Register the left-sidebar page, the workbench tab, and the tool rows.
|
|
1017
|
+
* @param ctx - client root context.
|
|
1018
|
+
*/
|
|
1019
|
+
function apply(ctx) {
|
|
1020
|
+
shell = ctx;
|
|
1021
|
+
|
|
1022
|
+
ctx.slots.inject("main", () => ctx.slots.register({ name: "main", key: PANEL_ID }, ConnectAppsPage));
|
|
1023
|
+
ctx.slots.inject("sidebar.panellist", () =>
|
|
1024
|
+
ctx.slots.register(
|
|
1025
|
+
{ name: "sidebar.panellist", id: PANEL_ID, order: PANEL_ORDER, label: () => PANEL_TITLE },
|
|
1026
|
+
function PanelIcon() {
|
|
1027
|
+
return h(
|
|
1028
|
+
"svg",
|
|
1029
|
+
{ width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
|
|
1030
|
+
h("path", { d: "M9 7V3M15 7V3" }),
|
|
1031
|
+
h("path", { d: "M7 7h10v4a5 5 0 0 1-10 0z" }),
|
|
1032
|
+
h("path", { d: "M12 16v5" })
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
)
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
ctx.inject(["sidebarRight", "sidebarRightTabs"], (scope) => {
|
|
1039
|
+
rightScope = scope;
|
|
1040
|
+
scope.effect(
|
|
1041
|
+
() =>
|
|
1042
|
+
scope.sidebarRightTabs.register({
|
|
1043
|
+
id: WORKBENCH_ID,
|
|
1044
|
+
kind: WORKBENCH_KIND,
|
|
1045
|
+
multiple: false,
|
|
1046
|
+
priority: "extension",
|
|
1047
|
+
title: () => "工作台"
|
|
1048
|
+
}),
|
|
1049
|
+
"mcp-apps.workbench-type"
|
|
1050
|
+
);
|
|
1051
|
+
scope.slots.inject("sidebar.right.pane.tab", () =>
|
|
1052
|
+
scope.slots.register({ name: "sidebar.right.pane.tab", key: WORKBENCH_ID }, WorkbenchBody)
|
|
1053
|
+
);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
ctx.effect(() => {
|
|
1057
|
+
void refreshState();
|
|
1058
|
+
const timer = setInterval(() => {
|
|
1059
|
+
void refreshState();
|
|
1060
|
+
}, 15000);
|
|
1061
|
+
return () => clearInterval(timer);
|
|
1062
|
+
}, "mcp-apps: state poller");
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
exports.apply = apply;
|
|
1066
|
+
exports.inject = inject;
|
|
1067
|
+
return module.exports;
|
|
1068
|
+
}
|
|
1069
|
+
});
|