@coffer-org/server 1.7.1 → 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 (54) 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 +59 -159
  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-hooks.d.ts +10 -0
  36. package/dist/plugin-hooks.js +8 -0
  37. package/dist/plugin-runtime.d.ts +1 -0
  38. package/dist/plugin-runtime.js +30 -6
  39. package/dist/plugins-api.d.ts +1 -1
  40. package/dist/plugins-api.js +21 -11
  41. package/dist/records-api.d.ts +8 -8
  42. package/dist/records-api.js +35 -32
  43. package/dist/registry-context.d.ts +1 -1
  44. package/dist/registry-context.js +2 -2
  45. package/dist/schema-api.js +5 -5
  46. package/dist/settings-write.d.ts +19 -0
  47. package/dist/settings-write.js +60 -0
  48. package/dist/temporal.d.ts +3 -3
  49. package/dist/temporal.js +1 -1
  50. package/dist/thread-store.d.ts +20 -0
  51. package/dist/thread-store.js +27 -0
  52. package/dist/uploads.d.ts +1 -0
  53. package/dist/uploads.js +4 -0
  54. 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";
23
+ import { registerMcpHttp } from "./mcp-http.js";
30
24
  import { discoverPluginAssets, discoverRuntime } from "./plugin-discovery.js";
31
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
  }
@@ -291,123 +286,27 @@ app.put('/api/plugins/:id/settings', (req, reply) => {
291
286
  if (!p || !p.settings || p.settings.fields.length === 0) {
292
287
  return reply.code(404).send({ error: 'no_settings' });
293
288
  }
294
- const syntheticMod = {
295
- vault: '_settings',
296
- module: id,
297
- label: `${id}.plugin.label`,
298
- fields: p.settings.fields,
299
- };
300
- const existing = await getPluginSettings(id);
301
- const body = preserveTree(p.settings.fields, (req.body ?? {}), existing);
302
- const parsed = buildZodObject(syntheticMod).safeParse(body);
303
- if (!parsed.success) {
304
- throw new ValidationError(parsed.error.issues.map(toIssue));
305
- }
306
- const entityName = `_settings__${id}`;
307
- const row = { plugin_id: id, ...parsed.data };
308
- await getEm()
309
- .fork()
310
- .upsert(entityName, row);
311
- 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 };
312
291
  });
313
292
  });
314
- app.post('/api/plugins/finance/test', async (req, reply) => {
315
- if (!requireAdmin(req, reply))
316
- return;
317
- const settings = await getPluginSettings('finance');
318
- const url = settings['firefly_url'];
319
- const token = settings['token'];
320
- if (!url || !token) {
321
- return reply.code(400).send({ error: 'Finance settings not configured. Save them first.' });
322
- }
323
- const finRuntime = '@coffer-org/plugin-finance/runtime';
324
- const { checkFireflyConnection } = (await import(__rewriteRelativeImportExtension(finRuntime)));
325
- const result = await checkFireflyConnection(url, token);
326
- if (result.ok)
327
- return { ok: true };
328
- return reply.code(400).send({ error: result.error });
329
- });
330
- app.post('/api/plugins/finance/sync', async (req, reply) => {
331
- if (!requireAdmin(req, reply))
332
- return;
333
- const finRuntime = '@coffer-org/plugin-finance/runtime';
334
- const { runSync } = (await import(__rewriteRelativeImportExtension(finRuntime)));
335
- try {
336
- const counts = await runSync();
337
- return { ok: true, ...counts };
338
- }
339
- catch (e) {
340
- return reply.code(400).send({ error: e.message });
341
- }
342
- });
343
- app.post('/api/plugins/finance/import-wise', async (req, reply) => {
344
- if (!requireAdmin(req, reply))
345
- return;
346
- const finRuntime = '@coffer-org/plugin-finance/runtime';
347
- const { importWise } = (await import(__rewriteRelativeImportExtension(finRuntime)));
348
- try {
349
- return await importWise();
350
- }
351
- catch (e) {
352
- return reply.code(400).send({ error: e.message });
353
- }
354
- });
355
- app.post('/api/plugins/devices/scan', async (req, reply) => {
293
+ app.post('/api/plugins/:id/:action', async (req, reply) => {
356
294
  if (!requireAdmin(req, reply))
357
295
  return;
358
- const devRuntime = '@coffer-org/plugin-devices/runtime';
359
- 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}` });
360
300
  try {
361
- const result = await runScan({ create: true });
362
- return { ok: true, result };
301
+ return await fn((req.body ?? {}));
363
302
  }
364
303
  catch (e) {
365
304
  if (e instanceof ValidationError) {
366
305
  const detail = e.issues.map((i) => `${i.path.join('.') || i.field}: ${i.code}`).join('; ');
367
- 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 });
368
307
  }
369
- return reply.code(409).send({ error: e.message });
370
- }
371
- });
372
- app.post('/api/plugins/media/test', async (req, reply) => {
373
- if (!requireAdmin(req, reply))
374
- return;
375
- const settings = await getPluginSettings('media');
376
- const url = settings['jellyfin_url'];
377
- const token = settings['jellyfin_token'];
378
- if (!url || !token) {
379
- return reply.code(400).send({ error: 'Jellyfin settings not configured. Save them first.' });
380
- }
381
- const mediaRuntime = '@coffer-org/plugin-media/runtime';
382
- const { testConnection } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
383
- const result = await testConnection(url, token);
384
- if (result.ok)
385
- return { ok: true };
386
- return reply.code(400).send({ error: result.error });
387
- });
388
- app.post('/api/plugins/media/sync', async (req, reply) => {
389
- if (!requireAdmin(req, reply))
390
- return;
391
- const mediaRuntime = '@coffer-org/plugin-media/runtime';
392
- const { runJellyfinSync } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
393
- try {
394
- const counts = await runJellyfinSync();
395
- return { ok: true, ...counts };
396
- }
397
- catch (e) {
398
- return reply.code(400).send({ error: e.message });
399
- }
400
- });
401
- app.post('/api/plugins/media/add', async (req, reply) => {
402
- if (!requireAdmin(req, reply))
403
- return;
404
- const { query, tmdb_id, kind } = (req.body ?? {});
405
- const mediaRuntime = '@coffer-org/plugin-media/runtime';
406
- const { addTitle } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
407
- try {
408
- return await addTitle(query, tmdb_id, kind);
409
- }
410
- catch (e) {
308
+ if (e instanceof HttpError)
309
+ return reply.code(e.status).send({ error: e.message });
411
310
  return reply.code(400).send({ error: e.message });
412
311
  }
413
312
  });
@@ -434,12 +333,12 @@ app.get('/api/fs/list', async (req, reply) => {
434
333
  return { dir: abs, entries: [] };
435
334
  }
436
335
  });
437
- function maskRecordRow(vault, type, row) {
438
- const m = getModule(vault, type);
336
+ function maskRecordRow(library, type, row) {
337
+ const m = getShelf(library, type);
439
338
  let out = m ? maskTree(m.fields, row) : row;
440
339
  const ext = out._extends;
441
340
  if (ext) {
442
- const defs = getExtendsFor(vault, type);
341
+ const defs = getExtendsFor(library, type);
443
342
  const maskedExt = {};
444
343
  for (const [id, sub] of Object.entries(ext)) {
445
344
  const def = defs.find((e) => e.id === id);
@@ -449,12 +348,12 @@ function maskRecordRow(vault, type, row) {
449
348
  }
450
349
  return out;
451
350
  }
452
- app.get('/api/:vault/:type', async (req, reply) => {
453
- const { vault, type } = req.params;
351
+ app.get('/api/:library/:type', async (req, reply) => {
352
+ const { library, type } = req.params;
454
353
  const query = req.query;
455
354
  try {
456
- const rows = await recordList(vault, type, query);
457
- 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));
458
357
  }
459
358
  catch (e) {
460
359
  if (e instanceof UnknownTypeError)
@@ -462,16 +361,16 @@ app.get('/api/:vault/:type', async (req, reply) => {
462
361
  throw e;
463
362
  }
464
363
  });
465
- app.get('/api/:vault/:type/:id', async (req, reply) => {
466
- const { vault, type, id } = req.params;
364
+ app.get('/api/:library/:type/:id', async (req, reply) => {
365
+ const { library, type, id } = req.params;
467
366
  const rid = Number(id);
468
367
  if (isNaN(rid))
469
368
  return reply.code(400).send({ error: 'invalid_id' });
470
369
  try {
471
- const row = await recordGet(vault, type, rid);
370
+ const row = await recordGet(library, type, rid);
472
371
  if (!row)
473
372
  return reply.code(404).send({ error: 'not_found' });
474
- return maskRecordRow(vault, type, row);
373
+ return maskRecordRow(library, type, row);
475
374
  }
476
375
  catch (e) {
477
376
  if (e instanceof UnknownTypeError)
@@ -479,15 +378,15 @@ app.get('/api/:vault/:type/:id', async (req, reply) => {
479
378
  throw e;
480
379
  }
481
380
  });
482
- app.post('/api/:vault/:type', (req, reply) => guard(reply, async () => {
483
- const { vault, type } = req.params;
381
+ app.post('/api/:library/:type', (req, reply) => guard(reply, async () => {
382
+ const { library, type } = req.params;
484
383
  try {
485
- const m = getModule(vault, type);
384
+ const m = getShelf(library, type);
486
385
  let body = (req.body ?? {});
487
386
  if (m)
488
387
  body = preserveTree(m.fields, body, {});
489
- const row = await recordCreate(vault, type, body);
490
- 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));
491
390
  }
492
391
  catch (e) {
493
392
  if (e instanceof UnknownTypeError)
@@ -495,18 +394,18 @@ app.post('/api/:vault/:type', (req, reply) => guard(reply, async () => {
495
394
  throw e;
496
395
  }
497
396
  }));
498
- app.patch('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
499
- 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;
500
399
  const rid = Number(id);
501
400
  if (isNaN(rid))
502
401
  return reply.code(400).send({ error: 'invalid_id' });
503
402
  try {
504
- const m = getModule(vault, type);
403
+ const m = getShelf(library, type);
505
404
  let body = (req.body ?? {});
506
405
  if (m) {
507
- const existing = (await recordGet(vault, type, rid)) ?? {};
406
+ const existing = (await recordGet(library, type, rid)) ?? {};
508
407
  body = preserveTree(m.fields, body, existing);
509
- const exDefs = getExtendsFor(vault, type);
408
+ const exDefs = getExtendsFor(library, type);
510
409
  const exRecs = (existing._extends ?? {});
511
410
  for (const def of exDefs) {
512
411
  const bk = `_extend_${def.id}`;
@@ -516,8 +415,8 @@ app.patch('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
516
415
  }
517
416
  }
518
417
  }
519
- const row = await recordUpdate(vault, type, rid, body);
520
- return maskRecordRow(vault, type, row);
418
+ const row = await recordUpdate(library, type, rid, body);
419
+ return maskRecordRow(library, type, row);
521
420
  }
522
421
  catch (e) {
523
422
  if (e instanceof UnknownTypeError)
@@ -525,13 +424,13 @@ app.patch('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
525
424
  throw e;
526
425
  }
527
426
  }));
528
- app.delete('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
529
- 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;
530
429
  const rid = Number(id);
531
430
  if (isNaN(rid))
532
431
  return reply.code(400).send({ error: 'invalid_id' });
533
432
  try {
534
- await recordDelete(vault, type, rid);
433
+ await recordDelete(library, type, rid);
535
434
  return reply.code(204).send();
536
435
  }
537
436
  catch (e) {
@@ -541,6 +440,7 @@ app.delete('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
541
440
  }
542
441
  }));
543
442
  await registerAuthApi(app);
443
+ await registerMcpHttp(app);
544
444
  await registerPluginsApi(app);
545
445
  const WEB_DIST = process.env.WEB_DIST;
546
446
  if (WEB_DIST && existsSync(join(WEB_DIST, 'index.html'))) {
@@ -562,7 +462,7 @@ if (WEB_DIST && existsSync(join(WEB_DIST, 'index.html'))) {
562
462
  }
563
463
  app
564
464
  .listen({ port: PORT, host: '0.0.0.0' })
565
- .then(() => app.log.info(`vault-server :${PORT}`))
465
+ .then(() => app.log.info(`library-server :${PORT}`))
566
466
  .catch((e) => {
567
467
  app.log.error(e);
568
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
+ }