@coffer-org/server 3.4.0 → 5.0.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 (101) hide show
  1. package/dist/auth-api.js +4 -5
  2. package/dist/cache-clock.d.ts +8 -0
  3. package/dist/cache-clock.js +103 -0
  4. package/dist/cache-refresh.d.ts +7 -0
  5. package/dist/cache-refresh.js +322 -0
  6. package/dist/collection-io.d.ts +13 -0
  7. package/dist/collection-io.js +40 -29
  8. package/dist/compute-unit.d.ts +66 -0
  9. package/dist/compute-unit.js +369 -0
  10. package/dist/condition-where.js +1 -3
  11. package/dist/data-dir.d.ts +3 -0
  12. package/dist/data-dir.js +39 -0
  13. package/dist/entity-schema.js +5 -2
  14. package/dist/extend-table.d.ts +1 -1
  15. package/dist/extend-table.js +22 -8
  16. package/dist/file-fields.js +50 -23
  17. package/dist/frontend-agent.d.ts +1 -1
  18. package/dist/frontend-agent.js +2 -3
  19. package/dist/global-search.js +1 -1
  20. package/dist/index.js +20 -26
  21. package/dist/mcp-contract/client.d.ts +24 -0
  22. package/dist/mcp-contract/client.js +12 -0
  23. package/dist/mcp-contract/schema.d.ts +46 -0
  24. package/dist/mcp-contract/schema.js +90 -0
  25. package/dist/mcp-contract/tools.d.ts +19 -0
  26. package/dist/mcp-contract/tools.js +160 -0
  27. package/dist/mcp-http.js +4 -2
  28. package/dist/mcp-local.d.ts +1 -1
  29. package/dist/mcp-local.js +1 -1
  30. package/dist/mcp-tools.d.ts +13 -1
  31. package/dist/mcp-tools.js +46 -16
  32. package/dist/migrations.d.ts +5 -0
  33. package/dist/migrations.js +122 -0
  34. package/dist/mutate.js +77 -36
  35. package/dist/orchestrator/agent-capabilities.d.ts +2 -0
  36. package/dist/orchestrator/agent-capabilities.js +201 -0
  37. package/dist/orchestrator/allow.d.ts +16 -0
  38. package/dist/orchestrator/allow.js +65 -0
  39. package/dist/orchestrator/attachments.d.ts +2 -0
  40. package/dist/orchestrator/attachments.js +11 -0
  41. package/dist/orchestrator/config.d.ts +4 -0
  42. package/dist/orchestrator/config.js +19 -0
  43. package/dist/orchestrator/db.d.ts +17 -0
  44. package/dist/orchestrator/db.js +12 -0
  45. package/dist/orchestrator/diagnostics.d.ts +9 -0
  46. package/dist/orchestrator/diagnostics.js +13 -0
  47. package/dist/orchestrator/environment.d.ts +2 -0
  48. package/dist/orchestrator/environment.js +14 -0
  49. package/dist/orchestrator/file-inspection.d.ts +3 -0
  50. package/dist/orchestrator/file-inspection.js +61 -0
  51. package/dist/orchestrator/format.d.ts +1 -0
  52. package/dist/orchestrator/format.js +6 -0
  53. package/dist/orchestrator/index.d.ts +30 -0
  54. package/dist/orchestrator/index.js +56 -0
  55. package/dist/orchestrator/live-message.d.ts +14 -0
  56. package/dist/orchestrator/live-message.js +100 -0
  57. package/dist/orchestrator/pipeline.d.ts +11 -0
  58. package/dist/orchestrator/pipeline.js +198 -0
  59. package/dist/orchestrator/registry.d.ts +13 -0
  60. package/dist/orchestrator/registry.js +74 -0
  61. package/dist/orchestrator/starters.d.ts +19 -0
  62. package/dist/orchestrator/starters.js +81 -0
  63. package/dist/orchestrator/suggestion-capabilities.d.ts +5 -0
  64. package/dist/orchestrator/suggestion-capabilities.js +33 -0
  65. package/dist/orchestrator/system-assembly.d.ts +4 -0
  66. package/dist/orchestrator/system-assembly.js +3 -0
  67. package/dist/orchestrator/system-capabilities.d.ts +2 -0
  68. package/dist/orchestrator/system-capabilities.js +63 -0
  69. package/dist/orchestrator/types.d.ts +141 -0
  70. package/dist/orchestrator/types.js +1 -0
  71. package/dist/part-injection.d.ts +3 -4
  72. package/dist/part-injection.js +25 -368
  73. package/dist/plugin-hooks.d.ts +1 -0
  74. package/dist/plugin-i18n.js +2 -1
  75. package/dist/plugin-runtime.d.ts +2 -2
  76. package/dist/plugin-runtime.js +20 -11
  77. package/dist/plugin-updates.d.ts +2 -2
  78. package/dist/plugin-updates.js +6 -12
  79. package/dist/public-url.js +2 -1
  80. package/dist/recompute-derived.d.ts +2 -1
  81. package/dist/recompute-derived.js +111 -34
  82. package/dist/records-api.js +14 -16
  83. package/dist/registry-context.d.ts +1 -1
  84. package/dist/registry-context.js +3 -0
  85. package/dist/search-index.js +1 -1
  86. package/dist/search-indexer.d.ts +1 -1
  87. package/dist/search-indexer.js +3 -3
  88. package/dist/settings-groups.d.ts +14 -0
  89. package/dist/settings-groups.js +25 -0
  90. package/dist/settings-write.d.ts +4 -4
  91. package/dist/settings-write.js +23 -26
  92. package/dist/system-settings.d.ts +3 -0
  93. package/dist/system-settings.js +27 -0
  94. package/dist/thread-store.d.ts +3 -2
  95. package/dist/thread-store.js +6 -5
  96. package/dist/trigger-queue.d.ts +33 -0
  97. package/dist/trigger-queue.js +432 -0
  98. package/dist/uploads.js +6 -3
  99. package/dist/value-compare.d.ts +1 -0
  100. package/dist/value-compare.js +7 -0
  101. package/package.json +17 -3
@@ -87,38 +87,65 @@ export function dropUnchangedFileFields(m, input, stored) {
87
87
  }
88
88
  return out ?? input;
89
89
  }
90
+ function normalizeFileValue(raw, field, path, issues) {
91
+ const parsed = jsonValue(raw);
92
+ const isArray = Array.isArray(parsed);
93
+ const items = (isArray ? parsed : [parsed]);
94
+ const out = [];
95
+ let failed = false;
96
+ for (const it of items) {
97
+ if (typeof it !== 'object' || it === null || typeof it.name !== 'string') {
98
+ issues.push({ field, code: 'file_not_uploaded', path });
99
+ failed = true;
100
+ continue;
101
+ }
102
+ const resolved = resolveEntry(it);
103
+ if (!resolved) {
104
+ issues.push({ field, code: 'file_not_uploaded', params: { name: it.name }, path });
105
+ failed = true;
106
+ continue;
107
+ }
108
+ out.push(resolved);
109
+ }
110
+ if (failed)
111
+ return null;
112
+ return { value: isArray ? out : out[0] };
113
+ }
90
114
  function normalizeLevel(fields, data, path) {
91
115
  const issues = [];
92
116
  for (const [key, f] of fieldEntries(fields)) {
93
- if (f.prim !== 'file')
94
- continue;
95
117
  if (!(key in data))
96
118
  continue;
97
- const raw = data[key];
98
- if (raw == null || raw === '')
119
+ if (f.prim === 'file') {
120
+ const raw = data[key];
121
+ if (raw == null || raw === '')
122
+ continue;
123
+ const r = normalizeFileValue(raw, key, [...path, key], issues);
124
+ if (r)
125
+ data[key] = r.value;
126
+ continue;
127
+ }
128
+ if (!f.parts?.some((p) => p.meta.prim === 'file'))
99
129
  continue;
100
- const parsed = jsonValue(raw);
101
- const isArray = Array.isArray(parsed);
102
- const items = (isArray ? parsed : [parsed]);
103
- const out = [];
104
- let failed = false;
105
- for (const it of items) {
106
- if (typeof it !== 'object' || it === null || typeof it.name !== 'string') {
107
- issues.push({ field: key, code: 'file_not_uploaded', path: [...path, key] });
108
- failed = true;
130
+ const obj = jsonValue(data[key]);
131
+ if (obj == null || typeof obj !== 'object' || Array.isArray(obj))
132
+ continue;
133
+ const row = obj;
134
+ let touched = false;
135
+ for (const p of f.parts) {
136
+ if (p.meta.prim !== 'file')
137
+ continue;
138
+ const raw = row[p.role];
139
+ if (raw == null || raw === '')
109
140
  continue;
110
- }
111
- const resolved = resolveEntry(it);
112
- if (!resolved) {
113
- issues.push({ field: key, code: 'file_not_uploaded', params: { name: it.name }, path: [...path, key] });
114
- failed = true;
141
+ const r = normalizeFileValue(raw, key, [...path, key, p.role], issues);
142
+ if (!r)
115
143
  continue;
116
- }
117
- out.push(resolved);
144
+ row[p.role] = r.value;
145
+ touched = true;
118
146
  }
119
- if (failed)
120
- continue;
121
- data[key] = isArray ? out : out[0];
147
+ if (touched)
148
+ data[key] = row;
122
149
  }
123
150
  for (const c of collectionGroups(fields)) {
124
151
  const rows = data[c.key];
@@ -1 +1 @@
1
- export declare function frontendInstructions(): Promise<string | null>;
1
+ export declare function frontendInstructions(siteUrl: string): Promise<string | null>;
@@ -10,9 +10,8 @@ import { readFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { getLogger } from '@coffer-org/sdk/logger';
13
- import { configuredPublicUrl } from "./public-url.js";
14
13
  const log = getLogger('frontend-agent');
15
- export async function frontendInstructions() {
14
+ export async function frontendInstructions(siteUrl) {
16
15
  const dist = process.env['WEB_DIST'];
17
16
  if (!dist)
18
17
  return null;
@@ -27,7 +26,7 @@ export async function frontendInstructions() {
27
26
  log.warn(`frontend ${rel} exports no agent() — no site-link instructions`);
28
27
  return null;
29
28
  }
30
- const text = await mod.agent({ siteUrl: await configuredPublicUrl() });
29
+ const text = await mod.agent({ siteUrl });
31
30
  return typeof text === 'string' && text.trim() ? text : null;
32
31
  }
33
32
  catch (e) {
@@ -1,5 +1,5 @@
1
1
  import { textSearchKeys, titleKey, recordTitle, storageColumnsFor } from '@coffer-org/sdk/shelf';
2
- import { tokenize } from '@coffer-org/core/search';
2
+ import { tokenize } from '@coffer-org/sdk/search';
3
3
  import { getLogger } from '@coffer-org/sdk/logger';
4
4
  import { getEm } from "./db.js";
5
5
  import { rowMatch } from "./records-api.js";
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { resolveEmbed } from "./embed.js";
17
17
  import { resolveWithinHome, filterSort, listDir } from "./fs-list.js";
18
18
  import { initPlugins, teardownPlugins, readDisabled, purgePluginData, getPluginSettings, getPlugins, } from "./plugin-runtime.js";
19
19
  import { registerPluginsApi } from "./plugins-api.js";
20
+ import { orchestratorDiagnostics } from "./orchestrator/index.js";
20
21
  import { registerPluginUserApi, registerPluginAdminApi } from "./plugin-user-api.js";
21
22
  import { registerAuthApi, resolveRequestUser, requireAdmin, PUBLIC_API_PATHS } from "./auth-api.js";
22
23
  import { registerMcpHttp } from "./mcp-http.js";
@@ -30,12 +31,13 @@ import { showcaseList, showcaseListAll, showcaseCount, UnknownShowcaseError } fr
30
31
  import { getActiveRegistry } from "./registry-context.js";
31
32
  import { maskTree, preserveTree } from "./field-masking.js";
32
33
  import { writePluginSettings } from "./settings-write.js";
34
+ import { findSettingsGroup, settingsGroups } from "./settings-groups.js";
33
35
  import { rootLogger, getLogger } from "./log.js";
34
36
  import { uploadsDir } from "./uploads.js";
35
37
  import { mimeForName } from "./file-fields.js";
36
38
  import { verifyUploadTicket } from "./upload-ticket.js";
37
39
  import { setPluginState } from "./plugin-state.js";
38
- import { SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY } from "./search-indexer.js";
40
+ import { SEARCH_STATE_NAMESPACE, SEARCH_CURSOR_KEY } from "./search-indexer.js";
39
41
  import { globalSearch } from "./global-search.js";
40
42
  import { applySmartRanking, getRecordActivity, markRecordViewed, setRecordFavorite } from "./record-activity.js";
41
43
  import { tracksRecordActivity } from "./record-activity-policy.js";
@@ -191,8 +193,6 @@ app.patch('/api/plugins/:id', async (req, reply) => {
191
193
  const p = plugins.find((x) => x.id === id);
192
194
  if (!p)
193
195
  return reply.code(404).send({ error: 'unknown_plugin' });
194
- if (id === 'core')
195
- return reply.code(400).send({ error: 'core_required' });
196
196
  const disabled = await readDisabled();
197
197
  if (!enabled) {
198
198
  const dependents = plugins.filter((x) => !disabled.has(x.id) && x.id !== id && x.dependsOn.includes(id));
@@ -215,7 +215,7 @@ app.patch('/api/plugins/:id', async (req, reply) => {
215
215
  }
216
216
  await fork.flush();
217
217
  if (enabled) {
218
- await setPluginState(SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY, '0');
218
+ await setPluginState(SEARCH_STATE_NAMESPACE, SEARCH_CURSOR_KEY, '0');
219
219
  }
220
220
  return { ok: true, restartRequired: true };
221
221
  });
@@ -227,8 +227,6 @@ app.delete('/api/plugins/:id/data', async (req, reply) => {
227
227
  const p = plugins.find((x) => x.id === id);
228
228
  if (!p)
229
229
  return reply.code(404).send({ error: 'unknown_plugin' });
230
- if (id === 'core')
231
- return reply.code(400).send({ error: 'core_required' });
232
230
  const disabled = await readDisabled();
233
231
  const dependents = plugins.filter((x) => !disabled.has(x.id) && x.id !== id && x.dependsOn.includes(id));
234
232
  if (dependents.length) {
@@ -286,14 +284,9 @@ app.post('/api/plugins/update-all', async (req, reply) => {
286
284
  app.post('/api/system/update', async (req, reply) => {
287
285
  if (!requireAdmin(req, reply))
288
286
  return;
289
- const assets = await discoverPluginAssets();
290
287
  const runtime = await discoverRuntime();
291
- const core = assets.find((a) => a.id === 'core');
292
- const [coreLatest, runtimeLatest] = await Promise.all([
293
- core && !core.local ? checkLatestVersion(core.packageName) : Promise.resolve(null),
294
- runtime && !runtime.local ? checkLatestVersion(runtime.packageName) : Promise.resolve(null),
295
- ]);
296
- const targets = resolveBaseTargets(assets, runtime, coreLatest, runtimeLatest);
288
+ const runtimeLatest = runtime && !runtime.local ? await checkLatestVersion(runtime.packageName) : null;
289
+ const targets = resolveBaseTargets(runtime, runtimeLatest);
297
290
  if (targets.length === 0)
298
291
  return reply.code(400).send({ error: 'no_update_available' });
299
292
  const result = await runNpmInstall(targets.map((t) => `${t.packageName}@${t.to}`), process.cwd());
@@ -303,29 +296,30 @@ app.post('/api/system/update', async (req, reply) => {
303
296
  reply.send({ ok: true, updated: targets.map(({ id, from, to }) => ({ id, from, to })) });
304
297
  setTimeout(() => process.exit(0), 500);
305
298
  });
306
- app.get('/api/plugins/:id/settings', async (req, reply) => {
299
+ app.post('/api/system/orchestrator-diagnostics', async (req, reply) => {
300
+ if (!requireAdmin(req, reply))
301
+ return;
302
+ reply.send(await orchestratorDiagnostics());
303
+ });
304
+ app.get('/api/settings', async () => (await settingsGroups()).map((g) => ({ id: g.id, label: g.label, fields: g.fields })));
305
+ app.get('/api/settings/:id', async (req, reply) => {
307
306
  if (!requireAdmin(req, reply))
308
307
  return;
309
308
  const { id } = req.params;
310
- const plugins = await getPlugins();
311
- const p = plugins.find((x) => x.id === id);
312
- if (!p || !p.settings || p.settings.fields.length === 0) {
309
+ const group = await findSettingsGroup(id);
310
+ if (!group)
313
311
  return reply.code(404).send({ error: 'no_settings' });
314
- }
315
- const row = await getPluginSettings(id);
316
- return maskTree(p.settings.fields, row);
312
+ return maskTree(group.fields, await getPluginSettings(id));
317
313
  });
318
- app.put('/api/plugins/:id/settings', (req, reply) => {
314
+ app.put('/api/settings/:id', (req, reply) => {
319
315
  if (!requireAdmin(req, reply))
320
316
  return;
321
317
  return guard(reply, async () => {
322
318
  const { id } = req.params;
323
- const plugins = await getPlugins();
324
- const p = plugins.find((x) => x.id === id);
325
- if (!p || !p.settings || p.settings.fields.length === 0) {
319
+ const group = await findSettingsGroup(id);
320
+ if (!group)
326
321
  return reply.code(404).send({ error: 'no_settings' });
327
- }
328
- const row = await writePluginSettings(getEm().fork(), id, (req.body ?? {}), req.user.login, plugins);
322
+ const row = await writePluginSettings(getEm().fork(), id, (req.body ?? {}), req.user.login);
329
323
  return { ok: true, row };
330
324
  });
331
325
  });
@@ -0,0 +1,24 @@
1
+ export declare class ConnectionError extends Error {
2
+ }
3
+ export declare class NotFoundError extends Error {
4
+ }
5
+ export declare class ValidationError extends Error {
6
+ issues: unknown[];
7
+ constructor(issues: unknown[]);
8
+ }
9
+ export interface ListQuery {
10
+ folderId?: string;
11
+ limit?: number;
12
+ [field: string]: string | number | undefined;
13
+ }
14
+ export interface CofferClientApi {
15
+ getSchema(): Promise<unknown>;
16
+ listRecords(library: string, shelf: string, query?: ListQuery): Promise<unknown>;
17
+ getRecord(library: string, shelf: string, id: number): Promise<unknown>;
18
+ createRecord(library: string, shelf: string, fields: Record<string, unknown>): Promise<unknown>;
19
+ updateRecord(library: string, shelf: string, id: number, fields: Record<string, unknown>): Promise<unknown>;
20
+ deleteRecord(library: string, shelf: string, id: number): Promise<unknown>;
21
+ listTrash(): Promise<unknown>;
22
+ restoreRecord(library: string, shelf: string, id: number): Promise<unknown>;
23
+ purgeRecord(library: string, shelf: string, id: number): Promise<unknown>;
24
+ }
@@ -0,0 +1,12 @@
1
+ export class ConnectionError extends Error {
2
+ }
3
+ export class NotFoundError extends Error {
4
+ }
5
+ export class ValidationError extends Error {
6
+ issues;
7
+ constructor(issues) {
8
+ super('validation');
9
+ this.issues = issues;
10
+ this.name = 'ValidationError';
11
+ }
12
+ }
@@ -0,0 +1,46 @@
1
+ export interface ShelfIndexEntry {
2
+ library: string;
3
+ libraryLabel: string;
4
+ shelf: string;
5
+ label: string;
6
+ }
7
+ export interface FieldInfo {
8
+ key: string;
9
+ kind: string;
10
+ prim: string;
11
+ required: boolean;
12
+ relation?: {
13
+ library: string;
14
+ shelf: string;
15
+ };
16
+ options?: unknown[];
17
+ fields?: FieldInfo[];
18
+ multiple?: true;
19
+ derived?: true;
20
+ write?: string;
21
+ }
22
+ export declare const FILE_WRITE_HINT: string;
23
+ export declare const SOURCE_WRITE_HINT: string;
24
+ export declare const DERIVED_WRITE_HINT: string;
25
+ export declare const serverOwnedPartsHint: (roles: string[]) => string;
26
+ export interface ShelfDescription {
27
+ library: string;
28
+ shelf: string;
29
+ label: string;
30
+ fields: FieldInfo[];
31
+ }
32
+ interface SchemaClient {
33
+ getSchema(): Promise<unknown>;
34
+ }
35
+ export declare function flattenFields(items: unknown[]): FieldInfo[];
36
+ export declare class SchemaCache {
37
+ private client;
38
+ private cached?;
39
+ constructor(client: SchemaClient);
40
+ refresh(): void;
41
+ private load;
42
+ index(): Promise<ShelfIndexEntry[]>;
43
+ describeShelf(library: string, shelf: string): Promise<ShelfDescription>;
44
+ private find;
45
+ }
46
+ export {};
@@ -0,0 +1,90 @@
1
+ export const FILE_WRITE_HINT = 'Uploaded file only: {"name":"<filename returned by POST /api/upload>"} ' +
2
+ '(or an array of those when the field is multiple). ' +
3
+ 'A remote URL is rejected — the server stores files, not links. ' +
4
+ 'To add one: call create_upload_ticket, POST the bytes to the absolute upload_url it returns with that ' +
5
+ 'Bearer token, then write the returned name. mime and size are set by the server; ' +
6
+ 'sending them changes nothing. To remove a file, drop its entry from the array ' +
7
+ '(or clear the field) — an existing entry cannot be edited in place.';
8
+ export const SOURCE_WRITE_HINT = 'plain text — write the full content as a string. Content must be valid UTF-8 text; binary content is rejected. ' +
9
+ 'Capped per field (default 256 KB) — content over the cap fails with source_too_large. ' +
10
+ 'Read-only in the web UI (people can only replace it by uploading a file).';
11
+ export const DERIVED_WRITE_HINT = 'Read-only — the server recomputes this field from other fields of the record on every write ' +
12
+ 'and rejects any value sent for it. To change it, change what it is computed from.';
13
+ export const serverOwnedPartsHint = (roles) => `Partly read-only — the server owns ${roles.map((r) => `\`${r}\``).join(', ')} in this value and ` +
14
+ `computes ${roles.length > 1 ? 'them' : 'it'} on every write. Send the other roles only; anything ` +
15
+ `sent for these is discarded, and a read returns the server's value.`;
16
+ function serverOwnedRoles(t) {
17
+ const parts = t.parts ?? [];
18
+ return parts.filter((p) => p.mode === 'computedStored').map((p) => p.role);
19
+ }
20
+ function hasValue(el) {
21
+ const type = el?.type;
22
+ return type !== undefined && typeof type === 'object';
23
+ }
24
+ function hasChildren(el) {
25
+ return typeof el === 'object' && el !== null && Array.isArray(el.fields);
26
+ }
27
+ function isStorageGroup(el) {
28
+ return el.scope === 'nest';
29
+ }
30
+ export function flattenFields(items) {
31
+ const out = [];
32
+ const push = (key, t) => out.push({
33
+ key,
34
+ kind: t.kind,
35
+ prim: t.prim,
36
+ required: t.required,
37
+ ...(t.relation ? { relation: t.relation } : {}),
38
+ ...(t.options ? { options: t.options } : {}),
39
+ ...(t.prim === 'file' ? { write: FILE_WRITE_HINT } : {}),
40
+ ...(serverOwnedRoles(t).length ? { write: serverOwnedPartsHint(serverOwnedRoles(t)) } : {}),
41
+ ...(t.kind === 'source' ? { write: SOURCE_WRITE_HINT } : {}),
42
+ ...(t.derived ? { derived: true, write: DERIVED_WRITE_HINT } : {}),
43
+ });
44
+ for (const it of items) {
45
+ if (hasValue(it) && 'key' in it)
46
+ push(it.key, it.type);
47
+ else if (hasChildren(it) && isStorageGroup(it))
48
+ out.push({
49
+ key: it.key,
50
+ kind: 'group',
51
+ prim: 'group',
52
+ required: it.required === true,
53
+ ...(it.multiple ? { multiple: true } : {}),
54
+ fields: flattenFields(it.fields),
55
+ });
56
+ else if (hasChildren(it))
57
+ out.push(...flattenFields(it.fields));
58
+ }
59
+ return out;
60
+ }
61
+ export class SchemaCache {
62
+ client;
63
+ cached;
64
+ constructor(client) {
65
+ this.client = client;
66
+ }
67
+ refresh() {
68
+ this.cached = undefined;
69
+ }
70
+ async load(force = false) {
71
+ if (force || !this.cached)
72
+ this.cached = (await this.client.getSchema());
73
+ return this.cached;
74
+ }
75
+ async index() {
76
+ const libraries = await this.load();
77
+ return libraries.flatMap((v) => v.shelves.map((m) => ({ library: v.id, libraryLabel: v.label, shelf: m.shelf, label: m.label })));
78
+ }
79
+ async describeShelf(library, shelf) {
80
+ let m = this.find(await this.load(), library, shelf);
81
+ if (!m)
82
+ m = this.find(await this.load(true), library, shelf);
83
+ if (!m)
84
+ throw new Error(`unknown_shelf ${library}/${shelf}`);
85
+ return { library, shelf, label: m.label, fields: flattenFields(m.fields) };
86
+ }
87
+ find(libraries, library, shelf) {
88
+ return libraries.find((v) => v.id === library)?.shelves.find((m) => m.shelf === shelf);
89
+ }
90
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ import { type CofferClientApi } from './client.ts';
3
+ import { SchemaCache } from './schema.ts';
4
+ export interface ToolResult {
5
+ content: {
6
+ type: 'text';
7
+ text: string;
8
+ }[];
9
+ isError?: boolean;
10
+ }
11
+ export interface ToolDef {
12
+ name: string;
13
+ description: string;
14
+ inputSchema: z.ZodRawShape;
15
+ handler: (args: Record<string, unknown>) => Promise<ToolResult>;
16
+ }
17
+ export declare const LIST_DEFAULT_LIMIT = 25;
18
+ export declare const LIST_MAX_LIMIT = 200;
19
+ export declare function buildTools(client: CofferClientApi, cache: SchemaCache): ToolDef[];
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod';
2
+ import { ValidationError, NotFoundError, ConnectionError } from "./client.js";
3
+ const ok = (data) => ({
4
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
5
+ });
6
+ const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
7
+ const ISSUE_HINTS = {
8
+ file_remote_url: 'a file field stores an uploaded file, not a remote URL',
9
+ file_unknown_key: 'a file entry may only carry name, mime and size',
10
+ file_name: 'name must be a bare filename, without a path',
11
+ file_not_uploaded: 'no file by that name has been uploaded',
12
+ };
13
+ const FILE_RECIPE = '\n\nTo put a file in a file/image/media field:\n' +
14
+ '1. create_upload_ticket → {token, upload_url} — upload_url is the absolute address to post to\n' +
15
+ '2. curl -H "Authorization: Bearer <token>" -F "file=@<local path>" <upload_url> → {"name":"<filename>"}\n' +
16
+ '3. write the field as {"name":"<filename>"} (or an array of those).\n' +
17
+ 'Downloading a remote image is your side of the job: fetch it to a local file first, then upload that file. ' +
18
+ 'mime and size are set by the server. To remove a file, drop its entry — entries cannot be edited in place.';
19
+ async function guard(fn) {
20
+ try {
21
+ return ok(await fn());
22
+ }
23
+ catch (e) {
24
+ if (e instanceof ValidationError) {
25
+ const lines = e.issues.map((i) => {
26
+ const o = i;
27
+ const code = o.code ?? 'invalid';
28
+ const hint = ISSUE_HINTS[code];
29
+ return `- ${o.field ?? '?'}: ${code}${hint ? ` — ${hint}` : ''}`;
30
+ });
31
+ const fileIssue = e.issues.some((i) => (i.code ?? '').startsWith('file_'));
32
+ return fail(`Validation error:\n${lines.join('\n')}${fileIssue ? FILE_RECIPE : ''}`);
33
+ }
34
+ if (e instanceof NotFoundError)
35
+ return fail('Record or shelf not found.');
36
+ if (e instanceof ConnectionError)
37
+ return fail(e.message);
38
+ return fail(`Unexpected error: ${e.message}`);
39
+ }
40
+ }
41
+ export const LIST_DEFAULT_LIMIT = 25;
42
+ export const LIST_MAX_LIMIT = 200;
43
+ export function buildTools(client, cache) {
44
+ const library = z.string().describe('Library identifier, e.g. "things"');
45
+ const shelf = z.string().describe('Shelf identifier, e.g. "item"');
46
+ const id = z.coerce.number().int().describe('record id');
47
+ return [
48
+ {
49
+ name: 'list_libraries',
50
+ description: 'Lightweight index of all libraries and shelves (library, shelf, label). Starting point for exploring content.',
51
+ inputSchema: {},
52
+ handler: () => guard(() => cache.index()),
53
+ },
54
+ {
55
+ name: 'describe_shelf',
56
+ description: 'Detailed shelf schema: fields with kind/prim/required and relation/options. A collection is one entry with ' +
57
+ 'multiple:true and its row shape in fields[]. A field with derived:true is computed by the server and rejects ' +
58
+ 'writes — omit it when writing. Call BEFORE create_record/update_record.',
59
+ inputSchema: { library, shelf },
60
+ handler: (a) => guard(() => cache.describeShelf(a.library, a.shelf)),
61
+ },
62
+ {
63
+ name: 'refresh_schema',
64
+ description: 'Re-read the schema from the server. Call after enabling/installing a plugin (server restart required).',
65
+ inputSchema: {},
66
+ handler: async () => {
67
+ cache.refresh();
68
+ return ok({ refreshed: true });
69
+ },
70
+ },
71
+ {
72
+ name: 'list_records',
73
+ description: `List records of a shelf, one page at a time. q — free-text search over the shelf's text fields ` +
74
+ `(this is how you FIND a record by part of its name: q "minisforum" matches "Minisforum UM790"); ` +
75
+ `filters — exact field match; folderId — folder filter; ` +
76
+ `limit — page size (default ${LIST_DEFAULT_LIMIT}, max ${LIST_MAX_LIMIT}); offset — rows to skip. ` +
77
+ `Returns {rows, total, limit, offset}: "total" is how many records match IN FULL, so answer "how many do I have" ` +
78
+ `from it instead of listing everything, and never conclude a record is missing from one unfiltered page — ` +
79
+ `search with q first.`,
80
+ inputSchema: {
81
+ library,
82
+ shelf,
83
+ q: z.string().optional().describe('Free-text search over text fields; matches partial words'),
84
+ filters: z
85
+ .record(z.string(), z.union([z.string(), z.number()]))
86
+ .optional()
87
+ .describe('Exact field=value match'),
88
+ folderId: z.string().optional(),
89
+ limit: z.number().int().positive().optional(),
90
+ offset: z.number().int().nonnegative().optional(),
91
+ },
92
+ handler: async (a) => guard(async () => {
93
+ const q = { ...a.filters };
94
+ if (a.folderId !== undefined)
95
+ q.folderId = a.folderId;
96
+ if (typeof a.q === 'string' && a.q.trim())
97
+ q.q = a.q.trim();
98
+ const limit = Math.min(typeof a.limit === 'number' ? a.limit : LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT);
99
+ q.limit = limit;
100
+ const offset = typeof a.offset === 'number' ? a.offset : 0;
101
+ if (offset > 0)
102
+ q.offset = offset;
103
+ const res = await client.listRecords(a.library, a.shelf, q);
104
+ const paged = res;
105
+ const rows = Array.isArray(res) ? res : Array.isArray(paged.rows) ? paged.rows : [];
106
+ const total = typeof paged.total === 'number' ? paged.total : rows.length;
107
+ return { rows, total, limit, offset };
108
+ }),
109
+ },
110
+ {
111
+ name: 'get_record',
112
+ description: 'A single record by id (with _extends, if any).',
113
+ inputSchema: { library, shelf, id },
114
+ handler: (a) => guard(() => client.getRecord(a.library, a.shelf, a.id)),
115
+ },
116
+ {
117
+ name: 'create_record',
118
+ description: 'Create a record. fields — an object of field values (see describe_shelf). name is required.',
119
+ inputSchema: { library, shelf, fields: z.record(z.string(), z.unknown()).describe('Record field values') },
120
+ handler: (a) => guard(() => client.createRecord(a.library, a.shelf, a.fields)),
121
+ },
122
+ {
123
+ name: 'update_record',
124
+ description: 'Partial record update. fields — only the fields to change. Read the record first, call describe_shelf first, and preserve every unmentioned field; never overwrite conflicts silently.',
125
+ inputSchema: { library, shelf, id, fields: z.record(z.string(), z.unknown()) },
126
+ handler: (a) => guard(() => client.updateRecord(a.library, a.shelf, a.id, a.fields)),
127
+ },
128
+ {
129
+ name: 'delete_record',
130
+ description: 'Move a record to the trash by id (reversible soft-delete; data remains in the database). Read the complete record first and get explicit user confirmation for a non-obvious deletion. Never blank fields to simulate deletion.',
131
+ inputSchema: { library, shelf, id },
132
+ handler: (a) => guard(() => client.deleteRecord(a.library, a.shelf, a.id)),
133
+ },
134
+ {
135
+ name: 'list_trash',
136
+ description: 'List every record currently in the trash, including its full data. Use this before restoring or permanently purging a record; normal list_records/get_record calls hide trashed records.',
137
+ inputSchema: {},
138
+ handler: () => guard(() => client.listTrash()),
139
+ },
140
+ {
141
+ name: 'restore_record',
142
+ description: 'Restore a record from the trash. Inspect it with list_trash first and confirm the target if the user did not name the exact record.',
143
+ inputSchema: { library, shelf, id },
144
+ handler: (a) => guard(() => client.restoreRecord(a.library, a.shelf, a.id)),
145
+ },
146
+ {
147
+ name: 'purge_record',
148
+ description: 'Permanently delete a record that is already in the trash. This is irreversible: inspect list_trash and require explicit user confirmation immediately before calling it.',
149
+ inputSchema: {
150
+ library,
151
+ shelf,
152
+ id,
153
+ confirmed: z.literal(true).describe('Must be true only after the user explicitly confirmed permanent deletion'),
154
+ },
155
+ handler: async (a) => a.confirmed === true
156
+ ? guard(() => client.purgeRecord(a.library, a.shelf, a.id))
157
+ : fail('Permanent deletion requires explicit user confirmation.'),
158
+ },
159
+ ];
160
+ }
package/dist/mcp-http.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
- import { collectMcpTools, resolveRagDeps, buildDomainSections, buildMcpInstructions } from "./mcp-tools.js";
3
+ import { collectMcpTools, resolveRagDeps, buildDomainSections, buildMcpInstructions, siteSection, } from "./mcp-tools.js";
4
+ import { configuredPublicUrl } from "./public-url.js";
4
5
  import { getLogger } from "./log.js";
5
6
  const log = getLogger('mcp-http');
6
7
  export async function buildMcpServer(role, actor) {
7
8
  const rag = await resolveRagDeps();
8
9
  const tools = (await collectMcpTools({ rag, includeAdmin: role === 'admin', actor })).filter((t) => role === 'admin' || t.role === 'member');
9
- const sections = await buildDomainSections();
10
+ const site = await siteSection(await configuredPublicUrl());
11
+ const sections = [...(await buildDomainSections()), ...(site ? [site] : [])];
10
12
  const server = new McpServer({ name: 'coffer', version: '1.0.0' }, { instructions: buildMcpInstructions(sections) });
11
13
  const registerTool = server.registerTool.bind(server);
12
14
  for (const t of tools) {
@@ -1,4 +1,4 @@
1
- import type { CofferClientApi } from '@coffer-org/mcp/client';
1
+ import type { CofferClientApi } from './mcp-contract/client.ts';
2
2
  export declare function mapError(e: unknown): never;
3
3
  export declare function splitListQuery(raw: Record<string, unknown>): {
4
4
  query: Record<string, unknown>;
package/dist/mcp-local.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ValidationError, NotFoundError } from '@coffer-org/mcp/client';
1
+ import { ValidationError, NotFoundError } from "./mcp-contract/client.js";
2
2
  import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, recordTrashList, recordRestore, recordPurge, SingleShelfError, FilterError, } from "./records-api.js";
3
3
  import { ValidationError as ServerValidationError } from "./mutate.js";
4
4
  import { buildClientSchema } from "./schema-api.js";
@@ -1,6 +1,6 @@
1
1
  import type { EntityManager } from '@mikro-orm/core';
2
2
  import { z } from 'zod';
3
- import { type ToolResult } from '@coffer-org/mcp';
3
+ import { type ToolResult } from './mcp-contract/tools.ts';
4
4
  import { type AuthRole, type PluginHooks } from './plugin-hooks.ts';
5
5
  import { type Condition } from '@coffer-org/sdk/condition';
6
6
  import { type RagHit } from './rag-search.ts';
@@ -68,6 +68,18 @@ export declare function collectLibraryPurposes(reg?: {
68
68
  }[];
69
69
  }[];
70
70
  }): LibraryPurpose[];
71
+ export declare function siteSection(siteUrl: string): Promise<string | null>;
71
72
  export declare function buildDomainSections(): Promise<string[]>;
73
+ export declare function collectStarterHints(hooks?: Record<string, PluginHooks>, emFactory?: () => EntityManager, reg?: {
74
+ libraries: {
75
+ meta: {
76
+ id: string;
77
+ starterHint?: string;
78
+ };
79
+ }[];
80
+ }): Promise<{
81
+ combined: string;
82
+ hash: string;
83
+ }>;
72
84
  export declare function buildMcpInstructions(sections: string[]): string;
73
85
  export {};