@klhapp/skillmux 1.9.2 → 1.10.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/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/docs/README.md +1 -1
- package/docs/cli.md +74 -3
- package/docs/concepts.md +1 -1
- package/docs/configuration.md +52 -1
- package/docs/deployment.md +10 -6
- package/docs/getting-started.md +1 -1
- package/docs/skill-management.md +49 -0
- package/package.json +1 -1
- package/src/adapters.ts +148 -2
- package/src/cli.ts +302 -1291
- package/src/clients.ts +17 -0
- package/src/commands/audit.ts +54 -51
- package/src/commands/config.ts +11 -12
- package/src/commands/context.ts +103 -0
- package/src/commands/core.ts +5 -1
- package/src/commands/doctor.ts +76 -0
- package/src/commands/eval.ts +10 -13
- package/src/commands/init.ts +621 -0
- package/src/commands/install.ts +132 -0
- package/src/commands/local-vault.ts +60 -0
- package/src/commands/models.ts +10 -0
- package/src/commands/outdated.ts +8 -5
- package/src/commands/project.ts +37 -11
- package/src/commands/report.ts +66 -0
- package/src/commands/scan.ts +61 -0
- package/src/commands/skill.ts +32 -0
- package/src/commands/sync.ts +232 -0
- package/src/commands/target.ts +18 -6
- package/src/commands/update.ts +11 -5
- package/src/concurrency-limiter.ts +61 -0
- package/src/config-service.ts +1 -51
- package/src/config.ts +5 -0
- package/src/context.ts +8 -3
- package/src/db-audit.ts +286 -0
- package/src/db-index.ts +238 -0
- package/src/db.ts +3 -413
- package/src/global-flags.ts +46 -0
- package/src/install.ts +15 -0
- package/src/logger.ts +26 -0
- package/src/output.ts +30 -5
- package/src/redact.ts +52 -0
- package/src/router-core.ts +8 -27
- package/src/server.ts +594 -267
- package/src/toml-writer.ts +51 -0
- package/src/types.ts +7 -0
package/src/server.ts
CHANGED
|
@@ -16,11 +16,15 @@ import {
|
|
|
16
16
|
resolveSkill,
|
|
17
17
|
} from "./router-core";
|
|
18
18
|
import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
|
|
19
|
-
import { getStats, SINCE_PATTERN } from "./stats";
|
|
19
|
+
import { getStats, parseSince, SINCE_PATTERN } from "./stats";
|
|
20
|
+
import { countPrunable, insertAdminAuditRow, pruneAuditBefore, type AdminAuditChange } from "./db";
|
|
21
|
+
import { buildPromotedCases, evalVault, queryPromotableFetches } from "./eval";
|
|
20
22
|
import { SKILL_ID_PATTERN } from "./vault";
|
|
21
23
|
import { MetricsRegistry } from "./metrics";
|
|
22
24
|
import { ReadinessState } from "./readiness";
|
|
23
25
|
import { initializeRuntime } from "./lifecycle";
|
|
26
|
+
import { buildRedactor } from "./redact";
|
|
27
|
+
import { redactedErrorLog } from "./logger";
|
|
24
28
|
import type { Clients, Config } from "./types";
|
|
25
29
|
import {
|
|
26
30
|
computeHash,
|
|
@@ -34,8 +38,14 @@ import {
|
|
|
34
38
|
export const metricsRegistry = new MetricsRegistry();
|
|
35
39
|
export const readinessState = new ReadinessState();
|
|
36
40
|
|
|
41
|
+
// runtime-resource-hardening: positive defaults so the http transport is
|
|
42
|
+
// never unbounded by omission, unlike the opt-in [egress] allowlist.
|
|
43
|
+
const DEFAULT_MAX_BODY_BYTES = 1_048_576; // 1 MiB
|
|
44
|
+
const DEFAULT_MAX_CONCURRENT_REQUESTS = 100;
|
|
45
|
+
|
|
37
46
|
export interface ServerHandle {
|
|
38
47
|
port?: number;
|
|
48
|
+
statsPort?: number;
|
|
39
49
|
reloadStatus(): ReloadStatus;
|
|
40
50
|
stop(): Promise<void>;
|
|
41
51
|
}
|
|
@@ -98,6 +108,100 @@ export function safeTokenEquals(a: string, b: string): boolean {
|
|
|
98
108
|
return timingSafeEqual(hashA, hashB);
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
/**
|
|
112
|
+
* A second, narrow HTTP listener exposing only GET /health and GET /stats —
|
|
113
|
+
* nothing from the MCP tool surface or /admin/v1/*. Lets a stdio deployment
|
|
114
|
+
* (no HTTP transport at all) still answer `skillmux report --server ...`
|
|
115
|
+
* remotely, without switching its primary transport. Reuses the exact same
|
|
116
|
+
* bind-posture guard, auth-token check, and rate limiter as the http
|
|
117
|
+
* transport's /stats route (server.ts's main Bun.serve handler) rather than
|
|
118
|
+
* inventing a separate security model for this listener.
|
|
119
|
+
*/
|
|
120
|
+
async function serveStatsOnly(opts: {
|
|
121
|
+
config: Config;
|
|
122
|
+
port: number;
|
|
123
|
+
}): Promise<{ port: number | undefined; stop(): void }> {
|
|
124
|
+
const serverConfig = opts.config.server || {
|
|
125
|
+
auth_enabled: false,
|
|
126
|
+
auth_token_env: "SKILLMUX_AUTH_TOKEN",
|
|
127
|
+
allowed_origins: [],
|
|
128
|
+
};
|
|
129
|
+
const hostname = serverConfig.hostname ?? "127.0.0.1";
|
|
130
|
+
assertSafeBindPosture(hostname, serverConfig.auth_enabled ?? false);
|
|
131
|
+
|
|
132
|
+
const { RateLimiter } = await import("./rate-limiter");
|
|
133
|
+
const rateLimiter = new RateLimiter(
|
|
134
|
+
serverConfig.rate_limit || { enabled: false, requests_per_minute: 60 },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const bunServer = Bun.serve({
|
|
138
|
+
port: opts.port,
|
|
139
|
+
hostname,
|
|
140
|
+
async fetch(req, server) {
|
|
141
|
+
const rateLimitResult = rateLimiter.check({
|
|
142
|
+
nowMs: Date.now(),
|
|
143
|
+
auth_enabled: serverConfig.auth_enabled,
|
|
144
|
+
req,
|
|
145
|
+
server,
|
|
146
|
+
});
|
|
147
|
+
if (!rateLimitResult.allowed) {
|
|
148
|
+
return new Response("Too Many Requests", {
|
|
149
|
+
status: 429,
|
|
150
|
+
headers: rateLimitResult.headers,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const url = new URL(req.url);
|
|
155
|
+
// GET /health stays open (unauthenticated) even when auth_enabled — it
|
|
156
|
+
// carries no data, matching the http transport's /health, which returns
|
|
157
|
+
// before its own Token Auth Check for the same reason.
|
|
158
|
+
if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
|
|
159
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
160
|
+
status: 200,
|
|
161
|
+
headers: { "Content-Type": "application/json", ...rateLimitResult.headers },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (serverConfig.auth_enabled) {
|
|
166
|
+
const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
|
|
167
|
+
if (!expectedToken) {
|
|
168
|
+
return new Response(
|
|
169
|
+
"Server authentication configured but token environment variable is empty",
|
|
170
|
+
{ status: 500 },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const authHeader = req.headers.get("authorization") || "";
|
|
174
|
+
const token = authHeader.startsWith("Bearer ")
|
|
175
|
+
? authHeader.slice(7)
|
|
176
|
+
: authHeader;
|
|
177
|
+
if (!token || !safeTokenEquals(token, expectedToken)) {
|
|
178
|
+
return new Response("Unauthorized", { status: 401 });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (req.method === "GET" && url.pathname === "/stats") {
|
|
183
|
+
const since = url.searchParams.get("since") ?? "";
|
|
184
|
+
if (!SINCE_PATTERN.test(since)) {
|
|
185
|
+
return new Response(
|
|
186
|
+
JSON.stringify({
|
|
187
|
+
error: "since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
|
|
188
|
+
}),
|
|
189
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const { auditDb } = await getRuntime();
|
|
193
|
+
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
194
|
+
status: 200,
|
|
195
|
+
headers: { "Content-Type": "application/json", ...rateLimitResult.headers },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return new Response("Not Found", { status: 404 });
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return { port: bunServer.port, stop: () => bunServer.stop(true) };
|
|
203
|
+
}
|
|
204
|
+
|
|
101
205
|
export function createMcpServer(): McpServer {
|
|
102
206
|
const server = new McpServer({ name: "skillmux", version: "0.1.0" });
|
|
103
207
|
|
|
@@ -174,12 +278,14 @@ export function createMcpServer(): McpServer {
|
|
|
174
278
|
export async function startServer(opts?: {
|
|
175
279
|
transport?: "stdio" | "http";
|
|
176
280
|
port?: number;
|
|
281
|
+
statsPort?: number;
|
|
177
282
|
config?: Config;
|
|
178
283
|
clients?: Partial<Clients>;
|
|
179
284
|
configPath?: string;
|
|
180
285
|
}): Promise<ServerHandle> {
|
|
181
286
|
const configPath = resolveConfigPath(opts?.configPath);
|
|
182
287
|
const config = opts?.config ?? (await loadConfig(configPath));
|
|
288
|
+
const redact = buildRedactor(config);
|
|
183
289
|
const initialClients = { ...createClients(config), ...opts?.clients };
|
|
184
290
|
const snapshots = RuntimeSnapshotManager.create(config, initialClients);
|
|
185
291
|
const inactiveReloadStatus: ReloadStatus = {
|
|
@@ -204,18 +310,18 @@ export async function startServer(opts?: {
|
|
|
204
310
|
configure({ config: nextConfig, clients: nextClients });
|
|
205
311
|
},
|
|
206
312
|
onError: (error) =>
|
|
207
|
-
console.error("skillmux config reload error:", error),
|
|
313
|
+
console.error(...redactedErrorLog("skillmux config reload error:", error, redact)),
|
|
208
314
|
})
|
|
209
315
|
: undefined;
|
|
210
316
|
const stopWatcher = await startVaultWatcher();
|
|
211
317
|
const initPromise = initializeRuntime(readinessState)
|
|
212
318
|
.then(() => metricsRegistry.setReadiness(readinessState.get()))
|
|
213
|
-
.catch((err) => console.error("skillmux runtime init error:", err));
|
|
319
|
+
.catch((err) => console.error(...redactedErrorLog("skillmux runtime init error:", err, redact)));
|
|
214
320
|
|
|
215
321
|
// AC14: fire-and-forget so this never delays readiness or blocks a resolve;
|
|
216
322
|
// not chained onto initPromise, which is awaited below for HTTP transport.
|
|
217
323
|
const runAuditPrune = () =>
|
|
218
|
-
pruneAuditIfDue().catch((err) => console.error("skillmux audit prune error:", err));
|
|
324
|
+
pruneAuditIfDue().catch((err) => console.error(...redactedErrorLog("skillmux audit prune error:", err, redact)));
|
|
219
325
|
runAuditPrune();
|
|
220
326
|
const auditPruneInterval = setInterval(runAuditPrune, 24 * 60 * 60 * 1000);
|
|
221
327
|
auditPruneInterval.unref();
|
|
@@ -223,6 +329,16 @@ export async function startServer(opts?: {
|
|
|
223
329
|
const server = createMcpServer();
|
|
224
330
|
|
|
225
331
|
const transportType = opts?.transport ?? "stdio";
|
|
332
|
+
if (opts?.statsPort !== undefined && transportType === "http") {
|
|
333
|
+
throw new Error(
|
|
334
|
+
"skillmux: --stats-port is not supported with --transport http; the http transport already serves /stats on --port",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const statsHandle =
|
|
338
|
+
opts?.statsPort !== undefined
|
|
339
|
+
? await serveStatsOnly({ config, port: opts.statsPort })
|
|
340
|
+
: undefined;
|
|
341
|
+
|
|
226
342
|
if (transportType === "http") {
|
|
227
343
|
const { WebStandardStreamableHTTPServerTransport } =
|
|
228
344
|
await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
|
|
@@ -235,6 +351,10 @@ export async function startServer(opts?: {
|
|
|
235
351
|
const rateLimiter = new RateLimiter(
|
|
236
352
|
config.server?.rate_limit || { enabled: false, requests_per_minute: 60 },
|
|
237
353
|
);
|
|
354
|
+
const { ConcurrencyLimiter, releaseOnStreamClose } = await import("./concurrency-limiter");
|
|
355
|
+
const concurrencyLimiter = new ConcurrencyLimiter(
|
|
356
|
+
config.server?.max_concurrent_requests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
|
|
357
|
+
);
|
|
238
358
|
|
|
239
359
|
const port = opts?.port ?? Number(process.env.PORT || 3000);
|
|
240
360
|
const hostname = config.server?.hostname ?? "127.0.0.1";
|
|
@@ -242,319 +362,522 @@ export async function startServer(opts?: {
|
|
|
242
362
|
const bunServer = Bun.serve({
|
|
243
363
|
port,
|
|
244
364
|
hostname,
|
|
365
|
+
maxRequestBodySize: config.server?.max_body_bytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
245
366
|
async fetch(req, server) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
255
|
-
const allowOriginHeader = isAllowed
|
|
256
|
-
? allowedOrigins.includes("*")
|
|
257
|
-
? "*"
|
|
258
|
-
: origin
|
|
259
|
-
: "";
|
|
260
|
-
|
|
261
|
-
if (origin && !isAllowed) {
|
|
262
|
-
return new Response("CORS origin not allowed", { status: 403 });
|
|
367
|
+
// AC3: a positive bound on in-flight requests, checked before any other
|
|
368
|
+
// work — protects against connection exhaustion the same way
|
|
369
|
+
// maxRequestBodySize protects against a single oversized request.
|
|
370
|
+
if (!concurrencyLimiter.tryAcquire()) {
|
|
371
|
+
return new Response("Service Unavailable", {
|
|
372
|
+
status: 503,
|
|
373
|
+
headers: { "Retry-After": "1" },
|
|
374
|
+
});
|
|
263
375
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
376
|
+
try {
|
|
377
|
+
const res = await handleHttpRequest(req, server);
|
|
378
|
+
// Defer the release until the response body actually finishes —
|
|
379
|
+
// a buffered body drains almost immediately, but an open SSE
|
|
380
|
+
// stream (the MCP transport's notification channel and streaming
|
|
381
|
+
// replies) can stay open long after this async function returns,
|
|
382
|
+
// and the slot must reflect that real connection lifetime.
|
|
383
|
+
const body = releaseOnStreamClose(res.body, () => concurrencyLimiter.release());
|
|
384
|
+
return new Response(body, {
|
|
385
|
+
status: res.status,
|
|
386
|
+
statusText: res.statusText,
|
|
387
|
+
headers: res.headers,
|
|
273
388
|
});
|
|
389
|
+
} catch (error) {
|
|
390
|
+
concurrencyLimiter.release();
|
|
391
|
+
throw error;
|
|
274
392
|
}
|
|
275
393
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// Non-JSON or parsing error
|
|
394
|
+
async function handleHttpRequest(
|
|
395
|
+
req: Request,
|
|
396
|
+
server: { requestIP(request: Request): { address: string } | null },
|
|
397
|
+
): Promise<Response> {
|
|
398
|
+
const serverConfig = config.server || {
|
|
399
|
+
auth_enabled: false,
|
|
400
|
+
auth_token_env: "SKILLMUX_AUTH_TOKEN",
|
|
401
|
+
allowed_origins: [],
|
|
402
|
+
};
|
|
403
|
+
const origin = req.headers.get("origin") || "";
|
|
404
|
+
const allowedOrigins = serverConfig.allowed_origins || [];
|
|
405
|
+
const isAllowed =
|
|
406
|
+
allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
407
|
+
const allowOriginHeader = isAllowed
|
|
408
|
+
? allowedOrigins.includes("*")
|
|
409
|
+
? "*"
|
|
410
|
+
: origin
|
|
411
|
+
: "";
|
|
412
|
+
|
|
413
|
+
if (origin && !isAllowed) {
|
|
414
|
+
return new Response("CORS origin not allowed", { status: 403 });
|
|
298
415
|
}
|
|
299
|
-
metricsRegistry.recordRequest(mcpMethod);
|
|
300
416
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
417
|
+
if (req.method === "OPTIONS") {
|
|
418
|
+
return new Response(null, {
|
|
419
|
+
headers: {
|
|
420
|
+
"Access-Control-Allow-Origin": allowOriginHeader,
|
|
421
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
422
|
+
"Access-Control-Allow-Headers":
|
|
423
|
+
"Content-Type, Authorization, MCP-Protocol-Version",
|
|
424
|
+
},
|
|
425
|
+
});
|
|
304
426
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
427
|
+
|
|
428
|
+
// Run rate limiter check
|
|
429
|
+
const rateLimitResult = rateLimiter.check({
|
|
430
|
+
nowMs: Date.now(),
|
|
431
|
+
auth_enabled: serverConfig.auth_enabled,
|
|
432
|
+
req,
|
|
433
|
+
server,
|
|
308
434
|
});
|
|
309
|
-
}
|
|
310
435
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
436
|
+
if (!rateLimitResult.allowed) {
|
|
437
|
+
metricsRegistry.recordRateLimitExceeded();
|
|
438
|
+
|
|
439
|
+
// Count the request in requests_total under the method if possible
|
|
440
|
+
let mcpMethod = "unknown";
|
|
441
|
+
try {
|
|
442
|
+
const bodyClone = await req.clone().json();
|
|
443
|
+
if (bodyClone.method === "tools/call") {
|
|
444
|
+
mcpMethod = bodyClone.params?.name || "tools/call";
|
|
445
|
+
} else {
|
|
446
|
+
mcpMethod = bodyClone.method || "unknown";
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
// Non-JSON or parsing error
|
|
450
|
+
}
|
|
451
|
+
metricsRegistry.recordRequest(mcpMethod);
|
|
452
|
+
|
|
453
|
+
const headers = new Headers(rateLimitResult.headers);
|
|
315
454
|
if (allowOriginHeader) {
|
|
316
455
|
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
317
456
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
)) {
|
|
321
|
-
headers.set(key, value);
|
|
322
|
-
}
|
|
323
|
-
return new Response(JSON.stringify({ status: "ok" }), {
|
|
324
|
-
status: 200,
|
|
457
|
+
return new Response("Too Many Requests", {
|
|
458
|
+
status: 429,
|
|
325
459
|
headers,
|
|
326
460
|
});
|
|
327
461
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
462
|
+
|
|
463
|
+
const url = new URL(req.url);
|
|
464
|
+
if (req.method === "GET") {
|
|
465
|
+
if (url.pathname === "/health" || url.pathname === "/health/live") {
|
|
466
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
467
|
+
if (allowOriginHeader) {
|
|
468
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
469
|
+
}
|
|
470
|
+
for (const [key, value] of Object.entries(
|
|
471
|
+
rateLimitResult.headers,
|
|
472
|
+
)) {
|
|
473
|
+
headers.set(key, value);
|
|
474
|
+
}
|
|
475
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
476
|
+
status: 200,
|
|
477
|
+
headers,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (url.pathname === "/health/ready") {
|
|
481
|
+
const readiness = readinessState.get();
|
|
482
|
+
const deployment = describeDeployment(config);
|
|
483
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
484
|
+
if (allowOriginHeader)
|
|
485
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
486
|
+
return new Response(JSON.stringify({
|
|
487
|
+
...readiness,
|
|
488
|
+
version: deployment.version,
|
|
489
|
+
runtime: deployment.runtime,
|
|
490
|
+
image_variant: deployment.image_variant,
|
|
491
|
+
}), {
|
|
492
|
+
status: readiness.status === "ready" ? 200 : 503,
|
|
493
|
+
headers,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (url.pathname === "/metrics") {
|
|
497
|
+
const headers = new Headers({
|
|
498
|
+
"Content-Type": "text/plain; version=0.0.4",
|
|
499
|
+
});
|
|
500
|
+
if (allowOriginHeader) {
|
|
501
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
502
|
+
}
|
|
503
|
+
for (const [key, value] of Object.entries(
|
|
504
|
+
rateLimitResult.headers,
|
|
505
|
+
)) {
|
|
506
|
+
headers.set(key, value);
|
|
507
|
+
}
|
|
508
|
+
return new Response(metricsRegistry.render(), {
|
|
509
|
+
status: 200,
|
|
510
|
+
headers,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Token Auth Check
|
|
516
|
+
if (serverConfig.auth_enabled) {
|
|
517
|
+
const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
|
|
518
|
+
if (!expectedToken) {
|
|
519
|
+
return new Response(
|
|
520
|
+
"Server authentication configured but token environment variable is empty",
|
|
521
|
+
{ status: 500 },
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const authHeader = req.headers.get("authorization") || "";
|
|
525
|
+
const token = authHeader.startsWith("Bearer ")
|
|
526
|
+
? authHeader.slice(7)
|
|
527
|
+
: authHeader;
|
|
528
|
+
if (!token || !safeTokenEquals(token, expectedToken)) {
|
|
529
|
+
return new Response("Unauthorized", { status: 401 });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// GET /stats — placed after the Token Auth Check above (unlike /health and /metrics,
|
|
534
|
+
// which return earlier and stay open) since audit queries carry raw user text.
|
|
535
|
+
if (req.method === "GET" && url.pathname === "/stats") {
|
|
536
|
+
const since = url.searchParams.get("since") ?? "";
|
|
537
|
+
if (!SINCE_PATTERN.test(since)) {
|
|
538
|
+
return new Response(
|
|
539
|
+
JSON.stringify({
|
|
540
|
+
error:
|
|
541
|
+
"since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
|
|
542
|
+
}),
|
|
543
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const { auditDb } = await getRuntime();
|
|
331
547
|
const headers = new Headers({ "Content-Type": "application/json" });
|
|
332
548
|
if (allowOriginHeader)
|
|
333
549
|
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
334
|
-
|
|
335
|
-
...readiness,
|
|
336
|
-
version: deployment.version,
|
|
337
|
-
runtime: deployment.runtime,
|
|
338
|
-
image_variant: deployment.image_variant,
|
|
339
|
-
}), {
|
|
340
|
-
status: readiness.status === "ready" ? 200 : 503,
|
|
341
|
-
headers,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
if (url.pathname === "/metrics") {
|
|
345
|
-
const headers = new Headers({
|
|
346
|
-
"Content-Type": "text/plain; version=0.0.4",
|
|
347
|
-
});
|
|
348
|
-
if (allowOriginHeader) {
|
|
349
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
350
|
-
}
|
|
351
|
-
for (const [key, value] of Object.entries(
|
|
352
|
-
rateLimitResult.headers,
|
|
353
|
-
)) {
|
|
550
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers))
|
|
354
551
|
headers.set(key, value);
|
|
355
|
-
|
|
356
|
-
return new Response(metricsRegistry.render(), {
|
|
552
|
+
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
357
553
|
status: 200,
|
|
358
554
|
headers,
|
|
359
555
|
});
|
|
360
556
|
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Token Auth Check
|
|
364
|
-
if (serverConfig.auth_enabled) {
|
|
365
|
-
const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
|
|
366
|
-
if (!expectedToken) {
|
|
367
|
-
return new Response(
|
|
368
|
-
"Server authentication configured but token environment variable is empty",
|
|
369
|
-
{ status: 500 },
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
const authHeader = req.headers.get("authorization") || "";
|
|
373
|
-
const token = authHeader.startsWith("Bearer ")
|
|
374
|
-
? authHeader.slice(7)
|
|
375
|
-
: authHeader;
|
|
376
|
-
if (!token || !safeTokenEquals(token, expectedToken)) {
|
|
377
|
-
return new Response("Unauthorized", { status: 401 });
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// GET /stats — placed after the Token Auth Check above (unlike /health and /metrics,
|
|
382
|
-
// which return earlier and stay open) since audit queries carry raw user text.
|
|
383
|
-
if (req.method === "GET" && url.pathname === "/stats") {
|
|
384
|
-
const since = url.searchParams.get("since") ?? "";
|
|
385
|
-
if (!SINCE_PATTERN.test(since)) {
|
|
386
|
-
return new Response(
|
|
387
|
-
JSON.stringify({
|
|
388
|
-
error:
|
|
389
|
-
"since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
|
|
390
|
-
}),
|
|
391
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
const { auditDb } = await getRuntime();
|
|
395
|
-
const headers = new Headers({ "Content-Type": "application/json" });
|
|
396
|
-
if (allowOriginHeader)
|
|
397
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
398
|
-
for (const [key, value] of Object.entries(rateLimitResult.headers))
|
|
399
|
-
headers.set(key, value);
|
|
400
|
-
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
401
|
-
status: 200,
|
|
402
|
-
headers,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
557
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const adminTokenEnv =
|
|
413
|
-
serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
|
|
414
|
-
const expectedAdminToken = process.env[adminTokenEnv] || "";
|
|
415
|
-
const authHeader = req.headers.get("authorization") || "";
|
|
416
|
-
const token = authHeader.startsWith("Bearer ")
|
|
417
|
-
? authHeader.slice(7)
|
|
418
|
-
: authHeader;
|
|
419
|
-
|
|
420
|
-
if (
|
|
421
|
-
!expectedAdminToken ||
|
|
422
|
-
!token ||
|
|
423
|
-
!safeTokenEquals(token, expectedAdminToken)
|
|
424
|
-
) {
|
|
425
|
-
return new Response("Unauthorized", { status: 401 });
|
|
426
|
-
}
|
|
558
|
+
// Admin HTTP API (/admin/v1/*)
|
|
559
|
+
if (url.pathname.startsWith("/admin/v1/")) {
|
|
560
|
+
if (!serverConfig.admin?.enabled) {
|
|
561
|
+
return new Response("Admin endpoints disabled", { status: 403 });
|
|
562
|
+
}
|
|
427
563
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
564
|
+
const adminTokenEnv =
|
|
565
|
+
serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
|
|
566
|
+
const expectedAdminToken = process.env[adminTokenEnv] || "";
|
|
567
|
+
const authHeader = req.headers.get("authorization") || "";
|
|
568
|
+
const token = authHeader.startsWith("Bearer ")
|
|
569
|
+
? authHeader.slice(7)
|
|
570
|
+
: authHeader;
|
|
571
|
+
|
|
572
|
+
if (
|
|
573
|
+
!expectedAdminToken ||
|
|
574
|
+
!token ||
|
|
575
|
+
!safeTokenEquals(token, expectedAdminToken)
|
|
576
|
+
) {
|
|
577
|
+
return new Response("Unauthorized", { status: 401 });
|
|
578
|
+
}
|
|
431
579
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
) {
|
|
436
|
-
const isExternallyManaged =
|
|
437
|
-
process.env.SKILLMUX_CONFIG_READONLY === "true";
|
|
438
|
-
return new Response(
|
|
439
|
-
JSON.stringify({
|
|
440
|
-
config_read: true,
|
|
441
|
-
config_write: !isExternallyManaged,
|
|
442
|
-
persistence: isExternallyManaged
|
|
443
|
-
? "externally_managed"
|
|
444
|
-
: "writable",
|
|
445
|
-
reloadable_keys: RELOADABLE_KEYS,
|
|
446
|
-
restart_required_keys: RESTART_REQUIRED_KEYS,
|
|
447
|
-
}),
|
|
448
|
-
{ status: 200, headers },
|
|
449
|
-
);
|
|
450
|
-
}
|
|
580
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
581
|
+
if (allowOriginHeader)
|
|
582
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
451
583
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
desired_source_hash: desiredHash,
|
|
472
|
-
active_revision: activeRevision,
|
|
473
|
-
active_source_hash: activeRevision,
|
|
474
|
-
...status,
|
|
475
|
-
readiness: readinessState.get(),
|
|
476
|
-
runtime: "running",
|
|
477
|
-
version: deployment.version,
|
|
478
|
-
deployment_runtime: deployment.runtime,
|
|
479
|
-
image_variant: deployment.image_variant,
|
|
480
|
-
},
|
|
481
|
-
}),
|
|
482
|
-
{ status: 200, headers },
|
|
483
|
-
);
|
|
484
|
-
}
|
|
584
|
+
if (
|
|
585
|
+
req.method === "GET" &&
|
|
586
|
+
url.pathname === "/admin/v1/capabilities"
|
|
587
|
+
) {
|
|
588
|
+
const isExternallyManaged =
|
|
589
|
+
process.env.SKILLMUX_CONFIG_READONLY === "true";
|
|
590
|
+
return new Response(
|
|
591
|
+
JSON.stringify({
|
|
592
|
+
config_read: true,
|
|
593
|
+
config_write: !isExternallyManaged,
|
|
594
|
+
persistence: isExternallyManaged
|
|
595
|
+
? "externally_managed"
|
|
596
|
+
: "writable",
|
|
597
|
+
reloadable_keys: RELOADABLE_KEYS,
|
|
598
|
+
restart_required_keys: RESTART_REQUIRED_KEYS,
|
|
599
|
+
}),
|
|
600
|
+
{ status: 200, headers },
|
|
601
|
+
);
|
|
602
|
+
}
|
|
485
603
|
|
|
486
|
-
|
|
487
|
-
|
|
604
|
+
if (req.method === "GET" && url.pathname === "/admin/v1/config") {
|
|
605
|
+
const { effective, sources } = await getEffectiveConfig(configPath);
|
|
606
|
+
const deployment = describeDeployment(config);
|
|
607
|
+
const desiredHash = computeHash(effective);
|
|
608
|
+
const snapshot = snapshots.acquire();
|
|
609
|
+
const activeRevision = computeHash(snapshot.snapshot.config);
|
|
610
|
+
snapshot.release();
|
|
611
|
+
const status =
|
|
612
|
+
configWatcher?.reloadStatus() ?? inactiveReloadStatus;
|
|
613
|
+
headers.set("ETag", `"${desiredHash}"`);
|
|
488
614
|
return new Response(
|
|
489
615
|
JSON.stringify({
|
|
490
|
-
|
|
491
|
-
|
|
616
|
+
desired: effective,
|
|
617
|
+
effective,
|
|
618
|
+
sources,
|
|
619
|
+
active_revision: activeRevision,
|
|
620
|
+
runtime: {
|
|
621
|
+
target: "local",
|
|
622
|
+
desired_source: configPath,
|
|
623
|
+
desired_source_hash: desiredHash,
|
|
624
|
+
active_revision: activeRevision,
|
|
625
|
+
active_source_hash: activeRevision,
|
|
626
|
+
...status,
|
|
627
|
+
readiness: readinessState.get(),
|
|
628
|
+
runtime: "running",
|
|
629
|
+
version: deployment.version,
|
|
630
|
+
deployment_runtime: deployment.runtime,
|
|
631
|
+
image_variant: deployment.image_variant,
|
|
632
|
+
},
|
|
492
633
|
}),
|
|
493
|
-
{ status:
|
|
634
|
+
{ status: 200, headers },
|
|
494
635
|
);
|
|
495
636
|
}
|
|
496
637
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
638
|
+
if (req.method === "PATCH" && url.pathname === "/admin/v1/config") {
|
|
639
|
+
if (process.env.SKILLMUX_CONFIG_READONLY === "true") {
|
|
640
|
+
return new Response(
|
|
641
|
+
JSON.stringify({
|
|
642
|
+
error: "CONFIG_EXTERNALLY_MANAGED",
|
|
643
|
+
message: "Configuration is externally managed",
|
|
644
|
+
}),
|
|
645
|
+
{ status: 409, headers },
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const ifMatch = req.headers.get("if-match") || "";
|
|
650
|
+
const cleanIfMatch = ifMatch.replace(/^"|"$/g, "");
|
|
651
|
+
const { effective } = await getEffectiveConfig(configPath);
|
|
652
|
+
const currentHash = computeHash(effective);
|
|
653
|
+
|
|
654
|
+
if (!ifMatch || cleanIfMatch !== currentHash) {
|
|
655
|
+
return new Response(
|
|
656
|
+
JSON.stringify({
|
|
657
|
+
error: "CONFIG_REVISION_CONFLICT",
|
|
658
|
+
message: "Revision conflict",
|
|
659
|
+
}),
|
|
660
|
+
{ status: 409, headers },
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const body = (await req.json()) as {
|
|
665
|
+
changes: Record<string, string | number | boolean>;
|
|
666
|
+
};
|
|
667
|
+
let lastResult: any = null;
|
|
668
|
+
const auditChanges: AdminAuditChange[] = [];
|
|
669
|
+
for (const [k, v] of Object.entries(body.changes ?? {})) {
|
|
670
|
+
lastResult = await setDottedKey(k, String(v), {
|
|
671
|
+
targetName: "remote",
|
|
672
|
+
});
|
|
673
|
+
auditChanges.push({
|
|
674
|
+
key: k,
|
|
675
|
+
old_value: lastResult.prior_val,
|
|
676
|
+
new_value: lastResult.resulting_val,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (auditChanges.length > 0 && lastResult) {
|
|
681
|
+
const { auditDb } = await getRuntime();
|
|
682
|
+
insertAdminAuditRow(auditDb, {
|
|
683
|
+
ts: new Date().toISOString(),
|
|
684
|
+
changes: auditChanges,
|
|
685
|
+
resulting_revision: lastResult.resulting_revision,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return new Response(JSON.stringify(lastResult ?? { ok: true }), {
|
|
690
|
+
status: 200,
|
|
691
|
+
headers,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
501
694
|
|
|
502
|
-
if (
|
|
695
|
+
if (
|
|
696
|
+
req.method === "POST" &&
|
|
697
|
+
url.pathname === "/admin/v1/audit/prune"
|
|
698
|
+
) {
|
|
699
|
+
let body: {
|
|
700
|
+
older_than?: string;
|
|
701
|
+
dry_run?: boolean;
|
|
702
|
+
confirm?: boolean;
|
|
703
|
+
} = {};
|
|
704
|
+
try {
|
|
705
|
+
const text = await req.text();
|
|
706
|
+
if (text.trim()) {
|
|
707
|
+
body = JSON.parse(text);
|
|
708
|
+
}
|
|
709
|
+
} catch {
|
|
710
|
+
return new Response(
|
|
711
|
+
JSON.stringify({
|
|
712
|
+
error: "INVALID_JSON",
|
|
713
|
+
message: "Request body must be valid JSON",
|
|
714
|
+
}),
|
|
715
|
+
{ status: 400, headers },
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const dryRun = body.dry_run ?? false;
|
|
720
|
+
const confirm = body.confirm ?? false;
|
|
721
|
+
if (!dryRun && !confirm) {
|
|
722
|
+
return new Response(
|
|
723
|
+
JSON.stringify({
|
|
724
|
+
error: "CONFIRMATION_REQUIRED",
|
|
725
|
+
message:
|
|
726
|
+
"Non-dry-run audit prune requires confirm: true",
|
|
727
|
+
}),
|
|
728
|
+
{ status: 400, headers },
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const { effective } = await getEffectiveConfig(configPath);
|
|
733
|
+
let cutoff: Date;
|
|
734
|
+
if (body.older_than) {
|
|
735
|
+
try {
|
|
736
|
+
cutoff = parseSince(body.older_than);
|
|
737
|
+
} catch (err: any) {
|
|
738
|
+
return new Response(
|
|
739
|
+
JSON.stringify({
|
|
740
|
+
error: "INVALID_CUTOFF",
|
|
741
|
+
message: err.message,
|
|
742
|
+
}),
|
|
743
|
+
{ status: 400, headers },
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
const retentionDays = effective.audit?.retention_days ?? 90;
|
|
748
|
+
if (retentionDays <= 0) {
|
|
749
|
+
return new Response(
|
|
750
|
+
JSON.stringify({
|
|
751
|
+
audit_deleted: 0,
|
|
752
|
+
fetch_deleted: 0,
|
|
753
|
+
admin_audit_deleted: 0,
|
|
754
|
+
dry_run: dryRun,
|
|
755
|
+
cutoff: null,
|
|
756
|
+
}),
|
|
757
|
+
{ status: 200, headers },
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
cutoff = new Date(Date.now() - retentionDays * 86_400_000);
|
|
761
|
+
}
|
|
762
|
+
const cutoffIso = cutoff.toISOString();
|
|
763
|
+
|
|
764
|
+
const { auditDb } = await getRuntime();
|
|
765
|
+
if (dryRun) {
|
|
766
|
+
const counts = countPrunable(auditDb, cutoffIso);
|
|
767
|
+
return new Response(
|
|
768
|
+
JSON.stringify({
|
|
769
|
+
...counts,
|
|
770
|
+
dry_run: true,
|
|
771
|
+
cutoff: cutoffIso,
|
|
772
|
+
}),
|
|
773
|
+
{ status: 200, headers },
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const counts = pruneAuditBefore(auditDb, cutoffIso);
|
|
503
778
|
return new Response(
|
|
504
779
|
JSON.stringify({
|
|
505
|
-
|
|
506
|
-
|
|
780
|
+
...counts,
|
|
781
|
+
dry_run: false,
|
|
782
|
+
cutoff: cutoffIso,
|
|
507
783
|
}),
|
|
508
|
-
{ status:
|
|
784
|
+
{ status: 200, headers },
|
|
509
785
|
);
|
|
510
786
|
}
|
|
511
787
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
lastResult = await setDottedKey(k, String(v), {
|
|
518
|
-
targetName: "remote",
|
|
788
|
+
if (req.method === "POST" && url.pathname === "/admin/v1/eval") {
|
|
789
|
+
const report = await evalVault();
|
|
790
|
+
return new Response(JSON.stringify(report), {
|
|
791
|
+
status: 200,
|
|
792
|
+
headers,
|
|
519
793
|
});
|
|
520
794
|
}
|
|
521
795
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
796
|
+
if (
|
|
797
|
+
req.method === "POST" &&
|
|
798
|
+
url.pathname === "/admin/v1/eval/promote"
|
|
799
|
+
) {
|
|
800
|
+
let body: { since?: string } = {};
|
|
801
|
+
try {
|
|
802
|
+
const text = await req.text();
|
|
803
|
+
if (text.trim()) {
|
|
804
|
+
body = JSON.parse(text);
|
|
805
|
+
}
|
|
806
|
+
} catch {
|
|
807
|
+
return new Response(
|
|
808
|
+
JSON.stringify({
|
|
809
|
+
error: "INVALID_JSON",
|
|
810
|
+
message: "Request body must be valid JSON",
|
|
811
|
+
}),
|
|
812
|
+
{ status: 400, headers },
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (!body.since || typeof body.since !== "string") {
|
|
817
|
+
return new Response(
|
|
818
|
+
JSON.stringify({
|
|
819
|
+
error: "MISSING_SINCE",
|
|
820
|
+
message: "Field 'since' is required",
|
|
821
|
+
}),
|
|
822
|
+
{ status: 400, headers },
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
let sinceDate: Date;
|
|
827
|
+
try {
|
|
828
|
+
sinceDate = parseSince(body.since);
|
|
829
|
+
} catch (err: any) {
|
|
830
|
+
return new Response(
|
|
831
|
+
JSON.stringify({
|
|
832
|
+
error: "INVALID_SINCE",
|
|
833
|
+
message: err.message,
|
|
834
|
+
}),
|
|
835
|
+
{ status: 400, headers },
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
const sinceIso = sinceDate.toISOString();
|
|
839
|
+
|
|
840
|
+
const { auditDb } = await getRuntime();
|
|
841
|
+
const candidates = buildPromotedCases(
|
|
842
|
+
queryPromotableFetches(auditDb, sinceIso),
|
|
843
|
+
);
|
|
844
|
+
return new Response(JSON.stringify({ candidates }), {
|
|
845
|
+
status: 200,
|
|
846
|
+
headers,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
527
849
|
|
|
528
|
-
|
|
529
|
-
|
|
850
|
+
return new Response("Not Found", { status: 404, headers });
|
|
851
|
+
}
|
|
530
852
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
853
|
+
// Record request metrics
|
|
854
|
+
let mcpMethod = "unknown";
|
|
855
|
+
try {
|
|
856
|
+
const bodyClone = await req.clone().json();
|
|
857
|
+
if (bodyClone.method === "tools/call") {
|
|
858
|
+
mcpMethod = bodyClone.params?.name || "tools/call";
|
|
859
|
+
} else {
|
|
860
|
+
mcpMethod = bodyClone.method || "unknown";
|
|
861
|
+
}
|
|
862
|
+
} catch {
|
|
863
|
+
// Non-JSON or parsing error
|
|
539
864
|
}
|
|
540
|
-
|
|
541
|
-
// Non-JSON or parsing error
|
|
542
|
-
}
|
|
543
|
-
metricsRegistry.recordRequest(mcpMethod);
|
|
865
|
+
metricsRegistry.recordRequest(mcpMethod);
|
|
544
866
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
867
|
+
const res = await transport.handleRequest(req);
|
|
868
|
+
const headers = new Headers(res.headers);
|
|
869
|
+
if (allowOriginHeader) {
|
|
870
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
871
|
+
}
|
|
872
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers)) {
|
|
873
|
+
headers.set(key, value);
|
|
874
|
+
}
|
|
875
|
+
return new Response(res.body, {
|
|
876
|
+
status: res.status,
|
|
877
|
+
statusText: res.statusText,
|
|
878
|
+
headers,
|
|
879
|
+
});
|
|
552
880
|
}
|
|
553
|
-
return new Response(res.body, {
|
|
554
|
-
status: res.status,
|
|
555
|
-
statusText: res.statusText,
|
|
556
|
-
headers,
|
|
557
|
-
});
|
|
558
881
|
},
|
|
559
882
|
});
|
|
560
883
|
let stopped = false;
|
|
@@ -562,6 +885,7 @@ export async function startServer(opts?: {
|
|
|
562
885
|
console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
|
|
563
886
|
return {
|
|
564
887
|
port: bunServer.port,
|
|
888
|
+
statsPort: statsHandle?.port,
|
|
565
889
|
reloadStatus: () =>
|
|
566
890
|
configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
|
|
567
891
|
async stop() {
|
|
@@ -571,6 +895,7 @@ export async function startServer(opts?: {
|
|
|
571
895
|
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
572
896
|
metricsRegistry.setReadiness(readinessState.get());
|
|
573
897
|
bunServer.stop(true);
|
|
898
|
+
statsHandle?.stop();
|
|
574
899
|
configWatcher?.stop();
|
|
575
900
|
stopWatcher();
|
|
576
901
|
snapshots.dispose();
|
|
@@ -582,6 +907,7 @@ export async function startServer(opts?: {
|
|
|
582
907
|
await server.connect(new StdioServerTransport());
|
|
583
908
|
let stopped = false;
|
|
584
909
|
return {
|
|
910
|
+
statsPort: statsHandle?.port,
|
|
585
911
|
reloadStatus: () =>
|
|
586
912
|
configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
|
|
587
913
|
async stop() {
|
|
@@ -590,6 +916,7 @@ export async function startServer(opts?: {
|
|
|
590
916
|
clearInterval(auditPruneInterval);
|
|
591
917
|
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
592
918
|
metricsRegistry.setReadiness(readinessState.get());
|
|
919
|
+
statsHandle?.stop();
|
|
593
920
|
configWatcher?.stop();
|
|
594
921
|
stopWatcher();
|
|
595
922
|
snapshots.dispose();
|