@zhanguym/chatroom 1.2.0-beta.0 → 1.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/application.d.ts +2 -0
- package/dist/app/application.js +7 -1
- package/dist/app/lifecycle.js +6 -0
- package/dist/auth/auth-service.js +5 -1
- package/dist/infrastructure/database/custom-mcp-repository.d.ts +9 -0
- package/dist/infrastructure/database/custom-mcp-repository.js +37 -0
- package/dist/infrastructure/database/mcp-tool-settings-repository.d.ts +1 -0
- package/dist/infrastructure/database/mcp-tool-settings-repository.js +6 -0
- package/dist/infrastructure/database/schema.d.ts +1 -1
- package/dist/infrastructure/database/schema.js +9 -0
- package/dist/mcp/custom/manager.d.ts +54 -0
- package/dist/mcp/custom/manager.js +494 -0
- package/dist/mcp/custom/oauth.d.ts +38 -0
- package/dist/mcp/custom/oauth.js +116 -0
- package/dist/mcp/custom/types.d.ts +42 -0
- package/dist/mcp/custom/types.js +1 -0
- package/dist/mcp/server/create-mcp-server.d.ts +2 -1
- package/dist/mcp/server/create-mcp-server.js +4 -3
- package/dist/mcp/server/tool-control.d.ts +5 -0
- package/dist/mcp/server/tool-control.js +17 -0
- package/dist/native/linux/chatroom-computer-helper +0 -0
- package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
- package/dist/native/windows/chatroom-computer-helper.exe +0 -0
- package/dist/plugins/computer/computer-schemas.d.ts +8 -8
- package/dist/plugins/types.d.ts +2 -0
- package/dist/plugins/web/api-types.d.ts +2 -0
- package/dist/plugins/web/http/api-router.js +3 -0
- package/dist/plugins/web/http/custom-mcp-api-router.d.ts +6 -0
- package/dist/plugins/web/http/custom-mcp-api-router.js +114 -0
- package/dist/plugins/web/plugin.js +1 -1
- package/dist/plugins/web/runtime.d.ts +3 -1
- package/dist/plugins/web/runtime.js +3 -1
- package/dist/web/assets/{AgentView-DQr-5aLK.js → AgentView-Chyr0duc.js} +3 -3
- package/dist/web/assets/{CloudView-B2VBut3O.js → CloudView-DN3gdtln.js} +1 -1
- package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-B3tNL1LG.js +3 -0
- package/dist/web/assets/{ComputerView-CIFF1zUb.js → ComputerView-BQsCJDj6.js} +1 -1
- package/dist/web/assets/ComputerView-Bz2Wsz2X.css +1 -0
- package/dist/web/assets/{MarkdownContent-DRZp88_G.js → MarkdownContent-Cs2dEsGL.js} +1 -1
- package/dist/web/assets/McpToolsView-DEihWplq.js +2 -0
- package/dist/web/assets/McpToolsView-DdYOoY9_.css +1 -0
- package/dist/web/assets/{OperationsView-B_Nv1Mgi.js → OperationsView-xUzS2M6J.js} +2 -2
- package/dist/web/assets/{ProcessesView-5K8wNiPC.js → ProcessesView-BZJE6ibM.js} +2 -2
- package/dist/web/assets/{StateChip.vue_vue_type_script_setup_true_lang-CQEj-YRM.js → StateChip.vue_vue_type_script_setup_true_lang-DIF7HtT8.js} +1 -1
- package/dist/web/assets/SystemLogsView-BHNuvcuf.js +1 -0
- package/dist/web/assets/{TerminalView-DNaKG3II.css → TerminalView-B1cLrRJ3.css} +1 -1
- package/dist/web/assets/{TerminalView-Dvyy2GKF.js → TerminalView-DynA5Y2x.js} +10 -10
- package/dist/web/assets/VSelect-EZ3BvG23.js +1 -0
- package/dist/web/assets/VSheet-DQI4RXKJ.js +1 -0
- package/dist/web/assets/VSpacer-CYKfdiFh.js +1 -0
- package/dist/web/assets/{VSwitch-CvQYU48e.js → VSwitch-C-NDv5Ol.js} +1 -1
- package/dist/web/assets/{VTabs-CftrBFlF.js → VTabs-Cd28-4lH.js} +1 -1
- package/dist/web/assets/{VTextarea-Dcui00tn.js → VTextarea-j4nd1i82.js} +1 -1
- package/dist/web/assets/WorkspaceDownloadPreview-CT8fPWGZ.js +1 -0
- package/dist/web/assets/{WorkspaceMediaPreview-ctIyG3Ru.js → WorkspaceMediaPreview-BYfyE2rF.js} +3 -3
- package/dist/web/assets/{WorkspacePdfPreview-CeizxEAH.js → WorkspacePdfPreview-Cn1Z4mrW.js} +1 -1
- package/dist/web/assets/{WorkspacesView-DcmqHGvr.js → WorkspacesView-CHos0Dy0.js} +2 -2
- package/dist/web/assets/{index-BRI_re-n.css → index-BCUxnASh.css} +1 -1
- package/dist/web/assets/index-CR4g0KyE.js +19 -0
- package/dist/web/assets/{useMasterDetailFocus-DR5N95_A.js → useMasterDetailFocus-D_0RtiVd.js} +1 -1
- package/dist/web/assets/useOperationLabels-a5jWmoTY.js +1 -0
- package/dist/web/index.html +2 -2
- package/dist/web/transport-sw.js +6 -0
- package/package.json +2 -1
- package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-DkN8x_6L.js +0 -3
- package/dist/web/assets/ComputerView-DWzfSYJ7.css +0 -1
- package/dist/web/assets/McpToolsView-CSEru3ck.js +0 -1
- package/dist/web/assets/McpToolsView-DjxPbTHj.css +0 -1
- package/dist/web/assets/SystemLogsView-CBINILPo.js +0 -1
- package/dist/web/assets/VSelect-CBX17QQj.js +0 -1
- package/dist/web/assets/VSpacer-9N_Ffoce.js +0 -1
- package/dist/web/assets/WorkspaceDownloadPreview-DzM2p_xl.js +0 -1
- package/dist/web/assets/index-DTyxBHlJ.js +0 -19
- package/dist/web/assets/useOperationLabels-Dou4m0ac.js +0 -1
- /package/dist/web/assets/{CodeViewer-CH1DEJge.css → VSheet-CH1DEJge.css} +0 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { Client, StreamableHTTPClientTransport, UnauthorizedError, SdkHttpError, } from "@modelcontextprotocol/client";
|
|
3
|
+
import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/client/stdio";
|
|
4
|
+
import { fromJsonSchema } from "@modelcontextprotocol/server";
|
|
5
|
+
import { ChatRoomError } from "#core/errors/chatroom-error";
|
|
6
|
+
import { CustomMcpOAuthProvider, CustomMcpOAuthStore } from "./oauth.js";
|
|
7
|
+
const CONNECT_TIMEOUT = 10000;
|
|
8
|
+
const TOOL_TIMEOUT = 60000;
|
|
9
|
+
async function withConnectTimeout(promise) {
|
|
10
|
+
let timeout = null;
|
|
11
|
+
try {
|
|
12
|
+
return await Promise.race([
|
|
13
|
+
promise,
|
|
14
|
+
new Promise((_, reject) => {
|
|
15
|
+
timeout = setTimeout(() => reject(new Error("MCP connection timed out")), CONNECT_TIMEOUT);
|
|
16
|
+
timeout.unref();
|
|
17
|
+
}),
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
if (timeout)
|
|
22
|
+
clearTimeout(timeout);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function isAuthenticationError(error) {
|
|
26
|
+
return (error instanceof UnauthorizedError ||
|
|
27
|
+
(error instanceof SdkHttpError && error.status === 401));
|
|
28
|
+
}
|
|
29
|
+
const toolId = (id, name) => "custom_" + id + "__" + name;
|
|
30
|
+
function validate(config) {
|
|
31
|
+
if (!config.name?.trim() || config.name.length > 100)
|
|
32
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid MCP server name");
|
|
33
|
+
if (config.type === "stdio") {
|
|
34
|
+
if (!config.command?.trim())
|
|
35
|
+
throw new ChatRoomError("INVALID_INPUT", "Missing executable");
|
|
36
|
+
if (config.args &&
|
|
37
|
+
(!Array.isArray(config.args) ||
|
|
38
|
+
config.args.some((value) => typeof value !== "string")))
|
|
39
|
+
throw new ChatRoomError("INVALID_INPUT", "args must be an array of strings");
|
|
40
|
+
}
|
|
41
|
+
else if (config.type === "http") {
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(config.url ?? "");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid MCP URL");
|
|
48
|
+
}
|
|
49
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
50
|
+
url.username ||
|
|
51
|
+
url.password)
|
|
52
|
+
throw new ChatRoomError("INVALID_INPUT", "Unsupported MCP URL");
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
throw new ChatRoomError("INVALID_INPUT", "Unsupported MCP transport");
|
|
56
|
+
for (const values of [config.env, config.headers]) {
|
|
57
|
+
if (values &&
|
|
58
|
+
(typeof values !== "object" ||
|
|
59
|
+
Array.isArray(values) ||
|
|
60
|
+
Object.entries(values).some(([key, value]) => !key.trim() || typeof value !== "string")))
|
|
61
|
+
throw new ChatRoomError("INVALID_INPUT", "Environment variables and headers must be string maps");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function newEntry(config) {
|
|
65
|
+
return {
|
|
66
|
+
config,
|
|
67
|
+
tools: [],
|
|
68
|
+
client: null,
|
|
69
|
+
status: config.enabled ? "connecting" : "disabled",
|
|
70
|
+
error: null,
|
|
71
|
+
pending: null,
|
|
72
|
+
retry: null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export class CustomMcpManager {
|
|
76
|
+
repository;
|
|
77
|
+
control;
|
|
78
|
+
operations;
|
|
79
|
+
oauth;
|
|
80
|
+
authorizationFlows = new Map();
|
|
81
|
+
entries = new Map();
|
|
82
|
+
closed = false;
|
|
83
|
+
constructor(repository, control, operations, dataDir) {
|
|
84
|
+
this.repository = repository;
|
|
85
|
+
this.control = control;
|
|
86
|
+
this.operations = operations;
|
|
87
|
+
this.oauth = dataDir ? new CustomMcpOAuthStore(dataDir) : null;
|
|
88
|
+
for (const config of repository.list())
|
|
89
|
+
this.entries.set(config.id, newEntry(config));
|
|
90
|
+
}
|
|
91
|
+
start() {
|
|
92
|
+
for (const entry of this.entries.values())
|
|
93
|
+
if (entry.config.enabled)
|
|
94
|
+
void this.connect(entry);
|
|
95
|
+
}
|
|
96
|
+
list() {
|
|
97
|
+
return [...this.entries.values()].map((entry) => ({
|
|
98
|
+
id: entry.config.id,
|
|
99
|
+
name: entry.config.name,
|
|
100
|
+
type: entry.config.type,
|
|
101
|
+
...(entry.config.command ? { command: entry.config.command } : {}),
|
|
102
|
+
...(entry.config.args ? { args: entry.config.args } : {}),
|
|
103
|
+
...(entry.config.cwd ? { cwd: entry.config.cwd } : {}),
|
|
104
|
+
...(entry.config.url ? { url: entry.config.url } : {}),
|
|
105
|
+
envKeys: Object.keys(entry.config.env ?? {}),
|
|
106
|
+
headerKeys: Object.keys(entry.config.headers ?? {}),
|
|
107
|
+
enabled: entry.config.enabled,
|
|
108
|
+
authMode: this.authMode(entry.config),
|
|
109
|
+
authorized: Boolean(this.oauth &&
|
|
110
|
+
entry.config.type === "http" &&
|
|
111
|
+
this.oauth.load(entry.config.id).tokens),
|
|
112
|
+
status: entry.status,
|
|
113
|
+
error: entry.error,
|
|
114
|
+
tools: entry.tools.map((tool) => ({
|
|
115
|
+
name: toolId(entry.config.id, tool.name),
|
|
116
|
+
title: tool.title ?? tool.name,
|
|
117
|
+
description: tool.description ?? "",
|
|
118
|
+
enabled: this.control.isEnabled(toolId(entry.config.id, tool.name)),
|
|
119
|
+
})),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
async create(input) {
|
|
123
|
+
const config = { ...input, id: randomUUID().replaceAll("-", "") };
|
|
124
|
+
validate(config);
|
|
125
|
+
const entry = newEntry(config);
|
|
126
|
+
this.repository.save(config);
|
|
127
|
+
this.entries.set(config.id, entry);
|
|
128
|
+
if (config.enabled)
|
|
129
|
+
await this.connect(entry);
|
|
130
|
+
return this.summary(config.id);
|
|
131
|
+
}
|
|
132
|
+
async update(id, input) {
|
|
133
|
+
const previous = this.require(id);
|
|
134
|
+
const config = { ...previous.config, ...input, id };
|
|
135
|
+
validate(config);
|
|
136
|
+
const entry = newEntry(config);
|
|
137
|
+
if (previous.config.url !== config.url ||
|
|
138
|
+
previous.config.type !== config.type ||
|
|
139
|
+
this.authMode(previous.config) !== this.authMode(config)) {
|
|
140
|
+
this.dropPendingAuthorization(id);
|
|
141
|
+
this.oauth?.clear(id);
|
|
142
|
+
}
|
|
143
|
+
this.repository.save(config);
|
|
144
|
+
this.entries.set(id, entry);
|
|
145
|
+
await this.dispose(previous);
|
|
146
|
+
if (config.enabled)
|
|
147
|
+
await this.connect(entry);
|
|
148
|
+
return this.summary(id);
|
|
149
|
+
}
|
|
150
|
+
async remove(id) {
|
|
151
|
+
const entry = this.require(id);
|
|
152
|
+
this.entries.delete(id);
|
|
153
|
+
this.repository.delete(id);
|
|
154
|
+
this.dropPendingAuthorization(id);
|
|
155
|
+
this.oauth?.clear(id);
|
|
156
|
+
await this.dispose(entry);
|
|
157
|
+
}
|
|
158
|
+
async refresh(id) {
|
|
159
|
+
const entry = this.require(id);
|
|
160
|
+
if (!entry.config.enabled)
|
|
161
|
+
throw new ChatRoomError("INVALID_INPUT", "Enable the MCP server before refreshing");
|
|
162
|
+
return this.update(id, {});
|
|
163
|
+
}
|
|
164
|
+
async test(input) {
|
|
165
|
+
// Test connections are ephemeral, but OAuthStore requires a valid server ID.
|
|
166
|
+
// Never persist a temporary test entry or its credentials.
|
|
167
|
+
const config = { ...input, id: randomUUID().replaceAll("-", "") };
|
|
168
|
+
validate(config);
|
|
169
|
+
return this.probe(config);
|
|
170
|
+
}
|
|
171
|
+
async testStored(id) {
|
|
172
|
+
const entry = this.require(id);
|
|
173
|
+
if (entry.status === "connected")
|
|
174
|
+
return { tools: entry.tools.length };
|
|
175
|
+
return this.probe(entry.config);
|
|
176
|
+
}
|
|
177
|
+
async probe(config) {
|
|
178
|
+
const client = new Client({ name: "chatroom-test", version: "1.0.0" });
|
|
179
|
+
try {
|
|
180
|
+
await withConnectTimeout(client.connect(this.transport(config)));
|
|
181
|
+
const { tools } = await client.listTools(undefined, {
|
|
182
|
+
timeout: CONNECT_TIMEOUT,
|
|
183
|
+
});
|
|
184
|
+
return { tools: tools.length };
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (config.type === "http" &&
|
|
188
|
+
this.authMode(config) === "auto" &&
|
|
189
|
+
isAuthenticationError(error))
|
|
190
|
+
return { tools: 0, authRequired: true };
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await client.close().catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Start an interactive OAuth flow for an existing HTTP MCP server.
|
|
199
|
+
* The callback state is single-use and never exposed to the browser.
|
|
200
|
+
*/
|
|
201
|
+
async authorize(id, redirectUrl) {
|
|
202
|
+
const entry = this.require(id);
|
|
203
|
+
if (entry.config.type !== "http" ||
|
|
204
|
+
this.authMode(entry.config) !== "auto" ||
|
|
205
|
+
!this.oauth)
|
|
206
|
+
throw new ChatRoomError("INVALID_INPUT", "OAuth is only available for automatic HTTP MCP");
|
|
207
|
+
const redirect = new URL(redirectUrl);
|
|
208
|
+
if (redirect.pathname !== "/api/mcp/oauth/callback" ||
|
|
209
|
+
(redirect.protocol !== "https:" &&
|
|
210
|
+
!(redirect.protocol === "http:" &&
|
|
211
|
+
["localhost", "127.0.0.1", "::1"].includes(redirect.hostname))))
|
|
212
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid OAuth callback URL");
|
|
213
|
+
// A newer login invalidates earlier pending callbacks for the same server.
|
|
214
|
+
this.dropPendingAuthorization(id);
|
|
215
|
+
// Changing the callback URI requires fresh client registration.
|
|
216
|
+
if (this.oauth.load(id).redirectUrl !== redirectUrl)
|
|
217
|
+
this.oauth.clear(id);
|
|
218
|
+
this.oauth.patch(id, { redirectUrl, tokens: undefined });
|
|
219
|
+
const state = randomBytes(32).toString("base64url");
|
|
220
|
+
const provider = new CustomMcpOAuthProvider(id, this.oauth, state);
|
|
221
|
+
const client = new Client({ name: "chatroom-oauth", version: "1.0.0" });
|
|
222
|
+
const transport = this.transport(entry.config, provider);
|
|
223
|
+
try {
|
|
224
|
+
await withConnectTimeout(client.connect(transport));
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (!provider.authorizationUrl)
|
|
228
|
+
throw error;
|
|
229
|
+
this.authorizationFlows.set(state, {
|
|
230
|
+
id,
|
|
231
|
+
expiresAt: Date.now() + 10 * 60_000,
|
|
232
|
+
});
|
|
233
|
+
entry.status = "auth_required";
|
|
234
|
+
return { authorizationUrl: provider.authorizationUrl };
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await client.close().catch(() => undefined);
|
|
238
|
+
}
|
|
239
|
+
throw new ChatRoomError("INVALID_INPUT", "This MCP server does not require OAuth");
|
|
240
|
+
}
|
|
241
|
+
async completeAuthorization(query) {
|
|
242
|
+
const state = query.get("state") ?? "";
|
|
243
|
+
const pending = this.authorizationFlows.get(state);
|
|
244
|
+
if (!pending || pending.expiresAt < Date.now())
|
|
245
|
+
throw new ChatRoomError("FORBIDDEN", "Invalid or expired MCP OAuth state");
|
|
246
|
+
this.authorizationFlows.delete(state);
|
|
247
|
+
if (query.has("error"))
|
|
248
|
+
throw new ChatRoomError("FORBIDDEN", "MCP authorization denied");
|
|
249
|
+
const entry = this.require(pending.id);
|
|
250
|
+
if (!this.oauth ||
|
|
251
|
+
!query.get("code") ||
|
|
252
|
+
this.authMode(entry.config) !== "auto")
|
|
253
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid MCP authorization response");
|
|
254
|
+
const provider = new CustomMcpOAuthProvider(pending.id, this.oauth);
|
|
255
|
+
const transport = this.transport(entry.config, provider);
|
|
256
|
+
try {
|
|
257
|
+
await withConnectTimeout(transport.finishAuth(query));
|
|
258
|
+
await this.update(pending.id, { enabled: true });
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
await transport.close().catch(() => undefined);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async revokeAuthorization(id) {
|
|
265
|
+
this.require(id);
|
|
266
|
+
this.dropPendingAuthorization(id);
|
|
267
|
+
this.oauth?.clear(id);
|
|
268
|
+
await this.update(id, { enabled: false });
|
|
269
|
+
}
|
|
270
|
+
dropPendingAuthorization(id) {
|
|
271
|
+
for (const [state, flow] of this.authorizationFlows)
|
|
272
|
+
if (flow.id === id)
|
|
273
|
+
this.authorizationFlows.delete(state);
|
|
274
|
+
}
|
|
275
|
+
authMode(config) {
|
|
276
|
+
return (config.authMode ??
|
|
277
|
+
(Object.keys(config.headers ?? {}).length ? "headers" : "auto"));
|
|
278
|
+
}
|
|
279
|
+
registerMcp(server) {
|
|
280
|
+
for (const entry of this.entries.values()) {
|
|
281
|
+
if (!entry.config.enabled || entry.status !== "connected")
|
|
282
|
+
continue;
|
|
283
|
+
for (const tool of entry.tools) {
|
|
284
|
+
const name = toolId(entry.config.id, tool.name);
|
|
285
|
+
const registered = server.registerTool(name, {
|
|
286
|
+
title: tool.title ?? tool.name,
|
|
287
|
+
description: tool.description ?? "",
|
|
288
|
+
inputSchema: fromJsonSchema(tool.inputSchema),
|
|
289
|
+
...(tool.outputSchema
|
|
290
|
+
? {
|
|
291
|
+
outputSchema: fromJsonSchema(tool.outputSchema),
|
|
292
|
+
}
|
|
293
|
+
: {}),
|
|
294
|
+
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
295
|
+
}, async (args) => this.call(entry.config.id, tool.name, args));
|
|
296
|
+
this.control.attach(name, registered);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async call(id, name, args) {
|
|
301
|
+
const entry = this.entries.get(id);
|
|
302
|
+
if (!entry?.config.enabled ||
|
|
303
|
+
!entry.client ||
|
|
304
|
+
entry.status !== "connected" ||
|
|
305
|
+
!entry.tools.some((tool) => tool.name === name) ||
|
|
306
|
+
!this.control.isEnabled(toolId(id, name)))
|
|
307
|
+
throw new ChatRoomError("NOT_FOUND", "Custom MCP tool is unavailable");
|
|
308
|
+
const client = entry.client;
|
|
309
|
+
const operation = this.operations.start({
|
|
310
|
+
pluginId: "custom-mcp",
|
|
311
|
+
source: "mcp",
|
|
312
|
+
action: entry.config.name + "/" + name,
|
|
313
|
+
input: { serverId: id, toolName: name },
|
|
314
|
+
});
|
|
315
|
+
try {
|
|
316
|
+
const result = await client.callTool({ name, arguments: args }, { timeout: TOOL_TIMEOUT });
|
|
317
|
+
this.operations.finish(operation.operationId, result.isError ? "error" : "success", {
|
|
318
|
+
isError: Boolean(result.isError),
|
|
319
|
+
contentItems: result.content?.length ?? 0,
|
|
320
|
+
});
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
this.operations.finish(operation.operationId, "error", null, {
|
|
325
|
+
message: error instanceof Error ? error.message : String(error),
|
|
326
|
+
});
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async connect(entry) {
|
|
331
|
+
if (entry.pending)
|
|
332
|
+
return entry.pending;
|
|
333
|
+
if (!entry.config.enabled || this.closed)
|
|
334
|
+
return;
|
|
335
|
+
entry.status = "connecting";
|
|
336
|
+
entry.error = null;
|
|
337
|
+
const task = this.open(entry);
|
|
338
|
+
entry.pending = task;
|
|
339
|
+
try {
|
|
340
|
+
await task;
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
if (entry.pending === task)
|
|
344
|
+
entry.pending = null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async open(entry) {
|
|
348
|
+
const client = new Client({ name: "chatroom-gateway", version: "1.0.0" });
|
|
349
|
+
client.onclose = () => {
|
|
350
|
+
if (this.entries.get(entry.config.id) !== entry ||
|
|
351
|
+
entry.client !== client)
|
|
352
|
+
return;
|
|
353
|
+
entry.client = null;
|
|
354
|
+
entry.status = "error";
|
|
355
|
+
entry.error = "MCP connection closed";
|
|
356
|
+
this.forgetTools(entry);
|
|
357
|
+
this.scheduleRetry(entry);
|
|
358
|
+
};
|
|
359
|
+
try {
|
|
360
|
+
const transport = this.transport(entry.config);
|
|
361
|
+
await withConnectTimeout(client.connect(transport));
|
|
362
|
+
const { tools } = await client.listTools(undefined, {
|
|
363
|
+
timeout: CONNECT_TIMEOUT,
|
|
364
|
+
});
|
|
365
|
+
if (this.closed ||
|
|
366
|
+
this.entries.get(entry.config.id) !== entry ||
|
|
367
|
+
!entry.config.enabled)
|
|
368
|
+
return;
|
|
369
|
+
if (tools.length > 200)
|
|
370
|
+
throw new Error("MCP server exposes too many tools (max 200)");
|
|
371
|
+
entry.client = client;
|
|
372
|
+
entry.tools = tools;
|
|
373
|
+
entry.status = "connected";
|
|
374
|
+
entry.error = null;
|
|
375
|
+
for (const tool of tools)
|
|
376
|
+
this.define(entry, tool);
|
|
377
|
+
client.setNotificationHandler("notifications/tools/list_changed", () => {
|
|
378
|
+
if (this.entries.get(entry.config.id) === entry)
|
|
379
|
+
void this.refreshTools(entry).catch(() => undefined);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
if (this.entries.get(entry.config.id) === entry && !this.closed) {
|
|
384
|
+
const needsAuth = this.authMode(entry.config) === "auto" &&
|
|
385
|
+
entry.config.type === "http" &&
|
|
386
|
+
isAuthenticationError(error);
|
|
387
|
+
entry.status = needsAuth ? "auth_required" : "error";
|
|
388
|
+
entry.error = needsAuth
|
|
389
|
+
? null
|
|
390
|
+
: error instanceof Error
|
|
391
|
+
? error.message
|
|
392
|
+
: String(error);
|
|
393
|
+
if (!needsAuth)
|
|
394
|
+
this.scheduleRetry(entry);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
if (entry.client !== client)
|
|
399
|
+
await client.close().catch(() => undefined);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
define(entry, tool) {
|
|
403
|
+
this.control.define({
|
|
404
|
+
name: toolId(entry.config.id, tool.name),
|
|
405
|
+
pluginId: "custom-mcp",
|
|
406
|
+
title: tool.title ?? tool.name,
|
|
407
|
+
description: tool.description ?? "",
|
|
408
|
+
defaultEnabled: false,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
async refreshTools(entry) {
|
|
412
|
+
if (!entry.client)
|
|
413
|
+
return;
|
|
414
|
+
const { tools } = await entry.client.listTools(undefined, {
|
|
415
|
+
timeout: CONNECT_TIMEOUT,
|
|
416
|
+
});
|
|
417
|
+
if (this.entries.get(entry.config.id) !== entry ||
|
|
418
|
+
!entry.client ||
|
|
419
|
+
tools.length > 200)
|
|
420
|
+
return;
|
|
421
|
+
this.forgetTools(entry);
|
|
422
|
+
entry.tools = tools;
|
|
423
|
+
for (const tool of tools)
|
|
424
|
+
this.define(entry, tool);
|
|
425
|
+
}
|
|
426
|
+
transport(config, provider) {
|
|
427
|
+
if (config.type === "stdio")
|
|
428
|
+
return new StdioClientTransport({
|
|
429
|
+
command: config.command,
|
|
430
|
+
args: config.args ?? [],
|
|
431
|
+
...(config.cwd ? { cwd: config.cwd } : {}),
|
|
432
|
+
env: { ...getDefaultEnvironment(), ...(config.env ?? {}) },
|
|
433
|
+
stderr: "ignore",
|
|
434
|
+
});
|
|
435
|
+
const oauth = provider ??
|
|
436
|
+
(this.oauth &&
|
|
437
|
+
this.authMode(config) === "auto" &&
|
|
438
|
+
this.oauth.load(config.id).tokens
|
|
439
|
+
? new CustomMcpOAuthProvider(config.id, this.oauth)
|
|
440
|
+
: undefined);
|
|
441
|
+
return new StreamableHTTPClientTransport(new URL(config.url), {
|
|
442
|
+
...(oauth ? { authProvider: oauth } : {}),
|
|
443
|
+
requestInit: {
|
|
444
|
+
headers: this.authMode(config) === "headers" ? (config.headers ?? {}) : {},
|
|
445
|
+
redirect: "error",
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
scheduleRetry(entry) {
|
|
450
|
+
if (entry.retry || !entry.config.enabled || this.closed)
|
|
451
|
+
return;
|
|
452
|
+
entry.retry = setTimeout(() => {
|
|
453
|
+
entry.retry = null;
|
|
454
|
+
if (this.entries.get(entry.config.id) === entry)
|
|
455
|
+
void this.connect(entry);
|
|
456
|
+
}, 15000);
|
|
457
|
+
entry.retry.unref();
|
|
458
|
+
}
|
|
459
|
+
forgetTools(entry) {
|
|
460
|
+
for (const tool of entry.tools)
|
|
461
|
+
this.control.undefine(toolId(entry.config.id, tool.name));
|
|
462
|
+
entry.tools = [];
|
|
463
|
+
}
|
|
464
|
+
async disconnect(entry) {
|
|
465
|
+
if (entry.retry)
|
|
466
|
+
clearTimeout(entry.retry);
|
|
467
|
+
entry.retry = null;
|
|
468
|
+
this.forgetTools(entry);
|
|
469
|
+
const client = entry.client;
|
|
470
|
+
entry.client = null;
|
|
471
|
+
if (client)
|
|
472
|
+
await client.close().catch(() => undefined);
|
|
473
|
+
}
|
|
474
|
+
async dispose(entry) {
|
|
475
|
+
entry.config.enabled = false;
|
|
476
|
+
await this.disconnect(entry);
|
|
477
|
+
}
|
|
478
|
+
require(id) {
|
|
479
|
+
const entry = this.entries.get(id);
|
|
480
|
+
if (!entry)
|
|
481
|
+
throw new ChatRoomError("NOT_FOUND", "Custom MCP server not found");
|
|
482
|
+
return entry;
|
|
483
|
+
}
|
|
484
|
+
summary(id) {
|
|
485
|
+
const value = this.list().find((item) => item.id === id);
|
|
486
|
+
if (!value)
|
|
487
|
+
throw new ChatRoomError("NOT_FOUND", "Custom MCP server not found");
|
|
488
|
+
return value;
|
|
489
|
+
}
|
|
490
|
+
async close() {
|
|
491
|
+
this.closed = true;
|
|
492
|
+
await Promise.all([...this.entries.values()].map((entry) => this.dispose(entry)));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { OAuthClientProvider, OAuthClientMetadata, OAuthDiscoveryState, StoredOAuthClientInformation, StoredOAuthTokens } from "@modelcontextprotocol/client";
|
|
2
|
+
interface Credentials {
|
|
3
|
+
redirectUrl?: string | undefined;
|
|
4
|
+
client?: StoredOAuthClientInformation | undefined;
|
|
5
|
+
tokens?: StoredOAuthTokens | undefined;
|
|
6
|
+
verifier?: string | undefined;
|
|
7
|
+
discovery?: OAuthDiscoveryState | undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Keep credentials outside the ordinary MCP configuration, in owner-only files. */
|
|
10
|
+
export declare class CustomMcpOAuthStore {
|
|
11
|
+
private readonly directory;
|
|
12
|
+
constructor(dataDir: string);
|
|
13
|
+
private file;
|
|
14
|
+
load(id: string): Credentials;
|
|
15
|
+
patch(id: string, update: Partial<Credentials>): void;
|
|
16
|
+
clear(id: string): void;
|
|
17
|
+
}
|
|
18
|
+
export declare class CustomMcpOAuthProvider implements OAuthClientProvider {
|
|
19
|
+
readonly serverId: string;
|
|
20
|
+
private readonly store;
|
|
21
|
+
private readonly flowState?;
|
|
22
|
+
authorizationUrl: string | null;
|
|
23
|
+
constructor(serverId: string, store: CustomMcpOAuthStore, flowState?: string | undefined);
|
|
24
|
+
get redirectUrl(): string | undefined;
|
|
25
|
+
get clientMetadata(): OAuthClientMetadata;
|
|
26
|
+
state(): string;
|
|
27
|
+
clientInformation(): StoredOAuthClientInformation | undefined;
|
|
28
|
+
saveClientInformation(client: StoredOAuthClientInformation): void;
|
|
29
|
+
tokens(): StoredOAuthTokens | undefined;
|
|
30
|
+
saveTokens(tokens: StoredOAuthTokens): void;
|
|
31
|
+
redirectToAuthorization(url: URL): void;
|
|
32
|
+
saveCodeVerifier(verifier: string): void;
|
|
33
|
+
codeVerifier(): string;
|
|
34
|
+
saveDiscoveryState(discovery: OAuthDiscoveryState): void;
|
|
35
|
+
discoveryState(): OAuthDiscoveryState | undefined;
|
|
36
|
+
invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void;
|
|
37
|
+
}
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Keep credentials outside the ordinary MCP configuration, in owner-only files. */
|
|
5
|
+
export class CustomMcpOAuthStore {
|
|
6
|
+
directory;
|
|
7
|
+
constructor(dataDir) {
|
|
8
|
+
this.directory = path.join(dataDir, "custom-mcp-oauth");
|
|
9
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
10
|
+
chmodSync(this.directory, 0o700);
|
|
11
|
+
}
|
|
12
|
+
file(id) {
|
|
13
|
+
if (!/^[a-f0-9]{32}$/.test(id))
|
|
14
|
+
throw new Error("Invalid MCP server id");
|
|
15
|
+
return path.join(this.directory, id + ".json");
|
|
16
|
+
}
|
|
17
|
+
load(id) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(this.file(id), "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return {};
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
patch(id, update) {
|
|
28
|
+
const file = this.file(id);
|
|
29
|
+
const temp = file + "." + randomBytes(8).toString("hex");
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(temp, JSON.stringify({ ...this.load(id), ...update }), {
|
|
32
|
+
mode: 0o600,
|
|
33
|
+
flag: "wx",
|
|
34
|
+
});
|
|
35
|
+
renameSync(temp, file);
|
|
36
|
+
chmodSync(file, 0o600);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
rmSync(temp, { force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
clear(id) {
|
|
43
|
+
rmSync(this.file(id), { force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class CustomMcpOAuthProvider {
|
|
47
|
+
serverId;
|
|
48
|
+
store;
|
|
49
|
+
flowState;
|
|
50
|
+
authorizationUrl = null;
|
|
51
|
+
constructor(serverId, store, flowState) {
|
|
52
|
+
this.serverId = serverId;
|
|
53
|
+
this.store = store;
|
|
54
|
+
this.flowState = flowState;
|
|
55
|
+
}
|
|
56
|
+
get redirectUrl() {
|
|
57
|
+
return this.store.load(this.serverId).redirectUrl;
|
|
58
|
+
}
|
|
59
|
+
get clientMetadata() {
|
|
60
|
+
return {
|
|
61
|
+
client_name: "ChatRoom",
|
|
62
|
+
redirect_uris: this.redirectUrl ? [this.redirectUrl] : [],
|
|
63
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
64
|
+
response_types: ["code"],
|
|
65
|
+
token_endpoint_auth_method: "none",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
state() {
|
|
69
|
+
if (!this.flowState)
|
|
70
|
+
throw new Error("MCP OAuth login must be initiated from ChatRoom");
|
|
71
|
+
return this.flowState;
|
|
72
|
+
}
|
|
73
|
+
clientInformation() {
|
|
74
|
+
return this.store.load(this.serverId).client;
|
|
75
|
+
}
|
|
76
|
+
saveClientInformation(client) {
|
|
77
|
+
this.store.patch(this.serverId, { client });
|
|
78
|
+
}
|
|
79
|
+
tokens() {
|
|
80
|
+
return this.store.load(this.serverId).tokens;
|
|
81
|
+
}
|
|
82
|
+
saveTokens(tokens) {
|
|
83
|
+
this.store.patch(this.serverId, { tokens });
|
|
84
|
+
}
|
|
85
|
+
redirectToAuthorization(url) {
|
|
86
|
+
if (!this.flowState)
|
|
87
|
+
throw new Error("MCP server requires browser authorization");
|
|
88
|
+
this.authorizationUrl = url.toString();
|
|
89
|
+
}
|
|
90
|
+
saveCodeVerifier(verifier) {
|
|
91
|
+
this.store.patch(this.serverId, { verifier });
|
|
92
|
+
}
|
|
93
|
+
codeVerifier() {
|
|
94
|
+
const verifier = this.store.load(this.serverId).verifier;
|
|
95
|
+
if (!verifier)
|
|
96
|
+
throw new Error("MCP OAuth authorization has expired; retry login");
|
|
97
|
+
return verifier;
|
|
98
|
+
}
|
|
99
|
+
saveDiscoveryState(discovery) {
|
|
100
|
+
this.store.patch(this.serverId, { discovery });
|
|
101
|
+
}
|
|
102
|
+
discoveryState() {
|
|
103
|
+
return this.store.load(this.serverId).discovery;
|
|
104
|
+
}
|
|
105
|
+
invalidateCredentials(scope) {
|
|
106
|
+
const previous = this.store.load(this.serverId);
|
|
107
|
+
this.store.patch(this.serverId, {
|
|
108
|
+
client: scope === "all" || scope === "client" ? undefined : previous.client,
|
|
109
|
+
tokens: scope === "all" || scope === "tokens" ? undefined : previous.tokens,
|
|
110
|
+
verifier: scope === "all" || scope === "verifier" ? undefined : previous.verifier,
|
|
111
|
+
discovery: scope === "all" || scope === "discovery"
|
|
112
|
+
? undefined
|
|
113
|
+
: previous.discovery,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Tool } from "@modelcontextprotocol/client";
|
|
2
|
+
export interface CustomMcpConfig {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
type: "stdio" | "http";
|
|
6
|
+
command?: string;
|
|
7
|
+
args?: string[];
|
|
8
|
+
cwd?: string;
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
url?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
authMode?: "auto" | "none" | "headers";
|
|
14
|
+
}
|
|
15
|
+
export interface CustomMcpServerSummary {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
type: "stdio" | "http";
|
|
19
|
+
command?: string;
|
|
20
|
+
args?: string[];
|
|
21
|
+
cwd?: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
envKeys: string[];
|
|
24
|
+
headerKeys: string[];
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
authMode?: "auto" | "none" | "headers";
|
|
27
|
+
authorized?: boolean;
|
|
28
|
+
status: "disabled" | "connecting" | "connected" | "auth_required" | "error";
|
|
29
|
+
error: string | null;
|
|
30
|
+
tools: Array<{
|
|
31
|
+
name: string;
|
|
32
|
+
title: string;
|
|
33
|
+
description: string;
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export type DiscoveredMcpTool = Tool;
|
|
38
|
+
export interface CustomMcpStore {
|
|
39
|
+
list(): CustomMcpConfig[];
|
|
40
|
+
save(config: CustomMcpConfig): void;
|
|
41
|
+
delete(id: string): void;
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|