Skip to content
Merged
11 changes: 10 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,16 @@ 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
Comment thread
mthines marked this conversation as resolved.
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 — for `.mcp.json` / `~/.claude.json` that is `lorekit install --project|--global --force
--endpoint <url>`; 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
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,14 @@ 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 `<project-ref>` 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 `<project-ref>` 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 fix for an installed `.mcp.json` / `~/.claude.json` is
`lorekit install --project|--global --force --endpoint <url>`, 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
- the git-derived read/write scopes for the current directory

Expand Down
22 changes: 13 additions & 9 deletions packages/cli/skill/lorekit-self-host/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -215,14 +215,18 @@ 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 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

Expand Down
90 changes: 88 additions & 2 deletions packages/cli/src/commands/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -305,6 +306,80 @@ 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.
*/
//
// 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 <u>` 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 --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 --force --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 --force --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 = '<your MCP URL ending in /mcp>';

/**
* 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);
Expand All @@ -314,6 +389,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) {
Expand All @@ -327,7 +412,8 @@ async function checkRemote(control, root, args, record) {
} else if (endpoint.includes('<project-ref>')) {
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);
Expand Down
49 changes: 38 additions & 11 deletions packages/cli/src/shared/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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('<project-ref>')) 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.
Expand All @@ -678,6 +704,7 @@ export function resolveProjectConnection(root, splitEndpoint) {
return {
endpoint: committedEndpoint,
token: process.env.LOREKIT_TOKEN || null,
source: 'lorekit-json',
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/shared/control.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ export function loadControl(root, { env = process.env } = {}) {
const usable = Boolean(
conn.endpoint && conn.token && !String(conn.endpoint).includes('<project-ref>'),
);
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 });
}

Expand Down
84 changes: 83 additions & 1 deletion packages/cli/src/shared/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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=...'
Expand All @@ -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`;

// 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;
}

/**
* Minimal REST fetch for LoreKit REST API endpoints.
* Returns { ok, httpStatus, data, error, networkError } — same shape as mcpCall.
Expand Down
Loading
Loading