From f095285b34c0d442d111564902a17bad661592ca Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Sun, 4 Oct 2026 05:12:33 -0500 Subject: [PATCH] Add a global baseUrl setting for self-hosted servers Muse runs plugin hooks and MCP servers with a cleared environment, so SUPERMEMORY_API_URL set in the shell never reaches them, and the only way to point the plugin at a self-hosted server was a .muse/supermemory.json in every repo. getBaseUrl now also reads baseUrl from ~/.supermemory-muse/settings.json, after the env var and the project config. A settings.json that sets baseUrl but does not parse is an error rather than a silent fall back to the hosted API. When the base URL is not the hosted API, the MCP proxy does not forward to mcp.supermemory.ai unless mcpUrl or SUPERMEMORY_MCP_URL names an endpoint, and SessionStart says where to put a key instead of opening the hosted browser login. Co-Authored-By: Claude Opus 5.5 (1M context) --- .muse-plugin/plugin.json | 2 +- README.md | 9 +++ commands/status.md | 6 +- hooks/lib/settings.js | 32 +++++++- hooks/session-start.js | 22 +++++- mcp/proxy.js | 40 ++++++++-- package.json | 2 +- test/no-network.cjs | 13 +++ test/unit.mjs | 167 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 277 insertions(+), 16 deletions(-) create mode 100644 test/no-network.cjs diff --git a/.muse-plugin/plugin.json b/.muse-plugin/plugin.json index 2c859ed..486e080 100644 --- a/.muse-plugin/plugin.json +++ b/.muse-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "supermemory", "displayName": "Supermemory", - "version": "0.1.1", + "version": "0.1.2", "description": "Persistent memory across Muse Code sessions using Supermemory.", "compat": { "source": "native", diff --git a/README.md b/README.md index 941e9b9..7d06837 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,15 @@ Get a key at [app.supermemory.ai](https://app.supermemory.ai). } ``` +| Option | Description | +| --- | --- | +| `baseUrl` | Supermemory API URL for every project, e.g. a self-hosted server | +| `mcpUrl` | MCP endpoint to use when `baseUrl` is not the hosted API | + +Muse clears the environment for hooks and the MCP proxy, so set a self-hosted URL here rather than in your shell. The order is `SUPERMEMORY_API_URL`, then the project's `baseUrl` (a committed project config overrides this file), then this one, then `https://api.supermemory.ai`. + +With a self-hosted `baseUrl`, the MCP proxy stays off unless `mcpUrl` (or `SUPERMEMORY_MCP_URL`) names an MCP endpoint, and a missing key does not open the hosted browser login: write the server's key to `~/.supermemory-muse/credentials.json`. A key in `~/.supermemory-claude/credentials.json` is also used, so it must belong to the same server. + **Project** — `.muse/supermemory.json` ```json diff --git a/commands/status.md b/commands/status.md index fed20cf..ded5089 100644 --- a/commands/status.md +++ b/commands/status.md @@ -5,10 +5,10 @@ description: Show Supermemory authentication and connection status Report the user's Supermemory status for this Muse Code session. 1. Read `~/.supermemory-muse/credentials.json` (may not exist). Never print the full API key — show at most the first 6 and last 4 characters. Also check `~/.supermemory-claude/credentials.json` as a fallback. The key source is env `SUPERMEMORY_API_KEY` / `SUPERMEMORY_MUSE_API_KEY` when set, otherwise those files. -2. Probe real connectivity with the resolved key: +2. Resolve the base URL: `SUPERMEMORY_API_URL`, else `baseUrl` in the project config at the repo root (`.muse/supermemory.json`, or `.claude/.supermemory-claude/config.json` if that one is absent), else `baseUrl` in `~/.supermemory-muse/settings.json`, else `https://api.supermemory.ai`. Probe real connectivity with the resolved key against that URL (never send a self-hosted key to the hosted API): ``` -curl -sS -o /dev/null -w '%{http_code}' -m 8 -X POST "${SUPERMEMORY_API_URL:-https://api.supermemory.ai}/v4/profile" \ +curl -sS -o /dev/null -w '%{http_code}' -m 8 -X POST "$BASE_URL/v4/profile" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "x-sm-source: muse-code" \ -d '{"containerTag":"","q":"connectivity probe"}' ``` @@ -18,4 +18,4 @@ Interpret loudly: `200` → reachable and the key works; `401`/`403` → reachab 3. Call the `whoAmI` MCP tool if the supermemory MCP server is connected. 4. Report: authenticated or not, key source, the active project container tag, API reachability, and MCP reachability. -If not authenticated, tell the user a new session will open the browser login automatically, or they can write `{"apiKey":"sm_..."}` to `~/.supermemory-muse/credentials.json`. +If not authenticated, tell the user a new session will open the browser login automatically, or they can write `{"apiKey":"sm_..."}` to `~/.supermemory-muse/credentials.json`. With a self-hosted base URL there is no browser login; the key has to be written to that file. diff --git a/hooks/lib/settings.js b/hooks/lib/settings.js index c5466ae..bdab64b 100644 --- a/hooks/lib/settings.js +++ b/hooks/lib/settings.js @@ -12,12 +12,16 @@ const DEFAULT_SETTINGS = { recallDirective: null, }; +function readSettingsText(file) { + return fs.readFileSync(file, 'utf-8').replace(/^\uFEFF/, ''); +} + function loadSettings() { const settings = { ...DEFAULT_SETTINGS }; const file = settingsFile(); try { if (fs.existsSync(file)) { - Object.assign(settings, JSON.parse(fs.readFileSync(file, 'utf-8'))); + Object.assign(settings, JSON.parse(readSettingsText(file))); } } catch (err) { console.error(`Settings: Failed to load ${file}: ${err.message}`); @@ -60,10 +64,29 @@ function normalizeBaseUrl(baseUrl) { } } +// A settings.json that sets baseUrl but does not parse must not fall back to +// the hosted API, or a self-hosted key and transcripts would be sent there. +function globalBaseUrl() { + const file = settingsFile(); + if (!fs.existsSync(file)) return null; + const text = readSettingsText(file); + try { + return JSON.parse(text)?.baseUrl || null; + } catch (err) { + if (text.includes('baseUrl')) { + throw new Error(`Invalid ${file}: ${err.message}`); + } + return null; + } +} + function getBaseUrl(cwd, projectConfig) { projectConfig = projectConfig || loadProjectConfig(cwd || process.cwd()); const configured = - process.env.SUPERMEMORY_API_URL || projectConfig?.baseUrl || BASE_URL; + process.env.SUPERMEMORY_API_URL || + projectConfig?.baseUrl || + globalBaseUrl() || + BASE_URL; const normalized = normalizeBaseUrl(configured); if (!normalized) { throw new Error('Invalid baseUrl: expected an absolute http(s) URL'); @@ -89,7 +112,12 @@ function getRecallConfig(cwd) { }; } +function isCustomBaseUrl(baseUrl) { + return new URL(baseUrl).hostname !== new URL(BASE_URL).hostname; +} + module.exports = { + isCustomBaseUrl, settingsDir, settingsFile, DEFAULT_SETTINGS, diff --git a/hooks/session-start.js b/hooks/session-start.js index 69dfff4..76e152f 100644 --- a/hooks/session-start.js +++ b/hooks/session-start.js @@ -1,7 +1,13 @@ const { getProfile } = require('./lib/api'); const { getContainerTag, getProjectName } = require('./lib/container-tag'); const { loadProjectConfig } = require('./lib/project-config'); -const { loadSettings, getApiKey, getBaseUrl, debugLog } = require('./lib/settings'); +const { + loadSettings, + getApiKey, + getBaseUrl, + isCustomBaseUrl, + debugLog, +} = require('./lib/settings'); const { BRAND, bold, gray } = require('./lib/colors'); const { readStdin, writeOutput } = require('./lib/stdin'); const { startAuthFlow, AUTH_BASE_URL } = require('./lib/auth'); @@ -71,10 +77,22 @@ async function main() { debugLog(settings, 'SessionStart', { cwd, projectName, containerTag }); + const baseUrl = getBaseUrl(cwd, projectConfig); + let apiKey; try { apiKey = getApiKey(cwd, projectConfig); } catch { + if (isCustomBaseUrl(baseUrl)) { + // The hosted browser login cannot issue a key for a self-hosted server. + output( + ` +No API key for ${baseUrl}. Write {"apiKey":"..."} to ~/.supermemory-muse/credentials.json. +`, + [], + ); + return; + } try { apiKey = await startAuthFlow(); } catch (authErr) { @@ -90,8 +108,6 @@ Or write an API key to ~/.supermemory-muse/credentials.json as {"apiKey":"sm_... } } - const baseUrl = getBaseUrl(cwd, projectConfig); - let profileResult = null; let apiError = null; try { diff --git a/mcp/proxy.js b/mcp/proxy.js index 2677562..39907d4 100644 --- a/mcp/proxy.js +++ b/mcp/proxy.js @@ -1,10 +1,33 @@ #!/usr/bin/env node const readline = require('node:readline'); const { getContainerTag } = require('../hooks/lib/container-tag'); -const { getApiKey } = require('../hooks/lib/settings'); +const { + getApiKey, + getBaseUrl, + isCustomBaseUrl, + loadSettings, +} = require('../hooks/lib/settings'); + +const DEFAULT_MCP_URL = 'https://mcp.supermemory.ai/mcp'; + +// A self-hosted baseUrl must not send its key to the hosted MCP server. +function resolveMcpUrl(cwd) { + const explicit = process.env.SUPERMEMORY_MCP_URL || loadSettings().mcpUrl; + if (explicit) return { url: explicit }; + try { + if (isCustomBaseUrl(getBaseUrl(cwd))) { + return { + url: null, + reason: + 'baseUrl points at a self-hosted server. Set mcpUrl in ~/.supermemory-muse/settings.json to enable it.', + }; + } + } catch (err) { + return { url: null, reason: err.message }; + } + return { url: DEFAULT_MCP_URL }; +} -const MCP_URL = - process.env.SUPERMEMORY_MCP_URL || 'https://mcp.supermemory.ai/mcp'; const REQUEST_TIMEOUT_MS = 30000; const REPO_SCOPED_TOOLS = new Set([ @@ -66,7 +89,7 @@ function emitSseData(text) { } } -async function forward(message, apiKey) { +async function forward(message, apiKey, mcpUrl) { const headers = { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', @@ -74,7 +97,7 @@ async function forward(message, apiKey) { }; if (sessionId) headers['Mcp-Session-Id'] = sessionId; - const response = await fetch(MCP_URL, { + const response = await fetch(mcpUrl, { method: 'POST', headers, body: JSON.stringify(message), @@ -108,6 +131,7 @@ async function forward(message, apiKey) { async function main() { const cwd = process.cwd(); + const mcp = resolveMcpUrl(cwd); let apiKey = null; let keyError = null; let repoContainerTag = null; @@ -135,6 +159,10 @@ async function main() { } queue = queue.then(async () => { + if (!mcp.url) { + sendError(message.id, -32002, `Supermemory MCP is off: ${mcp.reason}`); + return; + } if (keyError) { sendError( message.id, @@ -145,7 +173,7 @@ async function main() { } try { injectRepoContainerTag(message, repoContainerTag); - await forward(message, apiKey); + await forward(message, apiKey, mcp.url); } catch (err) { sendError(message.id, -32000, `Supermemory MCP proxy error: ${err.message}`); } diff --git a/package.json b/package.json index 74da177..126d65d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muse-supermemory", - "version": "0.1.1", + "version": "0.1.2", "description": "Muse Code plugin by Supermemory AI", "private": true, "type": "commonjs", diff --git a/test/no-network.cjs b/test/no-network.cjs new file mode 100644 index 0000000..2bff188 --- /dev/null +++ b/test/no-network.cjs @@ -0,0 +1,13 @@ +// Preload for tests that run hook scripts in a child process: no request may +// leave the machine and no browser may open. +const childProcess = require('node:child_process'); + +globalThis.fetch = async (url) => { + throw new Error(`network disabled in tests: ${url}`); +}; + +childProcess.execFile = (command, args, options, callback) => { + const done = typeof options === 'function' ? options : callback; + process.stderr.write(`browser launch disabled: ${command}\n`); + if (done) done(new Error('browser launch disabled in tests')); +}; diff --git a/test/unit.mjs b/test/unit.mjs index 821841c..6026dbf 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -141,3 +141,170 @@ describe('mcp proxy', () => { assert.equal(message.params.arguments.containerTag, 'other'); }); }); + +describe('global baseUrl', () => { + const settingsPath = join(root, 'hooks/lib/settings.js'); + + function freshSettings() { + for (const key of Object.keys(require.cache)) { + if (key.includes(join('hooks', 'lib'))) delete require.cache[key]; + } + return require(settingsPath); + } + + function withHome(t, settings) { + const dir = mkdtempSync(join(tmpdir(), 'muse-sm-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const home = join(dir, 'home'); + mkdirSync(home, { recursive: true }); + if (settings) { + const text = typeof settings === 'string' ? settings : JSON.stringify(settings); + writeFileSync(join(home, 'settings.json'), text); + } + const cwd = join(dir, 'work'); + mkdirSync(cwd, { recursive: true }); + const saved = { + SUPERMEMORY_MUSE_HOME: process.env.SUPERMEMORY_MUSE_HOME, + SUPERMEMORY_API_URL: process.env.SUPERMEMORY_API_URL, + }; + process.env.SUPERMEMORY_MUSE_HOME = home; + delete process.env.SUPERMEMORY_API_URL; + t.after(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + return { dir, home, cwd }; + } + + test('falls back to the hosted API with no settings', (t) => { + const { cwd } = withHome(t, null); + assert.equal(freshSettings().getBaseUrl(cwd), 'https://api.supermemory.ai'); + }); + + test('uses baseUrl from ~/.supermemory-muse/settings.json', (t) => { + const { cwd } = withHome(t, { baseUrl: 'https://sm.example.internal:6767' }); + assert.equal(freshSettings().getBaseUrl(cwd), 'https://sm.example.internal:6767'); + }); + + test('project baseUrl and env still win over the global one', (t) => { + const { cwd } = withHome(t, { baseUrl: 'https://global.example' }); + mkdirSync(join(cwd, '.muse'), { recursive: true }); + writeFileSync( + join(cwd, '.muse', 'supermemory.json'), + JSON.stringify({ baseUrl: 'https://project.example' }), + ); + assert.equal(freshSettings().getBaseUrl(cwd), 'https://project.example'); + process.env.SUPERMEMORY_API_URL = 'https://env.example'; + assert.equal(freshSettings().getBaseUrl(cwd), 'https://env.example'); + }); + + test('a settings.json that sets baseUrl but does not parse is an error', (t) => { + const { cwd } = withHome(t, '{ "baseUrl": "https://sm.example.internal:6767", }'); + assert.throws(() => freshSettings().getBaseUrl(cwd), /Invalid .*settings\.json/); + }); + + test('a broken settings.json without baseUrl still uses the hosted API', (t) => { + const { cwd } = withHome(t, '{ "debug": true, }'); + assert.equal(freshSettings().getBaseUrl(cwd), 'https://api.supermemory.ai'); + }); + + test('a byte order mark in settings.json is ignored', (t) => { + const { cwd } = withHome(t, '\uFEFF{ "baseUrl": "https://sm.example.internal:6767" }'); + assert.equal(freshSettings().getBaseUrl(cwd), 'https://sm.example.internal:6767'); + }); + + test('other spellings of the hosted API are not treated as self-hosted', () => { + const { isCustomBaseUrl } = freshSettings(); + assert.equal(isCustomBaseUrl('https://api.supermemory.ai/'), false); + assert.equal(isCustomBaseUrl('https://API.supermemory.ai'), false); + assert.equal(isCustomBaseUrl('https://api.supermemory.ai:443'), false); + assert.equal(isCustomBaseUrl('https://sm.example.internal:6767'), true); + }); + + function runIsolated(t, script, input, settings, envOverrides = {}, timeout) { + const { dir, home, cwd } = withHome(t, settings); + const env = { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + SUPERMEMORY_MUSE_HOME: home, + HOME: dir, + USERPROFILE: dir, + SUPERMEMORY_MUSE_API_KEY: 'sm_test_dummy', + ...envOverrides, + }; + for (const [k, v] of Object.entries(env)) if (v === undefined) delete env[k]; + return spawnSync( + 'node', + ['--require', join(root, 'test/no-network.cjs'), join(root, script)], + { encoding: 'utf8', input, env, cwd, timeout }, + ); + } + + const initialize = `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })}\n`; + + function lastMessage(result) { + return JSON.parse(result.stdout.trim().split('\n').at(-1)); + } + + test('MCP proxy does not forward a self-hosted key to the hosted MCP', (t) => { + const result = runIsolated(t, 'mcp/proxy.js', initialize, { + baseUrl: 'https://sm.example.internal:6767', + }); + const out = lastMessage(result); + assert.equal(out.error.code, -32002, result.stdout + result.stderr); + assert.match(out.error.message, /self-hosted/); + }); + + test('MCP proxy stays off when settings.json is broken', (t) => { + const result = runIsolated( + t, + 'mcp/proxy.js', + initialize, + '{ "baseUrl": "https://sm.example.internal:6767", }', + ); + const out = lastMessage(result); + assert.equal(out.error.code, -32002, result.stdout + result.stderr); + assert.match(out.error.message, /Invalid .*settings\.json/); + }); + + test('MCP proxy uses mcpUrl with a self-hosted baseUrl', (t) => { + const result = runIsolated(t, 'mcp/proxy.js', initialize, { + baseUrl: 'https://sm.example.internal:6767', + mcpUrl: 'https://sm.example.internal:6767/mcp', + }); + assert.match(lastMessage(result).error.message, /sm\.example\.internal:6767\/mcp/); + }); + + test('MCP proxy still uses the hosted MCP without a baseUrl', (t) => { + const result = runIsolated(t, 'mcp/proxy.js', initialize, null); + assert.match(lastMessage(result).error.message, /mcp\.supermemory\.ai\/mcp/); + }); + + test('SessionStart with a self-hosted URL and no key does not open the hosted login', (t) => { + const result = runIsolated( + t, + 'hooks/session-start.js', + '{}', + { baseUrl: 'https://sm.example.internal:6767' }, + { SUPERMEMORY_MUSE_API_KEY: undefined }, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /No API key for https:\/\/sm\.example\.internal:6767/); + assert.doesNotMatch(result.stderr, /browser launch disabled/); + }); + + test('SessionStart with no baseUrl and no key still starts the hosted login', (t) => { + // The login waits 25 s for a browser callback; the launch happens first. + const result = runIsolated( + t, + 'hooks/session-start.js', + '{}', + null, + { SUPERMEMORY_MUSE_API_KEY: undefined }, + 5000, + ); + assert.match(result.stderr, /browser launch disabled/); + }); +});