codelocal 1.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/approval-memory.js +105 -0
- package/dist/audit.js +34 -0
- package/dist/chat-approval.js +77 -0
- package/dist/cli-saas.js +311 -0
- package/dist/cli.js +344 -0
- package/dist/client-entry-v2.js +22 -0
- package/dist/client-v2.js +910 -0
- package/dist/cloud-client-sync.js +6 -0
- package/dist/context-engine.js +295 -0
- package/dist/editing-engine.js +205 -0
- package/dist/identity.js +30 -0
- package/dist/log.js +235 -0
- package/dist/lsp.js +288 -0
- package/dist/mcp-cloud-sync.js +3 -0
- package/dist/mcp-hub.js +508 -0
- package/dist/native-watcher.js +148 -0
- package/dist/process-manager.js +261 -0
- package/dist/protocol.js +52 -0
- package/dist/runtime-daemon.js +162 -0
- package/dist/security-policy.js +293 -0
- package/dist/semantic-router.js +378 -0
- package/dist/semantic.js +263 -0
- package/dist/state.js +110 -0
- package/dist/terminal-history.js +102 -0
- package/dist/verification.js +66 -0
- package/dist/workspace-index.js +457 -0
- package/dist/workspace-registry.js +86 -0
- package/package.json +31 -0
package/dist/mcp-hub.js
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { audit } from "./audit.js";
|
|
9
|
+
const REGISTRY_VERSION = 1;
|
|
10
|
+
const CATALOG_VERSION = 1;
|
|
11
|
+
const MAX_CATALOG_TOOLS = Number(process.env.CODELOCAL_MCP_MAX_TOOLS ?? 5000);
|
|
12
|
+
const MAX_STDERR_TAIL = 16 * 1024;
|
|
13
|
+
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
14
|
+
function normalizeRoot(value) {
|
|
15
|
+
return path.resolve(value);
|
|
16
|
+
}
|
|
17
|
+
function stateRoot() {
|
|
18
|
+
return process.env.CODELOCAL_STATE_DIR ?? path.join(os.homedir(), ".codelocal");
|
|
19
|
+
}
|
|
20
|
+
export function mcpStatePaths() {
|
|
21
|
+
const dir = path.join(stateRoot(), "mcp");
|
|
22
|
+
return {
|
|
23
|
+
dir,
|
|
24
|
+
registry: path.join(dir, "registry.json"),
|
|
25
|
+
catalog: path.join(dir, "catalog.json"),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function ensurePrivateDir(dir) {
|
|
29
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
if (process.platform !== "win32")
|
|
31
|
+
await fs.chmod(dir, 0o700).catch(() => undefined);
|
|
32
|
+
}
|
|
33
|
+
async function readJson(file, fallback) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code === "ENOENT")
|
|
39
|
+
return fallback;
|
|
40
|
+
throw new Error(`Invalid CodeLocal MCP state file ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function writeJsonAtomic(file, value) {
|
|
44
|
+
await ensurePrivateDir(path.dirname(file));
|
|
45
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
46
|
+
const payload = JSON.stringify(value, null, 2) + "\n";
|
|
47
|
+
await fs.writeFile(temp, payload, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
48
|
+
if (process.platform !== "win32")
|
|
49
|
+
await fs.chmod(temp, 0o600).catch(() => undefined);
|
|
50
|
+
await fs.rename(temp, file);
|
|
51
|
+
}
|
|
52
|
+
function validateName(name) {
|
|
53
|
+
if (!NAME_RE.test(name))
|
|
54
|
+
throw new Error("MCP name must match [a-zA-Z0-9][a-zA-Z0-9._-]{0,63}.");
|
|
55
|
+
return name;
|
|
56
|
+
}
|
|
57
|
+
function validateEnvName(name) {
|
|
58
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
59
|
+
throw new Error(`Invalid environment variable name: ${name}`);
|
|
60
|
+
return name;
|
|
61
|
+
}
|
|
62
|
+
function isLoopback(hostname) {
|
|
63
|
+
const host = hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
64
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "0.0.0.0";
|
|
65
|
+
}
|
|
66
|
+
function validateRemoteUrl(raw) {
|
|
67
|
+
const url = new URL(raw);
|
|
68
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
69
|
+
throw new Error("Remote MCP URL must use http or https.");
|
|
70
|
+
if (url.protocol === "http:" && !isLoopback(url.hostname) && process.env.CODELOCAL_MCP_ALLOW_INSECURE_HTTP !== "1") {
|
|
71
|
+
throw new Error("Remote MCP must use HTTPS unless it is localhost. Set CODELOCAL_MCP_ALLOW_INSECURE_HTTP=1 only for trusted development endpoints.");
|
|
72
|
+
}
|
|
73
|
+
return url.toString();
|
|
74
|
+
}
|
|
75
|
+
function materializeEnv(refs) {
|
|
76
|
+
if (!refs)
|
|
77
|
+
return undefined;
|
|
78
|
+
const output = {};
|
|
79
|
+
for (const [target, ref] of Object.entries(refs)) {
|
|
80
|
+
validateEnvName(target);
|
|
81
|
+
validateEnvName(ref.source);
|
|
82
|
+
const value = process.env[ref.source];
|
|
83
|
+
if (value == null)
|
|
84
|
+
throw new Error(`MCP requires environment variable ${ref.source} for ${target}.`);
|
|
85
|
+
output[target] = value;
|
|
86
|
+
}
|
|
87
|
+
return output;
|
|
88
|
+
}
|
|
89
|
+
function materializeHeaders(refs) {
|
|
90
|
+
if (!refs)
|
|
91
|
+
return undefined;
|
|
92
|
+
const headers = {};
|
|
93
|
+
for (const [header, ref] of Object.entries(refs)) {
|
|
94
|
+
validateEnvName(ref.source);
|
|
95
|
+
const value = process.env[ref.source];
|
|
96
|
+
if (value == null)
|
|
97
|
+
throw new Error(`MCP requires environment variable ${ref.source} for HTTP header ${header}.`);
|
|
98
|
+
headers[header] = `${ref.prefix ?? ""}${value}`;
|
|
99
|
+
}
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
102
|
+
function normalizeConfig(input, previous) {
|
|
103
|
+
const name = validateName(input.name.trim());
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (input.transport === "stdio") {
|
|
106
|
+
if (!input.command?.trim())
|
|
107
|
+
throw new Error("stdio MCP requires a command.");
|
|
108
|
+
if (input.url)
|
|
109
|
+
throw new Error("stdio MCP cannot also define a URL.");
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
if (!input.url)
|
|
113
|
+
throw new Error("remote MCP requires a URL.");
|
|
114
|
+
validateRemoteUrl(input.url);
|
|
115
|
+
if (input.command)
|
|
116
|
+
throw new Error("remote MCP cannot also define a command.");
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...input,
|
|
120
|
+
name,
|
|
121
|
+
enabled: input.enabled !== false,
|
|
122
|
+
workspaceRoot: input.scope === "workspace" ? normalizeRoot(input.workspaceRoot ?? process.cwd()) : undefined,
|
|
123
|
+
command: input.command?.trim(),
|
|
124
|
+
args: input.args?.map(String),
|
|
125
|
+
url: input.url ? validateRemoteUrl(input.url) : undefined,
|
|
126
|
+
addedAt: previous?.addedAt ?? now,
|
|
127
|
+
updatedAt: now,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function configKey(server) {
|
|
131
|
+
if (server.scope === "global")
|
|
132
|
+
return `global:${server.name}`;
|
|
133
|
+
const rootHash = createHash("sha256").update(server.workspaceRoot ?? "").digest("hex").slice(0, 20);
|
|
134
|
+
return `workspace:${server.name}:${rootHash}`;
|
|
135
|
+
}
|
|
136
|
+
function effectiveServers(registry, workspaceRoot) {
|
|
137
|
+
const byName = new Map();
|
|
138
|
+
for (const server of registry.servers) {
|
|
139
|
+
if (server.scope === "global")
|
|
140
|
+
byName.set(server.name, server);
|
|
141
|
+
}
|
|
142
|
+
for (const server of registry.servers) {
|
|
143
|
+
if (server.scope === "workspace" && server.workspaceRoot === workspaceRoot)
|
|
144
|
+
byName.set(server.name, server);
|
|
145
|
+
}
|
|
146
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
147
|
+
}
|
|
148
|
+
function tokens(value) {
|
|
149
|
+
return value.toLowerCase().split(/[^a-z0-9_./:-]+/).filter(Boolean);
|
|
150
|
+
}
|
|
151
|
+
function searchScore(tool, query) {
|
|
152
|
+
const q = query.trim().toLowerCase();
|
|
153
|
+
if (!q)
|
|
154
|
+
return 1;
|
|
155
|
+
const hayName = `${tool.server}.${tool.name}`.toLowerCase();
|
|
156
|
+
const title = (tool.title ?? "").toLowerCase();
|
|
157
|
+
const description = (tool.description ?? "").toLowerCase();
|
|
158
|
+
const schema = JSON.stringify(tool.inputSchema ?? {}).toLowerCase();
|
|
159
|
+
let score = 0;
|
|
160
|
+
if (hayName === q || tool.name.toLowerCase() === q)
|
|
161
|
+
score += 100;
|
|
162
|
+
if (hayName.includes(q))
|
|
163
|
+
score += 40;
|
|
164
|
+
if (title.includes(q))
|
|
165
|
+
score += 24;
|
|
166
|
+
if (description.includes(q))
|
|
167
|
+
score += 12;
|
|
168
|
+
for (const term of tokens(q)) {
|
|
169
|
+
if (tool.name.toLowerCase() === term)
|
|
170
|
+
score += 25;
|
|
171
|
+
else if (tool.name.toLowerCase().includes(term))
|
|
172
|
+
score += 14;
|
|
173
|
+
if (tool.server.toLowerCase().includes(term))
|
|
174
|
+
score += 9;
|
|
175
|
+
if (title.includes(term))
|
|
176
|
+
score += 7;
|
|
177
|
+
if (description.includes(term))
|
|
178
|
+
score += 4;
|
|
179
|
+
if (schema.includes(term))
|
|
180
|
+
score += 1;
|
|
181
|
+
}
|
|
182
|
+
return score;
|
|
183
|
+
}
|
|
184
|
+
function safeConnectDetail(config) {
|
|
185
|
+
if (config.transport === "stdio")
|
|
186
|
+
return `stdio ${config.command ?? config.name}`;
|
|
187
|
+
try {
|
|
188
|
+
const url = new URL(config.url ?? "");
|
|
189
|
+
url.username = "";
|
|
190
|
+
url.password = "";
|
|
191
|
+
url.search = "";
|
|
192
|
+
url.hash = "";
|
|
193
|
+
return `http ${url.toString()}`;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return `http ${config.name}`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function defaultRuntimeConnectGuard(workspaceRoot) {
|
|
200
|
+
if (!process.env.SERVER_URL || !process.env.PROJECT_ROOT || process.env.CODELOCAL_MCP_START_APPROVAL === "0")
|
|
201
|
+
return undefined;
|
|
202
|
+
return async (config) => {
|
|
203
|
+
await audit({
|
|
204
|
+
event: "policy.mcp_runtime_start_blocked",
|
|
205
|
+
workspaceKey: workspaceRoot,
|
|
206
|
+
riskLevel: "REVIEW",
|
|
207
|
+
status: "blocked",
|
|
208
|
+
detail: { server: config.name, transport: config.transport, rule: `mcp-runtime:${config.name}` },
|
|
209
|
+
});
|
|
210
|
+
throw new Error(`Starting installed MCP runtime requires chat-mediated approval. Use mcp_call from ChatGPT: ${config.name}`);
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
export class McpHub {
|
|
214
|
+
workspaceRoot;
|
|
215
|
+
beforeConnect;
|
|
216
|
+
sessions = new Map();
|
|
217
|
+
constructor(workspaceRoot = process.cwd(), beforeConnect) {
|
|
218
|
+
this.workspaceRoot = workspaceRoot;
|
|
219
|
+
this.beforeConnect = beforeConnect;
|
|
220
|
+
this.workspaceRoot = normalizeRoot(workspaceRoot);
|
|
221
|
+
this.beforeConnect ??= defaultRuntimeConnectGuard(this.workspaceRoot);
|
|
222
|
+
}
|
|
223
|
+
async registry() {
|
|
224
|
+
const value = await readJson(mcpStatePaths().registry, { version: REGISTRY_VERSION, servers: [] });
|
|
225
|
+
if (value.version !== REGISTRY_VERSION || !Array.isArray(value.servers))
|
|
226
|
+
throw new Error("Unsupported CodeLocal MCP registry format.");
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
async catalogFile() {
|
|
230
|
+
const value = await readJson(mcpStatePaths().catalog, { version: CATALOG_VERSION, tools: [] });
|
|
231
|
+
if (value.version !== CATALOG_VERSION || !Array.isArray(value.tools))
|
|
232
|
+
throw new Error("Unsupported CodeLocal MCP catalog format.");
|
|
233
|
+
const tools = value.tools.filter((tool) => typeof tool?.serverKey === "string" && typeof tool?.server === "string" && typeof tool?.name === "string");
|
|
234
|
+
return { version: CATALOG_VERSION, tools };
|
|
235
|
+
}
|
|
236
|
+
async addServer(input) {
|
|
237
|
+
const registry = await this.registry();
|
|
238
|
+
const previous = registry.servers.find((server) => server.name === input.name && server.scope === input.scope && (server.scope === "global" || server.workspaceRoot === normalizeRoot(input.workspaceRoot ?? this.workspaceRoot)));
|
|
239
|
+
const normalized = normalizeConfig({ ...input, workspaceRoot: input.scope === "workspace" ? (input.workspaceRoot ?? this.workspaceRoot) : undefined }, previous);
|
|
240
|
+
registry.servers = registry.servers.filter((server) => !(server.name === normalized.name && server.scope === normalized.scope && (server.scope === "global" || server.workspaceRoot === normalized.workspaceRoot)));
|
|
241
|
+
registry.servers.push(normalized);
|
|
242
|
+
registry.servers.sort((a, b) => `${a.scope}:${a.name}:${a.workspaceRoot ?? ""}`.localeCompare(`${b.scope}:${b.name}:${b.workspaceRoot ?? ""}`));
|
|
243
|
+
await writeJsonAtomic(mcpStatePaths().registry, registry);
|
|
244
|
+
const catalog = await this.catalogFile();
|
|
245
|
+
const key = configKey(normalized);
|
|
246
|
+
const filtered = catalog.tools.filter((tool) => tool.serverKey !== key);
|
|
247
|
+
if (filtered.length !== catalog.tools.length)
|
|
248
|
+
await writeJsonAtomic(mcpStatePaths().catalog, { version: CATALOG_VERSION, tools: filtered });
|
|
249
|
+
await this.disconnect(normalized.name);
|
|
250
|
+
return this.publicServer(normalized);
|
|
251
|
+
}
|
|
252
|
+
async removeServer(name, scope) {
|
|
253
|
+
validateName(name);
|
|
254
|
+
const registry = await this.registry();
|
|
255
|
+
const removedConfigs = [];
|
|
256
|
+
const kept = [];
|
|
257
|
+
for (const server of registry.servers) {
|
|
258
|
+
const matchesName = server.name === name;
|
|
259
|
+
const matchesScope = !scope || server.scope === scope;
|
|
260
|
+
const visibleWorkspaceConfig = server.scope !== "workspace" || server.workspaceRoot === this.workspaceRoot;
|
|
261
|
+
if (matchesName && matchesScope && visibleWorkspaceConfig)
|
|
262
|
+
removedConfigs.push(server);
|
|
263
|
+
else
|
|
264
|
+
kept.push(server);
|
|
265
|
+
}
|
|
266
|
+
registry.servers = kept;
|
|
267
|
+
if (removedConfigs.length)
|
|
268
|
+
await writeJsonAtomic(mcpStatePaths().registry, registry);
|
|
269
|
+
if (removedConfigs.length) {
|
|
270
|
+
const removedKeys = new Set(removedConfigs.map(configKey));
|
|
271
|
+
const catalog = await this.catalogFile();
|
|
272
|
+
const filtered = catalog.tools.filter((tool) => !removedKeys.has(tool.serverKey));
|
|
273
|
+
if (filtered.length !== catalog.tools.length)
|
|
274
|
+
await writeJsonAtomic(mcpStatePaths().catalog, { version: CATALOG_VERSION, tools: filtered });
|
|
275
|
+
}
|
|
276
|
+
await this.disconnect(name);
|
|
277
|
+
return { removed: removedConfigs.length };
|
|
278
|
+
}
|
|
279
|
+
async listServers() {
|
|
280
|
+
const registry = await this.registry();
|
|
281
|
+
const visible = effectiveServers(registry, this.workspaceRoot);
|
|
282
|
+
const catalog = await this.catalogFile();
|
|
283
|
+
return visible.map((server) => {
|
|
284
|
+
const key = configKey(server);
|
|
285
|
+
return {
|
|
286
|
+
...this.publicServer(server),
|
|
287
|
+
toolsCached: catalog.tools.filter((tool) => tool.serverKey === key).length,
|
|
288
|
+
connected: this.sessions.has(server.name),
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
async serverInfo(name) {
|
|
293
|
+
const config = await this.resolveServer(name);
|
|
294
|
+
const key = configKey(config);
|
|
295
|
+
const catalog = await this.catalogFile();
|
|
296
|
+
return {
|
|
297
|
+
...this.publicServer(config),
|
|
298
|
+
connected: this.sessions.has(name),
|
|
299
|
+
tools: catalog.tools.filter((tool) => tool.serverKey === key),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
async probe(name) {
|
|
303
|
+
const config = await this.resolveServer(name);
|
|
304
|
+
const client = await this.getOrConnect(config);
|
|
305
|
+
const tools = await this.fetchAllTools(client);
|
|
306
|
+
await this.replaceCatalogForServer(config, tools);
|
|
307
|
+
return {
|
|
308
|
+
server: this.publicServer(config),
|
|
309
|
+
connected: true,
|
|
310
|
+
toolCount: tools.length,
|
|
311
|
+
tools: tools.slice(0, 100).map((tool) => ({ name: tool.name, title: tool.title, description: tool.description })),
|
|
312
|
+
truncated: tools.length > 100,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
async searchTools(query, options = {}) {
|
|
316
|
+
const limit = Math.max(1, Math.min(options.limit ?? 8, 50));
|
|
317
|
+
if (options.refresh && options.server)
|
|
318
|
+
await this.probe(options.server);
|
|
319
|
+
const registry = await this.registry();
|
|
320
|
+
const effective = effectiveServers(registry, this.workspaceRoot).filter((server) => server.enabled);
|
|
321
|
+
const effectiveByName = new Map(effective.map((server) => [server.name, server]));
|
|
322
|
+
if (options.server) {
|
|
323
|
+
const config = await this.resolveServer(options.server);
|
|
324
|
+
const key = configKey(config);
|
|
325
|
+
const existing = await this.catalogFile();
|
|
326
|
+
if (!existing.tools.some((tool) => tool.serverKey === key))
|
|
327
|
+
await this.probe(options.server);
|
|
328
|
+
}
|
|
329
|
+
const catalog = await this.catalogFile();
|
|
330
|
+
const allowedKeys = new Set(effective.map(configKey));
|
|
331
|
+
const ranked = catalog.tools
|
|
332
|
+
.filter((tool) => allowedKeys.has(tool.serverKey) && (!options.server || tool.server === options.server))
|
|
333
|
+
.map((tool) => ({ ...tool, score: searchScore(tool, query) }))
|
|
334
|
+
.filter((tool) => !query.trim() || tool.score > 0)
|
|
335
|
+
.sort((a, b) => b.score - a.score || `${a.server}.${a.name}`.localeCompare(`${b.server}.${b.name}`))
|
|
336
|
+
.slice(0, limit);
|
|
337
|
+
return {
|
|
338
|
+
query,
|
|
339
|
+
results: ranked.map((tool) => ({
|
|
340
|
+
server: tool.server,
|
|
341
|
+
tool: tool.name,
|
|
342
|
+
title: tool.title,
|
|
343
|
+
description: tool.description,
|
|
344
|
+
score: tool.score,
|
|
345
|
+
readOnlyHint: tool.annotations?.readOnlyHint === true,
|
|
346
|
+
})),
|
|
347
|
+
catalogToolCount: catalog.tools.filter((tool) => allowedKeys.has(tool.serverKey)).length,
|
|
348
|
+
installedServerCount: effectiveByName.size,
|
|
349
|
+
recommendation: ranked.length ? "Call mcp_tool_info before mcp_call when you need the exact input schema." : "Probe a newly installed MCP with explicit local approval, then search again.",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async toolInfo(server, tool) {
|
|
353
|
+
const config = await this.resolveServer(server);
|
|
354
|
+
const key = configKey(config);
|
|
355
|
+
let catalog = await this.catalogFile();
|
|
356
|
+
let found = catalog.tools.find((item) => item.serverKey === key && item.name === tool);
|
|
357
|
+
if (!found) {
|
|
358
|
+
await this.probe(server);
|
|
359
|
+
catalog = await this.catalogFile();
|
|
360
|
+
found = catalog.tools.find((item) => item.serverKey === key && item.name === tool);
|
|
361
|
+
}
|
|
362
|
+
if (!found)
|
|
363
|
+
throw new Error(`MCP tool not found: ${server}.${tool}`);
|
|
364
|
+
return found;
|
|
365
|
+
}
|
|
366
|
+
async callTool(server, tool, args = {}) {
|
|
367
|
+
const config = await this.resolveServer(server);
|
|
368
|
+
const info = await this.toolInfo(server, tool);
|
|
369
|
+
const client = await this.getOrConnect(config);
|
|
370
|
+
const session = this.sessions.get(server);
|
|
371
|
+
if (session)
|
|
372
|
+
session.lastUsedAt = Date.now();
|
|
373
|
+
const result = await client.callTool({ name: info.name, arguments: args });
|
|
374
|
+
return {
|
|
375
|
+
server,
|
|
376
|
+
tool,
|
|
377
|
+
readOnlyHint: info.annotations?.readOnlyHint === true,
|
|
378
|
+
result,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async disconnect(name) {
|
|
382
|
+
const session = this.sessions.get(name);
|
|
383
|
+
if (!session)
|
|
384
|
+
return;
|
|
385
|
+
this.sessions.delete(name);
|
|
386
|
+
await session.client.close().catch(() => undefined);
|
|
387
|
+
}
|
|
388
|
+
async shutdown() {
|
|
389
|
+
await Promise.all([...this.sessions.keys()].map((name) => this.disconnect(name)));
|
|
390
|
+
}
|
|
391
|
+
publicServer(server) {
|
|
392
|
+
return {
|
|
393
|
+
name: server.name,
|
|
394
|
+
enabled: server.enabled,
|
|
395
|
+
scope: server.scope,
|
|
396
|
+
workspaceRoot: server.scope === "workspace" ? server.workspaceRoot : undefined,
|
|
397
|
+
transport: server.transport,
|
|
398
|
+
command: server.command,
|
|
399
|
+
args: server.args,
|
|
400
|
+
cwd: server.cwd,
|
|
401
|
+
env: server.env ? Object.fromEntries(Object.entries(server.env).map(([key, ref]) => [key, ref.source])) : undefined,
|
|
402
|
+
url: server.url,
|
|
403
|
+
headers: server.headers ? Object.fromEntries(Object.entries(server.headers).map(([key, ref]) => [key, { source: ref.source, prefix: ref.prefix ? "[configured]" : undefined }])) : undefined,
|
|
404
|
+
addedAt: server.addedAt,
|
|
405
|
+
updatedAt: server.updatedAt,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
async resolveServer(name) {
|
|
409
|
+
validateName(name);
|
|
410
|
+
const registry = await this.registry();
|
|
411
|
+
const workspace = registry.servers.find((server) => server.name === name && server.scope === "workspace" && server.workspaceRoot === this.workspaceRoot);
|
|
412
|
+
const global = registry.servers.find((server) => server.name === name && server.scope === "global");
|
|
413
|
+
const config = workspace ?? global;
|
|
414
|
+
if (!config)
|
|
415
|
+
throw new Error(`MCP server not installed for this workspace: ${name}`);
|
|
416
|
+
if (!config.enabled)
|
|
417
|
+
throw new Error(`MCP server is disabled: ${name}`);
|
|
418
|
+
return config;
|
|
419
|
+
}
|
|
420
|
+
async getOrConnect(config) {
|
|
421
|
+
const existing = this.sessions.get(config.name);
|
|
422
|
+
if (existing)
|
|
423
|
+
return existing.client;
|
|
424
|
+
await this.beforeConnect?.(config);
|
|
425
|
+
const client = new Client({ name: "codelocal-mcp-hub", version: "1.0.0" });
|
|
426
|
+
let transport;
|
|
427
|
+
if (config.transport === "stdio") {
|
|
428
|
+
const cwd = config.cwd ? (path.isAbsolute(config.cwd) ? config.cwd : path.resolve(config.scope === "workspace" ? this.workspaceRoot : process.cwd(), config.cwd)) : (config.scope === "workspace" ? this.workspaceRoot : undefined);
|
|
429
|
+
const stdio = new StdioClientTransport({
|
|
430
|
+
command: config.command,
|
|
431
|
+
args: config.args ?? [],
|
|
432
|
+
cwd,
|
|
433
|
+
env: materializeEnv(config.env),
|
|
434
|
+
stderr: "pipe",
|
|
435
|
+
});
|
|
436
|
+
transport = stdio;
|
|
437
|
+
const session = { client, transport, connectedAt: Date.now(), lastUsedAt: Date.now(), stderrTail: "" };
|
|
438
|
+
stdio.stderr?.on("data", (chunk) => {
|
|
439
|
+
session.stderrTail = (session.stderrTail + String(chunk)).slice(-MAX_STDERR_TAIL);
|
|
440
|
+
});
|
|
441
|
+
this.sessions.set(config.name, session);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
const headers = materializeHeaders(config.headers);
|
|
445
|
+
transport = new StreamableHTTPClientTransport(new URL(config.url), headers ? { requestInit: { headers } } : undefined);
|
|
446
|
+
this.sessions.set(config.name, { client, transport, connectedAt: Date.now(), lastUsedAt: Date.now(), stderrTail: "" });
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
await client.connect(transport);
|
|
450
|
+
return client;
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
const session = this.sessions.get(config.name);
|
|
454
|
+
this.sessions.delete(config.name);
|
|
455
|
+
await client.close().catch(() => undefined);
|
|
456
|
+
const stderr = session?.stderrTail.trim();
|
|
457
|
+
throw new Error(`Failed to connect MCP ${config.name}: ${error instanceof Error ? error.message : String(error)}${stderr ? `\nMCP stderr:\n${stderr}` : ""}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async fetchAllTools(client) {
|
|
461
|
+
const tools = [];
|
|
462
|
+
let cursor;
|
|
463
|
+
do {
|
|
464
|
+
const result = await client.listTools(cursor ? { cursor } : undefined);
|
|
465
|
+
for (const tool of result.tools) {
|
|
466
|
+
tools.push({
|
|
467
|
+
name: tool.name,
|
|
468
|
+
title: tool.title,
|
|
469
|
+
description: tool.description,
|
|
470
|
+
inputSchema: tool.inputSchema,
|
|
471
|
+
outputSchema: tool.outputSchema,
|
|
472
|
+
annotations: tool.annotations,
|
|
473
|
+
discoveredAt: Date.now(),
|
|
474
|
+
});
|
|
475
|
+
if (tools.length > MAX_CATALOG_TOOLS)
|
|
476
|
+
throw new Error(`MCP catalog exceeds CODELOCAL_MCP_MAX_TOOLS=${MAX_CATALOG_TOOLS}.`);
|
|
477
|
+
}
|
|
478
|
+
cursor = result.nextCursor;
|
|
479
|
+
} while (cursor);
|
|
480
|
+
return tools;
|
|
481
|
+
}
|
|
482
|
+
async replaceCatalogForServer(server, tools) {
|
|
483
|
+
const catalog = await this.catalogFile();
|
|
484
|
+
const key = configKey(server);
|
|
485
|
+
const others = catalog.tools.filter((tool) => tool.serverKey !== key);
|
|
486
|
+
const next = [...others, ...tools.map((tool) => ({ ...tool, serverKey: key, server: server.name }))];
|
|
487
|
+
if (next.length > MAX_CATALOG_TOOLS)
|
|
488
|
+
throw new Error(`MCP catalog exceeds CODELOCAL_MCP_MAX_TOOLS=${MAX_CATALOG_TOOLS}.`);
|
|
489
|
+
next.sort((a, b) => `${a.serverKey}.${a.name}`.localeCompare(`${b.serverKey}.${b.name}`));
|
|
490
|
+
await writeJsonAtomic(mcpStatePaths().catalog, { version: CATALOG_VERSION, tools: next });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
export function parseEnvReference(value) {
|
|
494
|
+
const [targetRaw, sourceRaw] = value.includes("=") ? value.split("=", 2) : [value, value];
|
|
495
|
+
const target = validateEnvName(targetRaw.trim());
|
|
496
|
+
const source = validateEnvName(sourceRaw.trim());
|
|
497
|
+
return [target, { source }];
|
|
498
|
+
}
|
|
499
|
+
export function parseHeaderEnvReference(value) {
|
|
500
|
+
const index = value.indexOf("=");
|
|
501
|
+
if (index <= 0)
|
|
502
|
+
throw new Error("Header env format must be Header-Name=ENV_VAR.");
|
|
503
|
+
const header = value.slice(0, index).trim();
|
|
504
|
+
const source = validateEnvName(value.slice(index + 1).trim());
|
|
505
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(header))
|
|
506
|
+
throw new Error(`Invalid HTTP header name: ${header}`);
|
|
507
|
+
return [header, { source }];
|
|
508
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const COMMON_IGNORES = [
|
|
4
|
+
".git/**",
|
|
5
|
+
"node_modules/**",
|
|
6
|
+
".next/**",
|
|
7
|
+
"dist/**",
|
|
8
|
+
"build/**",
|
|
9
|
+
"target/**",
|
|
10
|
+
".venv/**",
|
|
11
|
+
"venv/**",
|
|
12
|
+
"coverage/**",
|
|
13
|
+
".cache/**",
|
|
14
|
+
".turbo/**",
|
|
15
|
+
".dart_tool/**",
|
|
16
|
+
".gradle/**",
|
|
17
|
+
"Pods/**",
|
|
18
|
+
"DerivedData/**",
|
|
19
|
+
];
|
|
20
|
+
function backendForPlatform() {
|
|
21
|
+
if (process.platform === "darwin")
|
|
22
|
+
return "fs-events";
|
|
23
|
+
if (process.platform === "linux")
|
|
24
|
+
return "inotify";
|
|
25
|
+
if (process.platform === "win32")
|
|
26
|
+
return "windows";
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
function isPlainDirectoryTarget(targets) {
|
|
30
|
+
return typeof targets === "string" && !/[*!?{}[\]]/.test(targets);
|
|
31
|
+
}
|
|
32
|
+
function ignoredByOption(ignored, absolutePath) {
|
|
33
|
+
if (!ignored)
|
|
34
|
+
return false;
|
|
35
|
+
if (typeof ignored === "function") {
|
|
36
|
+
try {
|
|
37
|
+
return !!ignored(absolutePath);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const values = Array.isArray(ignored) ? ignored : [ignored];
|
|
44
|
+
const normalized = absolutePath.replace(/\\/g, "/");
|
|
45
|
+
return values.some((value) => typeof value === "string" && normalized.includes(value.replace(/\*+/g, "")));
|
|
46
|
+
}
|
|
47
|
+
function mapEvent(type) {
|
|
48
|
+
if (type === "create")
|
|
49
|
+
return "add";
|
|
50
|
+
if (type === "delete")
|
|
51
|
+
return "unlink";
|
|
52
|
+
return "change";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Patch Chokidar's singleton `watch` entry point before client-v2 is imported.
|
|
56
|
+
* CodeLocal keeps the existing watcher consumer API, while large workspaces use
|
|
57
|
+
* @parcel/watcher native recursive backends (FSEvents/inotify/Windows) whenever
|
|
58
|
+
* the dependency is available. A shallow Chokidar fallback remains.
|
|
59
|
+
*/
|
|
60
|
+
export function installNativeWatcherAdapter(chokidar, options = {}) {
|
|
61
|
+
const originalWatch = chokidar.watch.bind(chokidar);
|
|
62
|
+
const fallbackDepth = Math.max(0, options.fallbackDepth ?? 2);
|
|
63
|
+
const emitLog = options.log ?? ((level, event, detail = {}) => {
|
|
64
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), level, event, ...detail });
|
|
65
|
+
if (level === "error")
|
|
66
|
+
console.error(line);
|
|
67
|
+
else if (level === "warn")
|
|
68
|
+
console.warn(line);
|
|
69
|
+
else
|
|
70
|
+
console.log(line);
|
|
71
|
+
});
|
|
72
|
+
chokidar.watch = (targets, watchOptions = {}) => {
|
|
73
|
+
if (!isPlainDirectoryTarget(targets)) {
|
|
74
|
+
return originalWatch(targets, { ...watchOptions, depth: watchOptions.depth ?? fallbackDepth, followSymlinks: false });
|
|
75
|
+
}
|
|
76
|
+
const root = path.resolve(targets);
|
|
77
|
+
const emitter = new EventEmitter();
|
|
78
|
+
let subscription = null;
|
|
79
|
+
let fallback = null;
|
|
80
|
+
let closed = false;
|
|
81
|
+
const close = async () => {
|
|
82
|
+
if (closed)
|
|
83
|
+
return;
|
|
84
|
+
closed = true;
|
|
85
|
+
try {
|
|
86
|
+
await subscription?.unsubscribe();
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
try {
|
|
90
|
+
await fallback?.close?.();
|
|
91
|
+
}
|
|
92
|
+
catch { }
|
|
93
|
+
emitter.removeAllListeners();
|
|
94
|
+
};
|
|
95
|
+
emitter.close = close;
|
|
96
|
+
emitter.unwatch = close;
|
|
97
|
+
emitter.add = () => emitter;
|
|
98
|
+
const startFallback = (cause) => {
|
|
99
|
+
if (closed)
|
|
100
|
+
return;
|
|
101
|
+
emitLog("warn", "workspace.watcher_fallback", {
|
|
102
|
+
backend: "chokidar-bounded",
|
|
103
|
+
depth: fallbackDepth,
|
|
104
|
+
reason: cause instanceof Error ? cause.message : String(cause),
|
|
105
|
+
});
|
|
106
|
+
fallback = originalWatch(root, {
|
|
107
|
+
...watchOptions,
|
|
108
|
+
depth: watchOptions.depth ?? fallbackDepth,
|
|
109
|
+
followSymlinks: false,
|
|
110
|
+
});
|
|
111
|
+
fallback.on("all", (event, changed) => emitter.emit("all", event, changed));
|
|
112
|
+
fallback.on("error", (error) => emitter.emit("error", error));
|
|
113
|
+
fallback.on("ready", () => emitter.emit("ready"));
|
|
114
|
+
};
|
|
115
|
+
queueMicrotask(async () => {
|
|
116
|
+
try {
|
|
117
|
+
const dynamicImport = new Function("m", "return import(m)");
|
|
118
|
+
const parcel = await dynamicImport("@parcel/watcher");
|
|
119
|
+
if (closed)
|
|
120
|
+
return;
|
|
121
|
+
const backend = backendForPlatform();
|
|
122
|
+
subscription = await parcel.subscribe(root, (error, events = []) => {
|
|
123
|
+
if (closed)
|
|
124
|
+
return;
|
|
125
|
+
if (error) {
|
|
126
|
+
emitter.emit("error", error);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const item of events) {
|
|
130
|
+
const absolute = path.resolve(item.path);
|
|
131
|
+
if (ignoredByOption(watchOptions.ignored, absolute))
|
|
132
|
+
continue;
|
|
133
|
+
const event = mapEvent(item.type);
|
|
134
|
+
emitter.emit("all", event, absolute);
|
|
135
|
+
emitter.emit(event, absolute);
|
|
136
|
+
}
|
|
137
|
+
}, { ignore: COMMON_IGNORES, ...(backend ? { backend } : {}) });
|
|
138
|
+
emitLog("info", "workspace.watcher_ready", { backend: backend ?? "native-default", recursive: true, root });
|
|
139
|
+
emitter.emit("ready");
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
startFallback(error);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
return emitter;
|
|
146
|
+
};
|
|
147
|
+
return { preferredBackend: backendForPlatform() ?? "native-default", fallbackDepth };
|
|
148
|
+
}
|