prism-mcp-server 17.0.0 → 17.1.0

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * PHI Guard — detect and redact Protected Health Information before storage/logging.
3
+ *
4
+ * HIPAA §164.502: PHI must not be disclosed except as permitted.
5
+ * This module scans text for common PHI patterns (SSN, DOB, MRN, phone,
6
+ * email, patient names in clinical context) and redacts them.
7
+ *
8
+ * Detection events are logged to stderr (picked up by DD agent) with
9
+ * the pattern type and character position — never the actual PHI value.
10
+ *
11
+ * Usage:
12
+ * import { scanAndRedactPHI, hasPHI } from './phiGuard.js';
13
+ * const { redacted, detections } = scanAndRedactPHI(userText);
14
+ * // `redacted` is safe to store/log; `detections` lists what was found
15
+ */
16
+ // Patterns ordered by specificity (most specific first)
17
+ const PHI_PATTERNS = [
18
+ // SSN: 123-45-6789 or 123456789
19
+ { name: 'SSN', regex: /\b\d{3}-\d{2}-\d{4}\b/g, replacement: '[SSN-REDACTED]' },
20
+ { name: 'SSN', regex: /\b\d{9}\b(?=\s|$|[,.])/g, replacement: '[SSN-REDACTED]' },
21
+ // Date of birth patterns: DOB: 01/15/1990, born 1990-01-15, birthday 01/15/90
22
+ { name: 'DOB', regex: /\b(?:dob|date\s*of\s*birth|born|birthday)\s*[:=]?\s*\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}\b/gi, replacement: '[DOB-REDACTED]' },
23
+ { name: 'DOB', regex: /\b(?:dob|date\s*of\s*birth|born|birthday)\s*[:=]?\s*\d{4}[/\-]\d{1,2}[/\-]\d{1,2}\b/gi, replacement: '[DOB-REDACTED]' },
24
+ // Medical Record Number: MRN: 12345678, MRN#12345
25
+ { name: 'MRN', regex: /\b(?:mrn|medical\s*record)\s*[#:=]?\s*\d{4,12}\b/gi, replacement: '[MRN-REDACTED]' },
26
+ // US Phone: (301) 433-1943, 301-433-1943, +1-301-433-1943
27
+ { name: 'PHONE', regex: /\b(?:\+?1[-.]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, replacement: '[PHONE-REDACTED]' },
28
+ // Email in clinical context: patient email, client email
29
+ { name: 'EMAIL', regex: /\b(?:patient|client|parent|caregiver)\s*(?:email|e-mail)\s*[:=]?\s*[\w.+-]+@[\w.-]+\.\w{2,}\b/gi, replacement: '[EMAIL-REDACTED]' },
30
+ // Patient/client name patterns: "Patient: John Doe", "Client Name: Jane Smith"
31
+ { name: 'PATIENT_NAME', regex: /\b(?:patient|client)\s*(?:name)?\s*[:=]\s*[A-Z][a-z]+\s+[A-Z][a-z]+/gi, replacement: '[NAME-REDACTED]' },
32
+ // Insurance ID: Ins#, Policy#, Member ID
33
+ { name: 'INSURANCE_ID', regex: /\b(?:ins(?:urance)?|policy|member)\s*(?:id|#|number)\s*[:=]?\s*[A-Z0-9]{6,20}\b/gi, replacement: '[INSURANCE-REDACTED]' },
34
+ // Diagnosis codes in patient context: "diagnosed with F84.0", "ICD: F32.1"
35
+ { name: 'DIAGNOSIS', regex: /\b(?:diagnos\w*|icd|dx)\s*(?:[:=]|with)?\s*[A-Z]\d{2}(?:\.\d{1,2})?\b/gi, replacement: '[DX-REDACTED]' },
36
+ ];
37
+ /**
38
+ * Scan text for PHI patterns and return redacted version + detection list.
39
+ * Never logs or stores the actual PHI values — only type + position.
40
+ */
41
+ export function scanAndRedactPHI(text) {
42
+ if (typeof text !== 'string' || !text) {
43
+ return { redacted: text || '', detections: [], hasPHI: false };
44
+ }
45
+ const detections = [];
46
+ let redacted = text;
47
+ for (const { name, regex, replacement } of PHI_PATTERNS) {
48
+ // Reset regex state for global patterns
49
+ regex.lastIndex = 0;
50
+ let match;
51
+ while ((match = regex.exec(text)) !== null) {
52
+ detections.push({
53
+ type: name,
54
+ position: match.index,
55
+ length: match[0].length,
56
+ });
57
+ }
58
+ redacted = redacted.replace(regex, replacement);
59
+ }
60
+ if (detections.length > 0) {
61
+ // Log detection event — type + count only, NEVER the actual value
62
+ const summary = detections.reduce((acc, d) => {
63
+ acc[d.type] = (acc[d.type] || 0) + 1;
64
+ return acc;
65
+ }, {});
66
+ const summaryStr = Object.entries(summary).map(([k, v]) => `${k}=${v}`).join(' ');
67
+ console.error(`[PHI-GUARD] Detected and redacted PHI: ${summaryStr}`);
68
+ }
69
+ return {
70
+ redacted,
71
+ detections,
72
+ hasPHI: detections.length > 0,
73
+ };
74
+ }
75
+ /**
76
+ * Quick check — does the text contain PHI patterns?
77
+ * Faster than full redaction when you only need a boolean.
78
+ */
79
+ export function hasPHI(text) {
80
+ if (typeof text !== 'string' || !text)
81
+ return false;
82
+ for (const { regex } of PHI_PATTERNS) {
83
+ regex.lastIndex = 0;
84
+ if (regex.test(text))
85
+ return true;
86
+ }
87
+ return false;
88
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Synalux Portal Search & Scrape Client
3
+ * ─────────────────────────────────────────────────────────────
4
+ * Routes web search and scrape calls through the Synalux portal
5
+ * so API keys (Brave, Firecrawl, etc.) live server-side. The
6
+ * portal endpoints:
7
+ *
8
+ * POST /api/v1/prism/search — { query, limit? }
9
+ * returns { status, results: [{title, url, description}], source }
10
+ *
11
+ * POST /api/v1/prism/scrape — { url, formats?, onlyMainContent?, waitFor? }
12
+ * returns { status, content }
13
+ *
14
+ * Auth uses the shared JWT exchange from synaluxJwt.ts (same
15
+ * refresh-token dance as SynaluxStorage, but without requiring
16
+ * the full storage class). Falls back gracefully: callers check
17
+ * SYNALUX_SEARCH_AVAILABLE before calling.
18
+ */
19
+ import { debugLog } from "./logger.js";
20
+ import { getSynaluxJwt, invalidateSynaluxJwt } from "./synaluxJwt.js";
21
+ import { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED, } from "../config.js";
22
+ // ─── Public availability flag ────────────────────────────────
23
+ /** True when Synalux portal credentials are configured. */
24
+ export const SYNALUX_SEARCH_AVAILABLE = SYNALUX_CONFIGURED;
25
+ // ─── Internal helpers ────────────────────────────────────────
26
+ /**
27
+ * POST to a portal endpoint with JWT auth. Retries once on 401
28
+ * (JWT may have just expired). Throws on network or HTTP errors.
29
+ */
30
+ async function portalPost(path, body, timeoutMs = 15_000) {
31
+ const baseUrl = PRISM_SYNALUX_BASE_URL.replace(/\/+$/, "");
32
+ const url = `${baseUrl}${path}`;
33
+ const send = async (jwt) => {
34
+ return fetch(url, {
35
+ method: "POST",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ "Authorization": `Bearer ${jwt}`,
39
+ "X-Prism-Client": "prism-mcp-search",
40
+ },
41
+ body: JSON.stringify(body),
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ });
44
+ };
45
+ let jwt = await getSynaluxJwt();
46
+ if (!jwt) {
47
+ throw new Error("[synaluxSearch] JWT exchange failed — no token available");
48
+ }
49
+ let res = await send(jwt);
50
+ // Retry once on 401 (stale JWT)
51
+ if (res.status === 401) {
52
+ debugLog("[synaluxSearch] 401 on first attempt, re-exchanging JWT");
53
+ invalidateSynaluxJwt();
54
+ jwt = await getSynaluxJwt();
55
+ if (!jwt) {
56
+ throw new Error("[synaluxSearch] JWT re-exchange failed after 401");
57
+ }
58
+ res = await send(jwt);
59
+ }
60
+ if (!res.ok) {
61
+ const text = await res.text().catch(() => "(no body)");
62
+ throw new Error(`[synaluxSearch] ${path} HTTP ${res.status}: ${text}`);
63
+ }
64
+ return (await res.json());
65
+ }
66
+ // ─── Public API ──────────────────────────────────────────────
67
+ /**
68
+ * Web search via Synalux portal. Returns formatted text matching
69
+ * the shape of performWebSearch() in braveApi.ts.
70
+ */
71
+ export async function synaluxWebSearch(query, count = 10) {
72
+ debugLog(`[synaluxSearch] web search: q="${query}", limit=${count}`);
73
+ const data = await portalPost("/api/v1/prism/search", {
74
+ query,
75
+ limit: Math.min(count, 20),
76
+ });
77
+ if (data.status === "error") {
78
+ throw new Error(`[synaluxSearch] portal error: ${data.error || "unknown"}`);
79
+ }
80
+ const results = (data.results || []).map((r) => ({
81
+ title: r.title || "",
82
+ description: r.description || "",
83
+ url: r.url || "",
84
+ }));
85
+ debugLog(`[synaluxSearch] got ${results.length} results (source=${data.source || "portal"})`);
86
+ return results
87
+ .map((r) => `Title: ${r.title}\nDescription: ${r.description}\nURL: ${r.url}`)
88
+ .join("\n\n");
89
+ }
90
+ /**
91
+ * Web search via Synalux portal — returns raw JSON string.
92
+ * Used by code-mode handlers that pass raw data to the QuickJS sandbox.
93
+ */
94
+ export async function synaluxWebSearchRaw(query, count = 10) {
95
+ debugLog(`[synaluxSearch] web search raw: q="${query}", limit=${count}`);
96
+ const data = await portalPost("/api/v1/prism/search", {
97
+ query,
98
+ limit: Math.min(count, 20),
99
+ });
100
+ if (data.status === "error") {
101
+ throw new Error(`[synaluxSearch] portal error: ${data.error || "unknown"}`);
102
+ }
103
+ // Re-shape into the Brave-compatible format that code-mode handlers expect
104
+ const braveCompatible = {
105
+ web: {
106
+ results: (data.results || []).map((r) => ({
107
+ title: r.title || "",
108
+ description: r.description || "",
109
+ url: r.url || "",
110
+ })),
111
+ },
112
+ };
113
+ debugLog(`[synaluxSearch] raw: ${braveCompatible.web.results.length} results`);
114
+ return JSON.stringify(braveCompatible);
115
+ }
116
+ /**
117
+ * Local/POI search via Synalux portal.
118
+ * Returns formatted text matching performLocalSearch() shape.
119
+ */
120
+ export async function synaluxLocalSearch(query, count = 5) {
121
+ debugLog(`[synaluxSearch] local search: q="${query}", count=${count}`);
122
+ const data = await portalPost("/api/v1/prism/local-search", {
123
+ query,
124
+ count: Math.min(count, 20),
125
+ });
126
+ if (data.status === "error") {
127
+ throw new Error(`[synaluxSearch] portal error: ${data.error || "unknown"}`);
128
+ }
129
+ const results = data.results || [];
130
+ debugLog(`[synaluxSearch] local: got ${results.length} results`);
131
+ return results
132
+ .map((r) => `Name: ${r.name || "N/A"}\nAddress: ${r.address || "N/A"}\nPhone: ${r.phone || "N/A"}\nRating: ${r.rating || "N/A"}\nHours: ${r.hours || "N/A"}\nDescription: ${r.description || "No description available"}`)
133
+ .join("\n---\n");
134
+ }
135
+ /**
136
+ * Local/POI search raw — returns JSON string for code-mode sandbox.
137
+ */
138
+ export async function synaluxLocalSearchRaw(query, count = 5) {
139
+ debugLog(`[synaluxSearch] local search raw: q="${query}", count=${count}`);
140
+ const data = await portalPost("/api/v1/prism/local-search", {
141
+ query,
142
+ count: Math.min(count, 20),
143
+ });
144
+ if (data.status === "error") {
145
+ throw new Error(`[synaluxSearch] portal error: ${data.error || "unknown"}`);
146
+ }
147
+ const results = data.results || [];
148
+ debugLog(`[synaluxSearch] local raw: ${results.length} results`);
149
+ // Build envelope compatible with code-mode sandbox expectations
150
+ const envelope = {
151
+ source: "local",
152
+ query,
153
+ count,
154
+ poisData: { results },
155
+ descriptionsData: {
156
+ descriptions: Object.fromEntries(results.map((r, i) => [String(i), r.description || ""])),
157
+ },
158
+ };
159
+ return JSON.stringify(envelope);
160
+ }
161
+ /**
162
+ * AI-grounded answers via Synalux portal.
163
+ */
164
+ export async function synaluxBraveAnswers(query, model) {
165
+ debugLog(`[synaluxSearch] answers: q="${query}", model=${model || "default"}`);
166
+ const body = { query };
167
+ if (model)
168
+ body.model = model;
169
+ const data = await portalPost("/api/v1/prism/answers", body);
170
+ if (data.status === "error") {
171
+ throw new Error(`[synaluxSearch] portal error: ${data.error || "unknown"}`);
172
+ }
173
+ if (!data.answer) {
174
+ throw new Error("[synaluxSearch] answers endpoint returned empty answer");
175
+ }
176
+ return data.answer;
177
+ }
178
+ /**
179
+ * Scrape a URL via Synalux portal. Returns the extracted content string.
180
+ */
181
+ export async function synaluxScrape(url, options) {
182
+ debugLog(`[synaluxSearch] scrape: url="${url}"`);
183
+ const body = { url };
184
+ if (options?.formats)
185
+ body.formats = options.formats;
186
+ if (options?.onlyMainContent !== undefined)
187
+ body.onlyMainContent = options.onlyMainContent;
188
+ if (options?.waitFor !== undefined)
189
+ body.waitFor = options.waitFor;
190
+ const data = await portalPost("/api/v1/prism/scrape", body);
191
+ if (data.status === "error") {
192
+ throw new Error(`[synaluxSearch] scrape error: ${data.error || "unknown"}`);
193
+ }
194
+ return data.content || "";
195
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "17.0.0",
3
+ "version": "17.1.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
- "description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 54 Agent Skills, 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:7b / 14b open-weights LLM fleet.",
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, 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:7b / 14b open-weights LLM fleet.",
6
6
  "module": "index.ts",
7
7
  "type": "module",
8
8
  "main": "dist/server.js",