@klhapp/skillmux 0.6.0 → 1.0.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/src/server.ts CHANGED
@@ -4,8 +4,15 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
6
  import { createClients } from "./clients";
7
- import { loadConfig } from "./config";
8
- import { backfillEmbeddings, configure, fetchSkill, resolveSkill } from "./router-core";
7
+ import { loadConfig, resolveConfigPath } from "./config";
8
+ import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
9
+ import { RuntimeSnapshotManager } from "./snapshot";
10
+ import {
11
+ backfillEmbeddings,
12
+ configure,
13
+ fetchSkill,
14
+ resolveSkill,
15
+ } from "./router-core";
9
16
  import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
10
17
  import { getStats, SINCE_PATTERN } from "./stats";
11
18
  import { SKILL_ID_PATTERN } from "./vault";
@@ -21,13 +28,18 @@ import {
21
28
  RELOADABLE_KEYS,
22
29
  RESTART_REQUIRED_KEYS,
23
30
  } from "./config-service";
24
- import { applyCalibrationRun, getCalibrationRun, listCalibrationRuns } from "./calibrate";
31
+ import {
32
+ applyCalibrationRun,
33
+ getCalibrationRun,
34
+ listCalibrationRuns,
35
+ } from "./calibrate";
25
36
 
26
37
  export const metricsRegistry = new MetricsRegistry();
27
38
  export const readinessState = new ReadinessState();
28
39
 
29
40
  export interface ServerHandle {
30
41
  port?: number;
42
+ reloadStatus(): ReloadStatus;
31
43
  stop(): Promise<void>;
32
44
  }
33
45
 
@@ -35,10 +47,15 @@ let warnedAuthToken = false;
35
47
  function resolveAuthToken(envName: string): string {
36
48
  const value = process.env[envName];
37
49
  if (value) return value;
38
- if (envName === "SKILLMUX_AUTH_TOKEN" && process.env.SKILL_ROUTER_AUTH_TOKEN) {
50
+ if (
51
+ envName === "SKILLMUX_AUTH_TOKEN" &&
52
+ process.env.SKILL_ROUTER_AUTH_TOKEN
53
+ ) {
39
54
  if (!warnedAuthToken) {
40
55
  warnedAuthToken = true;
41
- console.error("skillmux: SKILL_ROUTER_AUTH_TOKEN is deprecated, set SKILLMUX_AUTH_TOKEN instead");
56
+ console.error(
57
+ "skillmux: SKILL_ROUTER_AUTH_TOKEN is deprecated, set SKILLMUX_AUTH_TOKEN instead",
58
+ );
42
59
  }
43
60
  return process.env.SKILL_ROUTER_AUTH_TOKEN;
44
61
  }
@@ -57,9 +74,36 @@ export async function startServer(opts?: {
57
74
  port?: number;
58
75
  config?: Config;
59
76
  clients?: Partial<Clients>;
77
+ configPath?: string;
60
78
  }): Promise<ServerHandle> {
61
- const config = opts?.config ?? await loadConfig();
62
- configure({ config, clients: opts?.clients ?? createClients(config) });
79
+ const configPath = resolveConfigPath(opts?.configPath);
80
+ const config = opts?.config ?? (await loadConfig(configPath));
81
+ const initialClients = { ...createClients(config), ...opts?.clients };
82
+ const snapshots = RuntimeSnapshotManager.create(config, initialClients);
83
+ const inactiveReloadStatus: ReloadStatus = {
84
+ last_successful_reload_at: null,
85
+ last_reload_error: null,
86
+ restart_required_keys: [],
87
+ };
88
+ configure({ config, clients: initialClients });
89
+ // An injected config has no guaranteed file source. Watch it only when the
90
+ // caller explicitly supplies that source; normal server startup always watches.
91
+ const watcherPath =
92
+ opts?.configPath ?? (opts?.config ? undefined : configPath);
93
+ const configWatcher = watcherPath
94
+ ? await ConfigWatcher.start(watcherPath, {
95
+ onReload: (nextConfig) => {
96
+ const nextClients = {
97
+ ...createClients(nextConfig),
98
+ ...opts?.clients,
99
+ };
100
+ snapshots.replace(nextConfig, nextClients);
101
+ configure({ config: nextConfig, clients: nextClients });
102
+ },
103
+ onError: (error) =>
104
+ console.error("skillmux config reload error:", error),
105
+ })
106
+ : undefined;
63
107
  const stopWatcher = await startVaultWatcher();
64
108
  const initPromise = initializeRuntime(readinessState)
65
109
  .then(() => metricsRegistry.setReadiness(readinessState.get()))
@@ -88,7 +132,10 @@ export async function startServer(opts?: {
88
132
 
89
133
  if (result.outcome === "matched") {
90
134
  const { body, ...meta } = result;
91
- return { content: [{ type: "text" as const, text: body }], structuredContent: { ...meta } };
135
+ return {
136
+ content: [{ type: "text" as const, text: body }],
137
+ structuredContent: { ...meta },
138
+ };
92
139
  }
93
140
  return {
94
141
  content: [{ type: "text" as const, text: JSON.stringify(result) }],
@@ -113,7 +160,10 @@ export async function startServer(opts?: {
113
160
  try {
114
161
  const result = await fetchSkill({ skill_id });
115
162
  const { body, ...meta } = result;
116
- return { content: [{ type: "text" as const, text: body }], structuredContent: { ...meta } };
163
+ return {
164
+ content: [{ type: "text" as const, text: body }],
165
+ structuredContent: { ...meta },
166
+ };
117
167
  } catch (err) {
118
168
  metricsRegistry.recordError();
119
169
  throw err;
@@ -123,9 +173,8 @@ export async function startServer(opts?: {
123
173
 
124
174
  const transportType = opts?.transport ?? "stdio";
125
175
  if (transportType === "http") {
126
- const { WebStandardStreamableHTTPServerTransport } = await import(
127
- "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
128
- );
176
+ const { WebStandardStreamableHTTPServerTransport } =
177
+ await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
129
178
  const transport = new WebStandardStreamableHTTPServerTransport({
130
179
  sessionIdGenerator: () => crypto.randomUUID(),
131
180
  });
@@ -133,7 +182,7 @@ export async function startServer(opts?: {
133
182
 
134
183
  const { RateLimiter } = await import("./rate-limiter");
135
184
  const rateLimiter = new RateLimiter(
136
- config.server?.rate_limit || { enabled: false, requests_per_minute: 60 }
185
+ config.server?.rate_limit || { enabled: false, requests_per_minute: 60 },
137
186
  );
138
187
 
139
188
  const port = opts?.port ?? Number(process.env.PORT || 3000);
@@ -149,8 +198,13 @@ export async function startServer(opts?: {
149
198
  };
150
199
  const origin = req.headers.get("origin") || "";
151
200
  const allowedOrigins = serverConfig.allowed_origins;
152
- const isAllowed = allowedOrigins.includes("*") || allowedOrigins.includes(origin);
153
- const allowOriginHeader = isAllowed ? (allowedOrigins.includes("*") ? "*" : origin) : "";
201
+ const isAllowed =
202
+ allowedOrigins.includes("*") || allowedOrigins.includes(origin);
203
+ const allowOriginHeader = isAllowed
204
+ ? allowedOrigins.includes("*")
205
+ ? "*"
206
+ : origin
207
+ : "";
154
208
 
155
209
  if (origin && !isAllowed) {
156
210
  return new Response("CORS origin not allowed", { status: 403 });
@@ -161,7 +215,8 @@ export async function startServer(opts?: {
161
215
  headers: {
162
216
  "Access-Control-Allow-Origin": allowOriginHeader,
163
217
  "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
164
- "Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version",
218
+ "Access-Control-Allow-Headers":
219
+ "Content-Type, Authorization, MCP-Protocol-Version",
165
220
  },
166
221
  });
167
222
  }
@@ -208,29 +263,42 @@ export async function startServer(opts?: {
208
263
  if (allowOriginHeader) {
209
264
  headers.set("Access-Control-Allow-Origin", allowOriginHeader);
210
265
  }
211
- for (const [key, value] of Object.entries(rateLimitResult.headers)) {
266
+ for (const [key, value] of Object.entries(
267
+ rateLimitResult.headers,
268
+ )) {
212
269
  headers.set(key, value);
213
270
  }
214
- return new Response(JSON.stringify({ status: "ok" }), { status: 200, headers });
271
+ return new Response(JSON.stringify({ status: "ok" }), {
272
+ status: 200,
273
+ headers,
274
+ });
215
275
  }
216
276
  if (url.pathname === "/health/ready") {
217
277
  const readiness = readinessState.get();
218
278
  const headers = new Headers({ "Content-Type": "application/json" });
219
- if (allowOriginHeader) headers.set("Access-Control-Allow-Origin", allowOriginHeader);
279
+ if (allowOriginHeader)
280
+ headers.set("Access-Control-Allow-Origin", allowOriginHeader);
220
281
  return new Response(JSON.stringify(readiness), {
221
282
  status: readiness.status === "ready" ? 200 : 503,
222
283
  headers,
223
284
  });
224
285
  }
225
286
  if (url.pathname === "/metrics") {
226
- const headers = new Headers({ "Content-Type": "text/plain; version=0.0.4" });
287
+ const headers = new Headers({
288
+ "Content-Type": "text/plain; version=0.0.4",
289
+ });
227
290
  if (allowOriginHeader) {
228
291
  headers.set("Access-Control-Allow-Origin", allowOriginHeader);
229
292
  }
230
- for (const [key, value] of Object.entries(rateLimitResult.headers)) {
293
+ for (const [key, value] of Object.entries(
294
+ rateLimitResult.headers,
295
+ )) {
231
296
  headers.set(key, value);
232
297
  }
233
- return new Response(metricsRegistry.render(), { status: 200, headers });
298
+ return new Response(metricsRegistry.render(), {
299
+ status: 200,
300
+ headers,
301
+ });
234
302
  }
235
303
  }
236
304
 
@@ -238,10 +306,15 @@ export async function startServer(opts?: {
238
306
  if (serverConfig.auth_enabled) {
239
307
  const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
240
308
  if (!expectedToken) {
241
- return new Response("Server authentication configured but token environment variable is empty", { status: 500 });
309
+ return new Response(
310
+ "Server authentication configured but token environment variable is empty",
311
+ { status: 500 },
312
+ );
242
313
  }
243
314
  const authHeader = req.headers.get("authorization") || "";
244
- const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
315
+ const token = authHeader.startsWith("Bearer ")
316
+ ? authHeader.slice(7)
317
+ : authHeader;
245
318
  if (!token || !safeTokenEquals(token, expectedToken)) {
246
319
  return new Response("Unauthorized", { status: 401 });
247
320
  }
@@ -253,15 +326,23 @@ export async function startServer(opts?: {
253
326
  const since = url.searchParams.get("since") ?? "";
254
327
  if (!SINCE_PATTERN.test(since)) {
255
328
  return new Response(
256
- JSON.stringify({ error: "since must be a relative window (e.g. 30d) or an absolute ISO-8601 date" }),
329
+ JSON.stringify({
330
+ error:
331
+ "since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
332
+ }),
257
333
  { status: 400, headers: { "Content-Type": "application/json" } },
258
334
  );
259
335
  }
260
336
  const { db } = await getRuntime();
261
337
  const headers = new Headers({ "Content-Type": "application/json" });
262
- if (allowOriginHeader) headers.set("Access-Control-Allow-Origin", allowOriginHeader);
263
- for (const [key, value] of Object.entries(rateLimitResult.headers)) headers.set(key, value);
264
- return new Response(JSON.stringify(getStats(db, since)), { status: 200, headers });
338
+ if (allowOriginHeader)
339
+ headers.set("Access-Control-Allow-Origin", allowOriginHeader);
340
+ for (const [key, value] of Object.entries(rateLimitResult.headers))
341
+ headers.set(key, value);
342
+ return new Response(JSON.stringify(getStats(db, since)), {
343
+ status: 200,
344
+ headers,
345
+ });
265
346
  }
266
347
 
267
348
  // Admin HTTP API (/admin/v1/*)
@@ -270,55 +351,85 @@ export async function startServer(opts?: {
270
351
  return new Response("Admin endpoints disabled", { status: 403 });
271
352
  }
272
353
 
273
- const adminTokenEnv = serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
354
+ const adminTokenEnv =
355
+ serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
274
356
  const expectedAdminToken = process.env[adminTokenEnv] || "";
275
357
  const authHeader = req.headers.get("authorization") || "";
276
- const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
277
-
278
- if (!expectedAdminToken || !token || !safeTokenEquals(token, expectedAdminToken)) {
358
+ const token = authHeader.startsWith("Bearer ")
359
+ ? authHeader.slice(7)
360
+ : authHeader;
361
+
362
+ if (
363
+ !expectedAdminToken ||
364
+ !token ||
365
+ !safeTokenEquals(token, expectedAdminToken)
366
+ ) {
279
367
  return new Response("Unauthorized", { status: 401 });
280
368
  }
281
369
 
282
370
  const headers = new Headers({ "Content-Type": "application/json" });
283
- if (allowOriginHeader) headers.set("Access-Control-Allow-Origin", allowOriginHeader);
371
+ if (allowOriginHeader)
372
+ headers.set("Access-Control-Allow-Origin", allowOriginHeader);
284
373
 
285
- if (req.method === "GET" && url.pathname === "/admin/v1/capabilities") {
286
- const isExternallyManaged = process.env.SKILLMUX_CONFIG_READONLY === "true";
374
+ if (
375
+ req.method === "GET" &&
376
+ url.pathname === "/admin/v1/capabilities"
377
+ ) {
378
+ const isExternallyManaged =
379
+ process.env.SKILLMUX_CONFIG_READONLY === "true";
287
380
  return new Response(
288
381
  JSON.stringify({
289
382
  config_read: true,
290
383
  config_write: !isExternallyManaged,
291
384
  calibration: true,
292
- persistence: isExternallyManaged ? "externally_managed" : "writable",
385
+ persistence: isExternallyManaged
386
+ ? "externally_managed"
387
+ : "writable",
293
388
  reloadable_keys: RELOADABLE_KEYS,
294
389
  restart_required_keys: RESTART_REQUIRED_KEYS,
295
390
  }),
296
- { status: 200, headers }
391
+ { status: 200, headers },
297
392
  );
298
393
  }
299
394
 
300
395
  if (req.method === "GET" && url.pathname === "/admin/v1/config") {
301
- const { effective, sources } = await getEffectiveConfig();
302
- const hash = computeHash(effective);
303
- const status = await getLocalConfigStatus();
304
- headers.set("ETag", `"${hash}"`);
396
+ const { effective, sources } = await getEffectiveConfig(configPath);
397
+ const desiredHash = computeHash(effective);
398
+ const snapshot = snapshots.acquire();
399
+ const activeRevision = computeHash(snapshot.snapshot.config);
400
+ snapshot.release();
401
+ const status =
402
+ configWatcher?.reloadStatus() ?? inactiveReloadStatus;
403
+ headers.set("ETag", `"${desiredHash}"`);
305
404
  return new Response(
306
405
  JSON.stringify({
307
406
  desired: effective,
308
407
  effective,
309
408
  sources,
310
- active_revision: hash,
311
- runtime: status,
409
+ active_revision: activeRevision,
410
+ runtime: {
411
+ target: "local",
412
+ desired_source: configPath,
413
+ desired_source_hash: desiredHash,
414
+ active_revision: activeRevision,
415
+ active_source_hash: activeRevision,
416
+ ...status,
417
+ readiness: readinessState.get(),
418
+ runtime: "running",
419
+ },
312
420
  }),
313
- { status: 200, headers }
421
+ { status: 200, headers },
314
422
  );
315
423
  }
316
424
 
317
425
  if (req.method === "PATCH" && url.pathname === "/admin/v1/config") {
318
426
  if (process.env.SKILLMUX_CONFIG_READONLY === "true") {
319
427
  return new Response(
320
- JSON.stringify({ error: "CONFIG_EXTERNALLY_MANAGED", message: "Configuration is externally managed" }),
321
- { status: 409, headers }
428
+ JSON.stringify({
429
+ error: "CONFIG_EXTERNALLY_MANAGED",
430
+ message: "Configuration is externally managed",
431
+ }),
432
+ { status: 409, headers },
322
433
  );
323
434
  }
324
435
 
@@ -329,45 +440,91 @@ export async function startServer(opts?: {
329
440
 
330
441
  if (!ifMatch || cleanIfMatch !== currentHash) {
331
442
  return new Response(
332
- JSON.stringify({ error: "CONFIG_REVISION_CONFLICT", message: "Revision conflict" }),
333
- { status: 409, headers }
443
+ JSON.stringify({
444
+ error: "CONFIG_REVISION_CONFLICT",
445
+ message: "Revision conflict",
446
+ }),
447
+ { status: 409, headers },
334
448
  );
335
449
  }
336
450
 
337
- const body = (await req.json()) as { changes: Record<string, string | number | boolean> };
451
+ const body = (await req.json()) as {
452
+ changes: Record<string, string | number | boolean>;
453
+ };
338
454
  let lastResult: any = null;
339
455
  for (const [k, v] of Object.entries(body.changes ?? {})) {
340
- lastResult = await setDottedKey(k, String(v), { targetName: "remote" });
456
+ lastResult = await setDottedKey(k, String(v), {
457
+ targetName: "remote",
458
+ });
341
459
  }
342
460
 
343
- return new Response(JSON.stringify(lastResult ?? { ok: true }), { status: 200, headers });
461
+ return new Response(JSON.stringify(lastResult ?? { ok: true }), {
462
+ status: 200,
463
+ headers,
464
+ });
344
465
  }
345
466
 
346
467
  if (url.pathname.startsWith("/admin/v1/calibrations")) {
347
468
  const { db } = await getRuntime();
348
- if (req.method === "GET" && url.pathname === "/admin/v1/calibrations") {
469
+ if (
470
+ req.method === "GET" &&
471
+ url.pathname === "/admin/v1/calibrations"
472
+ ) {
349
473
  const runs = listCalibrationRuns(db);
350
- return new Response(JSON.stringify(runs), { status: 200, headers });
474
+ return new Response(JSON.stringify(runs), {
475
+ status: 200,
476
+ headers,
477
+ });
351
478
  }
352
- const runIdMatch = url.pathname.match(/^\/admin\/v1\/calibrations\/([^\/]+)$/);
479
+ const runIdMatch = url.pathname.match(
480
+ /^\/admin\/v1\/calibrations\/([^\/]+)$/,
481
+ );
353
482
  if (req.method === "GET" && runIdMatch && runIdMatch[1]) {
354
483
  const runId = runIdMatch[1];
355
484
  const run = getCalibrationRun(db, runId);
356
- if (!run) return new Response(JSON.stringify({ error: "Calibration run not found" }), { status: 404, headers });
357
- return new Response(JSON.stringify(run), { status: 200, headers });
485
+ if (!run)
486
+ return new Response(
487
+ JSON.stringify({ error: "Calibration run not found" }),
488
+ { status: 404, headers },
489
+ );
490
+ return new Response(JSON.stringify(run), {
491
+ status: 200,
492
+ headers,
493
+ });
358
494
  }
359
- if (req.method === "POST" && url.pathname === "/admin/v1/calibrations") {
495
+ if (
496
+ req.method === "POST" &&
497
+ url.pathname === "/admin/v1/calibrations"
498
+ ) {
360
499
  const runId = "run_" + Math.random().toString(36).slice(2, 10);
361
- return new Response(JSON.stringify({ run_id: runId, status: "running" }), { status: 202, headers });
500
+ return new Response(
501
+ JSON.stringify({ run_id: runId, status: "running" }),
502
+ { status: 202, headers },
503
+ );
362
504
  }
363
- const applyMatch = url.pathname.match(/^\/admin\/v1\/calibrations\/([^\/]+)\/apply$/);
505
+ const applyMatch = url.pathname.match(
506
+ /^\/admin\/v1\/calibrations\/([^\/]+)\/apply$/,
507
+ );
364
508
  if (req.method === "POST" && applyMatch && applyMatch[1]) {
365
509
  const runId = applyMatch[1];
366
510
  const run = getCalibrationRun(db, runId);
367
- if (!run) return new Response(JSON.stringify({ error: "Calibration run not found" }), { status: 404, headers });
368
- const { DEFAULT_CONFIG_PATH, expandHome } = await import("./config");
369
- await applyCalibrationRun(db, runId, expandHome(DEFAULT_CONFIG_PATH), {});
370
- return new Response(JSON.stringify({ ok: true, run_id: runId }), { status: 200, headers });
511
+ if (!run)
512
+ return new Response(
513
+ JSON.stringify({ error: "Calibration run not found" }),
514
+ { status: 404, headers },
515
+ );
516
+ const { DEFAULT_CONFIG_PATH, expandHome } =
517
+ await import("./config");
518
+ await applyCalibrationRun(
519
+ db,
520
+ runId,
521
+ expandHome(DEFAULT_CONFIG_PATH),
522
+ {},
523
+ );
524
+ return new Response(JSON.stringify({ ok: true, run_id: runId }), {
525
+ status: 200,
526
+ headers,
527
+ });
371
528
  }
372
529
  }
373
530
 
@@ -408,13 +565,17 @@ export async function startServer(opts?: {
408
565
  console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
409
566
  return {
410
567
  port: bunServer.port,
568
+ reloadStatus: () =>
569
+ configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
411
570
  async stop() {
412
571
  if (stopped) return;
413
572
  stopped = true;
414
573
  readinessState.set({ ...readinessState.get(), status: "stopping" });
415
574
  metricsRegistry.setReadiness(readinessState.get());
416
575
  bunServer.stop(true);
576
+ configWatcher?.stop();
417
577
  stopWatcher();
578
+ snapshots.dispose();
418
579
  await server.close();
419
580
  closeRuntime();
420
581
  },
@@ -423,12 +584,16 @@ export async function startServer(opts?: {
423
584
  await server.connect(new StdioServerTransport());
424
585
  let stopped = false;
425
586
  return {
587
+ reloadStatus: () =>
588
+ configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
426
589
  async stop() {
427
590
  if (stopped) return;
428
591
  stopped = true;
429
592
  readinessState.set({ ...readinessState.get(), status: "stopping" });
430
593
  metricsRegistry.setReadiness(readinessState.get());
594
+ configWatcher?.stop();
431
595
  stopWatcher();
596
+ snapshots.dispose();
432
597
  await server.close();
433
598
  closeRuntime();
434
599
  },