@klhapp/skillmux 1.11.2 → 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 (104) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +20 -10
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +15 -5
  6. package/docs/concepts.md +4 -3
  7. package/docs/configuration.md +22 -5
  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/skill-management.md +1 -1
  39. package/docs/troubleshooting.md +18 -2
  40. package/package.json +15 -8
  41. package/src/adapters.ts +0 -438
  42. package/src/audit.ts +0 -21
  43. package/src/cli.ts +0 -928
  44. package/src/clients.ts +0 -368
  45. package/src/commands/audit.ts +0 -85
  46. package/src/commands/config.ts +0 -224
  47. package/src/commands/context.ts +0 -104
  48. package/src/commands/core.ts +0 -56
  49. package/src/commands/doctor.ts +0 -97
  50. package/src/commands/eval.ts +0 -88
  51. package/src/commands/init.ts +0 -672
  52. package/src/commands/install.ts +0 -155
  53. package/src/commands/local-vault.ts +0 -60
  54. package/src/commands/models.ts +0 -10
  55. package/src/commands/outdated.ts +0 -115
  56. package/src/commands/project.ts +0 -580
  57. package/src/commands/report.ts +0 -66
  58. package/src/commands/scan.ts +0 -71
  59. package/src/commands/shared.ts +0 -38
  60. package/src/commands/skill.ts +0 -33
  61. package/src/commands/sync.ts +0 -233
  62. package/src/commands/target.ts +0 -225
  63. package/src/commands/update.ts +0 -277
  64. package/src/completions.ts +0 -196
  65. package/src/concurrency-limiter.ts +0 -61
  66. package/src/config-service.ts +0 -398
  67. package/src/config-watcher.ts +0 -253
  68. package/src/config.ts +0 -583
  69. package/src/context.ts +0 -188
  70. package/src/db-audit.ts +0 -286
  71. package/src/db-index.ts +0 -238
  72. package/src/db.ts +0 -3
  73. package/src/deployment.ts +0 -39
  74. package/src/doctor.ts +0 -205
  75. package/src/eval.ts +0 -302
  76. package/src/global-flags.ts +0 -46
  77. package/src/init-agents.ts +0 -329
  78. package/src/init-instructions.ts +0 -192
  79. package/src/init.ts +0 -389
  80. package/src/install.ts +0 -232
  81. package/src/lifecycle.ts +0 -51
  82. package/src/logger.ts +0 -26
  83. package/src/manifest.ts +0 -352
  84. package/src/mcp-registration.ts +0 -89
  85. package/src/metrics.ts +0 -121
  86. package/src/models.ts +0 -20
  87. package/src/output.ts +0 -214
  88. package/src/project-setup.ts +0 -36
  89. package/src/prompts.ts +0 -124
  90. package/src/provenance.ts +0 -99
  91. package/src/rate-limiter.ts +0 -137
  92. package/src/readiness.ts +0 -30
  93. package/src/redact.ts +0 -52
  94. package/src/router-core.ts +0 -666
  95. package/src/rrf.ts +0 -31
  96. package/src/scan.ts +0 -300
  97. package/src/server.ts +0 -930
  98. package/src/setup.ts +0 -145
  99. package/src/snapshot.ts +0 -135
  100. package/src/stats.ts +0 -340
  101. package/src/sync.ts +0 -482
  102. package/src/toml-writer.ts +0 -51
  103. package/src/types.ts +0 -212
  104. 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();