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/index.js
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-plugin-mcp-apps: a general MCP connector.
|
|
3
|
+
*
|
|
4
|
+
* Browser half (`./client.js`) owns the "连接应用" page and the workbench; this
|
|
5
|
+
* half owns what only the host can do — persisting connections, speaking MCP
|
|
6
|
+
* (stdio / streamable HTTP / SSE), injecting the configured auth parameters,
|
|
7
|
+
* and registering each enabled server's tools on the harness ToolRuntime under
|
|
8
|
+
* `mcp__<connectionId>__<tool>`.
|
|
9
|
+
*
|
|
10
|
+
* Disabling a connection disposes its registrations, so the model's next
|
|
11
|
+
* assembly no longer sees those tools.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-plugin-mcp-apps
|
|
14
|
+
*/
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { auth, Client, SSEClientTransport, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client";
|
|
19
|
+
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
20
|
+
import { authFetch } from "./auth.js";
|
|
21
|
+
import { CALLBACK_PATH, CLIENT_METADATA_PATH, RecordOAuthProvider, clearOAuth, clientMetadataDocument, redirectBaseFrom } from "./oauth.js";
|
|
22
|
+
|
|
23
|
+
/** Tool catalogs kept in memory are capped. */
|
|
24
|
+
const MAX_LISTED_TOOLS = 500;
|
|
25
|
+
/** Public tool names follow the DeepSeek function-name contract. */
|
|
26
|
+
const MAX_PUBLIC_NAME = 64;
|
|
27
|
+
/** Characters the function-name contract rejects. */
|
|
28
|
+
const INVALID_NAME = /[^A-Za-z0-9_-]/g;
|
|
29
|
+
/** Where connection records live when DSH_HOME is not set. */
|
|
30
|
+
const DEFAULT_HOME = "/data";
|
|
31
|
+
/** Route prefix this plugin owns on the web server. */
|
|
32
|
+
const API_PREFIX = "/api/mcp-apps";
|
|
33
|
+
/** Request body cap for connection records. */
|
|
34
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
35
|
+
|
|
36
|
+
/** Resolve DSH_HOME the same way the launcher does. */
|
|
37
|
+
function dshHome() {
|
|
38
|
+
return process.env.DSH_HOME && process.env.DSH_HOME.trim() !== "" ? process.env.DSH_HOME : DEFAULT_HOME;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Path of the persisted connection store. */
|
|
42
|
+
function storePath() {
|
|
43
|
+
return join(dshHome(), "mcp-apps", "connections.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One local namespace per connection, constrained to the public-name alphabet. */
|
|
47
|
+
function normalizeId(value) {
|
|
48
|
+
const cleaned = String(value ?? "").trim().replace(INVALID_NAME, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
49
|
+
return cleaned === "" ? "server-" + randomUUID().slice(0, 8) : cleaned;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Model-facing name for one upstream tool: `mcp__<id>__<raw>`, hashed when
|
|
54
|
+
* normalization or truncation would otherwise collapse two identities.
|
|
55
|
+
* @param connectionId - connection namespace.
|
|
56
|
+
* @param raw - the server's own tool name.
|
|
57
|
+
* @returns the public tool name.
|
|
58
|
+
*/
|
|
59
|
+
function publicToolName(connectionId, raw) {
|
|
60
|
+
const joined = `mcp__${connectionId}__${raw}`;
|
|
61
|
+
const normalized = joined.replace(INVALID_NAME, "_");
|
|
62
|
+
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME) return normalized;
|
|
63
|
+
const hash = createHash("sha256").update(`${connectionId}\0${raw}`).digest("hex").slice(0, 12);
|
|
64
|
+
return `${normalized.slice(0, MAX_PUBLIC_NAME - 13)}_${hash}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Join the text blocks of one MCP content array, keeping non-text blocks visible. */
|
|
68
|
+
function contentText(content) {
|
|
69
|
+
if (!Array.isArray(content)) return "";
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const block of content) {
|
|
72
|
+
if (!block || typeof block !== "object") continue;
|
|
73
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
74
|
+
else if (block.type === "image") parts.push(`[image ${block.mimeType ?? "unknown"} omitted from text projection]`);
|
|
75
|
+
else if (block.type === "audio") parts.push(`[audio ${block.mimeType ?? "unknown"} omitted from text projection]`);
|
|
76
|
+
else if (block.type === "resource") parts.push(`[resource ${block.resource?.uri ?? ""}]`);
|
|
77
|
+
else if (block.type === "resource_link") parts.push(`[resource link ${block.uri ?? ""}]`);
|
|
78
|
+
}
|
|
79
|
+
return parts.join("\n").trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Keep only content shapes the harness can carry without attachment admission. */
|
|
83
|
+
function projectableContent(content) {
|
|
84
|
+
if (!Array.isArray(content)) return [];
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const block of content) {
|
|
87
|
+
if (!block || typeof block !== "object") continue;
|
|
88
|
+
if (block.type === "text" && typeof block.text === "string") out.push({ type: "text", text: block.text });
|
|
89
|
+
else if (block.type === "image") out.push({ type: "text", text: `[image ${block.mimeType ?? "unknown"} omitted; raw data stays available to programmatic callers]` });
|
|
90
|
+
else if (block.type === "audio") out.push({ type: "text", text: `[audio ${block.mimeType ?? "unknown"} omitted]` });
|
|
91
|
+
else if (block.type === "resource_link" && typeof block.uri === "string") out.push({ type: "text", text: `[resource ${block.uri}]` });
|
|
92
|
+
else if (block.type === "resource") out.push({ type: "text", text: `[resource ${block.resource?.uri ?? ""}]` });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One live connection: transport, client, and the tool registrations it owns.
|
|
99
|
+
*/
|
|
100
|
+
class Connection {
|
|
101
|
+
/**
|
|
102
|
+
* @param {object} record - persisted connection record.
|
|
103
|
+
* @param {object} ctx - host context (tools registry + logger).
|
|
104
|
+
*/
|
|
105
|
+
constructor(record, ctx, onPersist) {
|
|
106
|
+
this.record = record;
|
|
107
|
+
this.ctx = ctx;
|
|
108
|
+
this.onPersist = onPersist ?? (async () => {});
|
|
109
|
+
this.client = null;
|
|
110
|
+
this.transport = null;
|
|
111
|
+
this.disposers = new Map();
|
|
112
|
+
this.tools = [];
|
|
113
|
+
this.status = record.enabled ? "connecting" : "disabled";
|
|
114
|
+
this.error = "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** OAuth provider bound to this record, when the connection uses OAuth. */
|
|
118
|
+
oauthProvider() {
|
|
119
|
+
if (this.record.auth?.mode !== "oauth") return undefined;
|
|
120
|
+
const base = this.record.auth?.oauth?.redirectBase ?? "http://127.0.0.1";
|
|
121
|
+
return new RecordOAuthProvider(
|
|
122
|
+
this.record,
|
|
123
|
+
() => this.onPersist(),
|
|
124
|
+
{
|
|
125
|
+
redirectBase: base,
|
|
126
|
+
scopes: this.record.auth?.scopes ?? "",
|
|
127
|
+
clientMode: this.record.auth?.clientMode ?? "auto",
|
|
128
|
+
clientId: this.record.auth?.clientId ?? "",
|
|
129
|
+
clientSecret: this.record.auth?.clientSecret ?? ""
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Create the transport described by the record. */
|
|
135
|
+
createTransport() {
|
|
136
|
+
const record = this.record;
|
|
137
|
+
if (record.transport === "stdio") {
|
|
138
|
+
if (!record.command) throw new Error("stdio 连接缺少 command");
|
|
139
|
+
return new StdioClientTransport({
|
|
140
|
+
command: record.command,
|
|
141
|
+
args: Array.isArray(record.args) ? record.args : [],
|
|
142
|
+
env: { ...process.env, ...(record.env ?? {}) },
|
|
143
|
+
cwd: record.cwd || undefined
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!record.url) throw new Error(`${record.transport} 连接缺少 url`);
|
|
147
|
+
const fetchImpl = authFetch(record, globalThis.fetch);
|
|
148
|
+
const url = new URL(record.url);
|
|
149
|
+
const authProvider = this.oauthProvider();
|
|
150
|
+
const common = authProvider === undefined ? { fetch: fetchImpl } : { fetch: fetchImpl, authProvider };
|
|
151
|
+
if (record.transport === "sse") return new SSEClientTransport(url, common);
|
|
152
|
+
return new StreamableHTTPClientTransport(url, common);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Connect, list tools, and register them. */
|
|
156
|
+
async start() {
|
|
157
|
+
this.status = "connecting";
|
|
158
|
+
this.error = "";
|
|
159
|
+
const client = new Client({ name: "dsh-mcp-apps", version: "0.5.7" }, { capabilities: {} });
|
|
160
|
+
const transport = this.createTransport();
|
|
161
|
+
try {
|
|
162
|
+
await client.connect(transport);
|
|
163
|
+
this.client = client;
|
|
164
|
+
this.transport = transport;
|
|
165
|
+
await this.registerTools();
|
|
166
|
+
this.status = "ready";
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
169
|
+
const httpStatus = typeof error?.status === "number" ? error.status : typeof error?.statusCode === "number" ? error.statusCode : undefined;
|
|
170
|
+
const unauthorized = UnauthorizedError.isInstance?.(error) === true || httpStatus === 401 || /unauthoriz|\b401\b/i.test(this.error);
|
|
171
|
+
this.status = unauthorized ? "needs_auth" : "error";
|
|
172
|
+
this.disposeTools();
|
|
173
|
+
try {
|
|
174
|
+
await client.close();
|
|
175
|
+
} catch {
|
|
176
|
+
/* already closed */
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Discover tools and swap the registrations in one go. */
|
|
183
|
+
async registerTools() {
|
|
184
|
+
const capabilities = this.client.getServerCapabilities?.();
|
|
185
|
+
const listed = capabilities?.tools === undefined ? { tools: [] } : await this.client.listTools(undefined, { cacheMode: "refresh" });
|
|
186
|
+
const next = [];
|
|
187
|
+
const definitions = [];
|
|
188
|
+
for (const tool of listed.tools ?? []) {
|
|
189
|
+
const publicName = publicToolName(this.record.id, tool.name);
|
|
190
|
+
const resourceUri = tool._meta?.ui?.resourceUri ?? tool._meta?.["ui/resourceUri"];
|
|
191
|
+
next.push({ name: tool.name, description: tool.description ?? "", resourceUri: typeof resourceUri === "string" ? resourceUri : undefined });
|
|
192
|
+
definitions.push(this.toolDefinition(publicName, tool));
|
|
193
|
+
}
|
|
194
|
+
// Swap: the registry refuses duplicates, so register before disposing the old set.
|
|
195
|
+
const created = [];
|
|
196
|
+
try {
|
|
197
|
+
for (const definition of definitions) created.push([definition.name, this.ctx.tools.register(definition)]);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
for (const [, dispose] of created) dispose();
|
|
200
|
+
throw new Error(`工具注册冲突:${error instanceof Error ? error.message : String(error)}`);
|
|
201
|
+
}
|
|
202
|
+
this.disposeTools();
|
|
203
|
+
for (const [name, dispose] of created) this.disposers.set(name, dispose);
|
|
204
|
+
this.tools = next.slice(0, MAX_LISTED_TOOLS);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One harness tool definition bridging to `tools/call`. */
|
|
208
|
+
toolDefinition(publicName, tool) {
|
|
209
|
+
const self = this;
|
|
210
|
+
return {
|
|
211
|
+
name: publicName,
|
|
212
|
+
description: tool.description ?? "",
|
|
213
|
+
parameters: tool.inputSchema ?? { type: "object", properties: {} },
|
|
214
|
+
output: {
|
|
215
|
+
schema: {
|
|
216
|
+
type: "object",
|
|
217
|
+
properties: { content: { type: "array", items: {} } },
|
|
218
|
+
required: ["content"],
|
|
219
|
+
additionalProperties: false
|
|
220
|
+
},
|
|
221
|
+
render(_args, value) {
|
|
222
|
+
return [{ type: "text", text: contentText(value.content) || "(空结果)" }];
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
async execute(args, exec) {
|
|
226
|
+
if (!self.client) throw new Error("连接已断开");
|
|
227
|
+
const result = await self.client.callTool({ name: tool.name, arguments: args ?? {} }, { signal: exec.signal });
|
|
228
|
+
const content = projectableContent(result.content);
|
|
229
|
+
if (result.isError === true) throw new Error(contentText(result.content) || "MCP 工具返回错误");
|
|
230
|
+
return { content };
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Drop every registration this connection owns. */
|
|
236
|
+
disposeTools() {
|
|
237
|
+
for (const dispose of this.disposers.values()) {
|
|
238
|
+
try {
|
|
239
|
+
dispose();
|
|
240
|
+
} catch {
|
|
241
|
+
/* registry already gone */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
this.disposers.clear();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Disconnect and clear registrations. */
|
|
248
|
+
async stop() {
|
|
249
|
+
this.disposeTools();
|
|
250
|
+
const client = this.client;
|
|
251
|
+
this.client = null;
|
|
252
|
+
this.transport = null;
|
|
253
|
+
this.status = "disabled";
|
|
254
|
+
if (client) {
|
|
255
|
+
try {
|
|
256
|
+
await client.close();
|
|
257
|
+
} catch {
|
|
258
|
+
/* already closed */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Public snapshot (never includes secret values). */
|
|
264
|
+
snapshot() {
|
|
265
|
+
return {
|
|
266
|
+
id: this.record.id,
|
|
267
|
+
name: this.record.name,
|
|
268
|
+
enabled: this.record.enabled,
|
|
269
|
+
transport: this.record.transport,
|
|
270
|
+
url: this.record.url ?? "",
|
|
271
|
+
command: this.record.command ?? "",
|
|
272
|
+
args: this.record.args ?? [],
|
|
273
|
+
env: this.record.env ?? {},
|
|
274
|
+
cwd: this.record.cwd ?? "",
|
|
275
|
+
auth: {
|
|
276
|
+
mode: this.record.auth?.mode ?? "none",
|
|
277
|
+
scopes: this.record.auth?.scopes ?? "",
|
|
278
|
+
clientMode: this.record.auth?.clientMode ?? "auto",
|
|
279
|
+
oauth: this.record.auth?.mode === "oauth"
|
|
280
|
+
? new RecordOAuthProvider(this.record, async () => {}, {
|
|
281
|
+
redirectBase: this.record.auth?.oauth?.redirectBase ?? "",
|
|
282
|
+
scopes: this.record.auth?.scopes ?? "",
|
|
283
|
+
clientMode: this.record.auth?.clientMode ?? "auto",
|
|
284
|
+
clientId: this.record.auth?.clientId ?? "",
|
|
285
|
+
clientSecret: this.record.auth?.clientSecret ?? ""
|
|
286
|
+
}).summary()
|
|
287
|
+
: null,
|
|
288
|
+
params: (this.record.auth?.params ?? []).map((param) => ({
|
|
289
|
+
in: param.in,
|
|
290
|
+
name: param.name,
|
|
291
|
+
hasValue: param.value !== undefined && param.value !== "",
|
|
292
|
+
value: param.value === undefined || param.value === "" ? "" : "••••••"
|
|
293
|
+
}))
|
|
294
|
+
},
|
|
295
|
+
note: this.record.note ?? "",
|
|
296
|
+
status: this.status,
|
|
297
|
+
error: this.error,
|
|
298
|
+
toolCount: this.tools.length,
|
|
299
|
+
tools: this.tools
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Normalize a record coming from the browser or a servers.json import. */
|
|
305
|
+
function normalizeRecord(input, previous) {
|
|
306
|
+
const base = previous ?? {};
|
|
307
|
+
const transport = ["stdio", "http", "sse"].includes(input.transport) ? input.transport : base.transport ?? "http";
|
|
308
|
+
const id = normalizeId(input.id ?? base.id ?? input.name ?? randomUUID().slice(0, 8));
|
|
309
|
+
const params = Array.isArray(input.auth?.params)
|
|
310
|
+
? input.auth.params
|
|
311
|
+
.filter((param) => param && ["header", "query", "body"].includes(param.in) && typeof param.name === "string" && param.name !== "")
|
|
312
|
+
.map((param) => {
|
|
313
|
+
const masked = param.value === "••••••";
|
|
314
|
+
const old = (previous?.auth?.params ?? []).find((candidate) => candidate.in === param.in && candidate.name === param.name);
|
|
315
|
+
return { in: param.in, name: param.name, value: masked ? old?.value ?? "" : String(param.value ?? "") };
|
|
316
|
+
})
|
|
317
|
+
: base.auth?.params ?? [];
|
|
318
|
+
const clientMode = ["auto", "cimd", "manual"].includes(input.auth?.clientMode) ? input.auth.clientMode : base.auth?.clientMode ?? "auto";
|
|
319
|
+
const previousClientMode = base.auth?.clientMode ?? "auto";
|
|
320
|
+
const oauthState = input.auth?.oauth ?? base.auth?.oauth;
|
|
321
|
+
const authRecord = {
|
|
322
|
+
mode: ["api", "none", "oauth"].includes(input.auth?.mode) ? input.auth.mode : base.auth?.mode ?? "none",
|
|
323
|
+
scopes: String(input.auth?.scopes ?? base.auth?.scopes ?? ""),
|
|
324
|
+
clientMode,
|
|
325
|
+
clientId: String(input.auth?.clientId ?? base.auth?.clientId ?? ""),
|
|
326
|
+
clientSecret: input.auth?.clientSecret === "••••••" ? base.auth?.clientSecret ?? "" : String(input.auth?.clientSecret ?? base.auth?.clientSecret ?? ""),
|
|
327
|
+
// A client registration belongs to the way it was obtained (DCR id vs CIMD
|
|
328
|
+
// metadata URL); switching modes must drop it.
|
|
329
|
+
oauth: clientMode === previousClientMode || oauthState === undefined ? oauthState : { ...oauthState, clientInformation: undefined },
|
|
330
|
+
params
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
id,
|
|
335
|
+
name: String(input.name ?? base.name ?? id),
|
|
336
|
+
enabled: input.enabled === undefined ? base.enabled ?? false : input.enabled === true,
|
|
337
|
+
transport,
|
|
338
|
+
url: String(input.url ?? base.url ?? "").trim(),
|
|
339
|
+
command: String(input.command ?? base.command ?? "").trim(),
|
|
340
|
+
args: Array.isArray(input.args) ? input.args.map(String) : base.args ?? [],
|
|
341
|
+
env: input.env && typeof input.env === "object" ? input.env : base.env ?? {},
|
|
342
|
+
cwd: String(input.cwd ?? base.cwd ?? ""),
|
|
343
|
+
headers: input.headers && typeof input.headers === "object" ? input.headers : base.headers ?? {},
|
|
344
|
+
auth: authRecord,
|
|
345
|
+
note: String(input.note ?? base.note ?? "")
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Convert a Claude-Code style `{"mcpServers": {...}}` document into records.
|
|
351
|
+
* @param raw - parsed JSON (or the servers map itself).
|
|
352
|
+
* @returns records ready for the store.
|
|
353
|
+
*/
|
|
354
|
+
function fromServersJson(raw) {
|
|
355
|
+
const servers = raw && typeof raw === "object" && raw.mcpServers && typeof raw.mcpServers === "object" ? raw.mcpServers : raw;
|
|
356
|
+
if (!servers || typeof servers !== "object") throw new Error('servers.json 需要形如 {"mcpServers": {...}}');
|
|
357
|
+
const out = [];
|
|
358
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
359
|
+
if (!spec || typeof spec !== "object") continue;
|
|
360
|
+
const type = typeof spec.type === "string" ? spec.type : spec.command ? "stdio" : "http";
|
|
361
|
+
const headers = spec.headers && typeof spec.headers === "object" ? spec.headers : {};
|
|
362
|
+
const params = Object.entries(headers).map(([header, value]) => ({ in: "header", name: String(header), value: String(value ?? "") }));
|
|
363
|
+
out.push({
|
|
364
|
+
id: normalizeId(name),
|
|
365
|
+
name: String(name),
|
|
366
|
+
enabled: false,
|
|
367
|
+
transport: type === "stdio" ? "stdio" : type === "sse" ? "sse" : "http",
|
|
368
|
+
url: String(spec.url ?? ""),
|
|
369
|
+
command: String(spec.command ?? ""),
|
|
370
|
+
args: Array.isArray(spec.args) ? spec.args.map(String) : [],
|
|
371
|
+
env: spec.env && typeof spec.env === "object" ? spec.env : {},
|
|
372
|
+
cwd: String(spec.cwd ?? ""),
|
|
373
|
+
headers: {},
|
|
374
|
+
auth: { mode: params.length > 0 ? "api" : "none", params },
|
|
375
|
+
note: ""
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Read a JSON request body with a size cap. */
|
|
382
|
+
async function readJson(req) {
|
|
383
|
+
const chunks = [];
|
|
384
|
+
let bytes = 0;
|
|
385
|
+
for await (const chunk of req) {
|
|
386
|
+
bytes += chunk.length;
|
|
387
|
+
if (bytes > MAX_BODY_BYTES) throw new Error("请求体过大");
|
|
388
|
+
chunks.push(chunk);
|
|
389
|
+
}
|
|
390
|
+
if (chunks.length === 0) return {};
|
|
391
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Send a small HTML page (the OAuth callback lands in a browser tab). */
|
|
395
|
+
function sendHtml(res, status, message, script) {
|
|
396
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
397
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>DSH 连接应用</title><style>body{font:14px/1.6 system-ui,sans-serif;margin:48px auto;max-width:520px;padding:0 20px;color:#222}code{background:#eee;padding:1px 5px;border-radius:4px}</style><p>${message}</p>${script ?? ""}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Send a JSON response. */
|
|
401
|
+
function send(res, status, value) {
|
|
402
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
403
|
+
res.end(JSON.stringify(value));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Register the workbench contract as a system-prompt section, and run the
|
|
408
|
+
* connector: store, live connections, HTTP API.
|
|
409
|
+
* @param ctx - host root context.
|
|
410
|
+
*/
|
|
411
|
+
export function apply(ctx) {
|
|
412
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
413
|
+
name: "mcp-apps:workbench",
|
|
414
|
+
order: ctx.systemPrompt.getSectionOrder("MCP_SERVERS") + 1,
|
|
415
|
+
text: [
|
|
416
|
+
"右侧「工作台」(MCP Apps)规则:",
|
|
417
|
+
"带交互工作台的工具(MCP 服务器声明了 ui:// 资源的工具)调用完成后,宿主会自动把可交互界面开在会话右侧的工作台里,聊天里的工具行也会标记【已开启工作台】。",
|
|
418
|
+
"1. 这些工具成功后,正文只写一行【已开启工作台】,不要复述金额、余额、账目明细或表格。",
|
|
419
|
+
"2. 用户要查看或修改数据时,请他在右侧工作台里操作,不要代读或代改数据。",
|
|
420
|
+
"3. 仅当用户明确要求「把结果写在对话里」时,才贴出必要数字。",
|
|
421
|
+
"4. 工具返回 isError 时按普通错误处理,正常说明失败原因。"
|
|
422
|
+
].join("\n")
|
|
423
|
+
}), "mcp-apps.workbench-section");
|
|
424
|
+
|
|
425
|
+
const connections = new Map();
|
|
426
|
+
let queue = Promise.resolve();
|
|
427
|
+
|
|
428
|
+
/** Serialize store mutations and live-connection changes. */
|
|
429
|
+
const serial = (task) => {
|
|
430
|
+
const next = queue.then(task, task);
|
|
431
|
+
queue = next.catch(() => undefined);
|
|
432
|
+
return next;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/** Persist records atomically with owner-only permissions. */
|
|
436
|
+
async function saveStore(records) {
|
|
437
|
+
const file = storePath();
|
|
438
|
+
await mkdir(join(dshHome(), "mcp-apps"), { recursive: true });
|
|
439
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
440
|
+
await writeFile(temp, JSON.stringify({ version: 1, connections: records }, null, 2) + "\n", "utf8");
|
|
441
|
+
await chmod(temp, 0o600);
|
|
442
|
+
await rename(temp, file);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Persist whatever the live records currently are (OAuth provider callback). */
|
|
446
|
+
async function persistAll() {
|
|
447
|
+
await saveStore([...connections.values()].map((entry) => entry.record));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Read the persisted store, seeding the mcp-stuxx example on first run. */
|
|
451
|
+
async function loadStore() {
|
|
452
|
+
const file = storePath();
|
|
453
|
+
try {
|
|
454
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
455
|
+
if (parsed && Array.isArray(parsed.connections)) return parsed.connections;
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error.code !== "ENOENT") ctx.logger.warn(`mcp-apps: 读取连接配置失败 ${String(error)}`);
|
|
458
|
+
}
|
|
459
|
+
const seeded = [{
|
|
460
|
+
id: "mcpstuxx",
|
|
461
|
+
name: "mcp-stuxx(示例)",
|
|
462
|
+
enabled: false,
|
|
463
|
+
transport: "http",
|
|
464
|
+
url: "https://mcp.stuxx.cn/mcp",
|
|
465
|
+
command: "",
|
|
466
|
+
args: [],
|
|
467
|
+
env: {},
|
|
468
|
+
cwd: "",
|
|
469
|
+
headers: {},
|
|
470
|
+
auth: { mode: "api", params: [{ in: "header", name: "Authorization", value: "" }] },
|
|
471
|
+
note: "示例:填入 Bearer token 后启用。连接 id 取 mcpstuxx,工具名与内置 mcp-client 一致(mcp__mcpstuxx__*),并额外带上 ui:// 元数据。"
|
|
472
|
+
}];
|
|
473
|
+
await saveStore(seeded);
|
|
474
|
+
return seeded;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Live connections, loaded from the store on first use. */
|
|
478
|
+
async function currentRecords() {
|
|
479
|
+
if (connections.size === 0) {
|
|
480
|
+
for (const record of await loadStore()) connections.set(record.id, new Connection(record, ctx, () => persistAll()));
|
|
481
|
+
}
|
|
482
|
+
return [...connections.values()];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Start every enabled connection, tolerating individual failures. */
|
|
486
|
+
async function bootConnections() {
|
|
487
|
+
for (const connection of await currentRecords()) {
|
|
488
|
+
if (!connection.record.enabled) continue;
|
|
489
|
+
try {
|
|
490
|
+
await connection.start();
|
|
491
|
+
ctx.logger.info(`mcp-apps(${connection.record.id}): ${connection.tools.length} 个工具已注册`);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
ctx.logger.warn(`mcp-apps(${connection.record.id}): 连接失败 ${String(error)}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const boot = bootConnections().catch((error) => ctx.logger.warn(`mcp-apps: 启动连接失败 ${String(error)}`));
|
|
499
|
+
ctx.effect(() => () => {
|
|
500
|
+
for (const connection of connections.values()) void connection.stop();
|
|
501
|
+
connections.clear();
|
|
502
|
+
}, "mcp-apps.connections");
|
|
503
|
+
|
|
504
|
+
/** Find one live connection or throw a message the page can show. */
|
|
505
|
+
async function requireConnection(id) {
|
|
506
|
+
await boot;
|
|
507
|
+
const connection = (await currentRecords()).find((candidate) => candidate.record.id === id);
|
|
508
|
+
if (connection === undefined) throw new Error(`未知连接:${id}`);
|
|
509
|
+
return connection;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Apply a record update: persist, then start/stop the live connection. */
|
|
513
|
+
async function upsert(record) {
|
|
514
|
+
await currentRecords();
|
|
515
|
+
const existing = connections.get(record.id);
|
|
516
|
+
if (existing !== undefined) await existing.stop();
|
|
517
|
+
const connection = new Connection(record, ctx, () => persistAll());
|
|
518
|
+
connections.set(record.id, connection);
|
|
519
|
+
await saveStore([...connections.values()].map((entry) => entry.record));
|
|
520
|
+
if (record.enabled) {
|
|
521
|
+
try {
|
|
522
|
+
await connection.start();
|
|
523
|
+
} catch (error) {
|
|
524
|
+
connection.error = error instanceof Error ? error.message : String(error);
|
|
525
|
+
const httpStatus = typeof error?.status === "number" ? error.status : undefined;
|
|
526
|
+
connection.status = httpStatus === 401 || /unauthoriz|\b401\b/i.test(connection.error) ? "needs_auth" : "error";
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return connection.snapshot();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
533
|
+
const trusted = (req) => {
|
|
534
|
+
const remote = req.socket?.remoteAddress ?? "";
|
|
535
|
+
return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
536
|
+
};
|
|
537
|
+
/** Browser requests must carry the harness session cookie; loopback may skip it. */
|
|
538
|
+
const authorized = (req) => {
|
|
539
|
+
if (trusted(req)) return true;
|
|
540
|
+
const service = webCtx.get("client-connection");
|
|
541
|
+
if (service === undefined) return false;
|
|
542
|
+
const secure = webCtx.webServer.protocol === "https:";
|
|
543
|
+
try {
|
|
544
|
+
return service.isAuthenticated(req, secure) || service.isAuthenticated(req, true) || service.isAuthenticated(req, false);
|
|
545
|
+
} catch {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const handler = async (req, res) => {
|
|
551
|
+
if (!authorized(req)) {
|
|
552
|
+
res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
553
|
+
res.end("dsh web authentication required\n");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const url = new URL(req.url ?? "/", "http://dsh.invalid");
|
|
557
|
+
const path = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, "") || "/";
|
|
558
|
+
if (req.method === "GET" && url.pathname === CLIENT_METADATA_PATH) {
|
|
559
|
+
const origin = redirectBaseFrom(req, "http://127.0.0.1").base;
|
|
560
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "max-age=60", "access-control-allow-origin": "*" });
|
|
561
|
+
return res.end(JSON.stringify(clientMetadataDocument(origin), null, 2));
|
|
562
|
+
}
|
|
563
|
+
if (req.method === "GET" && url.pathname === CALLBACK_PATH) {
|
|
564
|
+
const state = url.searchParams.get("state") ?? "";
|
|
565
|
+
const code = url.searchParams.get("code") ?? "";
|
|
566
|
+
const refused = url.searchParams.get("error");
|
|
567
|
+
const target = (await currentRecords()).find((candidate) => candidate.record.auth?.oauth?.state === state && state !== "");
|
|
568
|
+
if (target === undefined) return sendHtml(res, 400, "无效的授权回调:state 不匹配。请回到「连接应用」重新发起授权。");
|
|
569
|
+
if (refused !== null) return sendHtml(res, 400, `授权被拒绝:<code>${refused.replace(/[<>&]/g, "")}</code>`);
|
|
570
|
+
if (code === "") return sendHtml(res, 400, "授权回调缺少 code。");
|
|
571
|
+
try {
|
|
572
|
+
const provider = target.oauthProvider();
|
|
573
|
+
await auth(provider, {
|
|
574
|
+
serverUrl: target.record.url,
|
|
575
|
+
authorizationCode: code,
|
|
576
|
+
iss: url.searchParams.get("iss") ?? undefined,
|
|
577
|
+
scope: target.record.auth?.scopes || undefined
|
|
578
|
+
});
|
|
579
|
+
await target.stop();
|
|
580
|
+
if (target.record.enabled) await target.start();
|
|
581
|
+
return sendHtml(res, 200, "授权完成,可以关闭此窗口。", "<script>try{window.opener&&window.opener.postMessage('mcp-apps-oauth-done','*')}catch(e){};setTimeout(function(){window.close()},800)</script>");
|
|
582
|
+
} catch (error) {
|
|
583
|
+
return sendHtml(res, 500, `授权失败:<code>${String(error instanceof Error ? error.message : error).replace(/[<>&]/g, "")}</code>`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
if (req.method === "GET" && path === "/state") {
|
|
588
|
+
const list = [];
|
|
589
|
+
for (const connection of await currentRecords()) list.push(connection.snapshot());
|
|
590
|
+
return send(res, 200, { ok: true, connections: list });
|
|
591
|
+
}
|
|
592
|
+
if (req.method === "POST" && path === "/import") {
|
|
593
|
+
const body = await readJson(req);
|
|
594
|
+
const records = fromServersJson(typeof body.json === "string" ? JSON.parse(body.json) : body.servers);
|
|
595
|
+
const saved = [];
|
|
596
|
+
for (const record of records) saved.push(await serial(() => upsert(record)));
|
|
597
|
+
return send(res, 200, { ok: true, imported: saved.length, connections: saved });
|
|
598
|
+
}
|
|
599
|
+
if (req.method === "POST" && path === "/connections") {
|
|
600
|
+
const body = await readJson(req);
|
|
601
|
+
return send(res, 200, { ok: true, connection: await serial(() => upsert(normalizeRecord(body.record ?? body))) });
|
|
602
|
+
}
|
|
603
|
+
const match = /^\/connections\/([^/]+)(?:\/(enabled|test|oauth-start|oauth-reset))?$/.exec(path);
|
|
604
|
+
if (match) {
|
|
605
|
+
const connection = await requireConnection(decodeURIComponent(match[1]));
|
|
606
|
+
const action = match[2];
|
|
607
|
+
if (req.method === "PUT" && action === undefined) {
|
|
608
|
+
const body = await readJson(req);
|
|
609
|
+
return send(res, 200, { ok: true, connection: await serial(() => upsert(normalizeRecord(body.record ?? body, connection.record))) });
|
|
610
|
+
}
|
|
611
|
+
if (req.method === "DELETE" && action === undefined) {
|
|
612
|
+
await serial(async () => {
|
|
613
|
+
await connection.stop();
|
|
614
|
+
connections.delete(connection.record.id);
|
|
615
|
+
await saveStore([...connections.values()].map((entry) => entry.record));
|
|
616
|
+
});
|
|
617
|
+
return send(res, 200, { ok: true });
|
|
618
|
+
}
|
|
619
|
+
if (req.method === "POST" && action === "enabled") {
|
|
620
|
+
const body = await readJson(req);
|
|
621
|
+
return send(res, 200, { ok: true, connection: await serial(() => upsert({ ...connection.record, enabled: body.enabled === true })) });
|
|
622
|
+
}
|
|
623
|
+
if (req.method === "POST" && action === "test") {
|
|
624
|
+
const probe = new Connection({ ...connection.record, enabled: true }, ctx, () => persistAll());
|
|
625
|
+
try {
|
|
626
|
+
const client = new Client({ name: "dsh-mcp-apps-test", version: "0.5.7" }, { capabilities: {} });
|
|
627
|
+
const transport = probe.createTransport();
|
|
628
|
+
await client.connect(transport);
|
|
629
|
+
const listed = client.getServerCapabilities?.()?.tools === undefined ? { tools: [] } : await client.listTools(undefined, { cacheMode: "refresh" });
|
|
630
|
+
await client.close();
|
|
631
|
+
return send(res, 200, {
|
|
632
|
+
ok: true,
|
|
633
|
+
tools: (listed.tools ?? []).map((tool) => ({ name: tool.name, description: tool.description ?? "", resourceUri: tool._meta?.ui?.resourceUri }))
|
|
634
|
+
});
|
|
635
|
+
} catch (error) {
|
|
636
|
+
return send(res, 200, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (req.method === "POST" && action === "oauth-start") {
|
|
640
|
+
const body = await readJson(req);
|
|
641
|
+
const origin = redirectBaseFrom(req, "http://127.0.0.1");
|
|
642
|
+
const base = typeof body.redirectBase === "string" && body.redirectBase !== "" ? body.redirectBase.replace(/\/+$/, "") : origin.base;
|
|
643
|
+
const scopes = typeof body.scopes === "string" ? body.scopes.trim() : connection.record.auth?.scopes ?? "";
|
|
644
|
+
connection.record.auth = connection.record.auth ?? { mode: "oauth", params: [] };
|
|
645
|
+
connection.record.auth.mode = "oauth";
|
|
646
|
+
connection.record.auth.scopes = scopes;
|
|
647
|
+
if (connection.record.auth.clientMode === "cimd" && !/^https:/i.test(base)) {
|
|
648
|
+
return send(res, 200, { ok: false, error: "CIMD 需要 HTTPS 访问地址:请用 https:// 域名打开本页再做授权" });
|
|
649
|
+
}
|
|
650
|
+
connection.record.auth.oauth = { ...(connection.record.auth.oauth ?? {}), redirectBase: base };
|
|
651
|
+
await persistAll();
|
|
652
|
+
const provider = connection.oauthProvider();
|
|
653
|
+
try {
|
|
654
|
+
const outcome = await auth(provider, { serverUrl: connection.record.url, scope: scopes || undefined });
|
|
655
|
+
if (outcome === "AUTHORIZED") {
|
|
656
|
+
await connection.stop();
|
|
657
|
+
if (connection.record.enabled) await connection.start();
|
|
658
|
+
return send(res, 200, { ok: true, status: "AUTHORIZED", connection: connection.snapshot() });
|
|
659
|
+
}
|
|
660
|
+
return send(res, 200, {
|
|
661
|
+
ok: true,
|
|
662
|
+
status: "REDIRECT",
|
|
663
|
+
authorizationUrl: connection.record.auth.oauth.authorizationUrl ?? "",
|
|
664
|
+
redirectUrl: `${base}${CALLBACK_PATH}`
|
|
665
|
+
});
|
|
666
|
+
} catch (error) {
|
|
667
|
+
return send(res, 200, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (req.method === "POST" && action === "oauth-reset") {
|
|
671
|
+
await connection.stop();
|
|
672
|
+
clearOAuth(connection.record);
|
|
673
|
+
await persistAll();
|
|
674
|
+
return send(res, 200, { ok: true, connection: connection.snapshot() });
|
|
675
|
+
}
|
|
676
|
+
return send(res, 405, { ok: false, error: "不支持的请求" });
|
|
677
|
+
}
|
|
678
|
+
if (req.method === "POST" && path === "/rpc") {
|
|
679
|
+
const body = await readJson(req);
|
|
680
|
+
const connection = await requireConnection(String(body.connectionId ?? ""));
|
|
681
|
+
if (!connection.client) throw new Error("连接未启用");
|
|
682
|
+
if (body.method === "tools/call") {
|
|
683
|
+
const result = await connection.client.callTool({ name: body.params?.name, arguments: body.params?.arguments ?? {} });
|
|
684
|
+
return send(res, 200, { ok: true, result });
|
|
685
|
+
}
|
|
686
|
+
if (body.method === "resources/read") {
|
|
687
|
+
const result = await connection.client.readResource({ uri: body.params?.uri });
|
|
688
|
+
return send(res, 200, { ok: true, result });
|
|
689
|
+
}
|
|
690
|
+
if (body.method === "tools/list") {
|
|
691
|
+
return send(res, 200, { ok: true, result: { tools: connection.tools } });
|
|
692
|
+
}
|
|
693
|
+
return send(res, 400, { ok: false, error: `不支持的 RPC:${String(body.method)}` });
|
|
694
|
+
}
|
|
695
|
+
return send(res, 404, { ok: false, error: "未知接口" });
|
|
696
|
+
} catch (error) {
|
|
697
|
+
return send(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
webCtx.effect(() => webCtx.webServer.register({ kind: "prefix", path: API_PREFIX, handler }), "mcp-apps.api");
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Host half services: tool registry and prompt registry. */
|
|
706
|
+
export const inject = ["tools", "systemPrompt"];
|