prism-mcp-server 20.21.0 → 20.21.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/dist/cli.js CHANGED
@@ -223,6 +223,17 @@ program
223
223
  .option('--no-models', 'Skip model convergence; keep whatever model versions are installed')
224
224
  .action(async (options) => {
225
225
  try {
226
+ // Take the subscription key from the settings store when the environment
227
+ // does not carry it. connect writes into each host's MCP env block only
228
+ // what it can see, so without this a machine that logged in once lost its
229
+ // subscription on the next re-registration and every search fell through
230
+ // to a Brave key in the server's own environment, which a host launched
231
+ // from the graphical shell does not carry.
232
+ {
233
+ const { hydrateSynaluxCredentials } = await import('./utils/synaluxSearch.js');
234
+ const { getSetting } = await import('./storage/configStorage.js');
235
+ await hydrateSynaluxCredentials(getSetting);
236
+ }
226
237
  // ── Converge the PACKAGE first, then the configs ──────────────
227
238
  // connect is the one command the operator runs to make a machine
228
239
  // current; leaving it configuring with stale code produced the
package/dist/connect.js CHANGED
@@ -1163,6 +1163,9 @@ function buildMcpEntry(nodePath, serverPath, env) {
1163
1163
  PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
1164
1164
  PRISM_STORAGE: storage,
1165
1165
  };
1166
+ // process.env is hydrated from the settings store before connect runs, so a
1167
+ // machine that logged in once keeps its subscription across re-registration
1168
+ // instead of silently dropping to unauthenticated search.
1166
1169
  if (env.PRISM_SYNALUX_API_KEY) {
1167
1170
  serverEnv.PRISM_SYNALUX_API_KEY = env.PRISM_SYNALUX_API_KEY;
1168
1171
  }
@@ -12,7 +12,7 @@ import { searchYahooFree, scrapeArticleLocal } from "./freeSearch.js";
12
12
  // The same capability flag performWebSearchRaw itself branches on, imported
13
13
  // from the same module so the gate and the transport cannot disagree about
14
14
  // whether a web search is possible.
15
- import { SYNALUX_SEARCH_AVAILABLE } from "../utils/synaluxSearch.js";
15
+ import { synaluxSearchAvailable } from "../utils/synaluxSearch.js";
16
16
  // ─── Hivemind Integration Helpers ────────────────────────────
17
17
  const SCHOLAR_PROJECT = "prism-scholar";
18
18
  const SCHOLAR_ROLE = "scholar";
@@ -208,7 +208,7 @@ export async function runWebScholar(overrideTopic, overrideProject) {
208
208
  //
209
209
  // Google Custom Search used to take priority over both; now removed
210
210
  // because Google discontinues that API on 2027-01-01.
211
- const useWebSearch = SYNALUX_SEARCH_AVAILABLE || !!BRAVE_API_KEY;
211
+ const useWebSearch = synaluxSearchAvailable() || !!BRAVE_API_KEY;
212
212
  const topic = overrideTopic || await selectTopic();
213
213
  const project = overrideProject || SCHOLAR_PROJECT;
214
214
  if (!topic) {
package/dist/server.js CHANGED
@@ -76,9 +76,10 @@ import { SKILL_SAVE_TOOL, SKILL_MANAGE_TOOL, skillSaveHandler, skillManageHandle
76
76
  // error wrapper. Now uses getStorage() which routes through the
77
77
  // correct backend (Supabase or SQLite) with proper error handling.
78
78
  import { getStorage } from "./storage/index.js";
79
- import { getSettingSync, initConfigStorage } from "./storage/configStorage.js";
79
+ import { getSetting, getSettingSync, initConfigStorage } from "./storage/configStorage.js";
80
80
  import { sanitizeMcpOutput } from "./utils/sanitizer.js";
81
- import { sanitizeForLog } from "./utils/logger.js";
81
+ import { debugLog, sanitizeForLog } from "./utils/logger.js";
82
+ import { hydrateSynaluxCredentials } from "./utils/synaluxSearch.js";
82
83
  import { getTracer, initTelemetry } from "./utils/telemetry.js";
83
84
  import { context as otelContext, trace, SpanStatusCode } from "@opentelemetry/api";
84
85
  import { ddInfo, ddError as ddLogError } from "./utils/ddLogger.js";
@@ -1290,6 +1291,18 @@ export async function startServer() {
1290
1291
  // synchronously. This is a synchronous call — no await needed.
1291
1292
  // No-op when otel_enabled=false (the default).
1292
1293
  initTelemetry();
1294
+ // Put the subscription key in process.env before anything asks whether the
1295
+ // portal is reachable. `prism connect` copies it into the host's MCP env
1296
+ // block only if it was in the environment when connect ran; otherwise it is
1297
+ // in the settings store, and a paid subscriber's searches were failing on a
1298
+ // Brave key in the server's own environment, which a host launched from the
1299
+ // graphical shell does not carry.
1300
+ // getSetting reads the cache initConfigStorage() just warmed, so this adds
1301
+ // no I/O to the path the Initialize handshake waits on.
1302
+ const portalSearchReady = await hydrateSynaluxCredentials(getSetting);
1303
+ if (!portalSearchReady) {
1304
+ debugLog("[Prism] no Synalux subscription key in the environment or the settings store — search falls back to BRAVE_API_KEY");
1305
+ }
1293
1306
  const server = createServer();
1294
1307
  const transport = new StdioServerTransport();
1295
1308
  await server.connect(transport);
@@ -2,6 +2,7 @@ import { PRISM_STORAGE as ENV_PRISM_STORAGE, SUPABASE_CONFIGURED, SYNALUX_CONFIG
2
2
  import { debugLog } from "../utils/logger.js";
3
3
  import { SupabaseStorage } from "./supabase.js";
4
4
  import { getSetting } from "./configStorage.js";
5
+ import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
5
6
  export function isValidHttpUrl(url) {
6
7
  try {
7
8
  const parsed = new URL(url);
@@ -42,23 +43,7 @@ export function isValidHttpUrl(url) {
42
43
  * listener the connection fails afterwards, and the log line is what explains
43
44
  * why.
44
45
  */
45
- export function upgradeInsecureCloudUrl(raw) {
46
- try {
47
- const parsed = new URL(raw);
48
- if (parsed.protocol !== "http:")
49
- return raw;
50
- const host = parsed.hostname;
51
- if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]")
52
- return raw;
53
- parsed.protocol = "https:";
54
- const upgraded = parsed.toString().replace(/\/+$/, "");
55
- debugLog(`[Prism Storage] Upgraded ${raw} to ${upgraded}: session content is never sent over plaintext to a remote host.`);
56
- return upgraded;
57
- }
58
- catch {
59
- return raw;
60
- }
61
- }
46
+ export { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
62
47
  /**
63
48
  * Probe for synalux credentials: env vars first, then config DB.
64
49
  * Returns true if usable credentials are now in process.env.
@@ -31,7 +31,7 @@
31
31
  import { BRAVE_API_KEY, BRAVE_ANSWERS_API_KEY } from "../config.js";
32
32
  import { debugLog } from "./logger.js";
33
33
  import { isPortalPlanRefusal } from "./portalError.js";
34
- import { SYNALUX_SEARCH_AVAILABLE, synaluxWebSearch, synaluxWebSearchRaw, synaluxLocalSearch, synaluxLocalSearchRaw, synaluxBraveAnswers, } from "./synaluxSearch.js";
34
+ import { synaluxSearchAvailable, synaluxWebSearch, synaluxWebSearchRaw, synaluxLocalSearch, synaluxLocalSearchRaw, synaluxBraveAnswers, } from "./synaluxSearch.js";
35
35
  const BRAVE_API_KEY_MISSING_ERROR = "BRAVE_API_KEY is not configured";
36
36
  const BRAVE_ANSWERS_API_KEY_MISSING_ERROR = "BRAVE_ANSWERS_API_KEY is not configured";
37
37
  const SYNALUX_OFFSET_UNSUPPORTED_ERROR = "Configured Synalux search does not support search offsets";
@@ -65,7 +65,7 @@ async function portalFirst(viaPortal, ownKey, viaOwnKey) {
65
65
  }
66
66
  // Brave Answers API call (AI Grounding/OpenAI-compatible)
67
67
  export async function performBraveAnswers(query, model = "brave") {
68
- if (SYNALUX_SEARCH_AVAILABLE) {
68
+ if (synaluxSearchAvailable()) {
69
69
  return portalFirst(() => synaluxBraveAnswers(query, model), BRAVE_ANSWERS_API_KEY, () => braveAnswersDirect(query, model));
70
70
  }
71
71
  return braveAnswersDirect(query, model);
@@ -102,7 +102,7 @@ async function braveAnswersDirect(query, model) {
102
102
  }
103
103
  // Raw web search API call
104
104
  export async function performWebSearchRaw(query, count = 10, offset = 0) {
105
- if (SYNALUX_SEARCH_AVAILABLE) {
105
+ if (synaluxSearchAvailable()) {
106
106
  if (offset !== 0)
107
107
  throw new Error(SYNALUX_OFFSET_UNSUPPORTED_ERROR);
108
108
  return portalFirst(() => synaluxWebSearchRaw(query, count), BRAVE_API_KEY, () => braveWebSearchRaw(query, count, offset));
@@ -131,7 +131,7 @@ async function braveWebSearchRaw(query, count, offset) {
131
131
  }
132
132
  // Web search API call
133
133
  export async function performWebSearch(query, count = 10, offset = 0) {
134
- if (SYNALUX_SEARCH_AVAILABLE) {
134
+ if (synaluxSearchAvailable()) {
135
135
  if (offset !== 0)
136
136
  throw new Error(SYNALUX_OFFSET_UNSUPPORTED_ERROR);
137
137
  return portalFirst(() => synaluxWebSearch(query, count), BRAVE_API_KEY, () => braveWebSearch(query, count, offset));
@@ -197,7 +197,7 @@ function chunkArray(arr, size) {
197
197
  }
198
198
  // Raw local search API call with poi/details payload
199
199
  export async function performLocalSearchRaw(query, count = 5) {
200
- if (SYNALUX_SEARCH_AVAILABLE) {
200
+ if (synaluxSearchAvailable()) {
201
201
  return portalFirst(() => synaluxLocalSearchRaw(query, count), BRAVE_API_KEY, () => braveLocalSearchRaw(query, count));
202
202
  }
203
203
  return braveLocalSearchRaw(query, count);
@@ -260,7 +260,7 @@ async function braveLocalSearchRaw(query, count) {
260
260
  }
261
261
  // Local search API call with poi details
262
262
  export async function performLocalSearch(query, count = 5) {
263
- if (SYNALUX_SEARCH_AVAILABLE) {
263
+ if (synaluxSearchAvailable()) {
264
264
  return portalFirst(() => synaluxLocalSearch(query, count), BRAVE_API_KEY, () => braveLocalSearch(query, count));
265
265
  }
266
266
  return braveLocalSearch(query, count);
@@ -278,20 +278,29 @@ async function braveLocalSearch(query, count) {
278
278
  export function formatLocalResults(poisData, descData) {
279
279
  return ((poisData.results || [])
280
280
  .map((poi) => {
281
- const address = [
282
- poi.address?.streetAddress ?? "",
283
- poi.address?.addressLocality ?? "",
284
- poi.address?.addressRegion ?? "",
285
- poi.address?.postalCode ?? "",
286
- ]
287
- .filter((part) => part !== "")
288
- .join(", ") || "N/A";
289
- return `Name: ${poi.name || poi.title || "N/A"}
281
+ // Brave sends postal_address.displayAddress, contact.telephone and
282
+ // opening_hours. The streetAddress/phone/openingHours shapes below have
283
+ // never appeared on a POI, which is why every venue came back N/A while
284
+ // the name resolved through the title fallback. Measured 2026-09-16.
285
+ const today = poi.opening_hours?.current_day?.[0];
286
+ const hours = (poi.openingHours || []).join(", ")
287
+ || (today?.opens && today?.closes ? `${today.opens}-${today.closes}` : "")
288
+ || "N/A";
289
+ const address = poi.postal_address?.displayAddress ||
290
+ [
291
+ poi.address?.streetAddress ?? "",
292
+ poi.address?.addressLocality ?? "",
293
+ poi.address?.addressRegion ?? "",
294
+ poi.address?.postalCode ?? "",
295
+ ]
296
+ .filter((part) => part !== "")
297
+ .join(", ") || "N/A";
298
+ return `Name: ${poi.title || poi.name || "N/A"}
290
299
  Address: ${address}
291
- Phone: ${poi.phone || "N/A"}
292
- Rating: ${poi.rating?.ratingValue ?? "N/A"} (${poi.rating?.ratingCount ?? 0} reviews)
293
- Price Range: ${poi.priceRange || "N/A"}
294
- Hours: ${(poi.openingHours || []).join(", ") || "N/A"}
300
+ Phone: ${poi.contact?.telephone || poi.phone || "N/A"}
301
+ Rating: ${poi.rating?.ratingValue ?? "N/A"} (${poi.rating?.reviewCount ?? poi.rating?.ratingCount ?? 0} reviews)
302
+ Price Range: ${poi.price_range || poi.priceRange || "N/A"}
303
+ Hours: ${hours}
295
304
  Description: ${descData.descriptions[poi.id] || "No description available"}
296
305
  `;
297
306
  })
@@ -9,8 +9,9 @@
9
9
  * Authenticated users get their plan from the portal (5-minute cache).
10
10
  */
11
11
  import { getSynaluxJwt } from "./synaluxJwt.js";
12
- import { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED } from "../config.js";
12
+ import { SYNALUX_CONFIGURED } from "../config.js";
13
13
  import { debugLog } from "./logger.js";
14
+ import { resolvePortalBaseUrl, usablePortalKey } from "./synaluxSearch.js";
14
15
  /** What a host with NO portal (unconfigured), a portal that says nothing
15
16
  * (older deployment), or an assumed-free fallback gets: OFF. Multi-turn is
16
17
  * a paid-plan feature (owner decision 2026-09-15); a client default that
@@ -101,10 +102,13 @@ export function clampCeiling(requested, planCeiling) {
101
102
  }
102
103
  // ── Fetch ─────────────────────────────────────────────────────────
103
104
  async function fetchEntitlements() {
104
- // Re-read process.env because dashboard/bootstrap configuration can inject
105
- // credentials after config.ts captured its module-load constants.
106
- const baseUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() || PRISM_SYNALUX_BASE_URL;
107
- const apiKey = process.env.PRISM_SYNALUX_API_KEY?.trim();
105
+ // Resolve through the SAME helper the search client uses. These two answered
106
+ // "is the portal usable" differently before: entitlements ignored the legacy
107
+ // SYNALUX_BASE_URL alias, accepted an unexpanded ${...} template, and never
108
+ // checked the value was a URL, so a host whose alias arrived after load had
109
+ // working search and a free-tier plan. Measured 2026-09-16.
110
+ const baseUrl = resolvePortalBaseUrl();
111
+ const apiKey = usablePortalKey();
108
112
  if ((!SYNALUX_CONFIGURED && !apiKey) || !baseUrl) {
109
113
  debugLog("[entitlements] no Synalux auth configured — free tier");
110
114
  return { ...FREE_ENTITLEMENTS, source: "unconfigured" };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * One definition of "never send this over plaintext".
3
+ *
4
+ * This lived inside storage/index.ts, so only the storage path applied it. When
5
+ * startup hydration began publishing PRISM_SYNALUX_BASE_URL from the settings
6
+ * store, it bypassed the control entirely: a self-hosted portal stored as
7
+ * `http://portal.internal.example` was published unchanged, and the search
8
+ * client then POSTed the query plus `Authorization: Bearer <JWT>` to it in the
9
+ * clear. Measured 2026-09-16.
10
+ *
11
+ * Loopback is exempt because there is no network to intercept.
12
+ */
13
+ import { debugLog } from "./logger.js";
14
+ export function upgradeInsecureCloudUrl(raw) {
15
+ try {
16
+ const parsed = new URL(raw);
17
+ if (parsed.protocol !== "http:")
18
+ return raw;
19
+ const host = parsed.hostname;
20
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]")
21
+ return raw;
22
+ parsed.protocol = "https:";
23
+ const upgraded = parsed.toString().replace(/\/+$/, "");
24
+ debugLog(`[Prism] Upgraded ${raw} to ${upgraded}: content is never sent over plaintext to a remote host.`);
25
+ return upgraded;
26
+ }
27
+ catch {
28
+ return raw;
29
+ }
30
+ }
@@ -14,22 +14,113 @@
14
14
  * Auth uses the shared JWT exchange from synaluxJwt.ts (same
15
15
  * refresh-token dance as SynaluxStorage, but without requiring
16
16
  * the full storage class). Falls back gracefully: callers check
17
- * SYNALUX_SEARCH_AVAILABLE before calling.
17
+ * synaluxSearchAvailable() before calling.
18
18
  */
19
19
  import { debugLog } from "./logger.js";
20
+ import { upgradeInsecureCloudUrl } from "./secureUrl.js";
20
21
  import { PortalHttpError } from "./portalError.js";
21
22
  import { getSynaluxJwt, invalidateSynaluxJwt } from "./synaluxJwt.js";
22
- import { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED, } from "../config.js";
23
+ import { PRISM_SYNALUX_API_KEY, PRISM_SYNALUX_BASE_URL, } from "../config.js";
23
24
  // ─── Public availability flag ────────────────────────────────
24
- /** True when Synalux portal credentials are configured. */
25
- export const SYNALUX_SEARCH_AVAILABLE = SYNALUX_CONFIGURED;
25
+ /** A `${...}` template the shell never expanded is not a credential. config.ts
26
+ * rejects those at load; the live read rejects them too, or a half-written host
27
+ * config sends every search to a portal that can only answer 401. */
28
+ function usableEnvValue(raw) {
29
+ const trimmed = raw?.trim();
30
+ if (!trimmed || trimmed.includes("${"))
31
+ return undefined;
32
+ return trimmed;
33
+ }
34
+ function isHttpUrl(value) {
35
+ try {
36
+ const parsed = new URL(value);
37
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /** Whether the portal can serve a search, resolved at CALL time.
44
+ *
45
+ * This used to be a module-load constant taken from SYNALUX_CONFIGURED, and
46
+ * that was wrong for the most common installation. `prism connect` copies the
47
+ * subscription key into the host's MCP env block only when the key already
48
+ * happened to be in the environment; otherwise it lives in Prism's settings
49
+ * store and reaches process.env during startup. A constant read before that
50
+ * froze "unconfigured" for the life of the process, so a paying subscriber's
51
+ * every search skipped the portal and demanded a Brave key they had no reason
52
+ * to own — while entitlements, resolved later from the same hydrated key,
53
+ * correctly reported their paid plan. Measured 2026-09-16.
54
+ *
55
+ * The resolution order below is deliberately the same one fetchEntitlements()
56
+ * uses (live env first, module-load constant as the fallback). Search and
57
+ * entitlements answering "is the portal usable" differently IS the defect. */
58
+ export function resolvePortalBaseUrl() {
59
+ // Each candidate is validated in turn: a typo in one host config must fall
60
+ // through to the settings store rather than disabling portal search outright.
61
+ for (const candidate of [
62
+ usableEnvValue(process.env.PRISM_SYNALUX_BASE_URL),
63
+ usableEnvValue(process.env.SYNALUX_BASE_URL),
64
+ PRISM_SYNALUX_BASE_URL,
65
+ ]) {
66
+ if (!candidate || !isHttpUrl(candidate))
67
+ continue;
68
+ // Never hand back a cleartext transport URL for a remote host: this client
69
+ // sends the query and a bearer JWT over it.
70
+ return upgradeInsecureCloudUrl(candidate).replace(/\/+$/, "");
71
+ }
72
+ return undefined;
73
+ }
74
+ /** The subscription key, resolved the one way every portal client resolves it. */
75
+ export function usablePortalKey() {
76
+ return usableEnvValue(process.env.PRISM_SYNALUX_API_KEY) ?? PRISM_SYNALUX_API_KEY;
77
+ }
78
+ export function synaluxSearchAvailable() {
79
+ return !!resolvePortalBaseUrl() && !!usablePortalKey();
80
+ }
81
+ /** Put the subscription key where every consumer reads it — process.env —
82
+ * taking the settings store as the fallback source. Idempotent, and it never
83
+ * overwrites a value the environment already supplied. */
84
+ export async function hydrateSynaluxCredentials(getSetting) {
85
+ try {
86
+ const baseUrl = usableEnvValue(process.env.PRISM_SYNALUX_BASE_URL)
87
+ ?? usableEnvValue(process.env.SYNALUX_BASE_URL)
88
+ ?? usableEnvValue(await getSetting("PRISM_SYNALUX_BASE_URL", ""))
89
+ ?? usableEnvValue(await getSetting("SYNALUX_BASE_URL", ""))
90
+ ?? PRISM_SYNALUX_BASE_URL;
91
+ // Validate AND secure before publishing. process.env is shared with storage
92
+ // and entitlements, so a settings row holding a non-URL would poison every
93
+ // portal client in the process — and a cleartext http:// row would put the
94
+ // bearer JWT of every one of them on the wire in the clear.
95
+ if (baseUrl && isHttpUrl(baseUrl)) {
96
+ process.env.PRISM_SYNALUX_BASE_URL =
97
+ upgradeInsecureCloudUrl(baseUrl).replace(/\/+$/, "");
98
+ }
99
+ const apiKey = usableEnvValue(process.env.PRISM_SYNALUX_API_KEY)
100
+ ?? usableEnvValue(await getSetting("PRISM_SYNALUX_API_KEY", ""))
101
+ ?? PRISM_SYNALUX_API_KEY;
102
+ if (apiKey)
103
+ process.env.PRISM_SYNALUX_API_KEY = apiKey;
104
+ return synaluxSearchAvailable();
105
+ }
106
+ catch {
107
+ return synaluxSearchAvailable(); // a settings read must never break startup
108
+ }
109
+ }
26
110
  // ─── Internal helpers ────────────────────────────────────────
27
111
  /**
28
112
  * POST to a portal endpoint with JWT auth. Retries once on 401
29
113
  * (JWT may have just expired). Throws on network or HTTP errors.
30
114
  */
31
115
  async function portalPost(path, body, timeoutMs = 15_000) {
32
- const baseUrl = PRISM_SYNALUX_BASE_URL.replace(/\/+$/, "");
116
+ // Resolved, never the module-load constant: availability is now decided from
117
+ // the live environment, so a portal that is reachable must also be addressable.
118
+ // Reading a frozen constant here would throw on the very installation this
119
+ // routing exists to serve.
120
+ const baseUrl = resolvePortalBaseUrl();
121
+ if (!baseUrl) {
122
+ throw new Error("[synaluxSearch] no Synalux portal base URL is configured");
123
+ }
33
124
  const url = `${baseUrl}${path}`;
34
125
  const send = async (jwt) => {
35
126
  return fetch(url, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.0",
3
+ "version": "20.21.2",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",