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); + }); +});