json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
915 lines (856 loc) • 34 kB
JavaScript
// modules/MCP.js
/**
* Model Context Protocol (MCP) core module for JOE.
* This module provides a JSON-RPC 2.0 compatible interface to JOE objects and schemas.
* Agents (like OpenAI Assistants) can discover and call structured tools via manifest + POST.
*/
const MCP = {};
const ThoughtPipeline = require('./ThoughtPipeline');
// Internal helpers
function getStorage() {
return (global.JOE && global.JOE.Storage) || null;
}
function getSchemas() {
return (global.JOE && global.JOE.Schemas) || null;
}
function loadFromStorage(collection, query) {
return new Promise((resolve, reject) => {
try {
const Storage = getStorage();
if (!Storage) return reject(new Error('Storage module not initialized'));
Storage.load(collection, query || {}, function(err, results){
if (err) return reject(err);
resolve(results || []);
});
} catch (e) {
reject(e);
}
});
}
function sanitizeItems(items) {
try {
const arr = Array.isArray(items) ? items : [items];
return arr.map(i => {
if (!i || typeof i !== 'object') return i;
const copy = JSON.parse(JSON.stringify(i));
if (copy.password) copy.password = null;
if (copy.token) copy.token = null;
return copy;
});
} catch (e) {
return Array.isArray(items) ? items : [items];
}
}
// Resolve simple dotted paths against an object, including arrays.
// Example: getPathValues(recipe, "ingredients.id") → ["ing1","ing2",...]
function getPathValues(root, path) {
if (!root || !path) return [];
const parts = String(path).split('.');
let current = [root];
for (let i = 0; i < parts.length; i++) {
const key = parts[i];
const next = [];
for (let j = 0; j < current.length; j++) {
const val = current[j];
if (val == null) continue;
if (Array.isArray(val)) {
val.forEach(function (item) {
if (item && Object.prototype.hasOwnProperty.call(item, key)) {
next.push(item[key]);
}
});
} else if (Object.prototype.hasOwnProperty.call(val, key)) {
next.push(val[key]);
}
}
current = next;
if (!current.length) break;
}
// Flatten one level in case the last hop produced arrays
const out = [];
current.forEach(function (v) {
if (Array.isArray(v)) {
v.forEach(function (x) { if (x != null) out.push(x); });
} else if (v != null) {
out.push(v);
}
});
return out;
}
// Best-effort helper to get a normalized schema summary for a given name.
// Prefers the precomputed `Schemas.summary[name]` map, but falls back to
// `Schemas.schema[name].summary` when the summary map has not been generated
// or that particular schema has not yet been merged in.
function getSchemaSummary(name) {
if (!name) return null;
const Schemas = getSchemas();
if (!Schemas) return null;
if (Schemas.summary && Schemas.summary[name]) {
return Schemas.summary[name];
}
if (Schemas.schema && Schemas.schema[name] && Schemas.schema[name].summary) {
return Schemas.schema[name].summary;
}
return null;
}
function getComparable(val){
if (val == null) return null;
// Date-like
var d = new Date(val);
if (!isNaN(d.getTime())) return d.getTime();
if (typeof val === 'number') return val;
return (val+'').toLowerCase();
}
function sortItems(items, sortBy, sortDir){
if (!Array.isArray(items) || !sortBy) return items;
var dir = (sortDir === 'asc') ? 1 : -1;
return items.slice().sort(function(a,b){
var av = getComparable(a && a[sortBy]);
var bv = getComparable(b && b[sortBy]);
if (av == null && bv == null) return 0;
if (av == null) return 1; // nulls last
if (bv == null) return -1;
if (av > bv) return 1*dir;
if (av < bv) return -1*dir;
return 0;
});
}
function toSlim(item){
var name = (item && (item.name || item.title || item.label || item.email || item.slug)) || (item && item._id) || '';
var info = (item && (item.info || item.description || item.summary)) || '';
return {
_id: item && item._id,
itemtype: item && item.itemtype,
name: name,
info: info,
joeUpdated: item && (item.joeUpdated || item.updated || item.modified),
created: item && item.created
};
}
// ----------------------
// TOOL DEFINITIONS
// ----------------------
// This object maps tool names to actual execution functions.
// Each function takes a `params` object and returns a JSON-serializable result.
MCP.tools = {
// List all schema names in the system
listSchemas: async (_params, _ctx) => {
var Schemas = getSchemas();
const list = (Schemas && (
(Schemas.schemaList && Schemas.schemaList.length && Schemas.schemaList) ||
(Schemas.schema && Object.keys(Schemas.schema))
)) || [];
return list.slice().sort();
},
// Get schema definition; summaryOnly to return normalized summary instead of full schema
getSchema: async ({ name, summaryOnly }, _ctx) => {
if (!name) throw new Error("Missing required param 'name'");
const Schemas = getSchemas();
if (!Schemas) throw new Error('Schemas module not initialized');
if (summaryOnly) {
const sum = Schemas && Schemas.summary && Schemas.summary[name];
if (!sum) throw new Error(`Schema summary for "${name}" not found`);
return sum;
}
const def = Schemas.schema && Schemas.schema[name];
if (!def) throw new Error(`Schema "${name}" not found`);
return def;
},
// Get multiple schemas. With summaryOnly, return summaries; without names, returns all.
getSchemas: async ({ names, summaryOnly } = {}, _ctx) => {
const Schemas = getSchemas();
if (!Schemas) throw new Error('Schemas module not initialized');
const all = (Schemas && Schemas.schema) || {};
if (summaryOnly) {
const allS = (Schemas && Schemas.summary) || {};
if (Array.isArray(names) && names.length) {
const outS = {};
names.forEach(function(n){ if (allS[n]) { outS[n] = allS[n]; } });
return outS;
}
return allS;
} else {
if (Array.isArray(names) && names.length) {
const out = {};
names.forEach(function(n){ if (all[n]) { out[n] = all[n]; } });
return out;
}
return all;
}
},
// Convenience: fetch a single object by _id (itemtype optional). Prefer cache; fallback to storage.
// Accepts legacy alias 'schema' for itemtype.
getObject: async ({ _id, itemtype, schema, flatten = false, depth = 1 }, _ctx) => {
if (!_id) throw new Error("Missing required param '_id'");
itemtype = itemtype || schema; // legacy alias
// Fast path via global lookup
let obj = (JOE && JOE.Cache && JOE.Cache.findByID) ? (JOE.Cache.findByID(_id) || null) : null;
if (!obj && itemtype) {
const results = await loadFromStorage(itemtype, { _id });
obj = (results && results[0]) || null;
}
if (!obj && itemtype && JOE && JOE.Cache && JOE.Cache.findByID) {
obj = JOE.Cache.findByID(itemtype, _id) || null;
}
if (!obj) throw new Error(`Object not found${itemtype?(' in '+itemtype):''} with _id: ${_id}`);
if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
try { return sanitizeItems(JOE.Utils.flattenObject(_id, { recursive: true, depth }))[0]; } catch(e) {}
}
return sanitizeItems(obj)[0];
},
/**
* understandObject
*
* High-level helper for agents: given an _id (and optional itemtype),
* returns a rich payload combining:
* - object: the raw object (ids intact)
* - flattened: the same object flattened to a limited depth
* - schemas: a map of schema summaries for the main itemtype and any
* referenced itemtypes (keyed by schema name)
* - related: an array of referenced objects discovered via outbound
* relationships in the schema summary.
*
* When `slim` is false (default), each related entry includes both `object`
* and `flattened`. When `slim` is true, only the main object is flattened
* and related entries are reduced to slim references:
* { field, _id, itemtype, object: { _id, itemtype, name, info } }
*
* Agents should prefer this tool when they need to understand or work with
* an object by id, instead of issuing many individual getObject / getSchema
* calls. The original object always keeps its reference ids; expanded views
* live under `flattened` and `related[*]`.
*/
understandObject: async ({ _id, itemtype, schema, depth = 2, slim = false } = {}, _ctx) => {
if (!_id) throw new Error("Missing required param '_id'");
itemtype = itemtype || schema;
// Base object (sanitized) without flattening
const base = await MCP.tools.getObject({ _id, itemtype, flatten: false }, _ctx);
const mainType = base.itemtype || itemtype || null;
const result = {
_id: base._id,
itemtype: mainType,
object: base,
flattened: null,
schemas: {},
related: [],
// Deduped lookups for global reference types
tags: {},
statuses: {},
slim: !!slim
};
// Main schema summary
const mainSummary = getSchemaSummary(mainType);
if (mainType && mainSummary) {
result.schemas[mainType] = mainSummary;
}
// Flattened view of the main object (depth-limited)
if (JOE && JOE.Utils && JOE.Utils.flattenObject) {
try {
const flat = JOE.Utils.flattenObject(base._id, { recursive: true, depth });
result.flattened = sanitizeItems(flat)[0];
} catch (_e) {
result.flattened = null;
}
}
const seenSchemas = new Set(Object.keys(result.schemas || {}));
function addSchemaIfPresent(name) {
if (!name || seenSchemas.has(name)) return;
const sum = getSchemaSummary(name);
if (sum) {
result.schemas[name] = sum;
seenSchemas.add(name);
}
}
// Discover outbound relationships from schema summary
const schemaSummary = mainType && getSchemaSummary(mainType);
const outbound = (schemaSummary &&
schemaSummary.relationships &&
Array.isArray(schemaSummary.relationships.outbound))
? schemaSummary.relationships.outbound
: [];
for (let i = 0; i < outbound.length; i++) {
const rel = outbound[i] || {};
const field = rel.field;
const targetSchema = rel.targetSchema;
if (!field || !targetSchema) continue;
// Support nested paths like "ingredients.id" coming from objectList fields.
const vals = getPathValues(base, field);
if (!vals || !vals.length) continue;
const ids = Array.isArray(vals) ? vals : [vals];
for (let j = 0; j < ids.length; j++) {
const rid = ids[j];
if (!rid) continue;
let robj = null;
try {
robj = await MCP.tools.getObject({ _id: rid, itemtype: targetSchema, flatten: false }, _ctx);
} catch (_e) {
continue;
}
if (!robj) continue;
const rType = robj.itemtype || targetSchema;
// Global reference types (tag/status) go into top-level lookup maps
if (rType === 'tag' || rType === 'status') {
const mapName = (rType === 'tag') ? 'tags' : 'statuses';
if (!result[mapName][robj._id]) {
result[mapName][robj._id] = {
_id: robj._id,
itemtype: rType,
name: robj.name || robj.label || robj.info || ''
};
}
} else {
if (slim) {
const slimObj = toSlim(robj);
result.related.push({
field,
_id: slimObj._id,
itemtype: slimObj.itemtype || rType,
object: {
_id: slimObj._id,
itemtype: slimObj.itemtype || rType,
name: slimObj.name,
info: slimObj.info
}
});
} else {
let rflat = null;
if (JOE && JOE.Utils && JOE.Utils.flattenObject) {
try {
const f = JOE.Utils.flattenObject(robj._id, { recursive: true, depth: Math.max(1, depth - 1) });
rflat = sanitizeItems(f)[0];
} catch (_e) {
rflat = null;
}
}
result.related.push({
field,
_id: robj._id,
itemtype: rType,
object: robj,
flattened: rflat
});
}
}
addSchemaIfPresent(rType || targetSchema);
}
}
return result;
},
/* Deprecated: use unified 'search' instead
getObjectsByIds: async () => { throw new Error('Use search with ids instead'); },
queryObjects: async () => { throw new Error('Use search instead'); },
*/
/* Deprecated: use unified 'search' instead
searchCache: async () => { throw new Error('Use search instead'); },
*/
// Unified search: defaults to cache; set source="storage" to query DB for a given itemtype
// Accepts legacy alias 'schema' for itemtype.
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) => {
itemtype = itemtype || schema; // legacy alias
const useCache = !source || source === 'cache';
const useStorage = source === 'storage';
if (ids && !Array.isArray(ids)) throw new Error("'ids' must be an array if provided");
// When ids are provided and an itemtype is known, prefer cache for safety/speed
if (Array.isArray(ids) && itemtype) {
let items = [];
if (JOE && JOE.Cache && JOE.Cache.findByID) {
const found = JOE.Cache.findByID(itemtype, ids.join(',')) || [];
items = Array.isArray(found) ? found : (found ? [found] : []);
}
if (useStorage && (!items || items.length === 0)) {
try {
const fromStorage = await loadFromStorage(itemtype, { _id: { $in: ids } });
items = fromStorage || [];
} catch (e) { /* ignore storage errors here */ }
}
if (flatten && !slim && JOE && JOE.Utils && JOE.Utils.flattenObject) {
try { items = ids.map(id => JOE.Utils.flattenObject(id, { recursive: true, depth })); } catch (e) {}
}
items = sortItems(items, sortBy, sortDir);
if (countOnly) {
return { count: (items || []).length };
}
const total = (items || []).length;
const start = Math.max(0, parseInt(offset || 0, 10) || 0);
const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
const sliced = (typeof end === 'number') ? items.slice(start, end) : items.slice(start);
const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
return withCount ? { items: payload, count: total } : { items: payload };
}
// No ids: choose source
if (useCache) {
if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
let results = JOE.Cache.search(query || {});
if (itemtype) results = (results || []).filter(i => i && i.itemtype === itemtype);
results = sortItems(results, sortBy, sortDir);
if (countOnly) {
return { count: (results || []).length };
}
const total = (results || []).length;
const start = Math.max(0, parseInt(offset || 0, 10) || 0);
const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
const sliced = (typeof end === 'number') ? results.slice(start, end) : results.slice(start);
const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
return withCount ? { items: payload, count: total } : { items: payload };
}
if (useStorage) {
if (!itemtype) throw new Error("'itemtype' is required when source=storage");
const results = await loadFromStorage(itemtype, query || {});
let sorted = sortItems(results, sortBy, sortDir);
if (countOnly) {
return { count: (sorted || []).length };
}
const total = (sorted || []).length;
const start = Math.max(0, parseInt(offset || 0, 10) || 0);
const end = (typeof limit === 'number' && limit > 0) ? (start + limit) : undefined;
const sliced = (typeof end === 'number') ? (sorted || []).slice(start, end) : (sorted || []).slice(start);
if (flatten && !slim && JOE && JOE.Utils && JOE.Utils.flattenObject) {
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) {}
}
const payload = slim ? (sliced || []).map(toSlim) : sanitizeItems(sliced);
return withCount ? { items: payload, count: total } : { items: payload };
}
throw new Error("Invalid 'source'. Use 'cache' (default) or 'storage'.");
},
// Fuzzy, typo-tolerant search over cache with weighted fields
// Accepts legacy alias 'schema' for itemtype.
fuzzySearch: async ({ itemtype, schema, q, filters = {}, fields, threshold = 0.35, limit = 50, offset = 0, highlight = false, minQueryLength = 2 }, _ctx) => {
itemtype = itemtype || schema; // legacy alias
if (!q || (q+'').length < (minQueryLength||2)) {
return { items: [] };
}
if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
var query = Object.assign({}, filters || {});
query.$fuzzy = { q, fields, threshold, limit, offset, highlight, minQueryLength };
var results = JOE.Cache.search(query) || [];
if (itemtype) { results = results.filter(function(i){ return i && i.itemtype === itemtype; }); }
var total = (typeof results.count === 'number') ? results.count : results.length;
if (typeof limit === 'number' && limit > 0) { results = results.slice(0, limit); }
return { items: sanitizeItems(results), count: total };
},
// Save an object via Storage (respects events/history)
saveObject: async ({ object }, ctx = {}) => {
const Storage = getStorage();
if (!Storage) throw new Error('Storage module not initialized');
if (!object || !object.itemtype) throw new Error("'object' with 'itemtype' is required");
const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
// Ensure server-side update timestamp parity with /API/save
if (!object.joeUpdated) { object.joeUpdated = new Date().toISOString(); }
// Ensure a stable _id for new objects so history and events work consistently
try {
if (!object._id) { object._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); }
} catch(_e) { /* ignore id generation errors; Mongo will assign one */ }
const saved = await new Promise((resolve, reject) => {
try {
Storage.save(object, object.itemtype, function(err, data){
if (err) return reject(err);
resolve(data);
}, { user, history: true });
} catch (e) { reject(e); }
});
return sanitizeItems(saved)[0];
},
// Batch save with bounded concurrency; preserves per-item history/events
saveObjects: async ({ objects, stopOnError = false, concurrency = 5 } = {}, ctx = {}) => {
const Storage = getStorage();
if (!Storage) throw new Error('Storage module not initialized');
if (!Array.isArray(objects) || objects.length === 0) {
throw new Error("'objects' (non-empty array) is required");
}
const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
const size = Math.max(1, parseInt(concurrency, 10) || 5);
const results = new Array(objects.length);
const errors = [];
let cancelled = false;
async function saveOne(obj, index){
if (cancelled) return;
if (!obj || !obj.itemtype) {
const errMsg = "Each object must include 'itemtype'";
errors.push({ index, error: errMsg });
if (stopOnError) { cancelled = true; }
return;
}
if (!obj.joeUpdated) { obj.joeUpdated = new Date().toISOString(); }
try {
if (!obj._id) {
try { obj._id = (typeof cuid === 'function') ? cuid() : ($c && $c.guid ? $c.guid() : undefined); } catch(_e) {}
}
const saved = await new Promise((resolve, reject) => {
try {
Storage.save(obj, obj.itemtype, function(err, data){
if (err) return reject(err);
resolve(data);
}, { user, history: true });
} catch (e) { reject(e); }
});
results[index] = sanitizeItems(saved)[0];
} catch (e) {
errors.push({ index, error: e && e.message ? e.message : (e+'' ) });
if (stopOnError) { cancelled = true; }
}
}
// Simple promise pool
let cursor = 0;
const runners = new Array(Math.min(size, objects.length)).fill(0).map(async function(){
while (!cancelled && cursor < objects.length) {
const idx = cursor++;
await saveOne(objects[idx], idx);
if (stopOnError && cancelled) break;
}
});
await Promise.all(runners);
const saved = results.filter(function(x){ return !!x; }).length;
const failed = errors.length;
return { results: sanitizeItems(results.filter(function(x){ return !!x; })), errors, saved, failed };
},
// Hydration: surface core fields (from file), all schemas, statuses, and tags (no params)
hydrate: async (_params, _ctx) => {
var Schemas = getSchemas();
let coreDef = (JOE && JOE.Fields && JOE.Fields['core']) || null;
if (!coreDef) {
try { coreDef = require(__dirname + '/../fields/core.js'); } catch (_e) { coreDef = {}; }
}
const coreFields = Object.keys(coreDef || {}).map(name => ({
name,
definition: coreDef[name]
}));
const payload = {
coreFields,
schemas: Object.keys(Schemas?.schema || {}),
schemaSummary: (Schemas && Schemas.summary) || {},
schemaSummaryGeneratedAt: (Schemas && Schemas.summaryGeneratedAt) || null
};
payload.statuses = sanitizeItems(JOE.Data?.status || []);
payload.tags = sanitizeItems(JOE.Data?.tag || [])
return payload;
},
// List app definitions in a sanitized/summarized form
listApps: async (_params, _ctx) => {
const apps = (JOE && JOE.Apps && JOE.Apps.cache) || {};
const names = Object.keys(apps || {});
const summarized = {};
names.forEach(function(name){
try {
const app = apps[name] || {};
summarized[name] = {
title: app.title || name,
description: app.description || '',
collections: Array.isArray(app.collections) ? app.collections : [],
plugins: Array.isArray(app.plugins) ? app.plugins : []
};
} catch(_e) {
summarized[name] = { title: name, description: '' };
}
});
return { names, apps: summarized };
},
// Compile a named pipeline into a deterministic prompt payload.
compilePipeline: async ({ pipeline_id, scope_id, user_input } = {}, _ctx) => {
const compiled = await ThoughtPipeline.compile(pipeline_id, scope_id, { user_input });
return compiled;
},
// Run a thought agent (pipeline + Responses API) and materialize proposed Thoughts.
runThoughtAgent: async ({ agent_id, user_input, scope_id } = {}, ctx = {}) => {
const result = await ThoughtPipeline.runAgent(agent_id, user_input, scope_id, ctx);
return result;
}
// 🔧 Add more tools here as needed
};
// ----------------------
// METADATA FOR TOOLS
// ----------------------
// These are used to auto-generate the MCP manifest from the function registry
MCP.descriptions = {
listSchemas: "List all available JOE schema names.",
getSchema: "Retrieve schema by name. Set summaryOnly=true for normalized summary instead of full schema.",
getSchemas: "Retrieve multiple schemas. With summaryOnly=true, returns summaries; if names omitted, returns all.",
getObject: "Fetch a single object by _id (itemtype optional). Supports optional flatten. Accepts legacy alias 'schema' for itemtype.",
// getObjectsByIds: "Deprecated - use 'search' with ids.",
// queryObjects: "Deprecated - use 'search'.",
// searchCache: "Deprecated - use 'search'.",
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.",
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' } }.",
saveObject: "Create/update an object; triggers events/history.",
saveObjects: "Batch save objects with bounded concurrency; per-item history/events preserved.",
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.",
listApps: "List app definitions (title, description, collections, plugins).",
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.",
compilePipeline: "Compile a named pipeline (e.g., the default Thought pipeline) into a deterministic prompt payload with system_context, user_context, and attachments.steps.",
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."
};
MCP.params = {
listSchemas: {},
getSchema: {
type: "object",
properties: {
name: { type: "string" },
summaryOnly: { type: "boolean" }
},
required: ["name"]
},
getSchemas: {
type: "object",
properties: {
names: { type: "array", items: { type: "string" } },
summaryOnly: { type: "boolean" }
},
required: []
},
getObject: {
type: "object",
properties: {
_id: { type: "string" },
itemtype: { type: "string" },
flatten: { type: "boolean" },
depth: { type: "integer" }
},
required: ["_id"]
},
// getObjectsByIds: { ...deprecated },
// queryObjects: { ...deprecated },
// searchCache: { ...deprecated },
search: {
type: "object",
properties: {
itemtype: { type: "string" },
query: { type: "object" },
ids: { type: "array", items: { type: "string" } },
source: { type: "string", enum: ["cache","storage"] },
limit: { type: "integer" },
offset: { type: "integer" },
flatten: { type: "boolean" },
depth: { type: "integer" },
countOnly: { type: "boolean" },
withCount: { type: "boolean" },
sortBy: { type: "string" },
sortDir: { type: "string", enum: ["asc","desc"] },
slim: { type: "boolean" }
},
required: []
},
understandObject: {
type: "object",
properties: {
_id: { type: "string" },
itemtype: { type: "string" },
schema: { type: "string" },
depth: { type: "integer" },
slim: { type: "boolean" }
},
required: ["_id"]
},
fuzzySearch: {
type: "object",
properties: {
itemtype: { type: "string" },
q: { type: "string" },
filters: { type: "object" },
fields: {
type: "array",
items: {
anyOf: [
{ type: "string" },
{ type: "object", properties: { path: { type: "string" }, weight: { type: "number" } }, required: ["path"] }
]
}
},
threshold: { type: "number" },
limit: { type: "integer" },
offset: { type: "integer" },
highlight: { type: "boolean" },
minQueryLength: { type: "integer" }
},
required: ["q"]
},
saveObject: {
type: "object",
properties: {
object: { type: "object" }
},
required: ["object"]
},
saveObjects: {
type: "object",
properties: {
objects: { type: "array", items: { type: "object" } },
stopOnError: { type: "boolean" },
concurrency: { type: "integer" }
},
required: ["objects"]
},
hydrate: { type: "object", properties: {} }
,
listApps: { type: "object", properties: {} },
compilePipeline: {
type: "object",
properties: {
pipeline_id: { type: "string" },
scope_id: { type: "string" },
user_input: { type: "string" }
},
required: []
},
runThoughtAgent: {
type: "object",
properties: {
agent_id: { type: "string" },
user_input: { type: "string" },
scope_id: { type: "string" }
},
required: ["user_input"]
}
};
MCP.returns = {
listSchemas: {
type: "array",
items: { type: "string" }
},
getSchema: { type: "object" },
getSchemas: { type: "object" },
getObject: { type: "object" },
// getObjectsByIds: { ...deprecated },
// queryObjects: { ...deprecated },
// searchCache: { ...deprecated },
search: {
type: "object",
properties: {
items: { type: "array", items: { type: "object" } }
}
},
fuzzySearch: {
type: "object",
properties: {
items: { type: "array", items: { type: "object" } },
count: { type: "integer" }
}
},
// When countOnly is true, search returns { count }
saveObject: { type: "object" },
saveObjects: {
type: "object",
properties: {
results: { type: "array", items: { type: "object" } },
errors: { type: "array", items: { type: "object" } },
saved: { type: "integer" },
failed: { type: "integer" }
}
},
hydrate: { type: "object" },
listApps: {
type: "object",
properties: {
names: { type: "array", items: { type: "string" } },
apps: { type: "object" }
}
},
compilePipeline: {
type: "object",
properties: {
pipeline_id: { type: "string" },
scope_id: { type: "string" },
system_context: { type: "string" },
developer_context: { type: "string" },
user_context: { type: "string" },
attachments: { type: "object" }
}
},
runThoughtAgent: {
type: "object",
properties: {
agent_id: { type: "string" },
pipeline_id: { type: "string" },
scope_id: { type: "string" },
ai_response_id: { type: "string" },
proposed_thoughts_count: { type: "integer" },
saved_thought_ids: {
type: "array",
items: { type: "string" }
},
questions: {
type: "array",
items: { type: "string" }
},
missing_info: {
type: "array",
items: { type: "string" }
},
raw_response: { type: "string" }
}
}
};
// ----------------------
// MANIFEST HANDLER
// ----------------------
// Responds to GET /.well-known/mcp/manifest.json
// Returns tool descriptions for agent discovery
MCP.manifest = async function (req, res) {
try {
const toolNames = Object.keys(MCP.tools);
const tools = toolNames.map(name => ({
name,
description: MCP.descriptions[name],
params: MCP.params[name],
returns: MCP.returns[name]
}));
const joe = {
name: (JOE && JOE.webconfig && JOE.webconfig.name) || 'JOE',
version: (JOE && JOE.VERSION) || '',
hostname: (JOE && JOE.webconfig && JOE.webconfig.hostname) || ''
};
const base = req.protocol+':'+(req.get('host')||'');
const privacyUrl = base+'/'+'privacy';
const termsUrl = base+'/'+'terms';
return res.json({ version: "1.0", joe, privacy_policy_url: privacyUrl, terms_of_service_url: termsUrl, tools });
} catch (e) {
console.log('[MCP] manifest error:', e);
return res.status(500).json({ error: e.message || 'manifest error' });
}
};
// ----------------------
// JSON-RPC HANDLER
// ----------------------
// Responds to POST /mcp with JSON-RPC 2.0 calls
MCP.rpcHandler = async function (req, res) {
const { id, method, params } = req.body;
// Validate method
if (!MCP.tools[method]) {
return res.status(400).json({
jsonrpc: "2.0",
id,
error: { code: -32601, message: "Method not found" }
});
}
try {
const result = await MCP.tools[method](params, { req, res });
return res.json({ jsonrpc: "2.0", id, result });
} catch (err) {
console.error(`[MCP] Error in method ${method}:`, err);
return res.status(500).json({
jsonrpc: "2.0",
id,
error: { code: -32000, message: err.message || "Internal error" }
});
}
};
module.exports = MCP;
// Optional initializer to attach routes without modifying Server.js
MCP.init = function initMcpRoutes(){
try {
if (!global.JOE || !JOE.Server) return;
if (JOE._mcpInitialized) return;
const server = JOE.Server;
const auth = JOE.auth; // may be undefined for manifest
server.get('/.well-known/mcp/manifest.json', function(req, res){
return MCP.manifest(req, res);
});
if (auth) {
server.post('/mcp', auth, function(req, res){ return MCP.rpcHandler(req, res); });
} else {
server.post('/mcp', function(req, res){ return MCP.rpcHandler(req, res); });
}
JOE._mcpInitialized = true;
console.log('[MCP] routes attached');
} catch (e) {
console.log('[MCP] init error:', e);
}
};