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 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
- ddInfo("mcp.tool.success", { tool: name, project: args?.project, durationMs: Date.now() - _ddStart });
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
- 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)));
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: error instanceof Error ? error.message : String(error),
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 { 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;
@@ -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
  /**
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.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.19.3",
108
+ "tsx": "^4.22.4",
109
109
  "vitest": "^4.1.1"
110
110
  },
111
111
  "peerDependencies": {