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:
2026-06-01 09:37:19 -04:00
parent 70e95e7d29
commit 88228a6104
5 changed files with 417 additions and 86 deletions
+146 -31
View File
@@ -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', () => {