prism-mcp-server 18.0.0 → 18.0.2

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/README.md CHANGED
@@ -42,17 +42,17 @@ When major drift is detected, the alert routes to the **Synalux portal** so it's
42
42
 
43
43
  No scripts. No cron. No hooks. Three tool calls, Prism handles the rest.
44
44
 
45
- ### 🛡 PHI Guard *(new in v17)*
45
+ ### 🛡 PHI Guard *(v17+)*
46
46
  Automatic Protected Health Information detection and redaction in the memory pipeline. Every `session_save_ledger` and `session_save_handoff` call passes through the PHI guard before storage.
47
47
 
48
- **What it catches:** Names, DOBs, SSNs, MRNs, phone numbers, email addresses, and 18 HIPAA identifier categories. Redaction is deterministic (regex + pattern matching, no LLM) — zero false negatives on structured identifiers.
48
+ **What it catches:** DOBs, SSNs, MRNs, phone numbers, email addresses, and other structured HIPAA identifiers (18 categories). Redaction is deterministic (regex + pattern matching, no LLM) — zero false negatives on format-constrained identifiers (SSN, MRN, phone, email). Names require NER for reliable detection and are best-effort.
49
49
 
50
50
  **Fail-closed:** PHI detection errors log to stderr (never suppressed) and block the save. Metric: `phi_guard.detected` count per category is always emitted for audit compliance.
51
51
 
52
- ### ⚡ Prompt-based skill routing *(new in v17)*
52
+ ### ⚡ Prompt-based skill routing *(v17+)*
53
53
  114 agent skills auto-load based on prompt keywords. No manual skill selection needed — the MCP server scans the user's prompt and injects the relevant skill instructions into the session context before the AI responds.
54
54
 
55
- ### 💰 Tier enforcement *(new in v17.1)*
55
+ ### 💰 Tier enforcement *(v17.1+)*
56
56
  `prism_infer` now enforces subscription-tier gates: model ceiling, max tokens, daily limits, and cloud fallback are all gated by your plan. Free users get local-only inference up to 4b; paid tiers unlock higher models, more tokens, and cloud fallback. Flat-rate seat caps via `max_seats` per plan.
57
57
 
58
58
  ### 🛡 Local-first — security + speed
@@ -64,7 +64,7 @@ Free tier runs entirely on your machine — SQLite, local embedding model, no AP
64
64
  |--|---|---|
65
65
  | Tool-call latency | 200ms–3s | **~1.6s (1.7B) / ~1.1s (14B)** |
66
66
  | API key required | Yes | **No** |
67
- | Data sent externally | Every prompt | **Nothing** |
67
+ | Data sent externally | Every prompt | **Nothing (free tier)** |
68
68
  | Works offline | ❌ | ✅ |
69
69
  | Cost at scale | $0.002–0.06/call | **$0** |
70
70
  | HIPAA | Requires BAA | **On-prem = no BAA** |
@@ -84,20 +84,23 @@ export async function isAuthenticated(req, config) {
84
84
  try {
85
85
  const token = authHeader.slice(7);
86
86
  const verifyOpts = {
87
- clockTolerance: 30, // 30s clock skew tolerance
87
+ clockTolerance: 30,
88
+ // audience + issuer are REQUIRED when JWKS is configured — prevents
89
+ // cross-service token confusion (adversarial review H1, 2026-06-12).
90
+ audience: config.jwtAudience || "prism-mcp",
91
+ issuer: config.jwtIssuer || "https://synalux.ai",
88
92
  };
89
- if (config.jwtAudience)
90
- verifyOpts.audience = config.jwtAudience;
91
- if (config.jwtIssuer)
92
- verifyOpts.issuer = config.jwtIssuer;
93
93
  const { payload } = await jwtVerify(token, jwksCache, verifyOpts);
94
94
  const payloadDict = payload;
95
- // Attach agent_id and AgentLair audit metadata to the request for downstream traceability
96
95
  const authReq = req;
97
96
  authReq.agent_id =
98
97
  payloadDict.agent_id || payload.sub;
99
98
  authReq.al_name = payloadDict.al_name;
100
- authReq.al_audit_url = payloadDict.al_audit_url;
99
+ // al_audit_url is untrusted (attacker-influenceable via token) — do not
100
+ // act on it or call it. Stored for logging only, never fetched.
101
+ authReq.al_audit_url = typeof payloadDict.al_audit_url === "string"
102
+ ? payloadDict.al_audit_url.slice(0, 256)
103
+ : undefined;
101
104
  return true;
102
105
  }
103
106
  catch (err) {
@@ -3,6 +3,9 @@ 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";
7
+ import { dirname, join } from "node:path";
8
+ import { homedir } from "node:os";
6
9
  import { performWebSearchRaw } from "../utils/braveApi.js";
7
10
  import { performGoogleSearch } from "../utils/googleSearchApi.js";
8
11
  import { getTracer } from "../utils/telemetry.js";
@@ -58,9 +61,20 @@ async function selectTopic() {
58
61
  const topics = PRISM_SCHOLAR_TOPICS;
59
62
  if (!topics || topics.length === 0)
60
63
  return "";
61
- const randomPick = topics[Math.floor(Math.random() * topics.length)];
64
+ // Filter out topics already researched in the last 24h
65
+ const uncovered = [];
66
+ for (const t of topics) {
67
+ if (!(await hasRecentResearch(t, SCHOLAR_PROJECT, PRISM_USER_ID, 24))) {
68
+ uncovered.push(t);
69
+ }
70
+ }
71
+ if (uncovered.length === 0) {
72
+ debugLog("[WebScholar] All topics researched in last 24h — sleeping until tomorrow");
73
+ return "";
74
+ }
75
+ const pick = uncovered[Math.floor(Math.random() * uncovered.length)];
62
76
  if (!PRISM_ENABLE_HIVEMIND)
63
- return randomPick;
77
+ return pick;
64
78
  try {
65
79
  const storage = await getStorage();
66
80
  const allAgents = await storage.getAllAgents(PRISM_USER_ID);
@@ -68,22 +82,79 @@ async function selectTopic() {
68
82
  .filter(a => a.role !== SCHOLAR_ROLE && a.status === "active" && a.current_task)
69
83
  .map(a => a.current_task.toLowerCase());
70
84
  if (activeTasks.length === 0)
71
- return randomPick;
85
+ return pick;
72
86
  const taskText = activeTasks.join(" ");
73
- const matched = topics.filter(t => taskText.includes(t.toLowerCase()));
87
+ const matched = uncovered.filter(t => taskText.includes(t.toLowerCase()));
74
88
  if (matched.length > 0)
75
89
  return matched[Math.floor(Math.random() * matched.length)];
76
90
  }
77
91
  catch { }
78
- return randomPick;
92
+ return pick;
93
+ }
94
+ // ─── Dedup + Cross-Process Lock ─────────────────────────────
95
+ async function hasRecentResearch(topic, project, userId, windowHours = 24) {
96
+ try {
97
+ const storage = await getStorage();
98
+ const entries = await storage.getLedgerEntries({
99
+ project,
100
+ user_id: userId,
101
+ event_type: "learning",
102
+ limit: 20,
103
+ });
104
+ const cutoff = new Date(Date.now() - windowHours * 3600_000).toISOString();
105
+ return entries.some(e => (e.created_at ?? "") > cutoff && (e.summary ?? "").startsWith(`Research: ${topic}\n`));
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ const LOCK_PATH = join(homedir(), ".prism-mcp", "scholar.lock");
112
+ const LOCK_STALE_MS = 10 * 60_000;
113
+ function acquireFileLock() {
114
+ try {
115
+ const lockDir = dirname(LOCK_PATH);
116
+ if (!existsSync(lockDir))
117
+ mkdirSync(lockDir, { recursive: true });
118
+ if (existsSync(LOCK_PATH)) {
119
+ const stat = statSync(LOCK_PATH);
120
+ const ageMs = Date.now() - stat.mtimeMs;
121
+ if (ageMs < LOCK_STALE_MS)
122
+ return false;
123
+ unlinkSync(LOCK_PATH);
124
+ }
125
+ // wx = exclusive create — throws EEXIST if another process won the race
126
+ writeFileSync(LOCK_PATH, `${process.pid}\n${new Date().toISOString()}`, { flag: "wx" });
127
+ return true;
128
+ }
129
+ catch (err) {
130
+ if (err?.code === "EEXIST")
131
+ return false;
132
+ return false;
133
+ }
134
+ }
135
+ function releaseFileLock() {
136
+ try {
137
+ if (!existsSync(LOCK_PATH))
138
+ return;
139
+ const content = readFileSync(LOCK_PATH, "utf-8");
140
+ const lockPid = parseInt(content.split("\n")[0], 10);
141
+ if (lockPid === process.pid) {
142
+ unlinkSync(LOCK_PATH);
143
+ }
144
+ }
145
+ catch { }
79
146
  }
80
147
  // ─── Core Pipeline ───────────────────────────────────────────
81
148
  let isRunning = false;
82
149
  export async function runWebScholar(overrideTopic, overrideProject) {
83
150
  if (isRunning) {
84
- debugLog("[WebScholar] Skipped: already running");
151
+ debugLog("[WebScholar] Skipped: already running in this process");
85
152
  return "Skipped: already running";
86
153
  }
154
+ if (!acquireFileLock()) {
155
+ debugLog("[WebScholar] Skipped: another instance holds the lock");
156
+ return "Skipped: another instance is running";
157
+ }
87
158
  isRunning = true;
88
159
  const tracer = getTracer();
89
160
  const span = tracer.startSpan("background.web_scholar");
@@ -97,6 +168,11 @@ export async function runWebScholar(overrideTopic, overrideProject) {
97
168
  span.setAttribute("scholar.skipped_reason", "no_topics");
98
169
  return "No topics configured";
99
170
  }
171
+ if (await hasRecentResearch(topic, project, PRISM_USER_ID)) {
172
+ debugLog(`[WebScholar] Skipped: "${topic}" already researched in last 24h`);
173
+ span.setAttribute("scholar.skipped_reason", "dedup");
174
+ return `Skipped: "${topic}" already researched recently`;
175
+ }
100
176
  debugLog(`[WebScholar] 🧠 Starting research on: "${topic}"`);
101
177
  await hivemindRegister(topic);
102
178
  await hivemindHeartbeat(`Searching for: ${topic}`);
@@ -146,11 +222,11 @@ export async function runWebScholar(overrideTopic, overrideProject) {
146
222
  await storage.saveLedger({
147
223
  id: randomUUID(),
148
224
  project: project,
149
- conversation_id: "scholar-" + Date.now(),
225
+ conversation_id: `scholar-${randomUUID().slice(0, 8)}`,
150
226
  user_id: PRISM_USER_ID,
151
227
  role: "scholar",
152
228
  summary: `Research: ${topic}\n\n${summary}`,
153
- keywords: [topic, "research"],
229
+ keywords: [topic, "research", "auto-scholar"],
154
230
  event_type: "learning",
155
231
  importance: 7,
156
232
  created_at: new Date().toISOString()
@@ -164,6 +240,7 @@ export async function runWebScholar(overrideTopic, overrideProject) {
164
240
  }
165
241
  finally {
166
242
  await hivemindIdle();
243
+ releaseFileLock();
167
244
  isRunning = false;
168
245
  span.end();
169
246
  }
@@ -215,41 +292,3 @@ async function searchSemanticScholar(query, count) {
215
292
  return [];
216
293
  }
217
294
  }
218
- let watcherInterval = null;
219
- export async function startScholarWatcher() {
220
- if (watcherInterval)
221
- return;
222
- debugLog("[WebScholar] Starting bridge watcher (polling for research_tasks)");
223
- watcherInterval = setInterval(async () => {
224
- try {
225
- const storage = await getStorage();
226
- const pending = await storage.listPendingResearchTasks();
227
- for (const task of pending) {
228
- debugLog(`[WebScholar] Bridge pickup: Task ${task.id} (topic: ${task.topic})`);
229
- await storage.updateResearchTask(task.id, { status: 'RUNNING' });
230
- try {
231
- const result = await runWebScholar(task.topic, task.project);
232
- await storage.updateResearchTask(task.id, {
233
- status: 'COMPLETED',
234
- result_summary: result.slice(0, 1000) // snippet
235
- });
236
- }
237
- catch (err) {
238
- await storage.updateResearchTask(task.id, {
239
- status: 'FAILED',
240
- error_message: err.message || String(err)
241
- });
242
- }
243
- }
244
- }
245
- catch (err) {
246
- debugLog(`[WebScholar] Bridge poll failed: ${err}`);
247
- }
248
- }, 10_000); // Poll every 10 seconds
249
- }
250
- export function stopScholarWatcher() {
251
- if (watcherInterval) {
252
- clearInterval(watcherInterval);
253
- watcherInterval = null;
254
- }
255
- }
package/dist/server.js CHANGED
@@ -59,9 +59,9 @@ ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequ
59
59
  // Claude Desktop that the attached resource has changed.
60
60
  // Without this, the paperclipped context becomes stale.
61
61
  SubscribeRequestSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
62
- import { SERVER_CONFIG, SESSION_MEMORY_ENABLED, PRISM_USER_ID, PRISM_ENABLE_HIVEMIND, WATCHDOG_INTERVAL_MS, WATCHDOG_STALE_MIN, WATCHDOG_FROZEN_MIN, WATCHDOG_OFFLINE_MIN, WATCHDOG_LOOP_THRESHOLD, PRISM_SCHEDULER_ENABLED, PRISM_SCHEDULER_INTERVAL_MS, PRISM_SCHOLAR_ENABLED, PRISM_HDC_ENABLED, PRISM_TASK_ROUTER_ENABLED_ENV, PRISM_DARK_FACTORY_ENABLED, } from "./config.js";
62
+ import { SERVER_CONFIG, SESSION_MEMORY_ENABLED, PRISM_USER_ID, PRISM_ENABLE_HIVEMIND, WATCHDOG_INTERVAL_MS, WATCHDOG_STALE_MIN, WATCHDOG_FROZEN_MIN, WATCHDOG_OFFLINE_MIN, WATCHDOG_LOOP_THRESHOLD, PRISM_SCHEDULER_ENABLED, PRISM_SCHEDULER_INTERVAL_MS, PRISM_HDC_ENABLED, PRISM_TASK_ROUTER_ENABLED_ENV, PRISM_DARK_FACTORY_ENABLED, } from "./config.js";
63
63
  import { startWatchdog, drainAlerts } from "./hivemindWatchdog.js";
64
- import { startScheduler, startScholarScheduler } from "./backgroundScheduler.js";
64
+ import { startScheduler } from "./backgroundScheduler.js";
65
65
  import { startDarkFactoryRunner } from "./darkfactory/runner.js";
66
66
  import { getSyncBus } from "./sync/factory.js";
67
67
  import { startDashboardServer } from "./dashboard/server.js";
@@ -1353,16 +1353,12 @@ export async function startServer() {
1353
1353
  console.error(`[Scheduler] Startup failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1354
1354
  });
1355
1355
  }
1356
- // ─── v5.4: Autonomous Web Scholar Scheduler ──────────────
1357
- // Background LLM research pipeline. Independent from the
1358
- // maintenance scheduler has its own interval and enable flag.
1359
- if (PRISM_SCHOLAR_ENABLED && SESSION_MEMORY_ENABLED) {
1360
- storageReady?.then(() => {
1361
- startScholarScheduler();
1362
- }).catch(err => {
1363
- console.error(`[WebScholar] Startup failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1364
- });
1365
- }
1356
+ // ─── v5.4: Autonomous Web Scholar REMOVED (v18.0.0) ────
1357
+ // Auto-scheduler removed. Scholar now runs server-side via
1358
+ // Vercel cron (/api/v1/cron/scholar) every 6h. The client-side
1359
+ // scheduler caused 5,293 garbage entries when multiple MCP
1360
+ // instances ran in parallel. Manual trigger via MCP tool still
1361
+ // works for local/free-tier users. See: webScholar.ts
1366
1362
  // ─── v7.3: Dark Factory Background Runner ────────────────
1367
1363
  // Autonomous pipeline orchestration engine. Picks up RUNNING
1368
1364
  // pipelines and advances them through PLAN → EXECUTE → VERIFY
@@ -63,6 +63,10 @@ async function generateQAPairs(chunk, source) {
63
63
  debugLog("[ingest] No ANTHROPIC_API_KEY — skipping Q&A generation, storing raw chunks");
64
64
  return [{ prompt: `What does this ${source} code do?`, response: chunk.slice(0, 500) }];
65
65
  }
66
+ // PHI redaction BEFORE sending to cloud LLM — the chunk may contain
67
+ // client names in file paths, inline identifiers, or clinical notes.
68
+ const { scanAndRedactPHI } = await import("../utils/phiGuard.js");
69
+ const redactedChunk = scanAndRedactPHI(chunk).redacted;
66
70
  try {
67
71
  const res = await fetch("https://api.anthropic.com/v1/messages", {
68
72
  method: "POST",
@@ -75,7 +79,7 @@ async function generateQAPairs(chunk, source) {
75
79
  model: "claude-haiku-4-5-20251001",
76
80
  max_tokens: 2048,
77
81
  system: 'Generate 3 Q&A training pairs as JSON array: [{"prompt":"...","response":"..."}]. Focus on what the code does, how it works, and key patterns.',
78
- messages: [{ role: "user", content: `Source: ${source}\n\`\`\`\n${chunk.slice(0, 5000)}\n\`\`\`` }],
82
+ messages: [{ role: "user", content: `Source: ${source}\n\`\`\`\n${redactedChunk.slice(0, 5000)}\n\`\`\`` }],
79
83
  }),
80
84
  });
81
85
  if (!res.ok) {
@@ -98,7 +98,7 @@ export async function sessionSaveLedgerHandler(args) {
98
98
  const conversation_id = args.conversation_id;
99
99
  const summary = sanitizeMemoryInput(args.summary);
100
100
  const todos = args.todos ? sanitizeArray(args.todos) : undefined;
101
- const files_changed = args.files_changed;
101
+ const files_changed = args.files_changed ? sanitizeArray(args.files_changed) : undefined;
102
102
  const decisions = args.decisions ? sanitizeArray(args.decisions) : undefined;
103
103
  const role = args.role;
104
104
  const storage = await getStorage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "18.0.0",
3
+ "version": "18.0.2",
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",