@anteros/core 0.0.20 → 0.0.21

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 (42) hide show
  1. package/README.md +90 -5
  2. package/database/mongodbadapter.ts +3 -0
  3. package/index.ts +1 -1
  4. package/lib/define.ts +60 -3
  5. package/lib/files.ts +15 -27
  6. package/lib/mcp.ts +59 -0
  7. package/lib/scriptWorker.ts +76 -0
  8. package/lib/scripts.ts +77 -12
  9. package/package.json +5 -2
  10. package/probe-cache.ts +16 -0
  11. package/server/api.ts +4 -1
  12. package/server/boot.ts +75 -13
  13. package/server/cluster.ts +177 -0
  14. package/server/config.ts +0 -1
  15. package/server/env.ts +6 -0
  16. package/server/hono.ts +185 -14
  17. package/server/mcp.ts +232 -0
  18. package/server/metrics.ts +110 -0
  19. package/server/security.ts +13 -9
  20. package/tests/cache.test.ts +124 -0
  21. package/tests/crypto.test.ts +101 -0
  22. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  23. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  24. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  25. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  26. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  27. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  28. package/tests/fixtures/metrics-worker.ts +21 -0
  29. package/tests/hono.test.ts +211 -0
  30. package/tests/mcp.test.ts +186 -0
  31. package/tests/metrics-cluster.test.ts +103 -0
  32. package/tests/scripts-heavy.test.ts +49 -0
  33. package/tests/security.test.ts +102 -0
  34. package/tests/utils.test.ts +113 -0
  35. package/types/collection.d.ts +20 -5
  36. package/types/config.d.ts +20 -3
  37. package/types/hook.d.ts +16 -13
  38. package/types/mcp.d.ts +81 -0
  39. package/types/scripts.d.ts +14 -1
  40. package/utils/cache.ts +20 -1
  41. package/utils/crypto.ts +25 -8
  42. package/utils/func.ts +12 -4
package/server/mcp.ts ADDED
@@ -0,0 +1,232 @@
1
+ import type { Hono, Context } from "hono";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import {
4
+ ListToolsRequestSchema,
5
+ CallToolRequestSchema,
6
+ ListResourcesRequestSchema,
7
+ ListResourceTemplatesRequestSchema,
8
+ ReadResourceRequestSchema,
9
+ McpError,
10
+ ErrorCode,
11
+ } from "@modelcontextprotocol/sdk/types.js";
12
+ import { StreamableHTTPTransport } from "@hono/mcp";
13
+ import type Joi from "joi";
14
+ import { cfg } from "./config";
15
+ import { useRest } from "../database/rest";
16
+ import type { McpTool, McpResource } from "../types/mcp";
17
+ import type { HonoVariables } from "./env";
18
+
19
+ // ─── Joi → JSON Schema (for the MCP protocol) ────────────────────────────
20
+
21
+ function joiTypeToJson(type: string): string {
22
+ switch (type) {
23
+ case 'number': return 'number';
24
+ case 'integer': return 'integer';
25
+ case 'boolean': return 'boolean';
26
+ case 'date': return 'string';
27
+ default: return 'string';
28
+ }
29
+ }
30
+
31
+ /** Minimal Joi → JSON Schema converter (covers the common field types). */
32
+ function joiToJsonSchema(schema: Joi.Schema, describe = schema.describe() as any): any {
33
+ const node: any = {};
34
+ const type = describe?.type;
35
+
36
+ switch (type) {
37
+ case 'object': {
38
+ node.type = 'object';
39
+ const properties: Record<string, any> = {};
40
+ const required: string[] = [];
41
+ for (const [key, child] of Object.entries(describe.keys ?? {})) {
42
+ properties[key] = joiToJsonSchema(child as any, child);
43
+ if ((child as any)?.flags?.presence === 'required') required.push(key);
44
+ }
45
+ node.properties = properties;
46
+ if (required.length) node.required = required;
47
+ break;
48
+ }
49
+ case 'array': {
50
+ node.type = 'array';
51
+ if (describe.items?.length) node.items = joiToJsonSchema(describe.items[0], describe.items[0]);
52
+ break;
53
+ }
54
+ default: {
55
+ node.type = joiTypeToJson(type);
56
+ if (type === 'date') node.format = 'date-time';
57
+ if (type === 'string') {
58
+ for (const rule of describe.rules ?? []) {
59
+ if (['email', 'uri', 'uuid', 'isoDate', 'ip'].includes(rule.name)) {
60
+ node.format = rule.name;
61
+ }
62
+ if (rule.name === 'pattern' && rule.args?.regex) {
63
+ node.pattern = String(rule.args.regex).replace(/^\/|\/[gimsuy]*$/g, '');
64
+ }
65
+ }
66
+ }
67
+ if (describe.valids?.length && describe.valids.length <= 50) {
68
+ node.enum = describe.valids;
69
+ }
70
+ break;
71
+ }
72
+ }
73
+
74
+ if (describe?.flags?.presence === 'required') node.description = node.description ?? 'required';
75
+ if (describe?.flags?.description) node.description = describe.flags.description;
76
+ return node;
77
+ }
78
+
79
+ // ─── URI template matching (users://{id} → params) ───────────────────────
80
+
81
+ function compileTemplate(template: string): { regex: RegExp; params: string[] } {
82
+ const params: string[] = [];
83
+ const pattern = template.replace(/\{([^}]+)\}/g, (_, name) => {
84
+ params.push(name);
85
+ return '([^/]+)';
86
+ });
87
+ return { regex: new RegExp(`^${pattern}$`), params };
88
+ }
89
+
90
+ function matchUri(uri: string, resources: McpResource[]): { resource: McpResource; params: Record<string, string> } | null {
91
+ // Exact static match first
92
+ const staticMatch = resources.find((r) => r.uri === uri);
93
+ if (staticMatch) return { resource: staticMatch, params: {} };
94
+
95
+ // Template match
96
+ for (const resource of resources) {
97
+ if (!resource.uri.includes('{')) continue;
98
+ const { regex, params } = compileTemplate(resource.uri);
99
+ const m = uri.match(regex);
100
+ if (!m) continue;
101
+ const extracted: Record<string, string> = {};
102
+ params.forEach((name, i) => { extracted[name] = m[i + 1] ?? ''; });
103
+ return { resource, params: extracted };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ // ─── MCP server (one fresh instance per request — stateless) ─────────────
109
+
110
+ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[]): Server {
111
+ const server = new Server(
112
+ { name: 'anteros-mcp', version: cfg.version ?? '1.0.0' },
113
+ { capabilities: { tools: {}, resources: {} } }
114
+ );
115
+
116
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
117
+ tools: tools.map((t) => ({
118
+ name: t.name,
119
+ title: t.name,
120
+ description: t.description,
121
+ inputSchema: joiToJsonSchema(t.inputSchema),
122
+ })),
123
+ }));
124
+
125
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
126
+ const tool = tools.find((t) => t.name === req.params.name);
127
+ if (!tool) {
128
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${req.params.name}`);
129
+ }
130
+
131
+ const raw = req.params.arguments ?? {};
132
+ const { error, value } = tool.inputSchema.validate(raw, { allowUnknown: false });
133
+ if (error) {
134
+ return {
135
+ content: [{ type: 'text' as const, text: `Invalid arguments: ${error.message}` }],
136
+ isError: true,
137
+ };
138
+ }
139
+
140
+ const rest = new useRest({ tenant_id: tenantId });
141
+ try {
142
+ const result = await tool.exec({ c: undefined as any, rest, args: value });
143
+ if (result && Array.isArray(result.content)) return result;
144
+ const text = typeof result === 'string' ? result : JSON.stringify(result);
145
+ return { content: [{ type: 'text' as const, text }] };
146
+ } catch (err: any) {
147
+ return {
148
+ content: [{ type: 'text' as const, text: `Error: ${err?.message ?? 'unknown'}` }],
149
+ isError: true,
150
+ };
151
+ }
152
+ });
153
+
154
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
155
+ resources: resources
156
+ .filter((r) => !r.uri.includes('{')) // static only; templates are listed separately
157
+ .map((r) => ({
158
+ uri: r.uri,
159
+ name: r.name,
160
+ description: r.description,
161
+ mimeType: r.mimeType,
162
+ })),
163
+ }));
164
+
165
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
166
+ resourceTemplates: resources
167
+ .filter((r) => r.uri.includes('{'))
168
+ .map((r) => ({
169
+ uriTemplate: r.uri,
170
+ name: r.name,
171
+ description: r.description,
172
+ mimeType: r.mimeType,
173
+ })),
174
+ }));
175
+
176
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
177
+ const uri = req.params.uri;
178
+ const matched = matchUri(uri, resources);
179
+ if (!matched) {
180
+ throw new McpError(ErrorCode.InvalidParams, `Unknown resource URI: ${uri}`);
181
+ }
182
+
183
+ const rest = new useRest({ tenant_id: tenantId });
184
+ try {
185
+ const result = await matched.resource.read({
186
+ c: undefined as any,
187
+ rest,
188
+ params: matched.params,
189
+ uri,
190
+ });
191
+ return result;
192
+ } catch (err: any) {
193
+ throw new McpError(ErrorCode.InternalError, `Failed to read resource: ${err?.message ?? 'unknown'}`);
194
+ }
195
+ });
196
+
197
+ return server;
198
+ }
199
+
200
+ /**
201
+ * Expose each tenant's MCP tools & resources over the Model Context Protocol
202
+ * at `GET/POST /mcp/:tenant_id` (Streamable HTTP transport — works with
203
+ * Claude, Cursor, VS Code, and any MCP client).
204
+ */
205
+ export function initializeMcp(app: Hono<{ Variables: HonoVariables }>) {
206
+ const toolsByTenant = new Map<string, McpTool[]>();
207
+ for (const tool of cfg.mcpTools ?? []) {
208
+ const list = toolsByTenant.get(tool._tenant_ ?? '') ?? [];
209
+ list.push(tool);
210
+ toolsByTenant.set(tool._tenant_ ?? '', list);
211
+ }
212
+ const resourcesByTenant = new Map<string, McpResource[]>();
213
+ for (const resource of cfg.mcpResources ?? []) {
214
+ const list = resourcesByTenant.get(resource._tenant_ ?? '') ?? [];
215
+ list.push(resource);
216
+ resourcesByTenant.set(resource._tenant_ ?? '', list);
217
+ }
218
+
219
+ app.all('/mcp/:tenant_id', async (c: Context<{ Variables: HonoVariables }>) => {
220
+ const tenantId = c.req.param('tenant_id') ?? '';
221
+ const tools = toolsByTenant.get(tenantId) ?? [];
222
+ const resources = resourcesByTenant.get(tenantId) ?? [];
223
+ if (!tools.length && !resources.length) {
224
+ return c.json({ error: `No MCP tools or resources for tenant '${tenantId}'` }, 404);
225
+ }
226
+
227
+ const server = buildServer(tenantId, tools, resources);
228
+ const transport = new StreamableHTTPTransport();
229
+ await server.connect(transport);
230
+ return transport.handleRequest(c);
231
+ });
232
+ }
@@ -0,0 +1,110 @@
1
+ import type { Server } from "bun";
2
+ import os from "node:os";
3
+
4
+ // ─── Zero-dependency runtime metrics (memory, CPU, requests) ─────────────────
5
+
6
+ export type MetricsSnapshot = {
7
+ pid: number;
8
+ startedAt: number;
9
+ uptime: number;
10
+ env: string;
11
+ connections: number;
12
+ pendingRequests: number;
13
+ pendingWebSockets: number;
14
+ requests: {
15
+ total: number;
16
+ active: number;
17
+ errors: number;
18
+ byMethod: Record<string, number>;
19
+ byStatus: Record<string, number>;
20
+ };
21
+ memory: {
22
+ rss: number;
23
+ heapUsed: number;
24
+ heapTotal: number;
25
+ external: number;
26
+ };
27
+ cpu: {
28
+ /** % of a single core over the last sample window (0..100*cores) */
29
+ percent: number;
30
+ cores: number;
31
+ user: number;
32
+ system: number;
33
+ };
34
+ };
35
+
36
+ let serverRef: Server<any> | null = null;
37
+
38
+ const startedAt = Date.now();
39
+ let totalRequests = 0;
40
+ let totalErrors = 0;
41
+ const byMethod = new Map<string, number>();
42
+ const byStatus = new Map<string, number>();
43
+
44
+ let lastCpu = process.cpuUsage();
45
+ let lastCpuAt = Date.now();
46
+
47
+ /** Attach the running Bun server (used for connection / pending request counts). */
48
+ export function registerMetrics(server: Server<any>) {
49
+ serverRef = server;
50
+ }
51
+
52
+ /** Count a finished request (call once the response is resolved). */
53
+ export function trackRequest(req: Request, res: Response) {
54
+ totalRequests++;
55
+ const method = req.method || 'UNKNOWN';
56
+ byMethod.set(method, (byMethod.get(method) ?? 0) + 1);
57
+ const status = String(res.status);
58
+ byStatus.set(status, (byStatus.get(status) ?? 0) + 1);
59
+ if (res.status >= 500) totalErrors++;
60
+ }
61
+
62
+ /** Count an error that did not produce a response. */
63
+ export function trackError() {
64
+ totalErrors++;
65
+ }
66
+
67
+ /** Build a serializable metrics snapshot for the current process. */
68
+ export function getMetrics(): MetricsSnapshot {
69
+ const mem = process.memoryUsage();
70
+ const now = Date.now();
71
+ const cpu = process.cpuUsage();
72
+
73
+ const user = cpu.user - lastCpu.user;
74
+ const system = cpu.system - lastCpu.system;
75
+ const elapsedMs = Math.max(1, now - lastCpuAt);
76
+ const cores = Math.max(1, os.availableParallelism());
77
+ lastCpu = cpu;
78
+ lastCpuAt = now;
79
+
80
+ const cpuPercent = Math.min(100 * cores, ((user + system) / 1000 / elapsedMs) * 100);
81
+
82
+ return {
83
+ pid: process.pid,
84
+ startedAt,
85
+ uptime: Math.floor((now - startedAt) / 1000),
86
+ env: process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev',
87
+ connections: serverRef?.pendingWebSockets ?? 0,
88
+ pendingRequests: serverRef?.pendingRequests ?? 0,
89
+ pendingWebSockets: serverRef?.pendingWebSockets ?? 0,
90
+ requests: {
91
+ total: totalRequests,
92
+ active: serverRef?.pendingRequests ?? 0,
93
+ errors: totalErrors,
94
+ byMethod: Object.fromEntries(byMethod),
95
+ byStatus: Object.fromEntries(byStatus),
96
+ },
97
+ memory: {
98
+ rss: mem.rss,
99
+ heapUsed: mem.heapUsed,
100
+ heapTotal: mem.heapTotal,
101
+ external: mem.external,
102
+ },
103
+ cpu: {
104
+ percent: Math.round(cpuPercent * 10) / 10,
105
+ cores,
106
+ user,
107
+ system,
108
+ },
109
+ };
110
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Context } from "hono";
2
2
  import { getConnInfo } from "hono/bun";
3
+ import type { ConnInfo } from "hono/conninfo";
3
4
 
4
5
  // ─── Types ───────────────────────────────────────────────────────────────────
5
6
 
@@ -96,15 +97,18 @@ export function createRedisStore(redis: RedisClient): RateLimitStore {
96
97
  // ─── Default key generator ───────────────────────────────────────────────────
97
98
 
98
99
  function defaultKeyGenerator(c: Context): string {
99
- const connInfo = getConnInfo(c);
100
- const ip =
101
- connInfo.remote.address ??
102
- c.req.header("CF-Connecting-IP") ??
103
- c.req.header("True-Client-IP") ??
104
- c.req.header("X-Forwarded-For") ??
105
- "unknown";
106
-
107
- return `rn:rl:${ip}`;
100
+ // Prefer the client IP resolved once by the app middleware (trustProxy-aware).
101
+ const clientIp = (c as any).get('clientIp') as string | undefined;
102
+ if (clientIp) return `rn:rl:${clientIp}`;
103
+
104
+ // Fallback: real socket address only (no spoofable proxy headers)
105
+ let connInfo: ConnInfo | null = null;
106
+ try {
107
+ connInfo = getConnInfo(c);
108
+ } catch {
109
+ // getConnInfo throws outside a real Bun.serve (e.g. unit tests)
110
+ }
111
+ return `rn:rl:${connInfo?.remote.address ?? 'unknown'}`;
108
112
  }
109
113
 
110
114
  // ─── Middleware ──────────────────────────────────────────────────────────────
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Cache, useMemoryCache, useFilesystemCache } from "../utils/cache";
3
+
4
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
5
+
6
+ describe("Cache (memory)", () => {
7
+ let c: Cache;
8
+
9
+ beforeEach(() => {
10
+ c = useMemoryCache();
11
+ });
12
+
13
+ it("set + get round-trip", async () => {
14
+ await c.set({ key: "k", value: { id: 1 }, ttl: "5m" });
15
+ expect(await c.get({ key: "k" })).toEqual({ id: 1 });
16
+ });
17
+
18
+ it("setForever never expires", async () => {
19
+ await c.setForever({ key: "cfg", value: { theme: "dark" } });
20
+ expect(await c.get({ key: "cfg" })).toEqual({ theme: "dark" });
21
+ });
22
+
23
+ it("get returns null on miss", async () => {
24
+ expect(await c.get({ key: "nope" })).toBeUndefined();
25
+ });
26
+
27
+ it("has / missing", async () => {
28
+ await c.set({ key: "a", value: 1 });
29
+ expect(await c.has({ key: "a" })).toBe(true);
30
+ expect(await c.missing({ key: "a" })).toBe(false);
31
+ expect(await c.missing({ key: "b" })).toBe(true);
32
+ });
33
+
34
+ it("ttl expiry (ms)", async () => {
35
+ await c.set({ key: "x", value: 1, ttl: 50 });
36
+ expect(await c.get({ key: "x" })).toBe(1);
37
+ await sleep(80);
38
+ expect(await c.get({ key: "x" })).toBeUndefined();
39
+ });
40
+
41
+ it("getOrSet uses factory and caches", async () => {
42
+ let calls = 0;
43
+ const factory = async () => {
44
+ calls++;
45
+ return { n: calls };
46
+ };
47
+ expect(await c.getOrSet({ key: "g", factory })).toEqual({ n: 1 });
48
+ expect(await c.getOrSet({ key: "g", factory })).toEqual({ n: 1 });
49
+ expect(calls).toBe(1);
50
+ });
51
+
52
+ it("getOrSetForever", async () => {
53
+ const value = await c.getOrSetForever({ key: "f", factory: () => 42 });
54
+ expect(value).toBe(42);
55
+ expect(await c.get({ key: "f" })).toBe(42);
56
+ });
57
+
58
+ it("delete removes entry", async () => {
59
+ await c.set({ key: "d", value: 1 });
60
+ await c.delete({ key: "d" });
61
+ expect(await c.get({ key: "d" })).toBeUndefined();
62
+ });
63
+
64
+ it("deleteMany removes several keys", async () => {
65
+ await c.set({ key: "d1", value: 1 });
66
+ await c.set({ key: "d2", value: 2 });
67
+ await c.deleteMany({ keys: ["d1", "d2"] });
68
+ expect(await c.missing({ key: "d1" })).toBe(true);
69
+ expect(await c.missing({ key: "d2" })).toBe(true);
70
+ });
71
+
72
+ it("pull returns and removes", async () => {
73
+ await c.set({ key: "p", value: 7 });
74
+ expect(await c.pull("p")).toBe(7);
75
+ expect(await c.get({ key: "p" })).toBeUndefined();
76
+ });
77
+
78
+ it("clear empties everything", async () => {
79
+ await c.set({ key: "a", value: 1 });
80
+ await c.set({ key: "b", value: 2 });
81
+ await c.clear();
82
+ expect(await c.get({ key: "a" })).toBeUndefined();
83
+ expect(await c.get({ key: "b" })).toBeUndefined();
84
+ });
85
+
86
+ it("getOrSet recovers after ttl expiry (grace accepted)", async () => {
87
+ // miss → factory
88
+ const v1 = await c.getOrSet({ key: "e", factory: () => "fresh", ttl: 30, grace: 500 });
89
+ expect(v1).toBe("fresh");
90
+
91
+ await sleep(60); // past ttl
92
+ expect(await c.get({ key: "e" })).toBeUndefined(); // expired
93
+
94
+ // getOrSet re-runs the factory after expiry
95
+ const v2 = await c.getOrSet({ key: "e", factory: () => "fresh2" });
96
+ expect(v2).toBe("fresh2");
97
+ });
98
+
99
+ it("namespace groups keys", async () => {
100
+ const users = c.namespace("users");
101
+ await users.set({ key: "1", value: { name: "John" }, ttl: "5m" });
102
+ await users.set({ key: "2", value: { name: "Jane" } });
103
+ expect(await users.get({ key: "1" })).toEqual({ name: "John" });
104
+ expect(await c.get({ key: "1" })).toBeUndefined(); // not in root
105
+ await users.clear();
106
+ expect(await users.missing({ key: "1" })).toBe(true);
107
+ expect(await users.missing({ key: "2" })).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe("Cache (filesystem)", () => {
112
+ it("persists across instances", async () => {
113
+ const dir = "/tmp/anteros-cache-test";
114
+ const a = useFilesystemCache({ directory: dir });
115
+ await a.set({ key: "persist", value: { ok: true }, ttl: "10m" });
116
+ await a.disconnect();
117
+
118
+ const b = useFilesystemCache({ directory: dir });
119
+ expect(await b.get({ key: "persist" })).toEqual({ ok: true });
120
+ await b.delete({ key: "persist" });
121
+ expect(await b.get({ key: "persist" })).toBeUndefined();
122
+ await b.disconnect();
123
+ });
124
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { useSymCrypt, useAsymCrypt } from "../utils/crypto";
3
+
4
+ describe("crypto — symmetric AES-256-GCM", () => {
5
+ it("round-trips a string", async () => {
6
+ const c = useSymCrypt({ secret: "test-secret" });
7
+ const enc = await c.encrypt("hello world");
8
+ const dec = await c.decrypt(enc);
9
+ expect(dec).toBe("hello world");
10
+ });
11
+
12
+ it("round-trips an object (parseJson)", async () => {
13
+ const c = useSymCrypt({ secret: "s" });
14
+ const enc = await c.encrypt({ a: 1, b: "x", nested: { ok: true } });
15
+ const dec = await c.decrypt<{ a: number; b: string; nested: { ok: boolean } }>(enc, true);
16
+ expect(dec).toEqual({ a: 1, b: "x", nested: { ok: true } });
17
+ });
18
+
19
+ it("produces different ciphertexts each time (random IV)", async () => {
20
+ const c = useSymCrypt({ secret: "s" });
21
+ const a = await c.encrypt("same");
22
+ const b = await c.encrypt("same");
23
+ expect(a).not.toBe(b);
24
+ });
25
+
26
+ it("rejects tampered ciphertext (AES-GCM tag)", async () => {
27
+ const c = useSymCrypt({ secret: "s" });
28
+ const enc = await c.encrypt("data");
29
+ const buf = Buffer.from(enc, "base64");
30
+ buf[buf.length - 1]! ^= 0xff; // corrupt the tag
31
+ await expect(c.decrypt(buf.toString("base64"))).rejects.toThrow();
32
+ });
33
+
34
+ it("cannot decrypt with a different secret", async () => {
35
+ const c1 = useSymCrypt({ secret: "one" });
36
+ const c2 = useSymCrypt({ secret: "two" });
37
+ const enc = await c1.encrypt("secret");
38
+ await expect(c2.decrypt(enc)).rejects.toThrow();
39
+ });
40
+
41
+ it("binds ciphertext to its context via AAD (substitution-proof)", async () => {
42
+ const c = useSymCrypt({ secret: "s" });
43
+ // Alice encrypts with her context
44
+ const enc = await c.encrypt({ apiKey: "sk-alice" }, "users:alice:apiKey");
45
+
46
+ // Same context → OK
47
+ const dec = await c.decrypt<{ apiKey: string }>(enc, true, "users:alice:apiKey");
48
+ expect(dec.apiKey).toBe("sk-alice");
49
+
50
+ // Swapped into Bob's context (substitution) → rejected
51
+ await expect(c.decrypt(enc, true, "users:bob:apiKey")).rejects.toThrow();
52
+ // Missing context → rejected
53
+ await expect(c.decrypt(enc, true)).rejects.toThrow();
54
+ });
55
+
56
+ it("rejects truncated ciphertext with a clear error", async () => {
57
+ const c = useSymCrypt({ secret: "s" });
58
+ await expect(c.decrypt("YWJjZA==")).rejects.toThrow();
59
+ });
60
+ });
61
+
62
+ describe("crypto — asymmetric RSA-OAEP envelope", () => {
63
+ it("round-trips via exportPrivateKey / reuse", async () => {
64
+ const a = useAsymCrypt();
65
+ const enc = await a.encrypt({ secret: 42, list: [1, 2] });
66
+ const priv = await a.exportPrivateKey();
67
+ const b = useAsymCrypt({ privateKey: priv });
68
+ const dec = await b.decrypt<{ secret: number; list: number[] }>(enc, true);
69
+ expect(dec).toEqual({ secret: 42, list: [1, 2] });
70
+ });
71
+
72
+ it("encrypts with an external public key and decrypts with the private key", async () => {
73
+ const a = useAsymCrypt();
74
+ const pubJwk = await a.exportPublicKey();
75
+ const pubKey = await a.importPublicKey(pubJwk);
76
+ const enc = await a.encrypt("external-msg", pubKey);
77
+ const dec = await a.decrypt(enc);
78
+ expect(dec).toBe("external-msg");
79
+ });
80
+
81
+ it("rejects tampered ciphertext", async () => {
82
+ const a = useAsymCrypt();
83
+ const enc = await a.encrypt("data");
84
+ const buf = Buffer.from(enc, "base64");
85
+ buf[0]! ^= 0xff; // corrupt the wrapped key
86
+ await expect(a.decrypt(buf.toString("base64"))).rejects.toThrow();
87
+ });
88
+
89
+ it("binds ciphertext to its context via AAD (asym)", async () => {
90
+ const a = useAsymCrypt();
91
+ const enc = await a.encrypt({ doc: "sensitive" }, undefined, "tenant:acme:record:42");
92
+
93
+ const ok = await a.decrypt<{ doc: string }>(enc, true, "tenant:acme:record:42");
94
+ expect(ok.doc).toBe("sensitive");
95
+
96
+ // Wrong context → rejected
97
+ await expect(a.decrypt(enc, true, "tenant:acme:record:43")).rejects.toThrow();
98
+ // Missing context → rejected
99
+ await expect(a.decrypt(enc, true)).rejects.toThrow();
100
+ });
101
+ });
@@ -0,0 +1,8 @@
1
+ export default {
2
+ _isCollection_: true,
3
+ slug: 'items',
4
+ type: 'document',
5
+ fields: [
6
+ { name: 'name', type: 'string', required: true },
7
+ ],
8
+ };
@@ -0,0 +1,14 @@
1
+ export default {
2
+ _isScript_: true,
3
+ enabled: true,
4
+ heavy: true,
5
+ exec: async ({ rest, progress }: any) => {
6
+ const items = Array.from({ length: 10 }, (_, i) => ({ name: `item-${i}` }));
7
+ await rest.insertMany('items', items);
8
+ progress?.(50, { inserted: 10 });
9
+
10
+ await rest.insertMany('items', items.map((it) => ({ ...it, name: it.name + '-b' })));
11
+ progress?.(100, { inserted: 20 });
12
+ return { ok: true, total: 20 };
13
+ },
14
+ };
@@ -0,0 +1,17 @@
1
+ import { define } from "../../../../../index";
2
+
3
+ // Static resource — fixed URI
4
+ export default define.McpResource({
5
+ name: "orders-summary",
6
+ description: "Summary of orders",
7
+ uri: "orders://summary",
8
+ read: async ({ params }) => {
9
+ return {
10
+ contents: [{
11
+ uri: "orders://summary",
12
+ mimeType: "application/json",
13
+ text: JSON.stringify({ total: 42, pending: 7, params }),
14
+ }],
15
+ };
16
+ },
17
+ });
@@ -0,0 +1,18 @@
1
+ import { define, v } from "../../../../../index";
2
+
3
+ // Template resource — dynamic URI (params extracted from the URI)
4
+ export default define.McpResource({
5
+ name: "users-by-id",
6
+ description: "Fetch a user by id",
7
+ uri: "users://{id}",
8
+ mimeType: "application/json",
9
+ read: async ({ params, uri }) => {
10
+ return {
11
+ contents: [{
12
+ uri,
13
+ mimeType: "application/json",
14
+ text: JSON.stringify({ id: params.id, name: `User ${params.id}` }),
15
+ }],
16
+ };
17
+ },
18
+ });
@@ -0,0 +1,16 @@
1
+ import { define, v } from "../../../../../index";
2
+
3
+ export default define.McpTool({
4
+ name: "badge",
5
+ description: "Returns an image content block (protocol passthrough)",
6
+ inputSchema: v.object({}),
7
+ exec: async () => {
8
+ return {
9
+ content: [{
10
+ type: "image",
11
+ data: "aGVsbG8=", // "hello" base64
12
+ mimeType: "image/png",
13
+ }],
14
+ };
15
+ },
16
+ });