@coffer-org/server 1.7.0 → 1.8.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/dist/auth-api.js +3 -3
- package/dist/background-scheduler.d.ts +22 -0
- package/dist/background-scheduler.js +101 -0
- package/dist/collection-io.d.ts +7 -7
- package/dist/collection-io.js +2 -2
- package/dist/connector-identity.d.ts +5 -0
- package/dist/connector-identity.js +32 -0
- package/dist/embed-openai.d.ts +10 -0
- package/dist/embed-openai.js +28 -0
- package/dist/entity-schema.d.ts +6 -5
- package/dist/entity-schema.js +24 -10
- package/dist/extend-io.d.ts +5 -3
- package/dist/extend-io.js +17 -10
- package/dist/extend-table.d.ts +4 -3
- package/dist/extend-table.js +10 -18
- package/dist/field-masking.d.ts +7 -0
- package/dist/field-masking.js +60 -0
- package/dist/index-signal.d.ts +3 -0
- package/dist/index-signal.js +14 -0
- package/dist/index.js +78 -165
- package/dist/local-api.d.ts +3 -3
- package/dist/local-api.js +6 -6
- package/dist/mcp-http.d.ts +5 -0
- package/dist/mcp-http.js +37 -0
- package/dist/mcp-http.test-helpers.d.ts +17 -0
- package/dist/mcp-http.test-helpers.js +117 -0
- package/dist/mcp-local.d.ts +10 -0
- package/dist/mcp-local.js +57 -0
- package/dist/mcp-tools.d.ts +61 -0
- package/dist/mcp-tools.js +225 -0
- package/dist/msg-log.d.ts +0 -1
- package/dist/msg-log.js +2 -2
- package/dist/mutate.d.ts +7 -5
- package/dist/mutate.js +20 -8
- package/dist/plugin-discovery.d.ts +6 -0
- package/dist/plugin-discovery.js +13 -0
- package/dist/plugin-hooks.d.ts +10 -0
- package/dist/plugin-hooks.js +8 -0
- package/dist/plugin-runtime.d.ts +1 -0
- package/dist/plugin-runtime.js +30 -6
- package/dist/plugin-updates.d.ts +10 -0
- package/dist/plugin-updates.js +7 -0
- package/dist/plugins-api.d.ts +1 -1
- package/dist/plugins-api.js +29 -12
- package/dist/records-api.d.ts +8 -8
- package/dist/records-api.js +35 -32
- package/dist/registry-context.d.ts +1 -1
- package/dist/registry-context.js +2 -2
- package/dist/schema-api.js +5 -5
- package/dist/settings-write.d.ts +19 -0
- package/dist/settings-write.js +60 -0
- package/dist/temporal.d.ts +3 -3
- package/dist/temporal.js +1 -1
- package/dist/thread-store.d.ts +20 -0
- package/dist/thread-store.js +27 -0
- package/dist/uploads.d.ts +1 -0
- package/dist/uploads.js +4 -0
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
-
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
-
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
-
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
return path;
|
|
8
|
-
};
|
|
9
1
|
import { randomUUID } from 'node:crypto';
|
|
10
2
|
import { createWriteStream, existsSync } from 'node:fs';
|
|
11
3
|
import { realpath } from 'node:fs/promises';
|
|
@@ -17,21 +9,24 @@ import cors from '@fastify/cors';
|
|
|
17
9
|
import multipart from '@fastify/multipart';
|
|
18
10
|
import fastifyStatic from '@fastify/static';
|
|
19
11
|
import { serialize } from '@mikro-orm/core';
|
|
20
|
-
import { textSearchKeys, recordTitle
|
|
12
|
+
import { textSearchKeys, recordTitle } from '@coffer-org/sdk/shelf';
|
|
21
13
|
import { tokenize } from '@coffer-org/core/search';
|
|
22
|
-
import {
|
|
14
|
+
import { shelfTableName } from "./entity-schema.js";
|
|
23
15
|
import { getEm, closeDb } from "./db.js";
|
|
24
|
-
import { ValidationError, NotFoundError
|
|
16
|
+
import { ValidationError, NotFoundError } from "./mutate.js";
|
|
25
17
|
import { resolveEmbed } from "./embed.js";
|
|
26
18
|
import { resolveWithinHome, filterSort, listDir } from "./fs-list.js";
|
|
27
19
|
import { initPlugins, teardownPlugins, readDisabled, purgePluginData, getPluginSettings, getPlugins, } from "./plugin-runtime.js";
|
|
28
20
|
import { registerPluginsApi } from "./plugins-api.js";
|
|
21
|
+
import { pluginHooks, HttpError } from "./plugin-hooks.js";
|
|
29
22
|
import { registerAuthApi, resolveRequestUser, requireAdmin, PUBLIC_API_PATHS } from "./auth-api.js";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
23
|
+
import { registerMcpHttp } from "./mcp-http.js";
|
|
24
|
+
import { discoverPluginAssets, discoverRuntime } from "./plugin-discovery.js";
|
|
25
|
+
import { checkLatestVersion, resolveUpdateTarget, resolveAllUpdateTargets, resolveRuntimeTarget, runNpmInstall, } from "./plugin-updates.js";
|
|
32
26
|
import { buildClientSchema } from "./schema-api.js";
|
|
33
27
|
import { recordList, recordGet, recordCreate, recordUpdate, recordDelete, rowMatch, UnknownTypeError } from "./records-api.js";
|
|
34
|
-
import { maskTree, preserveTree } from "./
|
|
28
|
+
import { maskTree, preserveTree } from "./field-masking.js";
|
|
29
|
+
import { writePluginSettings } from "./settings-write.js";
|
|
35
30
|
import { rootLogger, getLogger } from "./log.js";
|
|
36
31
|
import { uploadsDir } from "./uploads.js";
|
|
37
32
|
const ENV_FILE = join(process.cwd(), '.env');
|
|
@@ -39,12 +34,12 @@ if (existsSync(ENV_FILE))
|
|
|
39
34
|
process.loadEnvFile(ENV_FILE);
|
|
40
35
|
const PORT = Number(process.env.PORT ?? 7023);
|
|
41
36
|
const reg = await initPlugins();
|
|
42
|
-
const {
|
|
37
|
+
const { libraries, shelves, getShelf, extends_, getExtendsFor } = reg;
|
|
43
38
|
const entityNames = new Map();
|
|
44
|
-
const
|
|
45
|
-
for (const m of
|
|
46
|
-
entityNames.set(`${m.
|
|
47
|
-
|
|
39
|
+
const shelfDefs = new Map();
|
|
40
|
+
for (const m of shelves) {
|
|
41
|
+
entityNames.set(`${m.library}/${m.shelf}`, shelfTableName(m.library, m.shelf));
|
|
42
|
+
shelfDefs.set(`${m.library}/${m.shelf}`, m);
|
|
48
43
|
}
|
|
49
44
|
const UPLOADS = uploadsDir();
|
|
50
45
|
const app = Fastify({
|
|
@@ -72,7 +67,7 @@ app.addHook('onSend', async (req, reply, payload) => {
|
|
|
72
67
|
return payload;
|
|
73
68
|
});
|
|
74
69
|
app.addHook('onRequest', async (req, reply) => {
|
|
75
|
-
const gated = req.url.startsWith('/api/') || req.url.startsWith('/uploads/');
|
|
70
|
+
const gated = req.url.startsWith('/api/') || req.url.startsWith('/uploads/') || req.url === '/mcp' || req.url.startsWith('/mcp?');
|
|
76
71
|
if (!gated)
|
|
77
72
|
return;
|
|
78
73
|
if (PUBLIC_API_PATHS.some((p) => req.url.startsWith(p)))
|
|
@@ -100,8 +95,8 @@ app.post('/api/embed', async (req, reply) => {
|
|
|
100
95
|
return reply.code(422).send({ error: 'unresolved' });
|
|
101
96
|
return reply.send(rec);
|
|
102
97
|
});
|
|
103
|
-
function key(
|
|
104
|
-
return `${
|
|
98
|
+
function key(library, type) {
|
|
99
|
+
return `${library}/${type}`;
|
|
105
100
|
}
|
|
106
101
|
async function guard(reply, fn) {
|
|
107
102
|
try {
|
|
@@ -125,11 +120,11 @@ app.get('/health', async (_req, reply) => {
|
|
|
125
120
|
return reply.code(503).send({ status: 'db_unavailable' });
|
|
126
121
|
}
|
|
127
122
|
});
|
|
128
|
-
app.get('/api/
|
|
123
|
+
app.get('/api/libraries', () => buildClientSchema());
|
|
129
124
|
app.get('/api/counts', async () => {
|
|
130
125
|
const fork = getEm().fork();
|
|
131
126
|
const out = {};
|
|
132
|
-
for (const [k, mdef] of
|
|
127
|
+
for (const [k, mdef] of shelfDefs) {
|
|
133
128
|
if (mdef.standalone === false)
|
|
134
129
|
continue;
|
|
135
130
|
const ename = entityNames.get(k);
|
|
@@ -147,7 +142,7 @@ app.get('/api/search', async (req) => {
|
|
|
147
142
|
const lim = Math.min(Number(limit) || 20, 100);
|
|
148
143
|
const fork = getEm().fork();
|
|
149
144
|
const scored = [];
|
|
150
|
-
for (const [k, mdef] of
|
|
145
|
+
for (const [k, mdef] of shelfDefs) {
|
|
151
146
|
if (mdef.standalone === false)
|
|
152
147
|
continue;
|
|
153
148
|
const ename = entityNames.get(k);
|
|
@@ -155,7 +150,7 @@ app.get('/api/search', async (req) => {
|
|
|
155
150
|
continue;
|
|
156
151
|
if (!textSearchKeys(mdef).length)
|
|
157
152
|
continue;
|
|
158
|
-
const [
|
|
153
|
+
const [library, type] = k.split('/');
|
|
159
154
|
const rows = (await fork.find(ename, {}, { limit: 1000 })).map((r) => serialize(r));
|
|
160
155
|
for (const row of rows) {
|
|
161
156
|
const m = rowMatch(mdef, row, tokens);
|
|
@@ -164,7 +159,7 @@ app.get('/api/search', async (req) => {
|
|
|
164
159
|
const label = recordTitle(mdef, row);
|
|
165
160
|
scored.push({
|
|
166
161
|
score: m.score,
|
|
167
|
-
result: {
|
|
162
|
+
result: { library, type, id: String(row.id), label, snippet: m.snippet },
|
|
168
163
|
});
|
|
169
164
|
}
|
|
170
165
|
}
|
|
@@ -245,15 +240,28 @@ app.post('/api/plugins/update-all', async (req, reply) => {
|
|
|
245
240
|
if (!requireAdmin(req, reply))
|
|
246
241
|
return;
|
|
247
242
|
const assets = await discoverPluginAssets();
|
|
248
|
-
const
|
|
243
|
+
const runtime = await discoverRuntime();
|
|
244
|
+
const [latestById, runtimeLatest] = await Promise.all([
|
|
245
|
+
Promise.all(assets.map(async (a) => [a.id, a.local ? null : await checkLatestVersion(a.packageName)])).then((entries) => new Map(entries)),
|
|
246
|
+
runtime && !runtime.local ? checkLatestVersion(runtime.packageName) : Promise.resolve(null),
|
|
247
|
+
]);
|
|
249
248
|
const targets = resolveAllUpdateTargets(assets, latestById);
|
|
250
|
-
|
|
249
|
+
const runtimeTarget = resolveRuntimeTarget(runtime, runtimeLatest);
|
|
250
|
+
const specs = [
|
|
251
|
+
...targets.map((t) => `${t.packageName}@${t.to}`),
|
|
252
|
+
...(runtimeTarget ? [`${runtimeTarget.packageName}@${runtimeTarget.to}`] : []),
|
|
253
|
+
];
|
|
254
|
+
if (specs.length === 0)
|
|
251
255
|
return reply.code(400).send({ error: 'no_update_available' });
|
|
252
|
-
const result = await runNpmInstall(
|
|
256
|
+
const result = await runNpmInstall(specs, process.cwd());
|
|
253
257
|
if (!result.ok) {
|
|
254
258
|
return reply.code(500).send({ error: 'install_failed', stderr: result.stderr });
|
|
255
259
|
}
|
|
256
|
-
|
|
260
|
+
const updated = [
|
|
261
|
+
...targets.map(({ id, from, to }) => ({ id, from, to })),
|
|
262
|
+
...(runtimeTarget ? [{ id: 'runtime', from: runtimeTarget.from, to: runtimeTarget.to }] : []),
|
|
263
|
+
];
|
|
264
|
+
reply.send({ ok: true, updated });
|
|
257
265
|
setTimeout(() => process.exit(0), 500);
|
|
258
266
|
});
|
|
259
267
|
app.get('/api/plugins/:id/settings', async (req, reply) => {
|
|
@@ -278,123 +286,27 @@ app.put('/api/plugins/:id/settings', (req, reply) => {
|
|
|
278
286
|
if (!p || !p.settings || p.settings.fields.length === 0) {
|
|
279
287
|
return reply.code(404).send({ error: 'no_settings' });
|
|
280
288
|
}
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
module: id,
|
|
284
|
-
label: `${id}.plugin.label`,
|
|
285
|
-
fields: p.settings.fields,
|
|
286
|
-
};
|
|
287
|
-
const existing = await getPluginSettings(id);
|
|
288
|
-
const body = preserveTree(p.settings.fields, (req.body ?? {}), existing);
|
|
289
|
-
const parsed = buildZodObject(syntheticMod).safeParse(body);
|
|
290
|
-
if (!parsed.success) {
|
|
291
|
-
throw new ValidationError(parsed.error.issues.map(toIssue));
|
|
292
|
-
}
|
|
293
|
-
const entityName = `_settings__${id}`;
|
|
294
|
-
const row = { plugin_id: id, ...parsed.data };
|
|
295
|
-
await getEm()
|
|
296
|
-
.fork()
|
|
297
|
-
.upsert(entityName, row);
|
|
298
|
-
return { ok: true, row: maskTree(p.settings.fields, row) };
|
|
289
|
+
const row = await writePluginSettings(getEm().fork(), id, (req.body ?? {}), req.user.login, plugins);
|
|
290
|
+
return { ok: true, row };
|
|
299
291
|
});
|
|
300
292
|
});
|
|
301
|
-
app.post('/api/plugins
|
|
302
|
-
if (!requireAdmin(req, reply))
|
|
303
|
-
return;
|
|
304
|
-
const settings = await getPluginSettings('finance');
|
|
305
|
-
const url = settings['firefly_url'];
|
|
306
|
-
const token = settings['token'];
|
|
307
|
-
if (!url || !token) {
|
|
308
|
-
return reply.code(400).send({ error: 'Finance settings not configured. Save them first.' });
|
|
309
|
-
}
|
|
310
|
-
const finRuntime = '@coffer-org/plugin-finance/runtime';
|
|
311
|
-
const { checkFireflyConnection } = (await import(__rewriteRelativeImportExtension(finRuntime)));
|
|
312
|
-
const result = await checkFireflyConnection(url, token);
|
|
313
|
-
if (result.ok)
|
|
314
|
-
return { ok: true };
|
|
315
|
-
return reply.code(400).send({ error: result.error });
|
|
316
|
-
});
|
|
317
|
-
app.post('/api/plugins/finance/sync', async (req, reply) => {
|
|
318
|
-
if (!requireAdmin(req, reply))
|
|
319
|
-
return;
|
|
320
|
-
const finRuntime = '@coffer-org/plugin-finance/runtime';
|
|
321
|
-
const { runSync } = (await import(__rewriteRelativeImportExtension(finRuntime)));
|
|
322
|
-
try {
|
|
323
|
-
const counts = await runSync();
|
|
324
|
-
return { ok: true, ...counts };
|
|
325
|
-
}
|
|
326
|
-
catch (e) {
|
|
327
|
-
return reply.code(400).send({ error: e.message });
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
app.post('/api/plugins/finance/import-wise', async (req, reply) => {
|
|
331
|
-
if (!requireAdmin(req, reply))
|
|
332
|
-
return;
|
|
333
|
-
const finRuntime = '@coffer-org/plugin-finance/runtime';
|
|
334
|
-
const { importWise } = (await import(__rewriteRelativeImportExtension(finRuntime)));
|
|
335
|
-
try {
|
|
336
|
-
return await importWise();
|
|
337
|
-
}
|
|
338
|
-
catch (e) {
|
|
339
|
-
return reply.code(400).send({ error: e.message });
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
app.post('/api/plugins/devices/scan', async (req, reply) => {
|
|
293
|
+
app.post('/api/plugins/:id/:action', async (req, reply) => {
|
|
343
294
|
if (!requireAdmin(req, reply))
|
|
344
295
|
return;
|
|
345
|
-
const
|
|
346
|
-
const
|
|
296
|
+
const { id, action } = req.params;
|
|
297
|
+
const fn = pluginHooks[id]?.actions?.[action];
|
|
298
|
+
if (!fn)
|
|
299
|
+
return reply.code(404).send({ error: `unknown action ${id}/${action}` });
|
|
347
300
|
try {
|
|
348
|
-
|
|
349
|
-
return { ok: true, result };
|
|
301
|
+
return await fn((req.body ?? {}));
|
|
350
302
|
}
|
|
351
303
|
catch (e) {
|
|
352
304
|
if (e instanceof ValidationError) {
|
|
353
305
|
const detail = e.issues.map((i) => `${i.path.join('.') || i.field}: ${i.code}`).join('; ');
|
|
354
|
-
return reply.code(422).send({ error: `Validation error
|
|
306
|
+
return reply.code(422).send({ error: `Validation error — ${detail}`, issues: e.issues });
|
|
355
307
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
});
|
|
359
|
-
app.post('/api/plugins/media/test', async (req, reply) => {
|
|
360
|
-
if (!requireAdmin(req, reply))
|
|
361
|
-
return;
|
|
362
|
-
const settings = await getPluginSettings('media');
|
|
363
|
-
const url = settings['jellyfin_url'];
|
|
364
|
-
const token = settings['jellyfin_token'];
|
|
365
|
-
if (!url || !token) {
|
|
366
|
-
return reply.code(400).send({ error: 'Jellyfin settings not configured. Save them first.' });
|
|
367
|
-
}
|
|
368
|
-
const mediaRuntime = '@coffer-org/plugin-media/runtime';
|
|
369
|
-
const { testConnection } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
|
|
370
|
-
const result = await testConnection(url, token);
|
|
371
|
-
if (result.ok)
|
|
372
|
-
return { ok: true };
|
|
373
|
-
return reply.code(400).send({ error: result.error });
|
|
374
|
-
});
|
|
375
|
-
app.post('/api/plugins/media/sync', async (req, reply) => {
|
|
376
|
-
if (!requireAdmin(req, reply))
|
|
377
|
-
return;
|
|
378
|
-
const mediaRuntime = '@coffer-org/plugin-media/runtime';
|
|
379
|
-
const { runJellyfinSync } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
|
|
380
|
-
try {
|
|
381
|
-
const counts = await runJellyfinSync();
|
|
382
|
-
return { ok: true, ...counts };
|
|
383
|
-
}
|
|
384
|
-
catch (e) {
|
|
385
|
-
return reply.code(400).send({ error: e.message });
|
|
386
|
-
}
|
|
387
|
-
});
|
|
388
|
-
app.post('/api/plugins/media/add', async (req, reply) => {
|
|
389
|
-
if (!requireAdmin(req, reply))
|
|
390
|
-
return;
|
|
391
|
-
const { query, tmdb_id, kind } = (req.body ?? {});
|
|
392
|
-
const mediaRuntime = '@coffer-org/plugin-media/runtime';
|
|
393
|
-
const { addTitle } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
|
|
394
|
-
try {
|
|
395
|
-
return await addTitle(query, tmdb_id, kind);
|
|
396
|
-
}
|
|
397
|
-
catch (e) {
|
|
308
|
+
if (e instanceof HttpError)
|
|
309
|
+
return reply.code(e.status).send({ error: e.message });
|
|
398
310
|
return reply.code(400).send({ error: e.message });
|
|
399
311
|
}
|
|
400
312
|
});
|
|
@@ -421,12 +333,12 @@ app.get('/api/fs/list', async (req, reply) => {
|
|
|
421
333
|
return { dir: abs, entries: [] };
|
|
422
334
|
}
|
|
423
335
|
});
|
|
424
|
-
function maskRecordRow(
|
|
425
|
-
const m =
|
|
336
|
+
function maskRecordRow(library, type, row) {
|
|
337
|
+
const m = getShelf(library, type);
|
|
426
338
|
let out = m ? maskTree(m.fields, row) : row;
|
|
427
339
|
const ext = out._extends;
|
|
428
340
|
if (ext) {
|
|
429
|
-
const defs = getExtendsFor(
|
|
341
|
+
const defs = getExtendsFor(library, type);
|
|
430
342
|
const maskedExt = {};
|
|
431
343
|
for (const [id, sub] of Object.entries(ext)) {
|
|
432
344
|
const def = defs.find((e) => e.id === id);
|
|
@@ -436,12 +348,12 @@ function maskRecordRow(vault, type, row) {
|
|
|
436
348
|
}
|
|
437
349
|
return out;
|
|
438
350
|
}
|
|
439
|
-
app.get('/api/:
|
|
440
|
-
const {
|
|
351
|
+
app.get('/api/:library/:type', async (req, reply) => {
|
|
352
|
+
const { library, type } = req.params;
|
|
441
353
|
const query = req.query;
|
|
442
354
|
try {
|
|
443
|
-
const rows = await recordList(
|
|
444
|
-
return rows.map((r) => maskRecordRow(
|
|
355
|
+
const rows = await recordList(library, type, query);
|
|
356
|
+
return rows.map((r) => maskRecordRow(library, type, r));
|
|
445
357
|
}
|
|
446
358
|
catch (e) {
|
|
447
359
|
if (e instanceof UnknownTypeError)
|
|
@@ -449,16 +361,16 @@ app.get('/api/:vault/:type', async (req, reply) => {
|
|
|
449
361
|
throw e;
|
|
450
362
|
}
|
|
451
363
|
});
|
|
452
|
-
app.get('/api/:
|
|
453
|
-
const {
|
|
364
|
+
app.get('/api/:library/:type/:id', async (req, reply) => {
|
|
365
|
+
const { library, type, id } = req.params;
|
|
454
366
|
const rid = Number(id);
|
|
455
367
|
if (isNaN(rid))
|
|
456
368
|
return reply.code(400).send({ error: 'invalid_id' });
|
|
457
369
|
try {
|
|
458
|
-
const row = await recordGet(
|
|
370
|
+
const row = await recordGet(library, type, rid);
|
|
459
371
|
if (!row)
|
|
460
372
|
return reply.code(404).send({ error: 'not_found' });
|
|
461
|
-
return maskRecordRow(
|
|
373
|
+
return maskRecordRow(library, type, row);
|
|
462
374
|
}
|
|
463
375
|
catch (e) {
|
|
464
376
|
if (e instanceof UnknownTypeError)
|
|
@@ -466,15 +378,15 @@ app.get('/api/:vault/:type/:id', async (req, reply) => {
|
|
|
466
378
|
throw e;
|
|
467
379
|
}
|
|
468
380
|
});
|
|
469
|
-
app.post('/api/:
|
|
470
|
-
const {
|
|
381
|
+
app.post('/api/:library/:type', (req, reply) => guard(reply, async () => {
|
|
382
|
+
const { library, type } = req.params;
|
|
471
383
|
try {
|
|
472
|
-
const m =
|
|
384
|
+
const m = getShelf(library, type);
|
|
473
385
|
let body = (req.body ?? {});
|
|
474
386
|
if (m)
|
|
475
387
|
body = preserveTree(m.fields, body, {});
|
|
476
|
-
const row = await recordCreate(
|
|
477
|
-
return reply.code(201).send(maskRecordRow(
|
|
388
|
+
const row = await recordCreate(library, type, body);
|
|
389
|
+
return reply.code(201).send(maskRecordRow(library, type, row));
|
|
478
390
|
}
|
|
479
391
|
catch (e) {
|
|
480
392
|
if (e instanceof UnknownTypeError)
|
|
@@ -482,18 +394,18 @@ app.post('/api/:vault/:type', (req, reply) => guard(reply, async () => {
|
|
|
482
394
|
throw e;
|
|
483
395
|
}
|
|
484
396
|
}));
|
|
485
|
-
app.patch('/api/:
|
|
486
|
-
const {
|
|
397
|
+
app.patch('/api/:library/:type/:id', (req, reply) => guard(reply, async () => {
|
|
398
|
+
const { library, type, id } = req.params;
|
|
487
399
|
const rid = Number(id);
|
|
488
400
|
if (isNaN(rid))
|
|
489
401
|
return reply.code(400).send({ error: 'invalid_id' });
|
|
490
402
|
try {
|
|
491
|
-
const m =
|
|
403
|
+
const m = getShelf(library, type);
|
|
492
404
|
let body = (req.body ?? {});
|
|
493
405
|
if (m) {
|
|
494
|
-
const existing = (await recordGet(
|
|
406
|
+
const existing = (await recordGet(library, type, rid)) ?? {};
|
|
495
407
|
body = preserveTree(m.fields, body, existing);
|
|
496
|
-
const exDefs = getExtendsFor(
|
|
408
|
+
const exDefs = getExtendsFor(library, type);
|
|
497
409
|
const exRecs = (existing._extends ?? {});
|
|
498
410
|
for (const def of exDefs) {
|
|
499
411
|
const bk = `_extend_${def.id}`;
|
|
@@ -503,8 +415,8 @@ app.patch('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
|
|
|
503
415
|
}
|
|
504
416
|
}
|
|
505
417
|
}
|
|
506
|
-
const row = await recordUpdate(
|
|
507
|
-
return maskRecordRow(
|
|
418
|
+
const row = await recordUpdate(library, type, rid, body);
|
|
419
|
+
return maskRecordRow(library, type, row);
|
|
508
420
|
}
|
|
509
421
|
catch (e) {
|
|
510
422
|
if (e instanceof UnknownTypeError)
|
|
@@ -512,13 +424,13 @@ app.patch('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
|
|
|
512
424
|
throw e;
|
|
513
425
|
}
|
|
514
426
|
}));
|
|
515
|
-
app.delete('/api/:
|
|
516
|
-
const {
|
|
427
|
+
app.delete('/api/:library/:type/:id', (req, reply) => guard(reply, async () => {
|
|
428
|
+
const { library, type, id } = req.params;
|
|
517
429
|
const rid = Number(id);
|
|
518
430
|
if (isNaN(rid))
|
|
519
431
|
return reply.code(400).send({ error: 'invalid_id' });
|
|
520
432
|
try {
|
|
521
|
-
await recordDelete(
|
|
433
|
+
await recordDelete(library, type, rid);
|
|
522
434
|
return reply.code(204).send();
|
|
523
435
|
}
|
|
524
436
|
catch (e) {
|
|
@@ -528,6 +440,7 @@ app.delete('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
|
|
|
528
440
|
}
|
|
529
441
|
}));
|
|
530
442
|
await registerAuthApi(app);
|
|
443
|
+
await registerMcpHttp(app);
|
|
531
444
|
await registerPluginsApi(app);
|
|
532
445
|
const WEB_DIST = process.env.WEB_DIST;
|
|
533
446
|
if (WEB_DIST && existsSync(join(WEB_DIST, 'index.html'))) {
|
|
@@ -549,7 +462,7 @@ if (WEB_DIST && existsSync(join(WEB_DIST, 'index.html'))) {
|
|
|
549
462
|
}
|
|
550
463
|
app
|
|
551
464
|
.listen({ port: PORT, host: '0.0.0.0' })
|
|
552
|
-
.then(() => app.log.info(`
|
|
465
|
+
.then(() => app.log.info(`library-server :${PORT}`))
|
|
553
466
|
.catch((e) => {
|
|
554
467
|
app.log.error(e);
|
|
555
468
|
process.exit(1);
|
package/dist/local-api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UnknownTypeError } from './records-api.ts';
|
|
2
2
|
export { UnknownTypeError };
|
|
3
|
-
export declare function localExists(
|
|
4
|
-
export declare function localPost(
|
|
5
|
-
export declare function localPatch(
|
|
3
|
+
export declare function localExists(library: string, type: string, id: number): Promise<boolean>;
|
|
4
|
+
export declare function localPost(library: string, type: string, body: unknown): Promise<Record<string, unknown>>;
|
|
5
|
+
export declare function localPatch(library: string, type: string, id: number, body: unknown): Promise<Record<string, unknown>>;
|
package/dist/local-api.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { recordCreate, recordUpdate, recordGet, UnknownTypeError } from "./records-api.js";
|
|
2
2
|
export { UnknownTypeError };
|
|
3
|
-
export async function localExists(
|
|
3
|
+
export async function localExists(library, type, id) {
|
|
4
4
|
try {
|
|
5
|
-
return Boolean(await recordGet(
|
|
5
|
+
return Boolean(await recordGet(library, type, id));
|
|
6
6
|
}
|
|
7
7
|
catch (e) {
|
|
8
8
|
if (e instanceof UnknownTypeError)
|
|
@@ -10,9 +10,9 @@ export async function localExists(vault, type, id) {
|
|
|
10
10
|
throw e;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
-
export function localPost(
|
|
14
|
-
return recordCreate(
|
|
13
|
+
export function localPost(library, type, body) {
|
|
14
|
+
return recordCreate(library, type, body, 'import');
|
|
15
15
|
}
|
|
16
|
-
export function localPatch(
|
|
17
|
-
return recordUpdate(
|
|
16
|
+
export function localPatch(library, type, id, body) {
|
|
17
|
+
return recordUpdate(library, type, id, body, 'import');
|
|
18
18
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import type { AuthRole } from './plugin-hooks.ts';
|
|
4
|
+
export declare function buildMcpServer(role: AuthRole, actor: string): Promise<McpServer>;
|
|
5
|
+
export declare function registerMcpHttp(app: FastifyInstance): Promise<void>;
|
package/dist/mcp-http.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import { collectMcpTools, resolveRagDeps, buildDomainSections, buildMcpInstructions } from "./mcp-tools.js";
|
|
4
|
+
import { getLogger } from "./log.js";
|
|
5
|
+
const log = getLogger('mcp-http');
|
|
6
|
+
export async function buildMcpServer(role, actor) {
|
|
7
|
+
const rag = await resolveRagDeps();
|
|
8
|
+
const tools = (await collectMcpTools({ rag, includeAdmin: role === 'admin', actor })).filter((t) => role === 'admin' || t.role === 'member');
|
|
9
|
+
const sections = await buildDomainSections();
|
|
10
|
+
const server = new McpServer({ name: 'coffer', version: '1.0.0' }, { instructions: buildMcpInstructions(sections) });
|
|
11
|
+
const registerTool = server.registerTool.bind(server);
|
|
12
|
+
for (const t of tools) {
|
|
13
|
+
registerTool(t.httpName, { description: t.description, inputSchema: t.inputSchema }, async (args) => t.handler(args));
|
|
14
|
+
}
|
|
15
|
+
return server;
|
|
16
|
+
}
|
|
17
|
+
export async function registerMcpHttp(app) {
|
|
18
|
+
app.all('/mcp', async (req, reply) => {
|
|
19
|
+
const role = req.user.role;
|
|
20
|
+
const actor = req.user.login;
|
|
21
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
22
|
+
reply.hijack();
|
|
23
|
+
reply.raw.on('close', () => void transport.close());
|
|
24
|
+
try {
|
|
25
|
+
const server = await buildMcpServer(role, actor);
|
|
26
|
+
await server.connect(transport);
|
|
27
|
+
await transport.handleRequest(req.raw, reply.raw, req.body);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
log.error(`/mcp handler error: ${e.message}`);
|
|
31
|
+
if (!reply.raw.headersSent) {
|
|
32
|
+
reply.raw.writeHead(500, { 'content-type': 'application/json' });
|
|
33
|
+
reply.raw.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'internal' }, id: null }));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type FastifyInstance } from 'fastify';
|
|
2
|
+
import { closeDb } from './db.ts';
|
|
3
|
+
export declare function freshMcpApp(opts?: {
|
|
4
|
+
withAdminTool?: boolean;
|
|
5
|
+
withInstructions?: boolean;
|
|
6
|
+
}): Promise<FastifyInstance>;
|
|
7
|
+
export declare function adminToken(): Promise<string>;
|
|
8
|
+
export declare function memberToken(): Promise<string>;
|
|
9
|
+
export declare function parseRpcResult(res: {
|
|
10
|
+
body: string;
|
|
11
|
+
headers: Record<string, unknown>;
|
|
12
|
+
}): Record<string, unknown> | undefined;
|
|
13
|
+
export declare function parseToolNames(res: {
|
|
14
|
+
body: string;
|
|
15
|
+
headers: Record<string, unknown>;
|
|
16
|
+
}): string[];
|
|
17
|
+
export { closeDb };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { initDb, closeDb } from "./db.js";
|
|
3
|
+
import { systemEntities } from "./entity-schema.js";
|
|
4
|
+
import { registerAuthApi, resolveRequestUser, PUBLIC_API_PATHS } from "./auth-api.js";
|
|
5
|
+
import { registerMcpHttp } from "./mcp-http.js";
|
|
6
|
+
import { createUser, createApiToken } from "./auth-store.js";
|
|
7
|
+
import { pluginHooks } from "./plugin-hooks.js";
|
|
8
|
+
let currentAdminToken;
|
|
9
|
+
let currentMemberToken;
|
|
10
|
+
export async function freshMcpApp(opts = {}) {
|
|
11
|
+
process.env['DB_PATH'] = ':memory:';
|
|
12
|
+
const orm = await initDb(systemEntities);
|
|
13
|
+
await orm.schema.update({ safe: false, dropTables: false });
|
|
14
|
+
const app = Fastify();
|
|
15
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
16
|
+
const gated = req.url.startsWith('/api/') || req.url.startsWith('/uploads/') || req.url === '/mcp' || req.url.startsWith('/mcp?');
|
|
17
|
+
if (!gated)
|
|
18
|
+
return;
|
|
19
|
+
if (PUBLIC_API_PATHS.some((p) => req.url.startsWith(p)))
|
|
20
|
+
return;
|
|
21
|
+
const user = await resolveRequestUser(req);
|
|
22
|
+
if (!user)
|
|
23
|
+
return reply.code(401).send({ error: 'unauthorized' });
|
|
24
|
+
req.user = user;
|
|
25
|
+
});
|
|
26
|
+
await registerAuthApi(app);
|
|
27
|
+
await registerMcpHttp(app);
|
|
28
|
+
const admin = await createUser({ login: 'admin', password: 'hunter2', displayName: 'Admin', role: 'admin' });
|
|
29
|
+
const member = await createUser({ login: 'member', password: 'hunter2', displayName: 'Member', role: 'member' });
|
|
30
|
+
currentAdminToken = `admin-raw-token-${Math.random().toString(36).slice(2)}`;
|
|
31
|
+
currentMemberToken = `member-raw-token-${Math.random().toString(36).slice(2)}`;
|
|
32
|
+
await createApiToken(admin.id, 'admin-token', currentAdminToken);
|
|
33
|
+
await createApiToken(member.id, 'member-token', currentMemberToken);
|
|
34
|
+
if (opts.withAdminTool || opts.withInstructions) {
|
|
35
|
+
pluginHooks['demo'] = {
|
|
36
|
+
agent: {
|
|
37
|
+
...(opts.withInstructions ? { instructions: 'Demo library — widgets.' } : {}),
|
|
38
|
+
...(opts.withAdminTool
|
|
39
|
+
? { tools: [{ name: 'danger', description: 'd', inputSchema: {}, role: 'admin', handler: () => 1 }] }
|
|
40
|
+
: {}),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const origClose = app.close.bind(app);
|
|
45
|
+
app.close = ((...args) => {
|
|
46
|
+
delete pluginHooks['demo'];
|
|
47
|
+
return origClose(...args);
|
|
48
|
+
});
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
51
|
+
export async function adminToken() {
|
|
52
|
+
if (!currentAdminToken)
|
|
53
|
+
throw new Error('adminToken() called before freshMcpApp()');
|
|
54
|
+
return currentAdminToken;
|
|
55
|
+
}
|
|
56
|
+
export async function memberToken() {
|
|
57
|
+
if (!currentMemberToken)
|
|
58
|
+
throw new Error('memberToken() called before freshMcpApp()');
|
|
59
|
+
return currentMemberToken;
|
|
60
|
+
}
|
|
61
|
+
export function parseRpcResult(res) {
|
|
62
|
+
const contentType = String(res.headers['content-type'] ?? '');
|
|
63
|
+
const raw = [];
|
|
64
|
+
if (contentType.includes('text/event-stream')) {
|
|
65
|
+
for (const line of res.body.split('\n')) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
if (trimmed.startsWith('data:'))
|
|
68
|
+
raw.push(trimmed.slice('data:'.length).trim());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
raw.push(res.body);
|
|
73
|
+
}
|
|
74
|
+
for (const s of raw) {
|
|
75
|
+
if (!s)
|
|
76
|
+
continue;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(s);
|
|
79
|
+
const msg = (Array.isArray(parsed) ? parsed : [parsed]).find((m) => m.result);
|
|
80
|
+
if (msg?.result)
|
|
81
|
+
return msg.result;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
export function parseToolNames(res) {
|
|
89
|
+
const contentType = String(res.headers['content-type'] ?? '');
|
|
90
|
+
const messages = [];
|
|
91
|
+
if (contentType.includes('text/event-stream')) {
|
|
92
|
+
for (const line of res.body.split('\n')) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed.startsWith('data:'))
|
|
95
|
+
continue;
|
|
96
|
+
const jsonStr = trimmed.slice('data:'.length).trim();
|
|
97
|
+
if (!jsonStr)
|
|
98
|
+
continue;
|
|
99
|
+
try {
|
|
100
|
+
messages.push(JSON.parse(jsonStr));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(res.body);
|
|
109
|
+
messages.push(...(Array.isArray(parsed) ? parsed : [parsed]));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const withTools = messages.find((m) => m.result?.tools);
|
|
115
|
+
return (withTools?.result?.tools ?? []).map((t) => t.name);
|
|
116
|
+
}
|
|
117
|
+
export { closeDb };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CofferClientApi } from '@coffer-org/mcp/client';
|
|
2
|
+
export declare function mapError(e: unknown): never;
|
|
3
|
+
export declare class LocalClient implements CofferClientApi {
|
|
4
|
+
getSchema(): Promise<unknown>;
|
|
5
|
+
listRecords(library: string, type: string, query?: Record<string, unknown>): Promise<unknown>;
|
|
6
|
+
getRecord(library: string, type: string, id: number): Promise<unknown>;
|
|
7
|
+
createRecord(library: string, type: string, fields: Record<string, unknown>): Promise<unknown>;
|
|
8
|
+
updateRecord(library: string, type: string, id: number, fields: Record<string, unknown>): Promise<unknown>;
|
|
9
|
+
deleteRecord(library: string, type: string, id: number): Promise<unknown>;
|
|
10
|
+
}
|