json-object-editor 0.10.650 → 0.10.653

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.
@@ -1,914 +1,1237 @@
1
- // modules/MCP.js
2
-
3
- /**
4
- * Model Context Protocol (MCP) core module for JOE.
5
- * This module provides a JSON-RPC 2.0 compatible interface to JOE objects and schemas.
6
- * Agents (like OpenAI Assistants) can discover and call structured tools via manifest + POST.
7
- */
8
-
9
- const MCP = {};
10
- const ThoughtPipeline = require('./ThoughtPipeline');
11
-
12
- // Internal helpers
13
- function getStorage() {
14
- return (global.JOE && global.JOE.Storage) || null;
15
- }
16
-
17
- function getSchemas() {
18
- return (global.JOE && global.JOE.Schemas) || null;
19
- }
20
-
21
- function loadFromStorage(collection, query) {
22
- return new Promise((resolve, reject) => {
23
- try {
24
- const Storage = getStorage();
25
- if (!Storage) return reject(new Error('Storage module not initialized'));
26
- Storage.load(collection, query || {}, function(err, results){
27
- if (err) return reject(err);
28
- resolve(results || []);
29
- });
30
- } catch (e) {
31
- reject(e);
32
- }
33
- });
34
- }
35
-
36
- function sanitizeItems(items) {
37
- try {
38
- const arr = Array.isArray(items) ? items : [items];
39
- return arr.map(i => {
40
- if (!i || typeof i !== 'object') return i;
41
- const copy = JSON.parse(JSON.stringify(i));
42
- if (copy.password) copy.password = null;
43
- if (copy.token) copy.token = null;
44
- return copy;
45
- });
46
- } catch (e) {
47
- return Array.isArray(items) ? items : [items];
48
- }
49
- }
50
-
51
- // Resolve simple dotted paths against an object, including arrays.
52
- // Example: getPathValues(recipe, "ingredients.id") → ["ing1","ing2",...]
53
- function getPathValues(root, path) {
54
- if (!root || !path) return [];
55
- const parts = String(path).split('.');
56
- let current = [root];
57
- for (let i = 0; i < parts.length; i++) {
58
- const key = parts[i];
59
- const next = [];
60
- for (let j = 0; j < current.length; j++) {
61
- const val = current[j];
62
- if (val == null) continue;
63
- if (Array.isArray(val)) {
64
- val.forEach(function (item) {
65
- if (item && Object.prototype.hasOwnProperty.call(item, key)) {
66
- next.push(item[key]);
67
- }
68
- });
69
- } else if (Object.prototype.hasOwnProperty.call(val, key)) {
70
- next.push(val[key]);
71
- }
72
- }
73
- current = next;
74
- if (!current.length) break;
75
- }
76
- // Flatten one level in case the last hop produced arrays
77
- const out = [];
78
- current.forEach(function (v) {
79
- if (Array.isArray(v)) {
80
- v.forEach(function (x) { if (x != null) out.push(x); });
81
- } else if (v != null) {
82
- out.push(v);
83
- }
84
- });
85
- return out;
86
- }
87
-
88
- // Best-effort helper to get a normalized schema summary for a given name.
89
- // Prefers the precomputed `Schemas.summary[name]` map, but falls back to
90
- // `Schemas.schema[name].summary` when the summary map has not been generated
91
- // or that particular schema has not yet been merged in.
92
- function getSchemaSummary(name) {
93
- if (!name) return null;
94
- const Schemas = getSchemas();
95
- if (!Schemas) return null;
96
- if (Schemas.summary && Schemas.summary[name]) {
97
- return Schemas.summary[name];
98
- }
99
- if (Schemas.schema && Schemas.schema[name] && Schemas.schema[name].summary) {
100
- return Schemas.schema[name].summary;
101
- }
102
- return null;
103
- }
104
-
105
- function getComparable(val){
106
- if (val == null) return null;
107
- // Date-like
108
- var d = new Date(val);
109
- if (!isNaN(d.getTime())) return d.getTime();
110
- if (typeof val === 'number') return val;
111
- return (val+'').toLowerCase();
112
- }
113
-
114
- function sortItems(items, sortBy, sortDir){
115
- if (!Array.isArray(items) || !sortBy) return items;
116
- var dir = (sortDir === 'asc') ? 1 : -1;
117
- return items.slice().sort(function(a,b){
118
- var av = getComparable(a && a[sortBy]);
119
- var bv = getComparable(b && b[sortBy]);
120
- if (av == null && bv == null) return 0;
121
- if (av == null) return 1; // nulls last
122
- if (bv == null) return -1;
123
- if (av > bv) return 1*dir;
124
- if (av < bv) return -1*dir;
125
- return 0;
126
- });
127
- }
128
-
129
- function toSlim(item){
130
- var name = (item && (item.name || item.title || item.label || item.email || item.slug)) || (item && item._id) || '';
131
- var info = (item && (item.info || item.description || item.summary)) || '';
132
- return {
133
- _id: item && item._id,
134
- itemtype: item && item.itemtype,
135
- name: name,
136
- info: info,
137
- joeUpdated: item && (item.joeUpdated || item.updated || item.modified),
138
- created: item && item.created
139
- };
140
- }
141
-
142
- // ----------------------
143
- // TOOL DEFINITIONS
144
- // ----------------------
145
- // This object maps tool names to actual execution functions.
146
- // Each function takes a `params` object and returns a JSON-serializable result.
147
- MCP.tools = {
148
-
149
- // List all schema names in the system
150
- listSchemas: async (_params, _ctx) => {
151
- var Schemas = getSchemas();
152
- const list = (Schemas && (
153
- (Schemas.schemaList && Schemas.schemaList.length && Schemas.schemaList) ||
154
- (Schemas.schema && Object.keys(Schemas.schema))
155
- )) || [];
156
- return list.slice().sort();
157
- },
158
-
159
- // Get schema definition; summaryOnly to return normalized summary instead of full schema
160
- getSchema: async ({ name, summaryOnly }, _ctx) => {
161
- if (!name) throw new Error("Missing required param 'name'");
162
- const Schemas = getSchemas();
163
- if (!Schemas) throw new Error('Schemas module not initialized');
164
- if (summaryOnly) {
165
- const sum = Schemas && Schemas.summary && Schemas.summary[name];
166
- if (!sum) throw new Error(`Schema summary for "${name}" not found`);
167
- return sum;
168
- }
169
- const def = Schemas.schema && Schemas.schema[name];
170
- if (!def) throw new Error(`Schema "${name}" not found`);
171
- return def;
172
- },
173
-
174
- // Get multiple schemas. With summaryOnly, return summaries; without names, returns all.
175
- getSchemas: async ({ names, summaryOnly } = {}, _ctx) => {
176
- const Schemas = getSchemas();
177
- if (!Schemas) throw new Error('Schemas module not initialized');
178
- const all = (Schemas && Schemas.schema) || {};
179
- if (summaryOnly) {
180
- const allS = (Schemas && Schemas.summary) || {};
181
- if (Array.isArray(names) && names.length) {
182
- const outS = {};
183
- names.forEach(function(n){ if (allS[n]) { outS[n] = allS[n]; } });
184
- return outS;
185
- }
186
- return allS;
187
- } else {
188
- if (Array.isArray(names) && names.length) {
189
- const out = {};
190
- names.forEach(function(n){ if (all[n]) { out[n] = all[n]; } });
191
- return out;
192
- }
193
- return all;
194
- }
195
- },
196
-
197
- // Convenience: fetch a single object by _id (itemtype optional). Prefer cache; fallback to storage.
198
- // Accepts legacy alias 'schema' for itemtype.
199
- getObject: async ({ _id, itemtype, schema, flatten = false, depth = 1 }, _ctx) => {
200
- if (!_id) throw new Error("Missing required param '_id'");
201
- itemtype = itemtype || schema; // legacy alias
202
- // Fast path via global lookup
203
- let obj = (JOE && JOE.Cache && JOE.Cache.findByID) ? (JOE.Cache.findByID(_id) || null) : null;
204
- if (!obj && itemtype) {
205
- const results = await loadFromStorage(itemtype, { _id });
206
- obj = (results && results[0]) || null;
207
- }
208
- if (!obj && itemtype && JOE && JOE.Cache && JOE.Cache.findByID) {
209
- obj = JOE.Cache.findByID(itemtype, _id) || null;
210
- }
211
- if (!obj) throw new Error(`Object not found${itemtype?(' in '+itemtype):''} with _id: ${_id}`);
212
- if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
213
- try { return sanitizeItems(JOE.Utils.flattenObject(_id, { recursive: true, depth }))[0]; } catch(e) {}
214
- }
215
- return sanitizeItems(obj)[0];
216
- },
217
-
218
- /**
219
- * understandObject
220
- *
221
- * High-level helper for agents: given an _id (and optional itemtype),
222
- * returns a rich payload combining:
223
- * - object: the raw object (ids intact)
224
- * - flattened: the same object flattened to a limited depth
225
- * - schemas: a map of schema summaries for the main itemtype and any
226
- * referenced itemtypes (keyed by schema name)
227
- * - related: an array of referenced objects discovered via outbound
228
- * relationships in the schema summary.
229
- *
230
- * When `slim` is false (default), each related entry includes both `object`
231
- * and `flattened`. When `slim` is true, only the main object is flattened
232
- * and related entries are reduced to slim references:
233
- * { field, _id, itemtype, object: { _id, itemtype, name, info } }
234
- *
235
- * Agents should prefer this tool when they need to understand or work with
236
- * an object by id, instead of issuing many individual getObject / getSchema
237
- * calls. The original object always keeps its reference ids; expanded views
238
- * live under `flattened` and `related[*]`.
239
- */
240
- understandObject: async ({ _id, itemtype, schema, depth = 2, slim = false } = {}, _ctx) => {
241
- if (!_id) throw new Error("Missing required param '_id'");
242
- itemtype = itemtype || schema;
243
-
244
- // Base object (sanitized) without flattening
245
- const base = await MCP.tools.getObject({ _id, itemtype, flatten: false }, _ctx);
246
- const mainType = base.itemtype || itemtype || null;
247
-
248
- const result = {
249
- _id: base._id,
250
- itemtype: mainType,
251
- object: base,
252
- flattened: null,
253
- schemas: {},
254
- related: [],
255
- // Deduped lookups for global reference types
256
- tags: {},
257
- statuses: {},
258
- slim: !!slim
259
- };
260
-
261
- // Main schema summary
262
- const mainSummary = getSchemaSummary(mainType);
263
- if (mainType && mainSummary) {
264
- result.schemas[mainType] = mainSummary;
265
- }
266
-
267
- // Flattened view of the main object (depth-limited)
268
- if (JOE && JOE.Utils && JOE.Utils.flattenObject) {
269
- try {
270
- const flat = JOE.Utils.flattenObject(base._id, { recursive: true, depth });
271
- result.flattened = sanitizeItems(flat)[0];
272
- } catch (_e) {
273
- result.flattened = null;
274
- }
275
- }
276
-
277
- const seenSchemas = new Set(Object.keys(result.schemas || {}));
278
- function addSchemaIfPresent(name) {
279
- if (!name || seenSchemas.has(name)) return;
280
- const sum = getSchemaSummary(name);
281
- if (sum) {
282
- result.schemas[name] = sum;
283
- seenSchemas.add(name);
284
- }
285
- }
286
-
287
- // Discover outbound relationships from schema summary
288
- const schemaSummary = mainType && getSchemaSummary(mainType);
289
- const outbound = (schemaSummary &&
290
- schemaSummary.relationships &&
291
- Array.isArray(schemaSummary.relationships.outbound))
292
- ? schemaSummary.relationships.outbound
293
- : [];
294
-
295
- for (let i = 0; i < outbound.length; i++) {
296
- const rel = outbound[i] || {};
297
- const field = rel.field;
298
- const targetSchema = rel.targetSchema;
299
- if (!field || !targetSchema) continue;
300
-
301
- // Support nested paths like "ingredients.id" coming from objectList fields.
302
- const vals = getPathValues(base, field);
303
- if (!vals || !vals.length) continue;
304
-
305
- const ids = Array.isArray(vals) ? vals : [vals];
306
- for (let j = 0; j < ids.length; j++) {
307
- const rid = ids[j];
308
- if (!rid) continue;
309
- let robj = null;
310
- try {
311
- robj = await MCP.tools.getObject({ _id: rid, itemtype: targetSchema, flatten: false }, _ctx);
312
- } catch (_e) {
313
- continue;
314
- }
315
- if (!robj) continue;
316
-
317
- const rType = robj.itemtype || targetSchema;
318
- // Global reference types (tag/status) go into top-level lookup maps
319
- if (rType === 'tag' || rType === 'status') {
320
- const mapName = (rType === 'tag') ? 'tags' : 'statuses';
321
- if (!result[mapName][robj._id]) {
322
- result[mapName][robj._id] = {
323
- _id: robj._id,
324
- itemtype: rType,
325
- name: robj.name || robj.label || robj.info || ''
326
- };
327
- }
328
- } else {
329
- if (slim) {
330
- const slimObj = toSlim(robj);
331
- result.related.push({
332
- field,
333
- _id: slimObj._id,
334
- itemtype: slimObj.itemtype || rType,
335
- object: {
336
- _id: slimObj._id,
337
- itemtype: slimObj.itemtype || rType,
338
- name: slimObj.name,
339
- info: slimObj.info
340
- }
341
- });
342
- } else {
343
- let rflat = null;
344
- if (JOE && JOE.Utils && JOE.Utils.flattenObject) {
345
- try {
346
- const f = JOE.Utils.flattenObject(robj._id, { recursive: true, depth: Math.max(1, depth - 1) });
347
- rflat = sanitizeItems(f)[0];
348
- } catch (_e) {
349
- rflat = null;
350
- }
351
- }
352
- result.related.push({
353
- field,
354
- _id: robj._id,
355
- itemtype: rType,
356
- object: robj,
357
- flattened: rflat
358
- });
359
- }
360
- }
361
- addSchemaIfPresent(rType || targetSchema);
362
- }
363
- }
364
-
365
- return result;
366
- },
367
-
368
- /* Deprecated: use unified 'search' instead
369
- getObjectsByIds: async () => { throw new Error('Use search with ids instead'); },
370
- queryObjects: async () => { throw new Error('Use search instead'); },
371
- */
372
-
373
- /* Deprecated: use unified 'search' instead
374
- searchCache: async () => { throw new Error('Use search instead'); },
375
- */
376
-
377
- // Unified search: defaults to cache; set source="storage" to query DB for a given itemtype
378
- // Accepts legacy alias 'schema' for itemtype.
379
- search: async ({ itemtype, schema, query = {}, ids, source = 'cache', limit = 50, offset = 0, flatten = false, depth = 1, countOnly = false, withCount = false, sortBy, sortDir = 'desc', slim = false }, _ctx) => {
380
- itemtype = itemtype || schema; // legacy alias
381
- const useCache = !source || source === 'cache';
382
- const useStorage = source === 'storage';
383
-
384
- if (ids && !Array.isArray(ids)) throw new Error("'ids' must be an array if provided");
385
-
386
- // When ids are provided and an itemtype is known, prefer cache for safety/speed
387
- if (Array.isArray(ids) && itemtype) {
388
- let items = [];
389
- if (JOE && JOE.Cache && JOE.Cache.findByID) {
390
- const found = JOE.Cache.findByID(itemtype, ids.join(',')) || [];
391
- items = Array.isArray(found) ? found : (found ? [found] : []);
392
- }
393
- if (useStorage && (!items || items.length === 0)) {
394
- try {
395
- const fromStorage = await loadFromStorage(itemtype, { _id: { $in: ids } });
396
- items = fromStorage || [];
397
- } catch (e) { /* ignore storage errors here */ }
398
- }
399
- if (flatten && !slim && JOE && JOE.Utils && JOE.Utils.flattenObject) {
400
- try { items = ids.map(id => JOE.Utils.flattenObject(id, { recursive: true, depth })); } catch (e) {}
401
- }
402
- items = sortItems(items, sortBy, sortDir);
403
- if (countOnly) {
404
- return { count: (items || []).length };
405
- }
406
- const total = (items || []).length;
407
- const start = Math.max(0, parseInt(offset || 0, 10) || 0);
408
- const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
409
- const sliced = (typeof end === 'number') ? items.slice(start, end) : items.slice(start);
410
- const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
411
- return withCount ? { items: payload, count: total } : { items: payload };
412
- }
413
-
414
- // No ids: choose source
415
- if (useCache) {
416
- if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
417
- let results = JOE.Cache.search(query || {});
418
- if (itemtype) results = (results || []).filter(i => i && i.itemtype === itemtype);
419
- results = sortItems(results, sortBy, sortDir);
420
- if (countOnly) {
421
- return { count: (results || []).length };
422
- }
423
- const total = (results || []).length;
424
- const start = Math.max(0, parseInt(offset || 0, 10) || 0);
425
- const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
426
- const sliced = (typeof end === 'number') ? results.slice(start, end) : results.slice(start);
427
- const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
428
- return withCount ? { items: payload, count: total } : { items: payload };
429
- }
430
-
431
- if (useStorage) {
432
- if (!itemtype) throw new Error("'itemtype' is required when source=storage");
433
- const results = await loadFromStorage(itemtype, query || {});
434
- let sorted = sortItems(results, sortBy, sortDir);
435
- if (countOnly) {
436
- return { count: (sorted || []).length };
437
- }
438
- const total = (sorted || []).length;
439
- const start = Math.max(0, parseInt(offset || 0, 10) || 0);
440
- const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
441
- const sliced = (typeof end === 'number') ? (sorted || []).slice(start, end) : (sorted || []).slice(start);
442
- if (flatten && !slim && JOE && JOE.Utils && JOE.Utils.flattenObject) {
443
- try { return withCount ? { items: sanitizeItems(sliced.map(function(it){ return JOE.Utils.flattenObject(it && it._id, { recursive: true, depth }); })), count: total } : { items: sanitizeItems(sliced.map(function(it){ return JOE.Utils.flattenObject(it && it._id, { recursive: true, depth }); })) }; } catch (e) {}
444
- }
445
- const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
446
- return withCount ? { items: payload, count: total } : { items: payload };
447
- }
448
-
449
- throw new Error("Invalid 'source'. Use 'cache' (default) or 'storage'.");
450
- },
451
-
452
- // Fuzzy, typo-tolerant search over cache with weighted fields
453
- // Accepts legacy alias 'schema' for itemtype.
454
- fuzzySearch: async ({ itemtype, schema, q, filters = {}, fields, threshold = 0.35, limit = 50, offset = 0, highlight = false, minQueryLength = 2 }, _ctx) => {
455
- itemtype = itemtype || schema; // legacy alias
456
- if (!q || (q+'').length < (minQueryLength||2)) {
457
- return { items: [] };
458
- }
459
- if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
460
- var query = Object.assign({}, filters || {});
461
- query.$fuzzy = { q, fields, threshold, limit, offset, highlight, minQueryLength };
462
- var results = JOE.Cache.search(query) || [];
463
- if (itemtype) { results = results.filter(function(i){ return i && i.itemtype === itemtype; }); }
464
- var total = (typeof results.count === 'number') ? results.count : results.length;
465
- if (typeof limit === 'number' && limit > 0) { results = results.slice(0, limit); }
466
- return { items: sanitizeItems(results), count: total };
467
- },
468
-
469
- // Save an object via Storage (respects events/history)
470
- saveObject: async ({ object }, ctx = {}) => {
471
- const Storage = getStorage();
472
- if (!Storage) throw new Error('Storage module not initialized');
473
- if (!object || !object.itemtype) throw new Error("'object' with 'itemtype' is required");
474
- const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
475
- // Ensure server-side update timestamp parity with /API/save
476
- if (!object.joeUpdated) { object.joeUpdated = new Date().toISOString(); }
477
- // Ensure a stable _id for new objects so history and events work consistently
478
- try {
479
- if (!object._id) { object._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); }
480
- } catch(_e) { /* ignore id generation errors; Mongo will assign one */ }
481
- const saved = await new Promise((resolve, reject) => {
482
- try {
483
- Storage.save(object, object.itemtype, function(err, data){
484
- if (err) return reject(err);
485
- resolve(data);
486
- }, { user, history: true });
487
- } catch (e) { reject(e); }
488
- });
489
- return sanitizeItems(saved)[0];
490
- },
491
-
492
- // Batch save with bounded concurrency; preserves per-item history/events
493
- saveObjects: async ({ objects, stopOnError = false, concurrency = 5 } = {}, ctx = {}) => {
494
- const Storage = getStorage();
495
- if (!Storage) throw new Error('Storage module not initialized');
496
- if (!Array.isArray(objects) || objects.length === 0) {
497
- throw new Error("'objects' (non-empty array) is required");
498
- }
499
- const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
500
- const size = Math.max(1, parseInt(concurrency, 10) || 5);
501
- const results = new Array(objects.length);
502
- const errors = [];
503
- let cancelled = false;
504
-
505
- async function saveOne(obj, index){
506
- if (cancelled) return;
507
- if (!obj || !obj.itemtype) {
508
- const errMsg = "Each object must include 'itemtype'";
509
- errors.push({ index, error: errMsg });
510
- if (stopOnError) { cancelled = true; }
511
- return;
512
- }
513
- if (!obj.joeUpdated) { obj.joeUpdated = new Date().toISOString(); }
514
- try {
515
- if (!obj._id) {
516
- try { obj._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); } catch(_e) {}
517
- }
518
- const saved = await new Promise((resolve, reject) => {
519
- try {
520
- Storage.save(obj, obj.itemtype, function(err, data){
521
- if (err) return reject(err);
522
- resolve(data);
523
- }, { user, history: true });
524
- } catch (e) { reject(e); }
525
- });
526
- results[index] = sanitizeItems(saved)[0];
527
- } catch (e) {
528
- errors.push({ index, error: e && e.message ? e.message : (e+'' ) });
529
- if (stopOnError) { cancelled = true; }
530
- }
531
- }
532
-
533
- // Simple promise pool
534
- let cursor = 0;
535
- const runners = new Array(Math.min(size, objects.length)).fill(0).map(async function(){
536
- while (!cancelled && cursor < objects.length) {
537
- const idx = cursor++;
538
- await saveOne(objects[idx], idx);
539
- if (stopOnError && cancelled) break;
540
- }
541
- });
542
- await Promise.all(runners);
543
-
544
- const saved = results.filter(function(x){ return !!x; }).length;
545
- const failed = errors.length;
546
- return { results: sanitizeItems(results.filter(function(x){ return !!x; })), errors, saved, failed };
547
- },
548
-
549
- // Hydration: surface core fields (from file), all schemas, statuses, and tags (no params)
550
- hydrate: async (_params, _ctx) => {
551
- var Schemas = getSchemas();
552
- let coreDef = (JOE && JOE.Fields && JOE.Fields['core']) || null;
553
- if (!coreDef) {
554
- try { coreDef = require(__dirname + '/../fields/core.js'); } catch (_e) { coreDef = {}; }
555
- }
556
- const coreFields = Object.keys(coreDef || {}).map(name => ({
557
- name,
558
- definition: coreDef[name]
559
- }));
560
-
561
- const payload = {
562
- coreFields,
563
- schemas: Object.keys(Schemas?.schema || {}),
564
- schemaSummary: (Schemas && Schemas.summary) || {},
565
- schemaSummaryGeneratedAt: (Schemas && Schemas.summaryGeneratedAt) || null
566
- };
567
- payload.statuses = sanitizeItems(JOE.Data?.status || []);
568
- payload.tags = sanitizeItems(JOE.Data?.tag || [])
569
-
570
- return payload;
571
- },
572
-
573
- // List app definitions in a sanitized/summarized form
574
- listApps: async (_params, _ctx) => {
575
- const apps = (JOE && JOE.Apps && JOE.Apps.cache) || {};
576
- const names = Object.keys(apps || {});
577
- const summarized = {};
578
- names.forEach(function(name){
579
- try {
580
- const app = apps[name] || {};
581
- summarized[name] = {
582
- title: app.title || name,
583
- description: app.description || '',
584
- collections: Array.isArray(app.collections) ? app.collections : [],
585
- plugins: Array.isArray(app.plugins) ? app.plugins : []
586
- };
587
- } catch(_e) {
588
- summarized[name] = { title: name, description: '' };
589
- }
590
- });
591
- return { names, apps: summarized };
592
- },
593
-
594
- // Compile a named pipeline into a deterministic prompt payload.
595
- compilePipeline: async ({ pipeline_id, scope_id, user_input } = {}, _ctx) => {
596
- const compiled = await ThoughtPipeline.compile(pipeline_id, scope_id, { user_input });
597
- return compiled;
598
- },
599
-
600
- // Run a thought agent (pipeline + Responses API) and materialize proposed Thoughts.
601
- runThoughtAgent: async ({ agent_id, user_input, scope_id } = {}, ctx = {}) => {
602
- const result = await ThoughtPipeline.runAgent(agent_id, user_input, scope_id, ctx);
603
- return result;
604
- }
605
-
606
- // 🔧 Add more tools here as needed
607
- };
608
-
609
- // ----------------------
610
- // METADATA FOR TOOLS
611
- // ----------------------
612
- // These are used to auto-generate the MCP manifest from the function registry
613
- MCP.descriptions = {
614
- listSchemas: "List all available JOE schema names.",
615
- getSchema: "Retrieve schema by name. Set summaryOnly=true for normalized summary instead of full schema.",
616
- getSchemas: "Retrieve multiple schemas. With summaryOnly=true, returns summaries; if names omitted, returns all.",
617
- getObject: "Fetch a single object by _id (itemtype optional). Supports optional flatten. Accepts legacy alias 'schema' for itemtype.",
618
- // getObjectsByIds: "Deprecated - use 'search' with ids.",
619
- // queryObjects: "Deprecated - use 'search'.",
620
- // searchCache: "Deprecated - use 'search'.",
621
- search: "Exact search. Defaults to cache; set source=storage to query DB. Supports sortBy/sortDir, offset/limit paging, withCount, and slim response for {_id,itemtype,name,info,joeUpdated,created}. Accepts legacy alias 'schema' for itemtype. Use fuzzySearch for typo-tolerant free text.",
622
- fuzzySearch: "Fuzzy free‑text search. Candidates are prefiltered by 'filters' (e.g., { itemtype: 'user' }) and then scored. Accepts legacy alias 'schema' for itemtype. If 'fields' are omitted, the schema's searchables (strings) are used with best-field scoring; provide 'fields' (strings or {path,weight}) to use weighted sums. Examples: { q: 'corey hadden', filters: { itemtype: 'user' } } or { q: 'backyard', filters: { itemtype: 'house' } }.",
623
- saveObject: "Create/update an object; triggers events/history.",
624
- saveObjects: "Batch save objects with bounded concurrency; per-item history/events preserved.",
625
- hydrate: "Loads and merges the full JOE context, including core and organization-specific schemas, relationships, universal fields (tags and statuses), and datasets. Returns a single unified object describing the active environment for use by agents, UIs, and plugins.",
626
- listApps: "List app definitions (title, description, collections, plugins).",
627
- understandObject: "High-level helper: given an _id (and optional itemtype), returns { object, flattened, schemas, related[] } combining the main object, its schema summary, and referenced objects plus their schemas. Prefer this when you need to understand or reason about an object by id instead of issuing many separate getObject/getSchema calls.",
628
- compilePipeline: "Compile a named pipeline (e.g., the default Thought pipeline) into a deterministic prompt payload with system_context, user_context, and attachments.steps.",
629
- runThoughtAgent: "Run a Thought agent: compile its pipeline, call the OpenAI Responses API, store an ai_response, and materialize any proposed thought objects for later review."
630
- };
631
-
632
- MCP.params = {
633
- listSchemas: {},
634
- getSchema: {
635
- type: "object",
636
- properties: {
637
- name: { type: "string" },
638
- summaryOnly: { type: "boolean" }
639
- },
640
- required: ["name"]
641
- },
642
- getSchemas: {
643
- type: "object",
644
- properties: {
645
- names: { type: "array", items: { type: "string" } },
646
- summaryOnly: { type: "boolean" }
647
- },
648
- required: []
649
- },
650
- getObject: {
651
- type: "object",
652
- properties: {
653
- _id: { type: "string" },
654
- itemtype: { type: "string" },
655
- flatten: { type: "boolean" },
656
- depth: { type: "integer" }
657
- },
658
- required: ["_id"]
659
- },
660
- // getObjectsByIds: { ...deprecated },
661
- // queryObjects: { ...deprecated },
662
- // searchCache: { ...deprecated },
663
- search: {
664
- type: "object",
665
- properties: {
666
- itemtype: { type: "string" },
667
- query: { type: "object" },
668
- ids: { type: "array", items: { type: "string" } },
669
- source: { type: "string", enum: ["cache","storage"] },
670
- limit: { type: "integer" },
671
- offset: { type: "integer" },
672
- flatten: { type: "boolean" },
673
- depth: { type: "integer" },
674
- countOnly: { type: "boolean" },
675
- withCount: { type: "boolean" },
676
- sortBy: { type: "string" },
677
- sortDir: { type: "string", enum: ["asc","desc"] },
678
- slim: { type: "boolean" }
679
- },
680
- required: []
681
- },
682
- understandObject: {
683
- type: "object",
684
- properties: {
685
- _id: { type: "string" },
686
- itemtype: { type: "string" },
687
- schema: { type: "string" },
688
- depth: { type: "integer" },
689
- slim: { type: "boolean" }
690
- },
691
- required: ["_id"]
692
- },
693
- fuzzySearch: {
694
- type: "object",
695
- properties: {
696
- itemtype: { type: "string" },
697
- q: { type: "string" },
698
- filters: { type: "object" },
699
- fields: {
700
- type: "array",
701
- items: {
702
- anyOf: [
703
- { type: "string" },
704
- { type: "object", properties: { path: { type: "string" }, weight: { type: "number" } }, required: ["path"] }
705
- ]
706
- }
707
- },
708
- threshold: { type: "number" },
709
- limit: { type: "integer" },
710
- offset: { type: "integer" },
711
- highlight: { type: "boolean" },
712
- minQueryLength: { type: "integer" }
713
- },
714
- required: ["q"]
715
- },
716
- saveObject: {
717
- type: "object",
718
- properties: {
719
- object: { type: "object" }
720
- },
721
- required: ["object"]
722
- },
723
- saveObjects: {
724
- type: "object",
725
- properties: {
726
- objects: { type: "array", items: { type: "object" } },
727
- stopOnError: { type: "boolean" },
728
- concurrency: { type: "integer" }
729
- },
730
- required: ["objects"]
731
- },
732
- hydrate: { type: "object", properties: {} }
733
- ,
734
- listApps: { type: "object", properties: {} },
735
- compilePipeline: {
736
- type: "object",
737
- properties: {
738
- pipeline_id: { type: "string" },
739
- scope_id: { type: "string" },
740
- user_input: { type: "string" }
741
- },
742
- required: []
743
- },
744
- runThoughtAgent: {
745
- type: "object",
746
- properties: {
747
- agent_id: { type: "string" },
748
- user_input: { type: "string" },
749
- scope_id: { type: "string" }
750
- },
751
- required: ["user_input"]
752
- }
753
- };
754
-
755
- MCP.returns = {
756
- listSchemas: {
757
- type: "array",
758
- items: { type: "string" }
759
- },
760
- getSchema: { type: "object" },
761
- getSchemas: { type: "object" },
762
- getObject: { type: "object" },
763
- // getObjectsByIds: { ...deprecated },
764
- // queryObjects: { ...deprecated },
765
- // searchCache: { ...deprecated },
766
- search: {
767
- type: "object",
768
- properties: {
769
- items: { type: "array", items: { type: "object" } }
770
- }
771
- },
772
- fuzzySearch: {
773
- type: "object",
774
- properties: {
775
- items: { type: "array", items: { type: "object" } },
776
- count: { type: "integer" }
777
- }
778
- },
779
- // When countOnly is true, search returns { count }
780
- saveObject: { type: "object" },
781
- saveObjects: {
782
- type: "object",
783
- properties: {
784
- results: { type: "array", items: { type: "object" } },
785
- errors: { type: "array", items: { type: "object" } },
786
- saved: { type: "integer" },
787
- failed: { type: "integer" }
788
- }
789
- },
790
- hydrate: { type: "object" },
791
- listApps: {
792
- type: "object",
793
- properties: {
794
- names: { type: "array", items: { type: "string" } },
795
- apps: { type: "object" }
796
- }
797
- },
798
- compilePipeline: {
799
- type: "object",
800
- properties: {
801
- pipeline_id: { type: "string" },
802
- scope_id: { type: "string" },
803
- system_context: { type: "string" },
804
- developer_context: { type: "string" },
805
- user_context: { type: "string" },
806
- attachments: { type: "object" }
807
- }
808
- },
809
- runThoughtAgent: {
810
- type: "object",
811
- properties: {
812
- agent_id: { type: "string" },
813
- pipeline_id: { type: "string" },
814
- scope_id: { type: "string" },
815
- ai_response_id: { type: "string" },
816
- proposed_thoughts_count: { type: "integer" },
817
- saved_thought_ids: {
818
- type: "array",
819
- items: { type: "string" }
820
- },
821
- questions: {
822
- type: "array",
823
- items: { type: "string" }
824
- },
825
- missing_info: {
826
- type: "array",
827
- items: { type: "string" }
828
- },
829
- raw_response: { type: "string" }
830
- }
831
- }
832
- };
833
-
834
- // ----------------------
835
- // MANIFEST HANDLER
836
- // ----------------------
837
- // Responds to GET /.well-known/mcp/manifest.json
838
- // Returns tool descriptions for agent discovery
839
- MCP.manifest = async function (req, res) {
840
- try {
841
- const toolNames = Object.keys(MCP.tools);
842
- const tools = toolNames.map(name => ({
843
- name,
844
- description: MCP.descriptions[name],
845
- params: MCP.params[name],
846
- returns: MCP.returns[name]
847
- }));
848
- const joe = {
849
- name: (JOE && JOE.webconfig && JOE.webconfig.name) || 'JOE',
850
- version: (JOE && JOE.VERSION) || '',
851
- hostname: (JOE && JOE.webconfig && JOE.webconfig.hostname) || ''
852
- };
853
- const base = req.protocol+':'+(req.get('host')||'');
854
- const privacyUrl = base+'/'+'privacy';
855
- const termsUrl = base+'/'+'terms';
856
- return res.json({ version: "1.0", joe, privacy_policy_url: privacyUrl, terms_of_service_url: termsUrl, tools });
857
- } catch (e) {
858
- console.log('[MCP] manifest error:', e);
859
- return res.status(500).json({ error: e.message || 'manifest error' });
860
- }
861
- };
862
-
863
- // ----------------------
864
- // JSON-RPC HANDLER
865
- // ----------------------
866
- // Responds to POST /mcp with JSON-RPC 2.0 calls
867
- MCP.rpcHandler = async function (req, res) {
868
- const { id, method, params } = req.body;
869
-
870
- // Validate method
871
- if (!MCP.tools[method]) {
872
- return res.status(400).json({
873
- jsonrpc: "2.0",
874
- id,
875
- error: { code: -32601, message: "Method not found" }
876
- });
877
- }
878
-
879
- try {
880
- const result = await MCP.tools[method](params, { req, res });
881
- return res.json({ jsonrpc: "2.0", id, result });
882
- } catch (err) {
883
- console.error(`[MCP] Error in method ${method}:`, err);
884
- return res.status(500).json({
885
- jsonrpc: "2.0",
886
- id,
887
- error: { code: -32000, message: err.message || "Internal error" }
888
- });
889
- }
890
- };
891
-
892
- module.exports = MCP;
893
-
894
- // Optional initializer to attach routes without modifying Server.js
895
- MCP.init = function initMcpRoutes(){
896
- try {
897
- if (!global.JOE || !JOE.Server) return;
898
- if (JOE._mcpInitialized) return;
899
- const server = JOE.Server;
900
- const auth = JOE.auth; // may be undefined for manifest
901
- server.get('/.well-known/mcp/manifest.json', function(req, res){
902
- return MCP.manifest(req, res);
903
- });
904
- if (auth) {
905
- server.post('/mcp', auth, function(req, res){ return MCP.rpcHandler(req, res); });
906
- } else {
907
- server.post('/mcp', function(req, res){ return MCP.rpcHandler(req, res); });
908
- }
909
- JOE._mcpInitialized = true;
910
- console.log('[MCP] routes attached');
911
- } catch (e) {
912
- console.log('[MCP] init error:', e);
913
- }
914
- };
1
+ // modules/MCP.js
2
+
3
+ /**
4
+ * Model Context Protocol (MCP) core module for JOE.
5
+ * This module provides a JSON-RPC 2.0 compatible interface to JOE objects and schemas.
6
+ * Agents (like OpenAI Assistants) can discover and call structured tools via manifest + POST.
7
+ */
8
+
9
+ const MCP = {};
10
+ const ThoughtPipeline = require('./ThoughtPipeline');
11
+
12
+ // Internal helpers
13
+ function getStorage() {
14
+ return (global.JOE && global.JOE.Storage) || null;
15
+ }
16
+
17
+ function getSchemas() {
18
+ return (global.JOE && global.JOE.Schemas) || null;
19
+ }
20
+
21
+ function loadFromStorage(collection, query) {
22
+ return new Promise((resolve, reject) => {
23
+ try {
24
+ const Storage = getStorage();
25
+ if (!Storage) return reject(new Error('Storage module not initialized'));
26
+ Storage.load(collection, query || {}, function(err, results){
27
+ if (err) return reject(err);
28
+ resolve(results || []);
29
+ });
30
+ } catch (e) {
31
+ reject(e);
32
+ }
33
+ });
34
+ }
35
+
36
+ function sanitizeItems(items) {
37
+ try {
38
+ const arr = Array.isArray(items) ? items : [items];
39
+ return arr.map(i => {
40
+ if (!i || typeof i !== 'object') return i;
41
+ const copy = JSON.parse(JSON.stringify(i));
42
+ if (copy.password) copy.password = null;
43
+ if (copy.token) copy.token = null;
44
+ return copy;
45
+ });
46
+ } catch (e) {
47
+ return Array.isArray(items) ? items : [items];
48
+ }
49
+ }
50
+
51
+ // Resolve simple dotted paths against an object, including arrays.
52
+ // Example: getPathValues(recipe, "ingredients.id") → ["ing1","ing2",...]
53
+ function getPathValues(root, path) {
54
+ if (!root || !path) return [];
55
+ const parts = String(path).split('.');
56
+ let current = [root];
57
+ for (let i = 0; i < parts.length; i++) {
58
+ const key = parts[i];
59
+ const next = [];
60
+ for (let j = 0; j < current.length; j++) {
61
+ const val = current[j];
62
+ if (val == null) continue;
63
+ if (Array.isArray(val)) {
64
+ val.forEach(function (item) {
65
+ if (item && Object.prototype.hasOwnProperty.call(item, key)) {
66
+ next.push(item[key]);
67
+ }
68
+ });
69
+ } else if (Object.prototype.hasOwnProperty.call(val, key)) {
70
+ next.push(val[key]);
71
+ }
72
+ }
73
+ current = next;
74
+ if (!current.length) break;
75
+ }
76
+ // Flatten one level in case the last hop produced arrays
77
+ const out = [];
78
+ current.forEach(function (v) {
79
+ if (Array.isArray(v)) {
80
+ v.forEach(function (x) { if (x != null) out.push(x); });
81
+ } else if (v != null) {
82
+ out.push(v);
83
+ }
84
+ });
85
+ return out;
86
+ }
87
+
88
+ // Best-effort helper to get a normalized schema summary for a given name.
89
+ // Prefers the precomputed `Schemas.summary[name]` map, but falls back to
90
+ // `Schemas.schema[name].summary` when the summary map has not been generated
91
+ // or that particular schema has not yet been merged in.
92
+ function getSchemaSummary(name) {
93
+ if (!name) return null;
94
+ const Schemas = getSchemas();
95
+ if (!Schemas) return null;
96
+ if (Schemas.summary && Schemas.summary[name]) {
97
+ return Schemas.summary[name];
98
+ }
99
+ if (Schemas.schema && Schemas.schema[name] && Schemas.schema[name].summary) {
100
+ return Schemas.schema[name].summary;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function getComparable(val){
106
+ if (val == null) return null;
107
+ // Date-like
108
+ var d = new Date(val);
109
+ if (!isNaN(d.getTime())) return d.getTime();
110
+ if (typeof val === 'number') return val;
111
+ return (val+'').toLowerCase();
112
+ }
113
+
114
+ function sortItems(items, sortBy, sortDir){
115
+ if (!Array.isArray(items) || !sortBy) return items;
116
+ var dir = (sortDir === 'asc') ? 1 : -1;
117
+ return items.slice().sort(function(a,b){
118
+ var av = getComparable(a && a[sortBy]);
119
+ var bv = getComparable(b && b[sortBy]);
120
+ if (av == null && bv == null) return 0;
121
+ if (av == null) return 1; // nulls last
122
+ if (bv == null) return -1;
123
+ if (av > bv) return 1*dir;
124
+ if (av < bv) return -1*dir;
125
+ return 0;
126
+ });
127
+ }
128
+
129
+ function toSlim(item){
130
+ var name = (item && (item.name || item.title || item.label || item.email || item.slug)) || (item && item._id) || '';
131
+ var info = (item && (item.info || item.description || item.summary)) || '';
132
+ return {
133
+ _id: item && item._id,
134
+ itemtype: item && item.itemtype,
135
+ name: name,
136
+ info: info,
137
+ joeUpdated: item && (item.joeUpdated || item.updated || item.modified),
138
+ created: item && item.created
139
+ };
140
+ }
141
+
142
+ // Helper: Check if a string looks like a CUID
143
+ function isCuid(str) {
144
+ if (!str || typeof str !== 'string') return false;
145
+ // Check if it's a valid CUID format (36 chars, UUID-like)
146
+ // Using the pattern from craydent library: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}
147
+ const cuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
148
+ if (cuidPattern.test(str) && str.length === 36) return true;
149
+ // Also check if $c.isCuid is available (from craydent)
150
+ if (typeof $c !== 'undefined' && $c.isCuid && typeof $c.isCuid === 'function') {
151
+ return $c.isCuid(str);
152
+ }
153
+ return false;
154
+ }
155
+
156
+ // Helper: Resolve tag name to tag ID using fuzzy search
157
+ function resolveTagId(tagInput, threshold = 0.5) {
158
+ if (!tagInput || typeof tagInput !== 'string') return null;
159
+
160
+ // If it's already a CUID, check if it exists and return it
161
+ if (isCuid(tagInput)) {
162
+ const tags = (JOE && JOE.Data && JOE.Data.tag) || [];
163
+ const found = tags.find(t => t && t._id === tagInput);
164
+ if (found) return tagInput;
165
+ // CUID format but not found - return null
166
+ return null;
167
+ }
168
+
169
+ // Not a CUID - search by name using fuzzy matching
170
+ const tags = (JOE && JOE.Data && JOE.Data.tag) || [];
171
+ if (!tags.length) return null;
172
+
173
+ // Use Cache fuzzySearch if available
174
+ if (JOE && JOE.Cache && JOE.Cache.fuzzySearch) {
175
+ const results = JOE.Cache.fuzzySearch(tags, {
176
+ q: tagInput,
177
+ fields: [{ path: 'name', weight: 1.0 }],
178
+ threshold: threshold,
179
+ limit: 1
180
+ });
181
+ if (results && results.length > 0 && results[0]._id) {
182
+ return results[0]._id;
183
+ }
184
+ }
185
+
186
+ // Fallback: simple case-insensitive exact match
187
+ const normalized = tagInput.toLowerCase().trim();
188
+ const exact = tags.find(t => t && t.name && t.name.toLowerCase().trim() === normalized);
189
+ if (exact) return exact._id;
190
+
191
+ // Fallback: case-insensitive contains match
192
+ const contains = tags.find(t => t && t.name && t.name.toLowerCase().includes(normalized));
193
+ if (contains) return contains._id;
194
+
195
+ return null;
196
+ }
197
+
198
+ // Helper: Resolve status name to status ID using fuzzy search
199
+ function resolveStatusId(statusInput, threshold = 0.5) {
200
+ if (!statusInput || typeof statusInput !== 'string') return null;
201
+
202
+ // If it's already a CUID, check if it exists and return it
203
+ if (isCuid(statusInput)) {
204
+ const statuses = (JOE && JOE.Data && JOE.Data.status) || [];
205
+ const found = statuses.find(s => s && s._id === statusInput);
206
+ if (found) return statusInput;
207
+ // CUID format but not found - return null
208
+ return null;
209
+ }
210
+
211
+ // Not a CUID - search by name using fuzzy matching
212
+ const statuses = (JOE && JOE.Data && JOE.Data.status) || [];
213
+ if (!statuses.length) return null;
214
+
215
+ // Use Cache fuzzySearch if available
216
+ if (JOE && JOE.Cache && JOE.Cache.fuzzySearch) {
217
+ const results = JOE.Cache.fuzzySearch(statuses, {
218
+ q: statusInput,
219
+ fields: [{ path: 'name', weight: 1.0 }],
220
+ threshold: threshold,
221
+ limit: 1
222
+ });
223
+ if (results && results.length > 0 && results[0]._id) {
224
+ return results[0]._id;
225
+ }
226
+ }
227
+
228
+ // Fallback: simple case-insensitive exact match
229
+ const normalized = statusInput.toLowerCase().trim();
230
+ const exact = statuses.find(s => s && s.name && s.name.toLowerCase().trim() === normalized);
231
+ if (exact) return exact._id;
232
+
233
+ // Fallback: case-insensitive contains match
234
+ const contains = statuses.find(s => s && s.name && s.name.toLowerCase().includes(normalized));
235
+ if (contains) return contains._id;
236
+
237
+ return null;
238
+ }
239
+
240
+ // ----------------------
241
+ // TOOL DEFINITIONS
242
+ // ----------------------
243
+ // This object maps tool names to actual execution functions.
244
+ // Each function takes a `params` object and returns a JSON-serializable result.
245
+ MCP.tools = {
246
+
247
+ // List all schema names in the system
248
+ listSchemas: async (_params, _ctx) => {
249
+ var Schemas = getSchemas();
250
+ const list = (Schemas && (
251
+ (Schemas.schemaList && Schemas.schemaList.length && Schemas.schemaList) ||
252
+ (Schemas.schema && Object.keys(Schemas.schema))
253
+ )) || [];
254
+ return list.slice().sort();
255
+ },
256
+
257
+ // Get schema definition; summaryOnly to return normalized summary instead of full schema
258
+ getSchema: async ({ name, summaryOnly }, _ctx) => {
259
+ if (!name) throw new Error("Missing required param 'name'");
260
+ const Schemas = getSchemas();
261
+ if (!Schemas) throw new Error('Schemas module not initialized');
262
+ if (summaryOnly) {
263
+ const sum = Schemas && Schemas.summary && Schemas.summary[name];
264
+ if (!sum) throw new Error(`Schema summary for "${name}" not found`);
265
+ return sum;
266
+ }
267
+ const def = Schemas.schema && Schemas.schema[name];
268
+ if (!def) throw new Error(`Schema "${name}" not found`);
269
+ return def;
270
+ },
271
+
272
+ // Get multiple schemas. With summaryOnly, return summaries; without names, returns all.
273
+ getSchemas: async ({ names, summaryOnly } = {}, _ctx) => {
274
+ const Schemas = getSchemas();
275
+ if (!Schemas) throw new Error('Schemas module not initialized');
276
+ const all = (Schemas && Schemas.schema) || {};
277
+ if (summaryOnly) {
278
+ const allS = (Schemas && Schemas.summary) || {};
279
+ if (Array.isArray(names) && names.length) {
280
+ const outS = {};
281
+ names.forEach(function(n){ if (allS[n]) { outS[n] = allS[n]; } });
282
+ return outS;
283
+ }
284
+ return allS;
285
+ } else {
286
+ if (Array.isArray(names) && names.length) {
287
+ const out = {};
288
+ names.forEach(function(n){ if (all[n]) { out[n] = all[n]; } });
289
+ return out;
290
+ }
291
+ return all;
292
+ }
293
+ },
294
+
295
+ // Convenience: fetch a single object by _id (itemtype optional). Prefer cache; fallback to storage.
296
+ // Accepts legacy alias 'schema' for itemtype.
297
+ getObject: async ({ _id, itemtype, schema, flatten = false, depth = 1 }, _ctx) => {
298
+ if (!_id) throw new Error("Missing required param '_id'");
299
+ itemtype = itemtype || schema; // legacy alias
300
+ // Fast path via global lookup
301
+ let obj = (JOE && JOE.Cache && JOE.Cache.findByID) ? (JOE.Cache.findByID(_id) || null) : null;
302
+ if (!obj && itemtype) {
303
+ const results = await loadFromStorage(itemtype, { _id });
304
+ obj = (results && results[0]) || null;
305
+ }
306
+ if (!obj && itemtype && JOE && JOE.Cache && JOE.Cache.findByID) {
307
+ obj = JOE.Cache.findByID(itemtype, _id) || null;
308
+ }
309
+ if (!obj) throw new Error(`Object not found${itemtype?(' in '+itemtype):''} with _id: ${_id}`);
310
+ if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
311
+ try { return sanitizeItems(JOE.Utils.flattenObject(_id, { recursive: true, depth }))[0]; } catch(e) {}
312
+ }
313
+ return sanitizeItems(obj)[0];
314
+ },
315
+
316
+ /**
317
+ * understandObject
318
+ *
319
+ * High-level helper for agents: given an _id (and optional itemtype),
320
+ * returns a rich payload combining:
321
+ * - object: the raw object (ids intact)
322
+ * - flattened: the same object flattened to a limited depth
323
+ * - schemas: a map of schema summaries for the main itemtype and any
324
+ * referenced itemtypes (keyed by schema name)
325
+ * - related: an array of referenced objects discovered via outbound
326
+ * relationships in the schema summary.
327
+ *
328
+ * When `slim` is false (default), each related entry includes both `object`
329
+ * and `flattened`. When `slim` is true, only the main object is flattened
330
+ * and related entries are reduced to slim references:
331
+ * { field, _id, itemtype, object: { _id, itemtype, name, info } }
332
+ *
333
+ * Agents should prefer this tool when they need to understand or work with
334
+ * an object by id, instead of issuing many individual getObject / getSchema
335
+ * calls. The original object always keeps its reference ids; expanded views
336
+ * live under `flattened` and `related[*]`.
337
+ */
338
+ understandObject: async ({ _id, itemtype, schema, depth = 2, slim = false } = {}, _ctx) => {
339
+ if (!_id) throw new Error("Missing required param '_id'");
340
+ itemtype = itemtype || schema;
341
+
342
+ // Base object (sanitized) without flattening
343
+ const base = await MCP.tools.getObject({ _id, itemtype, flatten: false }, _ctx);
344
+ const mainType = base.itemtype || itemtype || null;
345
+
346
+ const result = {
347
+ _id: base._id,
348
+ itemtype: mainType,
349
+ object: base,
350
+ flattened: null,
351
+ schemas: {},
352
+ related: [],
353
+ // Deduped lookups for global reference types
354
+ tags: {},
355
+ statuses: {},
356
+ slim: !!slim
357
+ };
358
+
359
+ // Main schema summary
360
+ const mainSummary = getSchemaSummary(mainType);
361
+ if (mainType && mainSummary) {
362
+ result.schemas[mainType] = mainSummary;
363
+ }
364
+
365
+ // Flattened view of the main object (depth-limited)
366
+ if (JOE && JOE.Utils && JOE.Utils.flattenObject) {
367
+ try {
368
+ const flat = JOE.Utils.flattenObject(base._id, { recursive: true, depth });
369
+ result.flattened = sanitizeItems(flat)[0];
370
+ } catch (_e) {
371
+ result.flattened = null;
372
+ }
373
+ }
374
+
375
+ const seenSchemas = new Set(Object.keys(result.schemas || {}));
376
+ function addSchemaIfPresent(name) {
377
+ if (!name || seenSchemas.has(name)) return;
378
+ const sum = getSchemaSummary(name);
379
+ if (sum) {
380
+ result.schemas[name] = sum;
381
+ seenSchemas.add(name);
382
+ }
383
+ }
384
+
385
+ // Discover outbound relationships from schema summary
386
+ const schemaSummary = mainType && getSchemaSummary(mainType);
387
+ const outbound = (schemaSummary &&
388
+ schemaSummary.relationships &&
389
+ Array.isArray(schemaSummary.relationships.outbound))
390
+ ? schemaSummary.relationships.outbound
391
+ : [];
392
+
393
+ for (let i = 0; i < outbound.length; i++) {
394
+ const rel = outbound[i] || {};
395
+ const field = rel.field;
396
+ const targetSchema = rel.targetSchema;
397
+ if (!field || !targetSchema) continue;
398
+
399
+ // Support nested paths like "ingredients.id" coming from objectList fields.
400
+ const vals = getPathValues(base, field);
401
+ if (!vals || !vals.length) continue;
402
+
403
+ const ids = Array.isArray(vals) ? vals : [vals];
404
+ for (let j = 0; j < ids.length; j++) {
405
+ const rid = ids[j];
406
+ if (!rid) continue;
407
+ let robj = null;
408
+ try {
409
+ robj = await MCP.tools.getObject({ _id: rid, itemtype: targetSchema, flatten: false }, _ctx);
410
+ } catch (_e) {
411
+ continue;
412
+ }
413
+ if (!robj) continue;
414
+
415
+ const rType = robj.itemtype || targetSchema;
416
+ // Global reference types (tag/status) go into top-level lookup maps
417
+ if (rType === 'tag' || rType === 'status') {
418
+ const mapName = (rType === 'tag') ? 'tags' : 'statuses';
419
+ if (!result[mapName][robj._id]) {
420
+ result[mapName][robj._id] = {
421
+ _id: robj._id,
422
+ itemtype: rType,
423
+ name: robj.name || robj.label || robj.info || ''
424
+ };
425
+ }
426
+ } else {
427
+ if (slim) {
428
+ const slimObj = toSlim(robj);
429
+ result.related.push({
430
+ field,
431
+ _id: slimObj._id,
432
+ itemtype: slimObj.itemtype || rType,
433
+ object: {
434
+ _id: slimObj._id,
435
+ itemtype: slimObj.itemtype || rType,
436
+ name: slimObj.name,
437
+ info: slimObj.info
438
+ }
439
+ });
440
+ } else {
441
+ let rflat = null;
442
+ if (JOE && JOE.Utils && JOE.Utils.flattenObject) {
443
+ try {
444
+ const f = JOE.Utils.flattenObject(robj._id, { recursive: true, depth: Math.max(1, depth - 1) });
445
+ rflat = sanitizeItems(f)[0];
446
+ } catch (_e) {
447
+ rflat = null;
448
+ }
449
+ }
450
+ result.related.push({
451
+ field,
452
+ _id: robj._id,
453
+ itemtype: rType,
454
+ object: robj,
455
+ flattened: rflat
456
+ });
457
+ }
458
+ }
459
+ addSchemaIfPresent(rType || targetSchema);
460
+ }
461
+ }
462
+
463
+ return result;
464
+ },
465
+
466
+ /* Deprecated: use unified 'search' instead
467
+ getObjectsByIds: async () => { throw new Error('Use search with ids instead'); },
468
+ queryObjects: async () => { throw new Error('Use search instead'); },
469
+ */
470
+
471
+ /* Deprecated: use unified 'search' instead
472
+ searchCache: async () => { throw new Error('Use search instead'); },
473
+ */
474
+
475
+ // Unified search: defaults to cache; set source="storage" to query DB for a given itemtype
476
+ // Accepts legacy alias 'schema' for itemtype.
477
+ search: async ({ itemtype, schema, query = {}, ids, source = 'cache', limit = 50, offset = 0, flatten = false, depth = 1, countOnly = false, withCount = false, sortBy, sortDir = 'desc', slim = false }, _ctx) => {
478
+ itemtype = itemtype || schema; // legacy alias
479
+ const useCache = !source || source === 'cache';
480
+ const useStorage = source === 'storage';
481
+
482
+ if (ids && !Array.isArray(ids)) throw new Error("'ids' must be an array if provided");
483
+
484
+ // When ids are provided and an itemtype is known, prefer cache for safety/speed
485
+ if (Array.isArray(ids) && itemtype) {
486
+ let items = [];
487
+ if (JOE && JOE.Cache && JOE.Cache.findByID) {
488
+ const found = JOE.Cache.findByID(itemtype, ids.join(',')) || [];
489
+ items = Array.isArray(found) ? found : (found ? [found] : []);
490
+ }
491
+ if (useStorage && (!items || items.length === 0)) {
492
+ try {
493
+ const fromStorage = await loadFromStorage(itemtype, { _id: { $in: ids } });
494
+ items = fromStorage || [];
495
+ } catch (e) { /* ignore storage errors here */ }
496
+ }
497
+ if (flatten && !slim && JOE && JOE.Utils && JOE.Utils.flattenObject) {
498
+ try { items = ids.map(id => JOE.Utils.flattenObject(id, { recursive: true, depth })); } catch (e) {}
499
+ }
500
+ items = sortItems(items, sortBy, sortDir);
501
+ if (countOnly) {
502
+ return { count: (items || []).length };
503
+ }
504
+ const total = (items || []).length;
505
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
506
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
507
+ const sliced = (typeof end === 'number') ? items.slice(start, end) : items.slice(start);
508
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
509
+ return withCount ? { items: payload, count: total } : { items: payload };
510
+ }
511
+
512
+ // No ids: choose source
513
+ if (useCache) {
514
+ if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
515
+ let results = JOE.Cache.search(query || {});
516
+ if (itemtype) results = (results || []).filter(i => i && i.itemtype === itemtype);
517
+ results = sortItems(results, sortBy, sortDir);
518
+ if (countOnly) {
519
+ return { count: (results || []).length };
520
+ }
521
+ const total = (results || []).length;
522
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
523
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
524
+ const sliced = (typeof end === 'number') ? results.slice(start, end) : results.slice(start);
525
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
526
+ return withCount ? { items: payload, count: total } : { items: payload };
527
+ }
528
+
529
+ if (useStorage) {
530
+ if (!itemtype) throw new Error("'itemtype' is required when source=storage");
531
+ const results = await loadFromStorage(itemtype, query || {});
532
+ let sorted = sortItems(results, sortBy, sortDir);
533
+ if (countOnly) {
534
+ return { count: (sorted || []).length };
535
+ }
536
+ const total = (sorted || []).length;
537
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
538
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
539
+ const sliced = (typeof end === 'number') ? (sorted || []).slice(start, end) : (sorted || []).slice(start);
540
+ if (flatten && !slim && JOE && JOE.Utils && JOE.Utils.flattenObject) {
541
+ try { return withCount ? { items: sanitizeItems(sliced.map(function(it){ return JOE.Utils.flattenObject(it && it._id, { recursive: true, depth }); })), count: total } : { items: sanitizeItems(sliced.map(function(it){ return JOE.Utils.flattenObject(it && it._id, { recursive: true, depth }); })) }; } catch (e) {}
542
+ }
543
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
544
+ return withCount ? { items: payload, count: total } : { items: payload };
545
+ }
546
+
547
+ throw new Error("Invalid 'source'. Use 'cache' (default) or 'storage'.");
548
+ },
549
+
550
+ // Fuzzy, typo-tolerant search over cache with weighted fields
551
+ // Accepts legacy alias 'schema' for itemtype.
552
+ fuzzySearch: async ({ itemtype, schema, q, filters = {}, fields, threshold = 0.35, limit = 50, offset = 0, highlight = false, minQueryLength = 2 }, _ctx) => {
553
+ itemtype = itemtype || schema; // legacy alias
554
+ if (!q || (q+'').length < (minQueryLength||2)) {
555
+ return { items: [] };
556
+ }
557
+ if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
558
+ var query = Object.assign({}, filters || {});
559
+ query.$fuzzy = { q, fields, threshold, limit, offset, highlight, minQueryLength };
560
+ var results = JOE.Cache.search(query) || [];
561
+ if (itemtype) { results = results.filter(function(i){ return i && i.itemtype === itemtype; }); }
562
+ var total = (typeof results.count === 'number') ? results.count : results.length;
563
+ if (typeof limit === 'number' && limit > 0) { results = results.slice(0, limit); }
564
+ return { items: sanitizeItems(results), count: total };
565
+ },
566
+
567
+ // Save an object via Storage (respects events/history)
568
+ saveObject: async ({ object }, ctx = {}) => {
569
+ const Storage = getStorage();
570
+ if (!Storage) throw new Error('Storage module not initialized');
571
+ if (!object || !object.itemtype) throw new Error("'object' with 'itemtype' is required");
572
+ const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
573
+ // Ensure server-side update timestamp parity with /API/save
574
+ if (!object.joeUpdated) { object.joeUpdated = new Date().toISOString(); }
575
+ // Ensure a stable _id for new objects so history and events work consistently
576
+ try {
577
+ if (!object._id) { object._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); }
578
+ } catch(_e) { /* ignore id generation errors; Mongo will assign one */ }
579
+ const saved = await new Promise((resolve, reject) => {
580
+ try {
581
+ Storage.save(object, object.itemtype, function(err, data){
582
+ if (err) return reject(err);
583
+ resolve(data);
584
+ }, { user, history: true });
585
+ } catch (e) { reject(e); }
586
+ });
587
+ return sanitizeItems(saved)[0];
588
+ },
589
+
590
+ // Batch save with bounded concurrency; preserves per-item history/events
591
+ saveObjects: async ({ objects, stopOnError = false, concurrency = 5 } = {}, ctx = {}) => {
592
+ const Storage = getStorage();
593
+ if (!Storage) throw new Error('Storage module not initialized');
594
+ if (!Array.isArray(objects) || objects.length === 0) {
595
+ throw new Error("'objects' (non-empty array) is required");
596
+ }
597
+ const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
598
+ const size = Math.max(1, parseInt(concurrency, 10) || 5);
599
+ const results = new Array(objects.length);
600
+ const errors = [];
601
+ let cancelled = false;
602
+
603
+ async function saveOne(obj, index){
604
+ if (cancelled) return;
605
+ if (!obj || !obj.itemtype) {
606
+ const errMsg = "Each object must include 'itemtype'";
607
+ errors.push({ index, error: errMsg });
608
+ if (stopOnError) { cancelled = true; }
609
+ return;
610
+ }
611
+ if (!obj.joeUpdated) { obj.joeUpdated = new Date().toISOString(); }
612
+ try {
613
+ if (!obj._id) {
614
+ try { obj._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); } catch(_e) {}
615
+ }
616
+ const saved = await new Promise((resolve, reject) => {
617
+ try {
618
+ Storage.save(obj, obj.itemtype, function(err, data){
619
+ if (err) return reject(err);
620
+ resolve(data);
621
+ }, { user, history: true });
622
+ } catch (e) { reject(e); }
623
+ });
624
+ results[index] = sanitizeItems(saved)[0];
625
+ } catch (e) {
626
+ errors.push({ index, error: e && e.message ? e.message : (e+'' ) });
627
+ if (stopOnError) { cancelled = true; }
628
+ }
629
+ }
630
+
631
+ // Simple promise pool
632
+ let cursor = 0;
633
+ const runners = new Array(Math.min(size, objects.length)).fill(0).map(async function(){
634
+ while (!cancelled && cursor < objects.length) {
635
+ const idx = cursor++;
636
+ await saveOne(objects[idx], idx);
637
+ if (stopOnError && cancelled) break;
638
+ }
639
+ });
640
+ await Promise.all(runners);
641
+
642
+ const saved = results.filter(function(x){ return !!x; }).length;
643
+ const failed = errors.length;
644
+ return { results: sanitizeItems(results.filter(function(x){ return !!x; })), errors, saved, failed };
645
+ },
646
+
647
+ // Hydration: surface core fields (from file), all schemas, statuses, and tags (no params)
648
+ hydrate: async (_params, _ctx) => {
649
+ var Schemas = getSchemas();
650
+ let coreDef = (JOE && JOE.Fields && JOE.Fields['core']) || null;
651
+ if (!coreDef) {
652
+ try { coreDef = require(__dirname + '/../fields/core.js'); } catch (_e) { coreDef = {}; }
653
+ }
654
+ const coreFields = Object.keys(coreDef || {}).map(name => ({
655
+ name,
656
+ definition: coreDef[name]
657
+ }));
658
+
659
+ const payload = {
660
+ coreFields,
661
+ schemas: Object.keys(Schemas?.schema || {}),
662
+ schemaSummary: (Schemas && Schemas.summary) || {},
663
+ schemaSummaryGeneratedAt: (Schemas && Schemas.summaryGeneratedAt) || null
664
+ };
665
+ payload.statuses = sanitizeItems(JOE.Data?.status || []);
666
+ payload.tags = sanitizeItems(JOE.Data?.tag || [])
667
+
668
+ return payload;
669
+ },
670
+
671
+ // List app definitions in a sanitized/summarized form
672
+ listApps: async (_params, _ctx) => {
673
+ const apps = (JOE && JOE.Apps && JOE.Apps.cache) || {};
674
+ const names = Object.keys(apps || {});
675
+ const summarized = {};
676
+ names.forEach(function(name){
677
+ try {
678
+ const app = apps[name] || {};
679
+ summarized[name] = {
680
+ title: app.title || name,
681
+ description: app.description || '',
682
+ collections: Array.isArray(app.collections) ? app.collections : [],
683
+ plugins: Array.isArray(app.plugins) ? app.plugins : []
684
+ };
685
+ } catch(_e) {
686
+ summarized[name] = { title: name, description: '' };
687
+ }
688
+ });
689
+ return { names, apps: summarized };
690
+ },
691
+
692
+ // Compile a named pipeline into a deterministic prompt payload.
693
+ compilePipeline: async ({ pipeline_id, scope_id, user_input } = {}, _ctx) => {
694
+ const compiled = await ThoughtPipeline.compile(pipeline_id, scope_id, { user_input });
695
+ return compiled;
696
+ },
697
+
698
+ // Run a thought agent (pipeline + Responses API) and materialize proposed Thoughts.
699
+ runThoughtAgent: async ({ agent_id, user_input, scope_id, model } = {}, ctx = {}) => {
700
+ const context = Object.assign({}, ctx, { model });
701
+ const result = await ThoughtPipeline.runAgent(agent_id, user_input, scope_id, context);
702
+ return result;
703
+ },
704
+
705
+ // Find objects by tags - objects must have ALL specified tags (AND logic)
706
+ // Tags can be provided as IDs (CUIDs) or names (strings) - names will be resolved via fuzzy search
707
+ findObjectsByTag: async ({ tags, itemtype, limit, offset = 0, source = 'cache', slim = false, withCount = false, countOnly = false, tagThreshold = 0.5 }, _ctx) => {
708
+ if (!tags || !Array.isArray(tags) || tags.length === 0) {
709
+ throw new Error("'tags' (non-empty array) is required");
710
+ }
711
+
712
+ // Resolve tag names to IDs and collect tag objects
713
+ const resolvedTagIds = [];
714
+ const resolvedTags = [];
715
+ const unresolvedTags = [];
716
+ const allTags = (JOE && JOE.Data && JOE.Data.tag) || [];
717
+
718
+ for (let i = 0; i < tags.length; i++) {
719
+ const tagInput = tags[i];
720
+ if (!tagInput || typeof tagInput !== 'string') {
721
+ unresolvedTags.push(tagInput);
722
+ continue;
723
+ }
724
+ const tagId = resolveTagId(tagInput, tagThreshold);
725
+ if (tagId) {
726
+ resolvedTagIds.push(tagId);
727
+ // Find the tag object
728
+ const tagObj = allTags.find(t => t && t._id === tagId);
729
+ if (tagObj) {
730
+ resolvedTags.push(sanitizeItems(tagObj)[0]);
731
+ }
732
+ } else {
733
+ unresolvedTags.push(tagInput);
734
+ }
735
+ }
736
+
737
+ // If there are unresolved tags, return error response instead of throwing
738
+ if (unresolvedTags.length > 0) {
739
+ return {
740
+ items: [],
741
+ tags: resolvedTags,
742
+ error: `Could not resolve tag(s) to IDs: ${unresolvedTags.join(', ')}`
743
+ };
744
+ }
745
+
746
+ if (resolvedTagIds.length === 0) {
747
+ return {
748
+ items: [],
749
+ tags: [],
750
+ error: "No valid tags found after resolution"
751
+ };
752
+ }
753
+
754
+ const useCache = !source || source === 'cache';
755
+ const useStorage = source === 'storage';
756
+
757
+ // Helper to check if an object's tags array contains all required tags
758
+ function hasAllTags(obj, requiredTags) {
759
+ if (!obj || !obj.tags || !Array.isArray(obj.tags)) return false;
760
+ return requiredTags.every(tagId => obj.tags.includes(tagId));
761
+ }
762
+
763
+ if (useCache) {
764
+ if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
765
+ // Search cache and filter by tags
766
+ let results = JOE.Cache.search({});
767
+ if (itemtype) results = (results || []).filter(i => i && i.itemtype === itemtype);
768
+ // Filter to objects that have all specified tags (using resolved IDs)
769
+ results = (results || []).filter(i => hasAllTags(i, resolvedTagIds));
770
+ results = sortItems(results, null, 'desc');
771
+ const total = (results || []).length;
772
+ if (countOnly) {
773
+ return { count: total, tags: resolvedTags };
774
+ }
775
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
776
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
777
+ const sliced = (typeof end === 'number') ? results.slice(start, end) : results.slice(start);
778
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
779
+ const response = { items: payload, tags: resolvedTags };
780
+ if (withCount) response.count = total;
781
+ return response;
782
+ }
783
+
784
+ if (useStorage) {
785
+ if (!itemtype) throw new Error("'itemtype' is required when source=storage");
786
+ // For storage, try MongoDB $all operator, fallback to loading and filtering
787
+ let query = { tags: { $all: resolvedTagIds } };
788
+ let results;
789
+ try {
790
+ results = await loadFromStorage(itemtype, query);
791
+ } catch (e) {
792
+ // If $all not supported, load all and filter
793
+ results = await loadFromStorage(itemtype, {});
794
+ results = (results || []).filter(i => hasAllTags(i, resolvedTagIds));
795
+ }
796
+ let sorted = sortItems(results, null, 'desc');
797
+ const total = (sorted || []).length;
798
+ if (countOnly) {
799
+ return { count: total, tags: resolvedTags };
800
+ }
801
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
802
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
803
+ const sliced = (typeof end === 'number') ? (sorted || []).slice(start, end) : (sorted || []).slice(start);
804
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
805
+ const response = { items: payload, tags: resolvedTags };
806
+ if (withCount) response.count = total;
807
+ return response;
808
+ }
809
+
810
+ throw new Error("Invalid 'source'. Use 'cache' (default) or 'storage'.");
811
+ },
812
+
813
+ // Find objects by status
814
+ // Status can be provided as ID (CUID) or name (string) - name will be resolved via fuzzy search
815
+ findObjectsByStatus: async ({ status, itemtype, limit, offset = 0, source = 'cache', slim = false, withCount = false, countOnly = false, statusThreshold = 0.5 }, _ctx) => {
816
+ if (!status || typeof status !== 'string') {
817
+ throw new Error("'status' (string) is required");
818
+ }
819
+
820
+ // Resolve status name to ID and get status object
821
+ const statusId = resolveStatusId(status, statusThreshold);
822
+ if (!statusId) {
823
+ return {
824
+ items: [],
825
+ status: null,
826
+ error: `Could not resolve status "${status}" to an ID`
827
+ };
828
+ }
829
+
830
+ // Find the status object
831
+ const allStatuses = (JOE && JOE.Data && JOE.Data.status) || [];
832
+ const statusObj = allStatuses.find(s => s && s._id === statusId);
833
+ const resolvedStatus = statusObj ? sanitizeItems(statusObj)[0] : null;
834
+
835
+ const useCache = !source || source === 'cache';
836
+ const useStorage = source === 'storage';
837
+
838
+ if (useCache) {
839
+ if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
840
+ let query = { status: statusId };
841
+ let results = JOE.Cache.search(query);
842
+ if (itemtype) results = (results || []).filter(i => i && i.itemtype === itemtype);
843
+ results = sortItems(results, null, 'desc');
844
+ const total = (results || []).length;
845
+ if (countOnly) {
846
+ return { count: total, status: resolvedStatus };
847
+ }
848
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
849
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
850
+ const sliced = (typeof end === 'number') ? results.slice(start, end) : results.slice(start);
851
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
852
+ const response = { items: payload, status: resolvedStatus };
853
+ if (withCount) response.count = total;
854
+ return response;
855
+ }
856
+
857
+ if (useStorage) {
858
+ if (!itemtype) throw new Error("'itemtype' is required when source=storage");
859
+ const query = { status: statusId };
860
+ const results = await loadFromStorage(itemtype, query);
861
+ let sorted = sortItems(results, null, 'desc');
862
+ const total = (sorted || []).length;
863
+ if (countOnly) {
864
+ return { count: total, status: resolvedStatus };
865
+ }
866
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
867
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
868
+ const sliced = (typeof end === 'number') ? (sorted || []).slice(start, end) : (sorted || []).slice(start);
869
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
870
+ const response = { items: payload, status: resolvedStatus };
871
+ if (withCount) response.count = total;
872
+ return response;
873
+ }
874
+
875
+ throw new Error("Invalid 'source'. Use 'cache' (default) or 'storage'.");
876
+ }
877
+
878
+ // 🔧 Add more tools here as needed
879
+ };
880
+
881
+ // ----------------------
882
+ // METADATA FOR TOOLS
883
+ // ----------------------
884
+ // These are used to auto-generate the MCP manifest from the function registry
885
+ MCP.descriptions = {
886
+ listSchemas: "List all available JOE schema names.",
887
+ getSchema: "Retrieve schema by name. Set summaryOnly=true for normalized summary instead of full schema.",
888
+ getSchemas: "Retrieve multiple schemas. With summaryOnly=true, returns summaries; if names omitted, returns all.",
889
+ getObject: "Fetch a single object by _id (itemtype optional). Supports optional flatten. Accepts legacy alias 'schema' for itemtype.",
890
+ // getObjectsByIds: "Deprecated - use 'search' with ids.",
891
+ // queryObjects: "Deprecated - use 'search'.",
892
+ // searchCache: "Deprecated - use 'search'.",
893
+ search: "Exact search. Defaults to cache; set source=storage to query DB. Supports sortBy/sortDir, offset/limit paging, withCount, and slim response for {_id,itemtype,name,info,joeUpdated,created}. Accepts legacy alias 'schema' for itemtype. Use fuzzySearch for typo-tolerant free text.",
894
+ fuzzySearch: "Fuzzy free‑text search. Candidates are prefiltered by 'filters' (e.g., { itemtype: 'user' }) and then scored. Accepts legacy alias 'schema' for itemtype. If 'fields' are omitted, the schema's searchables (strings) are used with best-field scoring; provide 'fields' (strings or {path,weight}) to use weighted sums. Examples: { q: 'corey hadden', filters: { itemtype: 'user' } } or { q: 'backyard', filters: { itemtype: 'house' } }.",
895
+ saveObject: "Create/update an object; triggers events/history.",
896
+ saveObjects: "Batch save objects with bounded concurrency; per-item history/events preserved.",
897
+ hydrate: "Loads and merges the full JOE context, including core and organization-specific schemas, relationships, universal fields (tags and statuses), and datasets. Returns a single unified object describing the active environment for use by agents, UIs, and plugins.",
898
+ listApps: "List app definitions (title, description, collections, plugins).",
899
+ understandObject: "High-level helper: given an _id (and optional itemtype), returns { object, flattened, schemas, related[] } combining the main object, its schema summary, and referenced objects plus their schemas. Prefer this when you need to understand or reason about an object by id instead of issuing many separate getObject/getSchema calls.",
900
+ compilePipeline: "Compile a named pipeline (e.g., the default Thought pipeline) into a deterministic prompt payload with system_context, user_context, and attachments.steps.",
901
+ runThoughtAgent: "Run a Thought agent: compile its pipeline, call the OpenAI Responses API, store an ai_response, and materialize any proposed thought objects for later review.",
902
+ findObjectsByTag: "Find objects by tags. Objects must have ALL specified tags (AND logic). Tags can be provided as IDs (CUIDs) or names (strings) - names are resolved via fuzzy search. Returns all matching results by default unless limit is specified. Supports itemtype filter, source (cache/storage), slim response, withCount, countOnly, and tagThreshold for fuzzy name matching.",
903
+ findObjectsByStatus: "Find objects by status. Status can be provided as ID (CUID) or name (string) - name is resolved via fuzzy search. Returns all matching results by default unless limit is specified. Supports itemtype filter, source (cache/storage), slim response, withCount, countOnly, and statusThreshold for fuzzy name matching."
904
+ };
905
+
906
+ MCP.params = {
907
+ listSchemas: {},
908
+ getSchema: {
909
+ type: "object",
910
+ properties: {
911
+ name: { type: "string" },
912
+ summaryOnly: { type: "boolean" }
913
+ },
914
+ required: ["name"]
915
+ },
916
+ getSchemas: {
917
+ type: "object",
918
+ properties: {
919
+ names: { type: "array", items: { type: "string" } },
920
+ summaryOnly: { type: "boolean" }
921
+ },
922
+ required: []
923
+ },
924
+ getObject: {
925
+ type: "object",
926
+ properties: {
927
+ _id: { type: "string" },
928
+ itemtype: { type: "string" },
929
+ flatten: { type: "boolean" },
930
+ depth: { type: "integer" }
931
+ },
932
+ required: ["_id"]
933
+ },
934
+ // getObjectsByIds: { ...deprecated },
935
+ // queryObjects: { ...deprecated },
936
+ // searchCache: { ...deprecated },
937
+ search: {
938
+ type: "object",
939
+ properties: {
940
+ itemtype: { type: "string" },
941
+ query: { type: "object" },
942
+ ids: { type: "array", items: { type: "string" } },
943
+ source: { type: "string", enum: ["cache","storage"] },
944
+ limit: { type: "integer" },
945
+ offset: { type: "integer" },
946
+ flatten: { type: "boolean" },
947
+ depth: { type: "integer" },
948
+ countOnly: { type: "boolean" },
949
+ withCount: { type: "boolean" },
950
+ sortBy: { type: "string" },
951
+ sortDir: { type: "string", enum: ["asc","desc"] },
952
+ slim: { type: "boolean" }
953
+ },
954
+ required: []
955
+ },
956
+ understandObject: {
957
+ type: "object",
958
+ properties: {
959
+ _id: { type: "string" },
960
+ itemtype: { type: "string" },
961
+ schema: { type: "string" },
962
+ depth: { type: "integer" },
963
+ slim: { type: "boolean" }
964
+ },
965
+ required: ["_id"]
966
+ },
967
+ fuzzySearch: {
968
+ type: "object",
969
+ properties: {
970
+ itemtype: { type: "string" },
971
+ q: { type: "string" },
972
+ filters: { type: "object" },
973
+ fields: {
974
+ type: "array",
975
+ items: {
976
+ anyOf: [
977
+ { type: "string" },
978
+ { type: "object", properties: { path: { type: "string" }, weight: { type: "number" } }, required: ["path"] }
979
+ ]
980
+ }
981
+ },
982
+ threshold: { type: "number" },
983
+ limit: { type: "integer" },
984
+ offset: { type: "integer" },
985
+ highlight: { type: "boolean" },
986
+ minQueryLength: { type: "integer" }
987
+ },
988
+ required: ["q"]
989
+ },
990
+ saveObject: {
991
+ type: "object",
992
+ properties: {
993
+ object: { type: "object" }
994
+ },
995
+ required: ["object"]
996
+ },
997
+ saveObjects: {
998
+ type: "object",
999
+ properties: {
1000
+ objects: { type: "array", items: { type: "object" } },
1001
+ stopOnError: { type: "boolean" },
1002
+ concurrency: { type: "integer" }
1003
+ },
1004
+ required: ["objects"]
1005
+ },
1006
+ hydrate: { type: "object", properties: {} }
1007
+ ,
1008
+ listApps: { type: "object", properties: {} },
1009
+ compilePipeline: {
1010
+ type: "object",
1011
+ properties: {
1012
+ pipeline_id: { type: "string" },
1013
+ scope_id: { type: "string" },
1014
+ user_input: { type: "string" }
1015
+ },
1016
+ required: []
1017
+ },
1018
+ runThoughtAgent: {
1019
+ type: "object",
1020
+ properties: {
1021
+ agent_id: { type: "string" },
1022
+ user_input: { type: "string" },
1023
+ scope_id: { type: "string" },
1024
+ model: { type: "string" }
1025
+ },
1026
+ required: ["user_input"]
1027
+ },
1028
+ findObjectsByTag: {
1029
+ type: "object",
1030
+ properties: {
1031
+ tags: { type: "array", items: { type: "string" }, description: "Array of tag IDs (CUIDs) or tag names (strings). Names are resolved via fuzzy search." },
1032
+ itemtype: { type: "string" },
1033
+ limit: { type: "integer" },
1034
+ offset: { type: "integer" },
1035
+ source: { type: "string", enum: ["cache","storage"] },
1036
+ slim: { type: "boolean" },
1037
+ withCount: { type: "boolean" },
1038
+ countOnly: { type: "boolean", description: "If true, returns only count and tags, no items" },
1039
+ tagThreshold: { type: "number", description: "Fuzzy search threshold (0-1) for resolving tag names. Default: 0.5" }
1040
+ },
1041
+ required: ["tags"]
1042
+ },
1043
+ findObjectsByStatus: {
1044
+ type: "object",
1045
+ properties: {
1046
+ status: { type: "string", description: "Status ID (CUID) or status name (string). Name is resolved via fuzzy search." },
1047
+ itemtype: { type: "string" },
1048
+ limit: { type: "integer" },
1049
+ offset: { type: "integer" },
1050
+ source: { type: "string", enum: ["cache","storage"] },
1051
+ slim: { type: "boolean" },
1052
+ withCount: { type: "boolean" },
1053
+ countOnly: { type: "boolean", description: "If true, returns only count and status, no items" },
1054
+ statusThreshold: { type: "number", description: "Fuzzy search threshold (0-1) for resolving status name. Default: 0.5" }
1055
+ },
1056
+ required: ["status"]
1057
+ }
1058
+ };
1059
+
1060
+ MCP.returns = {
1061
+ listSchemas: {
1062
+ type: "array",
1063
+ items: { type: "string" }
1064
+ },
1065
+ getSchema: { type: "object" },
1066
+ getSchemas: { type: "object" },
1067
+ getObject: { type: "object" },
1068
+ // getObjectsByIds: { ...deprecated },
1069
+ // queryObjects: { ...deprecated },
1070
+ // searchCache: { ...deprecated },
1071
+ search: {
1072
+ type: "object",
1073
+ properties: {
1074
+ items: { type: "array", items: { type: "object" } }
1075
+ }
1076
+ },
1077
+ fuzzySearch: {
1078
+ type: "object",
1079
+ properties: {
1080
+ items: { type: "array", items: { type: "object" } },
1081
+ count: { type: "integer" }
1082
+ }
1083
+ },
1084
+ // When countOnly is true, search returns { count }
1085
+ saveObject: { type: "object" },
1086
+ saveObjects: {
1087
+ type: "object",
1088
+ properties: {
1089
+ results: { type: "array", items: { type: "object" } },
1090
+ errors: { type: "array", items: { type: "object" } },
1091
+ saved: { type: "integer" },
1092
+ failed: { type: "integer" }
1093
+ }
1094
+ },
1095
+ hydrate: { type: "object" },
1096
+ listApps: {
1097
+ type: "object",
1098
+ properties: {
1099
+ names: { type: "array", items: { type: "string" } },
1100
+ apps: { type: "object" }
1101
+ }
1102
+ },
1103
+ compilePipeline: {
1104
+ type: "object",
1105
+ properties: {
1106
+ pipeline_id: { type: "string" },
1107
+ scope_id: { type: "string" },
1108
+ system_context: { type: "string" },
1109
+ developer_context: { type: "string" },
1110
+ user_context: { type: "string" },
1111
+ attachments: { type: "object" }
1112
+ }
1113
+ },
1114
+ runThoughtAgent: {
1115
+ type: "object",
1116
+ properties: {
1117
+ agent_id: { type: "string" },
1118
+ pipeline_id: { type: "string" },
1119
+ scope_id: { type: "string" },
1120
+ ai_response_id: { type: "string" },
1121
+ proposed_thoughts_count: { type: "integer" },
1122
+ saved_thought_ids: {
1123
+ type: "array",
1124
+ items: { type: "string" }
1125
+ },
1126
+ questions: {
1127
+ type: "array",
1128
+ items: { type: "string" }
1129
+ },
1130
+ missing_info: {
1131
+ type: "array",
1132
+ items: { type: "string" }
1133
+ },
1134
+ raw_response: { type: "string" }
1135
+ }
1136
+ },
1137
+ findObjectsByTag: {
1138
+ type: "object",
1139
+ properties: {
1140
+ items: { type: "array", items: { type: "object" }, description: "Array of matching items (omitted if countOnly=true)" },
1141
+ tags: { type: "array", items: { type: "object" }, description: "Array of resolved tag objects that were used in the search" },
1142
+ count: { type: "integer", description: "Total count of matching items" },
1143
+ error: { type: "string", description: "Error message if tag resolution failed" }
1144
+ }
1145
+ },
1146
+ findObjectsByStatus: {
1147
+ type: "object",
1148
+ properties: {
1149
+ items: { type: "array", items: { type: "object" }, description: "Array of matching items (omitted if countOnly=true)" },
1150
+ status: { type: "object", description: "Resolved status object that was used in the search" },
1151
+ count: { type: "integer", description: "Total count of matching items" },
1152
+ error: { type: "string", description: "Error message if status resolution failed" }
1153
+ }
1154
+ }
1155
+ };
1156
+
1157
+ // ----------------------
1158
+ // MANIFEST HANDLER
1159
+ // ----------------------
1160
+ // Responds to GET /.well-known/mcp/manifest.json
1161
+ // Returns tool descriptions for agent discovery
1162
+ MCP.manifest = async function (req, res) {
1163
+ try {
1164
+ const toolNames = Object.keys(MCP.tools);
1165
+ const tools = toolNames.map(name => ({
1166
+ name,
1167
+ description: MCP.descriptions[name],
1168
+ params: MCP.params[name],
1169
+ returns: MCP.returns[name]
1170
+ }));
1171
+ const joe = {
1172
+ name: (JOE && JOE.webconfig && JOE.webconfig.name) || 'JOE',
1173
+ version: (JOE && JOE.VERSION) || '',
1174
+ hostname: (JOE && JOE.webconfig && JOE.webconfig.hostname) || ''
1175
+ };
1176
+ const base = req.protocol+':'+(req.get('host')||'');
1177
+ const privacyUrl = base+'/'+'privacy';
1178
+ const termsUrl = base+'/'+'terms';
1179
+ return res.json({ version: "1.0", joe, privacy_policy_url: privacyUrl, terms_of_service_url: termsUrl, tools });
1180
+ } catch (e) {
1181
+ console.log('[MCP] manifest error:', e);
1182
+ return res.status(500).json({ error: e.message || 'manifest error' });
1183
+ }
1184
+ };
1185
+
1186
+ // ----------------------
1187
+ // JSON-RPC HANDLER
1188
+ // ----------------------
1189
+ // Responds to POST /mcp with JSON-RPC 2.0 calls
1190
+ MCP.rpcHandler = async function (req, res) {
1191
+ const { id, method, params } = req.body;
1192
+
1193
+ // Validate method
1194
+ if (!MCP.tools[method]) {
1195
+ return res.status(400).json({
1196
+ jsonrpc: "2.0",
1197
+ id,
1198
+ error: { code: -32601, message: "Method not found" }
1199
+ });
1200
+ }
1201
+
1202
+ try {
1203
+ const result = await MCP.tools[method](params, { req, res });
1204
+ return res.json({ jsonrpc: "2.0", id, result });
1205
+ } catch (err) {
1206
+ console.error(`[MCP] Error in method ${method}:`, err);
1207
+ return res.status(500).json({
1208
+ jsonrpc: "2.0",
1209
+ id,
1210
+ error: { code: -32000, message: err.message || "Internal error" }
1211
+ });
1212
+ }
1213
+ };
1214
+
1215
+ module.exports = MCP;
1216
+
1217
+ // Optional initializer to attach routes without modifying Server.js
1218
+ MCP.init = function initMcpRoutes(){
1219
+ try {
1220
+ if (!global.JOE || !JOE.Server) return;
1221
+ if (JOE._mcpInitialized) return;
1222
+ const server = JOE.Server;
1223
+ const auth = JOE.auth; // may be undefined for manifest
1224
+ server.get('/.well-known/mcp/manifest.json', function(req, res){
1225
+ return MCP.manifest(req, res);
1226
+ });
1227
+ if (auth) {
1228
+ server.post('/mcp', auth, function(req, res){ return MCP.rpcHandler(req, res); });
1229
+ } else {
1230
+ server.post('/mcp', function(req, res){ return MCP.rpcHandler(req, res); });
1231
+ }
1232
+ JOE._mcpInitialized = true;
1233
+ console.log('[MCP] routes attached');
1234
+ } catch (e) {
1235
+ console.log('[MCP] init error:', e);
1236
+ }
1237
+ };