@klhapp/skillmux 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/config.example.toml +5 -0
- package/config.remote.example.toml +25 -0
- package/docs/configuration.md +100 -0
- package/docs/releasing.md +62 -0
- package/docs/schema.json +314 -0
- package/package.json +55 -0
- package/src/audit.ts +15 -0
- package/src/cli.ts +482 -0
- package/src/clients.ts +114 -0
- package/src/config.ts +338 -0
- package/src/db.ts +280 -0
- package/src/decision.ts +42 -0
- package/src/doctor.ts +68 -0
- package/src/eval.ts +74 -0
- package/src/init.ts +122 -0
- package/src/install.ts +113 -0
- package/src/lifecycle.ts +51 -0
- package/src/manifest.ts +105 -0
- package/src/metrics.ts +101 -0
- package/src/models.ts +20 -0
- package/src/rate-limiter.ts +110 -0
- package/src/readiness.ts +30 -0
- package/src/router-core.ts +435 -0
- package/src/rrf.ts +31 -0
- package/src/scan.ts +260 -0
- package/src/server.ts +318 -0
- package/src/stats.ts +165 -0
- package/src/sync.ts +182 -0
- package/src/types.ts +178 -0
- package/src/vault.ts +109 -0
package/src/scan.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { decodeUtf8Strict, listSupportingFiles, scanVault } from "./vault";
|
|
4
|
+
|
|
5
|
+
export type ScanSeverity = "low" | "medium" | "high";
|
|
6
|
+
|
|
7
|
+
export interface RuleMatch {
|
|
8
|
+
rule_id: string;
|
|
9
|
+
severity: ScanSeverity;
|
|
10
|
+
message: string;
|
|
11
|
+
line?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Rule = (content: string) => RuleMatch[];
|
|
15
|
+
|
|
16
|
+
function lineOf(content: string, index: number): number {
|
|
17
|
+
return content.slice(0, index).split("\n").length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const INJECTION_PHRASES = [
|
|
21
|
+
"ignore previous instructions",
|
|
22
|
+
"ignore all previous instructions",
|
|
23
|
+
"ignore your instructions",
|
|
24
|
+
"disregard all prior instructions",
|
|
25
|
+
"disregard the above",
|
|
26
|
+
"disregard previous instructions",
|
|
27
|
+
"new instructions:",
|
|
28
|
+
"system prompt:",
|
|
29
|
+
"you are now",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const promptInjectionPhraseRule: Rule = (content) => {
|
|
33
|
+
const lower = content.toLowerCase();
|
|
34
|
+
const matches: RuleMatch[] = [];
|
|
35
|
+
for (const phrase of INJECTION_PHRASES) {
|
|
36
|
+
const index = lower.indexOf(phrase);
|
|
37
|
+
if (index !== -1) {
|
|
38
|
+
matches.push({
|
|
39
|
+
rule_id: "prompt-injection-phrase",
|
|
40
|
+
severity: "high",
|
|
41
|
+
message: `contains instruction-override phrase: "${phrase}"`,
|
|
42
|
+
line: lineOf(content, index),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return matches;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const INVISIBLE_CODE_POINTS = new Set([0x200b, 0x200c, 0x200d, 0xfeff]);
|
|
50
|
+
const TAG_CHAR_START = 0xe0000;
|
|
51
|
+
const TAG_CHAR_END = 0xe007f;
|
|
52
|
+
|
|
53
|
+
function isInvisibleCodePoint(codePoint: number): boolean {
|
|
54
|
+
return INVISIBLE_CODE_POINTS.has(codePoint) || (codePoint >= TAG_CHAR_START && codePoint <= TAG_CHAR_END);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const invisibleUnicodeRule: Rule = (content) => {
|
|
58
|
+
let count = 0;
|
|
59
|
+
let firstIndex = -1;
|
|
60
|
+
let searchOffset = 0;
|
|
61
|
+
for (const char of content) {
|
|
62
|
+
const codePoint = char.codePointAt(0)!;
|
|
63
|
+
if (isInvisibleCodePoint(codePoint)) {
|
|
64
|
+
count++;
|
|
65
|
+
if (firstIndex === -1) firstIndex = searchOffset;
|
|
66
|
+
}
|
|
67
|
+
searchOffset += char.length;
|
|
68
|
+
}
|
|
69
|
+
if (count === 0) return [];
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
rule_id: "invisible-unicode",
|
|
73
|
+
severity: "high",
|
|
74
|
+
message: `contains ${count} invisible/zero-width Unicode character${count === 1 ? "" : "s"}`,
|
|
75
|
+
line: lineOf(content, firstIndex),
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const SECRET_PATTERNS: { pattern: RegExp; describe: string }[] = [
|
|
81
|
+
{ pattern: /AKIA[0-9A-Z]{16}/, describe: "AWS-style access key" },
|
|
82
|
+
{ pattern: /-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----/, describe: "PEM private key block" },
|
|
83
|
+
{
|
|
84
|
+
pattern: /\b(api[_-]?key|token|secret)\s*[:=]\s*["']?[A-Za-z0-9_-]{16,}["']?/i,
|
|
85
|
+
describe: "hardcoded credential-shaped assignment",
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export const secretPatternRule: Rule = (content) => {
|
|
90
|
+
const matches: RuleMatch[] = [];
|
|
91
|
+
for (const { pattern, describe } of SECRET_PATTERNS) {
|
|
92
|
+
const match = pattern.exec(content);
|
|
93
|
+
if (match) {
|
|
94
|
+
matches.push({
|
|
95
|
+
rule_id: "secret-pattern",
|
|
96
|
+
severity: "high",
|
|
97
|
+
message: `contains a ${describe}`,
|
|
98
|
+
line: lineOf(content, match.index),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return matches;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const URL_PATTERN = /https?:\/\/[^\s)"']+/g;
|
|
106
|
+
const BARE_IP_URL_PATTERN = /^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?/;
|
|
107
|
+
const EXFIL_PHRASES = [
|
|
108
|
+
"post this to",
|
|
109
|
+
"send this to",
|
|
110
|
+
"upload this to",
|
|
111
|
+
"send the contents",
|
|
112
|
+
"exfiltrate",
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
export const suspiciousUrlRule: Rule = (content) => {
|
|
116
|
+
const lower = content.toLowerCase();
|
|
117
|
+
const matches: RuleMatch[] = [];
|
|
118
|
+
for (const match of content.matchAll(URL_PATTERN)) {
|
|
119
|
+
const url = match[0]!;
|
|
120
|
+
if (BARE_IP_URL_PATTERN.test(url)) {
|
|
121
|
+
matches.push({
|
|
122
|
+
rule_id: "suspicious-url",
|
|
123
|
+
severity: "medium",
|
|
124
|
+
message: `bare-IP-address URL: ${url}`,
|
|
125
|
+
line: lineOf(content, match.index!),
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const nearby = lower.slice(Math.max(0, match.index! - 60), match.index!);
|
|
130
|
+
const exfilPhrase = EXFIL_PHRASES.find((phrase) => nearby.includes(phrase));
|
|
131
|
+
if (exfilPhrase) {
|
|
132
|
+
matches.push({
|
|
133
|
+
rule_id: "suspicious-url",
|
|
134
|
+
severity: "medium",
|
|
135
|
+
message: `URL paired with exfiltration-suggesting text ("${exfilPhrase}"): ${url}`,
|
|
136
|
+
line: lineOf(content, match.index!),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return matches;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const RULES: Rule[] = [promptInjectionPhraseRule, invisibleUnicodeRule, secretPatternRule, suspiciousUrlRule];
|
|
144
|
+
|
|
145
|
+
export function scanContent(content: string): RuleMatch[] {
|
|
146
|
+
return RULES.flatMap((rule) => rule(content));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ScanFinding extends RuleMatch {
|
|
150
|
+
skill_id: string;
|
|
151
|
+
file: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ScanResult {
|
|
155
|
+
scanned: number;
|
|
156
|
+
findings: ScanFinding[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface ScanContentTarget {
|
|
160
|
+
skill_id: string;
|
|
161
|
+
file: string;
|
|
162
|
+
content: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function readTextFileOrNull(path: string): Promise<string | null> {
|
|
166
|
+
try {
|
|
167
|
+
const bytes = await Bun.file(path).bytes();
|
|
168
|
+
return decodeUtf8Strict(bytes);
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function collectSkillTargets(
|
|
175
|
+
vaultPath: string,
|
|
176
|
+
skillId: string,
|
|
177
|
+
skillMdBody: string,
|
|
178
|
+
): Promise<ScanContentTarget[]> {
|
|
179
|
+
const targets: ScanContentTarget[] = [{ skill_id: skillId, file: "SKILL.md", content: skillMdBody }];
|
|
180
|
+
for (const rel of listSupportingFiles(vaultPath, skillId)) {
|
|
181
|
+
const content = await readTextFileOrNull(join(vaultPath, skillId, rel));
|
|
182
|
+
if (content !== null) targets.push({ skill_id: skillId, file: rel, content });
|
|
183
|
+
}
|
|
184
|
+
return targets;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface ResolvedScanTargets {
|
|
188
|
+
targets: ScanContentTarget[];
|
|
189
|
+
/** skill_ids whose SKILL.md could not be parsed/decoded — must still be counted and
|
|
190
|
+
* flagged, never silently dropped, or a malformed SKILL.md becomes a scan-evasion trick. */
|
|
191
|
+
unparseable: string[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Single-skill-dir mode when `rootPath` itself holds a SKILL.md; otherwise treats
|
|
195
|
+
* `rootPath` as a vault root and enumerates every skill dir under it. */
|
|
196
|
+
async function resolveScanTargets(rootPath: string): Promise<ResolvedScanTargets> {
|
|
197
|
+
if (existsSync(join(rootPath, "SKILL.md"))) {
|
|
198
|
+
const skillId = basename(rootPath);
|
|
199
|
+
const vaultPath = dirname(rootPath);
|
|
200
|
+
const body = await readTextFileOrNull(join(rootPath, "SKILL.md"));
|
|
201
|
+
if (body === null) return { targets: [], unparseable: [skillId] };
|
|
202
|
+
return { targets: await collectSkillTargets(vaultPath, skillId, body), unparseable: [] };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const unparseable: string[] = [];
|
|
206
|
+
const skills = await scanVault(rootPath, (skillId) => unparseable.push(skillId));
|
|
207
|
+
const targets: ScanContentTarget[] = [];
|
|
208
|
+
for (const skill of skills) {
|
|
209
|
+
targets.push(...(await collectSkillTargets(rootPath, skill.skill_id, skill.body)));
|
|
210
|
+
}
|
|
211
|
+
return { targets, unparseable };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function scanPath(rootPath: string): Promise<ScanResult> {
|
|
215
|
+
const { targets, unparseable } = await resolveScanTargets(rootPath);
|
|
216
|
+
const findings: ScanFinding[] = [];
|
|
217
|
+
const skillIds = new Set<string>();
|
|
218
|
+
for (const target of targets) {
|
|
219
|
+
skillIds.add(target.skill_id);
|
|
220
|
+
for (const match of scanContent(target.content)) {
|
|
221
|
+
findings.push({ ...match, skill_id: target.skill_id, file: target.file });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const skillId of unparseable) {
|
|
225
|
+
skillIds.add(skillId);
|
|
226
|
+
findings.push({
|
|
227
|
+
skill_id: skillId,
|
|
228
|
+
file: "SKILL.md",
|
|
229
|
+
rule_id: "unparseable-skill",
|
|
230
|
+
severity: "medium",
|
|
231
|
+
message: "SKILL.md could not be parsed or decoded — content was not scanned; review manually",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return { scanned: skillIds.size, findings };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function renderScanText(result: ScanResult): string {
|
|
238
|
+
const skillWord = result.scanned === 1 ? "skill" : "skills";
|
|
239
|
+
if (result.findings.length === 0) {
|
|
240
|
+
return `scanned ${result.scanned} ${skillWord}, no findings`;
|
|
241
|
+
}
|
|
242
|
+
const lines: string[] = [`scanned ${result.scanned} ${skillWord}, ${result.findings.length} finding(s)`];
|
|
243
|
+
for (const finding of result.findings) {
|
|
244
|
+
const location = finding.line !== undefined ? `${finding.file}:${finding.line}` : finding.file;
|
|
245
|
+
lines.push(`[${finding.severity}] ${finding.skill_id}/${location} ${finding.rule_id} — ${finding.message}`);
|
|
246
|
+
}
|
|
247
|
+
return lines.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function renderScanJson(result: ScanResult): string {
|
|
251
|
+
return JSON.stringify(result, null, 2);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const SEVERITY_RANK: Record<ScanSeverity, number> = { low: 0, medium: 1, high: 2 };
|
|
255
|
+
|
|
256
|
+
export function scanExitCode(findings: RuleMatch[], failOn: ScanSeverity | undefined): number {
|
|
257
|
+
if (!failOn) return 0;
|
|
258
|
+
const threshold = SEVERITY_RANK[failOn];
|
|
259
|
+
return findings.some((f) => SEVERITY_RANK[f.severity] >= threshold) ? 1 : 0;
|
|
260
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { 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 { loadConfig } from "./config";
|
|
8
|
+
import { backfillEmbeddings, configure, fetchSkill, resolveSkill } from "./router-core";
|
|
9
|
+
import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
|
|
10
|
+
import { getStats, SINCE_PATTERN } from "./stats";
|
|
11
|
+
import { SKILL_ID_PATTERN } from "./vault";
|
|
12
|
+
import { MetricsRegistry } from "./metrics";
|
|
13
|
+
import { ReadinessState } from "./readiness";
|
|
14
|
+
import { initializeRuntime } from "./lifecycle";
|
|
15
|
+
import type { Clients, Config } from "./types";
|
|
16
|
+
|
|
17
|
+
export const metricsRegistry = new MetricsRegistry();
|
|
18
|
+
export const readinessState = new ReadinessState();
|
|
19
|
+
|
|
20
|
+
export interface ServerHandle {
|
|
21
|
+
port?: number;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let warnedAuthToken = false;
|
|
26
|
+
function resolveAuthToken(envName: string): string {
|
|
27
|
+
const value = process.env[envName];
|
|
28
|
+
if (value) return value;
|
|
29
|
+
if (envName === "SKILLMUX_AUTH_TOKEN" && process.env.SKILL_ROUTER_AUTH_TOKEN) {
|
|
30
|
+
if (!warnedAuthToken) {
|
|
31
|
+
warnedAuthToken = true;
|
|
32
|
+
console.error("skillmux: SKILL_ROUTER_AUTH_TOKEN is deprecated, set SKILLMUX_AUTH_TOKEN instead");
|
|
33
|
+
}
|
|
34
|
+
return process.env.SKILL_ROUTER_AUTH_TOKEN;
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeTokenEquals(a: string, b: string): boolean {
|
|
40
|
+
const bufA = Buffer.from(a);
|
|
41
|
+
const bufB = Buffer.from(b);
|
|
42
|
+
if (bufA.length !== bufB.length) return false;
|
|
43
|
+
return timingSafeEqual(bufA, bufB);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function startServer(opts?: {
|
|
47
|
+
transport?: "stdio" | "http";
|
|
48
|
+
port?: number;
|
|
49
|
+
config?: Config;
|
|
50
|
+
clients?: Partial<Clients>;
|
|
51
|
+
}): Promise<ServerHandle> {
|
|
52
|
+
const config = opts?.config ?? await loadConfig();
|
|
53
|
+
configure({ config, clients: opts?.clients ?? createClients(config) });
|
|
54
|
+
await initializeRuntime(readinessState);
|
|
55
|
+
metricsRegistry.setReadiness(readinessState.get());
|
|
56
|
+
const stopWatcher = await startVaultWatcher();
|
|
57
|
+
|
|
58
|
+
const server = new McpServer({ name: "skillmux", version: "0.1.0" });
|
|
59
|
+
|
|
60
|
+
// Transport rule from schema.json: the SKILL.md body appears exactly once on
|
|
61
|
+
// the wire — verbatim as text content; structuredContent carries the metadata.
|
|
62
|
+
server.registerTool(
|
|
63
|
+
"resolve_skill",
|
|
64
|
+
{
|
|
65
|
+
description:
|
|
66
|
+
"Route a natural-language task description to the most relevant skill in the vault. " +
|
|
67
|
+
"Returns outcome matched (skill delivered inline), ambiguous (shortlist — pick one, then call fetch_skill), " +
|
|
68
|
+
"or no_match (proceed under your normal workflow).",
|
|
69
|
+
inputSchema: { query: z.string().min(1).max(8192) },
|
|
70
|
+
},
|
|
71
|
+
async ({ query }) => {
|
|
72
|
+
const startTime = performance.now();
|
|
73
|
+
try {
|
|
74
|
+
const result = await resolveSkill({ query });
|
|
75
|
+
const duration = (performance.now() - startTime) / 1000;
|
|
76
|
+
metricsRegistry.recordResolveLatencySeconds(duration);
|
|
77
|
+
metricsRegistry.recordResolveOutcome(result.outcome);
|
|
78
|
+
|
|
79
|
+
if (result.outcome === "matched") {
|
|
80
|
+
const { body, ...meta } = result;
|
|
81
|
+
return { content: [{ type: "text" as const, text: body }], structuredContent: { ...meta } };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
85
|
+
structuredContent: { ...result },
|
|
86
|
+
};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
metricsRegistry.recordError();
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
server.registerTool(
|
|
95
|
+
"fetch_skill",
|
|
96
|
+
{
|
|
97
|
+
description:
|
|
98
|
+
"Fetch a skill's SKILL.md verbatim by skill_id, with sha256 and supporting-file paths. " +
|
|
99
|
+
"Independent of any prior resolve_skill outcome.",
|
|
100
|
+
inputSchema: { skill_id: z.string().regex(SKILL_ID_PATTERN) },
|
|
101
|
+
},
|
|
102
|
+
async ({ skill_id }) => {
|
|
103
|
+
try {
|
|
104
|
+
const result = await fetchSkill({ skill_id });
|
|
105
|
+
const { body, ...meta } = result;
|
|
106
|
+
return { content: [{ type: "text" as const, text: body }], structuredContent: { ...meta } };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
metricsRegistry.recordError();
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const transportType = opts?.transport ?? "stdio";
|
|
115
|
+
if (transportType === "http") {
|
|
116
|
+
const { WebStandardStreamableHTTPServerTransport } = await import(
|
|
117
|
+
"@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
|
118
|
+
);
|
|
119
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
120
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
121
|
+
});
|
|
122
|
+
await server.connect(transport);
|
|
123
|
+
|
|
124
|
+
const { RateLimiter } = await import("./rate-limiter");
|
|
125
|
+
const rateLimiter = new RateLimiter(
|
|
126
|
+
config.server?.rate_limit || { enabled: false, requests_per_minute: 60 }
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const port = opts?.port ?? Number(process.env.PORT || 3000);
|
|
130
|
+
const hostname = config.server?.hostname ?? "127.0.0.1";
|
|
131
|
+
const bunServer = Bun.serve({
|
|
132
|
+
port,
|
|
133
|
+
hostname,
|
|
134
|
+
async fetch(req, server) {
|
|
135
|
+
const serverConfig = config.server || {
|
|
136
|
+
auth_enabled: false,
|
|
137
|
+
auth_token_env: "SKILLMUX_AUTH_TOKEN",
|
|
138
|
+
allowed_origins: [],
|
|
139
|
+
};
|
|
140
|
+
const origin = req.headers.get("origin") || "";
|
|
141
|
+
const allowedOrigins = serverConfig.allowed_origins;
|
|
142
|
+
const isAllowed = allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
143
|
+
const allowOriginHeader = isAllowed ? (allowedOrigins.includes("*") ? "*" : origin) : "";
|
|
144
|
+
|
|
145
|
+
if (origin && !isAllowed) {
|
|
146
|
+
return new Response("CORS origin not allowed", { status: 403 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (req.method === "OPTIONS") {
|
|
150
|
+
return new Response(null, {
|
|
151
|
+
headers: {
|
|
152
|
+
"Access-Control-Allow-Origin": allowOriginHeader,
|
|
153
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
154
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Run rate limiter check
|
|
160
|
+
const rateLimitResult = rateLimiter.check({
|
|
161
|
+
nowMs: Date.now(),
|
|
162
|
+
auth_enabled: serverConfig.auth_enabled,
|
|
163
|
+
req,
|
|
164
|
+
server,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!rateLimitResult.allowed) {
|
|
168
|
+
metricsRegistry.recordRateLimitExceeded();
|
|
169
|
+
|
|
170
|
+
// Count the request in requests_total under the method if possible
|
|
171
|
+
let mcpMethod = "unknown";
|
|
172
|
+
try {
|
|
173
|
+
const bodyClone = await req.clone().json();
|
|
174
|
+
if (bodyClone.method === "tools/call") {
|
|
175
|
+
mcpMethod = bodyClone.params?.name || "tools/call";
|
|
176
|
+
} else {
|
|
177
|
+
mcpMethod = bodyClone.method || "unknown";
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Non-JSON or parsing error
|
|
181
|
+
}
|
|
182
|
+
metricsRegistry.recordRequest(mcpMethod);
|
|
183
|
+
|
|
184
|
+
const headers = new Headers(rateLimitResult.headers);
|
|
185
|
+
if (allowOriginHeader) {
|
|
186
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
187
|
+
}
|
|
188
|
+
return new Response("Too Many Requests", {
|
|
189
|
+
status: 429,
|
|
190
|
+
headers,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const url = new URL(req.url);
|
|
195
|
+
if (req.method === "GET") {
|
|
196
|
+
if (url.pathname === "/health" || url.pathname === "/health/live") {
|
|
197
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
198
|
+
if (allowOriginHeader) {
|
|
199
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
200
|
+
}
|
|
201
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers)) {
|
|
202
|
+
headers.set(key, value);
|
|
203
|
+
}
|
|
204
|
+
return new Response(JSON.stringify({ status: "ok" }), { status: 200, headers });
|
|
205
|
+
}
|
|
206
|
+
if (url.pathname === "/health/ready") {
|
|
207
|
+
const readiness = readinessState.get();
|
|
208
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
209
|
+
if (allowOriginHeader) headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
210
|
+
return new Response(JSON.stringify(readiness), {
|
|
211
|
+
status: readiness.status === "ready" ? 200 : 503,
|
|
212
|
+
headers,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (url.pathname === "/metrics") {
|
|
216
|
+
const headers = new Headers({ "Content-Type": "text/plain; version=0.0.4" });
|
|
217
|
+
if (allowOriginHeader) {
|
|
218
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
219
|
+
}
|
|
220
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers)) {
|
|
221
|
+
headers.set(key, value);
|
|
222
|
+
}
|
|
223
|
+
return new Response(metricsRegistry.render(), { status: 200, headers });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Token Auth Check
|
|
228
|
+
if (serverConfig.auth_enabled) {
|
|
229
|
+
const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
|
|
230
|
+
if (!expectedToken) {
|
|
231
|
+
return new Response("Server authentication configured but token environment variable is empty", { status: 500 });
|
|
232
|
+
}
|
|
233
|
+
const authHeader = req.headers.get("authorization") || "";
|
|
234
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
|
|
235
|
+
if (!token || !safeTokenEquals(token, expectedToken)) {
|
|
236
|
+
return new Response("Unauthorized", { status: 401 });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// GET /stats — placed after the Token Auth Check above (unlike /health and /metrics,
|
|
241
|
+
// which return earlier and stay open) since audit queries carry raw user text.
|
|
242
|
+
if (req.method === "GET" && url.pathname === "/stats") {
|
|
243
|
+
const since = url.searchParams.get("since") ?? "";
|
|
244
|
+
if (!SINCE_PATTERN.test(since)) {
|
|
245
|
+
return new Response(
|
|
246
|
+
JSON.stringify({ error: "since must be a relative window (e.g. 30d) or an absolute ISO-8601 date" }),
|
|
247
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const { db } = await getRuntime();
|
|
251
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
252
|
+
if (allowOriginHeader) headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
253
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers)) headers.set(key, value);
|
|
254
|
+
return new Response(JSON.stringify(getStats(db, since)), { status: 200, headers });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Record request metrics
|
|
258
|
+
let mcpMethod = "unknown";
|
|
259
|
+
try {
|
|
260
|
+
const bodyClone = await req.clone().json();
|
|
261
|
+
if (bodyClone.method === "tools/call") {
|
|
262
|
+
mcpMethod = bodyClone.params?.name || "tools/call";
|
|
263
|
+
} else {
|
|
264
|
+
mcpMethod = bodyClone.method || "unknown";
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// Non-JSON or parsing error
|
|
268
|
+
}
|
|
269
|
+
metricsRegistry.recordRequest(mcpMethod);
|
|
270
|
+
|
|
271
|
+
const res = await transport.handleRequest(req);
|
|
272
|
+
const headers = new Headers(res.headers);
|
|
273
|
+
if (allowOriginHeader) {
|
|
274
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
275
|
+
}
|
|
276
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers)) {
|
|
277
|
+
headers.set(key, value);
|
|
278
|
+
}
|
|
279
|
+
return new Response(res.body, {
|
|
280
|
+
status: res.status,
|
|
281
|
+
statusText: res.statusText,
|
|
282
|
+
headers,
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
let stopped = false;
|
|
287
|
+
console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
|
|
288
|
+
return {
|
|
289
|
+
port: bunServer.port,
|
|
290
|
+
async stop() {
|
|
291
|
+
if (stopped) return;
|
|
292
|
+
stopped = true;
|
|
293
|
+
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
294
|
+
metricsRegistry.setReadiness(readinessState.get());
|
|
295
|
+
bunServer.stop(true);
|
|
296
|
+
stopWatcher();
|
|
297
|
+
await server.close();
|
|
298
|
+
closeRuntime();
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
} else {
|
|
302
|
+
await server.connect(new StdioServerTransport());
|
|
303
|
+
let stopped = false;
|
|
304
|
+
return {
|
|
305
|
+
async stop() {
|
|
306
|
+
if (stopped) return;
|
|
307
|
+
stopped = true;
|
|
308
|
+
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
309
|
+
metricsRegistry.setReadiness(readinessState.get());
|
|
310
|
+
stopWatcher();
|
|
311
|
+
await server.close();
|
|
312
|
+
closeRuntime();
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (import.meta.main) await startServer();
|