prism-mcp-server 19.2.5 → 19.2.7

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.
@@ -558,7 +558,7 @@ return false;}
558
558
  // GET /api/skills → { skills: { dev: "...", qa: "..." } }
559
559
  if (url.pathname === "/api/skills" && req.method === "GET") {
560
560
  const all = await getAllSettings();
561
- const skills = {};
561
+ const skills = Object.create(null);
562
562
  for (const [k, v] of Object.entries(all)) {
563
563
  if (k.startsWith("skill:") && v) {
564
564
  skills[k.replace("skill:", "")] = v;
@@ -3,7 +3,7 @@ import { getStorage } from "../storage/index.js";
3
3
  import { debugLog } from "../utils/logger.js";
4
4
  import { getLLMProvider } from "../utils/llm/factory.js";
5
5
  import { randomUUID } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { performWebSearchRaw } from "../utils/braveApi.js";
@@ -115,20 +115,53 @@ function acquireFileLock() {
115
115
  const lockDir = dirname(LOCK_PATH);
116
116
  if (!existsSync(lockDir))
117
117
  mkdirSync(lockDir, { recursive: true });
118
- if (existsSync(LOCK_PATH)) {
119
- const stat = statSync(LOCK_PATH);
120
- const ageMs = Date.now() - stat.mtimeMs;
121
- if (ageMs < LOCK_STALE_MS)
118
+ const lockContent = `${process.pid}\n${new Date().toISOString()}`;
119
+ // Fast path: exclusive create — no lock exists.
120
+ try {
121
+ writeFileSync(LOCK_PATH, lockContent, { flag: "wx" });
122
+ return true;
123
+ }
124
+ catch (wxErr) {
125
+ if (wxErr?.code !== "EEXIST")
122
126
  return false;
123
- unlinkSync(LOCK_PATH);
124
127
  }
125
- // wx = exclusive create throws EEXIST if another process won the race
126
- writeFileSync(LOCK_PATH, `${process.pid}\n${new Date().toISOString()}`, { flag: "wx" });
128
+ // Lock existscheck if stale.
129
+ let stat;
130
+ try {
131
+ stat = statSync(LOCK_PATH);
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ if (Date.now() - stat.mtimeMs < LOCK_STALE_MS)
137
+ return false;
138
+ // Stale lock — atomic takeover via rename.
139
+ // Write our identity to a temp file, then rename over the lock.
140
+ // rename() is atomic on POSIX; no window where the lock is absent.
141
+ const tmpLock = `${LOCK_PATH}.${process.pid}`;
142
+ try {
143
+ writeFileSync(tmpLock, lockContent, { flag: "wx" });
144
+ renameSync(tmpLock, LOCK_PATH);
145
+ }
146
+ catch {
147
+ try {
148
+ unlinkSync(tmpLock);
149
+ }
150
+ catch { /* cleanup */ }
151
+ return false;
152
+ }
153
+ // Verify we own the lock (another process may have renamed over us).
154
+ try {
155
+ const owner = readFileSync(LOCK_PATH, "utf-8").split("\n")[0];
156
+ if (owner !== String(process.pid))
157
+ return false;
158
+ }
159
+ catch {
160
+ return false;
161
+ }
127
162
  return true;
128
163
  }
129
- catch (err) {
130
- if (err?.code === "EEXIST")
131
- return false;
164
+ catch {
132
165
  return false;
133
166
  }
134
167
  }
package/dist/server.js CHANGED
@@ -76,10 +76,12 @@ import { verifyBehaviorHandler } from "./tools/behavioralVerifierHandler.js";
76
76
  import { getStorage } from "./storage/index.js";
77
77
  import { getSettingSync, initConfigStorage } from "./storage/configStorage.js";
78
78
  import { sanitizeMcpOutput } from "./utils/sanitizer.js";
79
+ import { sanitizeForLog } from "./utils/logger.js";
79
80
  import { getTracer, initTelemetry } from "./utils/telemetry.js";
80
81
  import { context as otelContext, trace, SpanStatusCode } from "@opentelemetry/api";
81
82
  import { ddInfo, ddError as ddLogError } from "./utils/ddLogger.js";
82
83
  import { inferenceMetricsHandler } from "./utils/inferenceMetrics.js";
84
+ import { recordInvocation } from "./utils/analytics.js";
83
85
  // ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
84
86
  import { WEB_SEARCH_TOOL, BRAVE_WEB_SEARCH_CODE_MODE_TOOL, LOCAL_SEARCH_TOOL, BRAVE_LOCAL_SEARCH_CODE_MODE_TOOL, CODE_MODE_TRANSFORM_TOOL, BRAVE_ANSWERS_TOOL, RESEARCH_PAPER_ANALYSIS_TOOL, webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, braveLocalSearchCodeModeHandler, codeModeTransformHandler, braveAnswersHandler, researchPaperAnalysisHandler, } from "./tools/index.js";
85
87
  // Session memory tools — only used if Supabase is configured
@@ -975,7 +977,9 @@ export function createServer() {
975
977
  };
976
978
  }
977
979
  rootSpan.setStatus({ code: SpanStatusCode.OK });
978
- ddInfo("mcp.tool.success", { tool: name, project: args?.project, durationMs: Date.now() - _ddStart });
980
+ const _ddDuration = Date.now() - _ddStart;
981
+ ddInfo("mcp.tool.success", { tool: name, project: args?.project, durationMs: _ddDuration });
982
+ recordInvocation(name, String(args?.project ?? ""), args, JSON.stringify(result?.content?.[0]?.text ?? "").slice(0, 2000), _ddDuration, true);
979
983
  // ═══ v5.3: Hivemind Watchdog Alert Injection (Telepathy) ═══
980
984
  // CRITICAL: Append alerts DIRECTLY to tool response content
981
985
  // so the LLM actually reads them. sendLoggingMessage goes to
@@ -1015,13 +1019,16 @@ export function createServer() {
1015
1019
  return result;
1016
1020
  }
1017
1021
  catch (error) {
1018
- console.error(`Error in tool handler: ${error instanceof Error ? error.message : String(error)}`);
1019
- ddLogError("mcp.tool.error", error instanceof Error ? error : undefined, { tool: name, project: args?.project, durationMs: Date.now() - _ddStart });
1020
- rootSpan.recordException(error instanceof Error ? error : new Error(String(error)));
1022
+ const errMsg = error instanceof Error ? error.message : String(error);
1023
+ const _ddErrDuration = Date.now() - _ddStart;
1024
+ console.error(`Error in tool handler: ${sanitizeForLog(errMsg)}`);
1025
+ ddLogError("mcp.tool.error", error instanceof Error ? error : undefined, { tool: name, project: args?.project, durationMs: _ddErrDuration });
1026
+ rootSpan.recordException(error instanceof Error ? error : new Error(errMsg));
1021
1027
  rootSpan.setStatus({
1022
1028
  code: SpanStatusCode.ERROR,
1023
- message: error instanceof Error ? error.message : String(error),
1029
+ message: errMsg,
1024
1030
  });
1031
+ recordInvocation(name, String(args?.project ?? ""), args, "", _ddErrDuration, false, errMsg);
1025
1032
  return {
1026
1033
  content: [
1027
1034
  {
@@ -2,6 +2,7 @@ import { createClient } from "@libsql/client";
2
2
  import { resolve, dirname } from "path";
3
3
  import { homedir } from "os";
4
4
  import { existsSync, mkdirSync } from "fs";
5
+ const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
5
6
  // We use a small, dedicated DB just for configuration settings.
6
7
  // This solves the chicken-and-egg problem: we need to know WHICH
7
8
  // storage backend to boot *before* we can use that backend.
@@ -22,7 +23,7 @@ let initialized = false;
22
23
  // (e.g. ReadResourceRequestSchema) can read settings synchronously
23
24
  // without opening an additional SQLite round-trip and stalling the
24
25
  // MCP stdio handshake (which causes a black-screen on startup).
25
- let settingsCache = null;
26
+ let settingsCache = null; // assigned as Object.create(null) below
26
27
  function getClient() {
27
28
  if (!configClient) {
28
29
  // Ensure the directory exists before opening the DB.
@@ -52,9 +53,12 @@ export async function initConfigStorage() {
52
53
  `);
53
54
  // Preload all rows into the cache so subsequent reads are zero-cost.
54
55
  const rs = await client.execute("SELECT key, value FROM system_settings");
55
- settingsCache = {};
56
+ settingsCache = Object.create(null);
57
+ const cache = settingsCache;
56
58
  for (const row of rs.rows) {
57
- settingsCache[row.key] = row.value;
59
+ const k = row.key;
60
+ if (!PROTO_KEYS.has(k))
61
+ cache[k] = row.value;
58
62
  }
59
63
  }
60
64
  catch (err) {
@@ -63,7 +67,7 @@ export async function initConfigStorage() {
63
67
  // getSettingSync() will return defaults; getSetting()/setSetting()
64
68
  // will attempt to re-open the DB on first call.
65
69
  console.error(`[configStorage] Failed to initialize (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
66
- settingsCache = {};
70
+ settingsCache = Object.create(null);
67
71
  }
68
72
  initialized = true;
69
73
  }
@@ -93,7 +97,7 @@ export async function getSetting(key, defaultValue = "") {
93
97
  if (rs.rows.length > 0) {
94
98
  const value = rs.rows[0].value;
95
99
  // Populate cache entry for future reads.
96
- if (settingsCache)
100
+ if (settingsCache && !PROTO_KEYS.has(key))
97
101
  settingsCache[key] = value;
98
102
  return value;
99
103
  }
@@ -117,7 +121,7 @@ export async function setSetting(key, value) {
117
121
  args: [key, value],
118
122
  });
119
123
  // Keep the cache in sync so getSettingSync() reflects the new value immediately.
120
- if (settingsCache && typeof key === "string" && !["__proto__", "constructor", "prototype"].includes(key)) {
124
+ if (settingsCache && typeof key === "string" && !PROTO_KEYS.has(key)) {
121
125
  settingsCache[key] = value;
122
126
  }
123
127
  return; // Success — exit
@@ -142,9 +146,11 @@ export async function getAllSettings() {
142
146
  }
143
147
  const client = getClient();
144
148
  const rs = await client.execute("SELECT key, value FROM system_settings");
145
- const settings = {};
149
+ const settings = Object.create(null);
146
150
  for (const row of rs.rows) {
147
- settings[row.key] = row.value;
151
+ const k = row.key;
152
+ if (!PROTO_KEYS.has(k))
153
+ settings[k] = row.value;
148
154
  }
149
155
  return settings;
150
156
  }
@@ -46,7 +46,7 @@ export function applySentinelBlock(existingContent, rulesBlock) {
46
46
  return `${existingContent}${separator}${rulesBlock}\n`;
47
47
  }
48
48
  export function redactSettings(settings) {
49
- const redacted = {};
49
+ const redacted = Object.create(null);
50
50
  for (const [k, v] of Object.entries(settings || {})) {
51
51
  if (typeof k !== "string" || k === "__proto__" || k === "constructor" || k === "prototype")
52
52
  continue;
@@ -1581,7 +1581,8 @@ export async function sessionExportMemoryHandler(args) {
1581
1581
  // Serialize
1582
1582
  const ext = format === "markdown" ? "md" : format === "vault" ? "zip" : "json";
1583
1583
  const safeProject = project.replace(/[^a-zA-Z0-9_-]/g, "_");
1584
- const filename = `prism-export-${safeProject}-${dateSuffix}.${ext}`;
1584
+ const token = randomUUID().slice(0, 8);
1585
+ const filename = `prism-export-${safeProject}-${dateSuffix}-${token}.${ext}`;
1585
1586
  const outputPath = join(output_dir, filename);
1586
1587
  let content;
1587
1588
  if (format === "vault") {
@@ -1611,7 +1612,7 @@ export async function sessionExportMemoryHandler(args) {
1611
1612
  const dot = filename.lastIndexOf(".");
1612
1613
  const stem = dot > 0 ? filename.slice(0, dot) : filename;
1613
1614
  const extPart = dot > 0 ? filename.slice(dot) : "";
1614
- finalPath = join(output_dir, `${stem}-${Date.now()}${extPart}`);
1615
+ finalPath = join(output_dir, `${stem}-${randomUUID()}${extPart}`);
1615
1616
  await writeFile(finalPath, content, { flag: "wx" });
1616
1617
  }
1617
1618
  else {
@@ -114,10 +114,10 @@ export async function extractEntitiesHandler(args) {
114
114
  }
115
115
  // ─── API Analytics Handler ───────────────────────────────────
116
116
  export async function apiAnalyticsHandler(args) {
117
- const { action, project, days } = args;
117
+ const { scope, project, days } = args;
118
118
  try {
119
119
  const analytics = await import("../utils/analytics.js");
120
- if (action === "dashboard" || !action) {
120
+ if (scope === "system" || !scope) {
121
121
  const dashboard = await analytics.getSystemAnalytics(days || 30);
122
122
  return {
123
123
  content: [{
@@ -130,7 +130,7 @@ export async function apiAnalyticsHandler(args) {
130
130
  }],
131
131
  };
132
132
  }
133
- if (action === "project" && project) {
133
+ if (scope === "project" && project) {
134
134
  const projectStats = await analytics.getProjectAnalytics(project, days || 30);
135
135
  return {
136
136
  content: [{
@@ -149,7 +149,7 @@ export async function apiAnalyticsHandler(args) {
149
149
  type: "text",
150
150
  text: JSON.stringify({
151
151
  status: "ok",
152
- message: "Use action='dashboard' for aggregate stats or action='project' with project='name' for per-project stats.",
152
+ message: "Use scope='system' for aggregate stats or scope='project' with project='name' for per-project stats.",
153
153
  }, null, 2),
154
154
  }],
155
155
  };
@@ -1,100 +1,123 @@
1
1
  /**
2
- * v12.2: API Usage Analytics — Per-Project Call Tracking
2
+ * API Usage Analytics — Per-Project Call Tracking
3
3
  *
4
4
  * Tracks every MCP tool invocation with timing, token estimates,
5
- * and project association. Provides aggregate analytics for the
6
- * Mind Palace Dashboard.
5
+ * and project association. Uses @libsql/client (same as the rest
6
+ * of prism's local storage layer).
7
7
  *
8
- * Storage: Local SQLite table `api_analytics` (auto-created).
9
- * Zero external dependencies — no cloud telemetry.
8
+ * Storage: `api_analytics` table in ~/.prism-mcp/data.db
10
9
  */
11
10
  import { debugLog } from "./logger.js";
11
+ import { createClient } from "@libsql/client";
12
+ import { existsSync, mkdirSync } from "node:fs";
12
13
  import { homedir } from "node:os";
13
- import { join } from "node:path";
14
- // Lazy-init helper for SQLite (avoids import issues with storage layer)
15
- function getAnalyticsDbPath() {
16
- return process.env.PRISM_DB_PATH || join(homedir(), ".prism", "prism.db");
14
+ import { join, dirname } from "node:path";
15
+ // ─── DB Connection ──────────────────────────────────────────
16
+ let _db = null;
17
+ let _tableReady = false;
18
+ function getDbPath() {
19
+ return process.env.PRISM_ANALYTICS_DB_PATH
20
+ || join(homedir(), ".prism-mcp", "data.db");
21
+ }
22
+ function getDb() {
23
+ if (_db)
24
+ return _db;
25
+ const dbPath = getDbPath();
26
+ const dir = dirname(dbPath);
27
+ if (!existsSync(dir))
28
+ mkdirSync(dir, { recursive: true });
29
+ _db = createClient({ url: `file:${dbPath}` });
30
+ return _db;
31
+ }
32
+ async function ensureTable() {
33
+ if (_tableReady)
34
+ return;
35
+ await getDb().execute("PRAGMA journal_mode=WAL");
36
+ await getDb().execute(`
37
+ CREATE TABLE IF NOT EXISTS api_analytics (
38
+ id TEXT PRIMARY KEY,
39
+ tool TEXT NOT NULL,
40
+ project TEXT NOT NULL,
41
+ timestamp TEXT NOT NULL,
42
+ duration_ms INTEGER NOT NULL,
43
+ input_tokens INTEGER NOT NULL,
44
+ output_tokens INTEGER NOT NULL,
45
+ success INTEGER NOT NULL,
46
+ error_message TEXT
47
+ )
48
+ `);
49
+ _tableReady = true;
50
+ }
51
+ /** Reset DB connection (for tests). */
52
+ export function _resetDb() {
53
+ _db = null;
54
+ _tableReady = false;
17
55
  }
18
56
  // ─── In-Memory Buffer ────────────────────────────────────────
19
- // Batch writes to SQLite every N invocations or M seconds.
20
57
  const BUFFER = [];
21
58
  const FLUSH_THRESHOLD = 25;
22
59
  const FLUSH_INTERVAL_MS = 30_000;
23
60
  let flushTimer = null;
24
- // ─── Token Estimation ────────────────────────────────────────
25
- // Rough heuristic: 1 token ≈ 4 characters
26
61
  function estimateTokens(text) {
27
62
  return Math.ceil(text.length / 4);
28
63
  }
29
64
  // ─── Recording ───────────────────────────────────────────────
30
- /**
31
- * Record a tool invocation for analytics tracking.
32
- *
33
- * Call this from server.ts after each tool handler completes.
34
- * Uses a write buffer to avoid per-call SQLite overhead.
35
- */
36
- function _unused_recordInvocation(tool, project, args, response, durationMs, success, errorMessage) {
37
- const invocation = {
38
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
39
- tool,
40
- project: project || "unknown",
41
- timestamp: new Date().toISOString(),
42
- durationMs,
43
- inputTokens: estimateTokens(JSON.stringify(args || {})),
44
- outputTokens: estimateTokens(response || ""),
45
- success,
46
- errorMessage,
47
- };
48
- BUFFER.push(invocation);
49
- if (BUFFER.length >= FLUSH_THRESHOLD) {
50
- flushBuffer();
65
+ export function recordInvocation(tool, project, args, response, durationMs, success, errorMessage) {
66
+ // Called before the tool response return in both success and error paths
67
+ // of server dispatch — a throw here would swallow the tool result. Isolate.
68
+ try {
69
+ const invocation = {
70
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
71
+ tool,
72
+ project: project || "unknown",
73
+ timestamp: new Date().toISOString(),
74
+ durationMs,
75
+ inputTokens: estimateTokens(JSON.stringify(args || {})),
76
+ outputTokens: estimateTokens(response || ""),
77
+ success,
78
+ errorMessage,
79
+ };
80
+ BUFFER.push(invocation);
81
+ if (BUFFER.length >= FLUSH_THRESHOLD) {
82
+ void flushBuffer();
83
+ }
84
+ if (!flushTimer) {
85
+ flushTimer = setTimeout(() => {
86
+ flushTimer = null;
87
+ void flushBuffer();
88
+ }, FLUSH_INTERVAL_MS);
89
+ if (typeof flushTimer === "object" && "unref" in flushTimer) {
90
+ flushTimer.unref();
91
+ }
92
+ }
51
93
  }
52
- // Ensure periodic flush
53
- if (!flushTimer) {
54
- flushTimer = setTimeout(() => {
55
- flushBuffer();
56
- flushTimer = null;
57
- }, FLUSH_INTERVAL_MS);
94
+ catch (err) {
95
+ debugLog(`Analytics recordInvocation skipped: ${err}`);
58
96
  }
59
97
  }
60
- /**
61
- * Flush buffered invocations to storage.
62
- */
63
98
  export async function flushBuffer() {
64
99
  if (BUFFER.length === 0)
65
100
  return 0;
66
101
  const batch = BUFFER.splice(0, BUFFER.length);
67
102
  try {
68
- // @ts-ignore — better-sqlite3 types not installed; runtime dep only
69
- const Database = (await import("better-sqlite3")).default;
70
- const db = new Database(getAnalyticsDbPath());
71
- // Ensure table exists
72
- db.exec(`
73
- CREATE TABLE IF NOT EXISTS api_analytics (
74
- id TEXT PRIMARY KEY,
75
- tool TEXT NOT NULL,
76
- project TEXT NOT NULL,
77
- timestamp TEXT NOT NULL,
78
- duration_ms INTEGER NOT NULL,
79
- input_tokens INTEGER NOT NULL,
80
- output_tokens INTEGER NOT NULL,
81
- success INTEGER NOT NULL,
82
- error_message TEXT
83
- )
84
- `);
85
- const stmt = db.prepare(`
86
- INSERT OR IGNORE INTO api_analytics
87
- (id, tool, project, timestamp, duration_ms, input_tokens, output_tokens, success, error_message)
88
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
89
- `);
103
+ await ensureTable();
104
+ const db = getDb();
90
105
  for (const inv of batch) {
91
- stmt.run(inv.id, inv.tool, inv.project, inv.timestamp, inv.durationMs, inv.inputTokens, inv.outputTokens, inv.success ? 1 : 0, inv.errorMessage || null);
106
+ await db.execute({
107
+ sql: `INSERT OR IGNORE INTO api_analytics
108
+ (id, tool, project, timestamp, duration_ms, input_tokens, output_tokens, success, error_message)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
110
+ args: [
111
+ inv.id, inv.tool, inv.project, inv.timestamp,
112
+ inv.durationMs, inv.inputTokens, inv.outputTokens,
113
+ inv.success ? 1 : 0, inv.errorMessage || null,
114
+ ],
115
+ });
92
116
  }
93
- debugLog(`Analytics: flushed ${batch.length} invocations to SQLite`);
117
+ debugLog(`Analytics: flushed ${batch.length} invocations`);
94
118
  return batch.length;
95
119
  }
96
120
  catch (err) {
97
- // Re-add to buffer on failure, cap max size to prevent unbounded growth
98
121
  BUFFER.unshift(...batch);
99
122
  if (BUFFER.length > 1000)
100
123
  BUFFER.splice(1000);
@@ -103,51 +126,51 @@ export async function flushBuffer() {
103
126
  }
104
127
  }
105
128
  // ─── Query Functions ─────────────────────────────────────────
106
- /**
107
- * Get analytics for a specific project.
108
- */
109
129
  export async function getProjectAnalytics(project, days = 30) {
110
- await flushBuffer(); // Ensure latest data is written
130
+ await flushBuffer();
111
131
  try {
112
- // @ts-ignore — better-sqlite3 types not installed; runtime dep only
113
- const Database = (await import("better-sqlite3")).default;
114
- const db = new Database(getAnalyticsDbPath());
115
- const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
116
- const stats = db.prepare(`
117
- SELECT
118
- COUNT(*) as total_calls,
119
- AVG(CASE WHEN success = 1 THEN 1.0 ELSE 0.0 END) as success_rate,
120
- AVG(duration_ms) as avg_duration,
121
- SUM(input_tokens) as total_input,
122
- SUM(output_tokens) as total_output,
123
- MIN(timestamp) as period_start,
124
- MAX(timestamp) as period_end
125
- FROM api_analytics
126
- WHERE project = ? AND timestamp >= ?
127
- `).get(project, since);
128
- const topTools = db.prepare(`
129
- SELECT tool, COUNT(*) as count
130
- FROM api_analytics
131
- WHERE project = ? AND timestamp >= ?
132
- GROUP BY tool ORDER BY count DESC LIMIT 10
133
- `).all(project, since);
134
- const callsByDay = db.prepare(`
135
- SELECT DATE(timestamp) as date, COUNT(*) as count
136
- FROM api_analytics
137
- WHERE project = ? AND timestamp >= ?
138
- GROUP BY DATE(timestamp) ORDER BY date
139
- `).all(project, since);
132
+ await ensureTable();
133
+ const db = getDb();
134
+ const since = new Date(Date.now() - days * 86_400_000).toISOString();
135
+ const stats = await db.execute({
136
+ sql: `SELECT
137
+ COUNT(*) as total_calls,
138
+ AVG(CASE WHEN success = 1 THEN 1.0 ELSE 0.0 END) as success_rate,
139
+ AVG(duration_ms) as avg_duration,
140
+ SUM(input_tokens) as total_input,
141
+ SUM(output_tokens) as total_output,
142
+ MIN(timestamp) as period_start,
143
+ MAX(timestamp) as period_end
144
+ FROM api_analytics
145
+ WHERE project = ? AND timestamp >= ?`,
146
+ args: [project, since],
147
+ });
148
+ const s = stats.rows[0];
149
+ const topTools = await db.execute({
150
+ sql: `SELECT tool, COUNT(*) as count
151
+ FROM api_analytics
152
+ WHERE project = ? AND timestamp >= ?
153
+ GROUP BY tool ORDER BY count DESC LIMIT 10`,
154
+ args: [project, since],
155
+ });
156
+ const callsByDay = await db.execute({
157
+ sql: `SELECT DATE(timestamp) as date, COUNT(*) as count
158
+ FROM api_analytics
159
+ WHERE project = ? AND timestamp >= ?
160
+ GROUP BY DATE(timestamp) ORDER BY date`,
161
+ args: [project, since],
162
+ });
140
163
  return {
141
164
  project,
142
- totalCalls: stats?.total_calls || 0,
143
- successRate: stats?.success_rate || 0,
144
- avgDurationMs: Math.round(stats?.avg_duration || 0),
145
- totalInputTokens: stats?.total_input || 0,
146
- totalOutputTokens: stats?.total_output || 0,
147
- topTools: topTools.map((r) => ({ tool: r.tool, count: r.count })),
148
- callsByDay: callsByDay.map((r) => ({ date: r.date, count: r.count })),
149
- periodStart: stats?.period_start || since,
150
- periodEnd: stats?.period_end || new Date().toISOString(),
165
+ totalCalls: Number(s?.total_calls) || 0,
166
+ successRate: Number(s?.success_rate) || 0,
167
+ avgDurationMs: Math.round(Number(s?.avg_duration) || 0),
168
+ totalInputTokens: Number(s?.total_input) || 0,
169
+ totalOutputTokens: Number(s?.total_output) || 0,
170
+ topTools: topTools.rows.map((r) => ({ tool: r.tool, count: Number(r.count) })),
171
+ callsByDay: callsByDay.rows.map((r) => ({ date: r.date, count: Number(r.count) })),
172
+ periodStart: s?.period_start || since,
173
+ periodEnd: s?.period_end || new Date().toISOString(),
151
174
  };
152
175
  }
153
176
  catch (err) {
@@ -161,37 +184,37 @@ export async function getProjectAnalytics(project, days = 30) {
161
184
  };
162
185
  }
163
186
  }
164
- /**
165
- * Get system-wide analytics across all projects.
166
- */
167
187
  export async function getSystemAnalytics(days = 30) {
168
188
  await flushBuffer();
169
189
  try {
170
- // @ts-ignore — better-sqlite3 types not installed; runtime dep only
171
- const Database = (await import("better-sqlite3")).default;
172
- const db = new Database(getAnalyticsDbPath());
173
- const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
174
- const stats = db.prepare(`
175
- SELECT COUNT(*) as total, COUNT(DISTINCT project) as projects,
176
- AVG(CASE WHEN success = 1 THEN 1.0 ELSE 0.0 END) as success_rate,
177
- AVG(duration_ms) as avg_duration
178
- FROM api_analytics WHERE timestamp >= ?
179
- `).get(since);
180
- const topProjects = db.prepare(`
181
- SELECT project, COUNT(*) as calls FROM api_analytics
182
- WHERE timestamp >= ? GROUP BY project ORDER BY calls DESC LIMIT 10
183
- `).all(since);
184
- const topTools = db.prepare(`
185
- SELECT tool, COUNT(*) as calls FROM api_analytics
186
- WHERE timestamp >= ? GROUP BY tool ORDER BY calls DESC LIMIT 10
187
- `).all(since);
190
+ await ensureTable();
191
+ const db = getDb();
192
+ const since = new Date(Date.now() - days * 86_400_000).toISOString();
193
+ const stats = await db.execute({
194
+ sql: `SELECT COUNT(*) as total, COUNT(DISTINCT project) as projects,
195
+ AVG(CASE WHEN success = 1 THEN 1.0 ELSE 0.0 END) as success_rate,
196
+ AVG(duration_ms) as avg_duration
197
+ FROM api_analytics WHERE timestamp >= ?`,
198
+ args: [since],
199
+ });
200
+ const s = stats.rows[0];
201
+ const topProjects = await db.execute({
202
+ sql: `SELECT project, COUNT(*) as calls FROM api_analytics
203
+ WHERE timestamp >= ? GROUP BY project ORDER BY calls DESC LIMIT 10`,
204
+ args: [since],
205
+ });
206
+ const topTools = await db.execute({
207
+ sql: `SELECT tool, COUNT(*) as calls FROM api_analytics
208
+ WHERE timestamp >= ? GROUP BY tool ORDER BY calls DESC LIMIT 10`,
209
+ args: [since],
210
+ });
188
211
  return {
189
- totalProjects: stats?.projects || 0,
190
- totalCalls: stats?.total || 0,
191
- globalSuccessRate: stats?.success_rate || 0,
192
- avgDurationMs: Math.round(stats?.avg_duration || 0),
193
- topProjects: topProjects.map((r) => ({ project: r.project, calls: r.calls })),
194
- topTools: topTools.map((r) => ({ tool: r.tool, calls: r.calls })),
212
+ totalProjects: Number(s?.projects) || 0,
213
+ totalCalls: Number(s?.total) || 0,
214
+ globalSuccessRate: Number(s?.success_rate) || 0,
215
+ avgDurationMs: Math.round(Number(s?.avg_duration) || 0),
216
+ topProjects: topProjects.rows.map((r) => ({ project: r.project, calls: Number(r.calls) })),
217
+ topTools: topTools.rows.map((r) => ({ tool: r.tool, calls: Number(r.calls) })),
195
218
  callsByHour: [],
196
219
  };
197
220
  }
@@ -202,4 +225,4 @@ export async function getSystemAnalytics(days = 30) {
202
225
  };
203
226
  }
204
227
  }
205
- debugLog("v12.2: API analytics module loaded");
228
+ debugLog("API analytics module loaded (@libsql/client)");
@@ -18,6 +18,7 @@ const CONTEXT_ALLOWLIST = new Set([
18
18
  "ceiling_clamped", "requested_tokens", "effective_tokens", "tokens_clamped",
19
19
  "cloud_requested", "cloud_allowed", "cloud_blocked",
20
20
  "verify_requested", "verify_allowed", "verify_blocked",
21
+ "tool", "project", "success", "durationMs",
21
22
  ]);
22
23
  const queue = [];
23
24
  let flushTimer = null;
@@ -30,7 +30,7 @@ import * as fs from "fs";
30
30
  import * as nodePath from "path";
31
31
  import { getLLMProvider } from "./llm/factory.js";
32
32
  import { getStorage } from "../storage/index.js";
33
- import { debugLog } from "./logger.js";
33
+ import { debugLog, sanitizeForLog } from "./logger.js";
34
34
  import { PRISM_USER_ID } from "../config.js";
35
35
  import { getTracer } from "./telemetry.js";
36
36
  import { SpanStatusCode, context as otelContext, trace } from "@opentelemetry/api";
@@ -93,7 +93,7 @@ export function fireCaptionAsync(project, imageId, vaultPath, userContext) {
93
93
  code: SpanStatusCode.ERROR,
94
94
  message: err instanceof Error ? err.message : String(err),
95
95
  });
96
- console.error(`[ImageCaptioner] Failed for [${imageId}]: ${err instanceof Error ? err.message : String(err)}`);
96
+ console.error(`[ImageCaptioner] Failed for [${sanitizeForLog(imageId)}]: ${sanitizeForLog(err instanceof Error ? err.message : String(err))}`);
97
97
  })
98
98
  .finally(() => {
99
99
  // Always end the span — even on VLM failure — to flush the BatchSpanProcessor.
@@ -127,8 +127,8 @@ async function captionImageAsync(project, imageId, vaultPath, userContext) {
127
127
  if (fileSizeBytes > maxBytes) {
128
128
  const limitMB = (maxBytes / 1024 / 1024).toFixed(0);
129
129
  const actualMB = (fileSizeBytes / 1024 / 1024).toFixed(1);
130
- console.warn(`[ImageCaptioner] Image [${imageId}] is ${actualMB}MB, exceeding ` +
131
- `the ${textProvider} VLM limit (${limitMB}MB). Captioning skipped. ` +
130
+ console.warn(`[ImageCaptioner] Image [${sanitizeForLog(imageId)}] is ${sanitizeForLog(actualMB)}MB, exceeding ` +
131
+ `the ${sanitizeForLog(textProvider)} VLM limit (${limitMB}MB). Captioning skipped. ` +
132
132
  (textProvider === "anthropic"
133
133
  ? "Switch Embedding Provider to Gemini/OpenAI to caption larger images."
134
134
  : "Consider resizing the image."));
@@ -165,8 +165,8 @@ async function captionImageAsync(project, imageId, vaultPath, userContext) {
165
165
  // Don't poison the caption pipeline. The caption already succeeded;
166
166
  // OCR is additive value. Surface as warn (not error) so it's grep-able
167
167
  // but doesn't trip alerting on transient VLM blips.
168
- console.warn(`[ImageCaptioner] OCR failed for [${imageId}] (non-fatal): ` +
169
- `${ocrErr instanceof Error ? ocrErr.message : String(ocrErr)}`);
168
+ console.warn(`[ImageCaptioner] OCR failed for [${sanitizeForLog(imageId)}] (non-fatal): ` +
169
+ `${sanitizeForLog(ocrErr instanceof Error ? ocrErr.message : String(ocrErr))}`);
170
170
  }
171
171
  }
172
172
  // ── Step 4: Patch handoff visual_memory entry ─────────────────────────
@@ -282,6 +282,6 @@ async function updateHandoffCaption(project, imageId, caption, vaultPath, ocrTex
282
282
  // OCC conflict — retry once with fresh version
283
283
  debugLog(`[ImageCaptioner] OCC conflict patching [${imageId}], attempt ${attempt}. Retrying…`);
284
284
  }
285
- console.warn(`[ImageCaptioner] Could not patch handoff for [${imageId}] after 2 attempts. ` +
285
+ console.warn(`[ImageCaptioner] Could not patch handoff for [${sanitizeForLog(imageId)}] after 2 attempts. ` +
286
286
  `Caption is still saved in the ledger and will surface via semantic search.`);
287
287
  }
@@ -47,6 +47,7 @@ import { VoyageAdapter } from "./adapters/voyage.js";
47
47
  import { LocalEmbeddingAdapter } from "./adapters/local.js";
48
48
  import { DisabledTextAdapter } from "./adapters/disabledText.js";
49
49
  import { TracingLLMProvider } from "./adapters/traced.js";
50
+ import { sanitizeForLog } from "../logger.js";
50
51
  // Module-level singleton — one composed provider per MCP server process.
51
52
  let providerInstance = null;
52
53
  // ─── Adapter Builders ─────────────────────────────────────────────────────────
@@ -149,13 +150,13 @@ export function getLLMProvider() {
149
150
  // The text provider name is used as the primary span attribute label.
150
151
  providerInstance = new TracingLLMProvider(composed, textType);
151
152
  if (textType !== embedType) {
152
- console.info(`[LLMFactory] Split provider: text=${textType}, embedding=${embedType}`);
153
+ console.info(`[LLMFactory] Split provider: text=${sanitizeForLog(textType)}, embedding=${sanitizeForLog(embedType)}`);
153
154
  }
154
155
  }
155
156
  catch (err) {
156
157
  // Init failure (e.g. missing API key) → fall back to full Gemini provider.
157
158
  // A crash here would silently kill the MCP server.
158
- console.error(`[LLMFactory] Failed to initialise providers (text=${textType}, embed=${embedType}): ${err instanceof Error ? err.message : String(err)}. ` +
159
+ console.error(`[LLMFactory] Failed to initialise providers (text=${sanitizeForLog(textType)}, embed=${sanitizeForLog(embedType)}): ${sanitizeForLog(err instanceof Error ? err.message : String(err))}. ` +
159
160
  `Falling back to GeminiAdapter for both.`);
160
161
  const fallback = new GeminiAdapter();
161
162
  const fallbackComposed = {
@@ -4,11 +4,11 @@ import { PRISM_DEBUG_LOGGING } from "../config.js";
4
4
  * newlines, and ANSI escape sequences that could be used for log
5
5
  * injection or terminal escape attacks.
6
6
  */
7
- function sanitizeForLog(msg) {
7
+ export function sanitizeForLog(msg) {
8
8
  return msg
9
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") // control chars (keep \n \r \t)
9
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "") // C0 + C1 control chars (keep \n \r \t)
10
10
  .replace(/\r?\n/g, " ⏎ ") // newlines → visible marker
11
- .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // ANSI escape sequences
11
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // CSI residue (defense-in-depth if first regex relaxed)
12
12
  }
13
13
  /**
14
14
  * Logs a message to stderr only if PRISM_DEBUG_LOGGING is true.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { debugLog } from "./logger.js";
14
14
  import { lookup } from "node:dns/promises";
15
+ import { Agent } from "undici";
15
16
  // ─── Default Config ──────────────────────────────────────────
16
17
  const DEFAULT_CONFIG = {
17
18
  enabled: false,
@@ -100,49 +101,54 @@ function isPrivateIP(ip) {
100
101
  return true;
101
102
  return false;
102
103
  }
103
- async function isAllowedUrl(url) {
104
+ async function validateUrl(url) {
104
105
  try {
105
106
  const parsed = new URL(url);
106
- // Block non-HTTP(S) schemes
107
107
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
108
- return false;
108
+ return { allowed: false };
109
109
  const hostname = parsed.hostname.toLowerCase();
110
- // Block localhost variants
111
110
  if (hostname === "localhost" || hostname === "localhost.localdomain")
112
- return false;
113
- // Block .internal, .local, .arpa TLDs
111
+ return { allowed: false };
114
112
  if (hostname.endsWith(".internal") || hostname.endsWith(".local") || hostname.endsWith(".arpa"))
115
- return false;
116
- // Block private/loopback IPs (covers 0.0.0.0, 127.x, 10.x, 172.16-31.x, 192.168.x, ::1, etc.)
113
+ return { allowed: false };
117
114
  if (isPrivateIP(hostname))
118
- return false;
119
- // Block bracketed IPv6
115
+ return { allowed: false };
120
116
  if (hostname.startsWith("[") && isPrivateIP(hostname))
121
- return false;
122
- // Resolve hostname and reject if ANY address is private (closes
123
- // attacker.example → 169.254.169.254 / 127.0.0.1 bypass)
117
+ return { allowed: false };
124
118
  try {
125
119
  const addrs = await lookup(hostname, { all: true });
126
120
  for (const { address } of addrs) {
127
121
  if (isPrivateIP(address))
128
- return false;
122
+ return { allowed: false };
129
123
  }
124
+ // Return the first validated address so senders can pin DNS
125
+ return { allowed: true, resolvedAddr: addrs[0].address, resolvedFamily: addrs[0].family };
130
126
  }
131
127
  catch {
132
- return false;
128
+ return { allowed: false };
133
129
  }
134
- return true;
135
130
  }
136
131
  catch {
137
- return false;
132
+ return { allowed: false };
138
133
  }
139
134
  }
135
+ function pinnedDispatcher(address, family) {
136
+ return new Agent({
137
+ connect: {
138
+ lookup: (_hostname, _opts, cb) => {
139
+ cb(null, address, family);
140
+ },
141
+ },
142
+ });
143
+ }
140
144
  // ─── Channel Senders ─────────────────────────────────────────
141
145
  async function sendWebhook(url, payload) {
142
- if (!(await isAllowedUrl(url))) {
146
+ const check = await validateUrl(url);
147
+ if (!check.allowed) {
143
148
  debugLog(`Webhook URL blocked by SSRF policy: ${url}`);
144
149
  return false;
145
150
  }
151
+ const dispatcher = pinnedDispatcher(check.resolvedAddr, check.resolvedFamily);
146
152
  try {
147
153
  const response = await fetch(url, {
148
154
  method: "POST",
@@ -150,6 +156,7 @@ async function sendWebhook(url, payload) {
150
156
  body: JSON.stringify(payload),
151
157
  redirect: "error",
152
158
  signal: AbortSignal.timeout(10_000),
159
+ dispatcher,
153
160
  });
154
161
  return response.ok;
155
162
  }
@@ -157,9 +164,13 @@ async function sendWebhook(url, payload) {
157
164
  debugLog(`Webhook notification failed: ${err}`);
158
165
  return false;
159
166
  }
167
+ finally {
168
+ await dispatcher.close().catch(() => { });
169
+ }
160
170
  }
161
171
  async function sendSlack(webhookUrl, payload) {
162
- if (!(await isAllowedUrl(webhookUrl))) {
172
+ const check = await validateUrl(webhookUrl);
173
+ if (!check.allowed) {
163
174
  debugLog(`Slack webhook URL blocked by SSRF policy: ${webhookUrl}`);
164
175
  return false;
165
176
  }
@@ -200,6 +211,7 @@ async function sendSlack(webhookUrl, payload) {
200
211
  : []),
201
212
  ],
202
213
  };
214
+ const dispatcher = pinnedDispatcher(check.resolvedAddr, check.resolvedFamily);
203
215
  try {
204
216
  const response = await fetch(webhookUrl, {
205
217
  method: "POST",
@@ -207,6 +219,7 @@ async function sendSlack(webhookUrl, payload) {
207
219
  body: JSON.stringify(slackPayload),
208
220
  redirect: "error",
209
221
  signal: AbortSignal.timeout(10_000),
222
+ dispatcher,
210
223
  });
211
224
  return response.ok;
212
225
  }
@@ -214,13 +227,17 @@ async function sendSlack(webhookUrl, payload) {
214
227
  debugLog(`Slack notification failed: ${err}`);
215
228
  return false;
216
229
  }
230
+ finally {
231
+ await dispatcher.close().catch(() => { });
232
+ }
217
233
  }
218
234
  async function sendEmail(endpoint, payload) {
219
- if (!(await isAllowedUrl(endpoint))) {
235
+ const check = await validateUrl(endpoint);
236
+ if (!check.allowed) {
220
237
  debugLog(`Email endpoint URL blocked by SSRF policy: ${endpoint}`);
221
238
  return false;
222
239
  }
223
- // Email via webhook relay (e.g., SendGrid, Mailgun, or custom endpoint)
240
+ const dispatcher = pinnedDispatcher(check.resolvedAddr, check.resolvedFamily);
224
241
  try {
225
242
  const response = await fetch(endpoint, {
226
243
  method: "POST",
@@ -233,6 +250,7 @@ async function sendEmail(endpoint, payload) {
233
250
  }),
234
251
  redirect: "error",
235
252
  signal: AbortSignal.timeout(10_000),
253
+ dispatcher,
236
254
  });
237
255
  return response.ok;
238
256
  }
@@ -240,6 +258,9 @@ async function sendEmail(endpoint, payload) {
240
258
  debugLog(`Email notification failed: ${err}`);
241
259
  return false;
242
260
  }
261
+ finally {
262
+ await dispatcher.close().catch(() => { });
263
+ }
243
264
  }
244
265
  // ─── Public API ──────────────────────────────────────────────
245
266
  /**
@@ -61,6 +61,7 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
61
61
  import { resourceFromAttributes } from "@opentelemetry/resources";
62
62
  import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
63
63
  import { getSettingSync } from "../storage/configStorage.js";
64
+ import { sanitizeForLog } from "./logger.js";
64
65
  // ─── Module-level singleton ───────────────────────────────────────────────────
65
66
  // Null when OTel is disabled or before initTelemetry() is called.
66
67
  let _provider = null;
@@ -175,11 +176,11 @@ export function initTelemetry() {
175
176
  });
176
177
  _provider.register();
177
178
  console.error(`[Telemetry] OpenTelemetry initialized. ` +
178
- `Service: "${serviceName}", Endpoint: "${endpoint}"`);
179
+ `Service: "${sanitizeForLog(serviceName)}", Endpoint: "${sanitizeForLog(endpoint)}"`);
179
180
  }
180
181
  catch (err) {
181
182
  // OTel init errors must NEVER crash the server. Log and continue.
182
- console.error(`[Telemetry] Failed to initialize OTel (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
183
+ console.error(`[Telemetry] Failed to initialize OTel (non-fatal): ${sanitizeForLog(err instanceof Error ? err.message : String(err))}`);
183
184
  _provider = null; // Clean state so getTracer() returns no-op
184
185
  }
185
186
  }
@@ -226,6 +227,6 @@ export async function shutdownTelemetry() {
226
227
  }
227
228
  catch (err) {
228
229
  // Log but don't rethrow — shutdown errors must not prevent DB cleanup.
229
- console.error(`[Telemetry] Error during shutdown (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
230
+ console.error(`[Telemetry] Error during shutdown (non-fatal): ${sanitizeForLog(err instanceof Error ? err.message : String(err))}`);
230
231
  }
231
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "19.2.5",
3
+ "version": "19.2.7",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",
@@ -105,7 +105,7 @@
105
105
  "@types/mozilla-readability": "^0.2.1",
106
106
  "@types/turndown": "^5.0.6",
107
107
  "dotenv": "^17.4.2",
108
- "tsx": "^4.19.3",
108
+ "tsx": "^4.22.4",
109
109
  "vitest": "^4.1.1"
110
110
  },
111
111
  "peerDependencies": {