@kolisachint/hoocode-agent-core 0.4.119 → 0.4.121
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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/tools/mcp-http-transport.d.ts +64 -0
- package/dist/tools/mcp-http-transport.d.ts.map +1 -0
- package/dist/tools/mcp-http-transport.js +245 -0
- package/dist/tools/mcp-http-transport.js.map +1 -0
- package/dist/tools/mcp-oauth.d.ts +90 -0
- package/dist/tools/mcp-oauth.d.ts.map +1 -0
- package/dist/tools/mcp-oauth.js +316 -0
- package/dist/tools/mcp-oauth.js.map +1 -0
- package/dist/tools/mcp-tools.d.ts +15 -8
- package/dist/tools/mcp-tools.d.ts.map +1 -1
- package/dist/tools/mcp-tools.js +68 -30
- package/dist/tools/mcp-tools.js.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth support for remote MCP servers: a file-backed
|
|
3
|
+
* {@link OAuthClientProvider} for the official MCP SDK plus a loopback
|
|
4
|
+
* callback server for the browser-based authorization-code + PKCE flow.
|
|
5
|
+
*
|
|
6
|
+
* The SDK's `auth()` orchestrates discovery (RFC 9728 / RFC 8414), dynamic
|
|
7
|
+
* client registration, PKCE, token exchange, and refresh; this module supplies
|
|
8
|
+
* the persistence and user-interaction pieces:
|
|
9
|
+
* - per-server-URL state (client registration, tokens, code verifier) stored
|
|
10
|
+
* as 0600 JSON files under `~/.hoocode/mcp-auth/` by default;
|
|
11
|
+
* - a localhost HTTP server that receives the authorization redirect. It is
|
|
12
|
+
* started lazily inside `clientInformation()` — the first provider call of
|
|
13
|
+
* every interactive `auth()` run that happens before the authorization URL
|
|
14
|
+
* is built — so no listener exists unless an OAuth flow is actually running.
|
|
15
|
+
* Re-binding the port used at registration keeps persisted dynamic client
|
|
16
|
+
* registrations valid; if the port is taken, the registration is dropped so
|
|
17
|
+
* the SDK re-registers with the fresh redirect URI.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { createServer } from "node:http";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
/** Default directory for persisted MCP OAuth state. */
|
|
26
|
+
export function defaultMcpAuthDir() {
|
|
27
|
+
return join(homedir(), ".hoocode", "mcp-auth");
|
|
28
|
+
}
|
|
29
|
+
/** Open a URL in the user's browser (best-effort, detached). */
|
|
30
|
+
export function openBrowserDefault(url) {
|
|
31
|
+
const [cmd, args] = process.platform === "darwin"
|
|
32
|
+
? ["open", [url]]
|
|
33
|
+
: process.platform === "win32"
|
|
34
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
35
|
+
: ["xdg-open", [url]];
|
|
36
|
+
try {
|
|
37
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// best-effort — the authorization URL is also surfaced via onAuthorizationUrl
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const CALLBACK_PATH = "/callback";
|
|
44
|
+
/** Loopback HTTP server that receives the OAuth authorization redirect. */
|
|
45
|
+
class OAuthCallbackServer {
|
|
46
|
+
server;
|
|
47
|
+
waiter;
|
|
48
|
+
/** Redirect that arrived before anyone called waitForCode (fast redirects). */
|
|
49
|
+
buffered;
|
|
50
|
+
url;
|
|
51
|
+
constructor(server, port) {
|
|
52
|
+
this.server = server;
|
|
53
|
+
this.url = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
|
|
54
|
+
}
|
|
55
|
+
/** Bind the callback server; `preferredPort` first, any free port otherwise. */
|
|
56
|
+
static async start(preferredPort) {
|
|
57
|
+
const listen = (port) => new Promise((resolve, reject) => {
|
|
58
|
+
const srv = createServer();
|
|
59
|
+
srv.once("error", reject);
|
|
60
|
+
srv.listen(port, "127.0.0.1", () => {
|
|
61
|
+
srv.removeAllListeners("error");
|
|
62
|
+
resolve(srv);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
let server;
|
|
66
|
+
let reboundPort = true;
|
|
67
|
+
if (preferredPort) {
|
|
68
|
+
try {
|
|
69
|
+
server = await listen(preferredPort);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
server = await listen(0);
|
|
73
|
+
reboundPort = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
server = await listen(0);
|
|
78
|
+
reboundPort = false;
|
|
79
|
+
}
|
|
80
|
+
const cb = new OAuthCallbackServer(server, server.address().port);
|
|
81
|
+
server.on("request", (req, res) => {
|
|
82
|
+
const url = new URL(req.url ?? "/", cb.url);
|
|
83
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
84
|
+
res.writeHead(404).end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const code = url.searchParams.get("code") ?? undefined;
|
|
88
|
+
const state = url.searchParams.get("state") ?? undefined;
|
|
89
|
+
const error = url.searchParams.get("error") ?? undefined;
|
|
90
|
+
if (error || !code) {
|
|
91
|
+
res.writeHead(400, { "content-type": "text/html" }).end("<h3>Authorization failed.</h3>");
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
res.writeHead(200, { "content-type": "text/html" }).end("<h3>Authorization complete.</h3><p>You can return to hoocode.</p>");
|
|
95
|
+
}
|
|
96
|
+
const waiter = cb.waiter;
|
|
97
|
+
if (waiter) {
|
|
98
|
+
cb.waiter = undefined;
|
|
99
|
+
cb.deliver(waiter, { code, state, error });
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Redirect can land before the client starts waiting (instant
|
|
103
|
+
// redirects, test drivers); hold it for the next waitForCode.
|
|
104
|
+
cb.buffered = { code, state, error };
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return { server: cb, reboundPort };
|
|
108
|
+
}
|
|
109
|
+
deliver(waiter, result) {
|
|
110
|
+
if (result.error || !result.code) {
|
|
111
|
+
waiter.reject(new Error(`OAuth authorization failed: ${result.error ?? "no code returned"}`));
|
|
112
|
+
}
|
|
113
|
+
else if (waiter.expectedState && result.state !== waiter.expectedState) {
|
|
114
|
+
waiter.reject(new Error("OAuth authorization failed: state mismatch"));
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
waiter.resolve(result.code);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
waitForCode(expectedState, timeoutMs) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
this.waiter = undefined;
|
|
124
|
+
reject(new Error(`Timed out waiting ${timeoutMs}ms for OAuth authorization`));
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
timer.unref?.();
|
|
127
|
+
const waiter = {
|
|
128
|
+
expectedState,
|
|
129
|
+
resolve: (code) => {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
resolve(code);
|
|
132
|
+
},
|
|
133
|
+
reject: (err) => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
reject(err);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
const buffered = this.buffered;
|
|
139
|
+
if (buffered) {
|
|
140
|
+
this.buffered = undefined;
|
|
141
|
+
this.deliver(waiter, buffered);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.waiter = waiter;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
close() {
|
|
148
|
+
this.waiter?.reject(new Error("OAuth callback server closed"));
|
|
149
|
+
this.waiter = undefined;
|
|
150
|
+
this.server.close();
|
|
151
|
+
// Sever keep-alive connections so close() doesn't linger.
|
|
152
|
+
this.server.closeAllConnections?.();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* File-backed OAuth provider for one remote MCP server, keyed by server URL.
|
|
157
|
+
* Implements the SDK's {@link OAuthClientProvider} so `auth()` handles the
|
|
158
|
+
* protocol; this class owns persistence, the loopback redirect listener, and
|
|
159
|
+
* opening the user's browser.
|
|
160
|
+
*/
|
|
161
|
+
export class McpFileOAuthProvider {
|
|
162
|
+
serverUrl;
|
|
163
|
+
statePath;
|
|
164
|
+
openBrowser;
|
|
165
|
+
callback;
|
|
166
|
+
callbackStarting;
|
|
167
|
+
currentState;
|
|
168
|
+
/** Last authorization URL handed to `redirectToAuthorization`. */
|
|
169
|
+
lastAuthorizationUrl;
|
|
170
|
+
constructor(serverUrl, options = {}) {
|
|
171
|
+
this.serverUrl = serverUrl;
|
|
172
|
+
this.openBrowser = options.openBrowser ?? openBrowserDefault;
|
|
173
|
+
const dir = options.storageDir ?? defaultMcpAuthDir();
|
|
174
|
+
const hash = createHash("sha256").update(serverUrl).digest("hex").slice(0, 12);
|
|
175
|
+
const host = (URL.canParse(serverUrl) ? new URL(serverUrl).hostname : "server").replace(/[^a-zA-Z0-9.-]/g, "_");
|
|
176
|
+
this.statePath = join(dir, `${host}-${hash}.json`);
|
|
177
|
+
}
|
|
178
|
+
read() {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(readFileSync(this.statePath, "utf8"));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { serverUrl: this.serverUrl };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
write(mutate) {
|
|
187
|
+
const state = this.read();
|
|
188
|
+
mutate(state);
|
|
189
|
+
mkdirSync(dirname(this.statePath), { recursive: true });
|
|
190
|
+
writeFileSync(this.statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Ensure the loopback redirect listener is running. Prefers the port used at
|
|
194
|
+
* dynamic client registration so the persisted client stays valid; when that
|
|
195
|
+
* port can't be re-bound the registration is dropped and the SDK registers a
|
|
196
|
+
* fresh client against the new redirect URI.
|
|
197
|
+
*/
|
|
198
|
+
async ensureCallbackServer() {
|
|
199
|
+
if (this.callback)
|
|
200
|
+
return this.callback;
|
|
201
|
+
if (!this.callbackStarting) {
|
|
202
|
+
this.callbackStarting = (async () => {
|
|
203
|
+
const persisted = this.read();
|
|
204
|
+
const preferredPort = persisted.redirectUrl && URL.canParse(persisted.redirectUrl)
|
|
205
|
+
? Number(new URL(persisted.redirectUrl).port) || undefined
|
|
206
|
+
: undefined;
|
|
207
|
+
const { server, reboundPort } = await OAuthCallbackServer.start(preferredPort);
|
|
208
|
+
if (preferredPort && !reboundPort) {
|
|
209
|
+
this.write((s) => {
|
|
210
|
+
s.clientInformation = undefined;
|
|
211
|
+
s.redirectUrl = undefined;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
this.callback = server;
|
|
215
|
+
return server;
|
|
216
|
+
})();
|
|
217
|
+
this.callbackStarting.catch(() => {
|
|
218
|
+
this.callbackStarting = undefined;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return this.callbackStarting;
|
|
222
|
+
}
|
|
223
|
+
/** Wait for the browser redirect to deliver an authorization code. */
|
|
224
|
+
async waitForAuthorizationCode(timeoutMs) {
|
|
225
|
+
const server = await this.ensureCallbackServer();
|
|
226
|
+
return server.waitForCode(this.currentState, timeoutMs);
|
|
227
|
+
}
|
|
228
|
+
/** Stop the loopback listener (auth finished, failed, or connection closed). */
|
|
229
|
+
closeCallbackServer() {
|
|
230
|
+
this.callback?.close();
|
|
231
|
+
this.callback = undefined;
|
|
232
|
+
this.callbackStarting = undefined;
|
|
233
|
+
}
|
|
234
|
+
// ---- OAuthClientProvider ----
|
|
235
|
+
get redirectUrl() {
|
|
236
|
+
// Must stay defined even before the listener is up: an undefined
|
|
237
|
+
// redirectUrl tells the SDK to run a non-interactive grant instead of the
|
|
238
|
+
// authorization-code flow. The real port is bound in clientInformation()
|
|
239
|
+
// before the SDK builds the authorization URL.
|
|
240
|
+
return this.callback?.url ?? this.read().redirectUrl ?? "http://127.0.0.1:0/callback";
|
|
241
|
+
}
|
|
242
|
+
get clientMetadata() {
|
|
243
|
+
return {
|
|
244
|
+
client_name: "hoocode",
|
|
245
|
+
redirect_uris: [String(this.redirectUrl)],
|
|
246
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
247
|
+
response_types: ["code"],
|
|
248
|
+
token_endpoint_auth_method: "none",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
state() {
|
|
252
|
+
this.currentState ??= randomBytes(16).toString("hex");
|
|
253
|
+
return this.currentState;
|
|
254
|
+
}
|
|
255
|
+
async clientInformation() {
|
|
256
|
+
// First provider call of every auth() run that precedes building the
|
|
257
|
+
// authorization URL — bind the redirect listener here so redirectUrl is
|
|
258
|
+
// live for registration and the authorization request.
|
|
259
|
+
await this.ensureCallbackServer();
|
|
260
|
+
return this.read().clientInformation;
|
|
261
|
+
}
|
|
262
|
+
saveClientInformation(clientInformation) {
|
|
263
|
+
const redirectUrl = String(this.redirectUrl);
|
|
264
|
+
this.write((s) => {
|
|
265
|
+
s.clientInformation = clientInformation;
|
|
266
|
+
s.redirectUrl = redirectUrl;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
tokens() {
|
|
270
|
+
return this.read().tokens;
|
|
271
|
+
}
|
|
272
|
+
saveTokens(tokens) {
|
|
273
|
+
this.write((s) => {
|
|
274
|
+
s.tokens = tokens;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
278
|
+
this.lastAuthorizationUrl = authorizationUrl.toString();
|
|
279
|
+
await this.openBrowser(this.lastAuthorizationUrl);
|
|
280
|
+
}
|
|
281
|
+
saveCodeVerifier(codeVerifier) {
|
|
282
|
+
this.write((s) => {
|
|
283
|
+
s.codeVerifier = codeVerifier;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
codeVerifier() {
|
|
287
|
+
const verifier = this.read().codeVerifier;
|
|
288
|
+
if (!verifier)
|
|
289
|
+
throw new Error(`No PKCE code verifier stored for ${this.serverUrl}`);
|
|
290
|
+
return verifier;
|
|
291
|
+
}
|
|
292
|
+
invalidateCredentials(scope) {
|
|
293
|
+
if (scope === "discovery")
|
|
294
|
+
return;
|
|
295
|
+
if (scope === "all" && existsSync(this.statePath)) {
|
|
296
|
+
try {
|
|
297
|
+
unlinkSync(this.statePath);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// fall through to field-level clearing
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.write((s) => {
|
|
305
|
+
if (scope === "all" || scope === "client") {
|
|
306
|
+
s.clientInformation = undefined;
|
|
307
|
+
s.redirectUrl = undefined;
|
|
308
|
+
}
|
|
309
|
+
if (scope === "all" || scope === "tokens")
|
|
310
|
+
s.tokens = undefined;
|
|
311
|
+
if (scope === "all" || scope === "verifier")
|
|
312
|
+
s.codeVerifier = undefined;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=mcp-oauth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-oauth.js","sourceRoot":"","sources":["../../src/tools/mcp-oauth.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * OAuth support for remote MCP servers: a file-backed\n * {@link OAuthClientProvider} for the official MCP SDK plus a loopback\n * callback server for the browser-based authorization-code + PKCE flow.\n *\n * The SDK's `auth()` orchestrates discovery (RFC 9728 / RFC 8414), dynamic\n * client registration, PKCE, token exchange, and refresh; this module supplies\n * the persistence and user-interaction pieces:\n * - per-server-URL state (client registration, tokens, code verifier) stored\n * as 0600 JSON files under `~/.hoocode/mcp-auth/` by default;\n * - a localhost HTTP server that receives the authorization redirect. It is\n * started lazily inside `clientInformation()` — the first provider call of\n * every interactive `auth()` run that happens before the authorization URL\n * is built — so no listener exists unless an OAuth flow is actually running.\n * Re-binding the port used at registration keeps persisted dynamic client\n * registrations valid; if the port is taken, the registration is dropped so\n * the SDK re-registers with the fresh redirect URI.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { createHash, randomBytes } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport { createServer, type Server } from \"node:http\";\nimport type { AddressInfo } from \"node:net\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type { OAuthClientProvider } from \"@modelcontextprotocol/sdk/client/auth.js\";\nimport type {\n\tOAuthClientInformationMixed,\n\tOAuthClientMetadata,\n\tOAuthTokens,\n} from \"@modelcontextprotocol/sdk/shared/auth.js\";\n\n/** Default directory for persisted MCP OAuth state. */\nexport function defaultMcpAuthDir(): string {\n\treturn join(homedir(), \".hoocode\", \"mcp-auth\");\n}\n\n/** Open a URL in the user's browser (best-effort, detached). */\nexport function openBrowserDefault(url: string): void {\n\tconst [cmd, args] =\n\t\tprocess.platform === \"darwin\"\n\t\t\t? [\"open\", [url]]\n\t\t\t: process.platform === \"win32\"\n\t\t\t\t? [\"cmd\", [\"/c\", \"start\", \"\", url]]\n\t\t\t\t: [\"xdg-open\", [url]];\n\ttry {\n\t\tspawn(cmd, args as string[], { detached: true, stdio: \"ignore\" }).unref();\n\t} catch {\n\t\t// best-effort — the authorization URL is also surfaced via onAuthorizationUrl\n\t}\n}\n\ninterface PersistedAuthState {\n\tserverUrl: string;\n\tclientInformation?: OAuthClientInformationMixed;\n\t/** Redirect URI the dynamic client registration was performed with. */\n\tredirectUrl?: string;\n\ttokens?: OAuthTokens;\n\tcodeVerifier?: string;\n}\n\nconst CALLBACK_PATH = \"/callback\";\n\ninterface CallbackWaiter {\n\tresolve: (code: string) => void;\n\treject: (err: Error) => void;\n\texpectedState?: string;\n}\n\n/** Loopback HTTP server that receives the OAuth authorization redirect. */\nclass OAuthCallbackServer {\n\tprivate server: Server;\n\tprivate waiter?: CallbackWaiter;\n\t/** Redirect that arrived before anyone called waitForCode (fast redirects). */\n\tprivate buffered?: { code?: string; state?: string; error?: string };\n\treadonly url: string;\n\n\tprivate constructor(server: Server, port: number) {\n\t\tthis.server = server;\n\t\tthis.url = `http://127.0.0.1:${port}${CALLBACK_PATH}`;\n\t}\n\n\t/** Bind the callback server; `preferredPort` first, any free port otherwise. */\n\tstatic async start(preferredPort?: number): Promise<{ server: OAuthCallbackServer; reboundPort: boolean }> {\n\t\tconst listen = (port: number) =>\n\t\t\tnew Promise<Server>((resolve, reject) => {\n\t\t\t\tconst srv = createServer();\n\t\t\t\tsrv.once(\"error\", reject);\n\t\t\t\tsrv.listen(port, \"127.0.0.1\", () => {\n\t\t\t\t\tsrv.removeAllListeners(\"error\");\n\t\t\t\t\tresolve(srv);\n\t\t\t\t});\n\t\t\t});\n\n\t\tlet server: Server;\n\t\tlet reboundPort = true;\n\t\tif (preferredPort) {\n\t\t\ttry {\n\t\t\t\tserver = await listen(preferredPort);\n\t\t\t} catch {\n\t\t\t\tserver = await listen(0);\n\t\t\t\treboundPort = false;\n\t\t\t}\n\t\t} else {\n\t\t\tserver = await listen(0);\n\t\t\treboundPort = false;\n\t\t}\n\n\t\tconst cb = new OAuthCallbackServer(server, (server.address() as AddressInfo).port);\n\t\tserver.on(\"request\", (req, res) => {\n\t\t\tconst url = new URL(req.url ?? \"/\", cb.url);\n\t\t\tif (url.pathname !== CALLBACK_PATH) {\n\t\t\t\tres.writeHead(404).end();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst code = url.searchParams.get(\"code\") ?? undefined;\n\t\t\tconst state = url.searchParams.get(\"state\") ?? undefined;\n\t\t\tconst error = url.searchParams.get(\"error\") ?? undefined;\n\t\t\tif (error || !code) {\n\t\t\t\tres.writeHead(400, { \"content-type\": \"text/html\" }).end(\"<h3>Authorization failed.</h3>\");\n\t\t\t} else {\n\t\t\t\tres.writeHead(200, { \"content-type\": \"text/html\" }).end(\n\t\t\t\t\t\"<h3>Authorization complete.</h3><p>You can return to hoocode.</p>\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst waiter = cb.waiter;\n\t\t\tif (waiter) {\n\t\t\t\tcb.waiter = undefined;\n\t\t\t\tcb.deliver(waiter, { code, state, error });\n\t\t\t} else {\n\t\t\t\t// Redirect can land before the client starts waiting (instant\n\t\t\t\t// redirects, test drivers); hold it for the next waitForCode.\n\t\t\t\tcb.buffered = { code, state, error };\n\t\t\t}\n\t\t});\n\t\treturn { server: cb, reboundPort };\n\t}\n\n\tprivate deliver(waiter: CallbackWaiter, result: { code?: string; state?: string; error?: string }): void {\n\t\tif (result.error || !result.code) {\n\t\t\twaiter.reject(new Error(`OAuth authorization failed: ${result.error ?? \"no code returned\"}`));\n\t\t} else if (waiter.expectedState && result.state !== waiter.expectedState) {\n\t\t\twaiter.reject(new Error(\"OAuth authorization failed: state mismatch\"));\n\t\t} else {\n\t\t\twaiter.resolve(result.code);\n\t\t}\n\t}\n\n\twaitForCode(expectedState: string | undefined, timeoutMs: number): Promise<string> {\n\t\treturn new Promise<string>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tthis.waiter = undefined;\n\t\t\t\treject(new Error(`Timed out waiting ${timeoutMs}ms for OAuth authorization`));\n\t\t\t}, timeoutMs);\n\t\t\ttimer.unref?.();\n\t\t\tconst waiter: CallbackWaiter = {\n\t\t\t\texpectedState,\n\t\t\t\tresolve: (code) => {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\tresolve(code);\n\t\t\t\t},\n\t\t\t\treject: (err) => {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\treject(err);\n\t\t\t\t},\n\t\t\t};\n\t\t\tconst buffered = this.buffered;\n\t\t\tif (buffered) {\n\t\t\t\tthis.buffered = undefined;\n\t\t\t\tthis.deliver(waiter, buffered);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.waiter = waiter;\n\t\t});\n\t}\n\n\tclose(): void {\n\t\tthis.waiter?.reject(new Error(\"OAuth callback server closed\"));\n\t\tthis.waiter = undefined;\n\t\tthis.server.close();\n\t\t// Sever keep-alive connections so close() doesn't linger.\n\t\tthis.server.closeAllConnections?.();\n\t}\n}\n\nexport interface McpOAuthProviderOptions {\n\t/** Directory for persisted auth state (default `~/.hoocode/mcp-auth`). */\n\tstorageDir?: string;\n\t/** Open the authorization URL (default: spawn the platform browser opener). */\n\topenBrowser?: (url: string) => void | Promise<void>;\n}\n\n/**\n * File-backed OAuth provider for one remote MCP server, keyed by server URL.\n * Implements the SDK's {@link OAuthClientProvider} so `auth()` handles the\n * protocol; this class owns persistence, the loopback redirect listener, and\n * opening the user's browser.\n */\nexport class McpFileOAuthProvider implements OAuthClientProvider {\n\tprivate readonly serverUrl: string;\n\tprivate readonly statePath: string;\n\tprivate readonly openBrowser: (url: string) => void | Promise<void>;\n\tprivate callback?: OAuthCallbackServer;\n\tprivate callbackStarting?: Promise<OAuthCallbackServer>;\n\tprivate currentState?: string;\n\t/** Last authorization URL handed to `redirectToAuthorization`. */\n\tlastAuthorizationUrl?: string;\n\n\tconstructor(serverUrl: string, options: McpOAuthProviderOptions = {}) {\n\t\tthis.serverUrl = serverUrl;\n\t\tthis.openBrowser = options.openBrowser ?? openBrowserDefault;\n\t\tconst dir = options.storageDir ?? defaultMcpAuthDir();\n\t\tconst hash = createHash(\"sha256\").update(serverUrl).digest(\"hex\").slice(0, 12);\n\t\tconst host = (URL.canParse(serverUrl) ? new URL(serverUrl).hostname : \"server\").replace(/[^a-zA-Z0-9.-]/g, \"_\");\n\t\tthis.statePath = join(dir, `${host}-${hash}.json`);\n\t}\n\n\tprivate read(): PersistedAuthState {\n\t\ttry {\n\t\t\treturn JSON.parse(readFileSync(this.statePath, \"utf8\")) as PersistedAuthState;\n\t\t} catch {\n\t\t\treturn { serverUrl: this.serverUrl };\n\t\t}\n\t}\n\n\tprivate write(mutate: (state: PersistedAuthState) => void): void {\n\t\tconst state = this.read();\n\t\tmutate(state);\n\t\tmkdirSync(dirname(this.statePath), { recursive: true });\n\t\twriteFileSync(this.statePath, JSON.stringify(state, null, 2), { mode: 0o600 });\n\t}\n\n\t/**\n\t * Ensure the loopback redirect listener is running. Prefers the port used at\n\t * dynamic client registration so the persisted client stays valid; when that\n\t * port can't be re-bound the registration is dropped and the SDK registers a\n\t * fresh client against the new redirect URI.\n\t */\n\tasync ensureCallbackServer(): Promise<OAuthCallbackServer> {\n\t\tif (this.callback) return this.callback;\n\t\tif (!this.callbackStarting) {\n\t\t\tthis.callbackStarting = (async () => {\n\t\t\t\tconst persisted = this.read();\n\t\t\t\tconst preferredPort =\n\t\t\t\t\tpersisted.redirectUrl && URL.canParse(persisted.redirectUrl)\n\t\t\t\t\t\t? Number(new URL(persisted.redirectUrl).port) || undefined\n\t\t\t\t\t\t: undefined;\n\t\t\t\tconst { server, reboundPort } = await OAuthCallbackServer.start(preferredPort);\n\t\t\t\tif (preferredPort && !reboundPort) {\n\t\t\t\t\tthis.write((s) => {\n\t\t\t\t\t\ts.clientInformation = undefined;\n\t\t\t\t\t\ts.redirectUrl = undefined;\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tthis.callback = server;\n\t\t\t\treturn server;\n\t\t\t})();\n\t\t\tthis.callbackStarting.catch(() => {\n\t\t\t\tthis.callbackStarting = undefined;\n\t\t\t});\n\t\t}\n\t\treturn this.callbackStarting;\n\t}\n\n\t/** Wait for the browser redirect to deliver an authorization code. */\n\tasync waitForAuthorizationCode(timeoutMs: number): Promise<string> {\n\t\tconst server = await this.ensureCallbackServer();\n\t\treturn server.waitForCode(this.currentState, timeoutMs);\n\t}\n\n\t/** Stop the loopback listener (auth finished, failed, or connection closed). */\n\tcloseCallbackServer(): void {\n\t\tthis.callback?.close();\n\t\tthis.callback = undefined;\n\t\tthis.callbackStarting = undefined;\n\t}\n\n\t// ---- OAuthClientProvider ----\n\n\tget redirectUrl(): string | undefined {\n\t\t// Must stay defined even before the listener is up: an undefined\n\t\t// redirectUrl tells the SDK to run a non-interactive grant instead of the\n\t\t// authorization-code flow. The real port is bound in clientInformation()\n\t\t// before the SDK builds the authorization URL.\n\t\treturn this.callback?.url ?? this.read().redirectUrl ?? \"http://127.0.0.1:0/callback\";\n\t}\n\n\tget clientMetadata(): OAuthClientMetadata {\n\t\treturn {\n\t\t\tclient_name: \"hoocode\",\n\t\t\tredirect_uris: [String(this.redirectUrl)],\n\t\t\tgrant_types: [\"authorization_code\", \"refresh_token\"],\n\t\t\tresponse_types: [\"code\"],\n\t\t\ttoken_endpoint_auth_method: \"none\",\n\t\t};\n\t}\n\n\tstate(): string {\n\t\tthis.currentState ??= randomBytes(16).toString(\"hex\");\n\t\treturn this.currentState;\n\t}\n\n\tasync clientInformation(): Promise<OAuthClientInformationMixed | undefined> {\n\t\t// First provider call of every auth() run that precedes building the\n\t\t// authorization URL — bind the redirect listener here so redirectUrl is\n\t\t// live for registration and the authorization request.\n\t\tawait this.ensureCallbackServer();\n\t\treturn this.read().clientInformation;\n\t}\n\n\tsaveClientInformation(clientInformation: OAuthClientInformationMixed): void {\n\t\tconst redirectUrl = String(this.redirectUrl);\n\t\tthis.write((s) => {\n\t\t\ts.clientInformation = clientInformation;\n\t\t\ts.redirectUrl = redirectUrl;\n\t\t});\n\t}\n\n\ttokens(): OAuthTokens | undefined {\n\t\treturn this.read().tokens;\n\t}\n\n\tsaveTokens(tokens: OAuthTokens): void {\n\t\tthis.write((s) => {\n\t\t\ts.tokens = tokens;\n\t\t});\n\t}\n\n\tasync redirectToAuthorization(authorizationUrl: URL): Promise<void> {\n\t\tthis.lastAuthorizationUrl = authorizationUrl.toString();\n\t\tawait this.openBrowser(this.lastAuthorizationUrl);\n\t}\n\n\tsaveCodeVerifier(codeVerifier: string): void {\n\t\tthis.write((s) => {\n\t\t\ts.codeVerifier = codeVerifier;\n\t\t});\n\t}\n\n\tcodeVerifier(): string {\n\t\tconst verifier = this.read().codeVerifier;\n\t\tif (!verifier) throw new Error(`No PKCE code verifier stored for ${this.serverUrl}`);\n\t\treturn verifier;\n\t}\n\n\tinvalidateCredentials(scope: \"all\" | \"client\" | \"tokens\" | \"verifier\" | \"discovery\"): void {\n\t\tif (scope === \"discovery\") return;\n\t\tif (scope === \"all\" && existsSync(this.statePath)) {\n\t\t\ttry {\n\t\t\t\tunlinkSync(this.statePath);\n\t\t\t} catch {\n\t\t\t\t// fall through to field-level clearing\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tthis.write((s) => {\n\t\t\tif (scope === \"all\" || scope === \"client\") {\n\t\t\t\ts.clientInformation = undefined;\n\t\t\t\ts.redirectUrl = undefined;\n\t\t\t}\n\t\t\tif (scope === \"all\" || scope === \"tokens\") s.tokens = undefined;\n\t\t\tif (scope === \"all\" || scope === \"verifier\") s.codeVerifier = undefined;\n\t\t});\n\t}\n}\n"]}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Headless MCP tool loader: parse a standard mcp.json file (the
|
|
3
3
|
* `{ "mcpServers": { ... } }` format used by Claude Desktop, VS Code, and the
|
|
4
|
-
* hoocode CLI),
|
|
5
|
-
*
|
|
4
|
+
* hoocode CLI), connect the declared servers — stdio (`command`), Streamable
|
|
5
|
+
* HTTP (`{ "type": "http", "url": ... }`), or legacy SSE (`"type": "sse"`) —
|
|
6
|
+
* and expose their tools as AgentTool instances usable by any Agent in any
|
|
7
|
+
* process.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* earlier (tests, graceful shutdown).
|
|
9
|
+
* Connections are tracked per loader call and reaped on process exit so
|
|
10
|
+
* spawned servers never linger as orphans. Call closeMcpTools() to terminate
|
|
11
|
+
* them earlier (tests, graceful shutdown).
|
|
10
12
|
*/
|
|
11
13
|
import type { AgentTool } from "../types.js";
|
|
14
|
+
import { type McpRemoteOptions } from "./mcp-http-transport.js";
|
|
12
15
|
export interface McpToolsServerConfig {
|
|
13
16
|
/** Unique server identifier used as prefix for tool names. */
|
|
14
17
|
name: string;
|
|
@@ -19,12 +22,16 @@ export interface McpToolsServerConfig {
|
|
|
19
22
|
/** Terminate every MCP server spawned by loadMcpTools() in this process. */
|
|
20
23
|
export declare function closeMcpTools(): void;
|
|
21
24
|
/**
|
|
22
|
-
* Parse a standard mcp.json file,
|
|
25
|
+
* Parse a standard mcp.json file, connect every declared server — stdio
|
|
26
|
+
* (`command`) or remote (`{ "type": "http" | "sse", "url": ... }`) — and
|
|
23
27
|
* return their tools as AgentTool instances (named `mcp_<server>_<tool>`).
|
|
24
28
|
*
|
|
25
29
|
* An empty or server-less config resolves to []. A missing or malformed file,
|
|
26
30
|
* or a server that fails its handshake, rejects — callers decide whether MCP
|
|
27
|
-
* is optional.
|
|
31
|
+
* is optional. Connections are terminated automatically on process exit.
|
|
32
|
+
*
|
|
33
|
+
* `remoteOptions` customizes remote-server behavior (OAuth storage directory,
|
|
34
|
+
* browser opener, authorization timeout); see {@link McpRemoteOptions}.
|
|
28
35
|
*/
|
|
29
|
-
export declare function loadMcpTools(mcpJsonPath: string): Promise<AgentTool<any>[]>;
|
|
36
|
+
export declare function loadMcpTools(mcpJsonPath: string, remoteOptions?: McpRemoteOptions): Promise<AgentTool<any>[]>;
|
|
30
37
|
//# sourceMappingURL=mcp-tools.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp-tools.d.ts","sourceRoot":"","sources":["../../src/tools/mcp-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,SAAS,EAAmB,MAAM,aAAa,CAAC;AAE9D,MAAM,WAAW,oBAAoB;IACpC,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B;AAmCD,4EAA4E;AAC5E,wBAAgB,aAAa,IAAI,IAAI,CASpC;AAuJD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAwBjF","sourcesContent":["/**\n * Headless MCP tool loader: parse a standard mcp.json file (the\n * `{ \"mcpServers\": { ... } }` format used by Claude Desktop, VS Code, and the\n * hoocode CLI), spawn the declared stdio servers, and expose their tools as\n * AgentTool instances usable by any Agent in any process.\n *\n * Spawned servers are tracked per loader call and reaped on process exit so\n * they never linger as orphans. Call closeMcpTools() to terminate them\n * earlier (tests, graceful shutdown).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { readFile } from \"node:fs/promises\";\nimport { createInterface } from \"node:readline\";\nimport { type TObject, Type } from \"typebox\";\nimport type { AgentTool, AgentToolResult } from \"../types.js\";\n\nexport interface McpToolsServerConfig {\n\t/** Unique server identifier used as prefix for tool names. */\n\tname: string;\n\tcommand: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n}\n\ninterface McpToolDef {\n\tname: string;\n\tdescription?: string;\n\tinputSchema?: {\n\t\ttype?: string;\n\t\tproperties?: Record<string, { type?: string; description?: string }>;\n\t\trequired?: string[];\n\t};\n}\n\ninterface McpConnection {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\n/** Timeout for the connection handshake (initialize / tools/list). Tool calls\n * themselves are left untimed since MCP tools can be long-running. */\nconst MCP_HANDSHAKE_TIMEOUT_MS = 15000;\n\nconst liveConnections = new Set<McpConnection>();\nlet exitCleanupInstalled = false;\n\n/** Kill spawned MCP servers when the host process exits so they don't linger\n * as orphans (their stdin merely goes idle, which doesn't terminate them). */\nfunction installExitCleanup(): void {\n\tif (exitCleanupInstalled) return;\n\texitCleanupInstalled = true;\n\tprocess.once(\"exit\", () => {\n\t\tcloseMcpTools();\n\t});\n}\n\n/** Terminate every MCP server spawned by loadMcpTools() in this process. */\nexport function closeMcpTools(): void {\n\tfor (const conn of liveConnections) {\n\t\ttry {\n\t\t\tconn.terminate();\n\t\t} catch {\n\t\t\t// best-effort cleanup\n\t\t}\n\t}\n\tliveConnections.clear();\n}\n\nfunction spawnMcpServer(config: McpToolsServerConfig): McpConnection {\n\tconst proc: ChildProcess = spawn(config.command, config.args ?? [], {\n\t\tenv: { ...process.env, ...(config.env ?? {}) },\n\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t});\n\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\n\tconst rl = createInterface({ input: proc.stdout! });\n\trl.on(\"line\", (line) => {\n\t\tif (!line.trim()) return;\n\t\ttry {\n\t\t\tconst msg = JSON.parse(line) as { id?: number; result?: unknown; error?: { message: string } };\n\t\t\tif (msg.id === undefined) return;\n\t\t\tconst cb = pending.get(msg.id);\n\t\t\tif (!cb) return;\n\t\t\tpending.delete(msg.id);\n\t\t\tif (msg.error) cb.reject(new Error(msg.error.message));\n\t\t\telse cb.resolve(msg.result);\n\t\t} catch {\n\t\t\t// ignore non-JSON server startup output\n\t\t}\n\t});\n\n\tproc.on(\"exit\", () => {\n\t\tfor (const cb of pending.values()) cb.reject(new Error(`MCP server \"${config.name}\" exited unexpectedly`));\n\t\tpending.clear();\n\t});\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", id, method, params })}\\n`);\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", method, params })}\\n`);\n\t}\n\n\treturn {\n\t\trpc,\n\t\tnotify,\n\t\tterminate: () => {\n\t\t\trl.close();\n\t\t\tproc.kill();\n\t\t},\n\t};\n}\n\nasync function connectMcpServer(config: McpToolsServerConfig): Promise<{ conn: McpConnection; tools: McpToolDef[] }> {\n\tconst conn = spawnMcpServer(config);\n\ttry {\n\t\tawait conn.rpc(\n\t\t\t\"initialize\",\n\t\t\t{\n\t\t\t\tprotocolVersion: \"2024-11-05\",\n\t\t\t\tcapabilities: { tools: {} },\n\t\t\t\tclientInfo: { name: \"hoocode-agent-core\", version: \"1.0.0\" },\n\t\t\t},\n\t\t\tMCP_HANDSHAKE_TIMEOUT_MS,\n\t\t);\n\t\t// Per the MCP spec the client must acknowledge a successful initialize with\n\t\t// the initialized notification before issuing further requests; strict\n\t\t// servers gate tools/call on it.\n\t\tconn.notify(\"notifications/initialized\");\n\t\tconst toolsResult = (await conn.rpc(\"tools/list\", {}, MCP_HANDSHAKE_TIMEOUT_MS)) as { tools?: McpToolDef[] };\n\t\treturn { conn, tools: toolsResult.tools ?? [] };\n\t} catch (error) {\n\t\tconn.terminate();\n\t\tthrow error;\n\t}\n}\n\nfunction buildMcpSchema(tool: McpToolDef): TObject {\n\tconst props = tool.inputSchema?.properties ?? {};\n\tconst required = new Set(tool.inputSchema?.required ?? []);\n\tconst shape: Record<string, ReturnType<typeof Type.String>> = {};\n\n\tfor (const [key, prop] of Object.entries(props)) {\n\t\tlet field: ReturnType<typeof Type.String>;\n\t\tswitch (prop.type) {\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\t\tfield = Type.Number({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tcase \"boolean\":\n\t\t\t\tfield = Type.Boolean({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tfield = Type.String({ description: prop.description });\n\t\t}\n\t\tshape[key] = required.has(key) ? field : (Type.Optional(field) as unknown as ReturnType<typeof Type.String>);\n\t}\n\n\treturn Type.Object(shape);\n}\n\nfunction createMcpAgentTool(serverName: string, conn: McpConnection, tool: McpToolDef): AgentTool<any> {\n\tconst schema = buildMcpSchema(tool);\n\treturn {\n\t\tname: `mcp_${serverName}_${tool.name}`,\n\t\tlabel: `[MCP] ${serverName} › ${tool.name}`,\n\t\tdescription: tool.description ?? `MCP tool ${tool.name} from server ${serverName}`,\n\t\tparameters: schema,\n\t\texecute: async (_toolCallId, params, signal): Promise<AgentToolResult<undefined>> => {\n\t\t\tconst abortPromise = new Promise<never>((_, reject) => {\n\t\t\t\tif (signal?.aborted) {\n\t\t\t\t\treject(new Error(\"Aborted\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsignal?.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")), { once: true });\n\t\t\t});\n\t\t\tconst result = await Promise.race([\n\t\t\t\tconn.rpc(\"tools/call\", { name: tool.name, arguments: params }),\n\t\t\t\tabortPromise,\n\t\t\t]);\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: JSON.stringify(result, null, 2) }],\n\t\t\t\tdetails: undefined,\n\t\t\t};\n\t\t},\n\t};\n}\n\ninterface StandardMcpConfig {\n\tmcpServers?: Record<string, { command: string; args?: string[]; env?: Record<string, string> }>;\n}\n\n/**\n * Parse a standard mcp.json file, start every declared stdio server, and\n * return their tools as AgentTool instances (named `mcp_<server>_<tool>`).\n *\n * An empty or server-less config resolves to []. A missing or malformed file,\n * or a server that fails its handshake, rejects — callers decide whether MCP\n * is optional. Spawned servers are terminated automatically on process exit.\n */\nexport async function loadMcpTools(mcpJsonPath: string): Promise<AgentTool<any>[]> {\n\tconst raw = await readFile(mcpJsonPath, \"utf-8\");\n\tconst parsed = JSON.parse(raw) as StandardMcpConfig;\n\tconst servers = Object.entries(parsed.mcpServers ?? {});\n\tif (servers.length === 0) return [];\n\n\tinstallExitCleanup();\n\tconst tools: AgentTool<any>[] = [];\n\tfor (const [name, serverConfig] of servers) {\n\t\tif (!serverConfig || typeof serverConfig.command !== \"string\") {\n\t\t\tthrow new Error(`${mcpJsonPath}: mcpServers[\"${name}\"] is missing a \"command\"`);\n\t\t}\n\t\tconst { conn, tools: toolDefs } = await connectMcpServer({\n\t\t\tname,\n\t\t\tcommand: serverConfig.command,\n\t\t\targs: serverConfig.args,\n\t\t\tenv: serverConfig.env,\n\t\t});\n\t\tliveConnections.add(conn);\n\t\tfor (const toolDef of toolDefs) {\n\t\t\ttools.push(createMcpAgentTool(name, conn, toolDef));\n\t\t}\n\t}\n\treturn tools;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"mcp-tools.d.ts","sourceRoot":"","sources":["../../src/tools/mcp-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAAE,SAAS,EAAmB,MAAM,aAAa,CAAC;AAC9D,OAAO,EAAwB,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAEtF,MAAM,WAAW,oBAAoB;IACpC,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B;AAmCD,4EAA4E;AAC5E,wBAAgB,aAAa,IAAI,IAAI,CASpC;AA6LD;;;;;;;;;;;GAWG;AACH,wBAAsB,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAuCnH","sourcesContent":["/**\n * Headless MCP tool loader: parse a standard mcp.json file (the\n * `{ \"mcpServers\": { ... } }` format used by Claude Desktop, VS Code, and the\n * hoocode CLI), connect the declared servers — stdio (`command`), Streamable\n * HTTP (`{ \"type\": \"http\", \"url\": ... }`), or legacy SSE (`\"type\": \"sse\"`) —\n * and expose their tools as AgentTool instances usable by any Agent in any\n * process.\n *\n * Connections are tracked per loader call and reaped on process exit so\n * spawned servers never linger as orphans. Call closeMcpTools() to terminate\n * them earlier (tests, graceful shutdown).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { readFile } from \"node:fs/promises\";\nimport { createInterface } from \"node:readline\";\nimport { type TObject, Type } from \"typebox\";\nimport type { AgentTool, AgentToolResult } from \"../types.js\";\nimport { connectHttpMcpServer, type McpRemoteOptions } from \"./mcp-http-transport.js\";\n\nexport interface McpToolsServerConfig {\n\t/** Unique server identifier used as prefix for tool names. */\n\tname: string;\n\tcommand: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n}\n\ninterface McpToolDef {\n\tname: string;\n\tdescription?: string;\n\tinputSchema?: {\n\t\ttype?: string;\n\t\tproperties?: Record<string, { type?: string; description?: string }>;\n\t\trequired?: string[];\n\t};\n}\n\ninterface McpConnection {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\n/** Timeout for the connection handshake (initialize / tools/list). Tool calls\n * themselves are left untimed since MCP tools can be long-running. */\nconst MCP_HANDSHAKE_TIMEOUT_MS = 15000;\n\nconst liveConnections = new Set<McpConnection>();\nlet exitCleanupInstalled = false;\n\n/** Kill spawned MCP servers when the host process exits so they don't linger\n * as orphans (their stdin merely goes idle, which doesn't terminate them). */\nfunction installExitCleanup(): void {\n\tif (exitCleanupInstalled) return;\n\texitCleanupInstalled = true;\n\tprocess.once(\"exit\", () => {\n\t\tcloseMcpTools();\n\t});\n}\n\n/** Terminate every MCP server spawned by loadMcpTools() in this process. */\nexport function closeMcpTools(): void {\n\tfor (const conn of liveConnections) {\n\t\ttry {\n\t\t\tconn.terminate();\n\t\t} catch {\n\t\t\t// best-effort cleanup\n\t\t}\n\t}\n\tliveConnections.clear();\n}\n\nfunction spawnMcpServer(config: McpToolsServerConfig): McpConnection {\n\tconst proc: ChildProcess = spawn(config.command, config.args ?? [], {\n\t\tenv: { ...process.env, ...(config.env ?? {}) },\n\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t});\n\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\n\tconst rl = createInterface({ input: proc.stdout! });\n\trl.on(\"line\", (line) => {\n\t\tif (!line.trim()) return;\n\t\ttry {\n\t\t\tconst msg = JSON.parse(line) as { id?: number; result?: unknown; error?: { message: string } };\n\t\t\tif (msg.id === undefined) return;\n\t\t\tconst cb = pending.get(msg.id);\n\t\t\tif (!cb) return;\n\t\t\tpending.delete(msg.id);\n\t\t\tif (msg.error) cb.reject(new Error(msg.error.message));\n\t\t\telse cb.resolve(msg.result);\n\t\t} catch {\n\t\t\t// ignore non-JSON server startup output\n\t\t}\n\t});\n\n\tproc.on(\"exit\", () => {\n\t\tfor (const cb of pending.values()) cb.reject(new Error(`MCP server \"${config.name}\" exited unexpectedly`));\n\t\tpending.clear();\n\t});\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", id, method, params })}\\n`);\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", method, params })}\\n`);\n\t}\n\n\treturn {\n\t\trpc,\n\t\tnotify,\n\t\tterminate: () => {\n\t\t\trl.close();\n\t\t\tproc.kill();\n\t\t},\n\t};\n}\n\nasync function handshake(conn: McpConnection): Promise<McpToolDef[]> {\n\tawait conn.rpc(\n\t\t\"initialize\",\n\t\t{\n\t\t\tprotocolVersion: \"2024-11-05\",\n\t\t\tcapabilities: { tools: {} },\n\t\t\tclientInfo: { name: \"hoocode-agent-core\", version: \"1.0.0\" },\n\t\t},\n\t\tMCP_HANDSHAKE_TIMEOUT_MS,\n\t);\n\t// Per the MCP spec the client must acknowledge a successful initialize with\n\t// the initialized notification before issuing further requests; strict\n\t// servers gate tools/call on it.\n\tconn.notify(\"notifications/initialized\");\n\tconst toolsResult = (await conn.rpc(\"tools/list\", {}, MCP_HANDSHAKE_TIMEOUT_MS)) as { tools?: McpToolDef[] };\n\treturn toolsResult.tools ?? [];\n}\n\nasync function connectMcpServer(config: McpToolsServerConfig): Promise<{ conn: McpConnection; tools: McpToolDef[] }> {\n\tconst conn = spawnMcpServer(config);\n\ttry {\n\t\treturn { conn, tools: await handshake(conn) };\n\t} catch (error) {\n\t\tconn.terminate();\n\t\tthrow error;\n\t}\n}\n\nfunction buildMcpSchema(tool: McpToolDef): TObject {\n\tconst props = tool.inputSchema?.properties ?? {};\n\tconst required = new Set(tool.inputSchema?.required ?? []);\n\tconst shape: Record<string, ReturnType<typeof Type.String>> = {};\n\n\tfor (const [key, prop] of Object.entries(props)) {\n\t\tlet field: ReturnType<typeof Type.String>;\n\t\tswitch (prop.type) {\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\t\tfield = Type.Number({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tcase \"boolean\":\n\t\t\t\tfield = Type.Boolean({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tfield = Type.String({ description: prop.description });\n\t\t}\n\t\tshape[key] = required.has(key) ? field : (Type.Optional(field) as unknown as ReturnType<typeof Type.String>);\n\t}\n\n\treturn Type.Object(shape);\n}\n\nfunction createMcpAgentTool(serverName: string, conn: McpConnection, tool: McpToolDef): AgentTool<any> {\n\tconst schema = buildMcpSchema(tool);\n\treturn {\n\t\tname: `mcp_${serverName}_${tool.name}`,\n\t\tlabel: `[MCP] ${serverName} › ${tool.name}`,\n\t\tdescription: tool.description ?? `MCP tool ${tool.name} from server ${serverName}`,\n\t\tparameters: schema,\n\t\texecute: async (_toolCallId, params, signal): Promise<AgentToolResult<undefined>> => {\n\t\t\tconst abortPromise = new Promise<never>((_, reject) => {\n\t\t\t\tif (signal?.aborted) {\n\t\t\t\t\treject(new Error(\"Aborted\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsignal?.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")), { once: true });\n\t\t\t});\n\t\t\tconst result = await Promise.race([\n\t\t\t\tconn.rpc(\"tools/call\", { name: tool.name, arguments: params }),\n\t\t\t\tabortPromise,\n\t\t\t]);\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: JSON.stringify(result, null, 2) }],\n\t\t\t\tdetails: undefined,\n\t\t\t};\n\t\t},\n\t};\n}\n\ninterface StandardMcpServerEntry {\n\tcommand?: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n\t/** \"stdio\" (default with command), \"http\" (Streamable HTTP), or \"sse\" (legacy). */\n\ttype?: string;\n\t/** Remote server URL for http/sse transports. */\n\turl?: string;\n\t/** Extra HTTP headers (e.g. Authorization) for remote transports. */\n\theaders?: Record<string, string>;\n}\n\ninterface StandardMcpConfig {\n\tmcpServers?: Record<string, StandardMcpServerEntry>;\n}\n\nasync function connectRemoteMcpServer(\n\tname: string,\n\tentry: StandardMcpServerEntry,\n\tremoteOptions?: McpRemoteOptions,\n): Promise<{ conn: McpConnection; tools: McpToolDef[] }> {\n\tconst conn = connectHttpMcpServer(\n\t\t{\n\t\t\tname,\n\t\t\turl: entry.url!,\n\t\t\theaders: entry.headers,\n\t\t\ttype: entry.type === \"sse\" ? \"sse\" : \"http\",\n\t\t},\n\t\tremoteOptions,\n\t);\n\ttry {\n\t\treturn { conn, tools: await handshake(conn) };\n\t} catch (error) {\n\t\tconn.terminate();\n\t\tthrow error;\n\t}\n}\n\n/**\n * Parse a standard mcp.json file, connect every declared server — stdio\n * (`command`) or remote (`{ \"type\": \"http\" | \"sse\", \"url\": ... }`) — and\n * return their tools as AgentTool instances (named `mcp_<server>_<tool>`).\n *\n * An empty or server-less config resolves to []. A missing or malformed file,\n * or a server that fails its handshake, rejects — callers decide whether MCP\n * is optional. Connections are terminated automatically on process exit.\n *\n * `remoteOptions` customizes remote-server behavior (OAuth storage directory,\n * browser opener, authorization timeout); see {@link McpRemoteOptions}.\n */\nexport async function loadMcpTools(mcpJsonPath: string, remoteOptions?: McpRemoteOptions): Promise<AgentTool<any>[]> {\n\tconst raw = await readFile(mcpJsonPath, \"utf-8\");\n\tconst parsed = JSON.parse(raw) as StandardMcpConfig;\n\tconst servers = Object.entries(parsed.mcpServers ?? {});\n\tif (servers.length === 0) return [];\n\n\tinstallExitCleanup();\n\tconst tools: AgentTool<any>[] = [];\n\tfor (const [name, serverConfig] of servers) {\n\t\tconst isRemote =\n\t\t\tserverConfig &&\n\t\t\t(serverConfig.type === \"http\" ||\n\t\t\t\tserverConfig.type === \"sse\" ||\n\t\t\t\t(typeof serverConfig.command !== \"string\" && typeof serverConfig.url === \"string\"));\n\t\tlet connected: { conn: McpConnection; tools: McpToolDef[] };\n\t\tif (isRemote) {\n\t\t\tif (typeof serverConfig.url !== \"string\") {\n\t\t\t\tthrow new Error(`${mcpJsonPath}: mcpServers[\"${name}\"] has type \"${serverConfig.type}\" but no \"url\"`);\n\t\t\t}\n\t\t\tconnected = await connectRemoteMcpServer(name, serverConfig, remoteOptions);\n\t\t} else {\n\t\t\tif (!serverConfig || typeof serverConfig.command !== \"string\") {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`${mcpJsonPath}: mcpServers[\"${name}\"] is missing a \"command\" (or a \"url\" for remote servers)`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconnected = await connectMcpServer({\n\t\t\t\tname,\n\t\t\t\tcommand: serverConfig.command,\n\t\t\t\targs: serverConfig.args,\n\t\t\t\tenv: serverConfig.env,\n\t\t\t});\n\t\t}\n\t\tliveConnections.add(connected.conn);\n\t\tfor (const toolDef of connected.tools) {\n\t\t\ttools.push(createMcpAgentTool(name, connected.conn, toolDef));\n\t\t}\n\t}\n\treturn tools;\n}\n"]}
|
package/dist/tools/mcp-tools.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Headless MCP tool loader: parse a standard mcp.json file (the
|
|
3
3
|
* `{ "mcpServers": { ... } }` format used by Claude Desktop, VS Code, and the
|
|
4
|
-
* hoocode CLI),
|
|
5
|
-
*
|
|
4
|
+
* hoocode CLI), connect the declared servers — stdio (`command`), Streamable
|
|
5
|
+
* HTTP (`{ "type": "http", "url": ... }`), or legacy SSE (`"type": "sse"`) —
|
|
6
|
+
* and expose their tools as AgentTool instances usable by any Agent in any
|
|
7
|
+
* process.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* earlier (tests, graceful shutdown).
|
|
9
|
+
* Connections are tracked per loader call and reaped on process exit so
|
|
10
|
+
* spawned servers never linger as orphans. Call closeMcpTools() to terminate
|
|
11
|
+
* them earlier (tests, graceful shutdown).
|
|
10
12
|
*/
|
|
11
13
|
import { spawn } from "node:child_process";
|
|
12
14
|
import { readFile } from "node:fs/promises";
|
|
13
15
|
import { createInterface } from "node:readline";
|
|
14
16
|
import { Type } from "typebox";
|
|
17
|
+
import { connectHttpMcpServer } from "./mcp-http-transport.js";
|
|
15
18
|
/** Timeout for the connection handshake (initialize / tools/list). Tool calls
|
|
16
19
|
* themselves are left untimed since MCP tools can be long-running. */
|
|
17
20
|
const MCP_HANDSHAKE_TIMEOUT_MS = 15000;
|
|
@@ -111,20 +114,23 @@ function spawnMcpServer(config) {
|
|
|
111
114
|
},
|
|
112
115
|
};
|
|
113
116
|
}
|
|
117
|
+
async function handshake(conn) {
|
|
118
|
+
await conn.rpc("initialize", {
|
|
119
|
+
protocolVersion: "2024-11-05",
|
|
120
|
+
capabilities: { tools: {} },
|
|
121
|
+
clientInfo: { name: "hoocode-agent-core", version: "1.0.0" },
|
|
122
|
+
}, MCP_HANDSHAKE_TIMEOUT_MS);
|
|
123
|
+
// Per the MCP spec the client must acknowledge a successful initialize with
|
|
124
|
+
// the initialized notification before issuing further requests; strict
|
|
125
|
+
// servers gate tools/call on it.
|
|
126
|
+
conn.notify("notifications/initialized");
|
|
127
|
+
const toolsResult = (await conn.rpc("tools/list", {}, MCP_HANDSHAKE_TIMEOUT_MS));
|
|
128
|
+
return toolsResult.tools ?? [];
|
|
129
|
+
}
|
|
114
130
|
async function connectMcpServer(config) {
|
|
115
131
|
const conn = spawnMcpServer(config);
|
|
116
132
|
try {
|
|
117
|
-
|
|
118
|
-
protocolVersion: "2024-11-05",
|
|
119
|
-
capabilities: { tools: {} },
|
|
120
|
-
clientInfo: { name: "hoocode-agent-core", version: "1.0.0" },
|
|
121
|
-
}, MCP_HANDSHAKE_TIMEOUT_MS);
|
|
122
|
-
// Per the MCP spec the client must acknowledge a successful initialize with
|
|
123
|
-
// the initialized notification before issuing further requests; strict
|
|
124
|
-
// servers gate tools/call on it.
|
|
125
|
-
conn.notify("notifications/initialized");
|
|
126
|
-
const toolsResult = (await conn.rpc("tools/list", {}, MCP_HANDSHAKE_TIMEOUT_MS));
|
|
127
|
-
return { conn, tools: toolsResult.tools ?? [] };
|
|
133
|
+
return { conn, tools: await handshake(conn) };
|
|
128
134
|
}
|
|
129
135
|
catch (error) {
|
|
130
136
|
conn.terminate();
|
|
@@ -178,15 +184,34 @@ function createMcpAgentTool(serverName, conn, tool) {
|
|
|
178
184
|
},
|
|
179
185
|
};
|
|
180
186
|
}
|
|
187
|
+
async function connectRemoteMcpServer(name, entry, remoteOptions) {
|
|
188
|
+
const conn = connectHttpMcpServer({
|
|
189
|
+
name,
|
|
190
|
+
url: entry.url,
|
|
191
|
+
headers: entry.headers,
|
|
192
|
+
type: entry.type === "sse" ? "sse" : "http",
|
|
193
|
+
}, remoteOptions);
|
|
194
|
+
try {
|
|
195
|
+
return { conn, tools: await handshake(conn) };
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
conn.terminate();
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
181
202
|
/**
|
|
182
|
-
* Parse a standard mcp.json file,
|
|
203
|
+
* Parse a standard mcp.json file, connect every declared server — stdio
|
|
204
|
+
* (`command`) or remote (`{ "type": "http" | "sse", "url": ... }`) — and
|
|
183
205
|
* return their tools as AgentTool instances (named `mcp_<server>_<tool>`).
|
|
184
206
|
*
|
|
185
207
|
* An empty or server-less config resolves to []. A missing or malformed file,
|
|
186
208
|
* or a server that fails its handshake, rejects — callers decide whether MCP
|
|
187
|
-
* is optional.
|
|
209
|
+
* is optional. Connections are terminated automatically on process exit.
|
|
210
|
+
*
|
|
211
|
+
* `remoteOptions` customizes remote-server behavior (OAuth storage directory,
|
|
212
|
+
* browser opener, authorization timeout); see {@link McpRemoteOptions}.
|
|
188
213
|
*/
|
|
189
|
-
export async function loadMcpTools(mcpJsonPath) {
|
|
214
|
+
export async function loadMcpTools(mcpJsonPath, remoteOptions) {
|
|
190
215
|
const raw = await readFile(mcpJsonPath, "utf-8");
|
|
191
216
|
const parsed = JSON.parse(raw);
|
|
192
217
|
const servers = Object.entries(parsed.mcpServers ?? {});
|
|
@@ -195,18 +220,31 @@ export async function loadMcpTools(mcpJsonPath) {
|
|
|
195
220
|
installExitCleanup();
|
|
196
221
|
const tools = [];
|
|
197
222
|
for (const [name, serverConfig] of servers) {
|
|
198
|
-
|
|
199
|
-
|
|
223
|
+
const isRemote = serverConfig &&
|
|
224
|
+
(serverConfig.type === "http" ||
|
|
225
|
+
serverConfig.type === "sse" ||
|
|
226
|
+
(typeof serverConfig.command !== "string" && typeof serverConfig.url === "string"));
|
|
227
|
+
let connected;
|
|
228
|
+
if (isRemote) {
|
|
229
|
+
if (typeof serverConfig.url !== "string") {
|
|
230
|
+
throw new Error(`${mcpJsonPath}: mcpServers["${name}"] has type "${serverConfig.type}" but no "url"`);
|
|
231
|
+
}
|
|
232
|
+
connected = await connectRemoteMcpServer(name, serverConfig, remoteOptions);
|
|
200
233
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
234
|
+
else {
|
|
235
|
+
if (!serverConfig || typeof serverConfig.command !== "string") {
|
|
236
|
+
throw new Error(`${mcpJsonPath}: mcpServers["${name}"] is missing a "command" (or a "url" for remote servers)`);
|
|
237
|
+
}
|
|
238
|
+
connected = await connectMcpServer({
|
|
239
|
+
name,
|
|
240
|
+
command: serverConfig.command,
|
|
241
|
+
args: serverConfig.args,
|
|
242
|
+
env: serverConfig.env,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
liveConnections.add(connected.conn);
|
|
246
|
+
for (const toolDef of connected.tools) {
|
|
247
|
+
tools.push(createMcpAgentTool(name, connected.conn, toolDef));
|
|
210
248
|
}
|
|
211
249
|
}
|
|
212
250
|
return tools;
|