@anteros/core 0.0.20 → 0.0.22
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/README.md +90 -5
- package/database/collection.ts +1 -1
- package/database/mongodbadapter.ts +3 -0
- package/index.ts +1 -1
- package/lib/define.ts +60 -3
- package/lib/files.ts +15 -27
- package/lib/mcp.ts +59 -0
- package/lib/scriptWorker.ts +76 -0
- package/lib/scripts.ts +77 -12
- package/package.json +5 -2
- package/probe-cache.ts +16 -0
- package/server/api.ts +4 -1
- package/server/boot.ts +75 -13
- package/server/cluster.ts +177 -0
- package/server/config.ts +0 -1
- package/server/env.ts +6 -0
- package/server/hono.ts +185 -14
- package/server/mcp.ts +232 -0
- package/server/metrics.ts +110 -0
- package/server/security.ts +13 -9
- package/tests/cache.test.ts +124 -0
- package/tests/crypto.test.ts +101 -0
- package/tests/fixtures/heavy/collections/items.model.ts +8 -0
- package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
- package/tests/fixtures/metrics-worker.ts +21 -0
- package/tests/hono.test.ts +211 -0
- package/tests/mcp.test.ts +186 -0
- package/tests/metrics-cluster.test.ts +103 -0
- package/tests/scripts-heavy.test.ts +49 -0
- package/tests/security.test.ts +102 -0
- package/tests/utils.test.ts +113 -0
- package/types/collection.d.ts +20 -5
- package/types/config.d.ts +20 -3
- package/types/hook.d.ts +16 -13
- package/types/mcp.d.ts +81 -0
- package/types/scripts.d.ts +14 -1
- package/utils/cache.ts +20 -1
- package/utils/crypto.ts +25 -8
- package/utils/func.ts +12 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { define, v } from "../../../../../index";
|
|
2
|
+
|
|
3
|
+
export default define.McpTool({
|
|
4
|
+
name: "echo",
|
|
5
|
+
description: "Echoes the message back to the caller",
|
|
6
|
+
inputSchema: v.object({
|
|
7
|
+
msg: v.string().required().description("The message to echo"),
|
|
8
|
+
times: v.number().integer().min(1).max(5).default(1),
|
|
9
|
+
}),
|
|
10
|
+
exec: async ({ args }) => {
|
|
11
|
+
// Protocol format: { content: [...] }
|
|
12
|
+
return {
|
|
13
|
+
content: [{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: JSON.stringify({ echoed: args.msg, times: args.times, at: new Date().toISOString() }),
|
|
16
|
+
}],
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Fixture: minimal worker that reports a fake metrics snapshot over IPC,
|
|
2
|
+
// then stays alive so the master can supervise it.
|
|
3
|
+
const payload = {
|
|
4
|
+
type: "metrics",
|
|
5
|
+
pid: process.pid,
|
|
6
|
+
metrics: {
|
|
7
|
+
pid: process.pid,
|
|
8
|
+
startedAt: Date.now(),
|
|
9
|
+
uptime: 3,
|
|
10
|
+
env: "test",
|
|
11
|
+
connections: 0,
|
|
12
|
+
pendingRequests: 0,
|
|
13
|
+
pendingWebSockets: 0,
|
|
14
|
+
requests: { total: 7, active: 0, errors: 1, byMethod: { GET: 5, POST: 2 }, byStatus: { "200": 6, "500": 1 } },
|
|
15
|
+
memory: { rss: 1024, heapUsed: 512, heapTotal: 1024, external: 64 },
|
|
16
|
+
cpu: { percent: 12.5, cores: 8, user: 100, system: 50 },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
process.send?.(payload);
|
|
21
|
+
setInterval(() => {}, 1000); // stay alive until killed
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { createApp } from "../server/hono";
|
|
3
|
+
import { cfg } from "../server/config";
|
|
4
|
+
import { AppError } from "../lib/error";
|
|
5
|
+
import { HTTPException } from "hono/http-exception";
|
|
6
|
+
|
|
7
|
+
const base = (server: any) => server.url.href.replace(/\/$/, "");
|
|
8
|
+
|
|
9
|
+
function resetServerConfig() {
|
|
10
|
+
cfg.server = {
|
|
11
|
+
...cfg.server,
|
|
12
|
+
cors: undefined,
|
|
13
|
+
ipRestriction: undefined,
|
|
14
|
+
trustProxy: undefined,
|
|
15
|
+
rateLimit: undefined,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
resetServerConfig();
|
|
21
|
+
delete process.env.REDIS_URL;
|
|
22
|
+
delete process.env.REDIS_HOST;
|
|
23
|
+
delete process.env.REDIS_PORT;
|
|
24
|
+
delete process.env.REDIS_PASSWORD;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
resetServerConfig();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ─── Error handling (C2) ─────────────────────────────────────────────────
|
|
32
|
+
// Note: these use a real Bun.serve — ipRestriction returns 403 when there is
|
|
33
|
+
// no real socket address (app.request() without a server).
|
|
34
|
+
|
|
35
|
+
async function bootAppWith(register: (app: any) => void) {
|
|
36
|
+
const app = createApp();
|
|
37
|
+
register(app);
|
|
38
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
39
|
+
return { url: `${base(server)}`, server };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("onError", () => {
|
|
43
|
+
it("maps AppError to its status + code + message", async () => {
|
|
44
|
+
const { url, server } = await bootAppWith((app) => {
|
|
45
|
+
app.get("/app-error", () => {
|
|
46
|
+
throw new AppError("Validation failed", { status: 400, code: "VALIDATION_ERROR" });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
const res = await fetch(`${url}/app-error`);
|
|
50
|
+
expect(res.status).toBe(400);
|
|
51
|
+
const body: any = await res.json();
|
|
52
|
+
expect(body.code).toBe("VALIDATION_ERROR");
|
|
53
|
+
expect(body.message).toBe("Validation failed");
|
|
54
|
+
server.stop(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("lets HTTPException respond with its own status", async () => {
|
|
58
|
+
const { url, server } = await bootAppWith((app) => {
|
|
59
|
+
app.get("/exc", () => {
|
|
60
|
+
throw new HTTPException(401, { message: "Unauthorized" });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
const res = await fetch(`${url}/exc`);
|
|
64
|
+
expect(res.status).toBe(401);
|
|
65
|
+
server.stop(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns 500 INTERNAL_SERVER_ERROR for unexpected errors", async () => {
|
|
69
|
+
const { url, server } = await bootAppWith((app) => {
|
|
70
|
+
app.get("/crash", () => {
|
|
71
|
+
throw new Error("boom");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
const res = await fetch(`${url}/crash`);
|
|
75
|
+
expect(res.status).toBe(500);
|
|
76
|
+
const body: any = await res.json();
|
|
77
|
+
expect(body.code).toBe("INTERNAL_SERVER_ERROR");
|
|
78
|
+
server.stop(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── CORS (C1) ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe("CORS origin", () => {
|
|
85
|
+
it("throws on credentials + wildcard", () => {
|
|
86
|
+
cfg.server.cors = { origin: "*", credentials: true };
|
|
87
|
+
expect(() => createApp()).toThrow(/CORS misconfiguration/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("accepts a string origin with credentials", () => {
|
|
91
|
+
cfg.server.cors = { origin: "https://example.com", credentials: true };
|
|
92
|
+
expect(() => createApp()).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("accepts a string[] origin", () => {
|
|
96
|
+
cfg.server.cors = { origin: ["https://a.com", "https://b.com"], credentials: true };
|
|
97
|
+
expect(() => createApp()).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("accepts a function origin", () => {
|
|
101
|
+
cfg.server.cors = {
|
|
102
|
+
origin: (ctx) => (ctx.origin === "https://ok.com" ? "https://ok.com" : "https://fallback.com"),
|
|
103
|
+
credentials: true,
|
|
104
|
+
};
|
|
105
|
+
expect(() => createApp()).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ─── Client IP / trustProxy (S1) ─────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
describe("client IP resolution", () => {
|
|
112
|
+
it("ignores proxy headers by default (spoof-proof)", async () => {
|
|
113
|
+
const app = createApp();
|
|
114
|
+
app.get("/echo-ip", (c) => c.json({ ip: c.get("clientIp") }));
|
|
115
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
116
|
+
|
|
117
|
+
cfg.server.trustProxy = false;
|
|
118
|
+
const res = await fetch(`${base(server)}/echo-ip`, { headers: { "X-Forwarded-For": "1.2.3.4" } });
|
|
119
|
+
const body: any = await res.json();
|
|
120
|
+
expect(body.ip).not.toBe("1.2.3.4");
|
|
121
|
+
|
|
122
|
+
server.stop(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("uses the header when trustProxy is enabled", async () => {
|
|
126
|
+
const app = createApp();
|
|
127
|
+
app.get("/echo-ip", (c) => c.json({ ip: c.get("clientIp") }));
|
|
128
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
129
|
+
|
|
130
|
+
cfg.server.trustProxy = true;
|
|
131
|
+
const res = await fetch(`${base(server)}/echo-ip`, { headers: { "X-Forwarded-For": "1.2.3.4" } });
|
|
132
|
+
const body: any = await res.json();
|
|
133
|
+
expect(body.ip).toBe("1.2.3.4");
|
|
134
|
+
|
|
135
|
+
server.stop(true);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── Health ──────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe("GET /health", () => {
|
|
142
|
+
it("returns metrics + client ip", async () => {
|
|
143
|
+
const { url, server } = await bootAppWith(() => {});
|
|
144
|
+
const res = await fetch(`${url}/health`);
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
const body: any = await res.json();
|
|
147
|
+
expect(typeof body.pid).toBe("number");
|
|
148
|
+
expect(typeof body.ip).toBe("string");
|
|
149
|
+
expect(body.requests).toBeDefined();
|
|
150
|
+
expect(body.memory).toBeDefined();
|
|
151
|
+
expect(body.cpu).toBeDefined();
|
|
152
|
+
server.stop(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── Rate limiting ───────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("rate limiting (in-memory)", () => {
|
|
159
|
+
it("serves X-RateLimit-* headers and blocks after max", async () => {
|
|
160
|
+
cfg.server.rateLimit = { enabled: true, windowMs: 60_000, max: 3 };
|
|
161
|
+
const app = createApp();
|
|
162
|
+
app.get("/echo", (c) => c.json({ ok: true }));
|
|
163
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
164
|
+
const url = `${base(server)}/echo`;
|
|
165
|
+
|
|
166
|
+
const statuses: number[] = [];
|
|
167
|
+
let first: Response | undefined;
|
|
168
|
+
for (let i = 0; i < 4; i++) {
|
|
169
|
+
const r = await fetch(url);
|
|
170
|
+
if (i === 0) first = r;
|
|
171
|
+
statuses.push(r.status);
|
|
172
|
+
}
|
|
173
|
+
expect(statuses).toEqual([200, 200, 200, 429]);
|
|
174
|
+
|
|
175
|
+
// headers present on the first request
|
|
176
|
+
expect(first!.headers.get("X-RateLimit-Limit")).toBe("3");
|
|
177
|
+
expect(first!.headers.get("X-RateLimit-Remaining")).toBe("2");
|
|
178
|
+
|
|
179
|
+
server.stop(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("login limiter uses a distinct counter (not blocked by global)", async () => {
|
|
183
|
+
cfg.server.rateLimit = { enabled: true, windowMs: 60_000, max: 100, login: { max: 2 } };
|
|
184
|
+
const app = createApp();
|
|
185
|
+
app.post("/api/x/login", (c) => c.json({ ok: true }));
|
|
186
|
+
app.get("/echo", (c) => c.json({ ok: true }));
|
|
187
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
188
|
+
const url = `${base(server)}`;
|
|
189
|
+
|
|
190
|
+
const statuses: number[] = [];
|
|
191
|
+
for (let i = 0; i < 3; i++) {
|
|
192
|
+
statuses.push((await fetch(`${url}/api/x/login`, { method: "POST" })).status);
|
|
193
|
+
}
|
|
194
|
+
expect(statuses).toEqual([200, 200, 429]);
|
|
195
|
+
|
|
196
|
+
// same IP on a normal route → still allowed (distinct key)
|
|
197
|
+
expect((await fetch(`${url}/echo`)).status).toBe(200);
|
|
198
|
+
|
|
199
|
+
server.stop(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("falls back to in-memory when Redis is configured but unreachable (auto-detect)", async () => {
|
|
203
|
+
cfg.server.rateLimit = { enabled: true, max: 100, redis: { host: "localhost", port: 1 } };
|
|
204
|
+
const app = createApp();
|
|
205
|
+
app.get("/echo", (c) => c.json({ ok: true }));
|
|
206
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
207
|
+
const res = await fetch(`${base(server)}/echo`);
|
|
208
|
+
expect(res.status).toBe(200);
|
|
209
|
+
server.stop(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { define, v } from "../index";
|
|
3
|
+
import { cfg } from "../server/config";
|
|
4
|
+
import { syncMcpTools } from "../lib/mcp";
|
|
5
|
+
import { createApp } from "../server/hono";
|
|
6
|
+
|
|
7
|
+
const TENANT = "mcp-test";
|
|
8
|
+
const TENANT_DIR = "packages/core/tests/fixtures/mcp-tenant";
|
|
9
|
+
|
|
10
|
+
// @hono/mcp frames every message as Server-Sent Events (MCP Streamable HTTP spec)
|
|
11
|
+
function parseSSE(text: string): any[] {
|
|
12
|
+
const messages: any[] = [];
|
|
13
|
+
let data: string[] = [];
|
|
14
|
+
for (const line of text.split(/\r?\n/)) {
|
|
15
|
+
if (line.startsWith("data:")) {
|
|
16
|
+
data.push(line.slice(5).trim());
|
|
17
|
+
} else if (line === "" && data.length) {
|
|
18
|
+
messages.push(JSON.parse(data.join("\n")));
|
|
19
|
+
data = [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (data.length) messages.push(JSON.parse(data.join("\n")));
|
|
23
|
+
return messages;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resetConfig() {
|
|
27
|
+
cfg.server = { ...cfg.server, rateLimit: undefined, cors: undefined, ipRestriction: undefined, trustProxy: undefined };
|
|
28
|
+
cfg.mcpTools = [];
|
|
29
|
+
cfg.tenants = [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
resetConfig();
|
|
34
|
+
delete process.env.REDIS_URL;
|
|
35
|
+
delete process.env.REDIS_HOST;
|
|
36
|
+
delete process.env.REDIS_PORT;
|
|
37
|
+
delete process.env.REDIS_PASSWORD;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("define.McpTool", () => {
|
|
41
|
+
it("marks the tool and enables it by default", () => {
|
|
42
|
+
const tool = define.McpTool({
|
|
43
|
+
name: "greet",
|
|
44
|
+
description: "greets",
|
|
45
|
+
inputSchema: v.object({ name: v.string().required() }),
|
|
46
|
+
exec: async () => ({ content: [{ type: "text", text: "hi" }] }),
|
|
47
|
+
});
|
|
48
|
+
expect(tool._isMcpTool_).toBe(true);
|
|
49
|
+
expect(tool.enabled).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("syncMcpTools", () => {
|
|
54
|
+
it("loads tools + resources from mcp/tools and mcp/resources", async () => {
|
|
55
|
+
cfg.tenants = [{ id: TENANT, dir: TENANT_DIR, database: { uri: "mongodb://localhost:27017/none" } }];
|
|
56
|
+
await syncMcpTools();
|
|
57
|
+
expect(cfg.mcpTools?.length).toBe(2);
|
|
58
|
+
expect(cfg.mcpTools?.map((t) => t.name).sort()).toEqual(["badge", "echo"]);
|
|
59
|
+
expect(cfg.mcpResources?.length).toBe(2);
|
|
60
|
+
expect(cfg.mcpResources?.map((r) => r.uri).sort()).toEqual(["orders://summary", "users://{id}"]);
|
|
61
|
+
expect(cfg.mcpTools?.[0]?._tenant_).toBe(TENANT);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
|
|
66
|
+
it("serves tools/list and tools/call over JSON-RPC", async () => {
|
|
67
|
+
cfg.tenants = [{ id: TENANT, dir: TENANT_DIR, database: { uri: "mongodb://localhost:27017/none" } }];
|
|
68
|
+
await syncMcpTools();
|
|
69
|
+
|
|
70
|
+
const app = createApp();
|
|
71
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
72
|
+
const url = server.url.href.replace(/\/$/, "");
|
|
73
|
+
|
|
74
|
+
// 1. tools/list
|
|
75
|
+
const listRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
|
|
79
|
+
});
|
|
80
|
+
expect(listRes.status).toBe(200);
|
|
81
|
+
const list = parseSSE(await listRes.text())[0];
|
|
82
|
+
const tools = list?.result?.tools ?? [];
|
|
83
|
+
expect(tools.some((t: any) => t.name === "echo")).toBe(true);
|
|
84
|
+
expect(tools.some((t: any) => t.name === "badge")).toBe(true);
|
|
85
|
+
|
|
86
|
+
// 1b. resources/list — static resources
|
|
87
|
+
const resListRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 9, method: "resources/list", params: {} }),
|
|
91
|
+
});
|
|
92
|
+
const resList = parseSSE(await resListRes.text())[0];
|
|
93
|
+
const resources = resList?.result?.resources ?? [];
|
|
94
|
+
expect(resources.some((r: any) => r.uri === "orders://summary")).toBe(true);
|
|
95
|
+
|
|
96
|
+
// 1c. resources/templates/list
|
|
97
|
+
const tmplRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 10, method: "resources/templates/list", params: {} }),
|
|
101
|
+
});
|
|
102
|
+
const tmplList = parseSSE(await tmplRes.text())[0];
|
|
103
|
+
const templates = tmplList?.result?.resourceTemplates ?? [];
|
|
104
|
+
expect(templates.some((t: any) => t.uriTemplate === "users://{id}")).toBe(true);
|
|
105
|
+
|
|
106
|
+
// 1d. resources/read — static URI
|
|
107
|
+
const readRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "orders://summary" } }),
|
|
111
|
+
});
|
|
112
|
+
const read = parseSSE(await readRes.text())[0];
|
|
113
|
+
const readText = read?.result?.contents?.[0]?.text ?? "";
|
|
114
|
+
expect(readText).toContain("\"total\":42");
|
|
115
|
+
|
|
116
|
+
// 1e. resources/read — template URI with params
|
|
117
|
+
const readTplRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 12, method: "resources/read", params: { uri: "users://abc123" } }),
|
|
121
|
+
});
|
|
122
|
+
const readTpl = parseSSE(await readTplRes.text())[0];
|
|
123
|
+
expect(readTpl?.result?.contents?.[0]?.text ?? "").toContain("\"id\":\"abc123\"");
|
|
124
|
+
|
|
125
|
+
// 1f. resources/read — unknown URI → error
|
|
126
|
+
const readBadRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 13, method: "resources/read", params: { uri: "nope://x" } }),
|
|
130
|
+
});
|
|
131
|
+
const readBad = parseSSE(await readBadRes.text())[0];
|
|
132
|
+
expect(readBad?.error).toBeDefined();
|
|
133
|
+
|
|
134
|
+
// 2. tools/call (valid args + Joi defaults)
|
|
135
|
+
const callRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "Content-Type": "application/json" },
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
jsonrpc: "2.0", id: 2, method: "tools/call",
|
|
140
|
+
params: { name: "echo", arguments: { msg: "hello mcp" } },
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
const call = parseSSE(await callRes.text())[0];
|
|
144
|
+
const block = call?.result?.content?.[0] ?? {};
|
|
145
|
+
expect(block.type).toBe("text");
|
|
146
|
+
expect(block.text ?? "").toContain("hello mcp");
|
|
147
|
+
// Joi default applied (times=1)
|
|
148
|
+
expect(block.text ?? "").toContain("\"times\":1");
|
|
149
|
+
|
|
150
|
+
// 2b. protocol content blocks pass through unchanged (image block)
|
|
151
|
+
const badgeRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
jsonrpc: "2.0", id: 3, method: "tools/call",
|
|
156
|
+
params: { name: "badge", arguments: {} },
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
const badge = parseSSE(await badgeRes.text())[0];
|
|
160
|
+
expect(badge?.result?.content?.[0]?.type).toBe("image");
|
|
161
|
+
expect(badge?.result?.content?.[0]?.mimeType).toBe("image/png");
|
|
162
|
+
|
|
163
|
+
// 2b. invalid args → isError (Joi validation)
|
|
164
|
+
const badRes = await fetch(`${url}/mcp/${TENANT}`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/json" },
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
jsonrpc: "2.0", id: 3, method: "tools/call",
|
|
169
|
+
params: { name: "echo", arguments: { msg: 123 } },
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
const bad = parseSSE(await badRes.text())[0];
|
|
173
|
+
expect(bad?.result?.isError).toBe(true);
|
|
174
|
+
expect(bad?.result?.content?.[0]?.text ?? "").toContain("Invalid arguments");
|
|
175
|
+
|
|
176
|
+
// 3. unknown tenant → 404
|
|
177
|
+
const notFound = await fetch(`${url}/mcp/ghost`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/list", params: {} }),
|
|
181
|
+
});
|
|
182
|
+
expect(notFound.status).toBe(404);
|
|
183
|
+
|
|
184
|
+
server.stop(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getMetrics, registerMetrics, trackRequest } from "../server/metrics";
|
|
4
|
+
import { startMaster, aggregateMetrics, type MasterMetrics } from "../server/cluster";
|
|
5
|
+
|
|
6
|
+
// ─── metrics ────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe("metrics", () => {
|
|
9
|
+
it("returns a snapshot with pid, memory and cpu", () => {
|
|
10
|
+
const m = getMetrics();
|
|
11
|
+
expect(m.pid).toBe(process.pid);
|
|
12
|
+
expect(m.uptime).toBeGreaterThanOrEqual(0);
|
|
13
|
+
expect(typeof m.memory.rss).toBe("number");
|
|
14
|
+
expect(typeof m.cpu.percent).toBe("number");
|
|
15
|
+
expect(m.cpu.cores).toBeGreaterThanOrEqual(1);
|
|
16
|
+
expect(typeof m.requests.total).toBe("number");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("trackRequest counts requests by method and status", () => {
|
|
20
|
+
const before = getMetrics().requests.total;
|
|
21
|
+
trackRequest(new Request("http://localhost/", { method: "POST" }), new Response("ok", { status: 201 }));
|
|
22
|
+
const after = getMetrics().requests.total;
|
|
23
|
+
expect(after).toBe(before + 1);
|
|
24
|
+
expect(getMetrics().requests.byMethod.POST).toBeGreaterThanOrEqual(1);
|
|
25
|
+
expect(getMetrics().requests.byStatus["201"]).toBeGreaterThanOrEqual(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("trackRequest counts 5xx as errors", () => {
|
|
29
|
+
const before = getMetrics().requests.errors;
|
|
30
|
+
trackRequest(new Request("http://localhost/x"), new Response("boom", { status: 500 }));
|
|
31
|
+
expect(getMetrics().requests.errors).toBe(before + 1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("registerMetrics exposes pendingRequests", () => {
|
|
35
|
+
const server = Bun.serve({ port: 0, fetch: () => new Response("ok") });
|
|
36
|
+
registerMetrics(server as any);
|
|
37
|
+
expect(typeof getMetrics().pendingRequests).toBe("number");
|
|
38
|
+
server.stop(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ─── aggregateMetrics ───────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function fakeSnapshot(pid: number, total: number, rss: number): MasterMetrics[number] {
|
|
45
|
+
return {
|
|
46
|
+
pid,
|
|
47
|
+
startedAt: Date.now(),
|
|
48
|
+
uptime: 10,
|
|
49
|
+
env: "test",
|
|
50
|
+
connections: 0,
|
|
51
|
+
pendingRequests: 0,
|
|
52
|
+
pendingWebSockets: 0,
|
|
53
|
+
requests: { total, active: 0, errors: total > 10 ? 1 : 0, byMethod: { GET: total }, byStatus: { "200": total } },
|
|
54
|
+
memory: { rss, heapUsed: rss / 2, heapTotal: rss, external: 0 },
|
|
55
|
+
cpu: { percent: 10, cores: 8, user: 100, system: 50 },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("aggregateMetrics", () => {
|
|
60
|
+
it("returns an empty aggregate without workers", () => {
|
|
61
|
+
const agg = aggregateMetrics({});
|
|
62
|
+
expect(agg.workers).toBe(0);
|
|
63
|
+
expect(agg.requests.total).toBe(0);
|
|
64
|
+
expect(agg.pids).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("sums worker snapshots", () => {
|
|
68
|
+
const agg = aggregateMetrics({ 1: fakeSnapshot(1, 5, 1000), 2: fakeSnapshot(2, 5, 1000) });
|
|
69
|
+
expect(agg.workers).toBe(2);
|
|
70
|
+
expect(agg.requests.total).toBe(10);
|
|
71
|
+
expect(agg.memory.rss).toBe(2000);
|
|
72
|
+
expect(agg.cpu.percent).toBe(10);
|
|
73
|
+
expect(agg.pids).toHaveLength(2);
|
|
74
|
+
expect(agg.requests.perSecond).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ─── startMaster (real subprocess + IPC) ────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("startMaster", () => {
|
|
81
|
+
it("spawns workers, collects IPC metrics and shutdown kills them", async () => {
|
|
82
|
+
const workerPath = path.join(import.meta.dir, "fixtures", "metrics-worker.ts");
|
|
83
|
+
const master = startMaster({
|
|
84
|
+
workers: 1,
|
|
85
|
+
argv: [process.execPath, workerPath],
|
|
86
|
+
env: { ...(process.env as Record<string, string>) },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Wait for the worker to start and report its metrics
|
|
90
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
91
|
+
|
|
92
|
+
expect(master.workers.length).toBe(1);
|
|
93
|
+
|
|
94
|
+
const metrics = master.metrics();
|
|
95
|
+
const pids = Object.keys(metrics);
|
|
96
|
+
expect(pids.length).toBe(1);
|
|
97
|
+
expect(metrics[Number(pids[0])]!.requests.total).toBe(7);
|
|
98
|
+
|
|
99
|
+
master.shutdown();
|
|
100
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
101
|
+
expect(master.workers.length).toBe(0);
|
|
102
|
+
}, 10_000);
|
|
103
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { MongoClient } from "mongodb";
|
|
3
|
+
import { cfg } from "../server/config";
|
|
4
|
+
import { runScripts } from "../lib/scripts";
|
|
5
|
+
|
|
6
|
+
const DB_URI = "mongodb://localhost:27017/anteros_heavy_test";
|
|
7
|
+
const TENANT_DIR = "packages/core/tests/fixtures/heavy";
|
|
8
|
+
|
|
9
|
+
let mongoAvailable = false;
|
|
10
|
+
let client: MongoClient;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
// Gracefully skip when MongoDB is not reachable
|
|
14
|
+
try {
|
|
15
|
+
client = new MongoClient(DB_URI, { serverSelectionTimeoutMS: 1500 });
|
|
16
|
+
await client.connect();
|
|
17
|
+
mongoAvailable = true;
|
|
18
|
+
} catch {
|
|
19
|
+
mongoAvailable = false;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
if (mongoAvailable && client) {
|
|
25
|
+
try { await client.db().dropDatabase(); } catch {}
|
|
26
|
+
await client.close();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("heavy scripts (subprocess)", () => {
|
|
31
|
+
it("runs a heavy:true script in a worker process and persists data", async () => {
|
|
32
|
+
if (!mongoAvailable) {
|
|
33
|
+
console.warn("Skipping: MongoDB unavailable");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
cfg.tenants = [{ id: "heavy-test", dir: TENANT_DIR, database: { uri: DB_URI } }];
|
|
38
|
+
|
|
39
|
+
await runScripts();
|
|
40
|
+
|
|
41
|
+
const count = await client.db().collection("items").countDocuments();
|
|
42
|
+
expect(count).toBe(20);
|
|
43
|
+
|
|
44
|
+
const sample: any = await client.db().collection("items").findOne();
|
|
45
|
+
expect(sample.name).toBeString();
|
|
46
|
+
expect(sample.createdAt).toBeInstanceOf(Date);
|
|
47
|
+
expect(sample.updatedAt).toBeInstanceOf(Date);
|
|
48
|
+
}, 30_000);
|
|
49
|
+
});
|