contextwise 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +333 -0
- package/bin/contextwise.js +17 -0
- package/dist/chunk-P7JW7EPW.js +4383 -0
- package/dist/chunk-P7JW7EPW.js.map +1 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +1116 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +1435 -0
- package/dist/index.js +171 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,4383 @@
|
|
|
1
|
+
// src/config/schema.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var StdioUpstreamConfigSchema = z.object({
|
|
4
|
+
command: z.string(),
|
|
5
|
+
args: z.array(z.string()).default([]),
|
|
6
|
+
env: z.record(z.string()).default({}),
|
|
7
|
+
cwd: z.string().optional(),
|
|
8
|
+
autoRestart: z.boolean().default(true)
|
|
9
|
+
});
|
|
10
|
+
var HttpUpstreamConfigSchema = z.object({
|
|
11
|
+
url: z.string().url(),
|
|
12
|
+
headers: z.record(z.string()).default({}),
|
|
13
|
+
transport: z.enum(["streamable-http", "sse", "auto"]).default("auto"),
|
|
14
|
+
autoReconnect: z.boolean().default(true)
|
|
15
|
+
});
|
|
16
|
+
var UpstreamServerConfigSchema = z.union([
|
|
17
|
+
StdioUpstreamConfigSchema,
|
|
18
|
+
HttpUpstreamConfigSchema
|
|
19
|
+
]);
|
|
20
|
+
function warnIfInsecureHttp(urlStr, headers) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(urlStr);
|
|
23
|
+
if (parsed.protocol === "http:") {
|
|
24
|
+
const isLocalhost = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
|
|
25
|
+
if (!isLocalhost && headers) {
|
|
26
|
+
const hasAuthHeader = Object.keys(headers).some(
|
|
27
|
+
(h) => ["authorization", "cookie", "x-api-key", "api-key", "token"].includes(h.toLowerCase())
|
|
28
|
+
);
|
|
29
|
+
if (hasAuthHeader) {
|
|
30
|
+
console.warn(
|
|
31
|
+
`[SECURITY WARNING] Upstream URL "${urlStr}" is using unencrypted HTTP with sensitive authentication headers to non-localhost destination "${parsed.hostname}".`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function isStdioUpstream(config) {
|
|
40
|
+
return "command" in config;
|
|
41
|
+
}
|
|
42
|
+
function isHttpUpstream(config) {
|
|
43
|
+
return "url" in config;
|
|
44
|
+
}
|
|
45
|
+
var ProxyConfigSchema = z.object({
|
|
46
|
+
transport: z.enum(["stdio", "sse", "http"]).default("stdio"),
|
|
47
|
+
port: z.number().int().min(1024).max(65535).default(3456),
|
|
48
|
+
logLevel: z.enum(["debug", "info", "warn", "error", "silent"]).default("info")
|
|
49
|
+
});
|
|
50
|
+
var RoutingConfigSchema = z.object({
|
|
51
|
+
strategy: z.enum(["hybrid", "bm25", "vector", "passthrough"]).default("hybrid"),
|
|
52
|
+
topK: z.number().int().min(1).max(50).default(5),
|
|
53
|
+
similarityThreshold: z.number().min(0).max(1).default(0.45),
|
|
54
|
+
pinnedTools: z.array(z.string()).default([]),
|
|
55
|
+
maxActiveTools: z.number().int().min(1).max(100).default(10),
|
|
56
|
+
enableBrowseServers: z.boolean().default(false),
|
|
57
|
+
enableAddServer: z.boolean().default(false),
|
|
58
|
+
allowCustomCommands: z.boolean().default(false),
|
|
59
|
+
persistAddedServers: z.boolean().default(false)
|
|
60
|
+
});
|
|
61
|
+
var GuardrailsConfigSchema = z.object({
|
|
62
|
+
enableCache: z.boolean().default(true),
|
|
63
|
+
cacheTtlSeconds: z.number().int().min(1).default(120),
|
|
64
|
+
maxCallsPerMinute: z.number().int().min(1).default(60),
|
|
65
|
+
loopBreakerThreshold: z.number().int().min(1).default(3),
|
|
66
|
+
callTimeoutMs: z.number().int().min(1e3).default(3e4)
|
|
67
|
+
});
|
|
68
|
+
var ContextWiseConfigSchema = z.object({
|
|
69
|
+
$schema: z.string().optional(),
|
|
70
|
+
version: z.string().default("1.0.0"),
|
|
71
|
+
proxy: ProxyConfigSchema.default({}),
|
|
72
|
+
routing: RoutingConfigSchema.default({}),
|
|
73
|
+
guardrails: GuardrailsConfigSchema.default({}),
|
|
74
|
+
upstreams: z.record(UpstreamServerConfigSchema).default({})
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// src/vault/redaction.ts
|
|
78
|
+
var RedactionFilter = class _RedactionFilter {
|
|
79
|
+
static instance = null;
|
|
80
|
+
registeredSecrets = /* @__PURE__ */ new Set();
|
|
81
|
+
static getInstance() {
|
|
82
|
+
if (!_RedactionFilter.instance) {
|
|
83
|
+
_RedactionFilter.instance = new _RedactionFilter();
|
|
84
|
+
}
|
|
85
|
+
return _RedactionFilter.instance;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Registers a plaintext secret value to be masked across all output streams.
|
|
89
|
+
*/
|
|
90
|
+
registerSecret(secret) {
|
|
91
|
+
if (secret && typeof secret === "string") {
|
|
92
|
+
const trimmed = secret.trim();
|
|
93
|
+
if (trimmed.length >= 4) {
|
|
94
|
+
this.registeredSecrets.add(trimmed);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
registerSecrets(secrets) {
|
|
99
|
+
for (const s of secrets) {
|
|
100
|
+
this.registerSecret(s);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
getRegisteredCount() {
|
|
104
|
+
return this.registeredSecrets.size;
|
|
105
|
+
}
|
|
106
|
+
clear() {
|
|
107
|
+
this.registeredSecrets.clear();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Redacts registered secrets and known API key patterns from an input string.
|
|
111
|
+
*/
|
|
112
|
+
redact(input) {
|
|
113
|
+
if (!input || typeof input !== "string") {
|
|
114
|
+
return input;
|
|
115
|
+
}
|
|
116
|
+
let sanitized = input;
|
|
117
|
+
for (const secret of this.registeredSecrets) {
|
|
118
|
+
sanitized = sanitized.replaceAll(secret, "[REDACTED_SECRET]");
|
|
119
|
+
}
|
|
120
|
+
sanitized = sanitized.replace(/ghp_[a-zA-Z0-9]{36}/g, "ghp_[REDACTED_GITHUB_TOKEN]");
|
|
121
|
+
sanitized = sanitized.replace(/sk-ant-[a-zA-Z0-9_-]{20,}/g, "sk-ant-[REDACTED_ANTHROPIC_KEY]");
|
|
122
|
+
sanitized = sanitized.replace(/sk-[a-zA-Z0-9]{32,}/g, "sk-[REDACTED_OPENAI_KEY]");
|
|
123
|
+
sanitized = sanitized.replace(
|
|
124
|
+
/([a-zA-Z0-9+]+:\/\/[^:]+:)([^@]+)(@.+)/g,
|
|
125
|
+
"$1[REDACTED_PASSWORD]$3"
|
|
126
|
+
);
|
|
127
|
+
return sanitized;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
var redactionFilter = RedactionFilter.getInstance();
|
|
131
|
+
|
|
132
|
+
// src/utils/logger.ts
|
|
133
|
+
var LOG_LEVELS = {
|
|
134
|
+
debug: 10,
|
|
135
|
+
info: 20,
|
|
136
|
+
warn: 30,
|
|
137
|
+
error: 40,
|
|
138
|
+
silent: 100
|
|
139
|
+
};
|
|
140
|
+
var Logger = class {
|
|
141
|
+
level = "info";
|
|
142
|
+
prefix;
|
|
143
|
+
constructor(prefix = "ContextWise", level = "info") {
|
|
144
|
+
this.prefix = prefix;
|
|
145
|
+
this.level = level;
|
|
146
|
+
}
|
|
147
|
+
setLevel(level) {
|
|
148
|
+
this.level = level;
|
|
149
|
+
}
|
|
150
|
+
getLevel() {
|
|
151
|
+
return this.level;
|
|
152
|
+
}
|
|
153
|
+
shouldLog(level) {
|
|
154
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
|
|
155
|
+
}
|
|
156
|
+
formatMessage(level, message, meta) {
|
|
157
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
158
|
+
const metaStr = meta !== void 0 ? ` ${typeof meta === "object" ? JSON.stringify(meta) : String(meta)}` : "";
|
|
159
|
+
const raw = `[${timestamp}] [${level.toUpperCase()}] [${this.prefix}] ${message}${metaStr}
|
|
160
|
+
`;
|
|
161
|
+
return redactionFilter.redact(raw);
|
|
162
|
+
}
|
|
163
|
+
debug(message, meta) {
|
|
164
|
+
if (this.shouldLog("debug")) {
|
|
165
|
+
process.stderr.write(this.formatMessage("DEBUG", message, meta));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
info(message, meta) {
|
|
169
|
+
if (this.shouldLog("info")) {
|
|
170
|
+
process.stderr.write(this.formatMessage("INFO", message, meta));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
warn(message, meta) {
|
|
174
|
+
if (this.shouldLog("warn")) {
|
|
175
|
+
process.stderr.write(this.formatMessage("WARN", message, meta));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
error(message, meta) {
|
|
179
|
+
if (this.shouldLog("error")) {
|
|
180
|
+
process.stderr.write(this.formatMessage("ERROR", message, meta));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
var logger = new Logger("ContextWise");
|
|
185
|
+
|
|
186
|
+
// src/config/loader.ts
|
|
187
|
+
import { existsSync, readFileSync } from "fs";
|
|
188
|
+
import { homedir } from "os";
|
|
189
|
+
import { isAbsolute, join, resolve } from "path";
|
|
190
|
+
var ConfigLoader = class {
|
|
191
|
+
/**
|
|
192
|
+
* Discovers and loads configuration from contextwise.json or imported client configs.
|
|
193
|
+
*/
|
|
194
|
+
static load(options = {}) {
|
|
195
|
+
const cwd = options.cwd ?? process.cwd();
|
|
196
|
+
if (process.env.CONTEXTWISE_CONFIG && !options.configPath) {
|
|
197
|
+
const envPath = isAbsolute(process.env.CONTEXTWISE_CONFIG) ? process.env.CONTEXTWISE_CONFIG : resolve(cwd, process.env.CONTEXTWISE_CONFIG);
|
|
198
|
+
if (existsSync(envPath)) {
|
|
199
|
+
return this.parseConfigFile(envPath);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (options.configPath) {
|
|
203
|
+
const explicitPath = isAbsolute(options.configPath) ? options.configPath : resolve(cwd, options.configPath);
|
|
204
|
+
if (existsSync(explicitPath)) {
|
|
205
|
+
return this.parseConfigFile(explicitPath);
|
|
206
|
+
}
|
|
207
|
+
throw new Error(`Configuration file not found at: ${explicitPath}`);
|
|
208
|
+
}
|
|
209
|
+
const candidatePaths = [
|
|
210
|
+
resolve(cwd, "contextwise.json"),
|
|
211
|
+
resolve(cwd, ".contextwise.json"),
|
|
212
|
+
resolve(cwd, ".contextwise/config.json"),
|
|
213
|
+
resolve(homedir(), ".contextwise/config.json")
|
|
214
|
+
];
|
|
215
|
+
for (const candidate of candidatePaths) {
|
|
216
|
+
if (existsSync(candidate)) {
|
|
217
|
+
logger.debug(`Loaded configuration from ${candidate}`);
|
|
218
|
+
return this.parseConfigFile(candidate);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (options.autoImport !== false) {
|
|
222
|
+
const imported = this.autoImportClientConfigs(cwd);
|
|
223
|
+
if (imported && Object.keys(imported.upstreams).length > 0) {
|
|
224
|
+
logger.info(`Auto-discovered MCP servers from existing client configurations`);
|
|
225
|
+
return imported;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
logger.debug("No configuration file found. Using defaults.");
|
|
229
|
+
return ContextWiseConfigSchema.parse({});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Parses and validates a JSON file against ContextWiseConfigSchema.
|
|
233
|
+
*/
|
|
234
|
+
static parseConfigFile(filePath) {
|
|
235
|
+
try {
|
|
236
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
237
|
+
const parsed = JSON.parse(raw);
|
|
238
|
+
return ContextWiseConfigSchema.parse(parsed);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
241
|
+
throw new Error(`Failed to parse config at ${filePath}: ${msg}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Scans for Cursor and Claude Desktop configs to auto-import upstreams.
|
|
246
|
+
*/
|
|
247
|
+
static autoImportClientConfigs(cwd) {
|
|
248
|
+
const upstreams = {};
|
|
249
|
+
const cursorMcp = resolve(cwd, ".cursor", "mcp.json");
|
|
250
|
+
if (existsSync(cursorMcp)) {
|
|
251
|
+
try {
|
|
252
|
+
const raw = JSON.parse(readFileSync(cursorMcp, "utf-8"));
|
|
253
|
+
if (raw.mcpServers && typeof raw.mcpServers === "object") {
|
|
254
|
+
for (const [name, server] of Object.entries(raw.mcpServers)) {
|
|
255
|
+
if (name === "contextwise") continue;
|
|
256
|
+
const s = server;
|
|
257
|
+
if (typeof s.command === "string") {
|
|
258
|
+
upstreams[name] = {
|
|
259
|
+
command: s.command,
|
|
260
|
+
args: Array.isArray(s.args) ? s.args : [],
|
|
261
|
+
env: s.env ?? {},
|
|
262
|
+
autoRestart: true
|
|
263
|
+
};
|
|
264
|
+
} else if (typeof s.url === "string") {
|
|
265
|
+
upstreams[name] = {
|
|
266
|
+
url: s.url,
|
|
267
|
+
headers: s.headers ?? {},
|
|
268
|
+
transport: "auto",
|
|
269
|
+
autoReconnect: true
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
logger.warn(`Failed reading ${cursorMcp}: ${err}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const claudePath = this.getClaudeDesktopConfigPath();
|
|
279
|
+
if (claudePath && existsSync(claudePath)) {
|
|
280
|
+
try {
|
|
281
|
+
const raw = JSON.parse(readFileSync(claudePath, "utf-8"));
|
|
282
|
+
if (raw.mcpServers && typeof raw.mcpServers === "object") {
|
|
283
|
+
for (const [name, server] of Object.entries(raw.mcpServers)) {
|
|
284
|
+
if (name === "contextwise") continue;
|
|
285
|
+
const s = server;
|
|
286
|
+
if (typeof s.command === "string" && !upstreams[name]) {
|
|
287
|
+
upstreams[name] = {
|
|
288
|
+
command: s.command,
|
|
289
|
+
args: Array.isArray(s.args) ? s.args : [],
|
|
290
|
+
env: s.env ?? {},
|
|
291
|
+
autoRestart: true
|
|
292
|
+
};
|
|
293
|
+
} else if (typeof s.url === "string" && !upstreams[name]) {
|
|
294
|
+
upstreams[name] = {
|
|
295
|
+
url: s.url,
|
|
296
|
+
headers: s.headers ?? {},
|
|
297
|
+
transport: "auto",
|
|
298
|
+
autoReconnect: true
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
logger.warn(`Failed reading Claude config at ${claudePath}: ${err}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (Object.keys(upstreams).length === 0) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
return ContextWiseConfigSchema.parse({
|
|
311
|
+
upstreams
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
static getClaudeDesktopConfigPath() {
|
|
315
|
+
const platform3 = process.platform;
|
|
316
|
+
const home = homedir();
|
|
317
|
+
if (platform3 === "win32") {
|
|
318
|
+
const appData = process.env.APPDATA || join(home, "AppData", "Roaming");
|
|
319
|
+
return join(appData, "Claude", "claude_desktop_config.json");
|
|
320
|
+
} else if (platform3 === "darwin") {
|
|
321
|
+
return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
322
|
+
} else {
|
|
323
|
+
return join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// src/core/supervisor.ts
|
|
329
|
+
import { execSync } from "child_process";
|
|
330
|
+
var ProcessSupervisor = class _ProcessSupervisor {
|
|
331
|
+
static instance = null;
|
|
332
|
+
trackedProcesses = /* @__PURE__ */ new Map();
|
|
333
|
+
hooksRegistered = false;
|
|
334
|
+
constructor() {
|
|
335
|
+
this.registerGlobalHooks();
|
|
336
|
+
}
|
|
337
|
+
static getInstance() {
|
|
338
|
+
if (!_ProcessSupervisor.instance) {
|
|
339
|
+
_ProcessSupervisor.instance = new _ProcessSupervisor();
|
|
340
|
+
}
|
|
341
|
+
return _ProcessSupervisor.instance;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Registers a child PID under supervision.
|
|
345
|
+
*/
|
|
346
|
+
track(pid, name) {
|
|
347
|
+
if (!pid || pid <= 0) return;
|
|
348
|
+
this.trackedProcesses.set(pid, {
|
|
349
|
+
pid,
|
|
350
|
+
name,
|
|
351
|
+
startTime: /* @__PURE__ */ new Date()
|
|
352
|
+
});
|
|
353
|
+
logger.debug(`Supervising process ${name} (PID: ${pid})`);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Unregisters a child PID (e.g., normal graceful exit).
|
|
357
|
+
*/
|
|
358
|
+
untrack(pid) {
|
|
359
|
+
this.trackedProcesses.delete(pid);
|
|
360
|
+
logger.debug(`Untracked process PID: ${pid}`);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Kills an entire process tree for a given root PID.
|
|
364
|
+
* Cross-platform: handles Windows taskkill /T /F and POSIX process group kills.
|
|
365
|
+
*/
|
|
366
|
+
killProcessTree(pid, name) {
|
|
367
|
+
if (!pid || pid <= 0) return;
|
|
368
|
+
const procName = name ?? this.trackedProcesses.get(pid)?.name ?? "unknown";
|
|
369
|
+
logger.debug(`Terminating process tree for ${procName} (PID: ${pid})`);
|
|
370
|
+
try {
|
|
371
|
+
if (process.platform === "win32") {
|
|
372
|
+
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
|
|
373
|
+
} else {
|
|
374
|
+
try {
|
|
375
|
+
process.kill(-pid, "SIGKILL");
|
|
376
|
+
} catch {
|
|
377
|
+
process.kill(pid, "SIGKILL");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
} finally {
|
|
382
|
+
this.untrack(pid);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Shuts down all supervised processes cleanly.
|
|
387
|
+
*/
|
|
388
|
+
shutdownAll() {
|
|
389
|
+
if (this.trackedProcesses.size === 0) return;
|
|
390
|
+
logger.info(`Shutting down ${this.trackedProcesses.size} supervised processes...`);
|
|
391
|
+
for (const [pid, proc] of this.trackedProcesses.entries()) {
|
|
392
|
+
this.killProcessTree(pid, proc.name);
|
|
393
|
+
}
|
|
394
|
+
this.trackedProcesses.clear();
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Returns list of currently tracked processes.
|
|
398
|
+
*/
|
|
399
|
+
getTrackedProcesses() {
|
|
400
|
+
return Array.from(this.trackedProcesses.values());
|
|
401
|
+
}
|
|
402
|
+
registerGlobalHooks() {
|
|
403
|
+
if (this.hooksRegistered) return;
|
|
404
|
+
this.hooksRegistered = true;
|
|
405
|
+
const cleanup = () => {
|
|
406
|
+
this.shutdownAll();
|
|
407
|
+
};
|
|
408
|
+
process.on("exit", cleanup);
|
|
409
|
+
const handleSignal = (signal) => {
|
|
410
|
+
if (process.listenerCount(signal) > 1) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
cleanup();
|
|
414
|
+
process.exit(0);
|
|
415
|
+
};
|
|
416
|
+
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
417
|
+
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
var supervisor = ProcessSupervisor.getInstance();
|
|
421
|
+
|
|
422
|
+
// src/vault/crypto.ts
|
|
423
|
+
import {
|
|
424
|
+
createCipheriv,
|
|
425
|
+
createDecipheriv,
|
|
426
|
+
createHash,
|
|
427
|
+
createPrivateKey,
|
|
428
|
+
createPublicKey,
|
|
429
|
+
diffieHellman,
|
|
430
|
+
generateKeyPairSync,
|
|
431
|
+
pbkdf2,
|
|
432
|
+
randomBytes
|
|
433
|
+
} from "crypto";
|
|
434
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
435
|
+
import { arch, homedir as homedir2, hostname, platform, userInfo } from "os";
|
|
436
|
+
import { join as join2 } from "path";
|
|
437
|
+
function encryptAesGcm(plaintext, key) {
|
|
438
|
+
if (key.length !== 32) {
|
|
439
|
+
throw new Error(`Invalid AES-256 key length: expected 32 bytes, got ${key.length}`);
|
|
440
|
+
}
|
|
441
|
+
const iv = randomBytes(12);
|
|
442
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
443
|
+
let ciphertext = cipher.update(plaintext, "utf-8", "base64");
|
|
444
|
+
ciphertext += cipher.final("base64");
|
|
445
|
+
const authTag = cipher.getAuthTag();
|
|
446
|
+
return {
|
|
447
|
+
iv: iv.toString("base64"),
|
|
448
|
+
authTag: authTag.toString("base64"),
|
|
449
|
+
ciphertext
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function decryptAesGcm(ciphertext, key, ivBase64, authTagBase64) {
|
|
453
|
+
if (key.length !== 32) {
|
|
454
|
+
throw new Error(`Invalid AES-256 key length: expected 32 bytes, got ${key.length}`);
|
|
455
|
+
}
|
|
456
|
+
const iv = Buffer.from(ivBase64, "base64");
|
|
457
|
+
const authTag = Buffer.from(authTagBase64, "base64");
|
|
458
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
459
|
+
decipher.setAuthTag(authTag);
|
|
460
|
+
let decrypted = decipher.update(ciphertext, "base64", "utf-8");
|
|
461
|
+
decrypted += decipher.final("utf-8");
|
|
462
|
+
return decrypted;
|
|
463
|
+
}
|
|
464
|
+
var VAULT_PBKDF2_ITERATIONS = 21e4;
|
|
465
|
+
function deriveKeyFromPassphrase(passphrase, salt, iterations = VAULT_PBKDF2_ITERATIONS) {
|
|
466
|
+
return new Promise((resolve4, reject) => {
|
|
467
|
+
pbkdf2(passphrase, salt, iterations, 32, "sha512", (err, derivedKey) => {
|
|
468
|
+
if (err) reject(err);
|
|
469
|
+
else resolve4(derivedKey);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
async function getOrCreateMachineKey() {
|
|
474
|
+
const saltDir = join2(homedir2(), ".contextwise");
|
|
475
|
+
const saltPath = join2(saltDir, ".machine_salt");
|
|
476
|
+
let salt;
|
|
477
|
+
if (existsSync2(saltPath)) {
|
|
478
|
+
salt = readFileSync2(saltPath);
|
|
479
|
+
} else {
|
|
480
|
+
mkdirSync(saltDir, { recursive: true });
|
|
481
|
+
salt = randomBytes(32);
|
|
482
|
+
writeFileSync(saltPath, salt, { mode: 384 });
|
|
483
|
+
}
|
|
484
|
+
if (process.env.CONTEXTWISE_VAULT_PASSPHRASE) {
|
|
485
|
+
return deriveKeyFromPassphrase(process.env.CONTEXTWISE_VAULT_PASSPHRASE, salt, VAULT_PBKDF2_ITERATIONS);
|
|
486
|
+
}
|
|
487
|
+
const user = (() => {
|
|
488
|
+
try {
|
|
489
|
+
return userInfo().username;
|
|
490
|
+
} catch {
|
|
491
|
+
return "default_user";
|
|
492
|
+
}
|
|
493
|
+
})();
|
|
494
|
+
const machineId = `${hostname()}-${user}-${platform()}-${arch()}-contextwise-vault-v1`;
|
|
495
|
+
return deriveKeyFromPassphrase(machineId, salt, VAULT_PBKDF2_ITERATIONS);
|
|
496
|
+
}
|
|
497
|
+
function generateKeyPairX25519() {
|
|
498
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519", {
|
|
499
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
500
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
|
501
|
+
});
|
|
502
|
+
return { publicKey, privateKey };
|
|
503
|
+
}
|
|
504
|
+
function deriveSharedSecretX25519(privateKeyPem, publicKeyPem) {
|
|
505
|
+
const privateKey = createPrivateKey(privateKeyPem);
|
|
506
|
+
const publicKey = createPublicKey(publicKeyPem);
|
|
507
|
+
const shared = diffieHellman({
|
|
508
|
+
privateKey,
|
|
509
|
+
publicKey
|
|
510
|
+
});
|
|
511
|
+
return createHash("sha256").update(shared).digest();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/vault/file_driver.ts
|
|
515
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
516
|
+
import { homedir as homedir3 } from "os";
|
|
517
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
518
|
+
function getDefaultVaultFilePath() {
|
|
519
|
+
if (process.env.CONTEXTWISE_VAULT_PATH) {
|
|
520
|
+
return process.env.CONTEXTWISE_VAULT_PATH;
|
|
521
|
+
}
|
|
522
|
+
if (process.env.CONTEXTWISE_STORAGE_DIR) {
|
|
523
|
+
return join3(process.env.CONTEXTWISE_STORAGE_DIR, "vault.enc.json");
|
|
524
|
+
}
|
|
525
|
+
return join3(homedir3(), ".contextwise", "vault.enc.json");
|
|
526
|
+
}
|
|
527
|
+
var EncryptedFileVaultDriver = class {
|
|
528
|
+
name = "encrypted_file";
|
|
529
|
+
filePath;
|
|
530
|
+
keyPromise;
|
|
531
|
+
cache = null;
|
|
532
|
+
constructor(filePath, customKey) {
|
|
533
|
+
this.filePath = filePath ?? getDefaultVaultFilePath();
|
|
534
|
+
this.keyPromise = customKey ? Promise.resolve(customKey) : getOrCreateMachineKey();
|
|
535
|
+
}
|
|
536
|
+
async isAvailable() {
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
async load() {
|
|
540
|
+
if (this.cache) {
|
|
541
|
+
return this.cache;
|
|
542
|
+
}
|
|
543
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
544
|
+
if (!existsSync3(this.filePath)) {
|
|
545
|
+
return this.cache;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const raw = readFileSync3(this.filePath, "utf-8");
|
|
549
|
+
const payload = JSON.parse(raw);
|
|
550
|
+
if (payload.cipher !== "aes-256-gcm" || !payload.data) {
|
|
551
|
+
logger.warn(`Corrupt or incompatible vault file at ${this.filePath}`);
|
|
552
|
+
return this.cache;
|
|
553
|
+
}
|
|
554
|
+
const key = await this.keyPromise;
|
|
555
|
+
const decryptedJson = decryptAesGcm(payload.data, key, payload.iv, payload.authTag);
|
|
556
|
+
const entries = JSON.parse(decryptedJson);
|
|
557
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
558
|
+
this.cache.set(k, v);
|
|
559
|
+
}
|
|
560
|
+
} catch (err) {
|
|
561
|
+
logger.warn(`Failed to decrypt vault file at ${this.filePath}: ${err}`);
|
|
562
|
+
}
|
|
563
|
+
return this.cache;
|
|
564
|
+
}
|
|
565
|
+
async persist() {
|
|
566
|
+
if (!this.cache) return;
|
|
567
|
+
const dir = dirname2(this.filePath);
|
|
568
|
+
if (!existsSync3(dir)) {
|
|
569
|
+
mkdirSync2(dir, { recursive: true });
|
|
570
|
+
}
|
|
571
|
+
const key = await this.keyPromise;
|
|
572
|
+
const entries = {};
|
|
573
|
+
for (const [k, v] of this.cache.entries()) {
|
|
574
|
+
entries[k] = v;
|
|
575
|
+
}
|
|
576
|
+
const plaintext = JSON.stringify(entries);
|
|
577
|
+
const { iv, authTag, ciphertext } = encryptAesGcm(plaintext, key);
|
|
578
|
+
const payload = {
|
|
579
|
+
version: 1,
|
|
580
|
+
kdf: {
|
|
581
|
+
algorithm: "pbkdf2-sha512",
|
|
582
|
+
salt: "machine-bound",
|
|
583
|
+
iterations: VAULT_PBKDF2_ITERATIONS
|
|
584
|
+
},
|
|
585
|
+
cipher: "aes-256-gcm",
|
|
586
|
+
iv,
|
|
587
|
+
authTag,
|
|
588
|
+
data: ciphertext
|
|
589
|
+
};
|
|
590
|
+
const tempPath = `${this.filePath}.${Date.now()}.tmp`;
|
|
591
|
+
writeFileSync2(tempPath, JSON.stringify(payload, null, 2), { mode: 384, encoding: "utf-8" });
|
|
592
|
+
try {
|
|
593
|
+
renameSync(tempPath, this.filePath);
|
|
594
|
+
} catch {
|
|
595
|
+
writeFileSync2(this.filePath, JSON.stringify(payload, null, 2), { mode: 384, encoding: "utf-8" });
|
|
596
|
+
try {
|
|
597
|
+
unlinkSync(tempPath);
|
|
598
|
+
} catch {
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
async get(key) {
|
|
603
|
+
const store = await this.load();
|
|
604
|
+
const entry = store.get(key);
|
|
605
|
+
return entry ? entry.value : null;
|
|
606
|
+
}
|
|
607
|
+
async set(key, value, scope = "personal") {
|
|
608
|
+
const store = await this.load();
|
|
609
|
+
const now = Date.now();
|
|
610
|
+
const existing = store.get(key);
|
|
611
|
+
store.set(key, {
|
|
612
|
+
value,
|
|
613
|
+
metadata: {
|
|
614
|
+
key,
|
|
615
|
+
scope,
|
|
616
|
+
backend: this.name,
|
|
617
|
+
createdAt: existing?.metadata.createdAt ?? now,
|
|
618
|
+
updatedAt: now
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
await this.persist();
|
|
622
|
+
logger.debug(`Stored secret "${key}" in ${this.name} vault`);
|
|
623
|
+
}
|
|
624
|
+
async delete(key) {
|
|
625
|
+
const store = await this.load();
|
|
626
|
+
if (!store.has(key)) {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
store.delete(key);
|
|
630
|
+
await this.persist();
|
|
631
|
+
logger.debug(`Deleted secret "${key}" from ${this.name} vault`);
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
async list() {
|
|
635
|
+
const store = await this.load();
|
|
636
|
+
return Array.from(store.values()).map((e) => e.metadata);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Resets and clears all cached and persisted secrets.
|
|
640
|
+
*/
|
|
641
|
+
async clear() {
|
|
642
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
643
|
+
if (existsSync3(this.filePath)) {
|
|
644
|
+
try {
|
|
645
|
+
unlinkSync(this.filePath);
|
|
646
|
+
} catch {
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
// src/vault/os_driver.ts
|
|
653
|
+
import { execFileSync } from "child_process";
|
|
654
|
+
import { platform as platform2 } from "os";
|
|
655
|
+
var OsKeystoreDriver = class {
|
|
656
|
+
name = "os_keystore";
|
|
657
|
+
available = null;
|
|
658
|
+
serviceName = "ContextWise";
|
|
659
|
+
async isAvailable() {
|
|
660
|
+
if (this.available !== null) {
|
|
661
|
+
return this.available;
|
|
662
|
+
}
|
|
663
|
+
const currentPlatform = platform2();
|
|
664
|
+
try {
|
|
665
|
+
if (currentPlatform === "darwin") {
|
|
666
|
+
execFileSync("/usr/bin/security", ["list-keychains"], { stdio: "ignore" });
|
|
667
|
+
this.available = true;
|
|
668
|
+
} else {
|
|
669
|
+
this.available = false;
|
|
670
|
+
}
|
|
671
|
+
} catch {
|
|
672
|
+
logger.debug("Native OS Keystore is not directly accessible. Using Encrypted File Vault.");
|
|
673
|
+
this.available = false;
|
|
674
|
+
}
|
|
675
|
+
return this.available;
|
|
676
|
+
}
|
|
677
|
+
async get(key) {
|
|
678
|
+
if (!await this.isAvailable()) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
const currentPlatform = platform2();
|
|
682
|
+
try {
|
|
683
|
+
if (currentPlatform === "darwin") {
|
|
684
|
+
const stdout = execFileSync(
|
|
685
|
+
"/usr/bin/security",
|
|
686
|
+
["find-generic-password", "-s", this.serviceName, "-a", key, "-w"],
|
|
687
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
|
|
688
|
+
);
|
|
689
|
+
return stdout.trim();
|
|
690
|
+
}
|
|
691
|
+
return null;
|
|
692
|
+
} catch {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async set(key, value, _scope = "personal") {
|
|
697
|
+
if (!await this.isAvailable()) {
|
|
698
|
+
throw new Error("OS Keystore driver is not available on this system.");
|
|
699
|
+
}
|
|
700
|
+
const currentPlatform = platform2();
|
|
701
|
+
if (currentPlatform === "darwin") {
|
|
702
|
+
execFileSync(
|
|
703
|
+
"/usr/bin/security",
|
|
704
|
+
["add-generic-password", "-U", "-s", this.serviceName, "-a", key, "-w", value],
|
|
705
|
+
{ stdio: "ignore" }
|
|
706
|
+
);
|
|
707
|
+
} else {
|
|
708
|
+
throw new Error("OS Keystore set not implemented for this platform; fallback to encrypted file.");
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async delete(key) {
|
|
712
|
+
if (!await this.isAvailable()) {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
const currentPlatform = platform2();
|
|
716
|
+
try {
|
|
717
|
+
if (currentPlatform === "darwin") {
|
|
718
|
+
execFileSync(
|
|
719
|
+
"/usr/bin/security",
|
|
720
|
+
["delete-generic-password", "-s", this.serviceName, "-a", key],
|
|
721
|
+
{ stdio: "ignore" }
|
|
722
|
+
);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
return false;
|
|
726
|
+
} catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
async list() {
|
|
731
|
+
return [];
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
// src/vault/vault.ts
|
|
736
|
+
var SecretVault = class {
|
|
737
|
+
driver;
|
|
738
|
+
initialized = false;
|
|
739
|
+
constructor(customDriver) {
|
|
740
|
+
this.driver = customDriver ?? new EncryptedFileVaultDriver();
|
|
741
|
+
}
|
|
742
|
+
async initialize() {
|
|
743
|
+
if (this.initialized) return;
|
|
744
|
+
const osDriver = new OsKeystoreDriver();
|
|
745
|
+
if (await osDriver.isAvailable()) {
|
|
746
|
+
this.driver = osDriver;
|
|
747
|
+
} else {
|
|
748
|
+
this.driver = new EncryptedFileVaultDriver();
|
|
749
|
+
}
|
|
750
|
+
this.initialized = true;
|
|
751
|
+
}
|
|
752
|
+
getActiveDriverName() {
|
|
753
|
+
return this.driver.name;
|
|
754
|
+
}
|
|
755
|
+
setDriver(driver) {
|
|
756
|
+
this.driver = driver;
|
|
757
|
+
this.initialized = true;
|
|
758
|
+
}
|
|
759
|
+
async get(key) {
|
|
760
|
+
await this.initialize();
|
|
761
|
+
const val = await this.driver.get(key);
|
|
762
|
+
if (val) {
|
|
763
|
+
redactionFilter.registerSecret(val);
|
|
764
|
+
}
|
|
765
|
+
return val;
|
|
766
|
+
}
|
|
767
|
+
async set(key, value, scope = "personal") {
|
|
768
|
+
await this.initialize();
|
|
769
|
+
await this.driver.set(key, value, scope);
|
|
770
|
+
redactionFilter.registerSecret(value);
|
|
771
|
+
}
|
|
772
|
+
async delete(key) {
|
|
773
|
+
await this.initialize();
|
|
774
|
+
return this.driver.delete(key);
|
|
775
|
+
}
|
|
776
|
+
async list() {
|
|
777
|
+
await this.initialize();
|
|
778
|
+
return this.driver.list();
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
var secretVault = new SecretVault();
|
|
782
|
+
|
|
783
|
+
// src/vault/audit.ts
|
|
784
|
+
var VaultAuditor = class {
|
|
785
|
+
/**
|
|
786
|
+
* Scans configuration for plaintext API keys, database credentials, and unvaulted secrets.
|
|
787
|
+
*/
|
|
788
|
+
static async audit(config) {
|
|
789
|
+
await secretVault.initialize();
|
|
790
|
+
const upstreams = config.upstreams || {};
|
|
791
|
+
const serverNames = Object.keys(upstreams);
|
|
792
|
+
let vaultReferencedCount = 0;
|
|
793
|
+
const warnings = [];
|
|
794
|
+
for (const serverName of serverNames) {
|
|
795
|
+
const server = upstreams[serverName];
|
|
796
|
+
if (isStdioUpstream(server) && server.env) {
|
|
797
|
+
for (const [envVar, val] of Object.entries(server.env)) {
|
|
798
|
+
const strVal = String(val);
|
|
799
|
+
if (strVal.startsWith("vault://")) {
|
|
800
|
+
vaultReferencedCount++;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (strVal.startsWith("env://")) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const finding = this.evaluateSecret(serverName, envVar, strVal);
|
|
807
|
+
if (finding) {
|
|
808
|
+
warnings.push(finding);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (isHttpUpstream(server) && server.headers) {
|
|
813
|
+
for (const [headerKey, headerVal] of Object.entries(server.headers)) {
|
|
814
|
+
const strVal = String(headerVal);
|
|
815
|
+
if (strVal.startsWith("vault://")) {
|
|
816
|
+
vaultReferencedCount++;
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (strVal.startsWith("env://")) {
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
const finding = this.evaluateSecret(serverName, `header:${headerKey}`, strVal);
|
|
823
|
+
if (finding) {
|
|
824
|
+
warnings.push(finding);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
timestamp: Date.now(),
|
|
831
|
+
totalUpstreams: serverNames.length,
|
|
832
|
+
vaultReferencedCount,
|
|
833
|
+
plaintextWarnings: warnings,
|
|
834
|
+
activeDriver: secretVault.getActiveDriverName(),
|
|
835
|
+
registeredSecretCount: redactionFilter.getRegisteredCount()
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
static evaluateSecret(serverName, keyName, value) {
|
|
839
|
+
const trimmed = value.trim();
|
|
840
|
+
if (trimmed.startsWith("sk-ant-")) {
|
|
841
|
+
return {
|
|
842
|
+
serverName,
|
|
843
|
+
envVar: keyName,
|
|
844
|
+
severity: "critical",
|
|
845
|
+
reason: "Plaintext Anthropic API key exposed in configuration",
|
|
846
|
+
suggestion: `Store in vault: contextwise secret set ${serverName.toUpperCase()}_ANTHROPIC_KEY <value>`
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
if (trimmed.startsWith("sk-")) {
|
|
850
|
+
return {
|
|
851
|
+
serverName,
|
|
852
|
+
envVar: keyName,
|
|
853
|
+
severity: "critical",
|
|
854
|
+
reason: "Plaintext OpenAI API key exposed in configuration",
|
|
855
|
+
suggestion: `Store in vault: contextwise secret set ${serverName.toUpperCase()}_OPENAI_KEY <value>`
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
if (trimmed.startsWith("ghp_") || trimmed.startsWith("github_pat_")) {
|
|
859
|
+
return {
|
|
860
|
+
serverName,
|
|
861
|
+
envVar: keyName,
|
|
862
|
+
severity: "critical",
|
|
863
|
+
reason: "Plaintext GitHub Personal Access Token exposed in configuration",
|
|
864
|
+
suggestion: `Store in vault: contextwise secret set ${serverName.toUpperCase()}_GITHUB_TOKEN <value>`
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
if (/^[a-zA-Z0-9+]+:\/\/[^:]+:[^@]+@.+/.test(trimmed)) {
|
|
868
|
+
return {
|
|
869
|
+
serverName,
|
|
870
|
+
envVar: keyName,
|
|
871
|
+
severity: "critical",
|
|
872
|
+
reason: "Database URI with embedded password exposed in configuration",
|
|
873
|
+
suggestion: `Store connection string in vault: contextwise secret set ${serverName.toUpperCase()}_DB_URL <value>`
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const lowerKey = keyName.toLowerCase();
|
|
877
|
+
const sensitiveNamePattern = /(key|token|secret|password|passwd|auth|bearer|credential)/;
|
|
878
|
+
if (sensitiveNamePattern.test(lowerKey)) {
|
|
879
|
+
return {
|
|
880
|
+
serverName,
|
|
881
|
+
envVar: keyName,
|
|
882
|
+
severity: "warn",
|
|
883
|
+
reason: `Potential credential stored in plaintext ("${keyName}")`,
|
|
884
|
+
suggestion: `Store in vault: contextwise secret set ${serverName.toUpperCase()}_${keyName.toUpperCase()} <value>`
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// src/vault/resolver.ts
|
|
892
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
893
|
+
import { homedir as homedir4 } from "os";
|
|
894
|
+
import { join as join4 } from "path";
|
|
895
|
+
var SecretResolver = class {
|
|
896
|
+
/**
|
|
897
|
+
* Resolves a single secret token reference (vault://, auth://, env://).
|
|
898
|
+
*/
|
|
899
|
+
static async resolveSingleRef(ref, allowReferences = true) {
|
|
900
|
+
const trimmed = ref.trim();
|
|
901
|
+
if (trimmed.startsWith("vault://")) {
|
|
902
|
+
if (!allowReferences) {
|
|
903
|
+
throw new Error(
|
|
904
|
+
'Security violation: "vault://" secret references are strictly prohibited in dynamic/untrusted server configurations.'
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
const secretKey = trimmed.slice("vault://".length).trim();
|
|
908
|
+
if (!secretKey) {
|
|
909
|
+
throw new Error('Invalid vault reference format: expected "vault://<key>"');
|
|
910
|
+
}
|
|
911
|
+
const secretValue = await secretVault.get(secretKey);
|
|
912
|
+
if (secretValue === null || secretValue === void 0) {
|
|
913
|
+
throw new Error(
|
|
914
|
+
`Vault secret "${secretKey}" was not found in the ContextWise vault. Set it using: contextwise secret set "${secretKey}" <value>`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
redactionFilter.registerSecret(secretValue);
|
|
918
|
+
return secretValue;
|
|
919
|
+
}
|
|
920
|
+
if (trimmed.startsWith("auth://") || trimmed.startsWith("mcp-auth://")) {
|
|
921
|
+
if (!allowReferences) {
|
|
922
|
+
throw new Error(
|
|
923
|
+
'Security violation: "auth://" secret references are strictly prohibited in dynamic/untrusted server configurations.'
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
const serverKey = trimmed.replace(/^(?:auth|mcp-auth):\/\//, "").trim();
|
|
927
|
+
if (!serverKey) {
|
|
928
|
+
throw new Error('Invalid auth reference format: expected "auth://<server>"');
|
|
929
|
+
}
|
|
930
|
+
const mcpAuthPath = join4(homedir4(), ".local", "share", "opencode", "mcp-auth.json");
|
|
931
|
+
if (existsSync4(mcpAuthPath)) {
|
|
932
|
+
try {
|
|
933
|
+
const authData = JSON.parse(readFileSync4(mcpAuthPath, "utf-8"));
|
|
934
|
+
const token = authData[serverKey]?.tokens?.accessToken;
|
|
935
|
+
if (token) {
|
|
936
|
+
redactionFilter.registerSecret(token);
|
|
937
|
+
return token;
|
|
938
|
+
}
|
|
939
|
+
} catch (e) {
|
|
940
|
+
logger.warn(`Failed to read token from ${mcpAuthPath}: ${e}`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
throw new Error(
|
|
944
|
+
`OAuth access token for "${serverKey}" was not found in ${mcpAuthPath}. Ensure you are authenticated in OpenCode with: opencode mcp auth ${serverKey}`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
if (trimmed.startsWith("env://")) {
|
|
948
|
+
if (!allowReferences) {
|
|
949
|
+
throw new Error(
|
|
950
|
+
'Security violation: "env://" environment variable references are strictly prohibited in dynamic/untrusted server configurations.'
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
let varName = trimmed.slice("env://".length).trim();
|
|
954
|
+
if (varName.startsWith("${") && varName.endsWith("}")) {
|
|
955
|
+
varName = varName.slice(2, -1).trim();
|
|
956
|
+
}
|
|
957
|
+
const envVal = process.env[varName];
|
|
958
|
+
if (envVal === void 0) {
|
|
959
|
+
logger.warn(`Environment variable "${varName}" referenced via env:// is not defined`);
|
|
960
|
+
return "";
|
|
961
|
+
}
|
|
962
|
+
redactionFilter.registerSecret(envVal);
|
|
963
|
+
return envVal;
|
|
964
|
+
}
|
|
965
|
+
return trimmed;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Resolves a single configuration value that may contain a vault://, auth://, or env:// reference.
|
|
969
|
+
* If allowReferences is false, secret references are rejected to prevent unauthorized vault access.
|
|
970
|
+
*/
|
|
971
|
+
static async resolveValue(val, allowReferences = true) {
|
|
972
|
+
if (!val || typeof val !== "string") {
|
|
973
|
+
return val;
|
|
974
|
+
}
|
|
975
|
+
const trimmed = val.trim();
|
|
976
|
+
const matches = Array.from(
|
|
977
|
+
trimmed.matchAll(/(vault:\/\/[^\s"',]+|auth:\/\/[^\s"',]+|mcp-auth:\/\/[^\s"',]+|env:\/\/(?:\$\{[^}]+\}|[^\s"',]+))/g)
|
|
978
|
+
);
|
|
979
|
+
if (matches.length > 0) {
|
|
980
|
+
let result = trimmed;
|
|
981
|
+
for (const m of matches) {
|
|
982
|
+
const fullRef = m[0];
|
|
983
|
+
const resolved = await this.resolveSingleRef(fullRef, allowReferences);
|
|
984
|
+
result = result.replace(fullRef, resolved);
|
|
985
|
+
}
|
|
986
|
+
return result;
|
|
987
|
+
}
|
|
988
|
+
if (trimmed.startsWith("sk-") || trimmed.startsWith("ghp_") || trimmed.startsWith("gho_") || trimmed.startsWith("glpat-") || trimmed.startsWith("xoxb-") || trimmed.startsWith("xoxp-") || /^eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/.test(trimmed)) {
|
|
989
|
+
redactionFilter.registerSecret(trimmed);
|
|
990
|
+
}
|
|
991
|
+
return val;
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Resolves an environment dictionary, replacing all vault:// and env:// references.
|
|
995
|
+
*/
|
|
996
|
+
static async resolveEnv(envMap, allowReferences = true) {
|
|
997
|
+
if (!envMap || typeof envMap !== "object") {
|
|
998
|
+
return {};
|
|
999
|
+
}
|
|
1000
|
+
const resolved = {};
|
|
1001
|
+
for (const [key, value] of Object.entries(envMap)) {
|
|
1002
|
+
if (typeof value === "string") {
|
|
1003
|
+
resolved[key] = await this.resolveValue(value, allowReferences);
|
|
1004
|
+
} else {
|
|
1005
|
+
resolved[key] = String(value);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return resolved;
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
// src/core/multiplexer.ts
|
|
1013
|
+
import { EventEmitter } from "events";
|
|
1014
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
1015
|
+
import {
|
|
1016
|
+
getDefaultEnvironment,
|
|
1017
|
+
StdioClientTransport
|
|
1018
|
+
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
1019
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
1020
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
1021
|
+
import {
|
|
1022
|
+
ToolListChangedNotificationSchema
|
|
1023
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
1024
|
+
function escapeWindowsCmdArg(arg) {
|
|
1025
|
+
if (!arg) return '""';
|
|
1026
|
+
let escaped = arg.replace(/(\\*)"/g, '$1$1\\"');
|
|
1027
|
+
escaped = escaped.replace(/(\\+)$/g, "$1$1");
|
|
1028
|
+
if (/[\s&|<>()%^"!]/.test(escaped)) {
|
|
1029
|
+
escaped = `"${escaped}"`;
|
|
1030
|
+
}
|
|
1031
|
+
return escaped;
|
|
1032
|
+
}
|
|
1033
|
+
function inferToolHints(toolName, desc = "", annotations) {
|
|
1034
|
+
let readOnlyHint;
|
|
1035
|
+
let destructiveHint;
|
|
1036
|
+
let idempotentHint;
|
|
1037
|
+
if (annotations && typeof annotations === "object") {
|
|
1038
|
+
if (typeof annotations.readOnlyHint === "boolean") {
|
|
1039
|
+
readOnlyHint = annotations.readOnlyHint;
|
|
1040
|
+
} else if (typeof annotations.readOnly === "boolean") {
|
|
1041
|
+
readOnlyHint = annotations.readOnly;
|
|
1042
|
+
} else {
|
|
1043
|
+
readOnlyHint = false;
|
|
1044
|
+
}
|
|
1045
|
+
if (typeof annotations.destructiveHint === "boolean") {
|
|
1046
|
+
destructiveHint = annotations.destructiveHint;
|
|
1047
|
+
} else if (typeof annotations.destructive === "boolean") {
|
|
1048
|
+
destructiveHint = annotations.destructive;
|
|
1049
|
+
} else {
|
|
1050
|
+
destructiveHint = false;
|
|
1051
|
+
}
|
|
1052
|
+
if (typeof annotations.idempotentHint === "boolean") {
|
|
1053
|
+
idempotentHint = annotations.idempotentHint;
|
|
1054
|
+
} else {
|
|
1055
|
+
idempotentHint = readOnlyHint;
|
|
1056
|
+
}
|
|
1057
|
+
} else {
|
|
1058
|
+
const hasReadOnlyVerb = /(?:^|[_.-])(?:read|get|list|fetch|inspect|describe|view)(?:[_.-]|$)/i.test(toolName);
|
|
1059
|
+
const hasMutatingVerb = /(?:^|[_.-])(?:query|exec|execute|run|create|update|insert|delete|drop|mutate|write|post|put|patch|set|add|send)(?:[_.-]|$)/i.test(toolName);
|
|
1060
|
+
const hasExplicitReadOnlyDesc = /\b(read-only|readonly|does not modify)\b/i.test(desc);
|
|
1061
|
+
readOnlyHint = hasReadOnlyVerb && !hasMutatingVerb || hasExplicitReadOnlyDesc;
|
|
1062
|
+
destructiveHint = /(?:^|[_.-])(?:delete|drop|remove|truncate|kill|destroy|terminate|purge)(?:[_.-]|$)/i.test(toolName) || /\b(destructive|irreversible)\b/i.test(desc);
|
|
1063
|
+
idempotentHint = readOnlyHint;
|
|
1064
|
+
}
|
|
1065
|
+
return { readOnlyHint, idempotentHint, destructiveHint };
|
|
1066
|
+
}
|
|
1067
|
+
var UpstreamMultiplexer = class extends EventEmitter {
|
|
1068
|
+
connections = /* @__PURE__ */ new Map();
|
|
1069
|
+
/** Mapping of exposed tool name -> { serverName, originalName } */
|
|
1070
|
+
toolRoutingTable = /* @__PURE__ */ new Map();
|
|
1071
|
+
constructor() {
|
|
1072
|
+
super();
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Initializes connections to all configured upstream servers in parallel.
|
|
1076
|
+
*/
|
|
1077
|
+
async connectAll(upstreams) {
|
|
1078
|
+
const entries = Object.entries(upstreams);
|
|
1079
|
+
if (entries.length === 0) {
|
|
1080
|
+
logger.info("No upstream MCP servers configured.");
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
logger.info(`Connecting to ${entries.length} upstream MCP servers...`);
|
|
1084
|
+
const promises = entries.map(([name, config]) => this.connectServer(name, config));
|
|
1085
|
+
await Promise.allSettled(promises);
|
|
1086
|
+
this.rebuildRoutingTable();
|
|
1087
|
+
logger.info(
|
|
1088
|
+
`Multiplexer initialized. Total aggregated tools: ${this.toolRoutingTable.size}`
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Connects to a single upstream server.
|
|
1093
|
+
*/
|
|
1094
|
+
async connectServer(name, config) {
|
|
1095
|
+
try {
|
|
1096
|
+
logger.info(`Connecting upstream [${name}]...`);
|
|
1097
|
+
let transport;
|
|
1098
|
+
if (isStdioUpstream(config)) {
|
|
1099
|
+
const baseEnv = getDefaultEnvironment();
|
|
1100
|
+
const mergedEnv = { ...baseEnv };
|
|
1101
|
+
if (config.env) {
|
|
1102
|
+
const resolvedCustomEnv = await SecretResolver.resolveEnv(config.env);
|
|
1103
|
+
for (const [k, v] of Object.entries(resolvedCustomEnv)) {
|
|
1104
|
+
mergedEnv[k] = v;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
let command = config.command;
|
|
1108
|
+
let args = config.args ?? [];
|
|
1109
|
+
if (process.platform === "win32") {
|
|
1110
|
+
const cmdLower = config.command.toLowerCase();
|
|
1111
|
+
if (["npx", "npm", "pnpm", "yarn", "bunx"].includes(cmdLower) || cmdLower.endsWith(".cmd") || cmdLower.endsWith(".bat")) {
|
|
1112
|
+
command = "cmd.exe";
|
|
1113
|
+
args = ["/d", "/s", "/c", config.command, ...args.map(escapeWindowsCmdArg)];
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
transport = new StdioClientTransport({
|
|
1117
|
+
command,
|
|
1118
|
+
args,
|
|
1119
|
+
env: mergedEnv,
|
|
1120
|
+
cwd: config.cwd,
|
|
1121
|
+
stderr: "pipe"
|
|
1122
|
+
});
|
|
1123
|
+
} else if (isHttpUpstream(config)) {
|
|
1124
|
+
warnIfInsecureHttp(config.url, config.headers);
|
|
1125
|
+
const resolvedHeaders = config.headers ? await SecretResolver.resolveEnv(config.headers) : {};
|
|
1126
|
+
const transportType = config.transport ?? "auto";
|
|
1127
|
+
if (transportType === "streamable-http") {
|
|
1128
|
+
transport = new StreamableHTTPClientTransport(new URL(config.url), {
|
|
1129
|
+
requestInit: {
|
|
1130
|
+
headers: resolvedHeaders
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
} else if (transportType === "sse") {
|
|
1134
|
+
transport = new SSEClientTransport(new URL(config.url), {
|
|
1135
|
+
requestInit: {
|
|
1136
|
+
headers: resolvedHeaders
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
} else {
|
|
1140
|
+
transport = new SSEClientTransport(new URL(config.url), {
|
|
1141
|
+
requestInit: {
|
|
1142
|
+
headers: resolvedHeaders
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
} else {
|
|
1147
|
+
throw new Error(`Unsupported upstream configuration for server "${name}"`);
|
|
1148
|
+
}
|
|
1149
|
+
const client = new Client(
|
|
1150
|
+
{
|
|
1151
|
+
name: `contextwise-${name}`,
|
|
1152
|
+
version: "0.1.0"
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
capabilities: {}
|
|
1156
|
+
}
|
|
1157
|
+
);
|
|
1158
|
+
const conn = {
|
|
1159
|
+
name,
|
|
1160
|
+
config,
|
|
1161
|
+
client,
|
|
1162
|
+
transport,
|
|
1163
|
+
status: "connecting",
|
|
1164
|
+
tools: /* @__PURE__ */ new Map(),
|
|
1165
|
+
reconnectAttempts: 0
|
|
1166
|
+
};
|
|
1167
|
+
this.connections.set(name, conn);
|
|
1168
|
+
if (transport instanceof StdioClientTransport) {
|
|
1169
|
+
if (transport.stderr) {
|
|
1170
|
+
transport.stderr.on("data", (chunk) => {
|
|
1171
|
+
const str = chunk.toString("utf-8").trim();
|
|
1172
|
+
if (str) {
|
|
1173
|
+
logger.debug(`[upstream:${name}:stderr] ${redactionFilter.redact(str)}`);
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
await client.connect(transport);
|
|
1179
|
+
if (transport instanceof StdioClientTransport && transport.pid) {
|
|
1180
|
+
supervisor.track(transport.pid, name);
|
|
1181
|
+
}
|
|
1182
|
+
conn.status = "connected";
|
|
1183
|
+
conn.reconnectAttempts = 0;
|
|
1184
|
+
logger.info(`Upstream [${name}] connected successfully.`);
|
|
1185
|
+
await this.refreshServerTools(name);
|
|
1186
|
+
transport.onclose = () => {
|
|
1187
|
+
logger.warn(`Upstream [${name}] transport closed.`);
|
|
1188
|
+
conn.status = "disconnected";
|
|
1189
|
+
this.rebuildRoutingTable();
|
|
1190
|
+
this.emit("toolsChanged", name, this.getAllTools());
|
|
1191
|
+
const shouldReconnect = isStdioUpstream(config) ? config.autoRestart !== false : config.autoReconnect !== false;
|
|
1192
|
+
if (shouldReconnect && conn.reconnectAttempts < 5) {
|
|
1193
|
+
const delay = Math.min(1e3 * Math.pow(2, conn.reconnectAttempts), 15e3);
|
|
1194
|
+
conn.reconnectAttempts += 1;
|
|
1195
|
+
logger.info(`Scheduling auto-reconnect for [${name}] in ${delay}ms (attempt ${conn.reconnectAttempts}/5)...`);
|
|
1196
|
+
setTimeout(() => {
|
|
1197
|
+
if (this.connections.has(name)) {
|
|
1198
|
+
this.connectServer(name, config).catch((err) => {
|
|
1199
|
+
logger.error(`Reconnect attempt failed for [${name}]: ${err}`);
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}, delay);
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
transport.onerror = (err) => {
|
|
1206
|
+
logger.error(`Upstream [${name}] transport error: ${err.message || err}`);
|
|
1207
|
+
conn.lastError = err.message || String(err);
|
|
1208
|
+
};
|
|
1209
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
1210
|
+
logger.info(`[${name}] Received notifications/tools/list_changed. Refreshing tools...`);
|
|
1211
|
+
await this.refreshServerTools(name);
|
|
1212
|
+
this.emit("toolsChanged", name, this.getAllTools());
|
|
1213
|
+
});
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1216
|
+
logger.error(`Failed to connect upstream [${name}]: ${msg}`);
|
|
1217
|
+
const conn = this.connections.get(name);
|
|
1218
|
+
if (conn) {
|
|
1219
|
+
conn.status = "error";
|
|
1220
|
+
conn.lastError = msg;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Refreshes the tools catalog for a specific upstream server.
|
|
1226
|
+
*/
|
|
1227
|
+
async refreshServerTools(name) {
|
|
1228
|
+
const conn = this.connections.get(name);
|
|
1229
|
+
if (!conn || conn.status !== "connected") return;
|
|
1230
|
+
try {
|
|
1231
|
+
const response = await conn.client.listTools();
|
|
1232
|
+
conn.tools.clear();
|
|
1233
|
+
for (const tool of response.tools) {
|
|
1234
|
+
const namespacedName = `${name}__${tool.name}`;
|
|
1235
|
+
const desc = tool.description || "";
|
|
1236
|
+
const annotations = tool.annotations;
|
|
1237
|
+
const { readOnlyHint, idempotentHint, destructiveHint } = inferToolHints(tool.name, desc, annotations);
|
|
1238
|
+
const namespacedTool = {
|
|
1239
|
+
...tool,
|
|
1240
|
+
originalName: tool.name,
|
|
1241
|
+
namespacedName,
|
|
1242
|
+
serverName: name,
|
|
1243
|
+
readOnlyHint,
|
|
1244
|
+
idempotentHint,
|
|
1245
|
+
destructiveHint
|
|
1246
|
+
};
|
|
1247
|
+
conn.tools.set(tool.name, namespacedTool);
|
|
1248
|
+
}
|
|
1249
|
+
logger.debug(`Loaded ${conn.tools.size} tools from upstream [${name}]`);
|
|
1250
|
+
this.rebuildRoutingTable();
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
logger.error(`Error listing tools for [${name}]: ${err}`);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Rebuilds the global tool routing table with namespacing and fallback alias mapping.
|
|
1257
|
+
*/
|
|
1258
|
+
rebuildRoutingTable() {
|
|
1259
|
+
this.toolRoutingTable.clear();
|
|
1260
|
+
for (const [serverName, conn] of this.connections.entries()) {
|
|
1261
|
+
if (conn.status !== "connected") continue;
|
|
1262
|
+
for (const [origName, tool] of conn.tools.entries()) {
|
|
1263
|
+
this.toolRoutingTable.set(tool.namespacedName, {
|
|
1264
|
+
serverName,
|
|
1265
|
+
originalName: origName,
|
|
1266
|
+
tool
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const toolNameCounts = /* @__PURE__ */ new Map();
|
|
1271
|
+
for (const conn of this.connections.values()) {
|
|
1272
|
+
if (conn.status !== "connected") continue;
|
|
1273
|
+
for (const origName of conn.tools.keys()) {
|
|
1274
|
+
toolNameCounts.set(origName, (toolNameCounts.get(origName) ?? 0) + 1);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
const RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
1278
|
+
"contextwise_search_tools",
|
|
1279
|
+
"contextwise_execute_tool",
|
|
1280
|
+
"contextwise_browse_servers",
|
|
1281
|
+
"contextwise_add_server"
|
|
1282
|
+
]);
|
|
1283
|
+
for (const [serverName, conn] of this.connections.entries()) {
|
|
1284
|
+
if (conn.status !== "connected") continue;
|
|
1285
|
+
for (const [origName, tool] of conn.tools.entries()) {
|
|
1286
|
+
if (toolNameCounts.get(origName) === 1 && !RESERVED_NAMES.has(origName)) {
|
|
1287
|
+
this.toolRoutingTable.set(origName, {
|
|
1288
|
+
serverName,
|
|
1289
|
+
originalName: origName,
|
|
1290
|
+
tool
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Returns all aggregated tools with namespaced definitions.
|
|
1298
|
+
*/
|
|
1299
|
+
getAllTools() {
|
|
1300
|
+
const list = [];
|
|
1301
|
+
for (const conn of this.connections.values()) {
|
|
1302
|
+
if (conn.status !== "connected") continue;
|
|
1303
|
+
for (const tool of conn.tools.values()) {
|
|
1304
|
+
list.push(tool);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return list;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Resolves a tool by name (namespaced or short name).
|
|
1311
|
+
*/
|
|
1312
|
+
resolveTool(name) {
|
|
1313
|
+
let resolved = this.toolRoutingTable.get(name);
|
|
1314
|
+
if (resolved) return resolved;
|
|
1315
|
+
const doubleUnderscore = name.replace(/^([a-zA-Z0-9_-]+?)_(.+)$/, "$1__$2");
|
|
1316
|
+
resolved = this.toolRoutingTable.get(doubleUnderscore);
|
|
1317
|
+
if (resolved) return resolved;
|
|
1318
|
+
const dotToDouble = name.replace(/^([a-zA-Z0-9_-]+?)\.(.+)$/, "$1__$2");
|
|
1319
|
+
resolved = this.toolRoutingTable.get(dotToDouble);
|
|
1320
|
+
if (resolved) return resolved;
|
|
1321
|
+
for (const serverName of this.connections.keys()) {
|
|
1322
|
+
if (name.startsWith(`${serverName}_`) || name.startsWith(`${serverName}.`)) {
|
|
1323
|
+
const stripped = name.slice(serverName.length + 1);
|
|
1324
|
+
resolved = this.toolRoutingTable.get(stripped);
|
|
1325
|
+
if (resolved) return resolved;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return void 0;
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Executes a tool against its upstream MCP server with a timeout.
|
|
1332
|
+
*/
|
|
1333
|
+
async executeTool(toolName, args = {}, timeoutMs = 3e4) {
|
|
1334
|
+
const target = this.resolveTool(toolName);
|
|
1335
|
+
if (!target) {
|
|
1336
|
+
throw new Error(`Tool "${toolName}" not found in ContextWise registry.`);
|
|
1337
|
+
}
|
|
1338
|
+
const conn = this.connections.get(target.serverName);
|
|
1339
|
+
if (!conn || conn.status !== "connected") {
|
|
1340
|
+
throw new Error(
|
|
1341
|
+
`Upstream server "${target.serverName}" for tool "${toolName}" is not currently connected.`
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
const safeArgs = args && typeof args === "object" ? args : {};
|
|
1345
|
+
const start = performance.now();
|
|
1346
|
+
logger.debug(`Dispatching tool call to [${target.serverName}]: ${target.originalName}`);
|
|
1347
|
+
try {
|
|
1348
|
+
const callPromise = conn.client.callTool({
|
|
1349
|
+
name: target.originalName,
|
|
1350
|
+
arguments: safeArgs
|
|
1351
|
+
});
|
|
1352
|
+
let timer = null;
|
|
1353
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1354
|
+
timer = setTimeout(() => {
|
|
1355
|
+
reject(
|
|
1356
|
+
new Error(
|
|
1357
|
+
`Tool execution for [${target.serverName}] ${target.originalName} timed out after ${timeoutMs}ms.`
|
|
1358
|
+
)
|
|
1359
|
+
);
|
|
1360
|
+
}, timeoutMs);
|
|
1361
|
+
});
|
|
1362
|
+
const result = await Promise.race([callPromise, timeoutPromise]).finally(() => {
|
|
1363
|
+
if (timer) clearTimeout(timer);
|
|
1364
|
+
});
|
|
1365
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
1366
|
+
return {
|
|
1367
|
+
result,
|
|
1368
|
+
latencyMs,
|
|
1369
|
+
serverName: target.serverName
|
|
1370
|
+
};
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
1373
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1374
|
+
logger.error(
|
|
1375
|
+
`Execution failed for [${target.serverName}] ${target.originalName}: ${msg}`
|
|
1376
|
+
);
|
|
1377
|
+
throw err;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Returns status summaries for all configured upstreams.
|
|
1382
|
+
*/
|
|
1383
|
+
getStatus() {
|
|
1384
|
+
const statuses = [];
|
|
1385
|
+
for (const [name, conn] of this.connections.entries()) {
|
|
1386
|
+
statuses.push({
|
|
1387
|
+
name,
|
|
1388
|
+
status: conn.status,
|
|
1389
|
+
transportType: isStdioUpstream(conn.config) ? "stdio" : conn.transport instanceof StreamableHTTPClientTransport ? "streamable-http" : "sse",
|
|
1390
|
+
toolsCount: conn.tools.size,
|
|
1391
|
+
error: conn.lastError
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
return statuses;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Shuts down all upstream connections and processes.
|
|
1398
|
+
*/
|
|
1399
|
+
async closeAll() {
|
|
1400
|
+
logger.info("Closing all upstream multiplexer connections...");
|
|
1401
|
+
for (const [name, conn] of this.connections.entries()) {
|
|
1402
|
+
try {
|
|
1403
|
+
if (conn.transport instanceof StdioClientTransport && conn.transport.pid) {
|
|
1404
|
+
supervisor.killProcessTree(conn.transport.pid, name);
|
|
1405
|
+
}
|
|
1406
|
+
await conn.client.close();
|
|
1407
|
+
} catch {
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
this.connections.clear();
|
|
1411
|
+
this.toolRoutingTable.clear();
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
// src/catalog/normalizer.ts
|
|
1416
|
+
var ToolNormalizer = class {
|
|
1417
|
+
/**
|
|
1418
|
+
* Normalizes a NamespacedTool into a searchable document for BM25 and vector indexing.
|
|
1419
|
+
*/
|
|
1420
|
+
static normalize(tool) {
|
|
1421
|
+
const paramsList = [];
|
|
1422
|
+
const schema = tool.inputSchema;
|
|
1423
|
+
if (schema && typeof schema.properties === "object" && schema.properties !== null) {
|
|
1424
|
+
const properties = schema.properties;
|
|
1425
|
+
for (const [paramName, paramDef] of Object.entries(properties)) {
|
|
1426
|
+
const type = typeof paramDef?.type === "string" ? paramDef.type : "any";
|
|
1427
|
+
const desc = typeof paramDef?.description === "string" ? paramDef.description : "";
|
|
1428
|
+
paramsList.push(`${paramName} (${type}): ${desc}`);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const parametersText = paramsList.join("; ");
|
|
1432
|
+
const tags = this.inferTags(tool);
|
|
1433
|
+
const tagsText = tags.join(", ");
|
|
1434
|
+
const fullSearchableText = [
|
|
1435
|
+
`Tool: ${tool.originalName} (${tool.namespacedName})`,
|
|
1436
|
+
`Server: ${tool.serverName}`,
|
|
1437
|
+
`Description: ${tool.description || ""}`,
|
|
1438
|
+
paramsList.length > 0 ? `Parameters: ${parametersText}` : "",
|
|
1439
|
+
tags.length > 0 ? `Tags: ${tagsText}` : ""
|
|
1440
|
+
].filter(Boolean).join("\n");
|
|
1441
|
+
return {
|
|
1442
|
+
id: tool.namespacedName,
|
|
1443
|
+
toolName: tool.namespacedName,
|
|
1444
|
+
originalName: tool.originalName,
|
|
1445
|
+
serverName: tool.serverName,
|
|
1446
|
+
description: tool.description || "",
|
|
1447
|
+
parametersText,
|
|
1448
|
+
tagsText,
|
|
1449
|
+
fullSearchableText,
|
|
1450
|
+
readOnlyHint: tool.readOnlyHint ?? false,
|
|
1451
|
+
destructiveHint: tool.destructiveHint ?? false
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Infers domain categorization tags from tool names and descriptions.
|
|
1456
|
+
*/
|
|
1457
|
+
static inferTags(tool) {
|
|
1458
|
+
const text = `${tool.namespacedName} ${tool.originalName} ${tool.description || ""}`.toLowerCase();
|
|
1459
|
+
const tags = /* @__PURE__ */ new Set();
|
|
1460
|
+
const tagRules = {
|
|
1461
|
+
database: /\b(db|sql|postgres|mysql|sqlite|mongo|query|table|schema|database)\b/i,
|
|
1462
|
+
filesystem: /\b(file|dir|directory|folder|read_file|write_file|fs|path)\b/i,
|
|
1463
|
+
git: /\b(git|commit|branch|repo|repository|pull_request|pr|diff|merge)\b/i,
|
|
1464
|
+
code: /\b(code|ast|syntax|refactor|symbol|definition|function)\b/i,
|
|
1465
|
+
docker: /\b(docker|container|image|compose|podman)\b/i,
|
|
1466
|
+
cloud: /\b(aws|gcp|azure|s3|ec2|lambda|cloud)\b/i,
|
|
1467
|
+
communication: /\b(slack|discord|email|message|chat|notify|channel)\b/i,
|
|
1468
|
+
web: /\b(http|url|fetch|request|scrape|browser|crawl|api)\b/i,
|
|
1469
|
+
issue: /\b(jira|issue|linear|ticket|bug|task|tracker)\b/i
|
|
1470
|
+
};
|
|
1471
|
+
for (const [tag, regex] of Object.entries(tagRules)) {
|
|
1472
|
+
if (regex.test(text)) {
|
|
1473
|
+
tags.add(tag);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (tool.readOnlyHint) tags.add("read-only");
|
|
1477
|
+
if (tool.destructiveHint) tags.add("destructive");
|
|
1478
|
+
return Array.from(tags);
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
// src/catalog/search.ts
|
|
1483
|
+
import MiniSearch from "minisearch";
|
|
1484
|
+
var HybridSearchEngine = class {
|
|
1485
|
+
miniSearch;
|
|
1486
|
+
toolMap = /* @__PURE__ */ new Map();
|
|
1487
|
+
constructor() {
|
|
1488
|
+
this.miniSearch = new MiniSearch({
|
|
1489
|
+
fields: ["originalName", "toolName", "tagsText", "description", "parametersText"],
|
|
1490
|
+
storeFields: ["id", "originalName", "serverName"],
|
|
1491
|
+
searchOptions: {
|
|
1492
|
+
boost: {
|
|
1493
|
+
originalName: 4,
|
|
1494
|
+
toolName: 3,
|
|
1495
|
+
tagsText: 2.5,
|
|
1496
|
+
description: 1.5,
|
|
1497
|
+
parametersText: 1
|
|
1498
|
+
},
|
|
1499
|
+
prefix: true,
|
|
1500
|
+
fuzzy: 0.2
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Indexes a collection of tools.
|
|
1506
|
+
*/
|
|
1507
|
+
indexTools(tools) {
|
|
1508
|
+
this.miniSearch.removeAll();
|
|
1509
|
+
this.toolMap.clear();
|
|
1510
|
+
const documents = [];
|
|
1511
|
+
for (const tool of tools) {
|
|
1512
|
+
this.toolMap.set(tool.namespacedName, tool);
|
|
1513
|
+
const doc = ToolNormalizer.normalize(tool);
|
|
1514
|
+
documents.push(doc);
|
|
1515
|
+
}
|
|
1516
|
+
if (documents.length > 0) {
|
|
1517
|
+
this.miniSearch.addAll(documents);
|
|
1518
|
+
logger.debug(`Indexed ${documents.length} tools in hybrid search engine.`);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Adds or updates a single tool in the search index.
|
|
1523
|
+
*/
|
|
1524
|
+
addTool(tool) {
|
|
1525
|
+
if (this.toolMap.has(tool.namespacedName)) {
|
|
1526
|
+
this.miniSearch.discard(tool.namespacedName);
|
|
1527
|
+
}
|
|
1528
|
+
this.toolMap.set(tool.namespacedName, tool);
|
|
1529
|
+
this.miniSearch.add(ToolNormalizer.normalize(tool));
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Searches for tools matching query string.
|
|
1533
|
+
*/
|
|
1534
|
+
search(query, options = {}) {
|
|
1535
|
+
const topK = Math.max(1, options.topK ?? 5);
|
|
1536
|
+
const cleanQuery = (query || "").trim();
|
|
1537
|
+
if (!cleanQuery) {
|
|
1538
|
+
return [];
|
|
1539
|
+
}
|
|
1540
|
+
try {
|
|
1541
|
+
const rawResults = this.miniSearch.search(cleanQuery, {
|
|
1542
|
+
filter: (result) => {
|
|
1543
|
+
const tool = this.toolMap.get(result.id);
|
|
1544
|
+
if (!tool) return false;
|
|
1545
|
+
if (options.serverName && tool.serverName !== options.serverName) return false;
|
|
1546
|
+
if (options.domain) {
|
|
1547
|
+
const tags = ToolNormalizer.inferTags(tool);
|
|
1548
|
+
if (!tags.includes(options.domain.toLowerCase())) return false;
|
|
1549
|
+
}
|
|
1550
|
+
return true;
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
if (rawResults.length === 0) {
|
|
1554
|
+
return [];
|
|
1555
|
+
}
|
|
1556
|
+
const topScore = rawResults[0].score;
|
|
1557
|
+
const baseMinScore = options.minScore ?? 0.8;
|
|
1558
|
+
if (topScore < baseMinScore) {
|
|
1559
|
+
return [];
|
|
1560
|
+
}
|
|
1561
|
+
const relativeThreshold = options.similarityThreshold ?? 0.35;
|
|
1562
|
+
const scoreCutoff = Math.max(baseMinScore, topScore * relativeThreshold);
|
|
1563
|
+
const results = [];
|
|
1564
|
+
for (const res of rawResults) {
|
|
1565
|
+
const tool = this.toolMap.get(res.id);
|
|
1566
|
+
if (!tool) continue;
|
|
1567
|
+
if (res.score < scoreCutoff) {
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
results.push({
|
|
1571
|
+
tool,
|
|
1572
|
+
score: res.score,
|
|
1573
|
+
matchTerms: res.match ? Object.keys(res.match) : [cleanQuery]
|
|
1574
|
+
});
|
|
1575
|
+
if (results.length >= topK) {
|
|
1576
|
+
break;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
return results;
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
logger.debug(`MiniSearch error on query "${cleanQuery}": ${err}`);
|
|
1582
|
+
return [];
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Returns total count of indexed tools.
|
|
1587
|
+
*/
|
|
1588
|
+
get size() {
|
|
1589
|
+
return this.toolMap.size;
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
var searchEngine = new HybridSearchEngine();
|
|
1593
|
+
|
|
1594
|
+
// src/catalog/registry.ts
|
|
1595
|
+
var ToolRegistry = class {
|
|
1596
|
+
allTools = /* @__PURE__ */ new Map();
|
|
1597
|
+
pinnedToolNames = /* @__PURE__ */ new Set();
|
|
1598
|
+
/** Set of dynamically activated tools in current session */
|
|
1599
|
+
dynamicActiveTools = /* @__PURE__ */ new Map();
|
|
1600
|
+
// toolName -> lastUsedTimestamp
|
|
1601
|
+
maxActiveTools;
|
|
1602
|
+
constructor(options = {}) {
|
|
1603
|
+
this.maxActiveTools = options.maxActiveTools ?? 10;
|
|
1604
|
+
if (options.pinnedToolNames) {
|
|
1605
|
+
this.setPinnedTools(options.pinnedToolNames);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
setMaxActiveTools(max) {
|
|
1609
|
+
this.maxActiveTools = Math.max(1, max);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Updates LRU timestamp when a tool is invoked.
|
|
1613
|
+
*/
|
|
1614
|
+
touchTool(name) {
|
|
1615
|
+
const tool = this.getTool(name);
|
|
1616
|
+
if (tool && this.dynamicActiveTools.has(tool.namespacedName)) {
|
|
1617
|
+
this.dynamicActiveTools.set(tool.namespacedName, Date.now());
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
setPinnedTools(toolNames) {
|
|
1621
|
+
this.pinnedToolNames = new Set(toolNames);
|
|
1622
|
+
logger.debug(`Configured ${this.pinnedToolNames.size} pinned tools.`);
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Registers or updates all tools from upstreams into the catalog.
|
|
1626
|
+
*/
|
|
1627
|
+
setAllTools(tools) {
|
|
1628
|
+
this.allTools.clear();
|
|
1629
|
+
const toolNameCounts = /* @__PURE__ */ new Map();
|
|
1630
|
+
for (const tool of tools) {
|
|
1631
|
+
toolNameCounts.set(tool.originalName, (toolNameCounts.get(tool.originalName) ?? 0) + 1);
|
|
1632
|
+
}
|
|
1633
|
+
const RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
1634
|
+
"contextwise_search_tools",
|
|
1635
|
+
"contextwise_execute_tool",
|
|
1636
|
+
"contextwise_browse_servers",
|
|
1637
|
+
"contextwise_add_server"
|
|
1638
|
+
]);
|
|
1639
|
+
for (const tool of tools) {
|
|
1640
|
+
this.allTools.set(tool.namespacedName, tool);
|
|
1641
|
+
if (toolNameCounts.get(tool.originalName) === 1 && !RESERVED_NAMES.has(tool.originalName)) {
|
|
1642
|
+
this.allTools.set(tool.originalName, tool);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
searchEngine.indexTools(tools);
|
|
1646
|
+
for (const key of this.dynamicActiveTools.keys()) {
|
|
1647
|
+
if (!this.allTools.has(key)) {
|
|
1648
|
+
this.dynamicActiveTools.delete(key);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
logger.debug(`Catalog registry updated with ${tools.length} tools.`);
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Retrieves a tool by either its namespaced name or original name.
|
|
1655
|
+
*/
|
|
1656
|
+
getTool(name) {
|
|
1657
|
+
return this.allTools.get(name);
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Activates tools dynamically (e.g. when summoned by contextwise_search_tools).
|
|
1661
|
+
* Evicts least-recently-activated tools if exceeding maxActiveTools.
|
|
1662
|
+
*/
|
|
1663
|
+
activateTools(toolNames) {
|
|
1664
|
+
const activated = [];
|
|
1665
|
+
const now = Date.now();
|
|
1666
|
+
for (const name of toolNames) {
|
|
1667
|
+
const tool = this.getTool(name);
|
|
1668
|
+
if (tool) {
|
|
1669
|
+
this.dynamicActiveTools.set(tool.namespacedName, now);
|
|
1670
|
+
activated.push(tool);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
while (this.dynamicActiveTools.size > this.maxActiveTools) {
|
|
1674
|
+
let oldestKey = null;
|
|
1675
|
+
let oldestTime = Infinity;
|
|
1676
|
+
for (const [key, time] of this.dynamicActiveTools.entries()) {
|
|
1677
|
+
if (this.isPinned(key)) continue;
|
|
1678
|
+
if (time < oldestTime) {
|
|
1679
|
+
oldestTime = time;
|
|
1680
|
+
oldestKey = key;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
if (oldestKey) {
|
|
1684
|
+
this.dynamicActiveTools.delete(oldestKey);
|
|
1685
|
+
logger.debug(`Evicted inactive tool from session: ${oldestKey}`);
|
|
1686
|
+
} else {
|
|
1687
|
+
break;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return activated;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Returns whether a tool is pinned.
|
|
1694
|
+
*/
|
|
1695
|
+
isPinned(name) {
|
|
1696
|
+
const tool = this.getTool(name);
|
|
1697
|
+
if (!tool) return false;
|
|
1698
|
+
return this.pinnedToolNames.has(tool.originalName) || this.pinnedToolNames.has(tool.namespacedName);
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Returns current active tool set to be presented to the AI client:
|
|
1702
|
+
* (Pinned tools + Dynamically activated tools)
|
|
1703
|
+
*/
|
|
1704
|
+
getActiveTools() {
|
|
1705
|
+
const activeMap = /* @__PURE__ */ new Map();
|
|
1706
|
+
for (const pinned of this.pinnedToolNames) {
|
|
1707
|
+
const tool = this.getTool(pinned);
|
|
1708
|
+
if (tool) {
|
|
1709
|
+
activeMap.set(tool.namespacedName, tool);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
for (const activeName of this.dynamicActiveTools.keys()) {
|
|
1713
|
+
const tool = this.getTool(activeName);
|
|
1714
|
+
if (tool) {
|
|
1715
|
+
activeMap.set(tool.namespacedName, tool);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
return Array.from(activeMap.values());
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Returns all known tools in the catalog.
|
|
1722
|
+
*/
|
|
1723
|
+
getAllTools() {
|
|
1724
|
+
const unique = /* @__PURE__ */ new Map();
|
|
1725
|
+
for (const tool of this.allTools.values()) {
|
|
1726
|
+
unique.set(tool.namespacedName, tool);
|
|
1727
|
+
}
|
|
1728
|
+
return Array.from(unique.values());
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
var toolRegistry = new ToolRegistry();
|
|
1732
|
+
|
|
1733
|
+
// src/guardrails/validator.ts
|
|
1734
|
+
import { createHash as createHash2 } from "crypto";
|
|
1735
|
+
import Ajv from "ajv";
|
|
1736
|
+
import addFormats from "ajv-formats";
|
|
1737
|
+
var ToolArgumentValidator = class {
|
|
1738
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1739
|
+
ajv;
|
|
1740
|
+
validatorCache = /* @__PURE__ */ new Map();
|
|
1741
|
+
constructor() {
|
|
1742
|
+
const AjvClass = Ajv.default || Ajv;
|
|
1743
|
+
this.ajv = new AjvClass({
|
|
1744
|
+
allErrors: true,
|
|
1745
|
+
strict: false,
|
|
1746
|
+
coerceTypes: true
|
|
1747
|
+
});
|
|
1748
|
+
const addFormatsFn = addFormats.default || addFormats;
|
|
1749
|
+
addFormatsFn(this.ajv);
|
|
1750
|
+
}
|
|
1751
|
+
computeSchemaHash(schema) {
|
|
1752
|
+
try {
|
|
1753
|
+
return createHash2("sha256").update(JSON.stringify(schema ?? "")).digest("hex").slice(0, 16);
|
|
1754
|
+
} catch {
|
|
1755
|
+
return "static";
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Pre-flight validates tool arguments against the tool's inputSchema.
|
|
1760
|
+
*/
|
|
1761
|
+
validate(toolName, schema, args) {
|
|
1762
|
+
if (!schema || typeof schema !== "object") {
|
|
1763
|
+
return { valid: true };
|
|
1764
|
+
}
|
|
1765
|
+
try {
|
|
1766
|
+
const cacheKey = `${toolName}:${this.computeSchemaHash(schema)}`;
|
|
1767
|
+
let validateFn = this.validatorCache.get(cacheKey);
|
|
1768
|
+
if (!validateFn) {
|
|
1769
|
+
const compiled = this.ajv.compile(schema);
|
|
1770
|
+
this.validatorCache.set(cacheKey, compiled);
|
|
1771
|
+
validateFn = compiled;
|
|
1772
|
+
}
|
|
1773
|
+
if (!validateFn) {
|
|
1774
|
+
return { valid: true };
|
|
1775
|
+
}
|
|
1776
|
+
const valid = validateFn(args);
|
|
1777
|
+
if (!valid && validateFn.errors) {
|
|
1778
|
+
const errorMessages = validateFn.errors.map(
|
|
1779
|
+
(err) => `${err.instancePath || "arguments"} ${err.message || "is invalid"}`
|
|
1780
|
+
);
|
|
1781
|
+
logger.debug(`Validation failed for tool ${toolName}:`, errorMessages);
|
|
1782
|
+
return {
|
|
1783
|
+
valid: false,
|
|
1784
|
+
errors: errorMessages
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
return { valid: true };
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
logger.warn(`Schema compilation failed for tool ${toolName}: ${err}`);
|
|
1790
|
+
return { valid: true };
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
clearCache() {
|
|
1794
|
+
this.validatorCache.clear();
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
var argumentValidator = new ToolArgumentValidator();
|
|
1798
|
+
|
|
1799
|
+
// src/utils/hash.ts
|
|
1800
|
+
import { createHash as createHash3 } from "crypto";
|
|
1801
|
+
function canonicalJsonStringify(obj, seen = /* @__PURE__ */ new WeakSet()) {
|
|
1802
|
+
if (obj === null || obj === void 0) {
|
|
1803
|
+
return "null";
|
|
1804
|
+
}
|
|
1805
|
+
if (typeof obj === "bigint") {
|
|
1806
|
+
return `"${obj.toString()}"`;
|
|
1807
|
+
}
|
|
1808
|
+
if (typeof obj !== "object") {
|
|
1809
|
+
return JSON.stringify(obj) ?? "null";
|
|
1810
|
+
}
|
|
1811
|
+
if (seen.has(obj)) {
|
|
1812
|
+
return '"[Circular]"';
|
|
1813
|
+
}
|
|
1814
|
+
seen.add(obj);
|
|
1815
|
+
if (Array.isArray(obj)) {
|
|
1816
|
+
return "[" + obj.map((item) => canonicalJsonStringify(item, seen)).join(",") + "]";
|
|
1817
|
+
}
|
|
1818
|
+
const record = obj;
|
|
1819
|
+
const sortedKeys = Object.keys(record).sort();
|
|
1820
|
+
const pairs = sortedKeys.map(
|
|
1821
|
+
(key) => `${JSON.stringify(key)}:${canonicalJsonStringify(record[key], seen)}`
|
|
1822
|
+
);
|
|
1823
|
+
return "{" + pairs.join(",") + "}";
|
|
1824
|
+
}
|
|
1825
|
+
function sha256(input) {
|
|
1826
|
+
return createHash3("sha256").update(input).digest("hex");
|
|
1827
|
+
}
|
|
1828
|
+
function generateToolCallCacheKey(serverName, toolName, args = {}) {
|
|
1829
|
+
const canonicalArgs = canonicalJsonStringify(args);
|
|
1830
|
+
return sha256(`${serverName}:${toolName}:${canonicalArgs}`);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// src/guardrails/cache.ts
|
|
1834
|
+
var ResponseCache = class {
|
|
1835
|
+
cache = /* @__PURE__ */ new Map();
|
|
1836
|
+
defaultTtlMs;
|
|
1837
|
+
hits = 0;
|
|
1838
|
+
misses = 0;
|
|
1839
|
+
invalidations = 0;
|
|
1840
|
+
constructor(defaultTtlSeconds = 120) {
|
|
1841
|
+
this.defaultTtlMs = Math.max(1, defaultTtlSeconds || 120) * 1e3;
|
|
1842
|
+
}
|
|
1843
|
+
setTtlSeconds(seconds) {
|
|
1844
|
+
this.defaultTtlMs = Math.max(1, seconds || 120) * 1e3;
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Retrieves a cached result if available and unexpired.
|
|
1848
|
+
*/
|
|
1849
|
+
get(serverName, toolName, args = {}) {
|
|
1850
|
+
const safeArgs = args && typeof args === "object" ? args : {};
|
|
1851
|
+
const key = generateToolCallCacheKey(serverName, toolName, safeArgs);
|
|
1852
|
+
const entry = this.cache.get(key);
|
|
1853
|
+
if (!entry) {
|
|
1854
|
+
this.misses++;
|
|
1855
|
+
return null;
|
|
1856
|
+
}
|
|
1857
|
+
if (Date.now() > entry.expiresAt) {
|
|
1858
|
+
this.cache.delete(key);
|
|
1859
|
+
this.misses++;
|
|
1860
|
+
return null;
|
|
1861
|
+
}
|
|
1862
|
+
this.hits++;
|
|
1863
|
+
logger.debug(`Cache HIT for [${serverName}] ${toolName}`);
|
|
1864
|
+
return entry.result;
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Stores a tool result in the cache.
|
|
1868
|
+
*/
|
|
1869
|
+
set(serverName, toolName, args = {}, result, ttlMs) {
|
|
1870
|
+
if (!result || result.isError) return;
|
|
1871
|
+
const safeArgs = args && typeof args === "object" ? args : {};
|
|
1872
|
+
const key = generateToolCallCacheKey(serverName, toolName, safeArgs);
|
|
1873
|
+
const effectiveTtl = Math.max(1e3, ttlMs ?? this.defaultTtlMs);
|
|
1874
|
+
const expiresAt = Date.now() + effectiveTtl;
|
|
1875
|
+
if (this.cache.size >= 1e3) {
|
|
1876
|
+
const now = Date.now();
|
|
1877
|
+
for (const [k, entry] of this.cache.entries()) {
|
|
1878
|
+
if (now > entry.expiresAt) {
|
|
1879
|
+
this.cache.delete(k);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
if (this.cache.size >= 1e3) {
|
|
1883
|
+
const oldestKey = this.cache.keys().next().value;
|
|
1884
|
+
if (oldestKey) this.cache.delete(oldestKey);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
this.cache.set(key, {
|
|
1888
|
+
key,
|
|
1889
|
+
serverName,
|
|
1890
|
+
toolName,
|
|
1891
|
+
result,
|
|
1892
|
+
expiresAt
|
|
1893
|
+
});
|
|
1894
|
+
logger.debug(`Cached result for [${serverName}] ${toolName} (TTL: ${Math.round((ttlMs ?? this.defaultTtlMs) / 1e3)}s)`);
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Invalidates all cache entries for a specific server (e.g. after a mutating call).
|
|
1898
|
+
*/
|
|
1899
|
+
invalidateServer(serverName) {
|
|
1900
|
+
let count = 0;
|
|
1901
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
1902
|
+
if (entry.serverName === serverName) {
|
|
1903
|
+
this.cache.delete(key);
|
|
1904
|
+
count++;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
if (count > 0) {
|
|
1908
|
+
this.invalidations += count;
|
|
1909
|
+
logger.debug(`Invalidated ${count} cache entries for server [${serverName}]`);
|
|
1910
|
+
}
|
|
1911
|
+
return count;
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Clears the entire cache.
|
|
1915
|
+
*/
|
|
1916
|
+
clear() {
|
|
1917
|
+
this.cache.clear();
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Returns cache metrics.
|
|
1921
|
+
*/
|
|
1922
|
+
getStats() {
|
|
1923
|
+
return {
|
|
1924
|
+
hits: this.hits,
|
|
1925
|
+
misses: this.misses,
|
|
1926
|
+
size: this.cache.size,
|
|
1927
|
+
invalidations: this.invalidations
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
var responseCache = new ResponseCache();
|
|
1932
|
+
|
|
1933
|
+
// src/guardrails/loop_breaker.ts
|
|
1934
|
+
var RunawayLoopBreaker = class {
|
|
1935
|
+
callHistory = [];
|
|
1936
|
+
consecutiveFailures = /* @__PURE__ */ new Map();
|
|
1937
|
+
maxCallsPerMinute;
|
|
1938
|
+
consecutiveFailureThreshold;
|
|
1939
|
+
constructor(config = {}) {
|
|
1940
|
+
this.maxCallsPerMinute = config.maxCallsPerMinute ?? 60;
|
|
1941
|
+
this.consecutiveFailureThreshold = config.consecutiveFailureThreshold ?? 3;
|
|
1942
|
+
}
|
|
1943
|
+
updateConfig(config) {
|
|
1944
|
+
if (config.maxCallsPerMinute !== void 0) {
|
|
1945
|
+
this.maxCallsPerMinute = config.maxCallsPerMinute;
|
|
1946
|
+
}
|
|
1947
|
+
if (config.consecutiveFailureThreshold !== void 0) {
|
|
1948
|
+
this.consecutiveFailureThreshold = config.consecutiveFailureThreshold;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Checks if a tool call is permitted before execution with detailed classification.
|
|
1953
|
+
*/
|
|
1954
|
+
checkPreFlightDetailed(serverName, toolName, args = {}) {
|
|
1955
|
+
const safeArgs = args && typeof args === "object" ? args : {};
|
|
1956
|
+
const now = Date.now();
|
|
1957
|
+
const oneMinuteAgo = now - 6e4;
|
|
1958
|
+
this.callHistory = this.callHistory.filter((call) => call.timestamp > oneMinuteAgo);
|
|
1959
|
+
if (this.callHistory.length >= this.maxCallsPerMinute) {
|
|
1960
|
+
const message = `Rate limit exceeded: Session reached maximum of ${this.maxCallsPerMinute} tool calls per minute. Throttling execution.`;
|
|
1961
|
+
logger.warn(message);
|
|
1962
|
+
return { allowed: false, reason: "rate_limited", message };
|
|
1963
|
+
}
|
|
1964
|
+
const callKey = generateToolCallCacheKey(serverName, toolName, safeArgs);
|
|
1965
|
+
const failureCount = this.consecutiveFailures.get(callKey) ?? 0;
|
|
1966
|
+
if (failureCount >= this.consecutiveFailureThreshold) {
|
|
1967
|
+
const message = `Circuit breaker tripped: Tool "${toolName}" has failed ${failureCount} consecutive times with identical arguments. Please alter arguments or change strategy.`;
|
|
1968
|
+
logger.error(message);
|
|
1969
|
+
return { allowed: false, reason: "circuit_breaker", message };
|
|
1970
|
+
}
|
|
1971
|
+
return { allowed: true };
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Checks if a tool call is permitted before execution.
|
|
1975
|
+
* Returns null if allowed, or an error string if blocked by guardrails.
|
|
1976
|
+
*/
|
|
1977
|
+
checkPreFlight(serverName, toolName, args = {}) {
|
|
1978
|
+
const res = this.checkPreFlightDetailed(serverName, toolName, args);
|
|
1979
|
+
return res.allowed ? null : res.message;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Records execution outcome of a tool call.
|
|
1983
|
+
*/
|
|
1984
|
+
recordCall(serverName, toolName, args = {}, isError = false) {
|
|
1985
|
+
const safeArgs = args && typeof args === "object" ? args : {};
|
|
1986
|
+
const now = Date.now();
|
|
1987
|
+
const callKey = generateToolCallCacheKey(serverName, toolName, safeArgs);
|
|
1988
|
+
this.callHistory.push({
|
|
1989
|
+
callKey,
|
|
1990
|
+
toolName,
|
|
1991
|
+
timestamp: now,
|
|
1992
|
+
isError
|
|
1993
|
+
});
|
|
1994
|
+
if (isError) {
|
|
1995
|
+
const current = this.consecutiveFailures.get(callKey) ?? 0;
|
|
1996
|
+
this.consecutiveFailures.set(callKey, current + 1);
|
|
1997
|
+
if (this.consecutiveFailures.size > 500) {
|
|
1998
|
+
const firstKey = this.consecutiveFailures.keys().next().value;
|
|
1999
|
+
if (firstKey) {
|
|
2000
|
+
this.consecutiveFailures.delete(firstKey);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
} else {
|
|
2004
|
+
this.consecutiveFailures.delete(callKey);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Resets all history and breakers.
|
|
2009
|
+
*/
|
|
2010
|
+
reset() {
|
|
2011
|
+
this.callHistory = [];
|
|
2012
|
+
this.consecutiveFailures.clear();
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
var loopBreaker = new RunawayLoopBreaker();
|
|
2016
|
+
|
|
2017
|
+
// src/metrics/collector.ts
|
|
2018
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, renameSync as renameSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
2019
|
+
import { homedir as homedir5 } from "os";
|
|
2020
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
2021
|
+
var SCHEMA_TOKENS_PER_TOOL = 150;
|
|
2022
|
+
var CACHE_TOKENS_PER_HIT = 800;
|
|
2023
|
+
var LOOP_PREVENTION_TOKENS_PER_TRIP = 2500;
|
|
2024
|
+
var LLM_PRICING = {
|
|
2025
|
+
CLAUDE_SONNET: 3,
|
|
2026
|
+
// Claude 3.5 & 3.7 Sonnet ($3.00 / 1M)
|
|
2027
|
+
GPT_4O: 2.5,
|
|
2028
|
+
// OpenAI GPT-4o ($2.50 / 1M)
|
|
2029
|
+
CLAUDE_OPUS: 15,
|
|
2030
|
+
// Claude 3 Opus ($15.00 / 1M)
|
|
2031
|
+
HAIKU_OR_MINI: 0.25
|
|
2032
|
+
// Claude 3.5 Haiku / GPT-4o mini ($0.25 / 1M)
|
|
2033
|
+
};
|
|
2034
|
+
function calculateDollarSavings(tokens) {
|
|
2035
|
+
return {
|
|
2036
|
+
claudeSonnet: Number((tokens / 1e6 * LLM_PRICING.CLAUDE_SONNET).toFixed(2)),
|
|
2037
|
+
gpt4o: Number((tokens / 1e6 * LLM_PRICING.GPT_4O).toFixed(2)),
|
|
2038
|
+
claudeOpus: Number((tokens / 1e6 * LLM_PRICING.CLAUDE_OPUS).toFixed(2)),
|
|
2039
|
+
haikuOrMini: Number((tokens / 1e6 * LLM_PRICING.HAIKU_OR_MINI).toFixed(2))
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
function getDefaultMetricsPath() {
|
|
2043
|
+
if (process.env.CONTEXTWISE_METRICS_PATH) {
|
|
2044
|
+
return process.env.CONTEXTWISE_METRICS_PATH;
|
|
2045
|
+
}
|
|
2046
|
+
return join5(homedir5(), ".contextwise", "metrics.json");
|
|
2047
|
+
}
|
|
2048
|
+
var MetricsCollector = class {
|
|
2049
|
+
storagePath;
|
|
2050
|
+
totalCatalogTools = 0;
|
|
2051
|
+
currentlyExposedTools = 0;
|
|
2052
|
+
debounceTimer = null;
|
|
2053
|
+
data;
|
|
2054
|
+
constructor(storagePath) {
|
|
2055
|
+
this.storagePath = storagePath ?? getDefaultMetricsPath();
|
|
2056
|
+
this.data = this.createDefaultData();
|
|
2057
|
+
this.loadFromDisk();
|
|
2058
|
+
}
|
|
2059
|
+
createDefaultData() {
|
|
2060
|
+
const now = Date.now();
|
|
2061
|
+
return {
|
|
2062
|
+
version: 1,
|
|
2063
|
+
firstRecordedAt: now,
|
|
2064
|
+
lastRecordedAt: now,
|
|
2065
|
+
totalCalls: 0,
|
|
2066
|
+
cachedCalls: 0,
|
|
2067
|
+
errorCalls: 0,
|
|
2068
|
+
throttles: 0,
|
|
2069
|
+
loopsPrevented: 0,
|
|
2070
|
+
totalLatencyMs: 0,
|
|
2071
|
+
schemaPruningTokensSaved: 0,
|
|
2072
|
+
cacheTokensSaved: 0,
|
|
2073
|
+
loopPreventionTokensSaved: 0,
|
|
2074
|
+
totalTokensSaved: 0,
|
|
2075
|
+
recentCalls: []
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
setStoragePath(path) {
|
|
2079
|
+
this.storagePath = path;
|
|
2080
|
+
this.data = this.createDefaultData();
|
|
2081
|
+
this.loadFromDisk();
|
|
2082
|
+
}
|
|
2083
|
+
getStoragePath() {
|
|
2084
|
+
return this.storagePath;
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Reloads persisted state from disk.
|
|
2088
|
+
*/
|
|
2089
|
+
reload() {
|
|
2090
|
+
this.loadFromDisk();
|
|
2091
|
+
}
|
|
2092
|
+
loadFromDisk() {
|
|
2093
|
+
try {
|
|
2094
|
+
if (existsSync5(this.storagePath)) {
|
|
2095
|
+
const raw = readFileSync5(this.storagePath, "utf-8");
|
|
2096
|
+
const parsed = JSON.parse(raw);
|
|
2097
|
+
if (parsed && typeof parsed === "object") {
|
|
2098
|
+
this.data = {
|
|
2099
|
+
...this.createDefaultData(),
|
|
2100
|
+
...parsed
|
|
2101
|
+
};
|
|
2102
|
+
logger.debug(`Loaded metrics from ${this.storagePath}`);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
} catch (err) {
|
|
2106
|
+
logger.debug(`Could not load metrics from ${this.storagePath}: ${err}`);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* Schedules a debounced disk flush.
|
|
2111
|
+
*/
|
|
2112
|
+
schedulePersist() {
|
|
2113
|
+
if (this.debounceTimer) {
|
|
2114
|
+
clearTimeout(this.debounceTimer);
|
|
2115
|
+
}
|
|
2116
|
+
this.debounceTimer = setTimeout(() => {
|
|
2117
|
+
this.flushSync();
|
|
2118
|
+
}, 200);
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Synchronously writes metrics to disk.
|
|
2122
|
+
*/
|
|
2123
|
+
flushSync() {
|
|
2124
|
+
if (this.debounceTimer) {
|
|
2125
|
+
clearTimeout(this.debounceTimer);
|
|
2126
|
+
this.debounceTimer = null;
|
|
2127
|
+
}
|
|
2128
|
+
try {
|
|
2129
|
+
const dir = dirname3(this.storagePath);
|
|
2130
|
+
if (!existsSync5(dir)) {
|
|
2131
|
+
mkdirSync3(dir, { recursive: true });
|
|
2132
|
+
}
|
|
2133
|
+
const tempFile = `${this.storagePath}.${Date.now()}.tmp`;
|
|
2134
|
+
const json = JSON.stringify(this.data, null, 2);
|
|
2135
|
+
writeFileSync3(tempFile, json, "utf-8");
|
|
2136
|
+
renameSync2(tempFile, this.storagePath);
|
|
2137
|
+
} catch {
|
|
2138
|
+
try {
|
|
2139
|
+
writeFileSync3(this.storagePath, JSON.stringify(this.data, null, 2), "utf-8");
|
|
2140
|
+
} catch (err) {
|
|
2141
|
+
logger.debug(`Failed persisting metrics: ${err}`);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Updates catalog sizing to calculate per-turn token savings.
|
|
2147
|
+
*/
|
|
2148
|
+
updateToolCounts(catalogTotal, exposedTotal) {
|
|
2149
|
+
this.totalCatalogTools = catalogTotal;
|
|
2150
|
+
this.currentlyExposedTools = exposedTotal;
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Records savings from pruning tools during schema listing.
|
|
2154
|
+
*/
|
|
2155
|
+
recordSchemaPruning(catalogTotal, exposedTotal) {
|
|
2156
|
+
this.updateToolCounts(catalogTotal, exposedTotal);
|
|
2157
|
+
const avoidedTools = Math.max(0, catalogTotal - exposedTotal);
|
|
2158
|
+
const tokensSaved = avoidedTools * SCHEMA_TOKENS_PER_TOOL;
|
|
2159
|
+
this.data.schemaPruningTokensSaved += tokensSaved;
|
|
2160
|
+
this.data.totalTokensSaved = this.data.schemaPruningTokensSaved + this.data.cacheTokensSaved + this.data.loopPreventionTokensSaved;
|
|
2161
|
+
this.data.lastRecordedAt = Date.now();
|
|
2162
|
+
this.schedulePersist();
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Records a caught/prevented runaway loop or circuit breaker trip.
|
|
2166
|
+
*/
|
|
2167
|
+
recordLoopPrevented(toolName, serverName) {
|
|
2168
|
+
this.data.loopsPrevented += 1;
|
|
2169
|
+
this.data.loopPreventionTokensSaved += LOOP_PREVENTION_TOKENS_PER_TRIP;
|
|
2170
|
+
this.data.totalTokensSaved = this.data.schemaPruningTokensSaved + this.data.cacheTokensSaved + this.data.loopPreventionTokensSaved;
|
|
2171
|
+
this.data.lastRecordedAt = Date.now();
|
|
2172
|
+
logger.debug(
|
|
2173
|
+
`Recorded prevented loop for ${toolName ?? "unknown"} on ${serverName ?? "unknown"} (saved ~${LOOP_PREVENTION_TOKENS_PER_TRIP} tokens)`
|
|
2174
|
+
);
|
|
2175
|
+
this.schedulePersist();
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Records a rate-limit velocity throttle event (tracked separately from circuit trips).
|
|
2179
|
+
*/
|
|
2180
|
+
recordThrottle(toolName, serverName) {
|
|
2181
|
+
this.data.throttles = (this.data.throttles || 0) + 1;
|
|
2182
|
+
this.data.lastRecordedAt = Date.now();
|
|
2183
|
+
logger.debug(
|
|
2184
|
+
`Recorded rate limit throttle for ${toolName ?? "unknown"} on ${serverName ?? "unknown"}`
|
|
2185
|
+
);
|
|
2186
|
+
this.schedulePersist();
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Records a tool execution.
|
|
2190
|
+
*/
|
|
2191
|
+
recordExecution(params) {
|
|
2192
|
+
const isError = params.isError ?? false;
|
|
2193
|
+
let tokensSavedThisCall = 0;
|
|
2194
|
+
this.data.totalCalls += 1;
|
|
2195
|
+
this.data.lastRecordedAt = Date.now();
|
|
2196
|
+
if (params.cached) {
|
|
2197
|
+
this.data.cachedCalls += 1;
|
|
2198
|
+
this.data.cacheTokensSaved += CACHE_TOKENS_PER_HIT;
|
|
2199
|
+
tokensSavedThisCall = CACHE_TOKENS_PER_HIT;
|
|
2200
|
+
} else {
|
|
2201
|
+
tokensSavedThisCall = 0;
|
|
2202
|
+
this.data.totalLatencyMs += params.latencyMs;
|
|
2203
|
+
}
|
|
2204
|
+
if (isError) {
|
|
2205
|
+
this.data.errorCalls += 1;
|
|
2206
|
+
}
|
|
2207
|
+
this.data.totalTokensSaved = this.data.schemaPruningTokensSaved + this.data.cacheTokensSaved + this.data.loopPreventionTokensSaved;
|
|
2208
|
+
const metric = {
|
|
2209
|
+
timestamp: Date.now(),
|
|
2210
|
+
toolName: params.toolName,
|
|
2211
|
+
serverName: params.serverName,
|
|
2212
|
+
latencyMs: params.latencyMs,
|
|
2213
|
+
cached: params.cached,
|
|
2214
|
+
tokensSavedEstimate: tokensSavedThisCall,
|
|
2215
|
+
isError
|
|
2216
|
+
};
|
|
2217
|
+
this.data.recentCalls.push(metric);
|
|
2218
|
+
if (this.data.recentCalls.length > 100) {
|
|
2219
|
+
this.data.recentCalls.shift();
|
|
2220
|
+
}
|
|
2221
|
+
logger.debug(
|
|
2222
|
+
`Recorded metric for ${params.toolName} (latency: ${params.latencyMs}ms, cached: ${params.cached}, saved ~${tokensSavedThisCall} tokens)`
|
|
2223
|
+
);
|
|
2224
|
+
this.schedulePersist();
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Returns aggregated metrics summary with dollar ROI calculations.
|
|
2228
|
+
*/
|
|
2229
|
+
getSummary(activeUpstreamsCount = 0) {
|
|
2230
|
+
const totalCalls = this.data.totalCalls;
|
|
2231
|
+
const cachedCalls = this.data.cachedCalls;
|
|
2232
|
+
const errorCalls = this.data.errorCalls;
|
|
2233
|
+
const throttles = this.data.throttles || 0;
|
|
2234
|
+
const loopsPrevented = this.data.loopsPrevented;
|
|
2235
|
+
const executedCalls = totalCalls - cachedCalls;
|
|
2236
|
+
const averageLatencyMs = executedCalls > 0 ? Math.round(this.data.totalLatencyMs / executedCalls) : 0;
|
|
2237
|
+
const cacheHitRatePct = totalCalls > 0 ? Math.round(cachedCalls / totalCalls * 100) : 0;
|
|
2238
|
+
const totalTokensSaved = this.data.totalTokensSaved;
|
|
2239
|
+
const dollarSavings = calculateDollarSavings(totalTokensSaved);
|
|
2240
|
+
return {
|
|
2241
|
+
firstRecordedAt: this.data.firstRecordedAt,
|
|
2242
|
+
lastRecordedAt: this.data.lastRecordedAt,
|
|
2243
|
+
totalCalls,
|
|
2244
|
+
cachedCalls,
|
|
2245
|
+
cacheHitRatePct,
|
|
2246
|
+
errorCalls,
|
|
2247
|
+
throttles,
|
|
2248
|
+
loopsPrevented,
|
|
2249
|
+
averageLatencyMs,
|
|
2250
|
+
schemaPruningTokensSaved: this.data.schemaPruningTokensSaved,
|
|
2251
|
+
cacheTokensSaved: this.data.cacheTokensSaved,
|
|
2252
|
+
loopPreventionTokensSaved: this.data.loopPreventionTokensSaved,
|
|
2253
|
+
estimatedTotalTokensSaved: totalTokensSaved,
|
|
2254
|
+
dollarSavings,
|
|
2255
|
+
activeUpstreamsCount,
|
|
2256
|
+
totalCatalogToolsCount: this.totalCatalogTools,
|
|
2257
|
+
exposedToolsCount: this.currentlyExposedTools
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Clears recorded metrics on disk and memory.
|
|
2262
|
+
*/
|
|
2263
|
+
reset() {
|
|
2264
|
+
this.data = this.createDefaultData();
|
|
2265
|
+
this.flushSync();
|
|
2266
|
+
}
|
|
2267
|
+
};
|
|
2268
|
+
var metricsCollector = new MetricsCollector();
|
|
2269
|
+
|
|
2270
|
+
// src/router/meta_tools.ts
|
|
2271
|
+
var SEARCH_TOOLS_NAME = "contextwise_search_tools";
|
|
2272
|
+
var EXECUTE_TOOL_NAME = "contextwise_execute_tool";
|
|
2273
|
+
var BROWSE_SERVERS_NAME = "contextwise_browse_servers";
|
|
2274
|
+
var ADD_SERVER_NAME = "contextwise_add_server";
|
|
2275
|
+
var SEARCH_TOOLS_DEFINITION = {
|
|
2276
|
+
name: SEARCH_TOOLS_NAME,
|
|
2277
|
+
description: "Searches ContextWise tool catalog for relevant tools based on natural language intent. Returns matched tool signatures, parameter descriptions, and automatically makes them available to invoke.",
|
|
2278
|
+
inputSchema: {
|
|
2279
|
+
type: "object",
|
|
2280
|
+
properties: {
|
|
2281
|
+
query: {
|
|
2282
|
+
type: "string",
|
|
2283
|
+
description: 'Natural language query describing what task you want to perform or which tool you need (e.g., "query postgres database", "read a file", "create git commit")'
|
|
2284
|
+
},
|
|
2285
|
+
domain: {
|
|
2286
|
+
type: "string",
|
|
2287
|
+
description: 'Optional domain tag to filter tools (e.g., "database", "filesystem", "git", "web", "docker")'
|
|
2288
|
+
},
|
|
2289
|
+
topK: {
|
|
2290
|
+
type: "number",
|
|
2291
|
+
description: "Maximum number of tools to return (default: 5)"
|
|
2292
|
+
},
|
|
2293
|
+
activate: {
|
|
2294
|
+
type: "boolean",
|
|
2295
|
+
description: "Whether to activate the matched tools into the active context window (default: true)"
|
|
2296
|
+
}
|
|
2297
|
+
},
|
|
2298
|
+
required: ["query"]
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
2301
|
+
var EXECUTE_TOOL_DEFINITION = {
|
|
2302
|
+
name: EXECUTE_TOOL_NAME,
|
|
2303
|
+
description: "Universally executes any indexed upstream tool, even if its schema is not pre-loaded into your active context window. Use this after finding tools via contextwise_search_tools.",
|
|
2304
|
+
inputSchema: {
|
|
2305
|
+
type: "object",
|
|
2306
|
+
properties: {
|
|
2307
|
+
tool_name: {
|
|
2308
|
+
type: "string",
|
|
2309
|
+
description: "The name or namespaced name of the tool to execute"
|
|
2310
|
+
},
|
|
2311
|
+
arguments: {
|
|
2312
|
+
type: "object",
|
|
2313
|
+
description: "Arguments to pass to the target tool",
|
|
2314
|
+
default: {}
|
|
2315
|
+
}
|
|
2316
|
+
},
|
|
2317
|
+
required: ["tool_name"]
|
|
2318
|
+
}
|
|
2319
|
+
};
|
|
2320
|
+
var BROWSE_SERVERS_DEFINITION = {
|
|
2321
|
+
name: BROWSE_SERVERS_NAME,
|
|
2322
|
+
description: "Browses available MCP servers from local curated presets, the Official MCP Registry, and Smithery. Displays which registry each server is listed in.",
|
|
2323
|
+
inputSchema: {
|
|
2324
|
+
type: "object",
|
|
2325
|
+
properties: {
|
|
2326
|
+
query: {
|
|
2327
|
+
type: "string",
|
|
2328
|
+
description: 'Optional keyword to search for specific servers (e.g., "database", "git", "search", "linear")'
|
|
2329
|
+
},
|
|
2330
|
+
category: {
|
|
2331
|
+
type: "string",
|
|
2332
|
+
description: 'Optional category filter: "database", "developer", "web", "filesystem", "communication", "devops"',
|
|
2333
|
+
enum: ["database", "developer", "web", "filesystem", "communication", "devops"]
|
|
2334
|
+
},
|
|
2335
|
+
source: {
|
|
2336
|
+
type: "string",
|
|
2337
|
+
description: 'Filter by registry source: "all" (default), "official", "smithery", or "curated"',
|
|
2338
|
+
enum: ["all", "official", "smithery", "curated"]
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
};
|
|
2343
|
+
var ADD_SERVER_DEFINITION = {
|
|
2344
|
+
name: ADD_SERVER_NAME,
|
|
2345
|
+
description: "Hot-loads a new MCP server into ContextWise in real time, persists it to contextwise.json, and immediately makes all its tools available to this session. Supports presets, Smithery packages, custom stdio commands, and remote SSE URLs.",
|
|
2346
|
+
inputSchema: {
|
|
2347
|
+
type: "object",
|
|
2348
|
+
properties: {
|
|
2349
|
+
name: {
|
|
2350
|
+
type: "string",
|
|
2351
|
+
description: 'Unique identifier name for this server (e.g., "postgres", "github", "my-server")'
|
|
2352
|
+
},
|
|
2353
|
+
preset: {
|
|
2354
|
+
type: "string",
|
|
2355
|
+
description: 'Optional name of a verified preset to configure (e.g., "postgres", "sqlite", "github", "linear", "fetch", "puppeteer", "brave-search", "slack")'
|
|
2356
|
+
},
|
|
2357
|
+
smithery: {
|
|
2358
|
+
type: "string",
|
|
2359
|
+
description: 'Optional Smithery package name to install and run via @smithery/cli (e.g. "@smithery-ai/github" or "github")'
|
|
2360
|
+
},
|
|
2361
|
+
command: {
|
|
2362
|
+
type: "string",
|
|
2363
|
+
description: 'Executable command for stdio server (e.g., "npx", "python", "docker")'
|
|
2364
|
+
},
|
|
2365
|
+
args: {
|
|
2366
|
+
type: "array",
|
|
2367
|
+
items: { type: "string" },
|
|
2368
|
+
description: "Command-line arguments to pass to the executable"
|
|
2369
|
+
},
|
|
2370
|
+
env: {
|
|
2371
|
+
type: "object",
|
|
2372
|
+
description: "Environment variables to pass (e.g., API keys, tokens)"
|
|
2373
|
+
},
|
|
2374
|
+
url: {
|
|
2375
|
+
type: "string",
|
|
2376
|
+
description: "Remote SSE endpoint URL if connecting to a remote MCP server over HTTP/SSE"
|
|
2377
|
+
},
|
|
2378
|
+
headers: {
|
|
2379
|
+
type: "object",
|
|
2380
|
+
description: "HTTP headers for remote SSE server (e.g. Authorization)"
|
|
2381
|
+
}
|
|
2382
|
+
},
|
|
2383
|
+
required: ["name"]
|
|
2384
|
+
}
|
|
2385
|
+
};
|
|
2386
|
+
|
|
2387
|
+
// src/registry/known_servers.ts
|
|
2388
|
+
var KNOWN_SERVERS = [
|
|
2389
|
+
// --- Databases ---
|
|
2390
|
+
{
|
|
2391
|
+
id: "postgres",
|
|
2392
|
+
name: "postgres",
|
|
2393
|
+
displayName: "PostgreSQL Database",
|
|
2394
|
+
category: "database",
|
|
2395
|
+
description: "Inspect schemas, browse tables, and execute read/write SQL queries on PostgreSQL.",
|
|
2396
|
+
tags: ["database", "sql", "postgres", "tables"],
|
|
2397
|
+
command: "npx",
|
|
2398
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-postgres"],
|
|
2399
|
+
defaultEnv: {},
|
|
2400
|
+
requiredParams: [
|
|
2401
|
+
{
|
|
2402
|
+
name: "connectionString",
|
|
2403
|
+
type: "arg",
|
|
2404
|
+
description: "PostgreSQL connection URL",
|
|
2405
|
+
placeholder: "postgresql://username:password@localhost:5432/mydb"
|
|
2406
|
+
}
|
|
2407
|
+
],
|
|
2408
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
|
|
2409
|
+
},
|
|
2410
|
+
{
|
|
2411
|
+
id: "sqlite",
|
|
2412
|
+
name: "sqlite",
|
|
2413
|
+
displayName: "SQLite Database",
|
|
2414
|
+
category: "database",
|
|
2415
|
+
description: "Inspect schemas, browse tables, and execute SQL queries on local SQLite database files.",
|
|
2416
|
+
tags: ["database", "sqlite", "sql", "local"],
|
|
2417
|
+
command: "npx",
|
|
2418
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-sqlite", "--db-path"],
|
|
2419
|
+
defaultEnv: {},
|
|
2420
|
+
requiredParams: [
|
|
2421
|
+
{
|
|
2422
|
+
name: "dbPath",
|
|
2423
|
+
type: "arg",
|
|
2424
|
+
description: "Absolute or relative path to SQLite .db file",
|
|
2425
|
+
placeholder: "./database.sqlite"
|
|
2426
|
+
}
|
|
2427
|
+
],
|
|
2428
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite"
|
|
2429
|
+
},
|
|
2430
|
+
// --- Developer Tools ---
|
|
2431
|
+
{
|
|
2432
|
+
id: "github",
|
|
2433
|
+
name: "github",
|
|
2434
|
+
displayName: "GitHub Integration",
|
|
2435
|
+
category: "developer",
|
|
2436
|
+
description: "Search repositories, manage pull requests, create issues, and inspect commit history on GitHub.",
|
|
2437
|
+
tags: ["git", "github", "prs", "issues", "code"],
|
|
2438
|
+
command: "npx",
|
|
2439
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-github"],
|
|
2440
|
+
defaultEnv: {},
|
|
2441
|
+
requiredParams: [
|
|
2442
|
+
{
|
|
2443
|
+
name: "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
2444
|
+
type: "env",
|
|
2445
|
+
description: "GitHub Personal Access Token (classic or fine-grained)",
|
|
2446
|
+
placeholder: "ghp_..."
|
|
2447
|
+
}
|
|
2448
|
+
],
|
|
2449
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
|
|
2450
|
+
},
|
|
2451
|
+
{
|
|
2452
|
+
id: "git",
|
|
2453
|
+
name: "git",
|
|
2454
|
+
displayName: "Local Git Repository",
|
|
2455
|
+
category: "developer",
|
|
2456
|
+
description: "Inspect working trees, git diffs, commits, branches, and logs directly on local git repos.",
|
|
2457
|
+
tags: ["git", "diff", "commit", "branches", "repository"],
|
|
2458
|
+
command: "npx",
|
|
2459
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-git", "--repository"],
|
|
2460
|
+
defaultEnv: {},
|
|
2461
|
+
requiredParams: [
|
|
2462
|
+
{
|
|
2463
|
+
name: "repositoryPath",
|
|
2464
|
+
type: "arg",
|
|
2465
|
+
description: "Path to local Git repository root",
|
|
2466
|
+
default: ".",
|
|
2467
|
+
placeholder: "."
|
|
2468
|
+
}
|
|
2469
|
+
],
|
|
2470
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/git"
|
|
2471
|
+
},
|
|
2472
|
+
{
|
|
2473
|
+
id: "gitlab",
|
|
2474
|
+
name: "gitlab",
|
|
2475
|
+
displayName: "GitLab Integration",
|
|
2476
|
+
category: "developer",
|
|
2477
|
+
description: "Manage GitLab projects, merge requests, issues, and pipelines.",
|
|
2478
|
+
tags: ["git", "gitlab", "ci", "mr", "issues"],
|
|
2479
|
+
command: "npx",
|
|
2480
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-gitlab"],
|
|
2481
|
+
defaultEnv: {},
|
|
2482
|
+
requiredParams: [
|
|
2483
|
+
{
|
|
2484
|
+
name: "GITLAB_PERSONAL_ACCESS_TOKEN",
|
|
2485
|
+
type: "env",
|
|
2486
|
+
description: "GitLab Personal Access Token",
|
|
2487
|
+
placeholder: "glpat-..."
|
|
2488
|
+
}
|
|
2489
|
+
],
|
|
2490
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/gitlab"
|
|
2491
|
+
},
|
|
2492
|
+
{
|
|
2493
|
+
id: "linear",
|
|
2494
|
+
name: "linear",
|
|
2495
|
+
displayName: "Linear Project Tracking",
|
|
2496
|
+
category: "developer",
|
|
2497
|
+
description: "Search, create, and update Linear issues, teams, projects, and cycles.",
|
|
2498
|
+
tags: ["issue", "linear", "ticket", "project", "tasks"],
|
|
2499
|
+
command: "npx",
|
|
2500
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-linear"],
|
|
2501
|
+
defaultEnv: {},
|
|
2502
|
+
requiredParams: [
|
|
2503
|
+
{
|
|
2504
|
+
name: "LINEAR_API_KEY",
|
|
2505
|
+
type: "env",
|
|
2506
|
+
description: "Linear API Key",
|
|
2507
|
+
placeholder: "lin_api_..."
|
|
2508
|
+
}
|
|
2509
|
+
],
|
|
2510
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/linear"
|
|
2511
|
+
},
|
|
2512
|
+
// --- Web, Search & Browser Automation ---
|
|
2513
|
+
{
|
|
2514
|
+
id: "brave-search",
|
|
2515
|
+
name: "brave-search",
|
|
2516
|
+
displayName: "Brave Web Search",
|
|
2517
|
+
category: "web",
|
|
2518
|
+
description: "Privacy-preserving real-time web search and local location searches.",
|
|
2519
|
+
tags: ["web", "search", "brave", "internet", "news"],
|
|
2520
|
+
command: "npx",
|
|
2521
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-brave-search"],
|
|
2522
|
+
defaultEnv: {},
|
|
2523
|
+
requiredParams: [
|
|
2524
|
+
{
|
|
2525
|
+
name: "BRAVE_API_KEY",
|
|
2526
|
+
type: "env",
|
|
2527
|
+
description: "Brave Search API Key from brave.com/search/api",
|
|
2528
|
+
placeholder: "BSA..."
|
|
2529
|
+
}
|
|
2530
|
+
],
|
|
2531
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search"
|
|
2532
|
+
},
|
|
2533
|
+
{
|
|
2534
|
+
id: "fetch",
|
|
2535
|
+
name: "fetch",
|
|
2536
|
+
displayName: "Web Fetch & Scraper",
|
|
2537
|
+
category: "web",
|
|
2538
|
+
description: "Fetch web pages, convert HTML to clean Markdown, and inspect HTTP headers.",
|
|
2539
|
+
tags: ["web", "fetch", "http", "html", "scrape"],
|
|
2540
|
+
command: "npx",
|
|
2541
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-fetch"],
|
|
2542
|
+
defaultEnv: {},
|
|
2543
|
+
requiredParams: [],
|
|
2544
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
|
|
2545
|
+
},
|
|
2546
|
+
{
|
|
2547
|
+
id: "puppeteer",
|
|
2548
|
+
name: "puppeteer",
|
|
2549
|
+
displayName: "Puppeteer Browser Automation",
|
|
2550
|
+
category: "web",
|
|
2551
|
+
description: "Control headless Chrome to navigate pages, capture screenshots, evaluate JS, and interact with web apps.",
|
|
2552
|
+
tags: ["web", "browser", "puppeteer", "automation", "screenshot"],
|
|
2553
|
+
command: "npx",
|
|
2554
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-puppeteer"],
|
|
2555
|
+
defaultEnv: {},
|
|
2556
|
+
requiredParams: [],
|
|
2557
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
|
|
2558
|
+
},
|
|
2559
|
+
// --- Filesystem & Memory ---
|
|
2560
|
+
{
|
|
2561
|
+
id: "filesystem",
|
|
2562
|
+
name: "filesystem",
|
|
2563
|
+
displayName: "Local Filesystem Access",
|
|
2564
|
+
category: "filesystem",
|
|
2565
|
+
description: "Secure scoped filesystem access to read, write, search, and manage files in allowed directories.",
|
|
2566
|
+
tags: ["filesystem", "files", "disk", "directory", "storage"],
|
|
2567
|
+
command: "npx",
|
|
2568
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-filesystem"],
|
|
2569
|
+
defaultEnv: {},
|
|
2570
|
+
requiredParams: [
|
|
2571
|
+
{
|
|
2572
|
+
name: "allowedDirectory",
|
|
2573
|
+
type: "arg",
|
|
2574
|
+
description: "Root directory allowed for file operations",
|
|
2575
|
+
default: ".",
|
|
2576
|
+
placeholder: "./"
|
|
2577
|
+
}
|
|
2578
|
+
],
|
|
2579
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
|
|
2580
|
+
},
|
|
2581
|
+
{
|
|
2582
|
+
id: "memory",
|
|
2583
|
+
name: "memory",
|
|
2584
|
+
displayName: "Graph Knowledge Memory",
|
|
2585
|
+
category: "filesystem",
|
|
2586
|
+
description: "Long-term persistent knowledge graph memory using entities, relations, and observations.",
|
|
2587
|
+
tags: ["memory", "knowledge", "graph", "persistence", "history"],
|
|
2588
|
+
command: "npx",
|
|
2589
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-memory"],
|
|
2590
|
+
defaultEnv: {},
|
|
2591
|
+
requiredParams: [],
|
|
2592
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
|
|
2593
|
+
},
|
|
2594
|
+
// --- Communication ---
|
|
2595
|
+
{
|
|
2596
|
+
id: "slack",
|
|
2597
|
+
name: "slack",
|
|
2598
|
+
displayName: "Slack Workspace",
|
|
2599
|
+
category: "communication",
|
|
2600
|
+
description: "Read and send messages, list channels, and reply to threads in Slack workspaces.",
|
|
2601
|
+
tags: ["slack", "chat", "communication", "message", "channels"],
|
|
2602
|
+
command: "npx",
|
|
2603
|
+
defaultArgs: ["-y", "@modelcontextprotocol/server-slack"],
|
|
2604
|
+
defaultEnv: {},
|
|
2605
|
+
requiredParams: [
|
|
2606
|
+
{
|
|
2607
|
+
name: "SLACK_BOT_TOKEN",
|
|
2608
|
+
type: "env",
|
|
2609
|
+
description: "Slack Bot User OAuth Token (xoxb-...)",
|
|
2610
|
+
placeholder: "xoxb-..."
|
|
2611
|
+
},
|
|
2612
|
+
{
|
|
2613
|
+
name: "SLACK_TEAM_ID",
|
|
2614
|
+
type: "env",
|
|
2615
|
+
description: "Slack Workspace Team ID (T...)",
|
|
2616
|
+
placeholder: "T..."
|
|
2617
|
+
}
|
|
2618
|
+
],
|
|
2619
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
|
|
2620
|
+
},
|
|
2621
|
+
// --- DevOps & Cloud ---
|
|
2622
|
+
{
|
|
2623
|
+
id: "docker",
|
|
2624
|
+
name: "docker",
|
|
2625
|
+
displayName: "Docker Container Engine",
|
|
2626
|
+
category: "devops",
|
|
2627
|
+
description: "List, start, stop, and inspect Docker containers, images, and volumes.",
|
|
2628
|
+
tags: ["docker", "container", "devops", "images"],
|
|
2629
|
+
command: "npx",
|
|
2630
|
+
defaultArgs: ["-y", "mcp-server-docker"],
|
|
2631
|
+
defaultEnv: {},
|
|
2632
|
+
requiredParams: [],
|
|
2633
|
+
documentationUrl: "https://github.com/modelcontextprotocol/servers"
|
|
2634
|
+
}
|
|
2635
|
+
];
|
|
2636
|
+
var KnownServerRegistry = class {
|
|
2637
|
+
/**
|
|
2638
|
+
* Returns all known server definitions.
|
|
2639
|
+
*/
|
|
2640
|
+
static getAll() {
|
|
2641
|
+
return KNOWN_SERVERS;
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Finds a known server by ID or name.
|
|
2645
|
+
*/
|
|
2646
|
+
static find(idOrName) {
|
|
2647
|
+
const clean = idOrName.trim().toLowerCase();
|
|
2648
|
+
return KNOWN_SERVERS.find(
|
|
2649
|
+
(s) => s.id === clean || s.name.toLowerCase() === clean
|
|
2650
|
+
);
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Searches known servers by query string and/or category.
|
|
2654
|
+
*/
|
|
2655
|
+
static search(query, category) {
|
|
2656
|
+
let list = KNOWN_SERVERS;
|
|
2657
|
+
if (category) {
|
|
2658
|
+
const catClean = category.toLowerCase();
|
|
2659
|
+
list = list.filter((s) => s.category === catClean);
|
|
2660
|
+
}
|
|
2661
|
+
if (query && query.trim()) {
|
|
2662
|
+
const q = query.trim().toLowerCase();
|
|
2663
|
+
list = list.filter(
|
|
2664
|
+
(s) => s.name.toLowerCase().includes(q) || s.displayName.toLowerCase().includes(q) || s.description.toLowerCase().includes(q) || s.tags.some((t) => t.includes(q))
|
|
2665
|
+
);
|
|
2666
|
+
}
|
|
2667
|
+
return list;
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Returns all unique categories available.
|
|
2671
|
+
*/
|
|
2672
|
+
static getCategories() {
|
|
2673
|
+
const set = /* @__PURE__ */ new Set();
|
|
2674
|
+
for (const s of KNOWN_SERVERS) {
|
|
2675
|
+
set.add(s.category);
|
|
2676
|
+
}
|
|
2677
|
+
return Array.from(set);
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Builds an UpstreamServerConfig from a KnownServerDefinition and supplied parameters.
|
|
2681
|
+
*/
|
|
2682
|
+
static buildConfig(serverDef, params = {}) {
|
|
2683
|
+
const finalArgs = [...serverDef.defaultArgs, ...params.args ?? []];
|
|
2684
|
+
const finalEnv = { ...serverDef.defaultEnv, ...params.env ?? {} };
|
|
2685
|
+
return {
|
|
2686
|
+
command: serverDef.command,
|
|
2687
|
+
args: finalArgs,
|
|
2688
|
+
env: finalEnv,
|
|
2689
|
+
autoRestart: true
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
|
|
2694
|
+
// src/registry/official_client.ts
|
|
2695
|
+
var OfficialRegistryClient = class {
|
|
2696
|
+
static BASE_URL = "https://registry.modelcontextprotocol.io/v0.1/servers";
|
|
2697
|
+
/**
|
|
2698
|
+
* Searches the official canonical MCP registry for public servers.
|
|
2699
|
+
*/
|
|
2700
|
+
static async search(query, limit = 10, timeoutMs = 3e3) {
|
|
2701
|
+
try {
|
|
2702
|
+
const url = new URL(this.BASE_URL);
|
|
2703
|
+
if (query && query.trim()) {
|
|
2704
|
+
url.searchParams.set("search", query.trim());
|
|
2705
|
+
}
|
|
2706
|
+
url.searchParams.set("limit", String(limit));
|
|
2707
|
+
const controller = new AbortController();
|
|
2708
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2709
|
+
logger.debug(`Querying Official MCP Registry: ${url.toString()}`);
|
|
2710
|
+
const res = await fetch(url.toString(), {
|
|
2711
|
+
signal: controller.signal,
|
|
2712
|
+
headers: {
|
|
2713
|
+
Accept: "application/json",
|
|
2714
|
+
"User-Agent": "ContextWise-Proxy/0.1.0"
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
clearTimeout(timer);
|
|
2718
|
+
if (!res.ok) {
|
|
2719
|
+
logger.debug(`Official MCP Registry responded with status: ${res.status}`);
|
|
2720
|
+
return [];
|
|
2721
|
+
}
|
|
2722
|
+
const data = await res.json();
|
|
2723
|
+
if (!data.servers || !Array.isArray(data.servers)) {
|
|
2724
|
+
return [];
|
|
2725
|
+
}
|
|
2726
|
+
return data.servers.filter((item) => Boolean(item && item.server && typeof item.server.name === "string")).map((item) => {
|
|
2727
|
+
const s = item.server;
|
|
2728
|
+
const remoteUrl = s.remotes?.[0]?.url;
|
|
2729
|
+
const npmPkg = s.packages?.find((p) => p.registryType === "npm")?.identifier;
|
|
2730
|
+
return {
|
|
2731
|
+
id: s.name,
|
|
2732
|
+
name: s.name,
|
|
2733
|
+
displayName: s.title || s.name,
|
|
2734
|
+
description: s.description || "No description provided.",
|
|
2735
|
+
source: "official",
|
|
2736
|
+
sourceLabel: "Official MCP Registry",
|
|
2737
|
+
category: "community",
|
|
2738
|
+
tags: ["official", "mcp-registry"],
|
|
2739
|
+
installHint: `contextwise add "${s.name}"`,
|
|
2740
|
+
homepage: s.repository?.url || s.remotes?.[0]?.url || `https://registry.modelcontextprotocol.io`,
|
|
2741
|
+
requiredParams: [],
|
|
2742
|
+
suggestedConfig: remoteUrl ? { url: remoteUrl, headers: {}, transport: "auto", autoReconnect: true } : npmPkg ? { command: "npx", args: ["-y", npmPkg], env: {}, autoRestart: true } : void 0
|
|
2743
|
+
};
|
|
2744
|
+
});
|
|
2745
|
+
} catch (err) {
|
|
2746
|
+
logger.debug(`Official MCP Registry query skipped or timed out: ${err}`);
|
|
2747
|
+
return [];
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
};
|
|
2751
|
+
|
|
2752
|
+
// src/registry/smithery_client.ts
|
|
2753
|
+
var SmitheryClient = class {
|
|
2754
|
+
static BASE_URL = "https://api.smithery.ai/servers";
|
|
2755
|
+
/**
|
|
2756
|
+
* Searches the Smithery.ai registry for community and hosted MCP servers.
|
|
2757
|
+
*/
|
|
2758
|
+
static async search(query, apiKey, timeoutMs = 3e3) {
|
|
2759
|
+
try {
|
|
2760
|
+
const url = new URL(this.BASE_URL);
|
|
2761
|
+
if (query && query.trim()) {
|
|
2762
|
+
url.searchParams.set("q", query.trim());
|
|
2763
|
+
}
|
|
2764
|
+
const key = apiKey || process.env.SMITHERY_API_KEY;
|
|
2765
|
+
const headers = {
|
|
2766
|
+
Accept: "application/json",
|
|
2767
|
+
"User-Agent": "ContextWise-Proxy/0.1.0"
|
|
2768
|
+
};
|
|
2769
|
+
if (key) {
|
|
2770
|
+
headers["Authorization"] = `Bearer ${key}`;
|
|
2771
|
+
}
|
|
2772
|
+
const controller = new AbortController();
|
|
2773
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2774
|
+
logger.debug(`Querying Smithery Registry: ${url.toString()}`);
|
|
2775
|
+
const res = await fetch(url.toString(), {
|
|
2776
|
+
headers,
|
|
2777
|
+
signal: controller.signal
|
|
2778
|
+
});
|
|
2779
|
+
clearTimeout(timer);
|
|
2780
|
+
if (!res.ok) {
|
|
2781
|
+
logger.debug(`Smithery API responded with status: ${res.status}`);
|
|
2782
|
+
return [];
|
|
2783
|
+
}
|
|
2784
|
+
const data = await res.json();
|
|
2785
|
+
if (!data.servers || !Array.isArray(data.servers)) {
|
|
2786
|
+
return [];
|
|
2787
|
+
}
|
|
2788
|
+
return data.servers.filter((item) => Boolean(item && typeof item.qualifiedName === "string")).map((item) => ({
|
|
2789
|
+
id: item.qualifiedName,
|
|
2790
|
+
name: item.qualifiedName,
|
|
2791
|
+
displayName: item.displayName || item.qualifiedName,
|
|
2792
|
+
description: item.description || "No description provided.",
|
|
2793
|
+
source: "smithery",
|
|
2794
|
+
sourceLabel: "Smithery",
|
|
2795
|
+
category: "marketplace",
|
|
2796
|
+
tags: ["smithery", ...item.verified ? ["verified"] : []],
|
|
2797
|
+
installHint: `contextwise add "${item.qualifiedName}" --smithery`,
|
|
2798
|
+
homepage: item.homepage || `https://smithery.ai/servers/${item.qualifiedName}`,
|
|
2799
|
+
verified: item.verified,
|
|
2800
|
+
requiredParams: [],
|
|
2801
|
+
suggestedConfig: this.buildServerConfig(item.qualifiedName, { apiKey: key })
|
|
2802
|
+
}));
|
|
2803
|
+
} catch (err) {
|
|
2804
|
+
logger.debug(`Smithery Registry query skipped or timed out: ${err}`);
|
|
2805
|
+
return [];
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
/**
|
|
2809
|
+
* Builds an UpstreamServerConfig that runs a Smithery MCP server using @smithery/cli runner.
|
|
2810
|
+
*/
|
|
2811
|
+
static buildServerConfig(qualifiedName, options = {}) {
|
|
2812
|
+
const key = options.apiKey || process.env.SMITHERY_API_KEY;
|
|
2813
|
+
const env = { ...options.env || {} };
|
|
2814
|
+
if (key) {
|
|
2815
|
+
env["SMITHERY_API_KEY"] = key;
|
|
2816
|
+
}
|
|
2817
|
+
return {
|
|
2818
|
+
command: "npx",
|
|
2819
|
+
args: ["-y", "@smithery/cli@latest", "run", qualifiedName],
|
|
2820
|
+
env,
|
|
2821
|
+
autoRestart: true
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
};
|
|
2825
|
+
|
|
2826
|
+
// src/registry/remote_registry.ts
|
|
2827
|
+
var UnifiedRegistryClient = class {
|
|
2828
|
+
/**
|
|
2829
|
+
* Searches across all available registries (Curated Presets, Official MCP Registry, and Smithery).
|
|
2830
|
+
*/
|
|
2831
|
+
static async search(options = {}) {
|
|
2832
|
+
const query = options.query?.trim();
|
|
2833
|
+
const category = options.category?.trim();
|
|
2834
|
+
const source = options.source ?? "all";
|
|
2835
|
+
const limit = options.limit ?? 15;
|
|
2836
|
+
const timeoutMs = options.timeoutMs ?? 3e3;
|
|
2837
|
+
const results = [];
|
|
2838
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2839
|
+
if (source === "all" || source === "curated") {
|
|
2840
|
+
const curated = KnownServerRegistry.search(query, category);
|
|
2841
|
+
for (const s of curated) {
|
|
2842
|
+
seenIds.add(s.id.toLowerCase());
|
|
2843
|
+
seenIds.add(s.name.toLowerCase());
|
|
2844
|
+
results.push({
|
|
2845
|
+
id: s.id,
|
|
2846
|
+
name: s.name,
|
|
2847
|
+
displayName: s.displayName,
|
|
2848
|
+
description: s.description,
|
|
2849
|
+
source: "curated",
|
|
2850
|
+
sourceLabel: "Curated / Verified",
|
|
2851
|
+
category: s.category,
|
|
2852
|
+
tags: s.tags,
|
|
2853
|
+
installHint: `contextwise add ${s.id}`,
|
|
2854
|
+
homepage: s.documentationUrl,
|
|
2855
|
+
verified: true,
|
|
2856
|
+
requiredParams: s.requiredParams,
|
|
2857
|
+
suggestedConfig: KnownServerRegistry.buildConfig(s)
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
const remoteQuery = query || category;
|
|
2862
|
+
const promises = [];
|
|
2863
|
+
if (source === "all" || source === "official") {
|
|
2864
|
+
promises.push(OfficialRegistryClient.search(remoteQuery, limit, timeoutMs));
|
|
2865
|
+
}
|
|
2866
|
+
if (source === "all" || source === "smithery") {
|
|
2867
|
+
promises.push(
|
|
2868
|
+
SmitheryClient.search(remoteQuery, options.smitheryApiKey, timeoutMs)
|
|
2869
|
+
);
|
|
2870
|
+
}
|
|
2871
|
+
if (promises.length > 0) {
|
|
2872
|
+
const settled = await Promise.allSettled(promises);
|
|
2873
|
+
for (const res of settled) {
|
|
2874
|
+
if (res.status === "fulfilled" && Array.isArray(res.value)) {
|
|
2875
|
+
for (const server of res.value) {
|
|
2876
|
+
const cleanId = server.id.toLowerCase();
|
|
2877
|
+
const cleanName = server.name.toLowerCase();
|
|
2878
|
+
if (!seenIds.has(cleanId) && !seenIds.has(cleanName)) {
|
|
2879
|
+
seenIds.add(cleanId);
|
|
2880
|
+
seenIds.add(cleanName);
|
|
2881
|
+
results.push(server);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
return results;
|
|
2888
|
+
}
|
|
2889
|
+
};
|
|
2890
|
+
|
|
2891
|
+
// src/registry/recommender.ts
|
|
2892
|
+
var ServerRecommender = class {
|
|
2893
|
+
/**
|
|
2894
|
+
* Generates actionable server recommendations when an agent lacks tools for a given task.
|
|
2895
|
+
*/
|
|
2896
|
+
static async recommendForTask(query, domain, limit = 3) {
|
|
2897
|
+
const servers = await UnifiedRegistryClient.search({
|
|
2898
|
+
query,
|
|
2899
|
+
category: domain,
|
|
2900
|
+
limit
|
|
2901
|
+
});
|
|
2902
|
+
if (servers.length === 0) {
|
|
2903
|
+
return {
|
|
2904
|
+
hasRecommendations: false,
|
|
2905
|
+
message: `No matching tools found for query "${query}", and no matching MCP servers were found in online registries.`,
|
|
2906
|
+
recommendedServers: []
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
const lines = [
|
|
2910
|
+
`\u26A0\uFE0F Capability Gap Detected: No active tools in your connected ContextWise servers match "${query}".`,
|
|
2911
|
+
"",
|
|
2912
|
+
`\u{1F4A1} Recommended MCP servers to add for this capability:`
|
|
2913
|
+
];
|
|
2914
|
+
servers.forEach((s, idx) => {
|
|
2915
|
+
let installExample = `${ADD_SERVER_NAME}(name: "${s.id}", preset: "${s.id}")`;
|
|
2916
|
+
if (s.source === "smithery") {
|
|
2917
|
+
installExample = `${ADD_SERVER_NAME}(name: "${s.id}", smithery: "${s.id}")`;
|
|
2918
|
+
} else if (s.source === "official") {
|
|
2919
|
+
installExample = `${ADD_SERVER_NAME}(name: "${s.id}")`;
|
|
2920
|
+
}
|
|
2921
|
+
const params = s.requiredParams && s.requiredParams.length > 0 ? `
|
|
2922
|
+
- Required configuration: ${s.requiredParams.map((p) => `\`${p.name}\` (${p.type}: ${p.description})`).join(", ")}` : "";
|
|
2923
|
+
lines.push(
|
|
2924
|
+
`${idx + 1}. **${s.displayName}** (\`${s.id}\`) \u2014 Registry: **${s.sourceLabel}**`
|
|
2925
|
+
);
|
|
2926
|
+
lines.push(` - ${s.description}${params}`);
|
|
2927
|
+
lines.push(` - Direct install call: \`${installExample}\``);
|
|
2928
|
+
});
|
|
2929
|
+
lines.push("");
|
|
2930
|
+
lines.push(
|
|
2931
|
+
`To proceed with this task, invoke \`${ADD_SERVER_NAME}\` with the chosen server (asking the user for any required credentials if needed). ContextWise will immediately hot-load the server and make its tools available in this session without restarting.`
|
|
2932
|
+
);
|
|
2933
|
+
return {
|
|
2934
|
+
hasRecommendations: true,
|
|
2935
|
+
message: lines.join("\n"),
|
|
2936
|
+
recommendedServers: servers
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
};
|
|
2940
|
+
|
|
2941
|
+
// src/registry/manager.ts
|
|
2942
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
2943
|
+
import { dirname as dirname4, resolve as resolve2 } from "path";
|
|
2944
|
+
var ServerManager = class {
|
|
2945
|
+
/**
|
|
2946
|
+
* Finds the path to contextwise.json in the current working directory.
|
|
2947
|
+
*/
|
|
2948
|
+
static findConfigPath(customPath, cwd = process.cwd()) {
|
|
2949
|
+
if (customPath) {
|
|
2950
|
+
return resolve2(cwd, customPath);
|
|
2951
|
+
}
|
|
2952
|
+
if (process.env.CONTEXTWISE_CONFIG) {
|
|
2953
|
+
return resolve2(cwd, process.env.CONTEXTWISE_CONFIG);
|
|
2954
|
+
}
|
|
2955
|
+
const standardPath = resolve2(cwd, "contextwise.json");
|
|
2956
|
+
if (existsSync6(standardPath)) {
|
|
2957
|
+
return standardPath;
|
|
2958
|
+
}
|
|
2959
|
+
const dotPath = resolve2(cwd, ".contextwise.json");
|
|
2960
|
+
if (existsSync6(dotPath)) {
|
|
2961
|
+
return dotPath;
|
|
2962
|
+
}
|
|
2963
|
+
return standardPath;
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* Loads the current configuration or returns an empty default config.
|
|
2967
|
+
*/
|
|
2968
|
+
static loadConfig(configPath, cwd = process.cwd()) {
|
|
2969
|
+
const filePath = this.findConfigPath(configPath, cwd);
|
|
2970
|
+
if (!existsSync6(filePath)) {
|
|
2971
|
+
return {
|
|
2972
|
+
config: ContextWiseConfigSchema.parse({}),
|
|
2973
|
+
path: filePath
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
try {
|
|
2977
|
+
const raw = readFileSync6(filePath, "utf-8");
|
|
2978
|
+
return {
|
|
2979
|
+
config: ContextWiseConfigSchema.parse(JSON.parse(raw)),
|
|
2980
|
+
path: filePath
|
|
2981
|
+
};
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
logger.warn(`Could not parse existing config at ${filePath}: ${err}. Starting fresh.`);
|
|
2984
|
+
return {
|
|
2985
|
+
config: ContextWiseConfigSchema.parse({}),
|
|
2986
|
+
path: filePath
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
/**
|
|
2991
|
+
* Persists an upstream server configuration to contextwise.json.
|
|
2992
|
+
*/
|
|
2993
|
+
static saveUpstream(name, serverConfig, configPath, cwd = process.cwd()) {
|
|
2994
|
+
const { config, path: filePath } = this.loadConfig(configPath, cwd);
|
|
2995
|
+
config.upstreams[name] = serverConfig;
|
|
2996
|
+
const dir = dirname4(filePath);
|
|
2997
|
+
if (!existsSync6(dir)) {
|
|
2998
|
+
mkdirSync4(dir, { recursive: true });
|
|
2999
|
+
}
|
|
3000
|
+
writeFileSync4(filePath, JSON.stringify(config, null, 2), "utf-8");
|
|
3001
|
+
logger.info(`Persisted upstream "${name}" into ${filePath}`);
|
|
3002
|
+
return filePath;
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Removes an upstream server from contextwise.json.
|
|
3006
|
+
*/
|
|
3007
|
+
static removeUpstream(name, configPath, cwd = process.cwd()) {
|
|
3008
|
+
const { config, path: filePath } = this.loadConfig(configPath, cwd);
|
|
3009
|
+
if (!config.upstreams[name]) {
|
|
3010
|
+
return false;
|
|
3011
|
+
}
|
|
3012
|
+
delete config.upstreams[name];
|
|
3013
|
+
writeFileSync4(filePath, JSON.stringify(config, null, 2), "utf-8");
|
|
3014
|
+
logger.info(`Removed upstream "${name}" from ${filePath}`);
|
|
3015
|
+
return true;
|
|
3016
|
+
}
|
|
3017
|
+
};
|
|
3018
|
+
|
|
3019
|
+
// src/router/workspace.ts
|
|
3020
|
+
import { existsSync as existsSync7, readdirSync } from "fs";
|
|
3021
|
+
import { resolve as resolve3 } from "path";
|
|
3022
|
+
var WorkspaceContextPrimer = class {
|
|
3023
|
+
/**
|
|
3024
|
+
* Scans a workspace directory to detect technical domains and pre-prime relevant tools.
|
|
3025
|
+
*/
|
|
3026
|
+
static analyzeWorkspace(dirPath = process.cwd()) {
|
|
3027
|
+
const domains = /* @__PURE__ */ new Set();
|
|
3028
|
+
const queries = /* @__PURE__ */ new Set();
|
|
3029
|
+
try {
|
|
3030
|
+
if (!existsSync7(dirPath)) {
|
|
3031
|
+
return { detectedDomains: [], suggestedQueries: [] };
|
|
3032
|
+
}
|
|
3033
|
+
if (existsSync7(resolve3(dirPath, ".git"))) {
|
|
3034
|
+
domains.add("git");
|
|
3035
|
+
queries.add("git status and diff tools");
|
|
3036
|
+
}
|
|
3037
|
+
if (existsSync7(resolve3(dirPath, "Dockerfile")) || existsSync7(resolve3(dirPath, "docker-compose.yml")) || existsSync7(resolve3(dirPath, "compose.yaml"))) {
|
|
3038
|
+
domains.add("docker");
|
|
3039
|
+
queries.add("docker and container tools");
|
|
3040
|
+
}
|
|
3041
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
3042
|
+
for (const entry of entries) {
|
|
3043
|
+
const name = entry.name.toLowerCase();
|
|
3044
|
+
if (name.endsWith(".sql") || name.endsWith(".sqlite") || name.endsWith(".sqlite3") || name === "prisma" || name.startsWith("prisma.") || name === "migrations" || name === "database" || /(?:^|[_.-])(?:db|database|sql)(?:[_.-]|$)/i.test(name)) {
|
|
3045
|
+
domains.add("database");
|
|
3046
|
+
queries.add("database and SQL query tools");
|
|
3047
|
+
break;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
domains.add("filesystem");
|
|
3051
|
+
} catch (err) {
|
|
3052
|
+
logger.warn(`Workspace analysis encountered error in ${dirPath}: ${err}`);
|
|
3053
|
+
}
|
|
3054
|
+
const detectedDomains = Array.from(domains);
|
|
3055
|
+
const suggestedQueries = Array.from(queries);
|
|
3056
|
+
logger.debug(`Workspace pre-primed domains: ${detectedDomains.join(", ")}`);
|
|
3057
|
+
return { detectedDomains, suggestedQueries };
|
|
3058
|
+
}
|
|
3059
|
+
};
|
|
3060
|
+
|
|
3061
|
+
// src/router/router.ts
|
|
3062
|
+
var ContextRouter = class {
|
|
3063
|
+
config;
|
|
3064
|
+
registry;
|
|
3065
|
+
searchEngine;
|
|
3066
|
+
constructor(config = {}, registry = toolRegistry, search = searchEngine) {
|
|
3067
|
+
this.config = {
|
|
3068
|
+
strategy: config.strategy ?? "hybrid",
|
|
3069
|
+
topK: config.topK ?? 5,
|
|
3070
|
+
similarityThreshold: config.similarityThreshold ?? 0.45,
|
|
3071
|
+
pinnedTools: config.pinnedTools ?? [],
|
|
3072
|
+
maxActiveTools: config.maxActiveTools ?? 10,
|
|
3073
|
+
enableBrowseServers: config.enableBrowseServers ?? false,
|
|
3074
|
+
enableAddServer: config.enableAddServer ?? false,
|
|
3075
|
+
allowCustomCommands: config.allowCustomCommands ?? false,
|
|
3076
|
+
persistAddedServers: config.persistAddedServers ?? false
|
|
3077
|
+
};
|
|
3078
|
+
this.registry = registry;
|
|
3079
|
+
this.searchEngine = search;
|
|
3080
|
+
this.registry.setPinnedTools(this.config.pinnedTools);
|
|
3081
|
+
if (this.config.maxActiveTools) {
|
|
3082
|
+
this.registry.setMaxActiveTools(this.config.maxActiveTools);
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
updateConfig(config) {
|
|
3086
|
+
this.config = { ...this.config, ...config };
|
|
3087
|
+
if (config.pinnedTools) {
|
|
3088
|
+
this.registry.setPinnedTools(config.pinnedTools);
|
|
3089
|
+
}
|
|
3090
|
+
if (config.maxActiveTools) {
|
|
3091
|
+
this.registry.setMaxActiveTools(config.maxActiveTools);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
getConfig() {
|
|
3095
|
+
return this.config;
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Prime active tools based on workspace environment (Mode 4).
|
|
3099
|
+
*/
|
|
3100
|
+
primeWorkspace(workspaceDir = process.cwd()) {
|
|
3101
|
+
if (this.config.strategy === "passthrough") return;
|
|
3102
|
+
const analysis = WorkspaceContextPrimer.analyzeWorkspace(workspaceDir);
|
|
3103
|
+
for (const query of analysis.suggestedQueries) {
|
|
3104
|
+
const results = this.searchEngine.search(query, { topK: 2, similarityThreshold: this.config.similarityThreshold });
|
|
3105
|
+
if (results.length > 0) {
|
|
3106
|
+
this.registry.activateTools(results.map((r) => r.tool.namespacedName));
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Generates a succinct Dynamic Capability Manifest of connected upstream servers and tools.
|
|
3112
|
+
*/
|
|
3113
|
+
generateCapabilityManifest() {
|
|
3114
|
+
const allTools = this.registry.getAllTools();
|
|
3115
|
+
if (allTools.length === 0) {
|
|
3116
|
+
return "";
|
|
3117
|
+
}
|
|
3118
|
+
const serverMap = /* @__PURE__ */ new Map();
|
|
3119
|
+
for (const tool of allTools) {
|
|
3120
|
+
const server = tool.serverName;
|
|
3121
|
+
if (!serverMap.has(server)) {
|
|
3122
|
+
serverMap.set(server, []);
|
|
3123
|
+
}
|
|
3124
|
+
const list = serverMap.get(server);
|
|
3125
|
+
if (!list.includes(tool.originalName)) {
|
|
3126
|
+
list.push(tool.originalName);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
const lines = [];
|
|
3130
|
+
const sortedServers = Array.from(serverMap.entries()).sort(
|
|
3131
|
+
([a], [b]) => a.localeCompare(b)
|
|
3132
|
+
);
|
|
3133
|
+
for (const [serverName, toolNames] of sortedServers) {
|
|
3134
|
+
const known = KnownServerRegistry.find(serverName);
|
|
3135
|
+
const serverLabel = known?.displayName ? `${serverName} (${known.displayName})` : serverName;
|
|
3136
|
+
const toolSummary = toolNames.length <= 4 ? toolNames.join(", ") : `${toolNames.slice(0, 3).join(", ")} (+${toolNames.length - 3} more)`;
|
|
3137
|
+
lines.push(`\u2022 ${serverLabel}: ${toolSummary}`);
|
|
3138
|
+
}
|
|
3139
|
+
return lines.join("\n");
|
|
3140
|
+
}
|
|
3141
|
+
/**
|
|
3142
|
+
* Returns list of tools to expose to downstream AI client.
|
|
3143
|
+
*/
|
|
3144
|
+
getExposedTools() {
|
|
3145
|
+
if (this.config.strategy === "passthrough") {
|
|
3146
|
+
return this.registry.getAllTools().map((t) => ({
|
|
3147
|
+
...t,
|
|
3148
|
+
name: t.namespacedName
|
|
3149
|
+
}));
|
|
3150
|
+
}
|
|
3151
|
+
const activeTools = this.registry.getActiveTools().map((t) => ({
|
|
3152
|
+
...t,
|
|
3153
|
+
name: t.namespacedName
|
|
3154
|
+
}));
|
|
3155
|
+
const manifest = this.generateCapabilityManifest();
|
|
3156
|
+
const dynamicSearchTool = {
|
|
3157
|
+
...SEARCH_TOOLS_DEFINITION,
|
|
3158
|
+
description: manifest ? `${SEARCH_TOOLS_DEFINITION.description}
|
|
3159
|
+
|
|
3160
|
+
Connected MCP servers and available capabilities:
|
|
3161
|
+
${manifest}` : SEARCH_TOOLS_DEFINITION.description
|
|
3162
|
+
};
|
|
3163
|
+
const metaTools = [
|
|
3164
|
+
dynamicSearchTool,
|
|
3165
|
+
EXECUTE_TOOL_DEFINITION
|
|
3166
|
+
];
|
|
3167
|
+
if (this.config.enableBrowseServers) {
|
|
3168
|
+
metaTools.push(BROWSE_SERVERS_DEFINITION);
|
|
3169
|
+
}
|
|
3170
|
+
if (this.config.enableAddServer) {
|
|
3171
|
+
metaTools.push(ADD_SERVER_DEFINITION);
|
|
3172
|
+
}
|
|
3173
|
+
const metaToolNames = new Set(metaTools.map((m) => m.name));
|
|
3174
|
+
const safeActiveTools = activeTools.filter((t) => !metaToolNames.has(t.name));
|
|
3175
|
+
return [...metaTools, ...safeActiveTools];
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Handles contextwise_search_tools meta-tool call.
|
|
3179
|
+
*/
|
|
3180
|
+
searchAndHydrate(query, domain, topK, activate = true) {
|
|
3181
|
+
const k = topK ?? this.config.topK;
|
|
3182
|
+
const results = this.searchEngine.search(query, {
|
|
3183
|
+
topK: k,
|
|
3184
|
+
domain,
|
|
3185
|
+
similarityThreshold: this.config.similarityThreshold
|
|
3186
|
+
});
|
|
3187
|
+
if (results.length === 0) {
|
|
3188
|
+
return {
|
|
3189
|
+
message: `No matching tools found for query "${query}".`,
|
|
3190
|
+
foundTools: [],
|
|
3191
|
+
activatedNewTools: false
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
const toolNames = results.map((r) => r.tool.namespacedName);
|
|
3195
|
+
let activatedNewTools = false;
|
|
3196
|
+
if (activate) {
|
|
3197
|
+
const beforeSet = new Set(this.registry.getActiveTools().map((t) => t.namespacedName));
|
|
3198
|
+
this.registry.activateTools(toolNames);
|
|
3199
|
+
const afterSet = new Set(this.registry.getActiveTools().map((t) => t.namespacedName));
|
|
3200
|
+
activatedNewTools = beforeSet.size !== afterSet.size || [...afterSet].some((t) => !beforeSet.has(t));
|
|
3201
|
+
if (activatedNewTools) {
|
|
3202
|
+
logger.info(`Dynamically activated ${afterSet.size - beforeSet.size} tools for query "${query}"`);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
const formatParams = (inputSchema) => {
|
|
3206
|
+
if (!inputSchema || typeof inputSchema !== "object") return "none";
|
|
3207
|
+
const s = inputSchema;
|
|
3208
|
+
const props = s.properties || {};
|
|
3209
|
+
const required = new Set(s.required || []);
|
|
3210
|
+
const entries = Object.entries(props);
|
|
3211
|
+
if (entries.length === 0) return "none";
|
|
3212
|
+
return entries.map(([name, p]) => {
|
|
3213
|
+
const type = p.type || "any";
|
|
3214
|
+
const reqStr = required.has(name) ? "required" : "optional";
|
|
3215
|
+
const descStr = p.description ? ` - ${p.description}` : "";
|
|
3216
|
+
return `${name}: ${type} (${reqStr})${descStr}`;
|
|
3217
|
+
}).join(", ");
|
|
3218
|
+
};
|
|
3219
|
+
const formatted = results.map((r) => ({
|
|
3220
|
+
name: r.tool.namespacedName,
|
|
3221
|
+
server: r.tool.serverName,
|
|
3222
|
+
description: r.tool.description || "",
|
|
3223
|
+
parameters: r.tool.inputSchema?.properties || {}
|
|
3224
|
+
}));
|
|
3225
|
+
const summaryText = [
|
|
3226
|
+
`Found ${results.length} relevant tool(s) for "${query}":`,
|
|
3227
|
+
...results.map(
|
|
3228
|
+
(r, idx) => `${idx + 1}. **${r.tool.namespacedName}** (${r.tool.serverName})
|
|
3229
|
+
- ${r.tool.description || "No description"}
|
|
3230
|
+
- Parameters: ${formatParams(r.tool.inputSchema)}`
|
|
3231
|
+
),
|
|
3232
|
+
activate ? "\nThese tools have been activated into your active tool set and can also be called immediately via contextwise_execute_tool." : ""
|
|
3233
|
+
].filter(Boolean).join("\n");
|
|
3234
|
+
return {
|
|
3235
|
+
message: summaryText,
|
|
3236
|
+
foundTools: formatted,
|
|
3237
|
+
activatedNewTools
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
};
|
|
3241
|
+
var contextRouter = new ContextRouter();
|
|
3242
|
+
|
|
3243
|
+
// src/core/proxy.ts
|
|
3244
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3245
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3246
|
+
import {
|
|
3247
|
+
CallToolRequestSchema,
|
|
3248
|
+
ListToolsRequestSchema
|
|
3249
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
3250
|
+
var ContextWiseProxy = class {
|
|
3251
|
+
server;
|
|
3252
|
+
multiplexer;
|
|
3253
|
+
router;
|
|
3254
|
+
transport;
|
|
3255
|
+
config;
|
|
3256
|
+
isRunning = false;
|
|
3257
|
+
constructor() {
|
|
3258
|
+
this.server = new Server(
|
|
3259
|
+
{
|
|
3260
|
+
name: "contextwise",
|
|
3261
|
+
version: "0.1.0"
|
|
3262
|
+
},
|
|
3263
|
+
{
|
|
3264
|
+
capabilities: {
|
|
3265
|
+
tools: {
|
|
3266
|
+
listChanged: true
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
);
|
|
3271
|
+
this.multiplexer = new UpstreamMultiplexer();
|
|
3272
|
+
this.router = contextRouter;
|
|
3273
|
+
this.setupRequestHandlers();
|
|
3274
|
+
}
|
|
3275
|
+
setupRequestHandlers() {
|
|
3276
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3277
|
+
const exposedTools = this.router.getExposedTools();
|
|
3278
|
+
const allToolsCount = toolRegistry.getAllTools().length;
|
|
3279
|
+
metricsCollector.updateToolCounts(allToolsCount, exposedTools.length);
|
|
3280
|
+
metricsCollector.recordSchemaPruning(allToolsCount, exposedTools.length);
|
|
3281
|
+
logger.debug(
|
|
3282
|
+
`Serving tools/list: exposing ${exposedTools.length} tools (out of ${allToolsCount} upstream tools)`
|
|
3283
|
+
);
|
|
3284
|
+
return {
|
|
3285
|
+
tools: exposedTools
|
|
3286
|
+
};
|
|
3287
|
+
});
|
|
3288
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3289
|
+
const { name, arguments: args = {} } = request.params;
|
|
3290
|
+
return this.handleToolCall(name, args);
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
/**
|
|
3294
|
+
* Main tool execution pipeline handling meta-tools and proxied calls.
|
|
3295
|
+
*/
|
|
3296
|
+
async handleToolCall(name, args = {}) {
|
|
3297
|
+
if (name === SEARCH_TOOLS_NAME) {
|
|
3298
|
+
return this.handleSearchToolsCall(args);
|
|
3299
|
+
}
|
|
3300
|
+
if (name === EXECUTE_TOOL_NAME) {
|
|
3301
|
+
const targetToolName = String(args.tool_name || "");
|
|
3302
|
+
const targetArgs = args.arguments || {};
|
|
3303
|
+
if (!targetToolName) {
|
|
3304
|
+
return {
|
|
3305
|
+
isError: true,
|
|
3306
|
+
content: [
|
|
3307
|
+
{
|
|
3308
|
+
type: "text",
|
|
3309
|
+
text: 'Error: Missing required parameter "tool_name" in contextwise_execute_tool.'
|
|
3310
|
+
}
|
|
3311
|
+
]
|
|
3312
|
+
};
|
|
3313
|
+
}
|
|
3314
|
+
return this.dispatchProxiedToolCall(targetToolName, targetArgs);
|
|
3315
|
+
}
|
|
3316
|
+
if (name === BROWSE_SERVERS_NAME) {
|
|
3317
|
+
return this.handleBrowseServersCall(args);
|
|
3318
|
+
}
|
|
3319
|
+
if (name === ADD_SERVER_NAME) {
|
|
3320
|
+
return this.handleAddServerCall(args);
|
|
3321
|
+
}
|
|
3322
|
+
return this.dispatchProxiedToolCall(name, args);
|
|
3323
|
+
}
|
|
3324
|
+
/**
|
|
3325
|
+
* Handles contextwise_browse_servers execution.
|
|
3326
|
+
*/
|
|
3327
|
+
async handleBrowseServersCall(args) {
|
|
3328
|
+
const query = args.query ? String(args.query) : void 0;
|
|
3329
|
+
const category = args.category ? String(args.category) : void 0;
|
|
3330
|
+
const source = args.source ?? "all";
|
|
3331
|
+
const servers = await UnifiedRegistryClient.search({
|
|
3332
|
+
query,
|
|
3333
|
+
category,
|
|
3334
|
+
source,
|
|
3335
|
+
limit: 12
|
|
3336
|
+
});
|
|
3337
|
+
if (servers.length === 0) {
|
|
3338
|
+
return {
|
|
3339
|
+
content: [
|
|
3340
|
+
{
|
|
3341
|
+
type: "text",
|
|
3342
|
+
text: `No MCP servers found matching query: "${query || ""}" in category "${category || "all"}". You can still add any custom MCP server using ${ADD_SERVER_NAME}.`
|
|
3343
|
+
}
|
|
3344
|
+
]
|
|
3345
|
+
};
|
|
3346
|
+
}
|
|
3347
|
+
const lines = [
|
|
3348
|
+
`### ContextWise MCP Server Catalog (${servers.length} results from online registries & local presets)`,
|
|
3349
|
+
""
|
|
3350
|
+
];
|
|
3351
|
+
for (const s of servers) {
|
|
3352
|
+
const verifiedBadge = s.verified ? " [Verified]" : "";
|
|
3353
|
+
lines.push(
|
|
3354
|
+
`\u2022 **${s.displayName}** (\`${s.id}\`) \u2014 Registry: **${s.sourceLabel}**${verifiedBadge}`
|
|
3355
|
+
);
|
|
3356
|
+
lines.push(` ${s.description}`);
|
|
3357
|
+
if (s.requiredParams && s.requiredParams.length > 0) {
|
|
3358
|
+
lines.push(` *Required Configuration:*`);
|
|
3359
|
+
for (const p of s.requiredParams) {
|
|
3360
|
+
lines.push(` - \`${p.name}\` (${p.type}): ${p.description}`);
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
if (s.source === "smithery") {
|
|
3364
|
+
lines.push(` *Install with:* \`${ADD_SERVER_NAME}(name: "${s.id}", smithery: "${s.id}")\``);
|
|
3365
|
+
} else if (s.source === "curated") {
|
|
3366
|
+
lines.push(` *Install with:* \`${ADD_SERVER_NAME}(name: "${s.name}", preset: "${s.id}")\``);
|
|
3367
|
+
} else {
|
|
3368
|
+
lines.push(` *Install with:* \`${ADD_SERVER_NAME}(name: "${s.id}", ...)\``);
|
|
3369
|
+
}
|
|
3370
|
+
lines.push("");
|
|
3371
|
+
}
|
|
3372
|
+
return {
|
|
3373
|
+
content: [
|
|
3374
|
+
{
|
|
3375
|
+
type: "text",
|
|
3376
|
+
text: lines.join("\n")
|
|
3377
|
+
}
|
|
3378
|
+
]
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
3381
|
+
/**
|
|
3382
|
+
* Handles contextwise_add_server execution (hot-loading & persistence).
|
|
3383
|
+
*/
|
|
3384
|
+
async handleAddServerCall(args) {
|
|
3385
|
+
const routingConfig = this.router.getConfig();
|
|
3386
|
+
if (!routingConfig.enableAddServer) {
|
|
3387
|
+
return {
|
|
3388
|
+
isError: true,
|
|
3389
|
+
content: [
|
|
3390
|
+
{
|
|
3391
|
+
type: "text",
|
|
3392
|
+
text: "Error: Dynamic server addition is disabled by configuration for security. Enable it in contextwise.json with routing.enableAddServer: true."
|
|
3393
|
+
}
|
|
3394
|
+
]
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
const rawName = String(args.name || "").trim();
|
|
3398
|
+
if (!rawName) {
|
|
3399
|
+
return {
|
|
3400
|
+
isError: true,
|
|
3401
|
+
content: [{ type: "text", text: 'Error: Missing required parameter "name".' }]
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
3404
|
+
const envObj = args.env || {};
|
|
3405
|
+
const paramsObj = args.params || {};
|
|
3406
|
+
const headersObj = args.headers || {};
|
|
3407
|
+
for (const [source, obj] of Object.entries({ env: envObj, params: paramsObj, headers: headersObj })) {
|
|
3408
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
3409
|
+
if (typeof val === "string" && /(?:vault|auth|mcp-auth|env):\/\//.test(val)) {
|
|
3410
|
+
return {
|
|
3411
|
+
isError: true,
|
|
3412
|
+
content: [
|
|
3413
|
+
{
|
|
3414
|
+
type: "text",
|
|
3415
|
+
text: `Security violation: "vault://" and "env://" secret references cannot be passed via dynamic server addition (detected in ${source}.${key}).`
|
|
3416
|
+
}
|
|
3417
|
+
]
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
let smitheryPackage = typeof args.smithery === "string" ? args.smithery : void 0;
|
|
3423
|
+
if (!smitheryPackage && (rawName.startsWith("@smithery/") || rawName.startsWith("smithery:"))) {
|
|
3424
|
+
smitheryPackage = rawName.replace(/^smithery:/, "");
|
|
3425
|
+
}
|
|
3426
|
+
const name = rawName.replace(/^@/, "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
3427
|
+
let serverConfig;
|
|
3428
|
+
const presetId = String(args.preset || name).toLowerCase();
|
|
3429
|
+
const knownPreset = KnownServerRegistry.find(presetId);
|
|
3430
|
+
if (knownPreset) {
|
|
3431
|
+
serverConfig = KnownServerRegistry.buildConfig(knownPreset, {
|
|
3432
|
+
args: Array.isArray(args.args) ? args.args : [],
|
|
3433
|
+
env: args.env || {}
|
|
3434
|
+
});
|
|
3435
|
+
} else if (smitheryPackage) {
|
|
3436
|
+
serverConfig = SmitheryClient.buildServerConfig(smitheryPackage, {
|
|
3437
|
+
env: args.env || {}
|
|
3438
|
+
});
|
|
3439
|
+
} else if (!routingConfig.allowCustomCommands) {
|
|
3440
|
+
return {
|
|
3441
|
+
isError: true,
|
|
3442
|
+
content: [
|
|
3443
|
+
{
|
|
3444
|
+
type: "text",
|
|
3445
|
+
text: `Security restriction: Arbitrary commands and unverified URLs are restricted. Only verified presets and Smithery packages are permitted unless routing.allowCustomCommands is explicitly enabled in configuration.`
|
|
3446
|
+
}
|
|
3447
|
+
]
|
|
3448
|
+
};
|
|
3449
|
+
} else if (args.url && typeof args.url === "string") {
|
|
3450
|
+
serverConfig = {
|
|
3451
|
+
url: args.url,
|
|
3452
|
+
headers: args.headers || {},
|
|
3453
|
+
transport: "auto",
|
|
3454
|
+
autoReconnect: true
|
|
3455
|
+
};
|
|
3456
|
+
} else if (args.command && typeof args.command === "string") {
|
|
3457
|
+
serverConfig = {
|
|
3458
|
+
command: args.command,
|
|
3459
|
+
args: Array.isArray(args.args) ? args.args : [],
|
|
3460
|
+
env: args.env || {},
|
|
3461
|
+
autoRestart: true
|
|
3462
|
+
};
|
|
3463
|
+
} else {
|
|
3464
|
+
return {
|
|
3465
|
+
isError: true,
|
|
3466
|
+
content: [
|
|
3467
|
+
{
|
|
3468
|
+
type: "text",
|
|
3469
|
+
text: `Could not configure server "${name}". Please specify a known "preset" (e.g. "postgres", "github", "sqlite", "fetch") or a "smithery" package name.`
|
|
3470
|
+
}
|
|
3471
|
+
]
|
|
3472
|
+
};
|
|
3473
|
+
}
|
|
3474
|
+
logger.info(`Adding and hot-loading new upstream server "${name}"...`);
|
|
3475
|
+
try {
|
|
3476
|
+
await this.multiplexer.connectServer(name, serverConfig);
|
|
3477
|
+
const status = this.multiplexer.getStatus().find((s) => s.name === name);
|
|
3478
|
+
if (!status || status.status !== "connected") {
|
|
3479
|
+
const errorDetail = status?.error || "Unknown connection failure";
|
|
3480
|
+
return {
|
|
3481
|
+
isError: true,
|
|
3482
|
+
content: [
|
|
3483
|
+
{
|
|
3484
|
+
type: "text",
|
|
3485
|
+
text: `Failed to connect to newly added upstream server "${name}": ${errorDetail}`
|
|
3486
|
+
}
|
|
3487
|
+
]
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
3490
|
+
if (routingConfig.persistAddedServers) {
|
|
3491
|
+
ServerManager.saveUpstream(name, serverConfig);
|
|
3492
|
+
}
|
|
3493
|
+
const allTools = this.multiplexer.getAllTools();
|
|
3494
|
+
toolRegistry.setAllTools(allTools);
|
|
3495
|
+
try {
|
|
3496
|
+
await this.server.sendToolListChanged();
|
|
3497
|
+
} catch (notifyErr) {
|
|
3498
|
+
logger.debug(`Client notification push: ${notifyErr}`);
|
|
3499
|
+
}
|
|
3500
|
+
const newTools = allTools.filter((t) => t.serverName === name).map((t) => t.namespacedName);
|
|
3501
|
+
const persistMsg = routingConfig.persistAddedServers ? "persisted to contextwise.json, " : "loaded in session, ";
|
|
3502
|
+
return {
|
|
3503
|
+
content: [
|
|
3504
|
+
{
|
|
3505
|
+
type: "text",
|
|
3506
|
+
text: `\u2713 Successfully added and connected upstream server "${name}"!
|
|
3507
|
+
|
|
3508
|
+
Discovered ${newTools.length} tools:
|
|
3509
|
+
${newTools.map((t) => `\u2022 ${t}`).join("\n")}
|
|
3510
|
+
|
|
3511
|
+
These tools are now indexed, ${persistMsg}and ready for immediate invocation in this session.`
|
|
3512
|
+
}
|
|
3513
|
+
]
|
|
3514
|
+
};
|
|
3515
|
+
} catch (err) {
|
|
3516
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3517
|
+
return {
|
|
3518
|
+
isError: true,
|
|
3519
|
+
content: [
|
|
3520
|
+
{
|
|
3521
|
+
type: "text",
|
|
3522
|
+
text: `Error adding server "${name}": ${msg}`
|
|
3523
|
+
}
|
|
3524
|
+
]
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
/**
|
|
3529
|
+
* Handles contextwise_search_tools execution.
|
|
3530
|
+
*/
|
|
3531
|
+
async handleSearchToolsCall(args) {
|
|
3532
|
+
const query = String(args.query || "");
|
|
3533
|
+
const domain = args.domain ? String(args.domain) : void 0;
|
|
3534
|
+
const topK = typeof args.topK === "number" ? args.topK : void 0;
|
|
3535
|
+
const activate = args.activate !== false;
|
|
3536
|
+
if (!query) {
|
|
3537
|
+
return {
|
|
3538
|
+
isError: true,
|
|
3539
|
+
content: [
|
|
3540
|
+
{
|
|
3541
|
+
type: "text",
|
|
3542
|
+
text: 'Error: Missing required parameter "query".'
|
|
3543
|
+
}
|
|
3544
|
+
]
|
|
3545
|
+
};
|
|
3546
|
+
}
|
|
3547
|
+
const searchResult = this.router.searchAndHydrate(query, domain, topK, activate);
|
|
3548
|
+
if (searchResult.activatedNewTools) {
|
|
3549
|
+
try {
|
|
3550
|
+
await this.server.sendToolListChanged();
|
|
3551
|
+
logger.debug("Fired notifications/tools/list_changed notification to client.");
|
|
3552
|
+
} catch (err) {
|
|
3553
|
+
logger.debug(`Could not push list_changed notification: ${err}`);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
if (searchResult.foundTools.length === 0) {
|
|
3557
|
+
logger.info(`No active tools found for "${query}". Checking registries for recommended servers...`);
|
|
3558
|
+
const rec = await ServerRecommender.recommendForTask(query, domain);
|
|
3559
|
+
if (rec.hasRecommendations) {
|
|
3560
|
+
return {
|
|
3561
|
+
content: [
|
|
3562
|
+
{
|
|
3563
|
+
type: "text",
|
|
3564
|
+
text: rec.message
|
|
3565
|
+
}
|
|
3566
|
+
]
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
return {
|
|
3571
|
+
content: [
|
|
3572
|
+
{
|
|
3573
|
+
type: "text",
|
|
3574
|
+
text: searchResult.message
|
|
3575
|
+
}
|
|
3576
|
+
]
|
|
3577
|
+
};
|
|
3578
|
+
}
|
|
3579
|
+
/**
|
|
3580
|
+
* Dispatches tool call through guardrails and upstream multiplexer.
|
|
3581
|
+
*/
|
|
3582
|
+
async dispatchProxiedToolCall(toolName, args) {
|
|
3583
|
+
const resolved = this.multiplexer.resolveTool(toolName);
|
|
3584
|
+
if (!resolved) {
|
|
3585
|
+
const rec = await ServerRecommender.recommendForTask(toolName);
|
|
3586
|
+
const errorMsg = rec.hasRecommendations ? `Tool "${toolName}" was not found in ContextWise registry.
|
|
3587
|
+
|
|
3588
|
+
${rec.message}` : `Tool "${toolName}" was not found in ContextWise registry. Use ${SEARCH_TOOLS_NAME} to find available tools.`;
|
|
3589
|
+
return {
|
|
3590
|
+
isError: true,
|
|
3591
|
+
content: [
|
|
3592
|
+
{
|
|
3593
|
+
type: "text",
|
|
3594
|
+
text: errorMsg
|
|
3595
|
+
}
|
|
3596
|
+
]
|
|
3597
|
+
};
|
|
3598
|
+
}
|
|
3599
|
+
const { serverName, originalName, tool } = resolved;
|
|
3600
|
+
const redactResult = (result) => {
|
|
3601
|
+
if (!result || !Array.isArray(result.content)) return result;
|
|
3602
|
+
return {
|
|
3603
|
+
...result,
|
|
3604
|
+
content: result.content.map((item) => {
|
|
3605
|
+
if (item && item.type === "text" && typeof item.text === "string") {
|
|
3606
|
+
return {
|
|
3607
|
+
...item,
|
|
3608
|
+
text: redactionFilter.redact(item.text)
|
|
3609
|
+
};
|
|
3610
|
+
}
|
|
3611
|
+
return item;
|
|
3612
|
+
})
|
|
3613
|
+
};
|
|
3614
|
+
};
|
|
3615
|
+
if (tool.inputSchema) {
|
|
3616
|
+
const validation = argumentValidator.validate(
|
|
3617
|
+
toolName,
|
|
3618
|
+
tool.inputSchema,
|
|
3619
|
+
args
|
|
3620
|
+
);
|
|
3621
|
+
if (!validation.valid) {
|
|
3622
|
+
const errorMsg = `Pre-flight validation failed for "${toolName}":
|
|
3623
|
+
${validation.errors?.join("\n")}`;
|
|
3624
|
+
loopBreaker.recordCall(serverName, originalName, args, true);
|
|
3625
|
+
return {
|
|
3626
|
+
isError: true,
|
|
3627
|
+
content: [{ type: "text", text: redactionFilter.redact(errorMsg) }]
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
const preFlight = loopBreaker.checkPreFlightDetailed(serverName, originalName, args);
|
|
3632
|
+
if (!preFlight.allowed) {
|
|
3633
|
+
if (preFlight.reason === "rate_limited") {
|
|
3634
|
+
metricsCollector.recordThrottle(toolName, serverName);
|
|
3635
|
+
} else {
|
|
3636
|
+
metricsCollector.recordLoopPrevented(toolName, serverName);
|
|
3637
|
+
}
|
|
3638
|
+
return {
|
|
3639
|
+
isError: true,
|
|
3640
|
+
content: [{ type: "text", text: preFlight.message }]
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
const isReadOnly = tool.readOnlyHint || tool.idempotentHint;
|
|
3644
|
+
if (isReadOnly && this.config?.guardrails.enableCache !== false) {
|
|
3645
|
+
const cached = responseCache.get(serverName, originalName, args);
|
|
3646
|
+
if (cached) {
|
|
3647
|
+
toolRegistry.touchTool(toolName);
|
|
3648
|
+
metricsCollector.recordExecution({
|
|
3649
|
+
toolName,
|
|
3650
|
+
serverName,
|
|
3651
|
+
latencyMs: 1,
|
|
3652
|
+
cached: true
|
|
3653
|
+
});
|
|
3654
|
+
return redactResult(cached);
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
try {
|
|
3658
|
+
const timeoutMs = this.config?.guardrails.callTimeoutMs ?? 3e4;
|
|
3659
|
+
const execution = await this.multiplexer.executeTool(toolName, args, timeoutMs);
|
|
3660
|
+
const isError = Boolean(execution.result.isError);
|
|
3661
|
+
loopBreaker.recordCall(serverName, originalName, args, isError);
|
|
3662
|
+
if (!isError) {
|
|
3663
|
+
toolRegistry.touchTool(toolName);
|
|
3664
|
+
}
|
|
3665
|
+
if (isError) {
|
|
3666
|
+
} else if (isReadOnly && this.config?.guardrails.enableCache !== false) {
|
|
3667
|
+
responseCache.set(serverName, originalName, args, execution.result);
|
|
3668
|
+
} else {
|
|
3669
|
+
responseCache.invalidateServer(serverName);
|
|
3670
|
+
}
|
|
3671
|
+
metricsCollector.recordExecution({
|
|
3672
|
+
toolName,
|
|
3673
|
+
serverName,
|
|
3674
|
+
latencyMs: execution.latencyMs,
|
|
3675
|
+
cached: false,
|
|
3676
|
+
isError
|
|
3677
|
+
});
|
|
3678
|
+
return redactResult(execution.result);
|
|
3679
|
+
} catch (err) {
|
|
3680
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3681
|
+
loopBreaker.recordCall(serverName, originalName, args, true);
|
|
3682
|
+
metricsCollector.recordExecution({
|
|
3683
|
+
toolName,
|
|
3684
|
+
serverName,
|
|
3685
|
+
latencyMs: 10,
|
|
3686
|
+
cached: false,
|
|
3687
|
+
isError: true
|
|
3688
|
+
});
|
|
3689
|
+
return {
|
|
3690
|
+
isError: true,
|
|
3691
|
+
content: [
|
|
3692
|
+
{
|
|
3693
|
+
type: "text",
|
|
3694
|
+
text: redactionFilter.redact(`Upstream error executing "${toolName}": ${msg}`)
|
|
3695
|
+
}
|
|
3696
|
+
]
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
/**
|
|
3701
|
+
* Starts ContextWise proxy using given configuration.
|
|
3702
|
+
*/
|
|
3703
|
+
async start(config) {
|
|
3704
|
+
this.config = config;
|
|
3705
|
+
this.isRunning = true;
|
|
3706
|
+
logger.setLevel(config.proxy.logLevel);
|
|
3707
|
+
logger.info("Starting ContextWise MCP Proxy Gateway...");
|
|
3708
|
+
this.router.updateConfig(config.routing);
|
|
3709
|
+
responseCache.setTtlSeconds(config.guardrails.cacheTtlSeconds);
|
|
3710
|
+
loopBreaker.updateConfig({
|
|
3711
|
+
maxCallsPerMinute: config.guardrails.maxCallsPerMinute,
|
|
3712
|
+
consecutiveFailureThreshold: config.guardrails.loopBreakerThreshold
|
|
3713
|
+
});
|
|
3714
|
+
await this.multiplexer.connectAll(config.upstreams);
|
|
3715
|
+
const allTools = this.multiplexer.getAllTools();
|
|
3716
|
+
toolRegistry.setAllTools(allTools);
|
|
3717
|
+
this.multiplexer.on("toolsChanged", async (_serverName, updatedTools) => {
|
|
3718
|
+
toolRegistry.setAllTools(updatedTools);
|
|
3719
|
+
try {
|
|
3720
|
+
await this.server.sendToolListChanged();
|
|
3721
|
+
} catch (err) {
|
|
3722
|
+
logger.debug(`Notification push error: ${err}`);
|
|
3723
|
+
}
|
|
3724
|
+
});
|
|
3725
|
+
this.router.primeWorkspace(process.cwd());
|
|
3726
|
+
this.transport = new StdioServerTransport();
|
|
3727
|
+
await this.server.connect(this.transport);
|
|
3728
|
+
logger.info("ContextWise MCP Proxy is running and ready for client connections.");
|
|
3729
|
+
}
|
|
3730
|
+
/**
|
|
3731
|
+
* Gracefully shuts down proxy and upstream connections.
|
|
3732
|
+
*/
|
|
3733
|
+
async stop() {
|
|
3734
|
+
if (!this.isRunning) return;
|
|
3735
|
+
this.isRunning = false;
|
|
3736
|
+
metricsCollector.flushSync();
|
|
3737
|
+
logger.info("Shutting down ContextWise proxy...");
|
|
3738
|
+
await this.multiplexer.closeAll();
|
|
3739
|
+
if (this.transport) {
|
|
3740
|
+
await this.transport.close();
|
|
3741
|
+
}
|
|
3742
|
+
await this.server.close();
|
|
3743
|
+
logger.info("ContextWise proxy stopped cleanly.");
|
|
3744
|
+
}
|
|
3745
|
+
getMultiplexer() {
|
|
3746
|
+
return this.multiplexer;
|
|
3747
|
+
}
|
|
3748
|
+
getRouter() {
|
|
3749
|
+
return this.router;
|
|
3750
|
+
}
|
|
3751
|
+
};
|
|
3752
|
+
var proxy = new ContextWiseProxy();
|
|
3753
|
+
|
|
3754
|
+
// src/cloud/client.ts
|
|
3755
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
3756
|
+
import { homedir as homedir6 } from "os";
|
|
3757
|
+
import { join as join6 } from "path";
|
|
3758
|
+
var ContextWiseCloudClient = class {
|
|
3759
|
+
apiUrl;
|
|
3760
|
+
storageDir;
|
|
3761
|
+
allowOfflineSimulation;
|
|
3762
|
+
token = null;
|
|
3763
|
+
constructor(options = {}) {
|
|
3764
|
+
this.apiUrl = options.apiUrl || process.env.CONTEXTWISE_API_URL || "https://contextwise.dev";
|
|
3765
|
+
this.storageDir = options.storageDir || process.env.CONTEXTWISE_STORAGE_DIR || join6(homedir6(), ".contextwise");
|
|
3766
|
+
this.allowOfflineSimulation = options.allowOfflineSimulation ?? false;
|
|
3767
|
+
this.loadToken();
|
|
3768
|
+
}
|
|
3769
|
+
getTokenPath() {
|
|
3770
|
+
return join6(this.storageDir, "auth.json");
|
|
3771
|
+
}
|
|
3772
|
+
loadToken() {
|
|
3773
|
+
const tokenPath = this.getTokenPath();
|
|
3774
|
+
if (existsSync8(tokenPath)) {
|
|
3775
|
+
try {
|
|
3776
|
+
const raw = readFileSync7(tokenPath, "utf-8");
|
|
3777
|
+
this.token = JSON.parse(raw);
|
|
3778
|
+
} catch {
|
|
3779
|
+
this.token = null;
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
saveToken(token) {
|
|
3784
|
+
if (!existsSync8(this.storageDir)) {
|
|
3785
|
+
mkdirSync5(this.storageDir, { recursive: true });
|
|
3786
|
+
}
|
|
3787
|
+
this.token = token;
|
|
3788
|
+
writeFileSync5(this.getTokenPath(), JSON.stringify(token, null, 2), {
|
|
3789
|
+
mode: 384,
|
|
3790
|
+
encoding: "utf-8"
|
|
3791
|
+
});
|
|
3792
|
+
}
|
|
3793
|
+
clearToken() {
|
|
3794
|
+
this.token = null;
|
|
3795
|
+
const tokenPath = this.getTokenPath();
|
|
3796
|
+
if (existsSync8(tokenPath)) {
|
|
3797
|
+
try {
|
|
3798
|
+
unlinkSync2(tokenPath);
|
|
3799
|
+
} catch {
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
getToken() {
|
|
3804
|
+
if (!this.token) {
|
|
3805
|
+
this.loadToken();
|
|
3806
|
+
}
|
|
3807
|
+
return this.token;
|
|
3808
|
+
}
|
|
3809
|
+
isAuthenticated() {
|
|
3810
|
+
const token = this.getToken();
|
|
3811
|
+
if (!token) return false;
|
|
3812
|
+
return token.expiresAt > Date.now();
|
|
3813
|
+
}
|
|
3814
|
+
/**
|
|
3815
|
+
* Starts RFC 8628 device authorization flow.
|
|
3816
|
+
*/
|
|
3817
|
+
async startDeviceFlow() {
|
|
3818
|
+
const res = await fetch(`${this.apiUrl}/v1/auth/device/code`, {
|
|
3819
|
+
method: "POST",
|
|
3820
|
+
headers: { "Content-Type": "application/json" }
|
|
3821
|
+
});
|
|
3822
|
+
if (!res.ok) {
|
|
3823
|
+
throw new Error(`Failed to initiate device login: HTTP ${res.status}`);
|
|
3824
|
+
}
|
|
3825
|
+
return await res.json();
|
|
3826
|
+
}
|
|
3827
|
+
/**
|
|
3828
|
+
* Polls device token until user approves the code in their browser.
|
|
3829
|
+
*/
|
|
3830
|
+
async pollDeviceToken(deviceCode, intervalSeconds = 5, maxWaitMs = 15 * 60 * 1e3) {
|
|
3831
|
+
const start = Date.now();
|
|
3832
|
+
const intervalMs = Math.max(intervalSeconds * 1e3, 2e3);
|
|
3833
|
+
while (Date.now() - start < maxWaitMs) {
|
|
3834
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
3835
|
+
try {
|
|
3836
|
+
const res = await fetch(`${this.apiUrl}/v1/auth/device/token`, {
|
|
3837
|
+
method: "POST",
|
|
3838
|
+
headers: { "Content-Type": "application/json" },
|
|
3839
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
3840
|
+
});
|
|
3841
|
+
if (res.ok) {
|
|
3842
|
+
const data = await res.json();
|
|
3843
|
+
const authToken = {
|
|
3844
|
+
token: data.token,
|
|
3845
|
+
userId: data.userId,
|
|
3846
|
+
email: data.email,
|
|
3847
|
+
expiresAt: data.expiresAt
|
|
3848
|
+
};
|
|
3849
|
+
this.saveToken(authToken);
|
|
3850
|
+
return authToken;
|
|
3851
|
+
}
|
|
3852
|
+
if (res.status === 428) {
|
|
3853
|
+
continue;
|
|
3854
|
+
}
|
|
3855
|
+
const errData = await res.json().catch(() => ({}));
|
|
3856
|
+
throw new Error(errData.message || `Device authorization failed: HTTP ${res.status}`);
|
|
3857
|
+
} catch (err) {
|
|
3858
|
+
if (err instanceof Error && err.message.includes("Device authorization failed")) {
|
|
3859
|
+
throw err;
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
throw new Error("Device authorization timed out. Please try logging in again.");
|
|
3864
|
+
}
|
|
3865
|
+
/**
|
|
3866
|
+
* Authenticates using a user API token or personal access key.
|
|
3867
|
+
*/
|
|
3868
|
+
async loginWithKey(apiKey) {
|
|
3869
|
+
const trimmed = apiKey.trim();
|
|
3870
|
+
if (!trimmed) {
|
|
3871
|
+
throw new Error("API key cannot be empty");
|
|
3872
|
+
}
|
|
3873
|
+
try {
|
|
3874
|
+
const res = await fetch(`${this.apiUrl}/v1/auth/verify`, {
|
|
3875
|
+
method: "POST",
|
|
3876
|
+
headers: {
|
|
3877
|
+
"Content-Type": "application/json",
|
|
3878
|
+
Authorization: `Bearer ${trimmed}`
|
|
3879
|
+
}
|
|
3880
|
+
});
|
|
3881
|
+
if (!res.ok) {
|
|
3882
|
+
if (res.status === 401 || res.status === 403) {
|
|
3883
|
+
throw new Error("Invalid or expired ContextWise Cloud API key.");
|
|
3884
|
+
}
|
|
3885
|
+
throw new Error(`Cloud API returned HTTP ${res.status}: ${res.statusText}`);
|
|
3886
|
+
}
|
|
3887
|
+
const data = await res.json();
|
|
3888
|
+
const authToken = {
|
|
3889
|
+
token: trimmed,
|
|
3890
|
+
userId: data.userId,
|
|
3891
|
+
email: data.email,
|
|
3892
|
+
expiresAt: data.expiresAt || Date.now() + 30 * 24 * 3600 * 1e3
|
|
3893
|
+
};
|
|
3894
|
+
this.saveToken(authToken);
|
|
3895
|
+
return authToken;
|
|
3896
|
+
} catch (err) {
|
|
3897
|
+
if (this.allowOfflineSimulation && (trimmed.startsWith("cw_test_") || trimmed.startsWith("cw_live_"))) {
|
|
3898
|
+
const dummyToken = {
|
|
3899
|
+
token: trimmed,
|
|
3900
|
+
userId: "usr_dev_" + trimmed.slice(-6),
|
|
3901
|
+
email: "developer@contextwise.dev",
|
|
3902
|
+
expiresAt: Date.now() + 30 * 24 * 3600 * 1e3
|
|
3903
|
+
};
|
|
3904
|
+
this.saveToken(dummyToken);
|
|
3905
|
+
return dummyToken;
|
|
3906
|
+
}
|
|
3907
|
+
throw err;
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
/**
|
|
3911
|
+
* Retrieves profile information for current authenticated account.
|
|
3912
|
+
*/
|
|
3913
|
+
async whoami() {
|
|
3914
|
+
const token = this.getToken();
|
|
3915
|
+
if (!token) {
|
|
3916
|
+
throw new Error('Not logged in. Run "contextwise login" first.');
|
|
3917
|
+
}
|
|
3918
|
+
try {
|
|
3919
|
+
const res = await fetch(`${this.apiUrl}/v1/user/profile`, {
|
|
3920
|
+
headers: { Authorization: `Bearer ${token.token}` }
|
|
3921
|
+
});
|
|
3922
|
+
if (res.ok) {
|
|
3923
|
+
return await res.json();
|
|
3924
|
+
}
|
|
3925
|
+
if (!this.allowOfflineSimulation) {
|
|
3926
|
+
throw new Error(`Failed to fetch profile: HTTP ${res.status} ${res.statusText}`);
|
|
3927
|
+
}
|
|
3928
|
+
} catch (err) {
|
|
3929
|
+
if (!this.allowOfflineSimulation) {
|
|
3930
|
+
throw err;
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
return {
|
|
3934
|
+
userId: token.userId,
|
|
3935
|
+
email: token.email,
|
|
3936
|
+
plan: "free",
|
|
3937
|
+
subscriptionStatus: "inactive",
|
|
3938
|
+
currentPeriodEnd: null,
|
|
3939
|
+
workspaces: [
|
|
3940
|
+
{
|
|
3941
|
+
id: "ws_default",
|
|
3942
|
+
name: "Personal Workspace",
|
|
3943
|
+
role: "owner",
|
|
3944
|
+
activeRevision: 1,
|
|
3945
|
+
updatedAt: Date.now()
|
|
3946
|
+
}
|
|
3947
|
+
],
|
|
3948
|
+
devices: []
|
|
3949
|
+
};
|
|
3950
|
+
}
|
|
3951
|
+
/**
|
|
3952
|
+
* Initiates Stripe Checkout session for Pro or Team upgrade.
|
|
3953
|
+
*/
|
|
3954
|
+
async createCheckoutSession(plan = "pro", interval = "month") {
|
|
3955
|
+
const token = this.getToken();
|
|
3956
|
+
if (!token) {
|
|
3957
|
+
throw new Error('Not logged in. Run "contextwise login" first.');
|
|
3958
|
+
}
|
|
3959
|
+
const res = await fetch(`${this.apiUrl}/v1/billing/checkout`, {
|
|
3960
|
+
method: "POST",
|
|
3961
|
+
headers: {
|
|
3962
|
+
"Content-Type": "application/json",
|
|
3963
|
+
Authorization: `Bearer ${token.token}`
|
|
3964
|
+
},
|
|
3965
|
+
body: JSON.stringify({ plan, interval })
|
|
3966
|
+
});
|
|
3967
|
+
if (!res.ok) {
|
|
3968
|
+
const errData = await res.json().catch(() => ({}));
|
|
3969
|
+
throw new Error(errData.message || `Failed to create checkout session: HTTP ${res.status}`);
|
|
3970
|
+
}
|
|
3971
|
+
return await res.json();
|
|
3972
|
+
}
|
|
3973
|
+
/**
|
|
3974
|
+
* Generates Stripe Customer Portal session to manage subscription and invoices.
|
|
3975
|
+
*/
|
|
3976
|
+
async createPortalSession() {
|
|
3977
|
+
const token = this.getToken();
|
|
3978
|
+
if (!token) {
|
|
3979
|
+
throw new Error('Not logged in. Run "contextwise login" first.');
|
|
3980
|
+
}
|
|
3981
|
+
const res = await fetch(`${this.apiUrl}/v1/billing/portal`, {
|
|
3982
|
+
method: "POST",
|
|
3983
|
+
headers: {
|
|
3984
|
+
"Content-Type": "application/json",
|
|
3985
|
+
Authorization: `Bearer ${token.token}`
|
|
3986
|
+
}
|
|
3987
|
+
});
|
|
3988
|
+
if (!res.ok) {
|
|
3989
|
+
const errData = await res.json().catch(() => ({}));
|
|
3990
|
+
throw new Error(errData.message || `Failed to open billing portal: HTTP ${res.status}`);
|
|
3991
|
+
}
|
|
3992
|
+
return await res.json();
|
|
3993
|
+
}
|
|
3994
|
+
/**
|
|
3995
|
+
* Pushes a workspace snapshot (config + encrypted vault) to the Cloudflare Workers / D1 backend.
|
|
3996
|
+
*/
|
|
3997
|
+
async pushSync(payload) {
|
|
3998
|
+
const token = this.getToken();
|
|
3999
|
+
if (!token) {
|
|
4000
|
+
throw new Error('Not logged in. Run "contextwise login" first.');
|
|
4001
|
+
}
|
|
4002
|
+
try {
|
|
4003
|
+
const res = await fetch(`${this.apiUrl}/v1/sync/push`, {
|
|
4004
|
+
method: "POST",
|
|
4005
|
+
headers: {
|
|
4006
|
+
"Content-Type": "application/json",
|
|
4007
|
+
Authorization: `Bearer ${token.token}`
|
|
4008
|
+
},
|
|
4009
|
+
body: JSON.stringify(payload)
|
|
4010
|
+
});
|
|
4011
|
+
if (res.ok) {
|
|
4012
|
+
return await res.json();
|
|
4013
|
+
}
|
|
4014
|
+
if (res.status === 402) {
|
|
4015
|
+
const payData = await res.json().catch(() => ({}));
|
|
4016
|
+
throw new Error(
|
|
4017
|
+
`${payData.message || "Cloud Sync requires an upgraded subscription."}
|
|
4018
|
+
Upgrade at: ${payData.upgradeUrl || "https://contextwise.dev/#pricing"}`
|
|
4019
|
+
);
|
|
4020
|
+
}
|
|
4021
|
+
if (res.status === 409) {
|
|
4022
|
+
const conflictData = await res.json();
|
|
4023
|
+
return {
|
|
4024
|
+
status: "conflict",
|
|
4025
|
+
revision: payload.baseRevision,
|
|
4026
|
+
serverRevision: conflictData.serverRevision,
|
|
4027
|
+
serverPayload: conflictData.serverPayload
|
|
4028
|
+
};
|
|
4029
|
+
}
|
|
4030
|
+
throw new Error(`Push sync failed: HTTP ${res.status} ${res.statusText}`);
|
|
4031
|
+
} catch (err) {
|
|
4032
|
+
if (err instanceof Error && (err.message.includes("requires an upgraded subscription") || err.message.includes("Cloud Sync requires"))) {
|
|
4033
|
+
throw err;
|
|
4034
|
+
}
|
|
4035
|
+
if (this.allowOfflineSimulation) {
|
|
4036
|
+
logger.debug(`Cloud API push endpoint unreachable (${err}). Using local snapshot commit.`);
|
|
4037
|
+
return {
|
|
4038
|
+
status: "committed",
|
|
4039
|
+
revision: payload.baseRevision + 1
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
throw err;
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
4045
|
+
/**
|
|
4046
|
+
* Pulls the latest workspace snapshot (config + encrypted vault) from the cloud backend.
|
|
4047
|
+
*/
|
|
4048
|
+
async pullSync(workspaceId, sinceRevision = 0) {
|
|
4049
|
+
const token = this.getToken();
|
|
4050
|
+
if (!token) {
|
|
4051
|
+
throw new Error('Not logged in. Run "contextwise login" first.');
|
|
4052
|
+
}
|
|
4053
|
+
try {
|
|
4054
|
+
const res = await fetch(
|
|
4055
|
+
`${this.apiUrl}/v1/sync/pull?workspaceId=${encodeURIComponent(
|
|
4056
|
+
workspaceId
|
|
4057
|
+
)}&since=${sinceRevision}`,
|
|
4058
|
+
{
|
|
4059
|
+
headers: { Authorization: `Bearer ${token.token}` }
|
|
4060
|
+
}
|
|
4061
|
+
);
|
|
4062
|
+
if (res.ok) {
|
|
4063
|
+
return await res.json();
|
|
4064
|
+
}
|
|
4065
|
+
if (res.status === 304) {
|
|
4066
|
+
return null;
|
|
4067
|
+
}
|
|
4068
|
+
} catch (err) {
|
|
4069
|
+
logger.debug(`Cloud API pull endpoint unreachable: ${err}`);
|
|
4070
|
+
}
|
|
4071
|
+
return null;
|
|
4072
|
+
}
|
|
4073
|
+
};
|
|
4074
|
+
var cloudClient = new ContextWiseCloudClient();
|
|
4075
|
+
|
|
4076
|
+
// src/cloud/crypto.ts
|
|
4077
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
|
|
4078
|
+
import { homedir as homedir7 } from "os";
|
|
4079
|
+
import { join as join7 } from "path";
|
|
4080
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
4081
|
+
function createEnvelope(workspaceKey, recipientPublicKeyPem) {
|
|
4082
|
+
const ephemeral = generateKeyPairX25519();
|
|
4083
|
+
const sharedKey = deriveSharedSecretX25519(ephemeral.privateKey, recipientPublicKeyPem);
|
|
4084
|
+
const { iv, authTag, ciphertext } = encryptAesGcm(
|
|
4085
|
+
workspaceKey.toString("base64"),
|
|
4086
|
+
sharedKey
|
|
4087
|
+
);
|
|
4088
|
+
return {
|
|
4089
|
+
recipientPublicKey: recipientPublicKeyPem,
|
|
4090
|
+
ephemeralPublicKey: ephemeral.publicKey,
|
|
4091
|
+
iv,
|
|
4092
|
+
authTag,
|
|
4093
|
+
ciphertext
|
|
4094
|
+
};
|
|
4095
|
+
}
|
|
4096
|
+
function openEnvelope(envelope, recipientPrivateKeyPem) {
|
|
4097
|
+
const sharedKey = deriveSharedSecretX25519(
|
|
4098
|
+
recipientPrivateKeyPem,
|
|
4099
|
+
envelope.ephemeralPublicKey
|
|
4100
|
+
);
|
|
4101
|
+
const decryptedBase64 = decryptAesGcm(
|
|
4102
|
+
envelope.ciphertext,
|
|
4103
|
+
sharedKey,
|
|
4104
|
+
envelope.iv,
|
|
4105
|
+
envelope.authTag
|
|
4106
|
+
);
|
|
4107
|
+
return Buffer.from(decryptedBase64, "base64");
|
|
4108
|
+
}
|
|
4109
|
+
function getOrCreateDeviceIdentity(storageDir) {
|
|
4110
|
+
const dir = storageDir || process.env.CONTEXTWISE_STORAGE_DIR || join7(homedir7(), ".contextwise");
|
|
4111
|
+
const filePath = join7(dir, "device.json");
|
|
4112
|
+
if (existsSync9(filePath)) {
|
|
4113
|
+
try {
|
|
4114
|
+
const data = JSON.parse(readFileSync8(filePath, "utf-8"));
|
|
4115
|
+
if (data.deviceId && data.publicKey && data.privateKey) {
|
|
4116
|
+
return data;
|
|
4117
|
+
}
|
|
4118
|
+
} catch {
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
if (!existsSync9(dir)) {
|
|
4122
|
+
mkdirSync6(dir, { recursive: true });
|
|
4123
|
+
}
|
|
4124
|
+
const { publicKey, privateKey } = generateKeyPairX25519();
|
|
4125
|
+
const deviceId = `cw_dev_${randomBytes2(8).toString("hex")}`;
|
|
4126
|
+
const identity = {
|
|
4127
|
+
deviceId,
|
|
4128
|
+
publicKey,
|
|
4129
|
+
privateKey
|
|
4130
|
+
};
|
|
4131
|
+
writeFileSync6(filePath, JSON.stringify(identity, null, 2), {
|
|
4132
|
+
mode: 384,
|
|
4133
|
+
encoding: "utf-8"
|
|
4134
|
+
});
|
|
4135
|
+
return identity;
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
// src/cloud/sync_manager.ts
|
|
4139
|
+
import { existsSync as existsSync10, readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "fs";
|
|
4140
|
+
import { homedir as homedir8 } from "os";
|
|
4141
|
+
import { join as join8 } from "path";
|
|
4142
|
+
var SyncManager = class {
|
|
4143
|
+
syncStatePath;
|
|
4144
|
+
localConfigPath;
|
|
4145
|
+
constructor(storageDir, localConfigPath) {
|
|
4146
|
+
const dir = storageDir || process.env.CONTEXTWISE_STORAGE_DIR || join8(homedir8(), ".contextwise");
|
|
4147
|
+
this.syncStatePath = join8(dir, "sync.json");
|
|
4148
|
+
this.localConfigPath = localConfigPath;
|
|
4149
|
+
}
|
|
4150
|
+
loadSyncState() {
|
|
4151
|
+
if (existsSync10(this.syncStatePath)) {
|
|
4152
|
+
try {
|
|
4153
|
+
const raw = readFileSync9(this.syncStatePath, "utf-8");
|
|
4154
|
+
return JSON.parse(raw);
|
|
4155
|
+
} catch {
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
return {
|
|
4159
|
+
workspaceId: "ws_default",
|
|
4160
|
+
revision: 0,
|
|
4161
|
+
lastSyncedAt: 0
|
|
4162
|
+
};
|
|
4163
|
+
}
|
|
4164
|
+
saveSyncState(state) {
|
|
4165
|
+
writeFileSync7(this.syncStatePath, JSON.stringify(state, null, 2), {
|
|
4166
|
+
mode: 384,
|
|
4167
|
+
encoding: "utf-8"
|
|
4168
|
+
});
|
|
4169
|
+
}
|
|
4170
|
+
/**
|
|
4171
|
+
* Pushes local configuration and encrypted vault payload to the cloud.
|
|
4172
|
+
*/
|
|
4173
|
+
async push(workspaceId) {
|
|
4174
|
+
const state = this.loadSyncState();
|
|
4175
|
+
const wsId = workspaceId || state.workspaceId;
|
|
4176
|
+
const device = getOrCreateDeviceIdentity();
|
|
4177
|
+
const localConfig = ConfigLoader.load();
|
|
4178
|
+
const vaultPath = getDefaultVaultFilePath();
|
|
4179
|
+
let encryptedVault;
|
|
4180
|
+
if (existsSync10(vaultPath)) {
|
|
4181
|
+
try {
|
|
4182
|
+
const rawVault = readFileSync9(vaultPath, "utf-8");
|
|
4183
|
+
encryptedVault = JSON.parse(rawVault);
|
|
4184
|
+
} catch {
|
|
4185
|
+
encryptedVault = this.createEmptyEncryptedVault();
|
|
4186
|
+
}
|
|
4187
|
+
} else {
|
|
4188
|
+
encryptedVault = this.createEmptyEncryptedVault();
|
|
4189
|
+
}
|
|
4190
|
+
const payload = {
|
|
4191
|
+
workspaceId: wsId,
|
|
4192
|
+
deviceId: device.deviceId,
|
|
4193
|
+
baseRevision: state.revision,
|
|
4194
|
+
config: localConfig,
|
|
4195
|
+
encryptedVault,
|
|
4196
|
+
timestamp: Date.now()
|
|
4197
|
+
};
|
|
4198
|
+
const result = await cloudClient.pushSync(payload);
|
|
4199
|
+
if (result.status === "committed") {
|
|
4200
|
+
state.workspaceId = wsId;
|
|
4201
|
+
state.revision = result.revision;
|
|
4202
|
+
state.lastSyncedAt = Date.now();
|
|
4203
|
+
this.saveSyncState(state);
|
|
4204
|
+
logger.info(`Successfully pushed revision #${result.revision} to ContextWise Cloud.`);
|
|
4205
|
+
}
|
|
4206
|
+
return result;
|
|
4207
|
+
}
|
|
4208
|
+
/**
|
|
4209
|
+
* Pulls the latest cloud configuration and merges upstream servers.
|
|
4210
|
+
*/
|
|
4211
|
+
async pull(workspaceId) {
|
|
4212
|
+
const state = this.loadSyncState();
|
|
4213
|
+
const wsId = workspaceId || state.workspaceId;
|
|
4214
|
+
const pullResult = await cloudClient.pullSync(wsId, state.revision);
|
|
4215
|
+
if (!pullResult) {
|
|
4216
|
+
logger.info("Local configuration is already up to date.");
|
|
4217
|
+
return null;
|
|
4218
|
+
}
|
|
4219
|
+
if (pullResult.config && pullResult.config.upstreams) {
|
|
4220
|
+
this.mergeConfigIntoLocal(pullResult.config);
|
|
4221
|
+
}
|
|
4222
|
+
if (pullResult.encryptedVault && pullResult.encryptedVault.data) {
|
|
4223
|
+
const vaultPath = getDefaultVaultFilePath();
|
|
4224
|
+
writeFileSync7(vaultPath, JSON.stringify(pullResult.encryptedVault, null, 2), {
|
|
4225
|
+
mode: 384,
|
|
4226
|
+
encoding: "utf-8"
|
|
4227
|
+
});
|
|
4228
|
+
}
|
|
4229
|
+
state.revision = pullResult.revision;
|
|
4230
|
+
state.lastSyncedAt = Date.now();
|
|
4231
|
+
this.saveSyncState(state);
|
|
4232
|
+
logger.info(`Pulled and applied cloud revision #${pullResult.revision}.`);
|
|
4233
|
+
return pullResult;
|
|
4234
|
+
}
|
|
4235
|
+
/**
|
|
4236
|
+
* Semantically merges remote configuration into local contextwise.json.
|
|
4237
|
+
*/
|
|
4238
|
+
mergeConfigIntoLocal(remoteConfig) {
|
|
4239
|
+
const validatedRemote = ContextWiseConfigSchema.parse(remoteConfig);
|
|
4240
|
+
const configPath = this.localConfigPath || process.env.CONTEXTWISE_CONFIG || join8(process.cwd(), "contextwise.json");
|
|
4241
|
+
let localConfig;
|
|
4242
|
+
if (existsSync10(configPath)) {
|
|
4243
|
+
try {
|
|
4244
|
+
localConfig = ContextWiseConfigSchema.parse(JSON.parse(readFileSync9(configPath, "utf-8")));
|
|
4245
|
+
} catch {
|
|
4246
|
+
localConfig = ContextWiseConfigSchema.parse({});
|
|
4247
|
+
}
|
|
4248
|
+
} else {
|
|
4249
|
+
localConfig = ContextWiseConfigSchema.parse({});
|
|
4250
|
+
}
|
|
4251
|
+
for (const [name, s] of Object.entries(validatedRemote.upstreams || {})) {
|
|
4252
|
+
if (isStdioUpstream(s) && !localConfig.upstreams[name]) {
|
|
4253
|
+
logger.warn(
|
|
4254
|
+
`[Cloud Sync Security] Remote configuration contains new stdio upstream "${name}" (${s.command}). Verify this server in contextwise.json before execution.`
|
|
4255
|
+
);
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
const mergedUpstreams = {
|
|
4259
|
+
...validatedRemote.upstreams || {},
|
|
4260
|
+
...localConfig.upstreams || {}
|
|
4261
|
+
};
|
|
4262
|
+
const merged = {
|
|
4263
|
+
...localConfig,
|
|
4264
|
+
upstreams: mergedUpstreams
|
|
4265
|
+
};
|
|
4266
|
+
writeFileSync7(configPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
4267
|
+
}
|
|
4268
|
+
createEmptyEncryptedVault() {
|
|
4269
|
+
return {
|
|
4270
|
+
version: 1,
|
|
4271
|
+
kdf: {
|
|
4272
|
+
algorithm: "pbkdf2-sha512",
|
|
4273
|
+
salt: "empty",
|
|
4274
|
+
iterations: 5e4
|
|
4275
|
+
},
|
|
4276
|
+
cipher: "aes-256-gcm",
|
|
4277
|
+
iv: "",
|
|
4278
|
+
authTag: "",
|
|
4279
|
+
data: ""
|
|
4280
|
+
};
|
|
4281
|
+
}
|
|
4282
|
+
getStatus() {
|
|
4283
|
+
const token = cloudClient.getToken();
|
|
4284
|
+
const state = this.loadSyncState();
|
|
4285
|
+
const device = getOrCreateDeviceIdentity();
|
|
4286
|
+
return {
|
|
4287
|
+
isLoggedIn: cloudClient.isAuthenticated(),
|
|
4288
|
+
userEmail: token?.email,
|
|
4289
|
+
workspaceId: state.workspaceId,
|
|
4290
|
+
revision: state.revision,
|
|
4291
|
+
lastSyncedAt: state.lastSyncedAt,
|
|
4292
|
+
deviceId: device.deviceId
|
|
4293
|
+
};
|
|
4294
|
+
}
|
|
4295
|
+
};
|
|
4296
|
+
var syncManager = new SyncManager();
|
|
4297
|
+
|
|
4298
|
+
export {
|
|
4299
|
+
StdioUpstreamConfigSchema,
|
|
4300
|
+
HttpUpstreamConfigSchema,
|
|
4301
|
+
UpstreamServerConfigSchema,
|
|
4302
|
+
warnIfInsecureHttp,
|
|
4303
|
+
isStdioUpstream,
|
|
4304
|
+
isHttpUpstream,
|
|
4305
|
+
ProxyConfigSchema,
|
|
4306
|
+
RoutingConfigSchema,
|
|
4307
|
+
GuardrailsConfigSchema,
|
|
4308
|
+
ContextWiseConfigSchema,
|
|
4309
|
+
RedactionFilter,
|
|
4310
|
+
redactionFilter,
|
|
4311
|
+
Logger,
|
|
4312
|
+
logger,
|
|
4313
|
+
ConfigLoader,
|
|
4314
|
+
ProcessSupervisor,
|
|
4315
|
+
supervisor,
|
|
4316
|
+
encryptAesGcm,
|
|
4317
|
+
decryptAesGcm,
|
|
4318
|
+
VAULT_PBKDF2_ITERATIONS,
|
|
4319
|
+
deriveKeyFromPassphrase,
|
|
4320
|
+
getOrCreateMachineKey,
|
|
4321
|
+
generateKeyPairX25519,
|
|
4322
|
+
deriveSharedSecretX25519,
|
|
4323
|
+
getDefaultVaultFilePath,
|
|
4324
|
+
EncryptedFileVaultDriver,
|
|
4325
|
+
OsKeystoreDriver,
|
|
4326
|
+
SecretVault,
|
|
4327
|
+
secretVault,
|
|
4328
|
+
VaultAuditor,
|
|
4329
|
+
SecretResolver,
|
|
4330
|
+
escapeWindowsCmdArg,
|
|
4331
|
+
inferToolHints,
|
|
4332
|
+
UpstreamMultiplexer,
|
|
4333
|
+
ToolNormalizer,
|
|
4334
|
+
HybridSearchEngine,
|
|
4335
|
+
searchEngine,
|
|
4336
|
+
ToolRegistry,
|
|
4337
|
+
toolRegistry,
|
|
4338
|
+
ToolArgumentValidator,
|
|
4339
|
+
argumentValidator,
|
|
4340
|
+
canonicalJsonStringify,
|
|
4341
|
+
sha256,
|
|
4342
|
+
generateToolCallCacheKey,
|
|
4343
|
+
ResponseCache,
|
|
4344
|
+
responseCache,
|
|
4345
|
+
RunawayLoopBreaker,
|
|
4346
|
+
loopBreaker,
|
|
4347
|
+
SCHEMA_TOKENS_PER_TOOL,
|
|
4348
|
+
CACHE_TOKENS_PER_HIT,
|
|
4349
|
+
LOOP_PREVENTION_TOKENS_PER_TRIP,
|
|
4350
|
+
LLM_PRICING,
|
|
4351
|
+
calculateDollarSavings,
|
|
4352
|
+
getDefaultMetricsPath,
|
|
4353
|
+
MetricsCollector,
|
|
4354
|
+
metricsCollector,
|
|
4355
|
+
SEARCH_TOOLS_NAME,
|
|
4356
|
+
EXECUTE_TOOL_NAME,
|
|
4357
|
+
BROWSE_SERVERS_NAME,
|
|
4358
|
+
ADD_SERVER_NAME,
|
|
4359
|
+
SEARCH_TOOLS_DEFINITION,
|
|
4360
|
+
EXECUTE_TOOL_DEFINITION,
|
|
4361
|
+
BROWSE_SERVERS_DEFINITION,
|
|
4362
|
+
ADD_SERVER_DEFINITION,
|
|
4363
|
+
KNOWN_SERVERS,
|
|
4364
|
+
KnownServerRegistry,
|
|
4365
|
+
OfficialRegistryClient,
|
|
4366
|
+
SmitheryClient,
|
|
4367
|
+
UnifiedRegistryClient,
|
|
4368
|
+
ServerRecommender,
|
|
4369
|
+
ServerManager,
|
|
4370
|
+
WorkspaceContextPrimer,
|
|
4371
|
+
ContextRouter,
|
|
4372
|
+
contextRouter,
|
|
4373
|
+
ContextWiseProxy,
|
|
4374
|
+
proxy,
|
|
4375
|
+
ContextWiseCloudClient,
|
|
4376
|
+
cloudClient,
|
|
4377
|
+
createEnvelope,
|
|
4378
|
+
openEnvelope,
|
|
4379
|
+
getOrCreateDeviceIdentity,
|
|
4380
|
+
SyncManager,
|
|
4381
|
+
syncManager
|
|
4382
|
+
};
|
|
4383
|
+
//# sourceMappingURL=chunk-P7JW7EPW.js.map
|