@klhapp/skillmux 1.12.0 → 1.13.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +18 -9
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +14 -4
  6. package/docs/concepts.md +2 -2
  7. package/docs/configuration.md +18 -3
  8. package/docs/deployment.md +3 -3
  9. package/docs/getting-started.md +32 -7
  10. package/docs/releasing.md +17 -2
  11. package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
  12. package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
  13. package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
  14. package/docs/sdd/cli-remote-target-parity/think.md +183 -0
  15. package/docs/sdd/cli-surface-consistency/think.md +149 -0
  16. package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
  17. package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
  18. package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
  19. package/docs/sdd/feat-packaging-deployment/review.md +95 -0
  20. package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
  21. package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
  22. package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
  23. package/docs/sdd/npm-native-binaries/review.md +103 -0
  24. package/docs/sdd/npm-native-binaries/spec.md +148 -0
  25. package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
  26. package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
  27. package/docs/sdd/runtime-resource-hardening/review.md +86 -0
  28. package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
  29. package/docs/sdd/security-hardening-phase/think.md +129 -0
  30. package/docs/sdd/security-observability/review.md +96 -0
  31. package/docs/sdd/security-observability/spec.md +55 -0
  32. package/docs/sdd/skill-provenance-update/review.md +152 -0
  33. package/docs/sdd/skill-provenance-update/schema.json +193 -0
  34. package/docs/sdd/skill-provenance-update/spec.md +134 -0
  35. package/docs/sdd/supply-chain-hardening/review.md +50 -0
  36. package/docs/sdd/supply-chain-hardening/spec.md +42 -0
  37. package/docs/sdd/target-marker-rehome/spec.md +33 -0
  38. package/docs/troubleshooting.md +18 -2
  39. package/package.json +15 -8
  40. package/src/adapters.ts +0 -438
  41. package/src/audit.ts +0 -21
  42. package/src/cli.ts +0 -928
  43. package/src/clients.ts +0 -368
  44. package/src/commands/audit.ts +0 -85
  45. package/src/commands/config.ts +0 -224
  46. package/src/commands/context.ts +0 -104
  47. package/src/commands/core.ts +0 -56
  48. package/src/commands/doctor.ts +0 -97
  49. package/src/commands/eval.ts +0 -88
  50. package/src/commands/init.ts +0 -672
  51. package/src/commands/install.ts +0 -155
  52. package/src/commands/local-vault.ts +0 -60
  53. package/src/commands/models.ts +0 -10
  54. package/src/commands/outdated.ts +0 -115
  55. package/src/commands/project.ts +0 -580
  56. package/src/commands/report.ts +0 -66
  57. package/src/commands/scan.ts +0 -71
  58. package/src/commands/shared.ts +0 -38
  59. package/src/commands/skill.ts +0 -33
  60. package/src/commands/sync.ts +0 -233
  61. package/src/commands/target.ts +0 -225
  62. package/src/commands/update.ts +0 -277
  63. package/src/completions.ts +0 -196
  64. package/src/concurrency-limiter.ts +0 -61
  65. package/src/config-service.ts +0 -398
  66. package/src/config-watcher.ts +0 -253
  67. package/src/config.ts +0 -583
  68. package/src/context.ts +0 -188
  69. package/src/db-audit.ts +0 -286
  70. package/src/db-index.ts +0 -238
  71. package/src/db.ts +0 -3
  72. package/src/deployment.ts +0 -39
  73. package/src/doctor.ts +0 -205
  74. package/src/eval.ts +0 -302
  75. package/src/global-flags.ts +0 -46
  76. package/src/init-agents.ts +0 -329
  77. package/src/init-instructions.ts +0 -192
  78. package/src/init.ts +0 -390
  79. package/src/install.ts +0 -232
  80. package/src/lifecycle.ts +0 -51
  81. package/src/logger.ts +0 -26
  82. package/src/manifest.ts +0 -359
  83. package/src/mcp-registration.ts +0 -89
  84. package/src/metrics.ts +0 -121
  85. package/src/models.ts +0 -20
  86. package/src/output.ts +0 -214
  87. package/src/project-setup.ts +0 -36
  88. package/src/prompts.ts +0 -124
  89. package/src/provenance.ts +0 -99
  90. package/src/rate-limiter.ts +0 -137
  91. package/src/readiness.ts +0 -30
  92. package/src/redact.ts +0 -52
  93. package/src/router-core.ts +0 -666
  94. package/src/rrf.ts +0 -31
  95. package/src/scan.ts +0 -300
  96. package/src/server.ts +0 -930
  97. package/src/setup.ts +0 -145
  98. package/src/snapshot.ts +0 -135
  99. package/src/stats.ts +0 -340
  100. package/src/sync.ts +0 -482
  101. package/src/toml-writer.ts +0 -51
  102. package/src/types.ts +0 -212
  103. package/src/vault.ts +0 -207
package/src/server.ts DELETED
@@ -1,930 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { createHash, timingSafeEqual } from "node:crypto";
3
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { z } from "zod";
6
- import { createClients } from "./clients";
7
- import { isLoopbackBindHost, loadConfig, resolveConfigPath } from "./config";
8
- import { describeDeployment } from "./deployment";
9
- import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
10
- import { RuntimeSnapshotManager } from "./snapshot";
11
- import {
12
- backfillEmbeddings,
13
- configure,
14
- fetchSkill,
15
- pruneAuditIfDue,
16
- resolveSkill,
17
- } from "./router-core";
18
- import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
19
- import { getStats, parseSince, SINCE_PATTERN } from "./stats";
20
- import { countPrunable, insertAdminAuditRow, pruneAuditBefore, type AdminAuditChange } from "./db";
21
- import { buildPromotedCases, evalVault, queryPromotableFetches } from "./eval";
22
- import { SKILL_ID_PATTERN } from "./vault";
23
- import { MetricsRegistry } from "./metrics";
24
- import { ReadinessState } from "./readiness";
25
- import { initializeRuntime } from "./lifecycle";
26
- import { buildRedactor } from "./redact";
27
- import { redactedErrorLog } from "./logger";
28
- import type { Clients, Config } from "./types";
29
- import {
30
- computeHash,
31
- getEffectiveConfig,
32
- getLocalConfigStatus,
33
- setDottedKey,
34
- RELOADABLE_KEYS,
35
- RESTART_REQUIRED_KEYS,
36
- } from "./config-service";
37
-
38
- export const metricsRegistry = new MetricsRegistry();
39
- export const readinessState = new ReadinessState();
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
-
46
- export interface ServerHandle {
47
- port?: number;
48
- statsPort?: number;
49
- reloadStatus(): ReloadStatus;
50
- stop(): Promise<void>;
51
- }
52
-
53
- /**
54
- * Docker's documented deployment mode auto-switches hostname to "0.0.0.0"
55
- * (config.ts's RUNNING_IN_DOCKER override) while server.auth_enabled still
56
- * defaults to false and allowed_origins defaults to [] — which only blocks
57
- * requests that carry an Origin header, so a plain server-to-server/curl
58
- * request sails through unauthenticated. That default combination leaves MCP
59
- * tools (resolve_skill/fetch_skill) and /stats (raw historical query text)
60
- * open to anyone who can reach the port — the documented common case, not an
61
- * edge case (SMX-91). Refuse to start rather than silently exposing it;
62
- * SKILLMUX_ALLOW_INSECURE_BIND is the explicit, logged escape hatch for
63
- * operators who rely on network-level isolation instead of application auth.
64
- */
65
- export function assertSafeBindPosture(
66
- hostname: string,
67
- authEnabled: boolean,
68
- env: Record<string, string | undefined> = process.env,
69
- ): void {
70
- if (isLoopbackBindHost(hostname) || authEnabled) return;
71
- const message =
72
- `refusing to bind "${hostname}" (reachable beyond this machine) with server.auth_enabled=false — ` +
73
- "MCP tools (resolve_skill/fetch_skill) and /stats would be open to anyone who can reach this port. " +
74
- "Set server.auth_enabled=true (with SKILLMUX_AUTH_TOKEN) or bind a loopback hostname instead. " +
75
- "To start anyway — e.g. when network isolation is the intended boundary — set SKILLMUX_ALLOW_INSECURE_BIND=true.";
76
- if (env.SKILLMUX_ALLOW_INSECURE_BIND !== "true") {
77
- throw new Error(`skillmux: ${message}`);
78
- }
79
- console.error(`skillmux: WARNING — ${message}`);
80
- }
81
-
82
- let warnedAuthToken = false;
83
- function resolveAuthToken(envName: string): string {
84
- const value = process.env[envName];
85
- if (value) return value;
86
- if (
87
- envName === "SKILLMUX_AUTH_TOKEN" &&
88
- process.env.SKILL_ROUTER_AUTH_TOKEN
89
- ) {
90
- if (!warnedAuthToken) {
91
- warnedAuthToken = true;
92
- console.error(
93
- "skillmux: SKILL_ROUTER_AUTH_TOKEN is deprecated, set SKILLMUX_AUTH_TOKEN instead",
94
- );
95
- }
96
- return process.env.SKILL_ROUTER_AUTH_TOKEN;
97
- }
98
- return "";
99
- }
100
-
101
- // SMX-94: comparing raw buffers made length itself observable — a
102
- // mismatched-length pair returns before ever reaching timingSafeEqual.
103
- // Hashing both sides to a fixed 32-byte digest first means every
104
- // comparison takes the same constant-time path regardless of input length.
105
- export function safeTokenEquals(a: string, b: string): boolean {
106
- const hashA = createHash("sha256").update(a).digest();
107
- const hashB = createHash("sha256").update(b).digest();
108
- return timingSafeEqual(hashA, hashB);
109
- }
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
-
205
- export function createMcpServer(): McpServer {
206
- const server = new McpServer({ name: "skillmux", version: "0.1.0" });
207
-
208
- // Transport rule from schema.json: the SKILL.md body appears exactly once on
209
- // the wire — verbatim as text content; structuredContent carries the metadata.
210
- server.registerTool(
211
- "resolve_skill",
212
- {
213
- description:
214
- "Route a natural-language task description to candidate skills in the vault. " +
215
- "Returns a ranked shortlist of candidates. Use fetch_skill to retrieve a candidate's complete instructions.",
216
- inputSchema: {
217
- query: z.string().min(1).describe("Natural-language task or prompt description to route."),
218
- top_k: z
219
- .number()
220
- .int()
221
- .min(1)
222
- .optional()
223
- .describe("Optional maximum number of ranked candidates to return (subject to server max_top_k limit)."),
224
- },
225
- },
226
- async ({ query, top_k }) => {
227
- const startTime = performance.now();
228
- try {
229
- const result = await resolveSkill({ query, top_k });
230
- const duration = (performance.now() - startTime) / 1000;
231
- metricsRegistry.recordResolveLatencySeconds(duration);
232
- if (result.degradation_reason) {
233
- const stage = result.degradation_reason.startsWith("embedding_") ? "embedding" : "reranker";
234
- metricsRegistry.recordDegradation(stage, result.degradation_reason);
235
- }
236
-
237
- return {
238
- content: [{ type: "text" as const, text: JSON.stringify(result) }],
239
- structuredContent: { ...result },
240
- };
241
- } catch (err) {
242
- metricsRegistry.recordError();
243
- throw err;
244
- }
245
- },
246
- );
247
-
248
- server.registerTool(
249
- "fetch_skill",
250
- {
251
- description:
252
- "Fetch a skill's SKILL.md verbatim by skill_id, with sha256 and supporting-file paths. " +
253
- "Independent of any prior resolve_skill outcome. Pass the request_id from a prior " +
254
- "resolve_skill call to link this fetch to it for quality measurement.",
255
- inputSchema: {
256
- skill_id: z.string().regex(SKILL_ID_PATTERN),
257
- request_id: z.string().min(1).max(128).optional(),
258
- },
259
- },
260
- async ({ skill_id, request_id }) => {
261
- try {
262
- const result = await fetchSkill({ skill_id, request_id });
263
- const { body, ...meta } = result;
264
- return {
265
- content: [{ type: "text" as const, text: body }],
266
- structuredContent: { ...meta },
267
- };
268
- } catch (err) {
269
- metricsRegistry.recordError();
270
- throw err;
271
- }
272
- },
273
- );
274
-
275
- return server;
276
- }
277
-
278
- export async function startServer(opts?: {
279
- transport?: "stdio" | "http";
280
- port?: number;
281
- statsPort?: number;
282
- config?: Config;
283
- clients?: Partial<Clients>;
284
- configPath?: string;
285
- }): Promise<ServerHandle> {
286
- const configPath = resolveConfigPath(opts?.configPath);
287
- const config = opts?.config ?? (await loadConfig(configPath));
288
- const redact = buildRedactor(config);
289
- const initialClients = { ...createClients(config), ...opts?.clients };
290
- const snapshots = RuntimeSnapshotManager.create(config, initialClients);
291
- const inactiveReloadStatus: ReloadStatus = {
292
- last_successful_reload_at: null,
293
- last_reload_error: null,
294
- restart_required_keys: [],
295
- };
296
- configure({ config, clients: initialClients });
297
- metricsRegistry.setDeployment(describeDeployment(config));
298
- // An injected config has no guaranteed file source. Watch it only when the
299
- // caller explicitly supplies that source; normal server startup always watches.
300
- const watcherPath =
301
- opts?.configPath ?? (opts?.config ? undefined : configPath);
302
- const configWatcher = watcherPath
303
- ? await ConfigWatcher.start(watcherPath, {
304
- onReload: (nextConfig) => {
305
- const nextClients = {
306
- ...createClients(nextConfig),
307
- ...opts?.clients,
308
- };
309
- snapshots.replace(nextConfig, nextClients);
310
- configure({ config: nextConfig, clients: nextClients });
311
- },
312
- onError: (error) =>
313
- console.error(...redactedErrorLog("skillmux config reload error:", error, redact)),
314
- })
315
- : undefined;
316
- const stopWatcher = await startVaultWatcher();
317
- const initPromise = initializeRuntime(readinessState)
318
- .then(() => metricsRegistry.setReadiness(readinessState.get()))
319
- .catch((err) => console.error(...redactedErrorLog("skillmux runtime init error:", err, redact)));
320
-
321
- // AC14: fire-and-forget so this never delays readiness or blocks a resolve;
322
- // not chained onto initPromise, which is awaited below for HTTP transport.
323
- const runAuditPrune = () =>
324
- pruneAuditIfDue().catch((err) => console.error(...redactedErrorLog("skillmux audit prune error:", err, redact)));
325
- runAuditPrune();
326
- const auditPruneInterval = setInterval(runAuditPrune, 24 * 60 * 60 * 1000);
327
- auditPruneInterval.unref();
328
-
329
- const server = createMcpServer();
330
-
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
-
342
- if (transportType === "http") {
343
- const { WebStandardStreamableHTTPServerTransport } =
344
- await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
345
- const transport = new WebStandardStreamableHTTPServerTransport({
346
- sessionIdGenerator: () => crypto.randomUUID(),
347
- });
348
- await server.connect(transport);
349
-
350
- const { RateLimiter } = await import("./rate-limiter");
351
- const rateLimiter = new RateLimiter(
352
- config.server?.rate_limit || { enabled: false, requests_per_minute: 60 },
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
- );
358
-
359
- const port = opts?.port ?? Number(process.env.PORT || 3000);
360
- const hostname = config.server?.hostname ?? "127.0.0.1";
361
- assertSafeBindPosture(hostname, config.server?.auth_enabled ?? false);
362
- const bunServer = Bun.serve({
363
- port,
364
- hostname,
365
- maxRequestBodySize: config.server?.max_body_bytes ?? DEFAULT_MAX_BODY_BYTES,
366
- async fetch(req, server) {
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
- });
375
- }
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,
388
- });
389
- } catch (error) {
390
- concurrencyLimiter.release();
391
- throw error;
392
- }
393
-
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 });
415
- }
416
-
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
- });
426
- }
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,
434
- });
435
-
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);
454
- if (allowOriginHeader) {
455
- headers.set("Access-Control-Allow-Origin", allowOriginHeader);
456
- }
457
- return new Response("Too Many Requests", {
458
- status: 429,
459
- headers,
460
- });
461
- }
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();
547
- const headers = new Headers({ "Content-Type": "application/json" });
548
- if (allowOriginHeader)
549
- headers.set("Access-Control-Allow-Origin", allowOriginHeader);
550
- for (const [key, value] of Object.entries(rateLimitResult.headers))
551
- headers.set(key, value);
552
- return new Response(JSON.stringify(getStats(auditDb, since)), {
553
- status: 200,
554
- headers,
555
- });
556
- }
557
-
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
- }
563
-
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
- }
579
-
580
- const headers = new Headers({ "Content-Type": "application/json" });
581
- if (allowOriginHeader)
582
- headers.set("Access-Control-Allow-Origin", allowOriginHeader);
583
-
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
- }
603
-
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}"`);
614
- return new Response(
615
- JSON.stringify({
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
- },
633
- }),
634
- { status: 200, headers },
635
- );
636
- }
637
-
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
- contextName: "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
- }
694
-
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);
778
- return new Response(
779
- JSON.stringify({
780
- ...counts,
781
- dry_run: false,
782
- cutoff: cutoffIso,
783
- }),
784
- { status: 200, headers },
785
- );
786
- }
787
-
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,
793
- });
794
- }
795
-
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
- }
849
-
850
- return new Response("Not Found", { status: 404, headers });
851
- }
852
-
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
864
- }
865
- metricsRegistry.recordRequest(mcpMethod);
866
-
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
- });
880
- }
881
- },
882
- });
883
- let stopped = false;
884
- await initPromise;
885
- console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
886
- return {
887
- port: bunServer.port,
888
- statsPort: statsHandle?.port,
889
- reloadStatus: () =>
890
- configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
891
- async stop() {
892
- if (stopped) return;
893
- stopped = true;
894
- clearInterval(auditPruneInterval);
895
- readinessState.set({ ...readinessState.get(), status: "stopping" });
896
- metricsRegistry.setReadiness(readinessState.get());
897
- bunServer.stop(true);
898
- statsHandle?.stop();
899
- configWatcher?.stop();
900
- stopWatcher();
901
- snapshots.dispose();
902
- await server.close();
903
- closeRuntime();
904
- },
905
- };
906
- } else {
907
- await server.connect(new StdioServerTransport());
908
- let stopped = false;
909
- return {
910
- statsPort: statsHandle?.port,
911
- reloadStatus: () =>
912
- configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
913
- async stop() {
914
- if (stopped) return;
915
- stopped = true;
916
- clearInterval(auditPruneInterval);
917
- readinessState.set({ ...readinessState.get(), status: "stopping" });
918
- metricsRegistry.setReadiness(readinessState.get());
919
- statsHandle?.stop();
920
- configWatcher?.stop();
921
- stopWatcher();
922
- snapshots.dispose();
923
- await server.close();
924
- closeRuntime();
925
- },
926
- };
927
- }
928
- }
929
-
930
- if (import.meta.main) await startServer();