From 29fc80bddd5f5045805828759b3e1a8374aa85e0 Mon Sep 17 00:00:00 2001 From: mthines Date: Sat, 17 Oct 2026 13:47:05 +0200 Subject: [PATCH 1/8] feat(cli): warn in doctor when the MCP endpoint lacks the /mcp suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure classifyMcpEndpoint + MCP_PATH_SUFFIX shared with mcpToRestBase, thread a source id through resolveProjectConnection/ resolveConnection/loadControl, and use both in doctor's endpointCheck to WARN (never FAIL) with the offending source and a fix command — closing the gap where a suffix-missing endpoint passed every doctor check via the suffix-agnostic REST probes and then 404s for MCP clients. --- packages/cli/src/commands/doctor.mjs | 82 +++++++++++- packages/cli/src/shared/config.mjs | 49 ++++++-- packages/cli/src/shared/control.mjs | 2 +- packages/cli/src/shared/mcp.mjs | 84 ++++++++++++- packages/cli/test/config.test.mjs | 139 ++++++++++++++++++++- packages/cli/test/doctor.test.mjs | 179 +++++++++++++++++++++++++++ 6 files changed, 519 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/doctor.mjs b/packages/cli/src/commands/doctor.mjs index b90252c2f..b24786046 100644 --- a/packages/cli/src/commands/doctor.mjs +++ b/packages/cli/src/commands/doctor.mjs @@ -15,9 +15,10 @@ import { readMcpConfig, tokenKind, readLorekitJson, + isWebMcpServerEntry, } from '../shared/config.mjs'; import { checkSkillVersions } from '../shared/skill-versions.mjs'; -import { splitEndpoint } from '../shared/mcp.mjs'; +import { splitEndpoint, classifyMcpEndpoint } from '../shared/mcp.mjs'; import { resolveTelemetryConfig, resolveTelemetryTokenSource, @@ -305,6 +306,72 @@ async function checkLocal(control, root, args, record) { if (args.deep) await deepCheckLocal(store, scope, record); } +/** + * Where a resolved endpoint can have come from, keyed by the `source` id + * `resolveProjectConnection`/`resolveConnection` attach to their result (and + * `checkRemote` derives for the `--endpoint` flag / project `.mcp.json` + * branches it resolves directly). One record, not parallel label/fix maps — + * adding a new source is one entry here, not a matching `if` in two places. + */ +export const ENDPOINT_SOURCES = { + flag: { label: '--endpoint flag', fix: (u) => `pass \`--endpoint ${u}\`` }, + 'project-mcp': { label: '.mcp.json', fix: (u) => `run \`lorekit install --project --endpoint ${u}\`` }, + 'project-mcp-web': { + label: '.mcp.json (committable web entry)', + fix: (u) => `run \`lorekit install --project --mcp-json --endpoint ${u}\``, + }, + 'global-mcp': { label: '~/.claude.json', fix: (u) => `run \`lorekit install --global --endpoint ${u}\`` }, + 'lorekit-json': { + label: '.lorekit.json mcp.endpoint', + fix: (u) => `set "mcp.endpoint" to ${u} in .lorekit.json`, + }, + 'env:LOREKIT_MCP_URL': { label: 'LOREKIT_MCP_URL', fix: (u) => `export LOREKIT_MCP_URL=${u}` }, + 'env:LOREKIT_ENDPOINT': { label: 'LOREKIT_ENDPOINT', fix: (u) => `export LOREKIT_ENDPOINT=${u}` }, +}; + +// An unrecognised or missing source (e.g. a future resolver branch this map +// hasn't caught up with yet) still gets a usable warning instead of a thrown +// error — degraded attribution, not a crash. +const UNKNOWN_ENDPOINT_SOURCE = { + label: 'resolved config', + fix: (u) => `run \`lorekit install --endpoint ${u}\``, +}; + +// Used in the fix text when `classifyMcpEndpoint` could not guess a safe +// replacement URL (an invalid URL, or a custom domain with an unrecognised +// path) — never silently omitted, so the check always says what endpoint a +// fix needs to end with, even without a concrete suggestion to offer. +const SUGGESTED_URL_PLACEHOLDER = ''; + +/** + * Pure: judge a resolved endpoint's shape and report a doctor-shaped result. + * Never FAILs — a custom reverse proxy can legitimately serve MCP on an + * unconventional path, so this cannot prove an endpoint is broken, only that + * its shape is suspicious. See `classifyMcpEndpoint` for the shape rules. + * + * @returns {{ kind: 'pass', detail: string } | { kind: 'warn', detail: string }} + */ +export function endpointCheck(endpoint, source) { + const classification = classifyMcpEndpoint(endpoint); + if (classification.kind === 'ok') { + return { kind: 'pass', detail: endpoint }; + } + + const { label, fix } = ENDPOINT_SOURCES[source] || UNKNOWN_ENDPOINT_SOURCE; + const reason = + classification.kind === 'invalid-url' + ? 'is not a valid http(s) URL' + : `does not end in ${classification.expected}`; + const fixTarget = (classification.kind === 'wrong-path' && classification.suggested) || SUGGESTED_URL_PLACEHOLDER; + + return { + kind: 'warn', + detail: + `${endpoint} ${reason} (from ${label}) — MCP clients will 404 even though the ` + + `REST-based connectivity/authentication probes pass. fix: ${fix(fixTarget)}`, + }; +} + async function checkRemote(control, root, args, record) { const override = { endpoint: args.endpoint || null, token: args.token || null }; const mcp = readMcpConfig(root); @@ -314,6 +381,16 @@ async function checkRemote(control, root, args, record) { const endpoint = override.endpoint || fromMcp.endpoint || control.connection.endpoint; const token = override.token || fromMcp.token || control.connection.token; + // Resolved in the SAME precedence as `endpoint` above, so the `endpoint` + // check below can name exactly which config source produced it — re-reading + // the files here would risk drifting from that precedence; this just labels + // the branch already taken. + const endpointSource = override.endpoint + ? 'flag' + : fromMcp.endpoint + ? (isWebMcpServerEntry(configured.server) ? 'project-mcp-web' : 'project-mcp') + : control.connection.source; + if (mcp.present && !mcp.valid) { record('fail', '.mcp.json', 'invalid JSON — fix it or re-run `lorekit install`'); } else if (configured) { @@ -327,7 +404,8 @@ async function checkRemote(control, root, args, record) { } else if (endpoint.includes('')) { record('fail', 'endpoint', `still a placeholder: ${endpoint}`); } else { - record('pass', 'endpoint', endpoint); + const check = endpointCheck(endpoint, endpointSource); + record(check.kind, 'endpoint', check.detail); } const kind = tokenKind(token); diff --git a/packages/cli/src/shared/config.mjs b/packages/cli/src/shared/config.mjs index d83f55090..3b139fec1 100644 --- a/packages/cli/src/shared/config.mjs +++ b/packages/cli/src/shared/config.mjs @@ -610,17 +610,28 @@ export function copyDir(src, dest, { force = false } = {}) { return written; } -// Resolve endpoint + token from flags, then env, in that order. +// Resolve endpoint + token from flags, then env, in that order. `source` +// names which one won (`flag` / `env:LOREKIT_MCP_URL` / `env:LOREKIT_ENDPOINT`), +// or `null` when no endpoint was resolved at all — `doctor` reads it to name +// where a wrong-shaped endpoint came from without re-deriving this precedence. export function resolveConnection(args) { - const endpoint = - args.endpoint || - process.env.LOREKIT_MCP_URL || - process.env.LOREKIT_ENDPOINT || - null; + let endpoint = null; + let source = null; + if (args.endpoint) { + endpoint = args.endpoint; + source = 'flag'; + } else if (process.env.LOREKIT_MCP_URL) { + endpoint = process.env.LOREKIT_MCP_URL; + source = 'env:LOREKIT_MCP_URL'; + } else if (process.env.LOREKIT_ENDPOINT) { + endpoint = process.env.LOREKIT_ENDPOINT; + source = 'env:LOREKIT_ENDPOINT'; + } const token = args.token || process.env.LOREKIT_TOKEN || null; return { endpoint: typeof endpoint === 'string' ? endpoint.trim() : null, token: typeof token === 'string' ? token.trim() : null, + source: endpoint ? source : null, }; } @@ -652,19 +663,34 @@ export function tokenKind(token) { // LOREKIT_TOKEN was exported — so a tokenless source is only remembered as an // endpoint fallback, and the loop keeps looking for a stored token. export function resolveProjectConnection(root, splitEndpoint) { - const sources = [readLorekitServer(root), readServerFromFile(mcpConfigPath(root, 'global'))]; + // Each candidate source carries its own id so the winning branch can report + // WHERE the endpoint came from (doctor's `source` field) without re-reading + // the files a second time and re-deriving this same precedence. + const sources = [ + { configured: readLorekitServer(root), source: 'project-mcp' }, + { configured: readServerFromFile(mcpConfigPath(root, 'global')), source: 'global-mcp' }, + ]; let endpointOnly = null; // closest usable endpoint that carried no token (e.g. the web .mcp.json) - for (const configured of sources) { + let endpointOnlySource = null; + for (const { configured, source } of sources) { if (!configured || !configured.url) continue; const { endpoint, token } = splitEndpoint(configured.url); if (!endpoint || endpoint.includes('')) continue; - if (token) return { endpoint, token }; // a stored token wins; project (closest) beats global - if (!endpointOnly) endpointOnly = endpoint; // remember the closest tokenless source, keep looking + // The committable web form (auth via a `--header` referencing an env var) + // is a distinct source id from a plain embedded-token/plain-URL entry, so + // a WARN naming it points at `install --mcp-json`, not a bare `install`. + const resolvedSource = + source === 'project-mcp' && isWebMcpServerEntry(configured.server) ? 'project-mcp-web' : source; + if (token) return { endpoint, token, source: resolvedSource }; // a stored token wins; project (closest) beats global + if (!endpointOnly) { + endpointOnly = endpoint; // remember the closest tokenless source, keep looking + endpointOnlySource = resolvedSource; + } } if (endpointOnly) { // No source stored a token — the committable web .mcp.json is exactly this // case. Fall back to the env token so the local CLI still authenticates. - return { endpoint: endpointOnly, token: process.env.LOREKIT_TOKEN || null }; + return { endpoint: endpointOnly, token: process.env.LOREKIT_TOKEN || null, source: endpointOnlySource }; } // Fallback: `mcp.endpoint` in .lorekit.json — a committable URL without token. @@ -678,6 +704,7 @@ export function resolveProjectConnection(root, splitEndpoint) { return { endpoint: committedEndpoint, token: process.env.LOREKIT_TOKEN || null, + source: 'lorekit-json', }; } diff --git a/packages/cli/src/shared/control.mjs b/packages/cli/src/shared/control.mjs index 9cba002f1..dcf7e1f21 100644 --- a/packages/cli/src/shared/control.mjs +++ b/packages/cli/src/shared/control.mjs @@ -563,7 +563,7 @@ export function loadControl(root, { env = process.env } = {}) { const usable = Boolean( conn.endpoint && conn.token && !String(conn.endpoint).includes(''), ); - const connection = { endpoint: conn.endpoint, token: conn.token, usable }; + const connection = { endpoint: conn.endpoint, token: conn.token, usable, source: conn.source }; return resolveControl({ env, userConfig, repoConfig, connection, root, home }); } diff --git a/packages/cli/src/shared/mcp.mjs b/packages/cli/src/shared/mcp.mjs index 7e1249144..a22bc62aa 100644 --- a/packages/cli/src/shared/mcp.mjs +++ b/packages/cli/src/shared/mcp.mjs @@ -128,6 +128,27 @@ function parseBody(text) { } } +/** + * The shape of "this URL ends in /mcp" — shared by `mcpToRestBase` (which + * strips it to get the REST base) and `classifyMcpEndpoint` (which judges + * whether an endpoint has it). One regex, one concept: the two must always + * agree on what counts as the suffix, or doctor could warn on an endpoint + * the REST-based probes treat as perfectly fine (or vice versa). + */ +export const MCP_PATH_SUFFIX = /\/mcp\/?$/; + +// The stricter shape required on a hosted Supabase function host, where only +// paths under /functions/v1/ are routed at all — a bare /mcp on such a host +// (e.g. https://ref.supabase.co/mcp) 404s even though it satisfies the +// generic MCP_PATH_SUFFIX. +const SUPABASE_FUNCTIONS_MCP_SUFFIX = /\/functions\/v1\/mcp\/?$/; + +// Does this URL's pathname contain a /functions/v1 segment? Captures the +// prefix up to and including it, so a suggestion can be built by appending +// /mcp — this works for both a Supabase host and a custom domain fronting +// Supabase Functions behind a proxy. +const FUNCTIONS_V1_PREFIX = /^(.*\/functions\/v1)(?:\/.*)?$/; + /** * Derive the REST API base URL from an MCP endpoint URL. * e.g. 'https://ref.supabase.co/functions/v1/mcp?token=...' @@ -139,13 +160,74 @@ export function mcpToRestBase(mcpEndpointUrl) { const u = new URL(mcpEndpointUrl); u.searchParams.delete('token'); // Strip /mcp suffix (with or without trailing slash) - const restPath = u.pathname.replace(/\/mcp\/?$/, ''); + const restPath = u.pathname.replace(MCP_PATH_SUFFIX, ''); return `${u.origin}${restPath || '/'}`; } catch { return null; } } +/** + * Pure, offline shape-check of a resolved MCP endpoint — never throws, never + * touches the network. Used by `lorekit doctor` to warn when an endpoint is + * reachable (and even authenticates, via the suffix-agnostic REST probes) + * but is shaped wrong for an actual MCP client, which always 404s on it. + * + * Classification, in order: + * 1. Not a parseable http(s) URL → `invalid-url`. + * 2. A `*.supabase.co` host → must end in `/functions/v1/mcp` (the only + * path hosted Supabase Functions route traffic under). + * 3. Any other host → must end in `/mcp` (covers custom domains and + * reverse proxies, which may legitimately serve MCP off a bare path). + * + * `suggested` is a best-effort fix URL (origin + path only, never the query + * string) and is `null` whenever guessing would mean inventing a path on an + * unknown custom domain. + * + * @param {string} endpoint + * @returns {{ kind: 'ok' } + * | { kind: 'invalid-url' } + * | { kind: 'wrong-path', expected: '/mcp' | '/functions/v1/mcp', suggested: string | null }} + */ +export function classifyMcpEndpoint(endpoint) { + let u; + try { + u = new URL(endpoint); + } catch { + return { kind: 'invalid-url' }; + } + if (u.protocol !== 'http:' && u.protocol !== 'https:') { + return { kind: 'invalid-url' }; + } + + const isSupabaseHost = u.hostname.endsWith('.supabase.co'); + const pathOk = isSupabaseHost + ? SUPABASE_FUNCTIONS_MCP_SUFFIX.test(u.pathname) + : MCP_PATH_SUFFIX.test(u.pathname); + if (pathOk) return { kind: 'ok' }; + + return { + kind: 'wrong-path', + expected: isSupabaseHost ? '/functions/v1/mcp' : '/mcp', + suggested: suggestMcpEndpoint(u, isSupabaseHost), + }; +} + +// Best-effort fix target for a `wrong-path` endpoint. Strips a trailing +// slash first so '/functions/v1/' and '/functions/v1' suggest identically. +function suggestMcpEndpoint(u, isSupabaseHost) { + const pathname = u.pathname.replace(/\/$/, ''); + + const functionsMatch = pathname.match(FUNCTIONS_V1_PREFIX); + if (functionsMatch) return `${u.origin}${functionsMatch[1]}/mcp`; + + if (isSupabaseHost && (pathname === '' || pathname === '/mcp')) { + return `${u.origin}/functions/v1/mcp`; + } + + return null; +} + /** * Minimal REST fetch for LoreKit REST API endpoints. * Returns { ok, httpStatus, data, error, networkError } — same shape as mcpCall. diff --git a/packages/cli/test/config.test.mjs b/packages/cli/test/config.test.mjs index a2a622aa4..6fd2c0202 100644 --- a/packages/cli/test/config.test.mjs +++ b/packages/cli/test/config.test.mjs @@ -159,7 +159,7 @@ test('copyDir reports how many files it actually wrote', () => { }); // ── mcp.endpoint in .lorekit.json ───────────────────────────────────────────── -import { resolveProjectConnection } from '../src/shared/config.mjs'; +import { resolveProjectConnection, resolveConnection } from '../src/shared/config.mjs'; import { splitEndpoint } from '../src/shared/mcp.mjs'; test('resolveProjectConnection uses mcp.endpoint from .lorekit.json as fallback', () => { @@ -488,3 +488,140 @@ test('removeWebMcpServer removes the web form but leaves an embedded-token entry assert.equal(removeWebMcpServer(embedded).removed, false, 'embedded-token entry is not the web form'); assert.ok(readMcpConfig(embedded).config.mcpServers.lorekit, 'embedded-token entry preserved'); }); + +// ── `source` attribution (feeds doctor's endpoint-shape warning) ───────────── +// +// `resolveProjectConnection` / `resolveConnection` now report WHICH config +// source produced the resolved endpoint, so `doctor` can name it (and the +// matching fix command) without re-deriving this same precedence by re-reading +// the files. Every test here pins HOME/USERPROFILE to an empty temp dir and +// clears the LOREKIT_MCP_URL/LOREKIT_ENDPOINT/LOREKIT_TOKEN env vars first — +// the three pre-existing `resolveProjectConnection` tests above do NOT do this +// and leak the developer's real ~/.claude.json (known, pre-existing baseline +// reds; not fixed here per the plan). +import { loadControl } from '../src/shared/control.mjs'; + +function withIsolatedConnectionEnv(home, fn) { + const prev = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + LOREKIT_MCP_URL: process.env.LOREKIT_MCP_URL, + LOREKIT_ENDPOINT: process.env.LOREKIT_ENDPOINT, + LOREKIT_TOKEN: process.env.LOREKIT_TOKEN, + }; + process.env.HOME = home; + process.env.USERPROFILE = home; + delete process.env.LOREKIT_MCP_URL; + delete process.env.LOREKIT_ENDPOINT; + delete process.env.LOREKIT_TOKEN; + try { + return fn(); + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +test('resolveProjectConnection attributes an embedded-token project .mcp.json to source "project-mcp"', () => { + const home = tmpRoot(); + const root = tmpRoot(); + const ep = 'https://project.supabase.co/functions/v1/mcp'; + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ mcpServers: { lorekit: { command: 'npx', args: ['-y', 'mcp-remote', `${ep}?token=lk_rw_proj`] } } }), + ); + withIsolatedConnectionEnv(home, () => { + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.source, 'project-mcp'); + assert.equal(conn.endpoint, ep); + }); +}); + +test('resolveProjectConnection attributes the committable web .mcp.json to source "project-mcp-web"', () => { + const home = tmpRoot(); + const root = tmpRoot(); + upsertWebMcpServer(root, WEB_ENDPOINT); + withIsolatedConnectionEnv(home, () => { + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.source, 'project-mcp-web'); + assert.equal(conn.endpoint, WEB_ENDPOINT); + }); +}); + +test('resolveProjectConnection attributes ~/.claude.json to source "global-mcp"', () => { + const home = tmpRoot(); + const root = tmpRoot(); + const ep = 'https://global.supabase.co/functions/v1/mcp'; + fs.writeFileSync( + path.join(home, '.claude.json'), + JSON.stringify({ mcpServers: { lorekit: { command: 'npx', args: ['-y', 'mcp-remote', `${ep}?token=lk_rw_glob`] } } }), + ); + withIsolatedConnectionEnv(home, () => { + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.source, 'global-mcp'); + assert.equal(conn.endpoint, ep); + }); +}); + +test('resolveProjectConnection attributes .lorekit.json mcp.endpoint to source "lorekit-json"', () => { + const home = tmpRoot(); + const root = tmpRoot(); + const ep = 'https://fallback.supabase.co/functions/v1/mcp'; + fs.writeFileSync(path.join(root, '.lorekit.json'), JSON.stringify({ 'mcp.endpoint': ep })); + withIsolatedConnectionEnv(home, () => { + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.source, 'lorekit-json'); + assert.equal(conn.endpoint, ep); + }); +}); + +test('resolveProjectConnection attributes LOREKIT_MCP_URL to source "env:LOREKIT_MCP_URL"', () => { + const home = tmpRoot(); + const root = tmpRoot(); + withIsolatedConnectionEnv(home, () => { + process.env.LOREKIT_MCP_URL = 'https://envurl.supabase.co/functions/v1'; + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.source, 'env:LOREKIT_MCP_URL'); + assert.equal(conn.endpoint, 'https://envurl.supabase.co/functions/v1'); + }); +}); + +test('resolveProjectConnection attributes LOREKIT_ENDPOINT to source "env:LOREKIT_ENDPOINT"', () => { + const home = tmpRoot(); + const root = tmpRoot(); + withIsolatedConnectionEnv(home, () => { + process.env.LOREKIT_ENDPOINT = 'https://envep.supabase.co/functions/v1'; + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.source, 'env:LOREKIT_ENDPOINT'); + assert.equal(conn.endpoint, 'https://envep.supabase.co/functions/v1'); + }); +}); + +test('resolveConnection attributes the --endpoint flag to source "flag", and null endpoint to source null', () => { + const home = tmpRoot(); + withIsolatedConnectionEnv(home, () => { + const withFlag = resolveConnection({ endpoint: 'https://flag.supabase.co/functions/v1/mcp' }); + assert.equal(withFlag.source, 'flag'); + + const withNothing = resolveConnection({}); + assert.equal(withNothing.endpoint, null); + assert.equal(withNothing.source, null); + }); +}); + +test('loadControl(root).connection.source carries the same source resolveProjectConnection returned', () => { + const home = tmpRoot(); + const root = tmpRoot(); + const ep = 'https://project.supabase.co/functions/v1/mcp'; + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ mcpServers: { lorekit: { command: 'npx', args: ['-y', 'mcp-remote', `${ep}?token=lk_rw_proj`] } } }), + ); + withIsolatedConnectionEnv(home, () => { + const control = loadControl(root, { env: process.env }); + assert.equal(control.connection.source, 'project-mcp'); + assert.equal(control.connection.endpoint, ep); + }); +}); diff --git a/packages/cli/test/doctor.test.mjs b/packages/cli/test/doctor.test.mjs index 0ab76216d..561ef482b 100644 --- a/packages/cli/test/doctor.test.mjs +++ b/packages/cli/test/doctor.test.mjs @@ -481,3 +481,182 @@ test('a default doctor run reports telemetry as info and never fails on it', () assert.match(line, /export off/, `expected a telemetry line, got: ${line}`); assert.doesNotMatch(line, /FAIL/, 'a user with no phone-home is not broken'); }); + +// ── `lorekit doctor` warns when the resolved MCP endpoint lacks the /mcp +// suffix (classifyMcpEndpoint / endpointCheck / the `endpoint` check itself) +// ───────────────────────────────────────────────────────────────────────── +import { classifyMcpEndpoint } from '../src/shared/mcp.mjs'; +import { ENDPOINT_SOURCES, endpointCheck } from '../src/commands/doctor.mjs'; + +// One row per Edge Cases entry in plan.md — every input the plan commits to a +// specific kind/expected/suggested for. +const EDGE_CASES = [ + { url: 'https://ref.supabase.co/functions/v1/mcp/', kind: 'ok' }, + { url: 'https://ref.supabase.co/functions/v1/mcp?foo=1', kind: 'ok' }, + { + url: 'https://ref.supabase.co/functions/v1', + kind: 'wrong-path', + expected: '/functions/v1/mcp', + suggested: 'https://ref.supabase.co/functions/v1/mcp', + }, + { + url: 'https://ref.supabase.co/functions/v1/', + kind: 'wrong-path', + expected: '/functions/v1/mcp', + suggested: 'https://ref.supabase.co/functions/v1/mcp', + }, + { + url: 'https://ref.supabase.co', + kind: 'wrong-path', + expected: '/functions/v1/mcp', + suggested: 'https://ref.supabase.co/functions/v1/mcp', + }, + { + url: 'https://ref.supabase.co/', + kind: 'wrong-path', + expected: '/functions/v1/mcp', + suggested: 'https://ref.supabase.co/functions/v1/mcp', + }, + { + url: 'https://ref.supabase.co/mcp', + kind: 'wrong-path', + expected: '/functions/v1/mcp', + suggested: 'https://ref.supabase.co/functions/v1/mcp', + }, + { + url: 'https://ref.supabase.co/functions/v1/mcp/webhooks/github', + kind: 'wrong-path', + expected: '/functions/v1/mcp', + suggested: 'https://ref.supabase.co/functions/v1/mcp', + }, + { url: 'http://127.0.0.1:54321/functions/v1/mcp', kind: 'ok' }, + { url: 'https://lore.example.com/mcp', kind: 'ok' }, + { + url: 'https://lore.example.com/functions/v1', + kind: 'wrong-path', + expected: '/mcp', + suggested: 'https://lore.example.com/functions/v1/mcp', + }, + { url: 'https://lore.example.com/api', kind: 'wrong-path', expected: '/mcp', suggested: null }, + { url: 'https://lore.example.com', kind: 'wrong-path', expected: '/mcp', suggested: null }, + { url: 'not a url', kind: 'invalid-url' }, + { url: 'ftp://x/mcp', kind: 'invalid-url' }, +]; + +test('classifyMcpEndpoint matches every Edge Cases row from plan.md', () => { + for (const { url, kind, expected, suggested } of EDGE_CASES) { + const result = classifyMcpEndpoint(url); + assert.equal(result.kind, kind, `${url}: expected kind ${kind}, got ${JSON.stringify(result)}`); + if (kind === 'wrong-path') { + assert.equal(result.expected, expected, `${url}: expected shape ${expected}`); + assert.equal(result.suggested, suggested, `${url}: expected suggestion ${suggested}`); + } + } +}); + +test('classifyMcpEndpoint: ok never carries expected/suggested fields', () => { + assert.deepEqual(classifyMcpEndpoint('https://ref.supabase.co/functions/v1/mcp'), { kind: 'ok' }); +}); + +test('endpointCheck passes a canonical endpoint with the endpoint itself as detail', () => { + const result = endpointCheck('https://ref.supabase.co/functions/v1/mcp', 'flag'); + assert.deepEqual(result, { kind: 'pass', detail: 'https://ref.supabase.co/functions/v1/mcp' }); +}); + +test('endpointCheck warns for every ENDPOINT_SOURCES key, naming the endpoint, /mcp, the label, and the fix', () => { + const badEndpoint = 'https://ref.supabase.co/functions/v1'; + for (const [source, { label, fix }] of Object.entries(ENDPOINT_SOURCES)) { + const result = endpointCheck(badEndpoint, source); + assert.equal(result.kind, 'warn', `source ${source}`); + assert.match(result.detail, new RegExp(badEndpoint.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), source); + assert.match(result.detail, /\/mcp/, source); + assert.match(result.detail, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), source); + const expectedFix = fix('https://ref.supabase.co/functions/v1/mcp'); + assert.ok(result.detail.includes(expectedFix), `${source}: expected fix "${expectedFix}" in "${result.detail}"`); + } +}); + +test('endpointCheck falls back to the placeholder URL when no suggestion can be guessed', () => { + const result = endpointCheck('https://lore.example.com/api', 'global-mcp'); + assert.equal(result.kind, 'warn'); + assert.match(result.detail, //); +}); + +test('endpointCheck warns on an invalid URL, naming "not a valid http\\(s\\) URL"', () => { + const result = endpointCheck('not a url', 'env:LOREKIT_MCP_URL'); + assert.equal(result.kind, 'warn'); + assert.match(result.detail, /not a valid http\(s\) URL/); + assert.match(result.detail, /LOREKIT_MCP_URL/); +}); + +test('endpointCheck labels an unrecognised source as "resolved config" rather than throwing', () => { + const result = endpointCheck('https://ref.supabase.co/functions/v1', 'some-future-source'); + assert.equal(result.kind, 'warn'); + assert.match(result.detail, /resolved config/); + assert.match(result.detail, /lorekit install --endpoint/); +}); + +// ── doctor --mode remote: the endpoint line itself (AC-5, AC-6) ────────────── + +function runRemoteEndpointDoctor(dir, home, extraArgs = []) { + return spawnSync( + process.execPath, + [BIN, 'doctor', '--mode', 'remote', '--dir', dir, ...extraArgs], + { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + NO_COLOR: '1', + LOREKIT_TELEMETRY: '0', + LOREKIT_TOKEN: '', + LOREKIT_MCP_URL: '', + LOREKIT_ENDPOINT: '', + }, + }, + ); +} + +test('doctor --mode remote WARNs (not FAILs) the endpoint check for a --endpoint flag missing the /mcp suffix', () => { + const dir = tmp('lk-doc-epwarn-'); + const home = tmp('lk-doc-epwarn-home-'); + const res = runRemoteEndpointDoctor(dir, home, [ + '--endpoint', 'https://ref.supabase.co/functions/v1', + ]); + const line = lineFor(res.stdout, 'endpoint'); + assert.match(line, /WARN/, `expected endpoint WARN, got: ${line}`); + assert.match(line, /--endpoint flag/, `expected the flag source named, got: ${line}`); + assert.match(line, /https:\/\/ref\.supabase\.co\/functions\/v1\/mcp/, `expected the suggested fix URL, got: ${line}`); +}); + +test('doctor --mode remote PASSes the endpoint check for a canonical --endpoint, byte-identical to before', () => { + const dir = tmp('lk-doc-eppass-'); + const home = tmp('lk-doc-eppass-home-'); + const res = runRemoteEndpointDoctor(dir, home, [ + '--endpoint', 'https://ref.supabase.co/functions/v1/mcp', + ]); + const line = lineFor(res.stdout, 'endpoint'); + assert.match(line, /PASS/, `expected endpoint PASS, got: ${line}`); + assert.match(line, /https:\/\/ref\.supabase\.co\/functions\/v1\/mcp/, `expected the endpoint as detail, got: ${line}`); +}); + +test('doctor --mode remote names .mcp.json and the project fix when the tokenless project entry lacks the suffix', () => { + const dir = tmp('lk-doc-epmcp-'); + const home = tmp('lk-doc-epmcp-home-'); + fs.writeFileSync( + path.join(dir, '.mcp.json'), + JSON.stringify({ + mcpServers: { lorekit: { command: 'npx', args: ['-y', 'mcp-remote', 'https://ref.supabase.co/functions/v1'] } }, + }), + ); + const res = runRemoteEndpointDoctor(dir, home); + const line = lineFor(res.stdout, 'endpoint'); + assert.match(line, /WARN/, `expected endpoint WARN, got: ${line}`); + assert.match(line, /\.mcp\.json/, `expected .mcp.json named, got: ${line}`); + assert.match( + line, + /lorekit install --project --endpoint https:\/\/ref\.supabase\.co\/functions\/v1\/mcp/, + `expected the project fix command, got: ${line}`, + ); +}); From 35ee16aa2db1f2494ba8d4bb3bbe9083d901fc70 Mon Sep 17 00:00:00 2001 From: mthines Date: Sat, 17 Oct 2026 13:50:30 +0200 Subject: [PATCH 2/8] docs(cli): document doctor's endpoint-shape warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update docs/cli.md, packages/cli/README.md, the self-host SKILL.md (Step 8 + version bump to 1.0.1, mirrored to the plugin copy), and self-host.mdx Step 3 to describe the new WARN when a resolved MCP endpoint does not end in /mcp — replacing the old "check the suffix yourself" guidance now that doctor does it automatically. --- docs/cli.md | 9 ++++++++- packages/cli/README.md | 10 ++++++---- packages/cli/skill/lorekit-self-host/SKILL.md | 20 ++++++++++--------- packages/web/src/content/docs/self-host.mdx | 7 +++++++ .../skills/lorekit-self-host/SKILL.md | 20 ++++++++++--------- 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 07b7434c7..d45d79c76 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,7 +48,14 @@ Connectivity/token/scope health checks; `connectivity` probes the PUBLIC `/healt therefore proves only the network path, so a separate `authentication` check calls `RemoteStore.verifyAuth()` — one authenticated `GET /memories?limit=1` — and FAILS on a 401 revoked/deleted token, PASSES a 403 as accepted-but-unpermitted [a healthy `lk_wo_*`], and never -turns a 429/5xx/network error into a revoked verdict. Every installed skill also reports its +turns a 429/5xx/network error into a revoked verdict. In remote mode the `endpoint` check also +judges the resolved URL's **shape**: doctor WARNS when the endpoint does not end in `/mcp` (on a +`*.supabase.co` host, specifically `/functions/v1/mcp`) — never fails, since a custom reverse proxy +can legitimately serve MCP off a non-standard path. This is the gap where `.../functions/v1` passes +every other check via the suffix-agnostic REST probes and then 404s for an actual MCP client. The +warning names the config source that produced the endpoint (`--endpoint` flag, `.mcp.json`, +`~/.claude.json`, `.lorekit.json`'s `mcp.endpoint`, or an env var) and a fix command for that +source. Every installed skill also reports its **version** against the one this running CLI ships (`checkSkillVersions` in `shared/skill-versions.mjs` — an offline compare of `metadata.version` in each SKILL.md's frontmatter, checked in BOTH the project and global install locations): a `current` install is a diff --git a/packages/cli/README.md b/packages/cli/README.md index c8645c71e..05669e81b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -180,10 +180,12 @@ Verifies the setup and prints a status report: - for `local`: the store path, entry count, and whether it is committed or gitignored - for `remote`: `.mcp.json` has a `lorekit` server, the endpoint is real (not - the `` placeholder), the token and its permission tier - (`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), that the endpoint is reachable, and — - the `authentication` check — that the token is **still accepted by the - server** + the `` placeholder) **and ends in `/mcp`** (a warning, naming + the config source and a fix, when it doesn't — e.g. `.../functions/v1` with + no `/mcp` still answers the REST-based probes below but 404s for an actual + MCP client), the token and its permission tier (`lk_rw_*` / `lk_ro_*` / + `lk_wo_*`), that the endpoint is reachable, and — the `authentication` + check — that the token is **still accepted by the server** - for `off`: a note that memory is disabled - the git-derived read/write scopes for the current directory diff --git a/packages/cli/skill/lorekit-self-host/SKILL.md b/packages/cli/skill/lorekit-self-host/SKILL.md index 3ce6fd436..24e763e31 100644 --- a/packages/cli/skill/lorekit-self-host/SKILL.md +++ b/packages/cli/skill/lorekit-self-host/SKILL.md @@ -19,7 +19,7 @@ argument-hint: '[full|byod]' license: MIT metadata: author: mthines - version: '1.0.0' + version: '1.0.1' workflow_type: self-host-deployment-runbook tags: - lorekit @@ -215,14 +215,16 @@ hardcoded in `install.mjs`. Only a plain re-run on a fully installed project leaves `.mcp.json` alone. Always pass `--endpoint`, or export `LOREKIT_MCP_URL` in your shell profile, on every install. -**Verify:** `npx @lorekit/cli doctor` shows your endpoint and reports -connectivity + authentication passing, **and** that endpoint line ends in -`/functions/v1/mcp`. Check the suffix yourself: `doctor` probes over REST, -which drops the `/mcp` suffix, so an endpoint missing it still passes -`doctor` while the agent's MCP client gets a 404. Step 9 is the real MCP -check. **On failure:** re-check the endpoint -URL (must end in `/functions/v1/mcp`) and that the token matches the -permission tier the connection needs. +**Verify:** `npx @lorekit/cli doctor` shows your endpoint, reports +connectivity + authentication passing, and the `endpoint` line itself reads +`PASS` — not `WARN`. `doctor`'s `endpoint` check judges the URL's shape +directly now (the `connectivity`/`authentication` checks above it go over +REST, a transport that is suffix-agnostic, which is exactly the trap this +check closes): a `WARN` there names which config source produced the +endpoint and the fix command to re-run, so follow that line rather than +re-deriving it by hand. Step 9 remains the real MCP check. **On failure:** +follow the `WARN` line's fix (it ends in `/functions/v1/mcp`) and confirm +the token matches the permission tier the connection needs. ## Step 9 — Write/read round-trip diff --git a/packages/web/src/content/docs/self-host.mdx b/packages/web/src/content/docs/self-host.mdx index 4adf95adf..05f3820e6 100644 --- a/packages/web/src/content/docs/self-host.mdx +++ b/packages/web/src/content/docs/self-host.mdx @@ -104,6 +104,13 @@ writes the hosted URL. Only a plain re-run on a fully installed project leaves +Run `npx @lorekit/cli doctor` to confirm the connection: it now WARNS (not +just the plain pass/fail before) when the resolved endpoint does not end in +`/functions/v1/mcp` — a shape like `.../functions/v1` still answers doctor's +other REST-based checks but 404s for an actual MCP client, so the warning +catches it before your agent does, naming the config source and the exact +fix command to re-run. + diff --git a/plugins/lorekit-claude/skills/lorekit-self-host/SKILL.md b/plugins/lorekit-claude/skills/lorekit-self-host/SKILL.md index 3ce6fd436..24e763e31 100644 --- a/plugins/lorekit-claude/skills/lorekit-self-host/SKILL.md +++ b/plugins/lorekit-claude/skills/lorekit-self-host/SKILL.md @@ -19,7 +19,7 @@ argument-hint: '[full|byod]' license: MIT metadata: author: mthines - version: '1.0.0' + version: '1.0.1' workflow_type: self-host-deployment-runbook tags: - lorekit @@ -215,14 +215,16 @@ hardcoded in `install.mjs`. Only a plain re-run on a fully installed project leaves `.mcp.json` alone. Always pass `--endpoint`, or export `LOREKIT_MCP_URL` in your shell profile, on every install. -**Verify:** `npx @lorekit/cli doctor` shows your endpoint and reports -connectivity + authentication passing, **and** that endpoint line ends in -`/functions/v1/mcp`. Check the suffix yourself: `doctor` probes over REST, -which drops the `/mcp` suffix, so an endpoint missing it still passes -`doctor` while the agent's MCP client gets a 404. Step 9 is the real MCP -check. **On failure:** re-check the endpoint -URL (must end in `/functions/v1/mcp`) and that the token matches the -permission tier the connection needs. +**Verify:** `npx @lorekit/cli doctor` shows your endpoint, reports +connectivity + authentication passing, and the `endpoint` line itself reads +`PASS` — not `WARN`. `doctor`'s `endpoint` check judges the URL's shape +directly now (the `connectivity`/`authentication` checks above it go over +REST, a transport that is suffix-agnostic, which is exactly the trap this +check closes): a `WARN` there names which config source produced the +endpoint and the fix command to re-run, so follow that line rather than +re-deriving it by hand. Step 9 remains the real MCP check. **On failure:** +follow the `WARN` line's fix (it ends in `/functions/v1/mcp`) and confirm +the token matches the permission tier the connection needs. ## Step 9 — Write/read round-trip From b93b2036002fb5306d1e0d274b8f6a6b37344d2c Mon Sep 17 00:00:00 2001 From: mthines Date: Sat, 17 Oct 2026 14:25:08 +0200 Subject: [PATCH 3/8] fix(cli): make doctor's endpoint install fixes pass install's already-installed gate A plain `lorekit install --project|--global --endpoint ` early-returns on an installed scope, so the printed fix rewrote nothing. Add --force to every install fix and prove each one rewrites an already-installed entry. --- docs/cli.md | 4 +- packages/cli/README.md | 4 +- packages/cli/src/commands/doctor.mjs | 14 ++++-- packages/cli/test/doctor.test.mjs | 69 +++++++++++++++++++++++++++- 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index d45d79c76..e22d9b18d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -55,7 +55,9 @@ can legitimately serve MCP off a non-standard path. This is the gap where `.../f every other check via the suffix-agnostic REST probes and then 404s for an actual MCP client. The warning names the config source that produced the endpoint (`--endpoint` flag, `.mcp.json`, `~/.claude.json`, `.lorekit.json`'s `mcp.endpoint`, or an env var) and a fix command for that -source. Every installed skill also reports its +source — for `.mcp.json` / `~/.claude.json` that is `lorekit install --project|--global --force +--endpoint `; the `--force` is load-bearing, because `install` on an already-installed scope +otherwise prints "already installed" and rewrites nothing. Every installed skill also reports its **version** against the one this running CLI ships (`checkSkillVersions` in `shared/skill-versions.mjs` — an offline compare of `metadata.version` in each SKILL.md's frontmatter, checked in BOTH the project and global install locations): a `current` install is a diff --git a/packages/cli/README.md b/packages/cli/README.md index 05669e81b..7dd4d69d5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -183,7 +183,9 @@ Verifies the setup and prints a status report: the `` placeholder) **and ends in `/mcp`** (a warning, naming the config source and a fix, when it doesn't — e.g. `.../functions/v1` with no `/mcp` still answers the REST-based probes below but 404s for an actual - MCP client), the token and its permission tier (`lk_rw_*` / `lk_ro_*` / + MCP client; the fix for an installed `.mcp.json` / `~/.claude.json` is + `lorekit install --project|--global --force --endpoint `, since a plain + re-install of an installed scope changes nothing), the token and its permission tier (`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), that the endpoint is reachable, and — the `authentication` check — that the token is **still accepted by the server** - for `off`: a note that memory is disabled diff --git a/packages/cli/src/commands/doctor.mjs b/packages/cli/src/commands/doctor.mjs index b24786046..a9b061597 100644 --- a/packages/cli/src/commands/doctor.mjs +++ b/packages/cli/src/commands/doctor.mjs @@ -313,14 +313,22 @@ async function checkLocal(control, root, args, record) { * branches it resolves directly). One record, not parallel label/fix maps — * adding a new source is one entry here, not a matching `if` in two places. */ +// +// Every `lorekit install` fix MUST carry a flag that passes install's +// already-installed early return (`--force`, `--hooks`, `--mcp-json` or +// `--completions`) — a plain `install --endpoint ` on an installed scope +// prints "already installed" and rewrites nothing. `--force` is the one that +// means "rewrite this install": it re-copies the shipped skill files and, on an +// interactive run with a stored token, asks whether to keep it (non-interactive +// runs keep it silently) — both harmless next to an MCP entry that 404s. export const ENDPOINT_SOURCES = { flag: { label: '--endpoint flag', fix: (u) => `pass \`--endpoint ${u}\`` }, - 'project-mcp': { label: '.mcp.json', fix: (u) => `run \`lorekit install --project --endpoint ${u}\`` }, + 'project-mcp': { label: '.mcp.json', fix: (u) => `run \`lorekit install --project --force --endpoint ${u}\`` }, 'project-mcp-web': { label: '.mcp.json (committable web entry)', fix: (u) => `run \`lorekit install --project --mcp-json --endpoint ${u}\``, }, - 'global-mcp': { label: '~/.claude.json', fix: (u) => `run \`lorekit install --global --endpoint ${u}\`` }, + 'global-mcp': { label: '~/.claude.json', fix: (u) => `run \`lorekit install --global --force --endpoint ${u}\`` }, 'lorekit-json': { label: '.lorekit.json mcp.endpoint', fix: (u) => `set "mcp.endpoint" to ${u} in .lorekit.json`, @@ -334,7 +342,7 @@ export const ENDPOINT_SOURCES = { // error — degraded attribution, not a crash. const UNKNOWN_ENDPOINT_SOURCE = { label: 'resolved config', - fix: (u) => `run \`lorekit install --endpoint ${u}\``, + fix: (u) => `run \`lorekit install --force --endpoint ${u}\``, }; // Used in the fix text when `classifyMcpEndpoint` could not guess a safe diff --git a/packages/cli/test/doctor.test.mjs b/packages/cli/test/doctor.test.mjs index 561ef482b..9e92f888d 100644 --- a/packages/cli/test/doctor.test.mjs +++ b/packages/cli/test/doctor.test.mjs @@ -593,7 +593,7 @@ test('endpointCheck labels an unrecognised source as "resolved config" rather th const result = endpointCheck('https://ref.supabase.co/functions/v1', 'some-future-source'); assert.equal(result.kind, 'warn'); assert.match(result.detail, /resolved config/); - assert.match(result.detail, /lorekit install --endpoint/); + assert.match(result.detail, /lorekit install --force --endpoint/); }); // ── doctor --mode remote: the endpoint line itself (AC-5, AC-6) ────────────── @@ -656,7 +656,72 @@ test('doctor --mode remote names .mcp.json and the project fix when the tokenles assert.match(line, /\.mcp\.json/, `expected .mcp.json named, got: ${line}`); assert.match( line, - /lorekit install --project --endpoint https:\/\/ref\.supabase\.co\/functions\/v1\/mcp/, + /lorekit install --project --force --endpoint https:\/\/ref\.supabase\.co\/functions\/v1\/mcp/, `expected the project fix command, got: ${line}`, ); }); + +// ── every printed `lorekit install` fix actually rewrites the MCP entry ────── +// `install` early-returns on an already-installed scope unless one of +// `--force`/`--hooks`/`--mcp-json`/`--completions` is passed, so a fix like +// `lorekit install --project --endpoint ` would print "already installed" +// and change nothing. Run each suggested command against a scope that is +// ALREADY installed with the bad endpoint and assert the entry now holds the +// suggested one. + +// `run \`lorekit install --a --b v\`` → { a: true, b: 'v' } +function installOptsFromFix(fixText) { + const cmd = fixText.match(/`lorekit install ([^`]*)`/); + assert.ok(cmd, `fix is not a lorekit install command: ${fixText}`); + const tokens = cmd[1].trim().split(/\s+/); + const opts = {}; + for (let i = 0; i < tokens.length; i++) { + const key = tokens[i].replace(/^--/, ''); + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + opts[key] = next; + i++; + } else { + opts[key] = true; + } + } + return opts; +} + +function lorekitArgs(file) { + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + return config.mcpServers?.lorekit?.args ?? []; +} + +const BAD_ENDPOINT = 'https://ref.supabase.co/functions/v1'; +const FIX_CASES = [ + { source: 'project-mcp', initial: { project: true }, file: (dir) => path.join(dir, '.mcp.json') }, + { source: 'project-mcp-web', initial: { project: true, 'mcp-json': true }, file: (dir) => path.join(dir, '.mcp.json') }, + { source: 'global-mcp', initial: { global: true }, file: (_dir, home) => path.join(home, '.claude.json') }, + // An unrecognised source falls back to a scope-less install, which defaults + // to the project scope non-interactively. + { source: 'some-future-source', initial: { project: true }, file: (dir) => path.join(dir, '.mcp.json') }, +]; + +for (const { source, initial, file } of FIX_CASES) { + test(`the ${source} fix rewrites an already-installed MCP entry to the suggested endpoint`, async () => { + const dir = tmp('lk-doc-fix-'); + const home = tmp('lk-doc-fix-home-'); + await installWith({ dir, endpoint: BAD_ENDPOINT, token: TOKEN, yes: true, ...initial }, home); + assert.ok( + !lorekitArgs(file(dir, home)).some((a) => a.startsWith(ENDPOINT)), + 'precondition: the installed entry holds the bad endpoint', + ); + + const check = endpointCheck(BAD_ENDPOINT, source); + assert.equal(check.kind, 'warn'); + const opts = installOptsFromFix(check.detail); + await installWith({ ...opts, dir, yes: true }, home); + + const args = lorekitArgs(file(dir, home)); + assert.ok( + args.some((a) => a.startsWith(ENDPOINT)), + `${source}: \`${check.detail}\` left the entry unchanged: ${JSON.stringify(args)}`, + ); + }); +} From 5b664208020f64e247674fb9e4c55fd4952f1947 Mon Sep 17 00:00:00 2001 From: mthines Date: Sat, 17 Oct 2026 14:25:30 +0200 Subject: [PATCH 4/8] fix(cli): suggest /functions/v1/mcp for any wrong path on a supabase.co host A *.supabase.co endpoint like /rest/v1 got a placeholder fix even though the host only serves MCP at one path; suggest the concrete URL instead. --- packages/cli/src/shared/mcp.mjs | 6 +++--- packages/cli/test/doctor.test.mjs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/shared/mcp.mjs b/packages/cli/src/shared/mcp.mjs index a22bc62aa..9d024d025 100644 --- a/packages/cli/src/shared/mcp.mjs +++ b/packages/cli/src/shared/mcp.mjs @@ -221,9 +221,9 @@ function suggestMcpEndpoint(u, isSupabaseHost) { const functionsMatch = pathname.match(FUNCTIONS_V1_PREFIX); if (functionsMatch) return `${u.origin}${functionsMatch[1]}/mcp`; - if (isSupabaseHost && (pathname === '' || pathname === '/mcp')) { - return `${u.origin}/functions/v1/mcp`; - } + // A `*.supabase.co` host only ever serves LoreKit's MCP at one path, so any + // other path (bare origin, `/mcp`, `/rest/v1`, …) has a safe, concrete fix. + if (isSupabaseHost) return `${u.origin}/functions/v1/mcp`; return null; } diff --git a/packages/cli/test/doctor.test.mjs b/packages/cli/test/doctor.test.mjs index 9e92f888d..50808156e 100644 --- a/packages/cli/test/doctor.test.mjs +++ b/packages/cli/test/doctor.test.mjs @@ -529,6 +529,18 @@ const EDGE_CASES = [ expected: '/functions/v1/mcp', suggested: 'https://ref.supabase.co/functions/v1/mcp', }, + { + url: 'https://ref.supabase.co/rest/v1', + kind: 'wrong-path', + expected: '/functions/v1/mcp', + suggested: 'https://ref.supabase.co/functions/v1/mcp', + }, + { + url: 'https://ref.supabase.co/rest/v1/?apikey=x', + kind: 'wrong-path', + expected: '/functions/v1/mcp', + suggested: 'https://ref.supabase.co/functions/v1/mcp', + }, { url: 'http://127.0.0.1:54321/functions/v1/mcp', kind: 'ok' }, { url: 'https://lore.example.com/mcp', kind: 'ok' }, { From a7b4507dd9c327c6b96c9bc9aa7a01974b756b5e Mon Sep 17 00:00:00 2001 From: mthines Date: Sat, 17 Oct 2026 14:26:03 +0200 Subject: [PATCH 5/8] test(cli): drop references to uncommitted plan artifacts from test comments --- packages/cli/test/config.test.mjs | 3 +-- packages/cli/test/doctor.test.mjs | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/cli/test/config.test.mjs b/packages/cli/test/config.test.mjs index 6fd2c0202..18f7f95d3 100644 --- a/packages/cli/test/config.test.mjs +++ b/packages/cli/test/config.test.mjs @@ -497,8 +497,7 @@ test('removeWebMcpServer removes the web form but leaves an embedded-token entry // the files. Every test here pins HOME/USERPROFILE to an empty temp dir and // clears the LOREKIT_MCP_URL/LOREKIT_ENDPOINT/LOREKIT_TOKEN env vars first — // the three pre-existing `resolveProjectConnection` tests above do NOT do this -// and leak the developer's real ~/.claude.json (known, pre-existing baseline -// reds; not fixed here per the plan). +// and leak the developer's real ~/.claude.json. import { loadControl } from '../src/shared/control.mjs'; function withIsolatedConnectionEnv(home, fn) { diff --git a/packages/cli/test/doctor.test.mjs b/packages/cli/test/doctor.test.mjs index 50808156e..837d0b767 100644 --- a/packages/cli/test/doctor.test.mjs +++ b/packages/cli/test/doctor.test.mjs @@ -488,8 +488,8 @@ test('a default doctor run reports telemetry as info and never fails on it', () import { classifyMcpEndpoint } from '../src/shared/mcp.mjs'; import { ENDPOINT_SOURCES, endpointCheck } from '../src/commands/doctor.mjs'; -// One row per Edge Cases entry in plan.md — every input the plan commits to a -// specific kind/expected/suggested for. +// One row per endpoint shape the classifier commits to, each pinned to a +// specific kind/expected/suggested. const EDGE_CASES = [ { url: 'https://ref.supabase.co/functions/v1/mcp/', kind: 'ok' }, { url: 'https://ref.supabase.co/functions/v1/mcp?foo=1', kind: 'ok' }, @@ -555,7 +555,7 @@ const EDGE_CASES = [ { url: 'ftp://x/mcp', kind: 'invalid-url' }, ]; -test('classifyMcpEndpoint matches every Edge Cases row from plan.md', () => { +test('classifyMcpEndpoint classifies every known endpoint shape', () => { for (const { url, kind, expected, suggested } of EDGE_CASES) { const result = classifyMcpEndpoint(url); assert.equal(result.kind, kind, `${url}: expected kind ${kind}, got ${JSON.stringify(result)}`); @@ -608,7 +608,7 @@ test('endpointCheck labels an unrecognised source as "resolved config" rather th assert.match(result.detail, /lorekit install --force --endpoint/); }); -// ── doctor --mode remote: the endpoint line itself (AC-5, AC-6) ────────────── +// ── doctor --mode remote: the endpoint line itself ─────────────────────────── function runRemoteEndpointDoctor(dir, home, extraArgs = []) { return spawnSync( From b1316deb158ffb334774eb09863b00f911dbbbc6 Mon Sep 17 00:00:00 2001 From: mthines Date: Sat, 17 Oct 2026 14:26:38 +0200 Subject: [PATCH 6/8] test(cli): isolate the resolveProjectConnection fallback tests from the real HOME --- packages/cli/test/config.test.mjs | 73 +++++++++++++++++-------------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/packages/cli/test/config.test.mjs b/packages/cli/test/config.test.mjs index 18f7f95d3..a3ed2a27b 100644 --- a/packages/cli/test/config.test.mjs +++ b/packages/cli/test/config.test.mjs @@ -162,12 +162,41 @@ test('copyDir reports how many files it actually wrote', () => { import { resolveProjectConnection, resolveConnection } from '../src/shared/config.mjs'; import { splitEndpoint } from '../src/shared/mcp.mjs'; +// Pin HOME/USERPROFILE to an empty temp dir and clear the +// LOREKIT_MCP_URL/LOREKIT_ENDPOINT/LOREKIT_TOKEN env vars for the duration of +// `fn`, so a resolver test never reads the developer's real ~/.claude.json or +// shell env — a machine with a global install would otherwise change the result. +function withIsolatedConnectionEnv(home, fn) { + const prev = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + LOREKIT_MCP_URL: process.env.LOREKIT_MCP_URL, + LOREKIT_ENDPOINT: process.env.LOREKIT_ENDPOINT, + LOREKIT_TOKEN: process.env.LOREKIT_TOKEN, + }; + process.env.HOME = home; + process.env.USERPROFILE = home; + delete process.env.LOREKIT_MCP_URL; + delete process.env.LOREKIT_ENDPOINT; + delete process.env.LOREKIT_TOKEN; + try { + return fn(); + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + test('resolveProjectConnection uses mcp.endpoint from .lorekit.json as fallback', () => { const root = tmpRoot(); const ep = 'https://abc.supabase.co/functions/v1/mcp'; fs.writeFileSync(path.join(root, '.lorekit.json'), JSON.stringify({ 'mcp.endpoint': ep })); - const conn = resolveProjectConnection(root, splitEndpoint); - assert.equal(conn.endpoint, ep); + withIsolatedConnectionEnv(tmpRoot(), () => { + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.endpoint, ep); + }); }); test('resolveProjectConnection prefers .mcp.json over mcp.endpoint in .lorekit.json', () => { @@ -184,8 +213,10 @@ test('resolveProjectConnection prefers .mcp.json over mcp.endpoint in .lorekit.j }; fs.writeFileSync(path.join(root, '.mcp.json'), JSON.stringify(mcpJson)); fs.writeFileSync(path.join(root, '.lorekit.json'), JSON.stringify({ 'mcp.endpoint': fallbackEp })); - const conn = resolveProjectConnection(root, splitEndpoint); - assert.equal(conn.endpoint, mpcEp); + withIsolatedConnectionEnv(tmpRoot(), () => { + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.endpoint, mpcEp); + }); }); test('resolveProjectConnection ignores placeholder mcp.endpoint', () => { @@ -194,8 +225,10 @@ test('resolveProjectConnection ignores placeholder mcp.endpoint', () => { path.join(root, '.lorekit.json'), JSON.stringify({ 'mcp.endpoint': 'https://.supabase.co/functions/v1/mcp' }), ); - const conn = resolveProjectConnection(root, splitEndpoint); - assert.equal(conn.endpoint, null); + withIsolatedConnectionEnv(tmpRoot(), () => { + const conn = resolveProjectConnection(root, splitEndpoint); + assert.equal(conn.endpoint, null); + }); }); // ── Hook modes: the vocabulary `install` and `doctor` share ────────────────── @@ -494,35 +527,9 @@ test('removeWebMcpServer removes the web form but leaves an embedded-token entry // `resolveProjectConnection` / `resolveConnection` now report WHICH config // source produced the resolved endpoint, so `doctor` can name it (and the // matching fix command) without re-deriving this same precedence by re-reading -// the files. Every test here pins HOME/USERPROFILE to an empty temp dir and -// clears the LOREKIT_MCP_URL/LOREKIT_ENDPOINT/LOREKIT_TOKEN env vars first — -// the three pre-existing `resolveProjectConnection` tests above do NOT do this -// and leak the developer's real ~/.claude.json. +// the files. Every test here runs inside `withIsolatedConnectionEnv`. import { loadControl } from '../src/shared/control.mjs'; -function withIsolatedConnectionEnv(home, fn) { - const prev = { - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - LOREKIT_MCP_URL: process.env.LOREKIT_MCP_URL, - LOREKIT_ENDPOINT: process.env.LOREKIT_ENDPOINT, - LOREKIT_TOKEN: process.env.LOREKIT_TOKEN, - }; - process.env.HOME = home; - process.env.USERPROFILE = home; - delete process.env.LOREKIT_MCP_URL; - delete process.env.LOREKIT_ENDPOINT; - delete process.env.LOREKIT_TOKEN; - try { - return fn(); - } finally { - for (const [k, v] of Object.entries(prev)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - } -} - test('resolveProjectConnection attributes an embedded-token project .mcp.json to source "project-mcp"', () => { const home = tmpRoot(); const root = tmpRoot(); From 145685bcf44378167ed11fe370faa9c6e3af7459 Mon Sep 17 00:00:00 2001 From: mthines Date: Sat, 17 Oct 2026 14:26:54 +0200 Subject: [PATCH 7/8] docs(self-host): scope the /functions/v1/mcp suffix rule to supabase.co hosts --- packages/cli/skill/lorekit-self-host/SKILL.md | 6 ++++-- packages/web/src/content/docs/self-host.mdx | 13 +++++++------ .../skills/lorekit-self-host/SKILL.md | 6 ++++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/cli/skill/lorekit-self-host/SKILL.md b/packages/cli/skill/lorekit-self-host/SKILL.md index 24e763e31..2ce3e6a68 100644 --- a/packages/cli/skill/lorekit-self-host/SKILL.md +++ b/packages/cli/skill/lorekit-self-host/SKILL.md @@ -223,8 +223,10 @@ REST, a transport that is suffix-agnostic, which is exactly the trap this check closes): a `WARN` there names which config source produced the endpoint and the fix command to re-run, so follow that line rather than re-deriving it by hand. Step 9 remains the real MCP check. **On failure:** -follow the `WARN` line's fix (it ends in `/functions/v1/mcp`) and confirm -the token matches the permission tier the connection needs. +follow the `WARN` line's fix and confirm the token matches the permission +tier the connection needs. The required suffix depends on the host: a +`*.supabase.co` endpoint must end in `/functions/v1/mcp`; a custom domain or +reverse proxy only needs the path to end in `/mcp`. ## Step 9 — Write/read round-trip diff --git a/packages/web/src/content/docs/self-host.mdx b/packages/web/src/content/docs/self-host.mdx index 05f3820e6..7e14639fb 100644 --- a/packages/web/src/content/docs/self-host.mdx +++ b/packages/web/src/content/docs/self-host.mdx @@ -104,12 +104,13 @@ writes the hosted URL. Only a plain re-run on a fully installed project leaves -Run `npx @lorekit/cli doctor` to confirm the connection: it now WARNS (not -just the plain pass/fail before) when the resolved endpoint does not end in -`/functions/v1/mcp` — a shape like `.../functions/v1` still answers doctor's -other REST-based checks but 404s for an actual MCP client, so the warning -catches it before your agent does, naming the config source and the exact -fix command to re-run. +Run `npx @lorekit/cli doctor` to confirm the connection: its `endpoint` line +WARNS when the resolved endpoint is shaped wrong for an MCP client. On a +`*.supabase.co` host the path must end in `/functions/v1/mcp`; behind a custom +domain or reverse proxy it only needs to end in `/mcp`. A shape like +`.../functions/v1` still answers doctor's other REST-based checks but 404s for +an actual MCP client, so the warning catches it before your agent does, naming +the config source and the exact fix command to re-run. diff --git a/plugins/lorekit-claude/skills/lorekit-self-host/SKILL.md b/plugins/lorekit-claude/skills/lorekit-self-host/SKILL.md index 24e763e31..2ce3e6a68 100644 --- a/plugins/lorekit-claude/skills/lorekit-self-host/SKILL.md +++ b/plugins/lorekit-claude/skills/lorekit-self-host/SKILL.md @@ -223,8 +223,10 @@ REST, a transport that is suffix-agnostic, which is exactly the trap this check closes): a `WARN` there names which config source produced the endpoint and the fix command to re-run, so follow that line rather than re-deriving it by hand. Step 9 remains the real MCP check. **On failure:** -follow the `WARN` line's fix (it ends in `/functions/v1/mcp`) and confirm -the token matches the permission tier the connection needs. +follow the `WARN` line's fix and confirm the token matches the permission +tier the connection needs. The required suffix depends on the host: a +`*.supabase.co` endpoint must end in `/functions/v1/mcp`; a custom domain or +reverse proxy only needs the path to end in `/mcp`. ## Step 9 — Write/read round-trip From 3eabed0a37e449a3205e80d87f5c5b5864c373f1 Mon Sep 17 00:00:00 2001 From: mthines Date: Sat, 17 Oct 2026 14:28:34 +0200 Subject: [PATCH 8/8] docs: describe doctor's endpoint-shape warning in llms.txt and the remote guide --- packages/schemas/src/llms/template.md | 8 +++++++- packages/web/public/llms.txt | 8 +++++++- packages/web/src/content/docs/remote.mdx | 2 ++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/schemas/src/llms/template.md b/packages/schemas/src/llms/template.md index 4043a6e95..1192fd1bb 100644 --- a/packages/schemas/src/llms/template.md +++ b/packages/schemas/src/llms/template.md @@ -15,7 +15,13 @@ npx @lorekit/cli install \ --token lk_rw_ ``` -5. Verify: `npx @lorekit/cli doctor` +5. Verify: `npx @lorekit/cli doctor`. Its `endpoint` line WARNS when the MCP + URL is shaped wrong for an MCP client — a `*.supabase.co` endpoint must end in + `/functions/v1/mcp`, any other host in `/mcp`. A URL like `.../functions/v1` + still passes the REST-based `connectivity`/`authentication` checks but 404s for + an MCP client. The warning names the config source and the fix, e.g. + `npx @lorekit/cli install --project --force --endpoint https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp` + (`--force` is required: a plain re-install of an installed scope rewrites nothing). `install` asks whether to wire the Claude Code lifecycle hooks. They inject context into the agent — none of them writes memory (the write is still the diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index 31875cb97..270ac7f51 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -26,7 +26,13 @@ npx @lorekit/cli install \ --token lk_rw_ ``` -5. Verify: `npx @lorekit/cli doctor` +5. Verify: `npx @lorekit/cli doctor`. Its `endpoint` line WARNS when the MCP + URL is shaped wrong for an MCP client — a `*.supabase.co` endpoint must end in + `/functions/v1/mcp`, any other host in `/mcp`. A URL like `.../functions/v1` + still passes the REST-based `connectivity`/`authentication` checks but 404s for + an MCP client. The warning names the config source and the fix, e.g. + `npx @lorekit/cli install --project --force --endpoint https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp` + (`--force` is required: a plain re-install of an installed scope rewrites nothing). `install` asks whether to wire the Claude Code lifecycle hooks. They inject context into the agent — none of them writes memory (the write is still the diff --git a/packages/web/src/content/docs/remote.mdx b/packages/web/src/content/docs/remote.mdx index 1437f3929..fa5880671 100644 --- a/packages/web/src/content/docs/remote.mdx +++ b/packages/web/src/content/docs/remote.mdx @@ -76,6 +76,8 @@ Two of those checks answer different questions, and both must be green: - **`connectivity`** probes the public `/health` function. It proves the network path only — it stays green for a token that no longer works. - **`authentication`** makes one authenticated request and reports what the server said about the token. A revoked or deleted token fails here with HTTP 401, and doctor exits non-zero. A write-only `lk_wo_*` token passes with a note that it has no read permission. +The **`endpoint`** line checks the URL's shape. It warns (without failing) when the endpoint is shaped wrong for an MCP client: a `*.supabase.co` endpoint must end in `/functions/v1/mcp`, and a custom domain or reverse proxy must end in `/mcp`. A URL like `https://pqokxlhvnosogizsjztg.supabase.co/functions/v1` still passes `connectivity` and `authentication`, because those go over REST, but your agent's MCP client gets a 404. The warning names where the endpoint came from and the command that fixes it, for example `npx @lorekit/cli install --project --force --endpoint https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp`. + Seeing `authentication — token REJECTED`? The token was revoked or deleted. Generate a new one at [lorekit.io](https://lorekit.io), then run `npx @lorekit/cli install --force` — it asks whether to keep, replace, or remove the token already in your config.