@njinlabs/njin 0.10.2 → 0.11.0-beta.1

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 (55) hide show
  1. package/README.md +31 -0
  2. package/package.json +19 -4
  3. package/src/cli/build-assets.ts +4 -1
  4. package/src/cli/build.ts +47 -23
  5. package/src/cli/create.ts +35 -9
  6. package/src/cli/index.ts +2 -1
  7. package/src/cli/update.ts +93 -16
  8. package/src/config/module.ts +9 -0
  9. package/src/core/adapters/bun_filesystem.ts +13 -5
  10. package/src/core/adapters/s3.ts +18 -6
  11. package/src/core/admin_schema.ts +54 -0
  12. package/src/core/banner.ts +18 -4
  13. package/src/core/config.ts +49 -11
  14. package/src/core/helper.ts +4 -1
  15. package/src/core/html_page.ts +63 -0
  16. package/src/core/model/data_type/array.ts +16 -5
  17. package/src/core/model/data_type/boolean.ts +4 -1
  18. package/src/core/model/data_type/date.ts +4 -1
  19. package/src/core/model/data_type/email.ts +4 -1
  20. package/src/core/model/data_type/file.ts +17 -4
  21. package/src/core/model/data_type/multi_file.ts +9 -4
  22. package/src/core/model/data_type/numeric.ts +4 -1
  23. package/src/core/model/data_type/object.ts +8 -2
  24. package/src/core/model/data_type/relation.ts +21 -5
  25. package/src/core/model/data_type/relation_many.ts +24 -6
  26. package/src/core/model/data_type/richtext.ts +4 -1
  27. package/src/core/model/data_type/select.ts +17 -3
  28. package/src/core/model/data_type/text.ts +4 -1
  29. package/src/core/model/hooks.ts +42 -15
  30. package/src/core/model/index.ts +101 -37
  31. package/src/core/path_guard.ts +10 -3
  32. package/src/core/plugin.ts +8 -1
  33. package/src/core/public_url.ts +24 -0
  34. package/src/core/route.ts +2 -1
  35. package/src/core/vars/index.ts +22 -8
  36. package/src/core/worker.ts +22 -6
  37. package/src/models/file.ts +1 -1
  38. package/src/models/user.ts +1 -1
  39. package/src/modules/admin.ts +2 -2
  40. package/src/modules/analytics.ts +60 -25
  41. package/src/modules/api.ts +67 -95
  42. package/src/modules/auth.ts +25 -8
  43. package/src/modules/elysia.ts +1 -1
  44. package/src/modules/file.ts +50 -33
  45. package/src/modules/img.ts +33 -14
  46. package/src/modules/logger.ts +1 -1
  47. package/src/modules/mcp.ts +437 -0
  48. package/src/modules/mcp_token.ts +295 -0
  49. package/src/modules/mcp_upload.ts +313 -0
  50. package/src/modules/oauth.ts +550 -0
  51. package/src/modules/setup.ts +13 -5
  52. package/src/modules/surreal.ts +43 -13
  53. package/src/modules/users.ts +23 -5
  54. package/src/modules/vars.ts +1 -1
  55. package/src/modules/view.ts +88 -54
@@ -0,0 +1,437 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
+ import Elysia from "elysia";
4
+ import { RecordId } from "surrealdb";
5
+ import z from "zod";
6
+ import { toAdminSchema } from "../core/admin_schema";
7
+ import { getConfig, type ModelFactory, type VarsFactory } from "../core/config";
8
+ import { makeModule } from "../core/module";
9
+ import { publicBase } from "../core/public_url";
10
+ import elysia from "./elysia";
11
+ import fileModule from "./file";
12
+ import logger from "./logger";
13
+ import mcpToken from "./mcp_token";
14
+ import { createUploadTicket, getUploadStatus } from "./mcp_upload";
15
+
16
+ type Model = Awaited<ReturnType<ModelFactory>>["default"];
17
+ type VarsGroup = Awaited<ReturnType<VarsFactory>>["default"];
18
+
19
+ const INSTRUCTIONS = `Manage the content of an njin website.
20
+
21
+ Start with list_models: it returns every content model and settings (vars) group with its JSON schema. Then use read_records / get_record to look at existing content before changing it.
22
+
23
+ Field conventions:
24
+ - A relation field takes the id of the related record (a string, not the whole record).
25
+ - A field with renderAs "file" takes the id of a file record, not a URL.
26
+ - update_record and update_vars only change the fields you send; omitted fields are kept.
27
+ - Validation errors name the offending field — fix it and retry instead of guessing.
28
+
29
+ Files: you cannot send file bytes through a tool call. To add a file the user gave you, call create_upload_url, then either upload it yourself (curl -F file=@PATH URL, from your sandbox) or — if you cannot reach the URL or have no shell — give the URL to the user so they can open it and drop the file. Then call check_upload to get the file id, and put that id in the model's file field.
30
+
31
+ delete_record and delete_file are permanent.`;
32
+
33
+ type ToolResult = {
34
+ content: { type: "text"; text: string }[];
35
+ isError?: boolean;
36
+ };
37
+
38
+ // Record ids go out as the bare id ("abc123"), never "post:abc123": that bare form is what
39
+ // every tool parameter and every relation/file field takes back in. A relation field given the
40
+ // full "file:abc123" would silently become a record id of its own and dangle.
41
+ function plainIds(this: Record<string, unknown>, key: string, value: unknown) {
42
+ const raw = this[key];
43
+ return raw instanceof RecordId ? String(raw.id) : value;
44
+ }
45
+
46
+ const ok = (data: unknown): ToolResult => ({
47
+ content: [{ type: "text", text: JSON.stringify(data ?? null, plainIds) }],
48
+ });
49
+
50
+ // Agents copy ids out of earlier results, and occasionally from a record link they saw as
51
+ // "table:id" — tolerate that on the way in.
52
+ const bareId = (prefix: string, id: string) =>
53
+ id.startsWith(`${prefix}:`) ? id.slice(prefix.length + 1) : id;
54
+
55
+ const fail = (message: string): ToolResult => ({
56
+ content: [{ type: "text", text: message }],
57
+ isError: true,
58
+ });
59
+
60
+ // Turns anything a tool body throws into an isError result the agent can read and react to
61
+ // (a validation message it can fix, a unique-constraint clash) rather than a protocol-level
62
+ // failure that just says "internal error".
63
+ const run = async (fn: () => Promise<unknown>): Promise<ToolResult> => {
64
+ try {
65
+ return ok(await fn());
66
+ } catch (e) {
67
+ if (e instanceof z.ZodError) {
68
+ return fail(`Validation failed:\n${z.prettifyError(e)}`);
69
+ }
70
+ if (e instanceof Error) return fail(e.message);
71
+ throw e;
72
+ }
73
+ };
74
+
75
+ const mcp = makeModule(() => {
76
+ const fn = () => {};
77
+
78
+ fn.init = async () => {
79
+ const { plugin: tokenPlugin } = await mcpToken();
80
+
81
+ const models = new Map<string, Model>();
82
+ for (const factory of getConfig().models) {
83
+ const { default: model } = await factory();
84
+ models.set(model.prefix, model);
85
+ }
86
+
87
+ const groups = new Map<string, VarsGroup>();
88
+ for (const factory of getConfig().vars) {
89
+ const { default: group } = await factory();
90
+ groups.set(group.prefix, group);
91
+ }
92
+
93
+ // Static for the process lifetime (config can't change without a restart), so computed
94
+ // once instead of on every list_models call.
95
+ const catalog = {
96
+ models: [...models.values()].map((model) => ({
97
+ name: model.name,
98
+ prefix: model.prefix,
99
+ schema: toAdminSchema(model.validation),
100
+ })),
101
+ vars: [...groups.values()].map((group) => ({
102
+ name: group.name,
103
+ prefix: group.prefix,
104
+ schema: toAdminSchema(group.validation),
105
+ })),
106
+ };
107
+
108
+ const getModel = (prefix: string) => {
109
+ const model = models.get(prefix);
110
+ if (!model) {
111
+ throw new Error(
112
+ `Unknown model "${prefix}". Available: ${[...models.keys()].join(", ") || "(none)"}`,
113
+ );
114
+ }
115
+ return model;
116
+ };
117
+
118
+ const getGroup = (prefix: string) => {
119
+ const group = groups.get(prefix);
120
+ if (!group) {
121
+ throw new Error(
122
+ `Unknown vars group "${prefix}". Available: ${[...groups.keys()].join(", ") || "(none)"}`,
123
+ );
124
+ }
125
+ return group;
126
+ };
127
+
128
+ // One server per request — the endpoint is stateless (no session to keep alive), which
129
+ // also suits njin's workers being evicted when idle.
130
+ const buildServer = (
131
+ token: { id: string; name: string; createdBy: string | null },
132
+ base: string,
133
+ ) => {
134
+ const server = new McpServer(
135
+ { name: "njin", version: "1.0.0" },
136
+ { instructions: INSTRUCTIONS },
137
+ );
138
+
139
+ const audit = (tool: string, target?: string, id?: string) =>
140
+ logger()?.info({ mcpToken: token.name, tool, target, id }, "mcp tool");
141
+
142
+ server.registerTool(
143
+ "list_models",
144
+ {
145
+ title: "List models",
146
+ description:
147
+ "List every content model and settings (vars) group with its JSON schema. Call this first.",
148
+ annotations: { readOnlyHint: true },
149
+ },
150
+ async () => {
151
+ audit("list_models");
152
+ return ok(catalog);
153
+ },
154
+ );
155
+
156
+ server.registerTool(
157
+ "read_records",
158
+ {
159
+ title: "Read records",
160
+ description:
161
+ "List records of a model with search, filters, sorting and pagination.",
162
+ inputSchema: {
163
+ model: z.string().describe("Model prefix from list_models"),
164
+ search: z.string().optional(),
165
+ page: z.number().int().positive().default(1),
166
+ limit: z.number().int().positive().max(100).default(20),
167
+ sort: z.string().optional().describe("Field name to sort by"),
168
+ order: z.enum(["asc", "desc"]).default("asc"),
169
+ populate: z
170
+ .union([z.literal("none"), z.array(z.string())])
171
+ .optional()
172
+ .describe(
173
+ 'Relation fields to expand. Omit for the default, "none" for ids only.',
174
+ ),
175
+ filters: z
176
+ .record(
177
+ z.string(),
178
+ z.union([z.string(), z.record(z.string(), z.string())]),
179
+ )
180
+ .optional()
181
+ .describe("Field -> value, or field -> { operator: value }"),
182
+ },
183
+ annotations: { readOnlyHint: true },
184
+ },
185
+ async ({ model, ...options }) =>
186
+ run(async () => {
187
+ audit("read_records", model);
188
+ return getModel(model).read(options);
189
+ }),
190
+ );
191
+
192
+ server.registerTool(
193
+ "get_record",
194
+ {
195
+ title: "Get record",
196
+ description: "Get one record by id, with its relations expanded.",
197
+ inputSchema: { model: z.string(), id: z.string() },
198
+ annotations: { readOnlyHint: true },
199
+ },
200
+ async ({ model, id }) =>
201
+ run(async () => {
202
+ audit("get_record", model, id);
203
+ const record = await getModel(model).show(bareId(model, id));
204
+ if (!record) throw new Error(`No ${model} record with id "${id}"`);
205
+ return record;
206
+ }),
207
+ );
208
+
209
+ server.registerTool(
210
+ "create_record",
211
+ {
212
+ title: "Create record",
213
+ description:
214
+ "Create a record. `data` must satisfy the model's schema from list_models.",
215
+ inputSchema: {
216
+ model: z.string(),
217
+ data: z.record(z.string(), z.unknown()),
218
+ },
219
+ },
220
+ async ({ model, data }) =>
221
+ run(async () => {
222
+ audit("create_record", model);
223
+ const target = getModel(model);
224
+ return target.create(target.validation.parse(data) as never);
225
+ }),
226
+ );
227
+
228
+ server.registerTool(
229
+ "update_record",
230
+ {
231
+ title: "Update record",
232
+ description:
233
+ "Update a record. Only the fields in `data` change; the rest are kept.",
234
+ inputSchema: {
235
+ model: z.string(),
236
+ id: z.string(),
237
+ data: z.record(z.string(), z.unknown()),
238
+ },
239
+ annotations: { idempotentHint: true },
240
+ },
241
+ async ({ model, id, data }) =>
242
+ run(async () => {
243
+ audit("update_record", model, id);
244
+ const target = getModel(model);
245
+ return target.update(
246
+ bareId(model, id),
247
+ target.validation.partial().parse(data) as never,
248
+ );
249
+ }),
250
+ );
251
+
252
+ server.registerTool(
253
+ "delete_record",
254
+ {
255
+ title: "Delete record",
256
+ description: "Permanently delete a record by id. Cannot be undone.",
257
+ inputSchema: { model: z.string(), id: z.string() },
258
+ annotations: { destructiveHint: true },
259
+ },
260
+ async ({ model, id }) =>
261
+ run(async () => {
262
+ audit("delete_record", model, id);
263
+ return getModel(model).destroy(bareId(model, id));
264
+ }),
265
+ );
266
+
267
+ server.registerTool(
268
+ "get_vars",
269
+ {
270
+ title: "Get settings",
271
+ description:
272
+ "Get the current values of a settings (vars) group, defaults filled in.",
273
+ inputSchema: { group: z.string().describe("Group prefix") },
274
+ annotations: { readOnlyHint: true },
275
+ },
276
+ async ({ group }) =>
277
+ run(async () => {
278
+ audit("get_vars", group);
279
+ return getGroup(group).get();
280
+ }),
281
+ );
282
+
283
+ server.registerTool(
284
+ "update_vars",
285
+ {
286
+ title: "Update settings",
287
+ description:
288
+ "Update a settings (vars) group. Only the fields in `data` change.",
289
+ inputSchema: {
290
+ group: z.string(),
291
+ data: z.record(z.string(), z.unknown()),
292
+ },
293
+ annotations: { idempotentHint: true },
294
+ },
295
+ async ({ group, data }) =>
296
+ run(async () => {
297
+ audit("update_vars", group);
298
+ const target = getGroup(group);
299
+ return target.update(
300
+ target.validation.partial().parse(data) as never,
301
+ );
302
+ }),
303
+ );
304
+
305
+ server.registerTool(
306
+ "create_upload_url",
307
+ {
308
+ title: "Create upload URL",
309
+ description:
310
+ "Get a short-lived URL for adding files to the site (the way to upload — file bytes cannot go through tool calls). Upload with `curl -F file=@PATH URL` (repeat -F file=@... for several files), or give the URL to the user to open and drop files on. Returns upload_id for check_upload. Only some file types are accepted (see allowed_extensions).",
311
+ },
312
+ async () =>
313
+ run(async () => {
314
+ audit("create_upload_url");
315
+ return {
316
+ ...(await createUploadTicket({
317
+ tokenId: token.id,
318
+ userId: token.createdBy,
319
+ base,
320
+ })),
321
+ how_to: [
322
+ "From a shell: curl -F file=@/path/to/file URL",
323
+ "No shell or network access: show the URL to the user and ask them to open it and drop the file(s).",
324
+ "Then call check_upload with upload_id to get the new file ids.",
325
+ ],
326
+ };
327
+ }),
328
+ );
329
+
330
+ server.registerTool(
331
+ "check_upload",
332
+ {
333
+ title: "Check upload",
334
+ description:
335
+ "List the files that have arrived through an upload URL, with their ids. Call after uploading or after asking the user to.",
336
+ inputSchema: { upload_id: z.string() },
337
+ annotations: { readOnlyHint: true },
338
+ },
339
+ async ({ upload_id }) =>
340
+ run(async () => {
341
+ audit("check_upload", undefined, upload_id);
342
+ const status = await getUploadStatus(upload_id);
343
+ if (!status) throw new Error("Unknown upload_id.");
344
+ return status;
345
+ }),
346
+ );
347
+
348
+ server.registerTool(
349
+ "list_files",
350
+ {
351
+ title: "List files",
352
+ description:
353
+ "List uploaded files (newest first by default) with search and pagination.",
354
+ inputSchema: {
355
+ search: z.string().optional().describe("Match on file name"),
356
+ page: z.number().int().positive().default(1),
357
+ limit: z.number().int().positive().max(100).default(20),
358
+ sort: z.string().optional(),
359
+ order: z.enum(["asc", "desc"]).default("desc"),
360
+ },
361
+ annotations: { readOnlyHint: true },
362
+ },
363
+ async (options) =>
364
+ run(async () => {
365
+ audit("list_files");
366
+ return fileModule().model.read({
367
+ ...options,
368
+ sort: options.sort ?? "createdAt",
369
+ });
370
+ }),
371
+ );
372
+
373
+ server.registerTool(
374
+ "get_file",
375
+ {
376
+ title: "Get file",
377
+ description: "Get a file's metadata and URL by id.",
378
+ inputSchema: { id: z.string() },
379
+ annotations: { readOnlyHint: true },
380
+ },
381
+ async ({ id }) =>
382
+ run(async () => {
383
+ audit("get_file", undefined, id);
384
+ const record = await fileModule().model.show(bareId("file", id));
385
+ if (!record) throw new Error(`No file with id "${id}"`);
386
+ return record;
387
+ }),
388
+ );
389
+
390
+ server.registerTool(
391
+ "delete_file",
392
+ {
393
+ title: "Delete file",
394
+ description:
395
+ "Permanently delete a file from storage. Records that still reference it are not updated and will point at nothing — check usage first.",
396
+ inputSchema: { id: z.string() },
397
+ annotations: { destructiveHint: true },
398
+ },
399
+ async ({ id }) =>
400
+ run(async () => {
401
+ audit("delete_file", undefined, id);
402
+ return fileModule().remove(bareId("file", id));
403
+ }),
404
+ );
405
+
406
+ return server;
407
+ };
408
+
409
+ const controller = new Elysia().use(tokenPlugin).all(
410
+ "/mcp",
411
+ async ({ request, mcpToken }) => {
412
+ const server = buildServer(mcpToken, publicBase(request));
413
+ const transport = new WebStandardStreamableHTTPServerTransport({
414
+ sessionIdGenerator: undefined,
415
+ enableJsonResponse: true,
416
+ });
417
+
418
+ try {
419
+ await server.connect(transport);
420
+ return await transport.handleRequest(request);
421
+ } finally {
422
+ await server.close();
423
+ }
424
+ },
425
+ // The transport reads the raw request body itself — Elysia must not consume it first.
426
+ { mcpAuth: true, parse: "none" },
427
+ );
428
+
429
+ elysia().use(controller);
430
+
431
+ return {};
432
+ };
433
+
434
+ return fn;
435
+ });
436
+
437
+ export default mcp;