@klhapp/skillmux 1.9.2 → 1.9.3

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