json-object-editor 0.10.509 → 0.10.521

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/_www/mcp-export.html +11 -4
  3. package/_www/mcp-nav.js +8 -4
  4. package/_www/mcp-prompt.html +96 -121
  5. package/_www/mcp-schemas.html +294 -0
  6. package/_www/mcp-test.html +86 -0
  7. package/docs/JOE_Master_Knowledge_Export.md +135 -0
  8. package/docs/joe_agent_custom_gpt_instructions_v_2.md +54 -0
  9. package/docs/joe_agent_spec_v_2.2.md +64 -0
  10. package/docs/schema_summary_guidelines.md +128 -0
  11. package/package.json +1 -1
  12. package/readme.md +525 -469
  13. package/server/modules/MCP.js +606 -405
  14. package/server/modules/Schemas.js +321 -111
  15. package/server/modules/Server.js +26 -15
  16. package/server/modules/Storage.js +9 -0
  17. package/server/relationships.graph.json +5 -0
  18. package/server/schemas/block.js +37 -0
  19. package/server/schemas/board.js +2 -1
  20. package/server/schemas/budget.js +28 -1
  21. package/server/schemas/event.js +42 -0
  22. package/server/schemas/financial_account.js +35 -0
  23. package/server/schemas/goal.js +30 -0
  24. package/server/schemas/group.js +31 -0
  25. package/server/schemas/include.js +28 -0
  26. package/server/schemas/ingredient.js +28 -0
  27. package/server/schemas/initiative.js +32 -0
  28. package/server/schemas/instance.js +31 -1
  29. package/server/schemas/layout.js +31 -0
  30. package/server/schemas/ledger.js +30 -0
  31. package/server/schemas/list.js +33 -0
  32. package/server/schemas/meal.js +30 -0
  33. package/server/schemas/note.js +30 -0
  34. package/server/schemas/notification.js +33 -1
  35. package/server/schemas/page.js +43 -0
  36. package/server/schemas/post.js +32 -0
  37. package/server/schemas/project.js +36 -0
  38. package/server/schemas/recipe.js +32 -0
  39. package/server/schemas/report.js +32 -0
  40. package/server/schemas/setting.js +22 -0
  41. package/server/schemas/site.js +30 -0
  42. package/server/schemas/status.js +33 -0
  43. package/server/schemas/tag.js +28 -1
  44. package/server/schemas/task.js +778 -737
  45. package/server/schemas/transaction.js +43 -0
  46. package/server/schemas/user.js +36 -1
  47. package/server/schemas/workflow.js +30 -1
@@ -1,405 +1,606 @@
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 { Storage, Schemas } = global.JOE; // Adjust as needed based on how your modules are wired
11
-
12
- // Internal helpers
13
- function loadFromStorage(collection, query) {
14
- return new Promise((resolve, reject) => {
15
- try {
16
- Storage.load(collection, query || {}, function(err, results){
17
- if (err) return reject(err);
18
- resolve(results || []);
19
- });
20
- } catch (e) {
21
- reject(e);
22
- }
23
- });
24
- }
25
-
26
- function sanitizeItems(items) {
27
- try {
28
- const arr = Array.isArray(items) ? items : [items];
29
- return arr.map(i => {
30
- if (!i || typeof i !== 'object') return i;
31
- const copy = JSON.parse(JSON.stringify(i));
32
- if (copy.password) copy.password = null;
33
- if (copy.token) copy.token = null;
34
- return copy;
35
- });
36
- } catch (e) {
37
- return Array.isArray(items) ? items : [items];
38
- }
39
- }
40
-
41
- // ----------------------
42
- // TOOL DEFINITIONS
43
- // ----------------------
44
- // This object maps tool names to actual execution functions.
45
- // Each function takes a `params` object and returns a JSON-serializable result.
46
- MCP.tools = {
47
-
48
- // List all schema names in the system
49
- listSchemas: async (_params, _ctx) => {
50
- const list = (Schemas && (
51
- (Schemas.schemaList && Schemas.schemaList.length && Schemas.schemaList) ||
52
- (Schemas.schema && Object.keys(Schemas.schema))
53
- )) || [];
54
- return list.slice().sort();
55
- },
56
-
57
- // Get full schema definition by name
58
- getSchema: async ({ name }, _ctx) => {
59
- if (!name) throw new Error("Missing required param 'name'");
60
- const def = Schemas.schema && Schemas.schema[name];
61
- if (!def) throw new Error(`Schema "${name}" not found`);
62
- return def;
63
- },
64
-
65
- // Convenience: fetch a single object by _id (schema optional). Prefer cache; fallback to storage.
66
- getObject: async ({ _id, schema, flatten = false, depth = 1 }, _ctx) => {
67
- if (!_id) throw new Error("Missing required param '_id'");
68
- // Fast path via global lookup
69
- let obj = (JOE && JOE.Cache && JOE.Cache.findByID) ? (JOE.Cache.findByID(_id) || null) : null;
70
- if (!obj && schema) {
71
- const results = await loadFromStorage(schema, { _id });
72
- obj = (results && results[0]) || null;
73
- }
74
- if (!obj && schema && JOE && JOE.Cache && JOE.Cache.findByID) {
75
- obj = JOE.Cache.findByID(schema, _id) || null;
76
- }
77
- if (!obj) throw new Error(`Object not found${schema?(' in '+schema):''} with _id: ${_id}`);
78
- if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
79
- try { return sanitizeItems(JOE.Utils.flattenObject(_id, { recursive: true, depth }))[0]; } catch(e) {}
80
- }
81
- return sanitizeItems(obj)[0];
82
- },
83
-
84
- /* Deprecated: use unified 'search' instead
85
- getObjectsByIds: async () => { throw new Error('Use search with ids instead'); },
86
- queryObjects: async () => { throw new Error('Use search instead'); },
87
- */
88
-
89
- /* Deprecated: use unified 'search' instead
90
- searchCache: async () => { throw new Error('Use search instead'); },
91
- */
92
-
93
- // Unified search: defaults to cache; set source="storage" to query DB for a schema
94
- search: async ({ schema, query = {}, ids, source = 'cache', limit = 50, flatten = false, depth = 1, countOnly = false }, _ctx) => {
95
- const useCache = !source || source === 'cache';
96
- const useStorage = source === 'storage';
97
-
98
- if (ids && !Array.isArray(ids)) throw new Error("'ids' must be an array if provided");
99
-
100
- // When ids are provided and a schema is known, prefer cache for safety/speed
101
- if (Array.isArray(ids) && schema) {
102
- let items = [];
103
- if (JOE && JOE.Cache && JOE.Cache.findByID) {
104
- const found = JOE.Cache.findByID(schema, ids.join(',')) || [];
105
- items = Array.isArray(found) ? found : (found ? [found] : []);
106
- }
107
- if (useStorage && (!items || items.length === 0)) {
108
- try {
109
- const fromStorage = await loadFromStorage(schema, { _id: { $in: ids } });
110
- items = fromStorage || [];
111
- } catch (e) { /* ignore storage errors here */ }
112
- }
113
- if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
114
- try { items = ids.map(id => JOE.Utils.flattenObject(id, { recursive: true, depth })); } catch (e) {}
115
- }
116
- if (countOnly) {
117
- return { count: (items || []).length };
118
- }
119
- const sliced = (typeof limit === 'number' && limit > 0) ? items.slice(0, limit) : items;
120
- return { items: sanitizeItems(sliced) };
121
- }
122
-
123
- // No ids: choose source
124
- if (useCache) {
125
- if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
126
- let results = JOE.Cache.search(query || {});
127
- if (schema) results = (results || []).filter(i => i && i.itemtype === schema);
128
- if (countOnly) {
129
- return { count: (results || []).length };
130
- }
131
- const sliced = (typeof limit === 'number' && limit > 0) ? results.slice(0, limit) : results;
132
- return { items: sanitizeItems(sliced) };
133
- }
134
-
135
- if (useStorage) {
136
- if (!schema) throw new Error("'schema' is required when source=storage");
137
- const results = await loadFromStorage(schema, query || {});
138
- if (countOnly) {
139
- return { count: (results || []).length };
140
- }
141
- const sliced = (typeof limit === 'number' && limit > 0) ? (results || []).slice(0, limit) : (results || []);
142
- if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
143
- try { return { items: sanitizeItems(sliced.map(it => JOE.Utils.flattenObject(it && it._id, { recursive: true, depth }))) }; } catch (e) {}
144
- }
145
- return { items: sanitizeItems(sliced) };
146
- }
147
-
148
- throw new Error("Invalid 'source'. Use 'cache' (default) or 'storage'.");
149
- },
150
-
151
- // Fuzzy, typo-tolerant search over cache with weighted fields
152
- fuzzySearch: async ({ schema, q, filters = {}, fields, threshold = 0.35, limit = 50, offset = 0, highlight = false, minQueryLength = 2 }, _ctx) => {
153
- if (!q || (q+'').length < (minQueryLength||2)) {
154
- return { items: [] };
155
- }
156
- if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
157
- var query = Object.assign({}, filters || {});
158
- query.$fuzzy = { q, fields, threshold, limit, offset, highlight, minQueryLength };
159
- var results = JOE.Cache.search(query) || [];
160
- if (schema) { results = results.filter(function(i){ return i && i.itemtype === schema; }); }
161
- var total = (typeof results.count === 'number') ? results.count : results.length;
162
- if (typeof limit === 'number' && limit > 0) { results = results.slice(0, limit); }
163
- return { items: sanitizeItems(results), count: total };
164
- },
165
-
166
- // Save an object via Storage (respects events/history)
167
- saveObject: async ({ object }, ctx = {}) => {
168
- if (!object || !object.itemtype) throw new Error("'object' with 'itemtype' is required");
169
- const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
170
- // Ensure server-side update timestamp parity with /API/save
171
- if (!object.joeUpdated) { object.joeUpdated = new Date(); }
172
- // Ensure a stable _id for new objects so history and events work consistently
173
- try {
174
- if (!object._id) { object._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); }
175
- } catch(_e) { /* ignore id generation errors; Mongo will assign one */ }
176
- const saved = await new Promise((resolve, reject) => {
177
- try {
178
- Storage.save(object, object.itemtype, function(err, data){
179
- if (err) return reject(err);
180
- resolve(data);
181
- }, { user, history: true });
182
- } catch (e) { reject(e); }
183
- });
184
- return sanitizeItems(saved)[0];
185
- },
186
-
187
- // Hydration: surface core fields (from file), all schemas, statuses, and tags (no params)
188
- hydrate: async (_params, _ctx) => {
189
- let coreDef = (JOE && JOE.Fields && JOE.Fields['core']) || null;
190
- if (!coreDef) {
191
- try { coreDef = require(__dirname + '/../fields/core.js'); } catch (_e) { coreDef = {}; }
192
- }
193
- const coreFields = Object.keys(coreDef || {}).map(name => ({
194
- name,
195
- definition: coreDef[name]
196
- }));
197
-
198
- const payload = {
199
- coreFields,
200
- schemas: Object.keys(Schemas?.schema || {})
201
- };
202
- payload.statuses = sanitizeItems(JOE.Data?.status || []);
203
- payload.tags = sanitizeItems(JOE.Data?.tag || [])
204
-
205
- return payload;
206
- }
207
-
208
- // 🔧 Add more tools here as needed
209
- };
210
-
211
- // ----------------------
212
- // METADATA FOR TOOLS
213
- // ----------------------
214
- // These are used to auto-generate the MCP manifest from the function registry
215
- MCP.descriptions = {
216
- listSchemas: "List all available JOE schema names.",
217
- getSchema: "Retrieve a full schema definition by name.",
218
- getObject: "Fetch a single object by _id (schema optional). Supports optional flatten.",
219
- // getObjectsByIds: "Deprecated - use 'search' with ids.",
220
- // queryObjects: "Deprecated - use 'search'.",
221
- // searchCache: "Deprecated - use 'search'.",
222
- search: "Exact search. Defaults to cache; set source=storage to query DB. Use fuzzySearch for typo-tolerant free text.",
223
- fuzzySearch: "Fuzzy free‑text search. Candidates are prefiltered by 'filters' (e.g., { itemtype: 'user' }) and then scored. 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' } }.",
224
- saveObject: "Create/update an object; triggers events/history.",
225
- hydrate: "Describe core fields, statuses, tags, and inferred field shapes for an optional schema."
226
- };
227
-
228
- MCP.params = {
229
- listSchemas: {},
230
- getSchema: {
231
- type: "object",
232
- properties: {
233
- name: { type: "string" }
234
- },
235
- required: ["name"]
236
- },
237
- getObject: {
238
- type: "object",
239
- properties: {
240
- _id: { type: "string" },
241
- schema: { type: "string" },
242
- flatten: { type: "boolean" },
243
- depth: { type: "integer" }
244
- },
245
- required: ["_id"]
246
- },
247
- // getObjectsByIds: { ...deprecated },
248
- // queryObjects: { ...deprecated },
249
- // searchCache: { ...deprecated },
250
- search: {
251
- type: "object",
252
- properties: {
253
- schema: { type: "string" },
254
- query: { type: "object" },
255
- ids: { type: "array", items: { type: "string" } },
256
- source: { type: "string", enum: ["cache","storage"] },
257
- limit: { type: "integer" },
258
- flatten: { type: "boolean" },
259
- depth: { type: "integer" },
260
- countOnly: { type: "boolean" }
261
- },
262
- required: []
263
- },
264
- fuzzySearch: {
265
- type: "object",
266
- properties: {
267
- schema: { type: "string" },
268
- q: { type: "string" },
269
- filters: { type: "object" },
270
- fields: {
271
- type: "array",
272
- items: {
273
- anyOf: [
274
- { type: "string" },
275
- { type: "object", properties: { path: { type: "string" }, weight: { type: "number" } }, required: ["path"] }
276
- ]
277
- }
278
- },
279
- threshold: { type: "number" },
280
- limit: { type: "integer" },
281
- offset: { type: "integer" },
282
- highlight: { type: "boolean" },
283
- minQueryLength: { type: "integer" }
284
- },
285
- required: ["q"]
286
- },
287
- saveObject: {
288
- type: "object",
289
- properties: {
290
- object: { type: "object" }
291
- },
292
- required: ["object"]
293
- },
294
- hydrate: { type: "object", properties: {} }
295
- };
296
-
297
- MCP.returns = {
298
- listSchemas: {
299
- type: "array",
300
- items: { type: "string" }
301
- },
302
- getSchema: { type: "object" },
303
- getObject: { type: "object" },
304
- // getObjectsByIds: { ...deprecated },
305
- // queryObjects: { ...deprecated },
306
- // searchCache: { ...deprecated },
307
- search: {
308
- type: "object",
309
- properties: {
310
- items: { type: "array", items: { type: "object" } }
311
- }
312
- },
313
- fuzzySearch: {
314
- type: "object",
315
- properties: {
316
- items: { type: "array", items: { type: "object" } },
317
- count: { type: "integer" }
318
- }
319
- },
320
- // When countOnly is true, search returns { count }
321
- saveObject: { type: "object" },
322
- hydrate: { type: "object" }
323
- };
324
-
325
- // ----------------------
326
- // MANIFEST HANDLER
327
- // ----------------------
328
- // Responds to GET /.well-known/mcp/manifest.json
329
- // Returns tool descriptions for agent discovery
330
- MCP.manifest = async function (req, res) {
331
- try {
332
- const toolNames = Object.keys(MCP.tools);
333
- const tools = toolNames.map(name => ({
334
- name,
335
- description: MCP.descriptions[name],
336
- params: MCP.params[name],
337
- returns: MCP.returns[name]
338
- }));
339
- const joe = {
340
- name: (JOE && JOE.webconfig && JOE.webconfig.name) || 'JOE',
341
- version: (JOE && JOE.VERSION) || '',
342
- hostname: (JOE && JOE.webconfig && JOE.webconfig.hostname) || ''
343
- };
344
- const base = req.protocol+':'+(req.get('host')||'');
345
- const privacyUrl = base+'/'+'privacy';
346
- const termsUrl = base+'/'+'terms';
347
- return res.json({ version: "1.0", joe, privacy_policy_url: privacyUrl, terms_of_service_url: termsUrl, tools });
348
- } catch (e) {
349
- console.log('[MCP] manifest error:', e);
350
- return res.status(500).json({ error: e.message || 'manifest error' });
351
- }
352
- };
353
-
354
- // ----------------------
355
- // JSON-RPC HANDLER
356
- // ----------------------
357
- // Responds to POST /mcp with JSON-RPC 2.0 calls
358
- MCP.rpcHandler = async function (req, res) {
359
- const { id, method, params } = req.body;
360
-
361
- // Validate method
362
- if (!MCP.tools[method]) {
363
- return res.status(400).json({
364
- jsonrpc: "2.0",
365
- id,
366
- error: { code: -32601, message: "Method not found" }
367
- });
368
- }
369
-
370
- try {
371
- const result = await MCP.tools[method](params, { req, res });
372
- return res.json({ jsonrpc: "2.0", id, result });
373
- } catch (err) {
374
- console.error(`[MCP] Error in method ${method}:`, err);
375
- return res.status(500).json({
376
- jsonrpc: "2.0",
377
- id,
378
- error: { code: -32000, message: err.message || "Internal error" }
379
- });
380
- }
381
- };
382
-
383
- module.exports = MCP;
384
-
385
- // Optional initializer to attach routes without modifying Server.js
386
- MCP.init = function initMcpRoutes(){
387
- try {
388
- if (!global.JOE || !JOE.Server) return;
389
- if (JOE._mcpInitialized) return;
390
- const server = JOE.Server;
391
- const auth = JOE.auth; // may be undefined for manifest
392
- server.get('/.well-known/mcp/manifest.json', function(req, res){
393
- return MCP.manifest(req, res);
394
- });
395
- if (auth) {
396
- server.post('/mcp', auth, function(req, res){ return MCP.rpcHandler(req, res); });
397
- } else {
398
- server.post('/mcp', function(req, res){ return MCP.rpcHandler(req, res); });
399
- }
400
- JOE._mcpInitialized = true;
401
- console.log('[MCP] routes attached');
402
- } catch (e) {
403
- console.log('[MCP] init error:', e);
404
- }
405
- };
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 { Storage, Schemas } = global.JOE; // Adjust as needed based on how your modules are wired
11
+
12
+ // Internal helpers
13
+ function loadFromStorage(collection, query) {
14
+ return new Promise((resolve, reject) => {
15
+ try {
16
+ Storage.load(collection, query || {}, function(err, results){
17
+ if (err) return reject(err);
18
+ resolve(results || []);
19
+ });
20
+ } catch (e) {
21
+ reject(e);
22
+ }
23
+ });
24
+ }
25
+
26
+ function sanitizeItems(items) {
27
+ try {
28
+ const arr = Array.isArray(items) ? items : [items];
29
+ return arr.map(i => {
30
+ if (!i || typeof i !== 'object') return i;
31
+ const copy = JSON.parse(JSON.stringify(i));
32
+ if (copy.password) copy.password = null;
33
+ if (copy.token) copy.token = null;
34
+ return copy;
35
+ });
36
+ } catch (e) {
37
+ return Array.isArray(items) ? items : [items];
38
+ }
39
+ }
40
+
41
+ function getComparable(val){
42
+ if (val == null) return null;
43
+ // Date-like
44
+ var d = new Date(val);
45
+ if (!isNaN(d.getTime())) return d.getTime();
46
+ if (typeof val === 'number') return val;
47
+ return (val+'').toLowerCase();
48
+ }
49
+
50
+ function sortItems(items, sortBy, sortDir){
51
+ if (!Array.isArray(items) || !sortBy) return items;
52
+ var dir = (sortDir === 'asc') ? 1 : -1;
53
+ return items.slice().sort(function(a,b){
54
+ var av = getComparable(a && a[sortBy]);
55
+ var bv = getComparable(b && b[sortBy]);
56
+ if (av == null && bv == null) return 0;
57
+ if (av == null) return 1; // nulls last
58
+ if (bv == null) return -1;
59
+ if (av > bv) return 1*dir;
60
+ if (av < bv) return -1*dir;
61
+ return 0;
62
+ });
63
+ }
64
+
65
+ function toSlim(item){
66
+ var name = (item && (item.name || item.title || item.label || item.email || item.slug)) || (item && item._id) || '';
67
+ var info = (item && (item.info || item.description || item.summary)) || '';
68
+ return {
69
+ _id: item && item._id,
70
+ itemtype: item && item.itemtype,
71
+ name: name,
72
+ info: info,
73
+ joeUpdated: item && (item.joeUpdated || item.updated || item.modified),
74
+ created: item && item.created
75
+ };
76
+ }
77
+
78
+ // ----------------------
79
+ // TOOL DEFINITIONS
80
+ // ----------------------
81
+ // This object maps tool names to actual execution functions.
82
+ // Each function takes a `params` object and returns a JSON-serializable result.
83
+ MCP.tools = {
84
+
85
+ // List all schema names in the system
86
+ listSchemas: async (_params, _ctx) => {
87
+ const list = (Schemas && (
88
+ (Schemas.schemaList && Schemas.schemaList.length && Schemas.schemaList) ||
89
+ (Schemas.schema && Object.keys(Schemas.schema))
90
+ )) || [];
91
+ return list.slice().sort();
92
+ },
93
+
94
+ // Get schema definition; summaryOnly to return normalized summary instead of full schema
95
+ getSchema: async ({ name, summaryOnly }, _ctx) => {
96
+ if (!name) throw new Error("Missing required param 'name'");
97
+ if (summaryOnly) {
98
+ const sum = Schemas && Schemas.summary && Schemas.summary[name];
99
+ if (!sum) throw new Error(`Schema summary for "${name}" not found`);
100
+ return sum;
101
+ }
102
+ const def = Schemas.schema && Schemas.schema[name];
103
+ if (!def) throw new Error(`Schema "${name}" not found`);
104
+ return def;
105
+ },
106
+
107
+ // Get multiple schemas. With summaryOnly, return summaries; without names, returns all.
108
+ getSchemas: async ({ names, summaryOnly } = {}, _ctx) => {
109
+ const all = (Schemas && Schemas.schema) || {};
110
+ if (summaryOnly) {
111
+ const allS = (Schemas && Schemas.summary) || {};
112
+ if (Array.isArray(names) && names.length) {
113
+ const outS = {};
114
+ names.forEach(function(n){ if (allS[n]) { outS[n] = allS[n]; } });
115
+ return outS;
116
+ }
117
+ return allS;
118
+ } else {
119
+ if (Array.isArray(names) && names.length) {
120
+ const out = {};
121
+ names.forEach(function(n){ if (all[n]) { out[n] = all[n]; } });
122
+ return out;
123
+ }
124
+ return all;
125
+ }
126
+ },
127
+
128
+ // Convenience: fetch a single object by _id (schema optional). Prefer cache; fallback to storage.
129
+ getObject: async ({ _id, schema, flatten = false, depth = 1 }, _ctx) => {
130
+ if (!_id) throw new Error("Missing required param '_id'");
131
+ // Fast path via global lookup
132
+ let obj = (JOE && JOE.Cache && JOE.Cache.findByID) ? (JOE.Cache.findByID(_id) || null) : null;
133
+ if (!obj && schema) {
134
+ const results = await loadFromStorage(schema, { _id });
135
+ obj = (results && results[0]) || null;
136
+ }
137
+ if (!obj && schema && JOE && JOE.Cache && JOE.Cache.findByID) {
138
+ obj = JOE.Cache.findByID(schema, _id) || null;
139
+ }
140
+ if (!obj) throw new Error(`Object not found${schema?(' in '+schema):''} with _id: ${_id}`);
141
+ if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
142
+ try { return sanitizeItems(JOE.Utils.flattenObject(_id, { recursive: true, depth }))[0]; } catch(e) {}
143
+ }
144
+ return sanitizeItems(obj)[0];
145
+ },
146
+
147
+ /* Deprecated: use unified 'search' instead
148
+ getObjectsByIds: async () => { throw new Error('Use search with ids instead'); },
149
+ queryObjects: async () => { throw new Error('Use search instead'); },
150
+ */
151
+
152
+ /* Deprecated: use unified 'search' instead
153
+ searchCache: async () => { throw new Error('Use search instead'); },
154
+ */
155
+
156
+ // Unified search: defaults to cache; set source="storage" to query DB for a schema
157
+ search: async ({ schema, query = {}, ids, source = 'cache', limit = 50, offset = 0, flatten = false, depth = 1, countOnly = false, withCount = false, sortBy, sortDir = 'desc', slim = false }, _ctx) => {
158
+ const useCache = !source || source === 'cache';
159
+ const useStorage = source === 'storage';
160
+
161
+ if (ids && !Array.isArray(ids)) throw new Error("'ids' must be an array if provided");
162
+
163
+ // When ids are provided and a schema is known, prefer cache for safety/speed
164
+ if (Array.isArray(ids) && schema) {
165
+ let items = [];
166
+ if (JOE && JOE.Cache && JOE.Cache.findByID) {
167
+ const found = JOE.Cache.findByID(schema, ids.join(',')) || [];
168
+ items = Array.isArray(found) ? found : (found ? [found] : []);
169
+ }
170
+ if (useStorage && (!items || items.length === 0)) {
171
+ try {
172
+ const fromStorage = await loadFromStorage(schema, { _id: { $in: ids } });
173
+ items = fromStorage || [];
174
+ } catch (e) { /* ignore storage errors here */ }
175
+ }
176
+ if (flatten && !slim && JOE && JOE.Utils && JOE.Utils.flattenObject) {
177
+ try { items = ids.map(id => JOE.Utils.flattenObject(id, { recursive: true, depth })); } catch (e) {}
178
+ }
179
+ items = sortItems(items, sortBy, sortDir);
180
+ if (countOnly) {
181
+ return { count: (items || []).length };
182
+ }
183
+ const total = (items || []).length;
184
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
185
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
186
+ const sliced = (typeof end === 'number') ? items.slice(start, end) : items.slice(start);
187
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
188
+ return withCount ? { items: payload, count: total } : { items: payload };
189
+ }
190
+
191
+ // No ids: choose source
192
+ if (useCache) {
193
+ if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
194
+ let results = JOE.Cache.search(query || {});
195
+ if (schema) results = (results || []).filter(i => i && i.itemtype === schema);
196
+ results = sortItems(results, sortBy, sortDir);
197
+ if (countOnly) {
198
+ return { count: (results || []).length };
199
+ }
200
+ const total = (results || []).length;
201
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
202
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
203
+ const sliced = (typeof end === 'number') ? results.slice(start, end) : results.slice(start);
204
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
205
+ return withCount ? { items: payload, count: total } : { items: payload };
206
+ }
207
+
208
+ if (useStorage) {
209
+ if (!schema) throw new Error("'schema' is required when source=storage");
210
+ const results = await loadFromStorage(schema, query || {});
211
+ let sorted = sortItems(results, sortBy, sortDir);
212
+ if (countOnly) {
213
+ return { count: (sorted || []).length };
214
+ }
215
+ const total = (sorted || []).length;
216
+ const start = Math.max(0, parseInt(offset || 0, 10) || 0);
217
+ const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
218
+ const sliced = (typeof end === 'number') ? (sorted || []).slice(start, end) : (sorted || []).slice(start);
219
+ if (flatten && !slim && JOE && JOE.Utils && JOE.Utils.flattenObject) {
220
+ 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) {}
221
+ }
222
+ const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
223
+ return withCount ? { items: payload, count: total } : { items: payload };
224
+ }
225
+
226
+ throw new Error("Invalid 'source'. Use 'cache' (default) or 'storage'.");
227
+ },
228
+
229
+ // Fuzzy, typo-tolerant search over cache with weighted fields
230
+ fuzzySearch: async ({ schema, q, filters = {}, fields, threshold = 0.35, limit = 50, offset = 0, highlight = false, minQueryLength = 2 }, _ctx) => {
231
+ if (!q || (q+'').length < (minQueryLength||2)) {
232
+ return { items: [] };
233
+ }
234
+ if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
235
+ var query = Object.assign({}, filters || {});
236
+ query.$fuzzy = { q, fields, threshold, limit, offset, highlight, minQueryLength };
237
+ var results = JOE.Cache.search(query) || [];
238
+ if (schema) { results = results.filter(function(i){ return i && i.itemtype === schema; }); }
239
+ var total = (typeof results.count === 'number') ? results.count : results.length;
240
+ if (typeof limit === 'number' && limit > 0) { results = results.slice(0, limit); }
241
+ return { items: sanitizeItems(results), count: total };
242
+ },
243
+
244
+ // Save an object via Storage (respects events/history)
245
+ saveObject: async ({ object }, ctx = {}) => {
246
+ if (!object || !object.itemtype) throw new Error("'object' with 'itemtype' is required");
247
+ const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
248
+ // Ensure server-side update timestamp parity with /API/save
249
+ if (!object.joeUpdated) { object.joeUpdated = new Date().toISOString(); }
250
+ // Ensure a stable _id for new objects so history and events work consistently
251
+ try {
252
+ if (!object._id) { object._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); }
253
+ } catch(_e) { /* ignore id generation errors; Mongo will assign one */ }
254
+ const saved = await new Promise((resolve, reject) => {
255
+ try {
256
+ Storage.save(object, object.itemtype, function(err, data){
257
+ if (err) return reject(err);
258
+ resolve(data);
259
+ }, { user, history: true });
260
+ } catch (e) { reject(e); }
261
+ });
262
+ return sanitizeItems(saved)[0];
263
+ },
264
+
265
+ // Batch save with bounded concurrency; preserves per-item history/events
266
+ saveObjects: async ({ objects, stopOnError = false, concurrency = 5 } = {}, ctx = {}) => {
267
+ if (!Array.isArray(objects) || objects.length === 0) {
268
+ throw new Error("'objects' (non-empty array) is required");
269
+ }
270
+ const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
271
+ const size = Math.max(1, parseInt(concurrency, 10) || 5);
272
+ const results = new Array(objects.length);
273
+ const errors = [];
274
+ let cancelled = false;
275
+
276
+ async function saveOne(obj, index){
277
+ if (cancelled) return;
278
+ if (!obj || !obj.itemtype) {
279
+ const errMsg = "Each object must include 'itemtype'";
280
+ errors.push({ index, error: errMsg });
281
+ if (stopOnError) { cancelled = true; }
282
+ return;
283
+ }
284
+ if (!obj.joeUpdated) { obj.joeUpdated = new Date().toISOString(); }
285
+ try {
286
+ if (!obj._id) {
287
+ try { obj._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); } catch(_e) {}
288
+ }
289
+ const saved = await new Promise((resolve, reject) => {
290
+ try {
291
+ Storage.save(obj, obj.itemtype, function(err, data){
292
+ if (err) return reject(err);
293
+ resolve(data);
294
+ }, { user, history: true });
295
+ } catch (e) { reject(e); }
296
+ });
297
+ results[index] = sanitizeItems(saved)[0];
298
+ } catch (e) {
299
+ errors.push({ index, error: e && e.message ? e.message : (e+'' ) });
300
+ if (stopOnError) { cancelled = true; }
301
+ }
302
+ }
303
+
304
+ // Simple promise pool
305
+ let cursor = 0;
306
+ const runners = new Array(Math.min(size, objects.length)).fill(0).map(async function(){
307
+ while (!cancelled && cursor < objects.length) {
308
+ const idx = cursor++;
309
+ await saveOne(objects[idx], idx);
310
+ if (stopOnError && cancelled) break;
311
+ }
312
+ });
313
+ await Promise.all(runners);
314
+
315
+ const saved = results.filter(function(x){ return !!x; }).length;
316
+ const failed = errors.length;
317
+ return { results: sanitizeItems(results.filter(function(x){ return !!x; })), errors, saved, failed };
318
+ },
319
+
320
+ // Hydration: surface core fields (from file), all schemas, statuses, and tags (no params)
321
+ hydrate: async (_params, _ctx) => {
322
+ let coreDef = (JOE && JOE.Fields && JOE.Fields['core']) || null;
323
+ if (!coreDef) {
324
+ try { coreDef = require(__dirname + '/../fields/core.js'); } catch (_e) { coreDef = {}; }
325
+ }
326
+ const coreFields = Object.keys(coreDef || {}).map(name => ({
327
+ name,
328
+ definition: coreDef[name]
329
+ }));
330
+
331
+ const payload = {
332
+ coreFields,
333
+ schemas: Object.keys(Schemas?.schema || {}),
334
+ schemaSummary: (Schemas && Schemas.summary) || {},
335
+ schemaSummaryGeneratedAt: (Schemas && Schemas.summaryGeneratedAt) || null
336
+ };
337
+ payload.statuses = sanitizeItems(JOE.Data?.status || []);
338
+ payload.tags = sanitizeItems(JOE.Data?.tag || [])
339
+
340
+ return payload;
341
+ },
342
+
343
+ // List app definitions in a sanitized/summarized form
344
+ listApps: async (_params, _ctx) => {
345
+ const apps = (JOE && JOE.Apps && JOE.Apps.cache) || {};
346
+ const names = Object.keys(apps || {});
347
+ const summarized = {};
348
+ names.forEach(function(name){
349
+ try {
350
+ const app = apps[name] || {};
351
+ summarized[name] = {
352
+ title: app.title || name,
353
+ description: app.description || '',
354
+ collections: Array.isArray(app.collections) ? app.collections : [],
355
+ plugins: Array.isArray(app.plugins) ? app.plugins : []
356
+ };
357
+ } catch(_e) {
358
+ summarized[name] = { title: name, description: '' };
359
+ }
360
+ });
361
+ return { names, apps: summarized };
362
+ }
363
+
364
+ // 🔧 Add more tools here as needed
365
+ };
366
+
367
+ // ----------------------
368
+ // METADATA FOR TOOLS
369
+ // ----------------------
370
+ // These are used to auto-generate the MCP manifest from the function registry
371
+ MCP.descriptions = {
372
+ listSchemas: "List all available JOE schema names.",
373
+ getSchema: "Retrieve schema by name. Set summaryOnly=true for normalized summary instead of full schema.",
374
+ getSchemas: "Retrieve multiple schemas. With summaryOnly=true, returns summaries; if names omitted, returns all.",
375
+ getObject: "Fetch a single object by _id (schema optional). Supports optional flatten.",
376
+ // getObjectsByIds: "Deprecated - use 'search' with ids.",
377
+ // queryObjects: "Deprecated - use 'search'.",
378
+ // searchCache: "Deprecated - use 'search'.",
379
+ 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}. Use fuzzySearch for typo-tolerant free text.",
380
+ fuzzySearch: "Fuzzy free‑text search. Candidates are prefiltered by 'filters' (e.g., { itemtype: 'user' }) and then scored. 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' } }.",
381
+ saveObject: "Create/update an object; triggers events/history.",
382
+ saveObjects: "Batch save objects with bounded concurrency; per-item history/events preserved.",
383
+ 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.",
384
+ listApps: "List app definitions (title, description, collections, plugins)."
385
+ };
386
+
387
+ MCP.params = {
388
+ listSchemas: {},
389
+ getSchema: {
390
+ type: "object",
391
+ properties: {
392
+ name: { type: "string" },
393
+ summaryOnly: { type: "boolean" }
394
+ },
395
+ required: ["name"]
396
+ },
397
+ getSchemas: {
398
+ type: "object",
399
+ properties: {
400
+ names: { type: "array", items: { type: "string" } },
401
+ summaryOnly: { type: "boolean" }
402
+ },
403
+ required: []
404
+ },
405
+ getObject: {
406
+ type: "object",
407
+ properties: {
408
+ _id: { type: "string" },
409
+ schema: { type: "string" },
410
+ flatten: { type: "boolean" },
411
+ depth: { type: "integer" }
412
+ },
413
+ required: ["_id"]
414
+ },
415
+ // getObjectsByIds: { ...deprecated },
416
+ // queryObjects: { ...deprecated },
417
+ // searchCache: { ...deprecated },
418
+ search: {
419
+ type: "object",
420
+ properties: {
421
+ schema: { type: "string" },
422
+ query: { type: "object" },
423
+ ids: { type: "array", items: { type: "string" } },
424
+ source: { type: "string", enum: ["cache","storage"] },
425
+ limit: { type: "integer" },
426
+ offset: { type: "integer" },
427
+ flatten: { type: "boolean" },
428
+ depth: { type: "integer" },
429
+ countOnly: { type: "boolean" },
430
+ withCount: { type: "boolean" },
431
+ sortBy: { type: "string" },
432
+ sortDir: { type: "string", enum: ["asc","desc"] },
433
+ slim: { type: "boolean" }
434
+ },
435
+ required: []
436
+ },
437
+ fuzzySearch: {
438
+ type: "object",
439
+ properties: {
440
+ schema: { type: "string" },
441
+ q: { type: "string" },
442
+ filters: { type: "object" },
443
+ fields: {
444
+ type: "array",
445
+ items: {
446
+ anyOf: [
447
+ { type: "string" },
448
+ { type: "object", properties: { path: { type: "string" }, weight: { type: "number" } }, required: ["path"] }
449
+ ]
450
+ }
451
+ },
452
+ threshold: { type: "number" },
453
+ limit: { type: "integer" },
454
+ offset: { type: "integer" },
455
+ highlight: { type: "boolean" },
456
+ minQueryLength: { type: "integer" }
457
+ },
458
+ required: ["q"]
459
+ },
460
+ saveObject: {
461
+ type: "object",
462
+ properties: {
463
+ object: { type: "object" }
464
+ },
465
+ required: ["object"]
466
+ },
467
+ saveObjects: {
468
+ type: "object",
469
+ properties: {
470
+ objects: { type: "array", items: { type: "object" } },
471
+ stopOnError: { type: "boolean" },
472
+ concurrency: { type: "integer" }
473
+ },
474
+ required: ["objects"]
475
+ },
476
+ hydrate: { type: "object", properties: {} }
477
+ ,
478
+ listApps: { type: "object", properties: {} }
479
+ };
480
+
481
+ MCP.returns = {
482
+ listSchemas: {
483
+ type: "array",
484
+ items: { type: "string" }
485
+ },
486
+ getSchema: { type: "object" },
487
+ getSchemas: { type: "object" },
488
+ getObject: { type: "object" },
489
+ // getObjectsByIds: { ...deprecated },
490
+ // queryObjects: { ...deprecated },
491
+ // searchCache: { ...deprecated },
492
+ search: {
493
+ type: "object",
494
+ properties: {
495
+ items: { type: "array", items: { type: "object" } }
496
+ }
497
+ },
498
+ fuzzySearch: {
499
+ type: "object",
500
+ properties: {
501
+ items: { type: "array", items: { type: "object" } },
502
+ count: { type: "integer" }
503
+ }
504
+ },
505
+ // When countOnly is true, search returns { count }
506
+ saveObject: { type: "object" },
507
+ saveObjects: {
508
+ type: "object",
509
+ properties: {
510
+ results: { type: "array", items: { type: "object" } },
511
+ errors: { type: "array", items: { type: "object" } },
512
+ saved: { type: "integer" },
513
+ failed: { type: "integer" }
514
+ }
515
+ },
516
+ hydrate: { type: "object" },
517
+ listApps: {
518
+ type: "object",
519
+ properties: {
520
+ names: { type: "array", items: { type: "string" } },
521
+ apps: { type: "object" }
522
+ }
523
+ }
524
+ };
525
+
526
+ // ----------------------
527
+ // MANIFEST HANDLER
528
+ // ----------------------
529
+ // Responds to GET /.well-known/mcp/manifest.json
530
+ // Returns tool descriptions for agent discovery
531
+ MCP.manifest = async function (req, res) {
532
+ try {
533
+ const toolNames = Object.keys(MCP.tools);
534
+ const tools = toolNames.map(name => ({
535
+ name,
536
+ description: MCP.descriptions[name],
537
+ params: MCP.params[name],
538
+ returns: MCP.returns[name]
539
+ }));
540
+ const joe = {
541
+ name: (JOE && JOE.webconfig && JOE.webconfig.name) || 'JOE',
542
+ version: (JOE && JOE.VERSION) || '',
543
+ hostname: (JOE && JOE.webconfig && JOE.webconfig.hostname) || ''
544
+ };
545
+ const base = req.protocol+':'+(req.get('host')||'');
546
+ const privacyUrl = base+'/'+'privacy';
547
+ const termsUrl = base+'/'+'terms';
548
+ return res.json({ version: "1.0", joe, privacy_policy_url: privacyUrl, terms_of_service_url: termsUrl, tools });
549
+ } catch (e) {
550
+ console.log('[MCP] manifest error:', e);
551
+ return res.status(500).json({ error: e.message || 'manifest error' });
552
+ }
553
+ };
554
+
555
+ // ----------------------
556
+ // JSON-RPC HANDLER
557
+ // ----------------------
558
+ // Responds to POST /mcp with JSON-RPC 2.0 calls
559
+ MCP.rpcHandler = async function (req, res) {
560
+ const { id, method, params } = req.body;
561
+
562
+ // Validate method
563
+ if (!MCP.tools[method]) {
564
+ return res.status(400).json({
565
+ jsonrpc: "2.0",
566
+ id,
567
+ error: { code: -32601, message: "Method not found" }
568
+ });
569
+ }
570
+
571
+ try {
572
+ const result = await MCP.tools[method](params, { req, res });
573
+ return res.json({ jsonrpc: "2.0", id, result });
574
+ } catch (err) {
575
+ console.error(`[MCP] Error in method ${method}:`, err);
576
+ return res.status(500).json({
577
+ jsonrpc: "2.0",
578
+ id,
579
+ error: { code: -32000, message: err.message || "Internal error" }
580
+ });
581
+ }
582
+ };
583
+
584
+ module.exports = MCP;
585
+
586
+ // Optional initializer to attach routes without modifying Server.js
587
+ MCP.init = function initMcpRoutes(){
588
+ try {
589
+ if (!global.JOE || !JOE.Server) return;
590
+ if (JOE._mcpInitialized) return;
591
+ const server = JOE.Server;
592
+ const auth = JOE.auth; // may be undefined for manifest
593
+ server.get('/.well-known/mcp/manifest.json', function(req, res){
594
+ return MCP.manifest(req, res);
595
+ });
596
+ if (auth) {
597
+ server.post('/mcp', auth, function(req, res){ return MCP.rpcHandler(req, res); });
598
+ } else {
599
+ server.post('/mcp', function(req, res){ return MCP.rpcHandler(req, res); });
600
+ }
601
+ JOE._mcpInitialized = true;
602
+ console.log('[MCP] routes attached');
603
+ } catch (e) {
604
+ console.log('[MCP] init error:', e);
605
+ }
606
+ };