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.
- package/dist/dashboard/server.js +1 -1
- package/dist/scholar/webScholar.js +44 -11
- package/dist/server.js +12 -5
- package/dist/storage/configStorage.js +14 -8
- package/dist/tools/commonHelpers.js +1 -1
- package/dist/tools/ledgerHandlers.js +3 -2
- package/dist/tools/v12Handlers.js +4 -4
- package/dist/utils/analytics.js +159 -136
- package/dist/utils/ddLogger.js +1 -0
- package/dist/utils/imageCaptioner.js +7 -7
- package/dist/utils/llm/factory.js +3 -2
- package/dist/utils/logger.js +3 -3
- package/dist/utils/notifier.js +42 -21
- package/dist/utils/telemetry.js +4 -3
- package/package.json +2 -2
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
128
|
+
// Lock exists — check 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
|
|
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
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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:
|
|
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
|
-
|
|
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" && !
|
|
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
|
-
|
|
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
|
|
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}-${
|
|
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 {
|
|
117
|
+
const { scope, project, days } = args;
|
|
118
118
|
try {
|
|
119
119
|
const analytics = await import("../utils/analytics.js");
|
|
120
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
};
|
package/dist/utils/analytics.js
CHANGED
|
@@ -1,100 +1,123 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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.
|
|
6
|
-
*
|
|
5
|
+
* and project association. Uses @libsql/client (same as the rest
|
|
6
|
+
* of prism's local storage layer).
|
|
7
7
|
*
|
|
8
|
-
* Storage:
|
|
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
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
69
|
-
const
|
|
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
|
-
|
|
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
|
|
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();
|
|
130
|
+
await flushBuffer();
|
|
111
131
|
try {
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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:
|
|
143
|
-
successRate:
|
|
144
|
-
avgDurationMs: Math.round(
|
|
145
|
-
totalInputTokens:
|
|
146
|
-
totalOutputTokens:
|
|
147
|
-
topTools: topTools.map((r) => ({ tool: r.tool, count: r.count })),
|
|
148
|
-
callsByDay: callsByDay.map((r) => ({ date: r.date, count: r.count })),
|
|
149
|
-
periodStart:
|
|
150
|
-
periodEnd:
|
|
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
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
190
|
-
totalCalls:
|
|
191
|
-
globalSuccessRate:
|
|
192
|
-
avgDurationMs: Math.round(
|
|
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("
|
|
228
|
+
debugLog("API analytics module loaded (@libsql/client)");
|
package/dist/utils/ddLogger.js
CHANGED
|
@@ -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 = {
|
package/dist/utils/logger.js
CHANGED
|
@@ -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, ""); //
|
|
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.
|
package/dist/utils/notifier.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/utils/telemetry.js
CHANGED
|
@@ -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.
|
|
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.
|
|
108
|
+
"tsx": "^4.22.4",
|
|
109
109
|
"vitest": "^4.1.1"
|
|
110
110
|
},
|
|
111
111
|
"peerDependencies": {
|