prism-mcp-server 19.2.5 → 19.2.6
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/server.js +11 -5
- package/dist/tools/v12Handlers.js +4 -4
- package/dist/utils/analytics.js +159 -136
- package/dist/utils/ddLogger.js +1 -0
- package/dist/utils/notifier.js +42 -21
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -80,6 +80,7 @@ import { getTracer, initTelemetry } from "./utils/telemetry.js";
|
|
|
80
80
|
import { context as otelContext, trace, SpanStatusCode } from "@opentelemetry/api";
|
|
81
81
|
import { ddInfo, ddError as ddLogError } from "./utils/ddLogger.js";
|
|
82
82
|
import { inferenceMetricsHandler } from "./utils/inferenceMetrics.js";
|
|
83
|
+
import { recordInvocation } from "./utils/analytics.js";
|
|
83
84
|
// ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
|
|
84
85
|
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
86
|
// Session memory tools — only used if Supabase is configured
|
|
@@ -975,7 +976,9 @@ export function createServer() {
|
|
|
975
976
|
};
|
|
976
977
|
}
|
|
977
978
|
rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
978
|
-
|
|
979
|
+
const _ddDuration = Date.now() - _ddStart;
|
|
980
|
+
ddInfo("mcp.tool.success", { tool: name, project: args?.project, durationMs: _ddDuration });
|
|
981
|
+
recordInvocation(name, String(args?.project ?? ""), args, JSON.stringify(result?.content?.[0]?.text ?? "").slice(0, 2000), _ddDuration, true);
|
|
979
982
|
// ═══ v5.3: Hivemind Watchdog Alert Injection (Telepathy) ═══
|
|
980
983
|
// CRITICAL: Append alerts DIRECTLY to tool response content
|
|
981
984
|
// so the LLM actually reads them. sendLoggingMessage goes to
|
|
@@ -1015,13 +1018,16 @@ export function createServer() {
|
|
|
1015
1018
|
return result;
|
|
1016
1019
|
}
|
|
1017
1020
|
catch (error) {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
1022
|
+
const _ddErrDuration = Date.now() - _ddStart;
|
|
1023
|
+
console.error(`Error in tool handler: ${errMsg}`);
|
|
1024
|
+
ddLogError("mcp.tool.error", error instanceof Error ? error : undefined, { tool: name, project: args?.project, durationMs: _ddErrDuration });
|
|
1025
|
+
rootSpan.recordException(error instanceof Error ? error : new Error(errMsg));
|
|
1021
1026
|
rootSpan.setStatus({
|
|
1022
1027
|
code: SpanStatusCode.ERROR,
|
|
1023
|
-
message:
|
|
1028
|
+
message: errMsg,
|
|
1024
1029
|
});
|
|
1030
|
+
recordInvocation(name, String(args?.project ?? ""), args, "", _ddErrDuration, false, errMsg);
|
|
1025
1031
|
return {
|
|
1026
1032
|
content: [
|
|
1027
1033
|
{
|
|
@@ -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;
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "19.2.
|
|
3
|
+
"version": "19.2.6",
|
|
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": {
|