@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.
- package/dist/auth-api.js +4 -5
- package/dist/cache-clock.d.ts +8 -0
- package/dist/cache-clock.js +103 -0
- package/dist/cache-refresh.d.ts +7 -0
- package/dist/cache-refresh.js +322 -0
- package/dist/collection-io.d.ts +13 -0
- package/dist/collection-io.js +40 -29
- package/dist/compute-unit.d.ts +66 -0
- package/dist/compute-unit.js +369 -0
- package/dist/condition-where.js +1 -3
- package/dist/data-dir.d.ts +3 -0
- package/dist/data-dir.js +39 -0
- package/dist/entity-schema.js +5 -2
- package/dist/extend-table.d.ts +1 -1
- package/dist/extend-table.js +22 -8
- package/dist/file-fields.js +50 -23
- package/dist/frontend-agent.d.ts +1 -1
- package/dist/frontend-agent.js +2 -3
- package/dist/global-search.js +1 -1
- package/dist/index.js +20 -26
- package/dist/mcp-contract/client.d.ts +24 -0
- package/dist/mcp-contract/client.js +12 -0
- package/dist/mcp-contract/schema.d.ts +46 -0
- package/dist/mcp-contract/schema.js +90 -0
- package/dist/mcp-contract/tools.d.ts +19 -0
- package/dist/mcp-contract/tools.js +160 -0
- package/dist/mcp-http.js +4 -2
- package/dist/mcp-local.d.ts +1 -1
- package/dist/mcp-local.js +1 -1
- package/dist/mcp-tools.d.ts +13 -1
- package/dist/mcp-tools.js +46 -16
- package/dist/migrations.d.ts +5 -0
- package/dist/migrations.js +122 -0
- package/dist/mutate.js +77 -36
- package/dist/orchestrator/agent-capabilities.d.ts +2 -0
- package/dist/orchestrator/agent-capabilities.js +201 -0
- package/dist/orchestrator/allow.d.ts +16 -0
- package/dist/orchestrator/allow.js +65 -0
- package/dist/orchestrator/attachments.d.ts +2 -0
- package/dist/orchestrator/attachments.js +11 -0
- package/dist/orchestrator/config.d.ts +4 -0
- package/dist/orchestrator/config.js +19 -0
- package/dist/orchestrator/db.d.ts +17 -0
- package/dist/orchestrator/db.js +12 -0
- package/dist/orchestrator/diagnostics.d.ts +9 -0
- package/dist/orchestrator/diagnostics.js +13 -0
- package/dist/orchestrator/environment.d.ts +2 -0
- package/dist/orchestrator/environment.js +14 -0
- package/dist/orchestrator/file-inspection.d.ts +3 -0
- package/dist/orchestrator/file-inspection.js +61 -0
- package/dist/orchestrator/format.d.ts +1 -0
- package/dist/orchestrator/format.js +6 -0
- package/dist/orchestrator/index.d.ts +30 -0
- package/dist/orchestrator/index.js +56 -0
- package/dist/orchestrator/live-message.d.ts +14 -0
- package/dist/orchestrator/live-message.js +100 -0
- package/dist/orchestrator/pipeline.d.ts +11 -0
- package/dist/orchestrator/pipeline.js +198 -0
- package/dist/orchestrator/registry.d.ts +13 -0
- package/dist/orchestrator/registry.js +74 -0
- package/dist/orchestrator/starters.d.ts +19 -0
- package/dist/orchestrator/starters.js +81 -0
- package/dist/orchestrator/suggestion-capabilities.d.ts +5 -0
- package/dist/orchestrator/suggestion-capabilities.js +33 -0
- package/dist/orchestrator/system-assembly.d.ts +4 -0
- package/dist/orchestrator/system-assembly.js +3 -0
- package/dist/orchestrator/system-capabilities.d.ts +2 -0
- package/dist/orchestrator/system-capabilities.js +63 -0
- package/dist/orchestrator/types.d.ts +141 -0
- package/dist/orchestrator/types.js +1 -0
- package/dist/part-injection.d.ts +3 -4
- package/dist/part-injection.js +25 -368
- package/dist/plugin-hooks.d.ts +1 -0
- package/dist/plugin-i18n.js +2 -1
- package/dist/plugin-runtime.d.ts +2 -2
- package/dist/plugin-runtime.js +20 -11
- package/dist/plugin-updates.d.ts +2 -2
- package/dist/plugin-updates.js +6 -12
- package/dist/public-url.js +2 -1
- package/dist/recompute-derived.d.ts +2 -1
- package/dist/recompute-derived.js +111 -34
- package/dist/records-api.js +14 -16
- package/dist/registry-context.d.ts +1 -1
- package/dist/registry-context.js +3 -0
- package/dist/search-index.js +1 -1
- package/dist/search-indexer.d.ts +1 -1
- package/dist/search-indexer.js +3 -3
- package/dist/settings-groups.d.ts +14 -0
- package/dist/settings-groups.js +25 -0
- package/dist/settings-write.d.ts +4 -4
- package/dist/settings-write.js +23 -26
- package/dist/system-settings.d.ts +3 -0
- package/dist/system-settings.js +27 -0
- package/dist/thread-store.d.ts +3 -2
- package/dist/thread-store.js +6 -5
- package/dist/trigger-queue.d.ts +33 -0
- package/dist/trigger-queue.js +432 -0
- package/dist/uploads.js +6 -3
- package/dist/value-compare.d.ts +1 -0
- package/dist/value-compare.js +7 -0
- package/package.json +17 -3
package/dist/file-fields.js
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
let
|
|
105
|
-
for (const
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
row[p.role] = r.value;
|
|
145
|
+
touched = true;
|
|
118
146
|
}
|
|
119
|
-
if (
|
|
120
|
-
|
|
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];
|
package/dist/frontend-agent.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function frontendInstructions(): Promise<string | null>;
|
|
1
|
+
export declare function frontendInstructions(siteUrl: string): Promise<string | null>;
|
package/dist/frontend-agent.js
CHANGED
|
@@ -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
|
|
29
|
+
const text = await mod.agent({ siteUrl });
|
|
31
30
|
return typeof text === 'string' && text.trim() ? text : null;
|
|
32
31
|
}
|
|
33
32
|
catch (e) {
|
package/dist/global-search.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { textSearchKeys, titleKey, recordTitle, storageColumnsFor } from '@coffer-org/sdk/shelf';
|
|
2
|
-
import { tokenize } from '@coffer-org/
|
|
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 {
|
|
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(
|
|
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
|
|
292
|
-
const
|
|
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.
|
|
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
|
|
311
|
-
|
|
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/
|
|
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
|
|
324
|
-
|
|
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
|
|
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) {
|
package/dist/mcp-local.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CofferClientApi } from '
|
|
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
|
|
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";
|
package/dist/mcp-tools.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/core';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { type ToolResult } from '
|
|
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 {};
|