@njinlabs/njin 0.10.3 → 0.11.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/README.md +48 -0
- package/package.json +19 -4
- package/src/cli/build-assets.ts +4 -1
- package/src/cli/build.ts +47 -23
- package/src/cli/create.ts +35 -9
- package/src/cli/index.ts +2 -1
- package/src/cli/update.ts +93 -16
- package/src/config/module.ts +9 -0
- package/src/core/adapters/bun_filesystem.ts +13 -5
- package/src/core/adapters/s3.ts +18 -6
- package/src/core/admin_schema.ts +79 -0
- package/src/core/banner.ts +18 -4
- package/src/core/config.ts +58 -11
- package/src/core/helper.ts +4 -1
- package/src/core/html_page.ts +63 -0
- package/src/core/model/data_type/array.ts +16 -5
- package/src/core/model/data_type/boolean.ts +4 -1
- package/src/core/model/data_type/date.ts +4 -1
- package/src/core/model/data_type/email.ts +4 -1
- package/src/core/model/data_type/file.ts +17 -4
- package/src/core/model/data_type/multi_file.ts +9 -4
- package/src/core/model/data_type/numeric.ts +4 -1
- package/src/core/model/data_type/object.ts +8 -2
- package/src/core/model/data_type/relation.ts +21 -5
- package/src/core/model/data_type/relation_many.ts +24 -6
- package/src/core/model/data_type/richtext.ts +4 -1
- package/src/core/model/data_type/select.ts +17 -3
- package/src/core/model/data_type/text.ts +4 -1
- package/src/core/model/hooks.ts +42 -15
- package/src/core/model/index.ts +101 -37
- package/src/core/path_guard.ts +10 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/public_url.ts +24 -0
- package/src/core/route.ts +2 -1
- package/src/core/vars/index.ts +22 -8
- package/src/core/worker.ts +22 -6
- package/src/models/file.ts +1 -1
- package/src/models/user.ts +1 -1
- package/src/modules/admin.ts +2 -2
- package/src/modules/analytics.ts +46 -26
- package/src/modules/api.ts +67 -95
- package/src/modules/auth.ts +25 -8
- package/src/modules/elysia.ts +1 -1
- package/src/modules/file.ts +50 -33
- package/src/modules/img.ts +33 -14
- package/src/modules/logger.ts +1 -1
- package/src/modules/mcp.ts +607 -0
- package/src/modules/mcp_token.ts +295 -0
- package/src/modules/mcp_upload.ts +313 -0
- package/src/modules/oauth.ts +579 -0
- package/src/modules/setup.ts +13 -5
- package/src/modules/surreal.ts +43 -13
- package/src/modules/users.ts +23 -5
- package/src/modules/vars.ts +1 -1
- package/src/modules/view.ts +88 -54
|
@@ -0,0 +1,607 @@
|
|
|
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 or file field takes the id of the related record as a plain string — never an object and never the whole record (an object is stored as a dead copy, not a link). Records you read, create or update come back with their relations expanded one level. A "_warnings" entry means a link points at a record that does not exist, or holds an embedded {id} object instead of a link: fix it with update_record, sending the plain id string.
|
|
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 RELATION_KINDS = new Set([
|
|
56
|
+
"relation",
|
|
57
|
+
"multi_relation",
|
|
58
|
+
"file",
|
|
59
|
+
"multi_file",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
type RelationField = { name: string; many: boolean; target: string };
|
|
63
|
+
|
|
64
|
+
// A model or a vars group — both carry the zod schema the link fields are read from.
|
|
65
|
+
type HasSchema = { validation: z.ZodObject };
|
|
66
|
+
|
|
67
|
+
const relationFieldsOf = (model: HasSchema): RelationField[] =>
|
|
68
|
+
Object.entries(model.validation.shape).flatMap(([name, field]) => {
|
|
69
|
+
const meta = (field as z.ZodType).meta() as
|
|
70
|
+
| { renderAs?: string; model?: string }
|
|
71
|
+
| undefined;
|
|
72
|
+
if (!RELATION_KINDS.has(meta?.renderAs ?? "")) return [];
|
|
73
|
+
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
name,
|
|
77
|
+
many:
|
|
78
|
+
meta?.renderAs === "multi_relation" ||
|
|
79
|
+
meta?.renderAs === "multi_file",
|
|
80
|
+
target: meta?.model ?? "",
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const toLinkId = (target: string, value: unknown) => {
|
|
86
|
+
const raw =
|
|
87
|
+
value && typeof value === "object" && "id" in value
|
|
88
|
+
? (value as { id: unknown }).id
|
|
89
|
+
: value;
|
|
90
|
+
|
|
91
|
+
return typeof raw === "string" ? bareId(target, raw) : value;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// A relation written as an object (`{ id }`, or a whole record echoed back from a read) is stored
|
|
95
|
+
// by njin as an embedded copy, not a link — it never expands and goes stale. Agents do send that
|
|
96
|
+
// shape, so reduce every link field to the plain id string before validation turns it into a
|
|
97
|
+
// real record link. Also drops a "table:" prefix that doesn't belong in a link.
|
|
98
|
+
const normalizeRelations = (
|
|
99
|
+
model: HasSchema,
|
|
100
|
+
data: Record<string, unknown>,
|
|
101
|
+
) => {
|
|
102
|
+
const next = { ...data };
|
|
103
|
+
|
|
104
|
+
for (const { name, many, target } of relationFieldsOf(model)) {
|
|
105
|
+
const value = next[name];
|
|
106
|
+
if (value === undefined || value === null) continue;
|
|
107
|
+
|
|
108
|
+
next[name] =
|
|
109
|
+
many && Array.isArray(value)
|
|
110
|
+
? value.map((item) => toLinkId(target, item))
|
|
111
|
+
: toLinkId(target, value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return next;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// An expanded link always carries the target's own fields; a bare `{ id }` is what an embedded
|
|
118
|
+
// stub looks like (a link written as an object before this was normalised).
|
|
119
|
+
//
|
|
120
|
+
// A RecordId (a link that wasn't expanded) has no own enumerable keys, so it must be ruled out
|
|
121
|
+
// first — `[].every(...)` is true and every healthy bare link would be reported as a stub.
|
|
122
|
+
const isStub = (value: unknown) => {
|
|
123
|
+
if (!value || typeof value !== "object") return false;
|
|
124
|
+
if (Array.isArray(value) || value instanceof RecordId) return false;
|
|
125
|
+
|
|
126
|
+
const keys = Object.keys(value);
|
|
127
|
+
return keys.length > 0 && keys.every((key) => key === "id");
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const stubFieldsOf = (model: HasSchema, row: Record<string, unknown>) =>
|
|
131
|
+
relationFieldsOf(model)
|
|
132
|
+
.filter(({ name, many }) =>
|
|
133
|
+
many
|
|
134
|
+
? Array.isArray(row[name]) && (row[name] as unknown[]).some(isStub)
|
|
135
|
+
: isStub(row[name]),
|
|
136
|
+
)
|
|
137
|
+
.map(({ name }) => name);
|
|
138
|
+
|
|
139
|
+
const stubWarning = (field: string, recordId?: string) =>
|
|
140
|
+
`${recordId ? `Record ${recordId}: ` : ""}"${field}" holds an embedded {id} object, not a link, so it cannot expand. Fix it with update_record, sending "${field}" as the plain id string.`;
|
|
141
|
+
|
|
142
|
+
// create()/update() hand back the row exactly as stored, so every relation field is a bare id —
|
|
143
|
+
// the agent can't tell a link that resolved from one pointing at nothing (SurrealDB drops a
|
|
144
|
+
// dangling link from a FETCH instead of erroring). Re-read through show(), the same FETCH
|
|
145
|
+
// get_record uses, so related records come back expanded, and flag any link that didn't resolve.
|
|
146
|
+
const readBack = async (model: Model, written: Record<string, unknown>) => {
|
|
147
|
+
const id =
|
|
148
|
+
written.id instanceof RecordId ? String(written.id.id) : String(written.id);
|
|
149
|
+
const shown = ((await model.show(id)) ?? written) as Record<string, unknown>;
|
|
150
|
+
|
|
151
|
+
const warnings: string[] = [];
|
|
152
|
+
for (const { name: field } of relationFieldsOf(model)) {
|
|
153
|
+
const stored = written[field];
|
|
154
|
+
if (stored === null || stored === undefined) continue;
|
|
155
|
+
|
|
156
|
+
const resolved = shown[field];
|
|
157
|
+
const isRecord = (value: unknown) => !!value && typeof value === "object";
|
|
158
|
+
const missing = Array.isArray(stored)
|
|
159
|
+
? stored.length -
|
|
160
|
+
(Array.isArray(resolved) ? resolved.filter(isRecord).length : 0)
|
|
161
|
+
: isRecord(resolved)
|
|
162
|
+
? 0
|
|
163
|
+
: 1;
|
|
164
|
+
|
|
165
|
+
if (missing > 0) {
|
|
166
|
+
warnings.push(
|
|
167
|
+
`"${field}": ${missing} linked record(s) do not exist — check the id(s) you sent.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return warnings.length ? { ...shown, _warnings: warnings } : shown;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const fail = (message: string): ToolResult => ({
|
|
176
|
+
content: [{ type: "text", text: message }],
|
|
177
|
+
isError: true,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Turns anything a tool body throws into an isError result the agent can read and react to
|
|
181
|
+
// (a validation message it can fix, a unique-constraint clash) rather than a protocol-level
|
|
182
|
+
// failure that just says "internal error".
|
|
183
|
+
const run = async (fn: () => Promise<unknown>): Promise<ToolResult> => {
|
|
184
|
+
try {
|
|
185
|
+
return ok(await fn());
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (e instanceof z.ZodError) {
|
|
188
|
+
return fail(`Validation failed:\n${z.prettifyError(e)}`);
|
|
189
|
+
}
|
|
190
|
+
if (e instanceof Error) return fail(e.message);
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const mcp = makeModule(() => {
|
|
196
|
+
const fn = () => {};
|
|
197
|
+
|
|
198
|
+
fn.init = async () => {
|
|
199
|
+
const { plugin: tokenPlugin } = await mcpToken();
|
|
200
|
+
|
|
201
|
+
const models = new Map<string, Model>();
|
|
202
|
+
for (const factory of getConfig().models) {
|
|
203
|
+
const { default: model } = await factory();
|
|
204
|
+
models.set(model.prefix, model);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const groups = new Map<string, VarsGroup>();
|
|
208
|
+
for (const factory of getConfig().vars) {
|
|
209
|
+
const { default: group } = await factory();
|
|
210
|
+
groups.set(group.prefix, group);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Static for the process lifetime (config can't change without a restart), so computed
|
|
214
|
+
// once instead of on every list_models call.
|
|
215
|
+
const catalog = {
|
|
216
|
+
models: [...models.values()].map((model) => ({
|
|
217
|
+
name: model.name,
|
|
218
|
+
prefix: model.prefix,
|
|
219
|
+
schema: toAdminSchema(model.validation, { forAgent: true }),
|
|
220
|
+
})),
|
|
221
|
+
vars: [...groups.values()].map((group) => ({
|
|
222
|
+
name: group.name,
|
|
223
|
+
prefix: group.prefix,
|
|
224
|
+
schema: toAdminSchema(group.validation, { forAgent: true }),
|
|
225
|
+
})),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const getModel = (prefix: string) => {
|
|
229
|
+
const model = models.get(prefix);
|
|
230
|
+
if (!model) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Unknown model "${prefix}". Available: ${[...models.keys()].join(", ") || "(none)"}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return model;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const getGroup = (prefix: string) => {
|
|
239
|
+
const group = groups.get(prefix);
|
|
240
|
+
if (!group) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Unknown vars group "${prefix}". Available: ${[...groups.keys()].join(", ") || "(none)"}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return group;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// One server per request — the endpoint is stateless (no session to keep alive), which
|
|
249
|
+
// also suits njin's workers being evicted when idle.
|
|
250
|
+
const buildServer = (
|
|
251
|
+
token: { id: string; name: string; createdBy: string | null },
|
|
252
|
+
base: string,
|
|
253
|
+
) => {
|
|
254
|
+
const server = new McpServer(
|
|
255
|
+
{ name: "njin", version: "1.0.0" },
|
|
256
|
+
{ instructions: INSTRUCTIONS },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const audit = (tool: string, target?: string, id?: string) =>
|
|
260
|
+
logger()?.info({ mcpToken: token.name, tool, target, id }, "mcp tool");
|
|
261
|
+
|
|
262
|
+
server.registerTool(
|
|
263
|
+
"list_models",
|
|
264
|
+
{
|
|
265
|
+
title: "List models",
|
|
266
|
+
description:
|
|
267
|
+
"List every content model and settings (vars) group with its JSON schema. Call this first.",
|
|
268
|
+
annotations: { readOnlyHint: true },
|
|
269
|
+
},
|
|
270
|
+
async () => {
|
|
271
|
+
audit("list_models");
|
|
272
|
+
return ok(catalog);
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
server.registerTool(
|
|
277
|
+
"read_records",
|
|
278
|
+
{
|
|
279
|
+
title: "Read records",
|
|
280
|
+
description:
|
|
281
|
+
"List records of a model with search, filters, sorting and pagination.",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
model: z.string().describe("Model prefix from list_models"),
|
|
284
|
+
search: z.string().optional(),
|
|
285
|
+
page: z.number().int().positive().default(1),
|
|
286
|
+
limit: z.number().int().positive().max(100).default(20),
|
|
287
|
+
sort: z.string().optional().describe("Field name to sort by"),
|
|
288
|
+
order: z.enum(["asc", "desc"]).default("asc"),
|
|
289
|
+
populate: z
|
|
290
|
+
.union([z.literal("none"), z.array(z.string())])
|
|
291
|
+
.optional()
|
|
292
|
+
.describe(
|
|
293
|
+
'Relation fields to expand. Omit for the default, "none" for ids only.',
|
|
294
|
+
),
|
|
295
|
+
filters: z
|
|
296
|
+
.record(
|
|
297
|
+
z.string(),
|
|
298
|
+
z.union([z.string(), z.record(z.string(), z.string())]),
|
|
299
|
+
)
|
|
300
|
+
.optional()
|
|
301
|
+
.describe("Field -> value, or field -> { operator: value }"),
|
|
302
|
+
},
|
|
303
|
+
annotations: { readOnlyHint: true },
|
|
304
|
+
},
|
|
305
|
+
async ({ model, ...options }) =>
|
|
306
|
+
run(async () => {
|
|
307
|
+
audit("read_records", model);
|
|
308
|
+
// [] means "not specified", same as an empty value over REST — passing it through
|
|
309
|
+
// would suppress every FETCH and leave relations as bare ids.
|
|
310
|
+
const populate =
|
|
311
|
+
Array.isArray(options.populate) && options.populate.length === 0
|
|
312
|
+
? undefined
|
|
313
|
+
: options.populate;
|
|
314
|
+
const target = getModel(model);
|
|
315
|
+
const result = await target.read({ ...options, populate });
|
|
316
|
+
|
|
317
|
+
const warnings = (result.data as Record<string, unknown>[]).flatMap(
|
|
318
|
+
(row) =>
|
|
319
|
+
stubFieldsOf(target, row).map((field) =>
|
|
320
|
+
stubWarning(
|
|
321
|
+
field,
|
|
322
|
+
row.id instanceof RecordId
|
|
323
|
+
? String(row.id.id)
|
|
324
|
+
: String(row.id),
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
return warnings.length
|
|
330
|
+
? { ...result, _warnings: warnings.slice(0, 20) }
|
|
331
|
+
: result;
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
server.registerTool(
|
|
336
|
+
"get_record",
|
|
337
|
+
{
|
|
338
|
+
title: "Get record",
|
|
339
|
+
description: "Get one record by id, with its relations expanded.",
|
|
340
|
+
inputSchema: { model: z.string(), id: z.string() },
|
|
341
|
+
annotations: { readOnlyHint: true },
|
|
342
|
+
},
|
|
343
|
+
async ({ model, id }) =>
|
|
344
|
+
run(async () => {
|
|
345
|
+
audit("get_record", model, id);
|
|
346
|
+
const target = getModel(model);
|
|
347
|
+
const record = (await target.show(bareId(model, id))) as
|
|
348
|
+
| Record<string, unknown>
|
|
349
|
+
| undefined;
|
|
350
|
+
if (!record) throw new Error(`No ${model} record with id "${id}"`);
|
|
351
|
+
|
|
352
|
+
const stubs = stubFieldsOf(target, record);
|
|
353
|
+
return stubs.length
|
|
354
|
+
? { ...record, _warnings: stubs.map((f) => stubWarning(f)) }
|
|
355
|
+
: record;
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
server.registerTool(
|
|
360
|
+
"create_record",
|
|
361
|
+
{
|
|
362
|
+
title: "Create record",
|
|
363
|
+
description:
|
|
364
|
+
"Create a record. `data` must satisfy the model's schema from list_models.",
|
|
365
|
+
inputSchema: {
|
|
366
|
+
model: z.string(),
|
|
367
|
+
data: z.record(z.string(), z.unknown()),
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
async ({ model, data }) =>
|
|
371
|
+
run(async () => {
|
|
372
|
+
audit("create_record", model);
|
|
373
|
+
const target = getModel(model);
|
|
374
|
+
return readBack(
|
|
375
|
+
target,
|
|
376
|
+
(await target.create(
|
|
377
|
+
target.validation.parse(
|
|
378
|
+
normalizeRelations(target, data),
|
|
379
|
+
) as never,
|
|
380
|
+
)) as Record<string, unknown>,
|
|
381
|
+
);
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
server.registerTool(
|
|
386
|
+
"update_record",
|
|
387
|
+
{
|
|
388
|
+
title: "Update record",
|
|
389
|
+
description:
|
|
390
|
+
"Update a record. Only the fields in `data` change; the rest are kept.",
|
|
391
|
+
inputSchema: {
|
|
392
|
+
model: z.string(),
|
|
393
|
+
id: z.string(),
|
|
394
|
+
data: z.record(z.string(), z.unknown()),
|
|
395
|
+
},
|
|
396
|
+
annotations: { idempotentHint: true },
|
|
397
|
+
},
|
|
398
|
+
async ({ model, id, data }) =>
|
|
399
|
+
run(async () => {
|
|
400
|
+
audit("update_record", model, id);
|
|
401
|
+
const target = getModel(model);
|
|
402
|
+
return readBack(
|
|
403
|
+
target,
|
|
404
|
+
(await target.update(
|
|
405
|
+
bareId(model, id),
|
|
406
|
+
target.validation
|
|
407
|
+
.partial()
|
|
408
|
+
.parse(normalizeRelations(target, data)) as never,
|
|
409
|
+
)) as Record<string, unknown>,
|
|
410
|
+
);
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
server.registerTool(
|
|
415
|
+
"delete_record",
|
|
416
|
+
{
|
|
417
|
+
title: "Delete record",
|
|
418
|
+
description: "Permanently delete a record by id. Cannot be undone.",
|
|
419
|
+
inputSchema: { model: z.string(), id: z.string() },
|
|
420
|
+
annotations: { destructiveHint: true },
|
|
421
|
+
},
|
|
422
|
+
async ({ model, id }) =>
|
|
423
|
+
run(async () => {
|
|
424
|
+
audit("delete_record", model, id);
|
|
425
|
+
return getModel(model).destroy(bareId(model, id));
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
server.registerTool(
|
|
430
|
+
"get_vars",
|
|
431
|
+
{
|
|
432
|
+
title: "Get settings",
|
|
433
|
+
description:
|
|
434
|
+
"Get the current values of a settings (vars) group, defaults filled in. Relation and file fields come back as ids (use get_record / get_file to look them up).",
|
|
435
|
+
inputSchema: { group: z.string().describe("Group prefix") },
|
|
436
|
+
annotations: { readOnlyHint: true },
|
|
437
|
+
},
|
|
438
|
+
async ({ group }) =>
|
|
439
|
+
run(async () => {
|
|
440
|
+
audit("get_vars", group);
|
|
441
|
+
const target = getGroup(group);
|
|
442
|
+
const values = (await target.get()) as Record<string, unknown>;
|
|
443
|
+
|
|
444
|
+
const stubs = stubFieldsOf(target, values);
|
|
445
|
+
return stubs.length
|
|
446
|
+
? { ...values, _warnings: stubs.map((f) => stubWarning(f)) }
|
|
447
|
+
: values;
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
server.registerTool(
|
|
452
|
+
"update_vars",
|
|
453
|
+
{
|
|
454
|
+
title: "Update settings",
|
|
455
|
+
description:
|
|
456
|
+
"Update a settings (vars) group. Only the fields in `data` change.",
|
|
457
|
+
inputSchema: {
|
|
458
|
+
group: z.string(),
|
|
459
|
+
data: z.record(z.string(), z.unknown()),
|
|
460
|
+
},
|
|
461
|
+
annotations: { idempotentHint: true },
|
|
462
|
+
},
|
|
463
|
+
async ({ group, data }) =>
|
|
464
|
+
run(async () => {
|
|
465
|
+
audit("update_vars", group);
|
|
466
|
+
const target = getGroup(group);
|
|
467
|
+
return target.update(
|
|
468
|
+
target.validation
|
|
469
|
+
.partial()
|
|
470
|
+
.parse(normalizeRelations(target, data)) as never,
|
|
471
|
+
);
|
|
472
|
+
}),
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
server.registerTool(
|
|
476
|
+
"create_upload_url",
|
|
477
|
+
{
|
|
478
|
+
title: "Create upload URL",
|
|
479
|
+
description:
|
|
480
|
+
"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).",
|
|
481
|
+
},
|
|
482
|
+
async () =>
|
|
483
|
+
run(async () => {
|
|
484
|
+
audit("create_upload_url");
|
|
485
|
+
return {
|
|
486
|
+
...(await createUploadTicket({
|
|
487
|
+
tokenId: token.id,
|
|
488
|
+
userId: token.createdBy,
|
|
489
|
+
base,
|
|
490
|
+
})),
|
|
491
|
+
how_to: [
|
|
492
|
+
"From a shell: curl -F file=@/path/to/file URL",
|
|
493
|
+
"No shell or network access: show the URL to the user and ask them to open it and drop the file(s).",
|
|
494
|
+
"Then call check_upload with upload_id to get the new file ids.",
|
|
495
|
+
],
|
|
496
|
+
};
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
server.registerTool(
|
|
501
|
+
"check_upload",
|
|
502
|
+
{
|
|
503
|
+
title: "Check upload",
|
|
504
|
+
description:
|
|
505
|
+
"List the files that have arrived through an upload URL, with their ids. Call after uploading or after asking the user to.",
|
|
506
|
+
inputSchema: { upload_id: z.string() },
|
|
507
|
+
annotations: { readOnlyHint: true },
|
|
508
|
+
},
|
|
509
|
+
async ({ upload_id }) =>
|
|
510
|
+
run(async () => {
|
|
511
|
+
audit("check_upload", undefined, upload_id);
|
|
512
|
+
const status = await getUploadStatus(upload_id);
|
|
513
|
+
if (!status) throw new Error("Unknown upload_id.");
|
|
514
|
+
return status;
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
server.registerTool(
|
|
519
|
+
"list_files",
|
|
520
|
+
{
|
|
521
|
+
title: "List files",
|
|
522
|
+
description:
|
|
523
|
+
"List uploaded files (newest first by default) with search and pagination.",
|
|
524
|
+
inputSchema: {
|
|
525
|
+
search: z.string().optional().describe("Match on file name"),
|
|
526
|
+
page: z.number().int().positive().default(1),
|
|
527
|
+
limit: z.number().int().positive().max(100).default(20),
|
|
528
|
+
sort: z.string().optional(),
|
|
529
|
+
order: z.enum(["asc", "desc"]).default("desc"),
|
|
530
|
+
},
|
|
531
|
+
annotations: { readOnlyHint: true },
|
|
532
|
+
},
|
|
533
|
+
async (options) =>
|
|
534
|
+
run(async () => {
|
|
535
|
+
audit("list_files");
|
|
536
|
+
return fileModule().model.read({
|
|
537
|
+
...options,
|
|
538
|
+
sort: options.sort ?? "createdAt",
|
|
539
|
+
});
|
|
540
|
+
}),
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
server.registerTool(
|
|
544
|
+
"get_file",
|
|
545
|
+
{
|
|
546
|
+
title: "Get file",
|
|
547
|
+
description: "Get a file's metadata and URL by id.",
|
|
548
|
+
inputSchema: { id: z.string() },
|
|
549
|
+
annotations: { readOnlyHint: true },
|
|
550
|
+
},
|
|
551
|
+
async ({ id }) =>
|
|
552
|
+
run(async () => {
|
|
553
|
+
audit("get_file", undefined, id);
|
|
554
|
+
const record = await fileModule().model.show(bareId("file", id));
|
|
555
|
+
if (!record) throw new Error(`No file with id "${id}"`);
|
|
556
|
+
return record;
|
|
557
|
+
}),
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
server.registerTool(
|
|
561
|
+
"delete_file",
|
|
562
|
+
{
|
|
563
|
+
title: "Delete file",
|
|
564
|
+
description:
|
|
565
|
+
"Permanently delete a file from storage. Records that still reference it are not updated and will point at nothing — check usage first.",
|
|
566
|
+
inputSchema: { id: z.string() },
|
|
567
|
+
annotations: { destructiveHint: true },
|
|
568
|
+
},
|
|
569
|
+
async ({ id }) =>
|
|
570
|
+
run(async () => {
|
|
571
|
+
audit("delete_file", undefined, id);
|
|
572
|
+
return fileModule().remove(bareId("file", id));
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
return server;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const controller = new Elysia().use(tokenPlugin).all(
|
|
580
|
+
"/mcp",
|
|
581
|
+
async ({ request, mcpToken }) => {
|
|
582
|
+
const server = buildServer(mcpToken, publicBase(request));
|
|
583
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
584
|
+
sessionIdGenerator: undefined,
|
|
585
|
+
enableJsonResponse: true,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
await server.connect(transport);
|
|
590
|
+
return await transport.handleRequest(request);
|
|
591
|
+
} finally {
|
|
592
|
+
await server.close();
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
// The transport reads the raw request body itself — Elysia must not consume it first.
|
|
596
|
+
{ mcpAuth: true, parse: "none" },
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
elysia().use(controller);
|
|
600
|
+
|
|
601
|
+
return {};
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
return fn;
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
export default mcp;
|