From 398eb0d1f16b410b64a96615c6463fbf2ab2f38d Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Wed, 4 Nov 2026 11:52:16 +0000 Subject: [PATCH] feat(setup): register LM Studio during setup (#8611) * feat(setup): register LM Studio during setup Register rea in mcp.json in the LM Studio home through a new lm_studio client. The home follows LM Studio's own lookup, read-only: the path in ~/.lmstudio-home-pointer, else an existing ~/.cache/lm-studio, else ~/.lmstudio. An unreadable or relative pointer reports a path error instead of a guessed home. LM Studio parses mcp.json with strict JSON.parse, so comments, trailing commas and a BOM are rejected before any backup or write. The entry carries command, args and env with no type or timeout. Local models have small context windows, so the registration selects REA_MCP_INPUT_SCHEMA_PROFILE=compact unless a profile is set explicitly, and doctor reports an entry without a profile as stale. One predicate now owns that default for Kimi Code and LM Studio. * fix(doctor): resolve provider budgets for LM Studio as setup does --- docs/client-configuration.md | 16 ++ docs/installation.md | 3 + .../ClientConfigurationDocument.ts | 10 +- src/application/ClientRegistrationStatus.ts | 16 +- src/application/SetupClientConfiguration.ts | 3 +- src/application/SupportedClients.ts | 43 +++- .../filesystem/clientLifecycle.test.ts | 1 + .../clientRegistrationStatus.test.ts | 18 +- .../lmStudioClientConfiguration.test.ts | 201 ++++++++++++++++++ 9 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 tests/boundary/filesystem/lmStudioClientConfiguration.test.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 54b5e27493..5f04937b10 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -310,6 +310,22 @@ Kimi Code also discovers personal skills in `~/.agents/skills` and project-local and verifies the matching skill files through the normal shared-skill flow. Start a new Kimi Code session to pick up MCP and skill changes. +## LM Studio + +For [LM Studio](https://lmstudio.ai/docs/app/mcp), setup writes `mcpServers.rea` +into `mcp.json` in the LM Studio home directory. Setup finds that directory as +LM Studio does: the path stored in `~/.lmstudio-home-pointer`, otherwise +`~/.cache/lm-studio` when it exists, otherwise `~/.lmstudio`. Setup only reads +the pointer file. LM Studio parses `mcp.json` as strict JSON, so setup rejects +a file with comments, trailing commas, or a byte order mark without a backup +or a write. The entry carries `command`, `args`, and `env`, and setup keeps +every other server. Local models often have small context windows, so setup +selects `REA_MCP_INPUT_SCHEMA_PROFILE=compact` unless the stored entry or the +setup environment selects a profile explicitly. Doctor reports an entry with no +explicit profile as stale, as for Kimi Code. LM Studio 0.4.26 on macOS loaded +this registration, and a local model called an REA tool through it. Linux and +Windows remain unverified. + ## Pi For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md), diff --git a/docs/installation.md b/docs/installation.md index c733c40a6e..322a0a55aa 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -654,6 +654,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | Factory Droid | `droid` | | Trae | `trae` | | WorkBuddy | `workbuddy` | +| LM Studio | `lm_studio` | | ZCode | `zcode` | | DeepSeek Harness | `dsh` | | FreeBuff | `freebuff` | @@ -663,6 +664,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s `freebuff` requires `rea-agents@6.8.0` or later. Older releases reject `rea setup --client freebuff` as an unsupported client. +`lm_studio` requires a release after 6.8.0. + See [client configuration and troubleshooting](client-configuration.md) for configuration paths, profiles, trust policies, and schema-limit workarounds. diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index ce2e56daeb..fccc63be21 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -152,9 +152,10 @@ const parseDocument = ( // Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so // comments, trailing commas, a BOM, or an empty existing file must be // rejected instead of registering into a document the client cannot load. - // Kimi also reads mcp.json with JSON.parse and an empty-file fallback. - if (format === "pi" || format === "kimi") { - if (format === "kimi" && text.trim().length === 0) return {}; + // Kimi and LM Studio also read mcp.json with JSON.parse; an empty file holds + // nothing to preserve. + if (format === "pi" || format === "kimi" || format === "lm_studio") { + if (format !== "pi" && text.trim().length === 0) return {}; return requireObject(JSON.parse(text), "The configuration root"); } // An empty file, which some clients create before any server is added, @@ -1748,7 +1749,8 @@ export const clientRegistrationEntry = ( : { environment: { ...environment } }), }; case "kimi": - // Kimi infers stdio from command; it uses `transport`, not `type`. + case "lm_studio": + // Both infer stdio from command; Kimi uses `transport`, not `type`. return { command: executable, args, diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index f1396eefa8..6c1ab64228 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -28,6 +28,7 @@ import { import { clientEvidencePaths, clientOwnershipEvidencePaths, + compactInputSchemaByDefault, manualRegistrationRemediation, supportedClients, } from "./SupportedClients.js"; @@ -279,12 +280,14 @@ export const readClientRegistrationInspections = async ( } const registration = parsedRegistration.value; const registrationEnvironment = - client.name === "kimi" || client.name === "codex" + compactInputSchemaByDefault(client) || client.name === "codex" ? await clientRegistrationEnvironmentDetailsWithBudget( parsed, {}, "effective", - options.resolveProviderRequestBudget, + client.name === "kimi" || client.name === "codex" + ? options.resolveProviderRequestBudget + : undefined, options.platform, ) : undefined; @@ -321,7 +324,7 @@ export const readClientRegistrationInspections = async ( command, commandFacts, platform: options.platform ?? process.platform, - kimiInputSchemaProfileSelected: + inputSchemaProfileSelected: registrationEnvironment?.ok === true && snapshotEnvironment( registrationEnvironment.value.values, @@ -449,14 +452,14 @@ const registrationAligned = ({ command, commandFacts, platform, - kimiInputSchemaProfileSelected, + inputSchemaProfileSelected, }: { readonly registration: ClientRegistration; readonly client: SetupClient; readonly command: readonly string[]; readonly commandFacts: ClientRegistrationCommandFacts; readonly platform: NodeJS.Platform; - readonly kimiInputSchemaProfileSelected: boolean; + readonly inputSchemaProfileSelected: boolean; }): "aligned" | "stale" | "unknown" => { const { identity } = commandFacts; if (identity === "unknown") return "unknown"; @@ -478,7 +481,8 @@ const registrationAligned = ({ registration.toolTimeoutMs === undefined) ) return "stale"; - if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale"; + if (compactInputSchemaByDefault(client) && !inputSchemaProfileSelected) + return "stale"; // A Harness registration only connects as the rea stdio server; another // server name or transport leaves the session without REA tools. if ( diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 393ebf52c6..242f0b148d 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -67,6 +67,7 @@ import type { SetupProviderEnvironment, } from "./SetupTypes.js"; import { + compactInputSchemaByDefault, registrationWriteBlocker, type SetupClient, } from "./SupportedClients.js"; @@ -936,7 +937,7 @@ const clientConfigurationDesired = async ( ); if (!environment.ok) throw environment.error; const registrationEnvironment = - client.name === "kimi" && + compactInputSchemaByDefault(client) && snapshotEnvironment(environment.value).REA_MCP_INPUT_SCHEMA_PROFILE === undefined ? { diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 2dadce7811..7ee94bf8a0 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -1,6 +1,6 @@ import { dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; -import { lstatSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { err, ok, type Result } from "../domain/result.js"; import { HermesClientPathError, @@ -59,6 +59,7 @@ export interface SetupClient { | "omp" | "pi" | "kimi" + | "lm_studio" | "hermes" | "zcode_fallback" | "zcode" @@ -193,6 +194,40 @@ const codexDirectory = ({ home, env }: ClientPathContext): string => const kimiDirectory = ({ home, env }: ClientPathContext): string => env.KIMI_CODE_HOME || join(home, ".kimi-code"); +/** Clients registered with the compact input-schema profile unless one is selected. */ +export const compactInputSchemaByDefault = ({ + name, +}: Pick): boolean => + name === "kimi" || name === "lm_studio"; + +/** + * LM Studio's findLMStudioHome, read-only: a home pointer file wins, then an + * existing ~/.cache/lm-studio, then ~/.lmstudio. Setup never writes the pointer. + */ +const lmStudioLocation = ({ + home, +}: ClientPathContext): Result => { + const pointerPath = join(home, ".lmstudio-home-pointer"); + let directory: string; + try { + directory = readFileSync(pointerPath, "utf8").trim(); + } catch (cause: unknown) { + if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")) + return err({ + path: pointerPath, + message: `${pointerPath} could not be read, so REA cannot establish which LM Studio home holds mcp.json: ${cause instanceof Error ? cause.message : String(cause)}. Repair access to that file, then rerun.`, + }); + const cache = join(home, ".cache", "lm-studio"); + directory = existsSync(cache) ? cache : join(home, ".lmstudio"); + } + if (!isAbsolute(directory)) + return err({ + path: pointerPath, + message: `${pointerPath} does not hold an absolute LM Studio home path. Correct or remove that file, then rerun.`, + }); + return ok({ configPath: join(directory, "mcp.json"), markerPath: directory }); +}; + const qoderDirectory = ({ home, env }: ClientPathContext): string => env.QODER_CONFIG_DIR || join(home, ".qoder"); @@ -652,6 +687,12 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: [".workbuddy"], format: "json", }, + { + name: "lm_studio", + displayName: "LM Studio", + location: lmStudioLocation, + format: "lm_studio", + }, { name: "grok_build", displayName: "Grok Build", diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 7bb9cc22d5..474bf5be61 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -62,6 +62,7 @@ describe("client configuration filesystem lifecycle", () => { "vscode", "trae", "workbuddy", + "lm_studio", "grok_build", "omp", "pi", diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts index 269f0ef68a..324689d0a6 100644 --- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts +++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts @@ -167,6 +167,8 @@ describe("client registration environment validation", () => { const home = await createTestTempDirectory("rea-registration-budget-"); await mkdir(join(home, ".cursor")); await mkdir(join(home, ".codex")); + await mkdir(join(home, ".lmstudio")); + await mkdir(join(home, ".kimi-code")); const entry = { command: "npx", args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], @@ -175,6 +177,11 @@ describe("client registration environment validation", () => { join(home, ".cursor/mcp.json"), JSON.stringify({ mcpServers: { rea: entry } }), ); + for (const path of [".lmstudio/mcp.json", ".kimi-code/mcp.json"]) + await writeFile( + join(home, path), + JSON.stringify({ mcpServers: { rea: entry } }), + ); await writeFile( join(home, ".codex/config.toml"), `[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "${PRODUCT_IDENTITY.registrationPackageSpecifier}", "mcp"]\n`, @@ -194,10 +201,13 @@ describe("client registration environment validation", () => { // The failing resolver must not invalidate non-Kimi/Codex registrations. expect(byClient.has("cursor")).toBe(true); expect(byClient.get("cursor")?.state).not.toBe("invalid"); - expect(byClient.get("codex")).toMatchObject({ - state: "invalid", - remediation: expect.stringContaining("budget unavailable"), - }); + expect(byClient.has("lm_studio")).toBe(true); + expect(byClient.get("lm_studio")?.state).not.toBe("invalid"); + for (const client of ["codex", "kimi"]) + expect(byClient.get(client)).toMatchObject({ + state: "invalid", + remediation: expect.stringContaining("budget unavailable"), + }); }); }); diff --git a/tests/boundary/filesystem/lmStudioClientConfiguration.test.ts b/tests/boundary/filesystem/lmStudioClientConfiguration.test.ts new file mode 100644 index 0000000000..1be933b4b6 --- /dev/null +++ b/tests/boundary/filesystem/lmStudioClientConfiguration.test.ts @@ -0,0 +1,201 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +const command = currentClientRegistrationCommand(); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const lmStudioClient = (home: string) => + supportedClients(home, process.platform, {}).find( + ({ name }) => name === "lm_studio", + ); + +const exists = async (path: string): Promise => { + try { + await access(path); + return true; + } catch { + return false; + } +}; + +const expectedRegistration = (profile = "compact") => ({ + command: command[0], + args: command.slice(1), + env: { REA_MCP_INPUT_SCHEMA_PROFILE: profile }, +}); + +const doctorState = async (home: string) => + ( + await readClientRegistrationStatuses(home, undefined, { environment: {} }) + ).find(({ client }) => client === "lm_studio"); + +/** An installed LM Studio home; the pointer and cache layouts are opt-in. */ +const installedHome = async (layout: "default" | "pointer" | "cache") => { + const home = await createTestTempDirectory("rea-lm-studio-"); + let directory = join(home, ".lmstudio"); + if (layout === "pointer") { + directory = await createTestTempDirectory("rea-lm-studio-target-"); + await writeFile(join(home, ".lmstudio-home-pointer"), `${directory}\n`); + } + if (layout === "cache") directory = join(home, ".cache", "lm-studio"); + await mkdir(directory, { recursive: true }); + const client = lmStudioClient(home); + if (client === undefined) throw new Error("missing LM Studio client"); + return { home, directory, client }; +}; + +describe("LM Studio home resolution", () => { + it.each(["default", "pointer", "cache"] as const)( + "resolves mcp.json in the %s home", + async (layout) => { + const { directory, client } = await installedHome(layout); + expect(client).toEqual({ + name: "lm_studio", + displayName: "LM Studio", + configPath: join(directory, "mcp.json"), + markerPath: directory, + format: "lm_studio", + }); + }, + ); + + it("prefers the home pointer over an existing cache directory", async () => { + const { home, directory } = await installedHome("pointer"); + await mkdir(join(home, ".cache", "lm-studio"), { recursive: true }); + expect(lmStudioClient(home)?.markerPath).toBe(directory); + }); + + it("reports a relative home pointer instead of guessing a home", async () => { + const home = await createTestTempDirectory("rea-lm-studio-relative-"); + await writeFile(join(home, ".lmstudio-home-pointer"), "lmstudio\n"); + expect(lmStudioClient(home)).toMatchObject({ + configPath: join(home, ".lmstudio-home-pointer"), + configPathError: expect.stringContaining("absolute"), + }); + }); +}); + +describe("LM Studio registration lifecycle", () => { + it.each(["default", "pointer", "cache"] as const)( + "registers rea with the compact profile in the %s home", + async (layout) => { + const { home, directory, client } = await installedHome(layout); + const pointer = join(home, ".lmstudio-home-pointer"); + const pointerBefore = (await exists(pointer)) + ? await readFile(pointer, "utf8") + : undefined; + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(join(directory, "mcp.json"), "utf8")), + ).toEqual({ mcpServers: { rea: expectedRegistration() } }); + expect(await doctorState(home)).toMatchObject({ + state: "aligned", + command, + }); + expect( + (await exists(pointer)) ? await readFile(pointer, "utf8") : undefined, + ).toBe(pointerBefore); + }, + ); + + it("preserves sibling servers, backs up, is idempotent, and uninstalls only rea", async () => { + const { home, client } = await installedHome("default"); + const original = `{ + "mcpServers": { + "other": { "command": "other-server", "args": ["--stdio"], "cwd": "/srv" } + } +} +`; + await writeFile(client.configPath, original); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "configured", + backupPath: `${client.configPath}.rea.backup`, + }); + const text = await readFile(client.configPath, "utf8"); + expect(JSON.parse(text)).toEqual({ + mcpServers: { + other: { command: "other-server", args: ["--stdio"], cwd: "/srv" }, + rea: expectedRegistration(), + }, + }); + expect(text).not.toContain('"type"'); + expect(text).not.toContain('"timeout"'); + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + original, + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await readFile(client.configPath, "utf8")).toBe(text); + expect(await doctorState(home)).toMatchObject({ state: "aligned" }); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({ + mcpServers: { + other: { command: "other-server", args: ["--stdio"], cwd: "/srv" }, + }, + }); + }); + + it("keeps an explicit full profile and reports an omitted profile stale", async () => { + const { home, client } = await installedHome("default"); + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { rea: { command: command[0], args: command.slice(1) } }, + }), + ); + expect(await doctorState(home)).toMatchObject({ state: "stale" }); + + expect( + ( + await configureClientConfiguration( + client, + { REA_MCP_INPUT_SCHEMA_PROFILE: "full" }, + command, + ) + ).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea, + ).toEqual(expectedRegistration("full")); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await doctorState(home)).toMatchObject({ state: "aligned" }); + }); + + it.each([ + '{"mcpServers": {', + '{/* comment */ "mcpServers": {}}', + '{"mcpServers": {},}', + '{"mcpServers": {}}', + ])("rejects invalid strict JSON %j without writes", async (original) => { + const { client } = await installedHome("default"); + await writeFile(client.configPath, original); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + expect(await readFile(client.configPath, "utf8")).toBe(original); + expect(await exists(`${client.configPath}.rea.backup`)).toBe(false); + }); +});