@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.
Files changed (58) hide show
  1. package/dist/auth-api.js +3 -3
  2. package/dist/background-scheduler.d.ts +22 -0
  3. package/dist/background-scheduler.js +101 -0
  4. package/dist/collection-io.d.ts +7 -7
  5. package/dist/collection-io.js +2 -2
  6. package/dist/connector-identity.d.ts +5 -0
  7. package/dist/connector-identity.js +32 -0
  8. package/dist/embed-openai.d.ts +10 -0
  9. package/dist/embed-openai.js +28 -0
  10. package/dist/entity-schema.d.ts +6 -5
  11. package/dist/entity-schema.js +24 -10
  12. package/dist/extend-io.d.ts +5 -3
  13. package/dist/extend-io.js +17 -10
  14. package/dist/extend-table.d.ts +4 -3
  15. package/dist/extend-table.js +10 -18
  16. package/dist/field-masking.d.ts +7 -0
  17. package/dist/field-masking.js +60 -0
  18. package/dist/index-signal.d.ts +3 -0
  19. package/dist/index-signal.js +14 -0
  20. package/dist/index.js +78 -165
  21. package/dist/local-api.d.ts +3 -3
  22. package/dist/local-api.js +6 -6
  23. package/dist/mcp-http.d.ts +5 -0
  24. package/dist/mcp-http.js +37 -0
  25. package/dist/mcp-http.test-helpers.d.ts +17 -0
  26. package/dist/mcp-http.test-helpers.js +117 -0
  27. package/dist/mcp-local.d.ts +10 -0
  28. package/dist/mcp-local.js +57 -0
  29. package/dist/mcp-tools.d.ts +61 -0
  30. package/dist/mcp-tools.js +225 -0
  31. package/dist/msg-log.d.ts +0 -1
  32. package/dist/msg-log.js +2 -2
  33. package/dist/mutate.d.ts +7 -5
  34. package/dist/mutate.js +20 -8
  35. package/dist/plugin-discovery.d.ts +6 -0
  36. package/dist/plugin-discovery.js +13 -0
  37. package/dist/plugin-hooks.d.ts +10 -0
  38. package/dist/plugin-hooks.js +8 -0
  39. package/dist/plugin-runtime.d.ts +1 -0
  40. package/dist/plugin-runtime.js +30 -6
  41. package/dist/plugin-updates.d.ts +10 -0
  42. package/dist/plugin-updates.js +7 -0
  43. package/dist/plugins-api.d.ts +1 -1
  44. package/dist/plugins-api.js +29 -12
  45. package/dist/records-api.d.ts +8 -8
  46. package/dist/records-api.js +35 -32
  47. package/dist/registry-context.d.ts +1 -1
  48. package/dist/registry-context.js +2 -2
  49. package/dist/schema-api.js +5 -5
  50. package/dist/settings-write.d.ts +19 -0
  51. package/dist/settings-write.js +60 -0
  52. package/dist/temporal.d.ts +3 -3
  53. package/dist/temporal.js +1 -1
  54. package/dist/thread-store.d.ts +20 -0
  55. package/dist/thread-store.js +27 -0
  56. package/dist/uploads.d.ts +1 -0
  57. package/dist/uploads.js +4 -0
  58. 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, buildZodObject } from '@coffer-org/sdk/module';
12
+ import { textSearchKeys, recordTitle } from '@coffer-org/sdk/shelf';
21
13
  import { tokenize } from '@coffer-org/core/search';
22
- import { moduleTableName } from "./entity-schema.js";
14
+ import { shelfTableName } from "./entity-schema.js";
23
15
  import { getEm, closeDb } from "./db.js";
24
- import { ValidationError, NotFoundError, toIssue } from "./mutate.js";
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 { discoverPluginAssets } from "./plugin-discovery.js";
31
- import { checkLatestVersion, resolveUpdateTarget, resolveAllUpdateTargets, runNpmInstall } from "./plugin-updates.js";
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 "./secrets.js";
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 { vaults, modules, getModule, extends_, getExtendsFor } = reg;
37
+ const { libraries, shelves, getShelf, extends_, getExtendsFor } = reg;
43
38
  const entityNames = new Map();
44
- const moduleDefs = new Map();
45
- for (const m of modules) {
46
- entityNames.set(`${m.vault}/${m.module}`, moduleTableName(m.vault, m.module));
47
- moduleDefs.set(`${m.vault}/${m.module}`, m);
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(vault, type) {
104
- return `${vault}/${type}`;
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/vaults', () => buildClientSchema());
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 moduleDefs) {
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 moduleDefs) {
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 [vault, type] = k.split('/');
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: { vault, type, id: String(row.id), label, snippet: m.snippet },
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 latestById = new Map(await Promise.all(assets.map(async (a) => [a.id, a.local ? null : await checkLatestVersion(a.packageName)])));
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
- if (targets.length === 0)
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(targets.map((t) => `${t.packageName}@${t.to}`), process.cwd());
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
- reply.send({ ok: true, updated: targets.map(({ id, from, to }) => ({ id, from, to })) });
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 syntheticMod = {
282
- vault: '_settings',
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/finance/test', async (req, reply) => {
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 devRuntime = '@coffer-org/plugin-devices/runtime';
346
- const { runScan } = (await import(__rewriteRelativeImportExtension(devRuntime)));
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
- const result = await runScan({ create: true });
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 during scan — ${detail}`, issues: e.issues });
306
+ return reply.code(422).send({ error: `Validation error — ${detail}`, issues: e.issues });
355
307
  }
356
- return reply.code(409).send({ error: e.message });
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(vault, type, row) {
425
- const m = getModule(vault, type);
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(vault, type);
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/:vault/:type', async (req, reply) => {
440
- const { vault, type } = req.params;
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(vault, type, query);
444
- return rows.map((r) => maskRecordRow(vault, type, r));
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/:vault/:type/:id', async (req, reply) => {
453
- const { vault, type, id } = req.params;
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(vault, type, rid);
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(vault, type, row);
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/:vault/:type', (req, reply) => guard(reply, async () => {
470
- const { vault, type } = req.params;
381
+ app.post('/api/:library/:type', (req, reply) => guard(reply, async () => {
382
+ const { library, type } = req.params;
471
383
  try {
472
- const m = getModule(vault, type);
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(vault, type, body);
477
- return reply.code(201).send(maskRecordRow(vault, type, row));
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/:vault/:type/:id', (req, reply) => guard(reply, async () => {
486
- const { vault, type, id } = req.params;
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 = getModule(vault, type);
403
+ const m = getShelf(library, type);
492
404
  let body = (req.body ?? {});
493
405
  if (m) {
494
- const existing = (await recordGet(vault, type, rid)) ?? {};
406
+ const existing = (await recordGet(library, type, rid)) ?? {};
495
407
  body = preserveTree(m.fields, body, existing);
496
- const exDefs = getExtendsFor(vault, type);
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(vault, type, rid, body);
507
- return maskRecordRow(vault, type, row);
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/:vault/:type/:id', (req, reply) => guard(reply, async () => {
516
- const { vault, type, id } = req.params;
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(vault, type, rid);
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(`vault-server :${PORT}`))
465
+ .then(() => app.log.info(`library-server :${PORT}`))
553
466
  .catch((e) => {
554
467
  app.log.error(e);
555
468
  process.exit(1);
@@ -1,5 +1,5 @@
1
1
  import { UnknownTypeError } from './records-api.ts';
2
2
  export { UnknownTypeError };
3
- export declare function localExists(vault: string, type: string, id: number): Promise<boolean>;
4
- export declare function localPost(vault: string, type: string, body: unknown): Promise<Record<string, unknown>>;
5
- export declare function localPatch(vault: string, type: string, id: number, body: unknown): Promise<Record<string, unknown>>;
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(vault, type, id) {
3
+ export async function localExists(library, type, id) {
4
4
  try {
5
- return Boolean(await recordGet(vault, type, id));
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(vault, type, body) {
14
- return recordCreate(vault, type, body, 'import');
13
+ export function localPost(library, type, body) {
14
+ return recordCreate(library, type, body, 'import');
15
15
  }
16
- export function localPatch(vault, type, id, body) {
17
- return recordUpdate(vault, type, id, body, 'import');
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>;
@@ -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
+ }