chore: fork with local patches for Syslog Solution
Patches applied: - enforceRelayTTL — auto-deletes expired relay nodes (>7d) - updateNode — tenant enforcement (block updates on NULL-namespace nodes) - buildCanonicalMetadata — reject nested objects, sanitize values - createNode — source-based and title-based deduplication - getContext — NPE-safe count queries, stale node detection - repair_log integration on updateNode - bridge-validation.js — auto-fill defaults for tenant/namespace Origin: github.com/bradwmorris/ra-h_os@70e95e7
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
const { validationMiddleware } = require("./bridge-validation.js");
|
||||
|
||||
// Check Node version early — better-sqlite3 native bindings don't support bleeding-edge Node
|
||||
const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
|
||||
@@ -26,7 +26,7 @@ try {
|
||||
|
||||
const { z } = require('zod');
|
||||
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
||||
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
|
||||
@@ -105,7 +105,7 @@ const addNodeInputSchema = {
|
||||
};
|
||||
|
||||
const searchNodesInputSchema = {
|
||||
query: z.string().min(1).max(400).describe('Search query'),
|
||||
query: z.string().max(400).optional().describe('Search query'),
|
||||
limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'),
|
||||
created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
||||
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
||||
@@ -184,7 +184,8 @@ function sanitizeFtsQuery(input) {
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(w))
|
||||
.join(' ');
|
||||
.map(w => `"${w}"`)
|
||||
.join(' OR ');
|
||||
}
|
||||
|
||||
let _ftsAvailability = null;
|
||||
@@ -235,10 +236,6 @@ function log(...args) {
|
||||
console.error('[ra-h-standalone]', ...args);
|
||||
}
|
||||
|
||||
function withVisibleJson(summary, payload) {
|
||||
return `${summary}\n\n${JSON.stringify(payload, null, 2)}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Initialize database
|
||||
try {
|
||||
@@ -311,7 +308,7 @@ async function main() {
|
||||
: result.reason;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: result.shouldRetrieve ? withVisibleJson(summary, result) : summary }],
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: result
|
||||
};
|
||||
}
|
||||
@@ -328,12 +325,17 @@ async function main() {
|
||||
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
|
||||
const normalizedDescription = typeof description === 'string' ? description.trim() : description;
|
||||
|
||||
// LINT GUARD: Reject writes with metadata.tenant (deprecated — use namespace)
|
||||
if (metadata && metadata.tenant) {
|
||||
throw new Error('metadata.tenant is deprecated. Use metadata.namespace only.');
|
||||
}
|
||||
const r = validationMiddleware("createNode", { title, source, link, description, metadata }); if (!r.success) throw new Error(r.errors.join(", "));
|
||||
const node = nodeService.createNode({
|
||||
title: title.trim(),
|
||||
source: sourceText,
|
||||
link: link?.trim(),
|
||||
description: normalizedDescription,
|
||||
metadata: metadata || {}
|
||||
metadata: r.payload?.metadata || {}
|
||||
});
|
||||
|
||||
const summary = `Created node #${node.id}: ${node.title}`;
|
||||
@@ -359,7 +361,7 @@ async function main() {
|
||||
async ({ query: searchQuery, limit = 10, created_after, created_before, event_after, event_before }) => {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 50);
|
||||
const result = directNodeLookup({
|
||||
search: searchQuery.trim(),
|
||||
search: (searchQuery ?? "").trim(),
|
||||
limit: safeLimit,
|
||||
createdAfter: created_after,
|
||||
createdBefore: created_before,
|
||||
@@ -367,28 +369,26 @@ async function main() {
|
||||
eventBefore: event_before,
|
||||
});
|
||||
|
||||
const mappedNodes = result.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
source: node.source ?? null,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
event_date: node.event_date ?? null,
|
||||
}));
|
||||
const payload = {
|
||||
count: result.count,
|
||||
filters_applied: result.filtersApplied,
|
||||
nodes: mappedNodes
|
||||
};
|
||||
const summary = result.count === 0
|
||||
? 'No nodes found matching that query.'
|
||||
: `Found ${result.count} node(s).`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: result.count === 0 ? summary : withVisibleJson(summary, payload) }],
|
||||
structuredContent: payload
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
count: result.count,
|
||||
filters_applied: result.filtersApplied,
|
||||
nodes: result.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
source: node.source ?? null,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
event_date: node.event_date ?? null,
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -470,7 +470,32 @@ async function main() {
|
||||
: mappedUpdates.description;
|
||||
}
|
||||
|
||||
const node = nodeService.updateNode(id, mappedUpdates);
|
||||
|
||||
// === TENANT ENFORCEMENT: verify node has namespace/tenant before allowing update ===
|
||||
const checkNode = nodeService.getNodeById(id);
|
||||
if (checkNode) {
|
||||
let meta = checkNode.metadata || {};
|
||||
let tenant = null;
|
||||
if (typeof meta === 'string') {
|
||||
try { meta = JSON.parse(meta); } catch(e) { meta = {}; }
|
||||
}
|
||||
if (!meta) meta = {};
|
||||
tenant = meta.namespace || null;
|
||||
// Bootstrap: allow updates that SET namespace even on NULL-namespace nodes
|
||||
// Handle both flat and nested metadata formats
|
||||
const metaUpdate = mappedUpdates.metadata || {};
|
||||
const isSettingNamespace = metaUpdate.namespace && (
|
||||
typeof metaUpdate.namespace === 'string' ||
|
||||
(typeof metaUpdate.namespace === 'object' && metaUpdate.namespace.value)
|
||||
);
|
||||
if (!tenant && !isSettingNamespace) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `ERROR: Node #${id} has no namespace. Blocked by namespace enforcement. Set metadata.namespace in this update.` }],
|
||||
structuredContent: { success: false, nodeId: id, error: 'namespace_required' }
|
||||
};
|
||||
}
|
||||
}
|
||||
const node = nodeService.updateNode(id, mappedUpdates);
|
||||
const message = `Updated node #${id}`;
|
||||
|
||||
return {
|
||||
@@ -497,6 +522,7 @@ async function main() {
|
||||
if (!confirmed_by_user) {
|
||||
throw new Error('createEdge requires explicit user confirmation before writing the relationship.');
|
||||
}
|
||||
const r = validationMiddleware("createEdge", { sourceId, targetId, explanation }); if (!r.success) throw new Error(r.errors.join(", "));
|
||||
|
||||
const edge = edgeService.createEdge({
|
||||
from_node_id: sourceId,
|
||||
@@ -563,7 +589,7 @@ async function main() {
|
||||
from_node_id: e.from_node_id,
|
||||
to_node_id: e.to_node_id,
|
||||
type: e.context?.type ?? null,
|
||||
explanation: e.explanation ?? e.context?.explanation ?? null
|
||||
explanation: e.context?.explanation ?? null
|
||||
}))
|
||||
}
|
||||
};
|
||||
@@ -820,10 +846,99 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// ==================== getAllNodes tool ====================
|
||||
registerToolWithAliases(
|
||||
'getAllNodes',
|
||||
{
|
||||
title: 'Get all RA-H nodes',
|
||||
description: 'Return all nodes in the graph with their metadata. Supports pagination via limit/offset. Use this for bulk operations, audits, and stale node detection. NOT for searching — use queryNodes for keyword search.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().min(1).max(500).optional().default(100).describe('Max nodes to return'),
|
||||
offset: z.number().min(0).optional().default(0).describe('Offset for pagination'),
|
||||
namespace: z.string().optional().describe('Filter by namespace'),
|
||||
tenant: z.string().optional().describe('Filter by tenant (syslogsolution, homelab, shared, personal)'),
|
||||
state: z.string().optional().describe('Filter by state (active, not_processed, etc.)')
|
||||
}).optional().default({})
|
||||
},
|
||||
async ({ limit = 100, offset = 0, namespace, tenant, state }) => {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 500);
|
||||
const safeOffset = Math.max(offset, 0);
|
||||
|
||||
// Build SQL with optional tenant filter
|
||||
let sql = "SELECT n.id, n.title, n.description, n.metadata, n.created_at, n.updated_at FROM nodes n";
|
||||
let countSql = "SELECT COUNT(*) as count FROM nodes n";
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
|
||||
if (tenant) {
|
||||
conditions.push("json_extract(n.metadata, '$.namespace') = ?");
|
||||
params.push(tenant);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
const where = " WHERE " + conditions.join(" AND ");
|
||||
sql += where;
|
||||
countSql += where;
|
||||
}
|
||||
|
||||
sql += " ORDER BY n.updated_at DESC LIMIT " + safeLimit + " OFFSET " + safeOffset;
|
||||
|
||||
const rows = query(sql, params);
|
||||
const totalRow = query(countSql, params);
|
||||
const total = totalRow[0].count;
|
||||
|
||||
let nodes = rows.map(row => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? null,
|
||||
metadata: row.metadata ? (typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
}));
|
||||
|
||||
if (namespace) {
|
||||
nodes = nodes.filter(n =>
|
||||
(n.metadata?.namespace || "").toLowerCase() === namespace.toLowerCase()
|
||||
);
|
||||
}
|
||||
if (state) {
|
||||
nodes = nodes.filter(n =>
|
||||
(n.metadata?.state || "").toLowerCase() === state.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Retrieved " + nodes.length + " of " + total + " node(s)." }],
|
||||
structuredContent: {
|
||||
total,
|
||||
returned: nodes.length,
|
||||
offset: safeOffset,
|
||||
limit: safeLimit,
|
||||
nodes: nodes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
description: n.description ?? null,
|
||||
metadata: n.metadata ?? null,
|
||||
created_at: n.created_at,
|
||||
updated_at: n.updated_at
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Connect transport
|
||||
const transport = new StdioServerTransport();
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
const http = require('http');
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
await server.connect(transport);
|
||||
log('MCP server ready');
|
||||
httpServer.listen(3100, '0.0.0.0', () => {
|
||||
log('HTTP server listening on port 3100');
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
|
||||
Reference in New Issue
Block a user