diff --git a/rust/contracts/mcp-registry-page.json b/rust/contracts/mcp-registry-page.json deleted file mode 100644 index 3535d54d..00000000 --- a/rust/contracts/mcp-registry-page.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "servers": [ - { - "server": { - "name": "io.example/documentation", - "title": "Documentation", - "description": "Example remote documentation service", - "version": "1.2.0", - "websiteUrl": "https://example.invalid/docs", - "remotes": [{"type": "streamable-http", "url": "https://example.invalid/mcp"}] - }, - "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}} - }, - { - "server": { - "name": "io.example/browser", - "description": "Example local package", - "version": "2.0.0", - "packages": [{"registryType": "npm", "identifier": "@example/browser", "version": "2.0.0", "transport": {"type": "stdio"}}] - } - }, - { - "server": { - "name": "io.example/database", - "version": "1.0.0", - "repository": {"url": "https://example.invalid/database"}, - "packages": [{"registryType": "npm", "identifier": "@example/database", "version": "1.0.0", "transport": {"type": "stdio"}, "environmentVariables": [{"name": "API_KEY", "isRequired": true, "isSecret": true}]}] - } - }, - { - "server": {"name": "io.example/removed"}, - "_meta": {"io.modelcontextprotocol.registry/official": {"status": "deleted"}} - } - ], - "metadata": {"nextCursor": "opaque/+ cursor?"} -} diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 6325eabd..fe66a7a7 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -373,557 +373,13 @@ stable error code and a user-facing message: ## Commands -### MCP configuration - -`mcp.probe` accepts a user-reviewed connection `{transport, command?, args?, env?, -url?, headers?}` with only those fields. Agent Host uses the pinned official -rmcp SDK for initialization, session handling and paginated `tools/list`; it -never sends `tools/call`. Results are `{tools:[{name,description}]}`, sorted by -name, deduplicated, bounded to 1000 tools/20 pages/2 MiB of metadata. stdio input -and each HTTP response body are bounded before decoding. A probe has a -30-second request budget plus bounded cleanup, cancellation is cooperative, -and stdio children and their process groups are reaped on completion or cancel. -HTTP accepts HTTPS and loopback HTTP, rejects credential-bearing URLs and -redirects, preserves TLS verification and explicit headers, and uses no cookie -store. It does not inherit another Agent's OAuth session or unmapped native -configuration options. Legacy `sse` remains a configurable transport but this -SDK probe returns `not_supported` rather than inventing a healthy status. -The host must require an explicit detection action; launching local code or -package runners also requires the product's execution confirmation. - -`mcp.toolPolicy` is a pure text operation with `{source,content,server,tools, -owned?,tool?,enabled?}`. `source` is `codex` or `claude`; omission of `tool` and -`enabled` reads current policy. It returns `{content,changed,owned,tools, -hasRestrictions}`. The returned tools contain `{name,enabled,editable}`. -Codex uses entry-local `disabled_tools`, preserving `enabled_tools`; Claude's -input is its user `settings.json`, editing only `permissions.deny` with exact -`mcp____` rules. Unknown settings/formatting -outside the targeted value survive. Existing unowned denies, broad wildcard -rules, allow-list exclusions and ambiguous normalized names are not relaxed. -Hosts retain the exact added tool names by native path/server, validate full -file snapshots before atomic writes, and never treat a read as ownership. -Policies describe the user configuration; project/managed settings and live -Agent sessions may differ. Claude server renames are refused while user MCP -restrictions exist, to avoid re-enabling tools under a new name. - -`mcp.commandLine` accepts `{action:"format",command,args}` to return `{line}`, -or `{action:"parse",line}` to return `{command,args}`. POSIX shell-style quoting -is only a lossless text projection: hosts pass argv directly, never evaluate a -shell, substitutions, pipes or redirects. Blank and embedded-newline arguments -are preserved; malformed quoting is rejected. These operations and probe -results use `rust/fixtures/mcp/tools-v1.json` as a synthetic contract fixture. - -`mcp.parseRegistry` accepts `{ content: string }` (at most 1 MiB UTF-8) and -normalizes an official MCP Registry v0.1 response into `{ entries, nextCursor }`. -Each entry has `id`, `title`, `summary`, `version`, nullable `publisher`, -`source: "registry"`, nullable HTTPS `website` and -nullable `draft: McpServer`. At most 100 input entries are accepted. Inactive -records are skipped, pagination cursors remain opaque, and links containing -credentials or unresolved variables are not offered. Only static HTTPS remote -endpoints without header/variable inputs, or versioned npm/PyPI stdio packages -without additional runtime/package/environment inputs, using the public default -registry and runner, produce drafts. npm versions must be complete releases; -mutable tags and major/minor ranges remain documentation-only. Other -records remain documentation-only. This operation never fetches, installs or -executes anything. Hosts own cancellable directory requests and require the -user to review and save a draft before modifying Agent configurations. -`rust/contracts/mcp-registry-page.json` is a portable synthetic response. - -`mcp.featuredRegistry` accepts `{}` and returns the same page shape with -`source: "featured"` from the Git-owned, compile-time catalog at -`rust/lithe-core/src/mcp/featured.json`. It performs no network, filesystem, -package resolution or execution. Catalog package versions are pinned; remote -requirements remain subject to the publisher's documentation. Hosts show this -page without contacting the Registry, extend matching entries on explicit -search, and retain available results when a directory request fails. -The release catalog includes Context7, Playwright, Chrome DevTools, Microsoft -Learn, GitHub, DeepWiki, Exa, Filesystem, Fetch and Memory. A featured entry may -add `setup: "bearer-token" | "allowed-directories"`: its draft is an incomplete -template, requiring an Authorization bearer value or at least one absolute -directory argument after the fixed Filesystem package argument. Hosts must -collect and validate these inputs before saving and must not select Agents by -default. Public Registry parsing never copies `setup`, even when input uses a -featured-looking ID. `rust/fixtures/mcp/featured-setup-v1.json` illustrates the -optional field; ordinary entries omit it. Brand marks are platform-owned, -read-only bundled assets, selected only for known featured IDs; public entries -keep the transport fallback. No response can select arbitrary resources or -cause remote image fetching. - -`mcp.parseConfiguration` accepts `{ source: "codex" | "claude", scope: -"global" | "project", content: string, projectPath?: string }` and returns -`{ servers: McpServer[] }`. Claude project scope requires `projectPath` and reads -`projects..mcpServers`; Codex reads `mcp_servers` in the supplied -file. Core handles bounded text only; hosts own paths, file I/O, backups and -atomic writes. Configuration text is limited to 1 MiB UTF-8. Invalid requests or -configuration return redacted `invalid_request` errors. - -A server has `name`, `transport` (`stdio`, `http`, `sse`), optional `command` and -`url`, `args: string[]`, `env` and `headers` arrays of `{ key, value }`, plus two -lowercase SHA-256 hashes with separate purposes: - -- `definitionHash` keeps the existing library grouping identity: name, transport, - and modeled fields used by that transport, with environment/header entries - sorted deterministically. Unknown fields do not change this hash. -- `sourceHash` covers the name and the **complete semantic source entry**, - including unknown authentication, timeout, nested and non-string values, as - well as fields unused by the active transport. Object/table keys are sorted, - array order and value types are preserved, and source formatting/comments are - excluded. This hash is source-specific and is not a grouping key. Parsed - servers always supply it; request decoding permits an absent/null value for - compatibility with older clients and new drafts. - -`mcp.planWrite` accepts `{ source, scope, projectPath?, content?: string | null, -operation: "set" | "remove", server?: McpServer, name?: string, -previousName?: string, expectedHash?: string, owned?: OwnedEntry[], -toolFilters?: {enabledTools?: string[], disabledTools?: string[]} }`. `set` -requires `server`, `remove` requires `name`, and project scope requires -`projectPath`. `previousName` identifies the original of a rename. Each owned -entry is `{ name, definitionHash, sourceHash?: string }`; hosts must record the -hashes returned from the actual write plan rather than hashes from the draft. - -Successful Codex removals also return `toolFilters` when native `enabled_tools` -or `disabled_tools` exists. These lists contain tool names only; an empty -allow-list stays empty and malformed lists reject removal. Hosts retain them -by native path/server only after the complete write batch succeeds, separately -from live source ownership. A later `set` applies retained filters only when -inserting a new Codex entry; existing/renamed native policy always takes -precedence. Returned hashes cover the restored policy. Claude's user deny -rules remain in its separate settings file during server removal. - -Automatic replacement/removal, including unchanged set plans used for repeated -registration, requires an owned record whose name, `definitionHash` and -`sourceHash` match the current entry. An existing raw entry omitted from discovery -because it lacks a usable command/URL also conflicts rather than being treated as -an empty slot. A legacy owned record -without `sourceHash` does not prove ownership: mutation is refused with -`externallyModified` when the name is recorded. An unrecorded name returns -`ownedByUser`. Hosts must not silently upgrade legacy records from the current -file or append the displayed hash to `owned` to authorize an explicit action. - -When supplied, `expectedHash` means the **full `sourceHash` of the original -entry displayed to the user**, not `definitionHash`. It is checked against the -current entry before unchanged-definition or ownership early exits; a missing -entry or different hash returns `externallyModified`, even if a newer ownership -ledger matches the current file or the desired modeled definition is unchanged. -An exact match authorizes an explicit edit/remove of that original even when -it is not owned. For rename, it refers to `previousName`, and cannot authorize -an unrelated existing destination; destination ownership checks still apply. - -The response is `{ content, changed, action, conflict, definitionHash?, -sourceHash?, toolFilters? }`. Actions are `inserted`, `updated`, `unchanged`, `removed`, `absent`, -or `conflict`; conflicts leave the input unchanged. Successful set plans return -both hashes computed by parsing the actual output entry, including preserved -unknown fields. Unchanged set plans also return the current entry's hashes; -removal can return the old definition hash for filter retention; absence has no entry hashes. Hashes sent on the draft server -are never trusted as write output or persisted into Agent configuration. - -The MCP server library is planned by three Core commands; hosts only discover -paths, read and atomically write files, persist the ledger and localize codes. -Each takes `files: LibraryFile[]` (at most 16, unique `id`), where a file is -`{ id, source: "codex" | "claude", scope, projectPath?, path, content?: string | -null, owned: LedgerEntry[] }`. `id` is the host's opaque ledger key and `path` -the native path that keys retained tool filters. A ledger entry is `{ name, -definitionHash, sourceHash?, toolFilters?: { : {enabledTools?, -disabledTools?} } }`; it never contains env/header values. - -`mcp.librarySnapshot` accepts `{ files }` and returns `{ rows, failures }`. Rows -merge every parsed server by `name#definitionHash` (`id`), carry the definition -from the first source in host file order, `sources: [{ file, definitionHash, -sourceHash, ownedByLithe }]`, `hasDefinitionConflict` (another global entry -with the same name has a different definition) and `projectOnly`. A source is -owned when a ledger entry records its name and complete `sourceHash`. Rows are -ordered by natural, case-insensitive name (digit runs compare numerically, bytes -break ties), then global before project-only, then definition hash. A file that -fails to parse contributes no rows and appears in `failures: [{ file, code, -message }]` without echoing content; hosts report their own read failures. - -`mcp.validateDraft` accepts `{ draft: { name, transport, command, url, -arguments, env, headers, enabledAgents, setup? } }` and returns `{ issues: -[{ field, code }], definition }`. Issues are ordered by display priority: -`name`/`nameRequired` or `nameInvalid` (`^[A-Za-z0-9][A-Za-z0-9._-]*$` after -trimming), `enabledAgents`/`agentsRequired`, `env` or `headers`/`duplicateKeys` -(active transport only, blank keys ignored), `command`/`commandRequired`, -`url`/`urlInvalid` (an absolute URL with a host), then featured setup: -`headers`/`bearerTokenRequired` (one `Authorization: Bearer ` value, -scheme case-insensitive) or `arguments`/`allowedDirectoriesRequired` (at least -one POSIX, drive or UNC absolute folder after the two fixed arguments). -`definition` is the normalized server a save writes — trimmed name, command or -URL, only the active transport's fields, blank keys dropped, entries sorted by -key — or `null` while issues remain. Values are never echoed in issues. - -`mcp.batchPlan` accepts `{ files, intent }`, with `intent` one of: -`{ kind: "save", draft, original?: { name, sources: [{ file, sourceHash?, -ownedByLithe }] } }`, `{ kind: "setAgent", definition: McpServer, agent, -enabled }` or `{ kind: "delete", name, files: string[] }`. `save` validates the -draft as above, removes the captured original from every global source whose -Agent is no longer enabled (requiring the captured `sourceHash` and a ledger -entry with that exact hash), and sets the definition in every global file whose -`source` is enabled, passing the captured hash as `expectedHash` and renaming -from the original name where the original existed. `setAgent` targets the first -global file of `agent`; `delete` removes the name from the listed files. Each -file's operations are planned in order with `mcp.planWrite` rules against the -previous output and the evolving ledger. The response is `{ status: "invalid", -issues }`, `{ status: "rejected", rejection: { code, name, file } }` with code -`notOwned`, `externallyModified` or `agentHasNoGlobalConfiguration` (`file` is -the conflicting file, or `null` for draft-level checks), or `{ status: "ready", -changes: [{ file, changed, content?, owned, renames: [{ from, to }] }] }` in -host file order. `content` is present only when changed; `owned` is the file's -next ledger (unchanged files return their input ledger). Hosts write every -changed file as one rollback batch and publish ledgers and renames only after -the whole batch succeeds. `rust/fixtures/mcp/library-v1.json` holds the shared -cases; its `{{sourceHash::}}` placeholders are resolved from the -parsed fixture files. - -### AI configuration - -`ai.configuration.sources` takes `{source, home, environment}` (`source` is -`codex` or `claude`, `home` an absolute path, `environment` only the directory -overrides `CODEX_HOME` / `CLAUDE_CONFIG_DIR`) and returns `{files}`: the native -files `{file, path}` the platform should read. `ai.configuration.parse` takes -`{source, files, environment, allowIncomplete}` with the file texts keyed -`config`, `auth`, `settings`, `credentials` and `root`, each at most 1 MiB, and -returns `null` when nothing is configured, otherwise `{model, provider, issue, -reasoningEffort, hasCredential, credential, unsupportedAuthentication}`. The -response never contains a secret: `credential` is a locator -(`{kind: "environment", name}` or `{kind: "file", file, path}`) that the host -resolves natively against the same text or environment. Malformed input returns -`invalid_request` with a fixed code (`AI_COMMIT_INVALID_CONFIG`, -`AI_CONFIGURATION_INVALID_AUTHENTICATION`, `AI_CONFIGURATION_SMART_QUOTES`, -`AI_CONFIGURATION_TOO_LARGE`) and never echoes input. `allowIncomplete: true` is -for editor drafts: it ignores the environment, accepts blank or partial text and -keeps missing endpoint/model fields empty. The shared cases are in -`rust/fixtures/ai/cli-configuration-v1.json`. It replaces the former -`agent.parseProviderConfiguration`. - -| Command | Purpose | -| --- | --- | -| `core.ping` | Verify the ABI and protocol version | -| `community.discourse.auth.begin` | Create an ephemeral RSA-OAEP authorization session and return the Discourse browser URL | -| `community.discourse.auth.complete` | Decrypt, validate, and consume one Discourse user API key callback | -| `community.discourse.auth.revoke` | Revoke the current Discourse user API key | -| `community.discourse.topics` | List normalized latest or top topic summaries | -| `community.discourse.topic` | Read one topic with ordered, sanitized post HTML | -| `community.discourse.categories` | List normalized visible categories | -| `community.discourse.search` | Search normalized topics and sanitized posts | -| `editor.lineEdit` | Apply a deterministic line-level text transform and return the replacement plus selection to restore | -| `lsp.planWorkspaceEdit` | Validate language-server workspace edit targets against the workspace and open documents, and order the commit | -| `lsp.classifyNavigationTarget` | Classify a language-server jump target as workspace, external read-only or rejected | -| `editor.planTextEdits` | Validate and order a plugin document edit batch into UTF-16 offsets with the document's line ending | -| `editor.parseNavigationInput` | Parse a bounded line or line:column input into zero-based editor coordinates | -| `editor.navigationHistory.reduce` | Reduce one bounded back/forward navigation history event and return the next state plus the location to open | -| `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | -| `workspace.releaseIndexing` | Retire a policy and clear index/cache data at project close; accepts `root`, optional `ownerId`, and that owner's last observed `revision`, returns `released`; an old close must not retire a new generation | -| `workspace.refreshIndexing` | Refresh external Git decisions for a live `root`, `ownerId`, `revision` lease without changing preferences or release tokens; returns lazy `IndexingStatus` | -| `workspace.configureIndexing` | Install a process-local, canonical-root indexing policy before workspace consumers run; returns a monotonic revision, effective directory reasons and Git failure state; see `rust/fixtures/workspace/indexing-policy-v1.json` | -| `network.proxyPolicy` | Normalize the stored proxy settings into the policy a network stack must apply (mode, server, credentials, exclusions) | -| `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index; the built-in hidden names are skipped without being read, and include `node_modules`, Python virtual environments (`.venv`, `venv`), `__pycache__` and `*.pyc` files; `includeTree: false` returns only `files` and a childless `root`; `files` (and the tree, when included) keep the lowercase traversal order that bounded consumers such as Windows Quick Open rely on, not the natural display order of `workspace.listDirectories` | -| `workspace.filterChanges` | Filter an array of workspace-relative paths, or `{ paths, hiddenFilePatterns }` whose `!` entries turn default exclusions off, against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files. | -| `workspace.planDuplicate` | Name the copy of one project item next to its source (`foo.txt` → `foo copy.txt`, then `foo copy 2.txt`) from the sibling names the host has seen to exist | -| `workspace.planPaste` | Name pasted project items in one destination directory: a free name is kept, a taken one gets the copy suffix, and earlier items of the same paste count as taken | -| `workspace.reduceDirectoryAction` | Apply "set role", "exclude" or "cancel exclusion" to a root's persisted directory rules and return the complete state to persist | -| `workspace.relocateDirectoryRules` | Move (or drop, after a delete) the directory rule keys of a renamed, moved or deleted subtree, optionally into another workspace root's rules | -| `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `rust/fixtures/workspace/file-name-order-v1.json`) | -| `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | -| `workspace.recentProjects.reduce` | Apply one record, remove, pin, import, or normalize action to the recent project list and return the retained order | -| `workspace.search` | Search visible file names and UTF-8 text files | -| `workspace.searchEverywhere` | Search visible file names, Java types/methods, and UTF-8 text files | -| `workspace.rankNames` | Pure ranking of candidate names that the platform enumerates itself (`{ query, candidates: [{ name, path? }], context: { openPaths, recentPaths }, limitPerTier? }`). Use it only for lists Core cannot index: Windows remote/WSL roots, symbol lists, navigator lists. Roots Core indexes go through `workspace.searchEverywhere`, so an inventory is never sent back to Core. Returns `matches: [{ index, degree, tier, nameMatchRanges }]` ordered by degree, open/recent tier, recency, then file-name order (fixture `rust/fixtures/search/rank-names-v1.json`) | -| `workspace.replacePreview` | Return deterministic replacement lines and complete replacement text | -| `workspace.replaceText` | Plan replacement against a host-captured text snapshot without writing | -| `workspace.searchTextBatch` | Search provider-captured text snapshots using workspace matching rules | -| `file.read` | Read a text file using a workspace-relative path; optional `encoding`, see [File encodings](#file-encodings) | -| `file.write` | Write a text file using a workspace-relative path; optional `encoding` and `bom` | -| `text.decode` | Decode `{ bytes }` (standard base64 of bytes the host read) like `file.read`, optional `encoding`; returns `{ text, encoding, hasBom, lossy, identity }` with the same 32 MB, plain-text and detection rules | -| `text.encode` | Encode `{ text }` like `file.write`, optional `encoding` and `bom`; returns `{ bytes, encoding, hasBom, identity }` with `bytes` in standard base64. Nothing is written, and an unmappable character fails with `encoding_unmappable` as `file.write` does | -| `document.lifecycle` | Reduce a shared document save, external-change, or conflict event without reading text or disk | -| `document.planSaveBatch` | Select the open documents a Save All or save-before-launch batch writes | -| `document.reduceSaveBatch` | Reduce save batch member outcomes and live documents into one batch status | -| `history.record` | Store a versioned text snapshot and metadata | -| `history.entries` | List valid history entries for one file or a workspace | -| `history.content` | Read a stored history snapshot by relative storage path | -| `history.relocate` | Move retained history for a file or directory subtree after a rename or move | -| `history.planEvent` | Plan which snapshots an editing event (save, auto-save, restore, manual, discarded unsaved text, external change, before rename, before batch replace) records and under which reason | -| `history.rename` | Set or clear a user-visible label on a history entry | -| `history.delete` | Delete one history entry and its snapshot | -| `history.ignorePreview` | Validate local history ignore rules and report which rule skips a sample path | -| `history.usage` | Report one project's allocated history size and the storage cap | -| `history.clear` | Delete every snapshot of one project's history | -| `diff.compareTexts` | Compare two host-supplied texts line by line into paired side-by-side rows and unified lines | -| `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | -| `maven.launchPlan` | Produce a deterministic Maven invocation from a versioned project context | -| `execution.resolveEnvironment` | Merge native `inherited` string values and `overrides` into `{ environment }`; string overrides win. Core names no tool: a runtime home such as `JAVA_HOME` reaches a launch through the selected toolchain installation (`toolchains.*`), which the host applies before this merge, or as a literal override from the plugin that owns the toolchain. A structured `{ toolchain, property }` value the host could not resolve to text has no defined value and contributes nothing. `caseInsensitive: true` canonicalizes Windows keys to uppercase. | -| `execution.resolveSearchPath` | Preserve existing path precedence, sort platform candidates by priority and identity, and deduplicate using the requested case policy. | -| `execution.planLaunchCommand` | Move oversized Java path-list options into a JDK argument file | -| `processLifecycle.resolve` | Decide how each process or resource of one Lithe instance ends | -| `processLifecycle.inventory` | Group a process snapshot of one Lithe instance into terminals, Agent tasks and background services | -| `processLifecycle.settle` | Turn confirmation answers into what ends, what is kept and the updated settings | -| `maven.dependencyPlan` | Produce a bounded dependency-tree invocation for one Maven module | -| `maven.dependencies` | Normalize the bounded dependency-tree file one plan wrote into a deterministic tree | -| `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | -| `maven.testResults` | Parse bounded JUnit/Surefire result summaries and failure locations | -| `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | -| `debug.validateAdapterEndpoint` | Validate an IDE-to-DAP TCP endpoint and classify whether remote consent is required; performs no DNS or network I/O | -| `debug.transport.policy` | Return the default byte-stream deadlines, queue budgets and the session `initialize` deadline; creates no state | -| `debug.transport.handle` | Apply one platform event to an IDE-to-adapter byte-stream lifecycle and return the effects the platform runs next | -| `debug.transport.release` | Forget the lifecycle of a stopped byte stream once the platform dropped its handles | -| `debug.launch` | Queue a launch or attach request, including during initialization | -| `debug.javaTestLaunch` | Normalize JUnit or TestNG launch metadata into Java DAP arguments | -| `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | -| `debug.relocateBreakpoints` | Move source breakpoints across one exact UTF-16 editor replacement | -| `debug.planVariablePage` | Plan the first bounded `variables` page of one scope or structured variable from its reported counts | -| `debug.reduceVariablePage` | Merge one returned variable page into its cursor and decide whether the container is exhausted | -| `debug.planDisassemblyWindow` | Plan the first `disassemble` request of the window around the instruction pointer or one listed edge | -| `debug.reduceDisassemblyPage` | Reduce one `disassemble` reply to the next bounded recovery request, a deduplicated page, or the adapter's failure | -| `debug.setBreakpoints` | Replace and deterministically order one source's DAP breakpoints | -| `debug.setExceptionBreakpoints` | Replace and deterministically order one session's exception filters | -| `debug.setFunctionBreakpoints` | Replace and deterministically order one session's named function breakpoints | -| `debug.dataBreakpointInfo` | Resolve an adapter-owned data breakpoint identity for a paused variable or field | -| `debug.setDataBreakpoints` | Replace and deterministically order one session's resolved data breakpoints | -| `debug.setInstructionBreakpoints` | Replace and deterministically order one session's machine-instruction breakpoints | -| `debug.setVariable` | Replace one visible variable value in its adapter-owned parent container | -| `debug.cancelOperation` | Cancel or time out one pending operation and ignore its late response | -| `debug.execute` | Submit continue, pause, next, step-in, or step-out control | -| `debug.inspect` | Request normalized threads, frames, scopes, variables, or evaluation | -| `debug.receive` | Reduce base64-encoded bytes received from a platform-owned DAP transport | -| `debug.runInTerminalResponse` | Complete one adapter-requested native terminal launch | -| `debug.disconnect` | Begin the DAP disconnect handshake without closing the native transport; optional `terminateDebuggee` applies to launch sessions only | -| `debug.destroySession` | Remove a session and its whole child tree after the platform closes its native transport; the response lists `destroyedChildSessionIds` (leaves first) whose transports the platform must also close | -| `lsp.applyTextEdits` | Apply LSP UTF-16 text edits with range validation | -| `lsp.plainSnippet` | Convert LSP snippet insert text into plain editor text | -| `lsp.builtinCompletions` | Return lightweight current-file identifier completions | -| `lsp.builtinHover` | Return lightweight current-symbol hover text | -| `lsp.builtinNavigation` | Return lightweight current-file definition/reference locations | -| `lsp.mergeCompletions` | Choose which candidates survive when several prioritized providers complete at one position | -| `lsp.activationPolicy` | Decide whether a language server may start automatically after failures, using a platform monotonic clock | -| `lsp.problems.query` | Filter, order, and group Problems tool-window diagnostics and count them by severity | -| `lsp.documentUri` | Convert a native absolute path into a canonical file URI | -| `lsp.documentIdentity` | Resolve a diagnostic URI or path strictly inside its workspace | -| `lsp.startServer` | Start one Rust-owned process/session and begin initialization | -| `lsp.jdtWorkspaceKey` | Derive the deterministic JDT LS workspace-state directory key | -| `java.workspacePolicy` | Decide Java workspace activation and classify changed paths | -| `java.runMarkers` | Project JDT main/test discovery and canonical build test outcomes into editor Run markers | -| `java.jdtWorkspaceFingerprint` | Reduce platform build-file observations to the portable JDT LS workspace fingerprint | -| `java.jdtCacheRetention` | Select expired inactive JDT LS workspace-state keys from platform metadata | -| `lsp.stopServer` | Gracefully shut down a session, with a bounded force-stop fallback | -| `lsp.updateWorkspaceConfiguration` | Replace the plugin configuration a running plugin-owned session answers `workspace/configuration` with, and announce it | -| `lsp.updateMavenConfiguration` | Send a changed Maven context to a running Java session, or force JDT LS to re-resolve its Maven projects | -| `lsp.syncDocument` | Open a document or apply a full-text or incremental `didChange` with monotonic versions | -| `lsp.saveDocument` | Report a confirmed disk save with its exact saved text, honoring server save options | -| `lsp.workspaceFilesChanged` | Publish normalized created, changed, or deleted workspace files to one session | -| `lsp.closeDocument` | Close a document and clear its diagnostics | -| `lsp.request` | Submit a typed semantic request and return an opaque operation ID | -| `lsp.buildRequest` | Forward one planned `lithe/build/*` request to a session that negotiated the build-provider protocol | -| `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | -| `lsp.hostResponse` | Answer one host-service request a plugin backend sent | -| `lsp.hostNotify` | Send one host notification to a plugin backend that negotiated it | -| `lsp.pluginServerRequest` | Send one request of a plugin backend to the plugin's own language server, under the plugin's declared session policy | -| `lsp.pluginServerNotify` | Send one notification of a plugin backend to the plugin's own language server | -| `lsp.configureFunctionRelay` | Replace the enabled plugins and live workspace contexts Core's plugin-to-plugin function relay checks calls against | -| `lsp.toolchainRequest` | Send one `toolchains/query` to a plugin backend that negotiated the provider | -| `lsp.keymapImportRequest` | Send one shortcut import request to a negotiated plugin provider | -| `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | -| `java.prepareLaunch` | Prepare a saved Java source through shared entry selection, serialized build and runtime path resolution | -| `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | -| `java.resolveNavigation` | Resolve one Java gutter marker to normalized parent or implementation locations | -| `lsp.cancelOperation` | Cancel one pending semantic operation | -| `lsp.pollEvents` | Drain ordered typed lifecycle/feature/diagnostic/result/log events | -| `lsp.waitEvents` | Block until queued events exist or a timeout elapses, then drain them | -| `lsp.clearDiagnostics` | Clear every diagnostic owned by a session | -| `lsp.snapshot` | Return a diagnostic runtime snapshot for testing and control surfaces | -| `lsp.destroyServer` | Remove a terminal session handle from the registry | -| `java.codeVision` | Return Java declaration usage counts for editor code vision | -| `java.className` | Resolve a Java source package and simple name into a runtime class name | -| `java.sourceDefinition` | Locate a Java type, method, or field declaration in source text | -| `java.serverPort` | Parse Spring server port settings from properties or YAML text | -| `java.structure` | Parse Java editor folds, inlay hints, and portable syntax roles | -| `spring.index` | Build a deterministic Spring configuration, bean, injection, and endpoint index | -| `mybatis.index` | Build a deterministic MyBatis mapper-interface and XML statement index | -| `runConfig.selectJava` | Select a project-compatible automatic JDK from platform-probed candidates | -| `runConfig.inspect` | Inspect `.lithe` run documents, versions, and staleness without writing files | -| `runConfig.projectToolchain` | Read saved project tool paths for one Provider root, with the nearest path override | -| `runConfig.generate` | Generate deterministic Java/Maven configurations and toolchain requirements | -| `runConfig.resolve` | Merge generated, project, and local layers and return diagnostics | -| `runConfig.selectContextConfiguration` | Choose a matching effective configuration for a provider-supplied source and entry identity | -| `runConfig.updateOptions` | Apply typed option edits and return an updated project or local document | -| `runConfig.updateToolPreferences` | Replace only generic preferences in a captured local Run document, preserving legacy defaults and unrelated metadata | -| `runConfig.argumentText` | Format a batch of exact argument vectors as reversible editor text | -| `runConfig.saveEditorChanges` | Prepare the local and optional project documents for one editor save | -| `runConfig.createUserConfiguration` | Validate a typed user configuration and return an updated document | -| `runConfig.createLaunchPlan` | Project one effective configuration into a platform-neutral Run or Debug plan | -| `git.repositorySetup` | Inspect repository/unborn state and scoped/effective commit identity; see `git-repository-setup.md` | -| `git.initialize` | Initialize a directory outside existing repositories without staging or committing | -| `git.configureIdentity` | Save or clear one local/global `user.name` or `user.email` override | -| `git.status` | Resolve the repository, current branch, and working-tree changes | -| `git.workspaceStatusMerge` | Plan publication of fresh, retained, and unavailable repository snapshots | -| `git.rollbackPlan` | Plan whole-file rollback from explicit Git status identities without reading or changing a repository | -| `git.rollbackPrepare` | Recheck selected identities against the repository and return rollback steps | -| `git.prepareCommitSelection` | Prepare and verify the real indexes for explicitly selected changes | -| `git.reconcileDeletedSelection` | Clear missing selected staged additions after filesystem deletion | -| `git.changelists` | Reduce local ChangeList metadata and project membership and commit scope | -| `git.commitMessageContext` | Capture bounded AI commit evidence and recheck its Git state | -| `git.untrackedDirectory` | List one level of a summarized untracked directory: its files and subdirectory summaries | -| `git.commitState` | Read exact HEAD, symbolic branch and index preconditions for a workspace commit | -| `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | -| `git.classifyMetadataChanges` | Classify a bounded page of coalesced watcher paths without filesystem access | -| `git.worktrees` | Return deterministic registered-worktree metadata without scanning each checkout | -| `git.pullRequestContext` | Resolve worktree-aware PR branch defaults, publication state, and uncommitted-change state | -| `git.pullRequestCheckoutPreview` | Inspect and review a non-destructive PR checkout plan | -| `git.pullRequestCheckout` | Recheck and execute a reviewed PR checkout without resetting an existing branch | -| `git.command` | Execute one argument-based Git operation and return its arguments, streams, exit code, and ordered subprocess invocations | -| `git.repositoryRoot` | Resolve the repository root containing a workspace path without acquiring repository write coordination | -| `git.resolveFileContext` | Resolve a file to its owning Git checkout and relative path | -| `git.write` | Validate and execute shared Git mutations such as stage, commit, branch, checkout, remote sync, clone, and stash | -| `git.fetchPlan` | Validate Fetch choices; optionally inspect a repository to expand enabled per-remote commands | -| `git.consolePresentation` | Pure IDEA-style configuration/progress/query folds, empty-output notices and search ranges over retained diagnostic snapshots | -| `git.remoteUrl` | Read a configured remote URL silently; a missing remote returns a null URL | -| `git.executionInspect` | Inspect executable capabilities, configuration provenance and effective Fetch preferences | -| `git.executionConfigure` | Explicitly save or clear one allowlisted value in a selected config scope | -| `git.authRespond` | Answer or cancel a live authentication challenge once | -| `remote.probe` | Check that an SSH connection reaches a supported host | -| `remote.authRespond` | Answer or cancel a prompt relayed by `remote.probe` or `remote.connect` once | -| `remote.connect` | Open a connection, installing the shipped server when needed | -| `remote.readDir` / `remote.readFile` / `remote.writeFile` | List, read or save over an open connection | -| `remote.disconnect` | Close an open connection and its OpenSSH process tree | -| `remote.closeAll` | Close every open connection; the host calls it at application exit | -| `git.historyRewritePreview` | Review undo, message edit, squash, or drop with complete messages, eligibility, and an immutable checkout expectation | -| `git.historyActionCapabilities` | Read history menu hints from actual HEAD and the bounded first-parent chain | -| `git.rebasePreview` | Resolve the complete local linear range strictly after a selected unchanged base | -| `git.rebaseStart` | Start a reviewed native interactive rebase with persisted messages and recovery identity | -| `git.rebaseSession` | Read the latest owned rebase session and distinguish edit/conflict pauses from completion | -| `git.rebaseControl` | Continue, skip, or abort an identified session, optionally amending an edit pause | -| `git.diff` | Produce a structured working-tree, index, reference, commit, or stash patch | -| `git.parsePatch` | Parse a complete saved patch into Core-owned per-file documents | -| `git.apply` | Apply or check a patch in `stage`, `unstage`, `discard`, or Shelf restore mode | -| `git.planDiffBlocks` | Plan commit-review blocks for one file; Core owns every block patch | -| `git.applyDiffBlock` | Include, exclude or roll back one planned block under the write lease | -| `git.stageGutterChange` | Stage one editor gutter change with its original bytes | -| `git.history` | Return the legacy combined reference snapshot and first bounded commit page | -| `git.references` | Return deterministic refs, recent local branches, ahead/behind state, and effective Git identity without scanning commit history | -| `git.remotes` | Return configured fetch remote names and URLs | -| `git.tags` | Return tag names, tag object IDs, target IDs, dates, and annotations | -| `git.parseCheckoutMetadata` | Interpret bounded HEAD text without filesystem access | -| `git.parseGitdirPointer` | Interpret a bounded gitdir pointer without filesystem access | -| `git.historyPage` | Return one bounded commit page, author date, UTC offset, optional Unix timestamp, and an opaque continuation cursor | -| `git.historyQuery` | Compare bounded commit date identities with explicit absolute time bounds | -| `git.historyCursorClose` | Release an unfinished incremental history cursor and its Git process | -| `git.historyLocate` | Append bounded history pages until one commit is found and report the budget, window cursor, and exhaustion | -| `git.branchNamePreview` | Clean and validate a new branch name against already loaded references without running Git | -| `git.repositoryColorAssignments` | Assign color slots to a bounded page of ordered repository identities | -| `git.pushPreview` | Resolve a local branch push destination and the bounded commits not present on that remote base | -| `git.commit` | Return one structured commit by revision with its full message body | -| `git.commitFiles` | Return files changed by one commit | -| `git.comparison` | Return files changed between a reference and the working tree | -| `git.stashes` | Return structured stash references and messages | -| `git.checkoutPreflight` | Return local paths that would block switching to a reference | -| `git.pullPreflight` | Report the configured upstream, ahead/behind counts, divergence, tracked local changes, and the strategy Git is configured for, without fetching | -| `git.updatePlan` | Route a branch Update and gate a Pull from facts the host collected: blockers, strategy default, whether to ask, and the expected checkout | -| `git.historyWindow` | Plan a history refresh window, page merge, and selection retention over commit identities | -| `git.graphProjection` | Project a loaded history window into commit-graph lanes, color IDs, routes, print elements, and labels, optionally filtered | -| `git.integrationPreflight` | Return local paths that block a merge, rebase, cherry-pick, or revert | -| `git.conflictMarkers` | Return staged text files that still contain conflict markers | -| `git.operationState` | Report an interrupted merge, rebase, cherry-pick, or revert and its conflicted paths | -| `git.blame` | Return structured line blame metadata | -| `git.lineHistory` | Trace which commits touched a one-based snapshot line range of one file | -| `github.parseRemote` | Parse a canonical GitHub HTTPS or SSH remote into owner/name | -| `github.requestPlan` | Validate one GitHub operation and produce a trusted platform HTTP request plan | -| `github.normalizeResponse` | Normalize raw GitHub JSON and HTTP status into deterministic data or a stable error | -| `github.deviceAuthorizationTransition` | Plan the next device-authorization action, polling interval, and deadline-bounded wait | -| `github.pullRequestCheckoutPlan` | Plan a PR's safe local branch and report an existing-ref conflict | -| `diagnostics.prepareLogEvent` | Normalize a bounded application event and recursively redact structured fields | -| `diagnostics.queryLogs` | Merge and filter bounded host-collected module log tails | -| `diagnostics.encodeLogBatch` | Encode independent structured v2 log events with per-item errors | -| `diagnostics.parseLogFilter` | Parse bounded filter expressions and UTF-16 diagnostic ranges | -| `diagnostics.composeLogFilter` | Compose a filter predicate preserving unrelated terms | -| `diagnostics.formatLogRecords` | Validate and format canonical redacted records for copying | -| `diagnostics.queryLogsV2` | Query structured v2 and legacy v1 native log tails | -| `diagnostics.planLogCollection` | Plan bounded native log-tail reads from raw file metadata | -| `diagnostics.planLogRetention` | Plan log cleanup from host-owned file inventories | -| `diagnostics.resolveEngineLogSubject` | Resolve the same routing segment and channel used by engine startup, without I/O | -| `diagnostics.resolveLogChannel` | Validate channels and resolve safe session-relative destinations | -| `diagnostics.drainCoreEvents` | Atomically take process-owned Core log facts and their dropped count | -| `diagnostics.configureCoreEvents` | Set the DEBUG admission gate for Core log facts from host detailed-diagnostics state | -| `diagnostics.redactText` | Redact credentials, tokens, and home-directory paths from diagnostic-bundle text | -| `diagnostics.buildManifest` | Shape a deterministic diagnostic bundle manifest from host-gathered environment and file facts | -| `plugins.declarative.validate` | Validate and normalize a declarative plugin manifest | -| `plugins.declarative.runPlan` | Expand a declarative plugin's run template for one workspace file | -| `plugins.declarative.discoverTests` | List a declarative plugin's workspace and file test items | -| `plugins.declarative.testPlan` | Expand a declarative plugin's test template for a workspace, file, or test case | -| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server, build-provider, VCS-provider, backend, or debug-adapter launch with host directories and architecture | -| `plugins.languageServers.decodeLaunch` | Bound the answer of a plugin backend that resolved a language server's launch and return the plan the host starts | -| `plugins.languageServers.stateRetention` | Choose which inactive per-workspace state directories of a plugin language server the host deletes | -| `plugins.contentProviders.decode` | Bound the answer of a plugin content provider function and return the document text | -| `debug.adapters.startArguments` | Check one debug start against a plugin's `debugAdapters[]` entry and return its DAP launch arguments | -| `debug.adapters.executablePlan` | Say where to look for the program a launch debugs: the user's setting and the adapter's candidate paths | -| `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | -| `plugins.installed.select` | Select numeric installed versions from native facts without reading paths | -| `plugins.declarative.settingsState` | Read complete plugin preference documents into Core-normalized rows, sources, and bounded native read plans | -| `plugins.declarative.editSettings` | Validate one atomic setting edit batch and return replacement text while preserving unrelated plugins and dormant values | -| `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | -| `toolchains.registry` | Revalidate complete enabled plugin manifests, `{plugins:[{manifest}]}`, into a deterministic tool-definition/provider registry; the host supplies no origin evidence. A short tool ID defined by several enabled plugins belongs to none of them: none of those definitions is registered, the rest register as usual, and `conflicts: [{tool, pluginIDs}]` (sorted by tool, `pluginIDs` sorted) reports each contested tool. A repeated plugin ID rejects the batch. No native host capability claim | -| `toolchains.decodeReply` | Validate an inert toolchain query/reply pair against bound provider identity, generation, declared tools and host-native filesystem evidence; no transport or native I/O | -| `toolchains.failureReply` | Build the JSON-RPC error reply for a query that missed its deadline or found no backend, so the host passes it to `toolchains.decodeReply` like a provider's own error; cancellation has none | -| `toolchains.resolvePins` | Validate a captured local document and find the nearest ancestor directory pin, retaining dormant and legacy entries without probing or writing | -| `toolchains.editPins` | Apply one bounded batch of directory pin edits to a captured local document without writing native files | -| `toolchains.pinStoragePlan` | Declare bounded workspace-native pin and ignore sources | -| `toolchains.decodePinDocument` | Validate a complete native source or supply the absent-source default | -| `toolchains.editPinSources` | Plan deterministic pin and Git ignore source edits without native I/O | -| `toolchains.resolveConsumerPreferences` | Validate an inert consumer preference document and resolve one tool's explicit Run, consumer, directory or workspace choice while keeping workspace/consumer inheritance symbolic; no native files or launch authority | -| `toolchains.editConsumerPreferences` | Edit or reset one exact consumer/directory tool preference; return the document and effective input without native writes | -| `toolchains.resolve` | Resolve the fixed/project/global/automatic layers from one generation-bound snapshot, retaining unmet requirements, unavailable providers with their cause, conflicts and terminal fixed-value failures | -| `toolchains.checkVersion` | Match captured output with an enabled plugin definition's version expression and compare generic numeric components with an optional minimum; never starts a probe | -| `toolchains.instantiateLaunch` | Expand an enabled tool definition's `launch` template for a selected executable, entry, runtime arguments and arguments; a part the template cannot carry is `plugin.toolchain.launchMismatch`; performs no I/O | -| `toolchains.discoveryPlan` | Return platform-filtered definition-owned scan declarations, layouts, probes, PATH entries, login-shell home variables and budgets; never scans native directories | -| `toolchains.candidates` | List one tool's login-shell homes and host-scanned installations with checked executables, versions or skip reasons, and the automatic-layer choice; never starts a probe | -| `plugins.declarative.decodeGrammar` | Validate independent static editor resources | -| `plugins.editorActions.decode` | Validate independent action registration, collection or click results | -| `plugins.hostApi.describe` | Describe backend host methods and their required grants from Core's method table | -| `plugins.hostApi.authorize` | Check a method, service and current session grants using Core's capability rules; the host still checks session and resource ownership | -| `plugins.editorProviders.decode` | Validate editor provider registrations and provider function results | -| `run.decodeStart` | Validate a plugin backend's `lithe/host/run/start` params against its manifest and the workspace, and return the normalized start with temporary-file paths substituted | -| `debug.decodeBackendConfiguration` | Validate a plugin backend's `lithe/host/debug/start` params against its manifest and return the qualified adapter, request, label and configuration | -| `outputLinks.match` | Find the links plugin `outputLinkMatchers` declare in Run or Test console lines, as UTF-16 ranges with candidate paths or a backend function to resolve them | -| `outputLinks.decodeResolution` | Check a matcher's `resolve` function reply and return the 1-based file location to open, or `null` | -| `build.detect` | Find every build-system instance the enabled plugins declare in a workspace, with models that need no process | -| `build.resolveStoredOptions` | `{ document, local: { version: 1, document, emptySelections?: { : { : [optionID] } } } }`, without a manifest | `storage: { document, local, emptySelections }`: checked team/local merge; local values override matching keys, unrelated systems/roots/choices stay intact. No filesystem I/O. Local documents are machine/workspace preferences, not team files or plugin-native configuration. Their storage syntax is [build-local-options-v1.schema.json](build-local-options-v1.schema.json), with additional cross-field membership and byte limits checked by Core. | -| `build.editStoredOption` | `{ document, local, root, optionID, value, modelValues? }` in the standard manifest/build-system envelope | `edit: { local, options }`: changes one declared local preference. `null` removes the local override and restores team/declaration inheritance. Invalid or partially accepted edits reject before persistence. Workspace multi-selections preserve sibling-only values; explicit per-root empty markers apply only to workspace multi-select declarations. Platforms compare both native source revisions, atomically write only the local document and read back. | -| `build.invocationPlan` | List a build system's executable candidates, runtime dependencies, and version probe for one platform | -| `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum | -| `build.resolveModelInputs` | Validate enabled producer facts and project a bounded public model snapshot for a declared consumer | -| `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint | -| `build.configurationPlan` | Plan a read of the provider's non-sensitive native configuration | -| `build.decodeConfiguration` | Validate native source identities, raw/effective values and XML bindings | -| `build.configurationEditPlan` | Plans format-independent provider editing into private host staging | -| `build.decodeConfigurationEdit` | Validates the receipt without native document bytes | -| `build.modelPlan` | Expand the command that prints a command-sourced build model, or the request a build provider answers | -| `build.taskPlan` | Expand one declared or model task for a workspace, project, or target | -| `build.testPlan` | Plan a test run for a scope, suite, or case and say where its reports will be | -| `build.testDiscoveryPlan` | Plan opt-in Provider test discovery for an instance | -| `build.decodeTestDiscovery` | Validate, deduplicate and order Provider test selectors | -| `build.prepareRunPlan` | Plan Provider-owned application/service/task preparation from a model target | -| `build.decodeRunPreparation` | Validate portable launch data before the native Run host starts a process | -| `build.prepareDebugPlan` | Plan same-plugin native debug preparation | -| `build.decodeDebugPreparation` | Validate an existing instance-contained native debug program and bounded process facts | -| `build.dependencyPlan` | Expand the command that prints a project's dependency tree, or the request a build provider answers | -| `build.decodeModel` | Decode model output or a provider result into projects, targets, tasks, content roots, option values, and run entries | -| `build.decodeTestResults` | Decode a test run's reports and output into per-case results and a summary | -| `build.decodeDiagnostics` | Decode build output into workspace file diagnostics | -| `build.decodeDependencies` | Decode dependency output or a provider result into one project's bounded tree | -| `build.syncFingerprint` | Digest the files a build model depends on, to tell whether it is stale | -| `build.languageImport` | Map a build system's options to the Java language server's Maven or Gradle project import | -| `build.resolveTestRunOutcome` | Reduce a test run's termination, selected scope and decoded summary to one tool-neutral verdict | -| `build.mergeTestOutcomes` | Merge a run's test result ids into the stored history inside the host-stated id/prefix scope | -| `vcs.detectRoots` | Find the VCS roots of a workspace from static markers, with registered providers, ambiguous directories, and applied mappings | -| `vcs.ownerOf` | Assign workspace paths to the detected root that owns each | -| `vcs.decodeReply` | Validate a VCS provider's reply and return the normalized result | -| `project.generatorCatalog` | List the project generators of the given plugins, with the plugins that provide each requirement | -| `project.generatorForm` | Evaluate a generator's form: derived defaults, visibility, problems, and active requirements | -| `project.generationPlan` | Expand a filled-in form into the files, commands, and archive downloads that create a project | -| `project.checkName` | Check that a name can be a new project's directory name | -| `project.extractArchive` | Extract a downloaded project archive into the staging directory, refusing unsafe entries | -| `plugins.marketplace.validateIndex` | Validate a marketplace index and report each plugin's availability for one platform | -| `plugins.marketplace.resolveSource` | Resolve a user-entered marketplace source to the index URL to fetch | -| `plugins.marketplace.installReview` | List a package's permissions and programs, and those not yet approved | - +The MCP configuration and library commands were removed with the agent-facing MCP +subsystem: `mcp.parseConfiguration`, `mcp.parseRegistry`, `mcp.featuredRegistry`, +`mcp.planWrite`, `mcp.toolPolicy`, `mcp.commandLine`, `mcp.librarySnapshot`, +`mcp.validateDraft` and `mcp.batchPlan` no longer exist, together with +`rust/fixtures/mcp` and `rust/contracts/mcp-registry-page.json`. The IDE-facing MCP +server (`lithe-mcp` plus `rust/fixtures/ide-api/mcp-protocol.json`) is unaffected and +stays in `lithe-ide-host`. ### Maven test results `maven.testResults` accepts `{ "root": string, "output": string }` and parses diff --git a/rust/fixtures/mcp/featured-setup-v1.json b/rust/fixtures/mcp/featured-setup-v1.json deleted file mode 100644 index a2b2df56..00000000 --- a/rust/fixtures/mcp/featured-setup-v1.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "entries": [ - { - "id": "lithe.featured/example-token", - "title": "Example token service", - "summary": "Synthetic setup contract", - "publisher": "Example", - "source": "featured", - "version": "", - "website": "https://example.invalid/docs", - "setup": "bearer-token", - "draft": { - "name": "example-token", - "transport": "http", - "command": null, - "args": [], - "env": [], - "url": "https://example.invalid/mcp", - "headers": [], - "definitionHash": "" - } - }, - { - "id": "lithe.featured/example-folders", - "title": "Example allowed folders", - "summary": "Synthetic setup contract", - "publisher": "Example", - "source": "featured", - "version": "1.2.3", - "website": null, - "setup": "allowed-directories", - "draft": { - "name": "example-folders", - "transport": "stdio", - "command": "npx", - "args": [ - "-y", - "@example/filesystem@1.2.3" - ], - "env": [], - "url": null, - "headers": [], - "definitionHash": "" - } - } - ], - "nextCursor": null -} diff --git a/rust/fixtures/mcp/library-v1.json b/rust/fixtures/mcp/library-v1.json deleted file mode 100644 index ca9fe8a5..00000000 --- a/rust/fixtures/mcp/library-v1.json +++ /dev/null @@ -1,818 +0,0 @@ -{ - "schema": "lithe.mcp.library.v1", - "description": "Synthetic MCP library inputs shared by Core and macOS. Ledger sourceHash values written as {{sourceHash::}} are replaced with the hash Core parses from that file before the case runs, so the fixture never stores secret-bearing content or brittle hashes. Every token-looking value is a placeholder.", - "files": { - "codexGlobal": { - "id": "codex|global|", - "source": "codex", - "scope": "global", - "path": "/fixture/home/.codex/config.toml", - "content": "# user comment\n[mcp_servers.docs]\ncommand = \"npx\"\nargs = [\"-y\", \"docs-mcp\"]\n\n[mcp_servers.Server10]\nurl = \"https://example.invalid/ten\"\n\n[mcp_servers.server2]\nurl = \"https://example.invalid/two\"\n" - }, - "claudeGlobal": { - "id": "claude|global|", - "source": "claude", - "scope": "global", - "path": "/fixture/home/.claude.json", - "content": "{\n \"mcpServers\": {\n \"docs\": {\"command\": \"npx\", \"args\": [\"-y\", \"docs-mcp\"]},\n \"server2\": {\"type\": \"http\", \"url\": \"https://example.invalid/other\"}\n }\n}\n" - }, - "claudeProject": { - "id": "claude|project|/fixture/project", - "source": "claude", - "scope": "project", - "projectPath": "/fixture/project", - "path": "/fixture/home/.claude.json", - "content": "{\n \"projects\": {\n \"/fixture/project\": {\n \"mcpServers\": {\"lithe\": {\"command\": \"/fixture/bin/lithe-mcp\"}}\n }\n }\n}\n" - }, - "codexBroken": { - "id": "codex|project|/fixture/project", - "source": "codex", - "scope": "project", - "projectPath": "/fixture/project", - "path": "/fixture/project/.codex/config.toml", - "content": "[mcp_servers.docs\ncommand = \"fixture-secret-should-not-echo\"\n" - }, - "codexMissing": { - "id": "codex|global|", - "source": "codex", - "scope": "global", - "path": "/fixture/home/.codex/config.toml", - "content": null - } - }, - "snapshot": { - "files": [ - "codexGlobal", - "claudeGlobal", - "claudeProject" - ], - "ledger": { - "claude|global|": [ - { - "name": "docs", - "definitionHash": "{{definitionHash:claude|global|:docs}}", - "sourceHash": "{{sourceHash:claude|global|:docs}}" - } - ] - }, - "expectedRows": [ - { - "name": "docs", - "files": [ - "codex|global|", - "claude|global|" - ], - "owned": [ - false, - true - ], - "hasDefinitionConflict": false, - "projectOnly": false - }, - { - "name": "lithe", - "files": [ - "claude|project|/fixture/project" - ], - "owned": [ - false - ], - "hasDefinitionConflict": false, - "projectOnly": true - }, - { - "name": "server2", - "files": null, - "hasDefinitionConflict": true, - "projectOnly": false - }, - { - "name": "server2", - "files": null, - "hasDefinitionConflict": true, - "projectOnly": false - }, - { - "name": "Server10", - "files": [ - "codex|global|" - ], - "owned": [ - false - ], - "hasDefinitionConflict": false, - "projectOnly": false - } - ], - "failureFiles": { - "files": [ - "codexBroken", - "claudeGlobal" - ], - "expectedFailures": [ - { - "file": "codex|project|/fixture/project", - "code": "invalid_request" - } - ], - "expectedRowCount": 2 - } - }, - "validateDraft": [ - { - "case": "valid stdio drops blank env keys and sorts env", - "draft": { - "name": " docs ", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [ - "", - " spaced " - ], - "env": [ - { - "key": "ZETA", - "value": "z" - }, - { - "key": " ", - "value": "ignored" - }, - { - "key": "ALPHA", - "value": "a" - } - ], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "issues": [], - "definition": { - "name": "docs", - "command": "npx", - "args": [ - "", - " spaced " - ], - "envKeys": [ - "ALPHA", - "ZETA" - ] - } - }, - { - "case": "empty name and no agents", - "draft": { - "name": " ", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [], - "env": [], - "headers": [], - "enabledAgents": [] - }, - "issues": [ - { - "field": "name", - "code": "nameRequired" - }, - { - "field": "enabledAgents", - "code": "agentsRequired" - } - ] - }, - { - "case": "invalid name", - "draft": { - "name": "-docs", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "issues": [ - { - "field": "name", - "code": "nameInvalid" - } - ] - }, - { - "case": "duplicate env keys", - "draft": { - "name": "docs", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [], - "env": [ - { - "key": "TOKEN", - "value": "placeholder" - }, - { - "key": "TOKEN", - "value": "placeholder" - } - ], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "issues": [ - { - "field": "env", - "code": "duplicateKeys" - } - ] - }, - { - "case": "inactive transport duplicates are ignored", - "draft": { - "name": "docs", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [], - "env": [], - "headers": [ - { - "key": "A", - "value": "1" - }, - { - "key": "A", - "value": "2" - } - ], - "enabledAgents": [ - "codex" - ] - }, - "issues": [] - }, - { - "case": "missing command", - "draft": { - "name": "docs", - "transport": "stdio", - "command": " ", - "url": "", - "arguments": [], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "issues": [ - { - "field": "command", - "code": "commandRequired" - } - ] - }, - { - "case": "url without host", - "draft": { - "name": "remote", - "transport": "http", - "command": "", - "url": "localhost:8080", - "arguments": [], - "env": [], - "headers": [], - "enabledAgents": [ - "claude" - ] - }, - "issues": [ - { - "field": "url", - "code": "urlInvalid" - } - ] - }, - { - "case": "bearer template needs token", - "draft": { - "name": "github", - "transport": "http", - "command": "", - "url": "https://example.invalid/mcp", - "arguments": [], - "env": [], - "headers": [ - { - "key": "Authorization", - "value": "" - } - ], - "enabledAgents": [ - "claude" - ], - "setup": "bearer-token" - }, - "issues": [ - { - "field": "headers", - "code": "bearerTokenRequired" - } - ] - }, - { - "case": "bearer template accepts scheme case-insensitively", - "draft": { - "name": "github", - "transport": "http", - "command": "", - "url": "https://example.invalid/mcp", - "arguments": [], - "env": [], - "headers": [ - { - "key": "authorization", - "value": "bearer fixture-placeholder" - } - ], - "enabledAgents": [ - "claude" - ], - "setup": "bearer-token" - }, - "issues": [] - }, - { - "case": "filesystem template needs absolute folders", - "draft": { - "name": "filesystem", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "relative/folder" - ], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ], - "setup": "allowed-directories" - }, - "issues": [ - { - "field": "arguments", - "code": "allowedDirectoriesRequired" - } - ] - }, - { - "case": "filesystem template accepts POSIX and Windows folders", - "draft": { - "name": "filesystem", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "/fixture/work", - "C:\\fixture\\work" - ], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ], - "setup": "allowed-directories" - }, - "issues": [] - } - ], - "batchPlan": [ - { - "case": "explicit rename of an unowned entry uses the captured source version", - "files": [ - "codexGlobal" - ], - "ledger": {}, - "intent": { - "kind": "save", - "draft": { - "name": "docs2", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [ - "-y", - "docs-mcp" - ], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "original": { - "name": "docs", - "sources": [ - { - "file": "codex|global|", - "sourceHash": "{{sourceHash:codex|global|:docs}}", - "ownedByLithe": false - } - ] - } - }, - "expect": { - "status": "ready", - "changes": [ - { - "file": "codex|global|", - "changed": true, - "contentContains": [ - "[mcp_servers.docs2]", - "# user comment" - ], - "contentExcludes": [ - "[mcp_servers.docs]" - ], - "ownedNames": [ - "docs2" - ], - "renames": [ - { - "from": "docs", - "to": "docs2" - } - ] - } - ] - } - }, - { - "case": "disabling an Agent whose entry Lithe never wrote is refused", - "files": [ - "codexGlobal", - "claudeGlobal" - ], - "ledger": {}, - "intent": { - "kind": "save", - "draft": { - "name": "docs", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [ - "-y", - "docs-mcp" - ], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "original": { - "name": "docs", - "sources": [ - { - "file": "codex|global|", - "sourceHash": "{{sourceHash:codex|global|:docs}}" - }, - { - "file": "claude|global|", - "sourceHash": "{{sourceHash:claude|global|:docs}}", - "ownedByLithe": false - } - ] - } - }, - "expect": { - "status": "rejected", - "rejection": { - "code": "notOwned", - "name": "docs", - "file": null - } - } - }, - { - "case": "disabling an Agent owned when captured but changed since is externally modified", - "files": [ - "codexGlobal", - "claudeGlobal" - ], - "ledger": {}, - "intent": { - "kind": "save", - "draft": { - "name": "docs", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [ - "-y", - "docs-mcp" - ], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "original": { - "name": "docs", - "sources": [ - { - "file": "codex|global|", - "sourceHash": "{{sourceHash:codex|global|:docs}}" - }, - { - "file": "claude|global|", - "sourceHash": "{{sourceHash:claude|global|:docs}}", - "ownedByLithe": true - } - ] - } - }, - "expect": { - "status": "rejected", - "rejection": { - "code": "externallyModified", - "name": "docs", - "file": null - } - } - }, - { - "case": "disabling an owned Agent removes its entry", - "files": [ - "codexGlobal", - "claudeGlobal" - ], - "ledger": { - "claude|global|": [ - { - "name": "docs", - "definitionHash": "{{definitionHash:claude|global|:docs}}", - "sourceHash": "{{sourceHash:claude|global|:docs}}" - } - ] - }, - "intent": { - "kind": "save", - "draft": { - "name": "docs", - "transport": "stdio", - "command": "npx", - "url": "", - "arguments": [ - "-y", - "docs-mcp" - ], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "original": { - "name": "docs", - "sources": [ - { - "file": "codex|global|", - "sourceHash": "{{sourceHash:codex|global|:docs}}" - }, - { - "file": "claude|global|", - "sourceHash": "{{sourceHash:claude|global|:docs}}", - "ownedByLithe": true - } - ] - } - }, - "expect": { - "status": "ready", - "changes": [ - { - "file": "codex|global|", - "changed": false, - "ownedNames": [] - }, - { - "file": "claude|global|", - "changed": true, - "contentExcludes": [ - "\"docs\"" - ], - "contentContains": [ - "\"server2\"" - ], - "ownedNames": [], - "renames": [] - } - ] - } - }, - { - "case": "a stale captured version conflicts on the target file", - "files": [ - "codexGlobal" - ], - "ledger": {}, - "intent": { - "kind": "save", - "draft": { - "name": "docs", - "transport": "stdio", - "command": "uvx", - "url": "", - "arguments": [], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ] - }, - "original": { - "name": "docs", - "sources": [ - { - "file": "codex|global|", - "sourceHash": "0000" - } - ] - } - }, - "expect": { - "status": "rejected", - "rejection": { - "code": "externallyModified", - "name": "docs", - "file": "codex|global|" - } - } - }, - { - "case": "automatic replacement of a user entry is not owned", - "files": [ - "codexGlobal" - ], - "ledger": {}, - "intent": { - "kind": "setAgent", - "agent": "codex", - "enabled": true, - "definition": { - "name": "docs", - "transport": "stdio", - "command": "uvx", - "args": [], - "env": [], - "url": null, - "headers": [] - } - }, - "expect": { - "status": "rejected", - "rejection": { - "code": "notOwned", - "name": "docs", - "file": "codex|global|" - } - } - }, - { - "case": "enabling an Agent without a global file is refused", - "files": [ - "codexGlobal" - ], - "ledger": {}, - "intent": { - "kind": "setAgent", - "agent": "claude", - "enabled": true, - "definition": { - "name": "fresh", - "transport": "stdio", - "command": "fresh-mcp", - "args": [], - "env": [], - "url": null, - "headers": [] - } - }, - "expect": { - "status": "rejected", - "rejection": { - "code": "agentHasNoGlobalConfiguration", - "name": "fresh", - "file": null - } - } - }, - { - "case": "enabling an Agent inserts and records ownership", - "files": [ - "codexMissing" - ], - "ledger": {}, - "intent": { - "kind": "setAgent", - "agent": "codex", - "enabled": true, - "definition": { - "name": "fresh", - "transport": "stdio", - "command": "fresh-mcp", - "args": [], - "env": [], - "url": null, - "headers": [] - } - }, - "expect": { - "status": "ready", - "changes": [ - { - "file": "codex|global|", - "changed": true, - "contentContains": [ - "[mcp_servers.fresh]" - ], - "ownedNames": [ - "fresh" - ], - "renames": [] - } - ] - } - }, - { - "case": "deleting a row without global sources is not owned", - "files": [ - "codexGlobal" - ], - "ledger": {}, - "intent": { - "kind": "delete", - "name": "lithe", - "files": [] - }, - "expect": { - "status": "rejected", - "rejection": { - "code": "notOwned", - "name": "lithe", - "file": null - } - } - }, - { - "case": "an invalid save draft returns field issues", - "files": [ - "codexGlobal" - ], - "ledger": {}, - "intent": { - "kind": "save", - "draft": { - "name": "docs", - "transport": "stdio", - "command": "", - "url": "", - "arguments": [], - "env": [], - "headers": [], - "enabledAgents": [ - "codex" - ] - } - }, - "expect": { - "status": "invalid", - "issues": [ - { - "field": "command", - "code": "commandRequired" - } - ] - } - } - ] -} diff --git a/rust/fixtures/mcp/tools-v1.json b/rust/fixtures/mcp/tools-v1.json deleted file mode 100644 index 5384e9f4..00000000 --- a/rust/fixtures/mcp/tools-v1.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "probeRequest": { - "transport": "http", - "url": "https://example.invalid/mcp", - "command": null, - "args": [], - "env": [], - "headers": [] - }, - "probeResult": { - "tools": [ - { - "name": "read", - "description": "Synthetic read-only tool metadata" - } - ] - }, - "policyRequest": { - "source": "claude", - "content": "{}", - "server": "docs", - "tools": [ - "read" - ], - "owned": [], - "tool": "read", - "enabled": false - }, - "policyResult": { - "content": "{\n \"permissions\": {\n \"deny\": [\"mcp__docs__read\"]\n}\n}", - "changed": true, - "owned": [ - "read" - ], - "tools": [ - { - "name": "read", - "enabled": false, - "editable": true - } - ], - "hasRestrictions": true - }, - "commandLine": { - "command": "/fixture/my tools/server", - "args": [ - "", - "folder with spaces", - "a'b" - ] - } -} diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index d490e08a..7a4180a8 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -13,7 +13,6 @@ mod git; mod github; mod keymap; mod lsp; -mod mcp; mod network; pub mod plugins; pub mod process; diff --git a/rust/lithe-core/src/mcp/featured.json b/rust/lithe-core/src/mcp/featured.json deleted file mode 100644 index c697d34d..00000000 --- a/rust/lithe-core/src/mcp/featured.json +++ /dev/null @@ -1,183 +0,0 @@ -{ - "reviewed": "2026-10-22", - "servers": [ - { - "server": { - "name": "lithe.featured/context7", - "title": "Context7", - "publisher": "Upstash", - "description": "Current library documentation and code examples for coding agents.", - "websiteUrl": "https://github.com/upstash/context7", - "remotes": [ - { - "type": "streamable-http", - "url": "https://mcp.context7.com/mcp" - } - ] - } - }, - { - "server": { - "name": "lithe.featured/playwright", - "title": "Playwright", - "publisher": "Microsoft", - "description": "Open pages, click, type and inspect a browser through its accessibility tree.", - "version": "0.0.83", - "websiteUrl": "https://github.com/microsoft/playwright-mcp", - "packages": [ - { - "registryType": "npm", - "identifier": "@playwright/mcp", - "version": "0.0.83", - "transport": { - "type": "stdio" - } - } - ] - } - }, - { - "server": { - "name": "lithe.featured/chrome-devtools", - "title": "Chrome DevTools", - "publisher": "Google", - "description": "Inspect Chrome pages, console messages, network requests and performance.", - "version": "1.11.0", - "websiteUrl": "https://github.com/ChromeDevTools/chrome-devtools-mcp", - "packages": [ - { - "registryType": "npm", - "identifier": "chrome-devtools-mcp", - "version": "1.11.0", - "transport": { - "type": "stdio" - } - } - ] - } - }, - { - "server": { - "name": "lithe.featured/microsoft-learn", - "title": "Microsoft Learn", - "publisher": "Microsoft", - "description": "Search official Microsoft and Azure documentation and code samples.", - "websiteUrl": "https://github.com/MicrosoftDocs/mcp", - "remotes": [ - { - "type": "streamable-http", - "url": "https://learn.microsoft.com/api/mcp" - } - ] - } - }, - { - "server": { - "name": "lithe.featured/github", - "title": "GitHub", - "publisher": "GitHub", - "description": "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required.", - "websiteUrl": "https://github.com/github/github-mcp-server", - "remotes": [ - { - "type": "streamable-http", - "url": "https://api.githubcopilot.com/mcp/" - } - ] - }, - "setup": "bearer-token" - }, - { - "server": { - "name": "lithe.featured/deepwiki", - "title": "DeepWiki", - "publisher": "Cognition", - "description": "Ask questions about public GitHub repositories and read their generated documentation.", - "websiteUrl": "https://docs.devin.ai/work-with-devin/deepwiki-mcp", - "remotes": [ - { - "type": "streamable-http", - "url": "https://mcp.deepwiki.com/mcp" - } - ] - } - }, - { - "server": { - "name": "lithe.featured/exa", - "title": "Exa", - "publisher": "Exa", - "description": "Search the web and code, and retrieve page content for coding agents.", - "websiteUrl": "https://exa.ai/docs/get-started/exa-mcp", - "remotes": [ - { - "type": "streamable-http", - "url": "https://mcp.exa.ai/mcp" - } - ] - } - }, - { - "server": { - "name": "lithe.featured/filesystem", - "title": "Filesystem", - "publisher": "Model Context Protocol", - "description": "Read, write and search files only in the directories you allow. Requires Node.js 24 or later.", - "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", - "version": "2.0.0", - "packages": [ - { - "registryType": "npm", - "identifier": "@modelcontextprotocol/server-filesystem", - "version": "2.0.0", - "transport": { - "type": "stdio" - } - } - ] - }, - "setup": "allowed-directories" - }, - { - "server": { - "name": "lithe.featured/fetch", - "title": "Fetch", - "publisher": "Model Context Protocol", - "description": "Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later.", - "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", - "version": "2026.10.19", - "packages": [ - { - "registryType": "pypi", - "identifier": "mcp-server-fetch", - "version": "2026.10.19", - "transport": { - "type": "stdio" - } - } - ] - } - }, - { - "server": { - "name": "lithe.featured/memory", - "title": "Memory", - "publisher": "Model Context Protocol", - "description": "Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later.", - "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", - "version": "2.0.0", - "packages": [ - { - "registryType": "npm", - "identifier": "@modelcontextprotocol/server-memory", - "version": "2.0.0", - "transport": { - "type": "stdio" - } - } - ] - } - } - ], - "metadata": {} -} diff --git a/rust/lithe-core/src/mcp/json_edit.rs b/rust/lithe-core/src/mcp/json_edit.rs deleted file mode 100644 index 26fee9d0..00000000 --- a/rust/lithe-core/src/mcp/json_edit.rs +++ /dev/null @@ -1,324 +0,0 @@ -//! Offset-based JSON editing that preserves every byte outside the targeted -//! `mcpServers` entry. -//! -//! Configuration files may contain unrelated settings, large state objects and -//! formatting the user maintains by hand. Re-serializing the whole document -//! would reorder keys and drop formatting, so this editor locates the exact -//! value span and splices only that range. Inputs with comments or other -//! non-strict JSON are rejected rather than rewritten. - -use crate::protocol::{CoreError, ErrorCode}; - -type Result = std::result::Result; - -fn edit_error() -> CoreError { - CoreError::new( - ErrorCode::InvalidRequest, - "MCP configuration is not editable JSON", - ) -} - -/// JSON parsers keep the last of repeated keys, while an offset editor sees the -/// first. Refusing the edit is safer than guessing which definition the Agent -/// actually uses. -fn duplicate_key_error() -> CoreError { - CoreError::new( - ErrorCode::InvalidRequest, - "MCP configuration contains duplicate keys", - ) -} - -struct Member { - key: String, - key_start: usize, - value_start: usize, - value_end: usize, -} - -/// A validated JSON object document addressed by byte offsets. -pub(crate) struct JsonDocument { - text: String, -} - -impl JsonDocument { - /// Parses strict JSON and requires an object at the root. - pub(crate) fn parse(content: &str) -> Result { - serde_json::from_str::(content).map_err(|_| edit_error())?; - let bytes = content.as_bytes(); - let open = skip_ws(bytes, 0); - if bytes.get(open) != Some(&b'{') { - return Err(edit_error()); - } - let close = value_end(bytes, open)?; - if skip_ws(bytes, close) != bytes.len() { - return Err(edit_error()); - } - Ok(Self { - text: content.to_string(), - }) - } - - /// Returns the finished document text after re-validating it. - /// - /// Every splice is local, but a wrong comma or offset would still produce - /// a file Claude Code can no longer parse. Refusing the plan is safer than - /// writing broken configuration. - pub(crate) fn into_string(self) -> Result { - serde_json::from_str::(&self.text).map_err(|_| edit_error())?; - Ok(self.text) - } - - /// Ensures every object in `path` exists, creating empty objects as needed, - /// and returns the byte offset of the deepest object's opening brace. - pub(crate) fn ensure_object(&mut self, path: &[&str]) -> Result { - let mut open = skip_ws(self.text.as_bytes(), 0); - for key in path { - let member = match self.find_member(open, key)? { - Some(member) => member, - None => { - self.insert_member(open, key, "{}")?; - self.find_member(open, key)?.ok_or_else(edit_error)? - } - }; - if self.text.as_bytes().get(member.value_start) != Some(&b'{') { - return Err(edit_error()); - } - open = member.value_start; - } - Ok(open) - } - - /// Returns the deepest existing object in `path` without creating anything. - pub(crate) fn object_at(&self, path: &[&str]) -> Result> { - let mut open = skip_ws(self.text.as_bytes(), 0); - for key in path { - let Some(member) = self.find_member(open, key)? else { - return Ok(None); - }; - if self.text.as_bytes().get(member.value_start) != Some(&b'{') { - return Ok(None); - } - open = member.value_start; - } - Ok(Some(open)) - } - - /// Returns the raw text of `key`'s value in the object at `open`. - pub(crate) fn member_value(&self, open: usize, key: &str) -> Result> { - Ok(self - .find_member(open, key)? - .map(|member| self.text[member.value_start..member.value_end].to_string())) - } - - /// Inserts or replaces the member `key` in the object at `open`. - pub(crate) fn set_member(&mut self, open: usize, key: &str, value_json: &str) -> Result<()> { - match self.find_member(open, key)? { - Some(member) => { - self.text - .replace_range(member.value_start..member.value_end, value_json); - Ok(()) - } - None => self.insert_member(open, key, value_json), - } - } - - /// Removes `key` from the object at `open`, returning whether it existed. - pub(crate) fn remove_member(&mut self, open: usize, key: &str) -> Result { - let members = self.members(open)?; - let Some(index) = members.iter().position(|member| member.key == key) else { - return Ok(false); - }; - let member = &members[index]; - if let Some(next) = members.get(index + 1) { - // Delete exactly the member and the separator up to the next key. - // Extending to the line start would remove a neighbouring entry - // that shares the line, and a previous entry's comma must stay to - // separate it from the next member. - self.text - .replace_range(member.key_start..next.key_start, ""); - } else if let Some(previous) = index.checked_sub(1).map(|index| &members[index]) { - // Removing the last member must also remove the comma that - // separates it from the previous member. Deleting only the last - // member's line would leave `previous,` and therefore invalid JSON. - self.text - .replace_range(previous.value_end..member.value_end, ""); - } else { - self.text - .replace_range(member.key_start..member.value_end, ""); - } - Ok(true) - } - - fn insert_member(&mut self, open: usize, key: &str, value_json: &str) -> Result<()> { - let key_json = serde_json::to_string(key).map_err(|_| edit_error())?; - let members = self.members(open)?; - if let Some(last) = members.last() { - let indent = self.member_indent(open, &members); - let block = format!(",\n{indent}{key_json}: {value_json}"); - self.text.insert_str(last.value_end, &block); - } else { - let close = object_close(self.text.as_bytes(), open)?; - let parent_indent = line_indent(&self.text, open).unwrap_or(""); - let indent = format!("{parent_indent} "); - let block = format!("\n{indent}{key_json}: {value_json}\n{parent_indent}"); - self.text.insert_str(close, &block); - } - Ok(()) - } - - fn member_indent(&self, open: usize, members: &[Member]) -> String { - let first = &members[0]; - if let Some(indent) = line_indent(&self.text, first.key_start) { - return indent.to_string(); - } - let parent = line_indent(&self.text, open).unwrap_or(""); - format!("{parent} ") - } - - fn find_member(&self, open: usize, key: &str) -> Result> { - Ok(self - .members(open)? - .into_iter() - .find(|member| member.key == key)) - } - - fn members(&self, open: usize) -> Result> { - let bytes = self.text.as_bytes(); - if bytes.get(open) != Some(&b'{') { - return Err(edit_error()); - } - let mut members = Vec::new(); - let mut index = skip_ws(bytes, open + 1); - loop { - match bytes.get(index) { - Some(b'}') => return Ok(members), - Some(b'"') => { - let key_start = index; - let key_end = string_end(bytes, index)?; - let key: String = serde_json::from_str(&self.text[key_start..key_end]) - .map_err(|_| edit_error())?; - if members.iter().any(|member| member.key == key) { - return Err(duplicate_key_error()); - } - index = skip_ws(bytes, key_end); - if bytes.get(index) != Some(&b':') { - return Err(edit_error()); - } - let value_start = skip_ws(bytes, index + 1); - let value_end = value_end(bytes, value_start)?; - members.push(Member { - key, - key_start, - value_start, - value_end, - }); - index = skip_ws(bytes, value_end); - match bytes.get(index) { - Some(b',') => index = skip_ws(bytes, index + 1), - Some(b'}') => return Ok(members), - _ => return Err(edit_error()), - } - } - _ => return Err(edit_error()), - } - } - } -} - -fn skip_ws(bytes: &[u8], mut index: usize) -> usize { - while matches!(bytes.get(index), Some(b' ' | b'\t' | b'\n' | b'\r')) { - index += 1; - } - index -} - -fn string_end(bytes: &[u8], start: usize) -> Result { - if bytes.get(start) != Some(&b'"') { - return Err(edit_error()); - } - let mut index = start + 1; - while let Some(byte) = bytes.get(index) { - match byte { - b'\\' => index += 2, - b'"' => return Ok(index + 1), - _ => index += 1, - } - } - Err(edit_error()) -} - -fn value_end(bytes: &[u8], start: usize) -> Result { - match bytes.get(start) { - Some(b'"') => string_end(bytes, start), - Some(b'{') => object_close(bytes, start).map(|close| close + 1), - Some(b'[') => array_close(bytes, start).map(|close| close + 1), - Some(b't') if bytes[start..].starts_with(b"true") => Ok(start + 4), - Some(b'f') if bytes[start..].starts_with(b"false") => Ok(start + 5), - Some(b'n') if bytes[start..].starts_with(b"null") => Ok(start + 4), - Some(b'-' | b'0'..=b'9') => { - let mut index = start + 1; - while matches!( - bytes.get(index), - Some(b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-') - ) { - index += 1; - } - Ok(index) - } - _ => Err(edit_error()), - } -} - -fn object_close(bytes: &[u8], open: usize) -> Result { - let mut index = skip_ws(bytes, open + 1); - loop { - match bytes.get(index) { - Some(b'}') => return Ok(index), - Some(b'"') => { - index = skip_ws(bytes, string_end(bytes, index)?); - if bytes.get(index) != Some(&b':') { - return Err(edit_error()); - } - index = skip_ws(bytes, index + 1); - index = skip_ws(bytes, value_end(bytes, index)?); - match bytes.get(index) { - Some(b',') => index = skip_ws(bytes, index + 1), - Some(b'}') => return Ok(index), - _ => return Err(edit_error()), - } - } - _ => return Err(edit_error()), - } - } -} - -fn array_close(bytes: &[u8], open: usize) -> Result { - let mut index = skip_ws(bytes, open + 1); - loop { - match bytes.get(index) { - Some(b']') => return Ok(index), - Some(_) => { - index = skip_ws(bytes, value_end(bytes, index)?); - match bytes.get(index) { - Some(b',') => index = skip_ws(bytes, index + 1), - Some(b']') => return Ok(index), - _ => return Err(edit_error()), - } - } - None => return Err(edit_error()), - } - } -} - -fn line_start(text: &str, offset: usize) -> usize { - text[..offset].rfind('\n').map_or(0, |index| index + 1) -} - -fn line_indent(text: &str, offset: usize) -> Option<&str> { - let start = line_start(text, offset); - let indent = &text[start..offset]; - indent - .chars() - .all(|character| character == ' ' || character == '\t') - .then_some(indent) -} diff --git a/rust/lithe-core/src/mcp/library.rs b/rust/lithe-core/src/mcp/library.rs deleted file mode 100644 index 662de776..00000000 --- a/rust/lithe-core/src/mcp/library.rs +++ /dev/null @@ -1,908 +0,0 @@ -//! MCP server library rules shared by every host: merging discovered sources, -//! validating editor drafts and planning multi-file edits against the ledger. -//! -//! Hosts read configuration files and the ownership ledger, then pass the exact -//! bytes and records here. Core returns the merged rows, field-level draft -//! issues, or the complete next state of every touched file. Hosts write the -//! returned content atomically and persist the returned ledger entries only -//! after the whole batch succeeds. Nothing here touches the filesystem, and -//! secret env/header values are never copied into errors. - -use super::parse::{ - find_server, parse_claude_servers, parse_codex_servers, MAX_CONFIGURATION_BYTES, -}; -use super::plan::{plan_write_request, OwnedEntry, ToolFilters, WriteRequest}; -use super::{McpKeyValue, McpServer, McpTransport}; -use crate::protocol::{CoreError, ErrorCode}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet}; - -/// Maximum configuration files one library request may carry. Hosts manage -/// one global and at most one project file per Agent. -const MAX_LIBRARY_FILES: usize = 16; - -/// One Agent configuration file plus its slice of the host ledger. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct LibraryFile { - /// Opaque host identity. Also the ledger key the host stores entries under. - id: String, - /// Configuration owner: `codex` or `claude`. Equals the Agent identity used - /// in drafts' `enabledAgents`. - source: String, - /// `global` or `project`. - scope: String, - /// Absolute project path for Claude project entries. - #[serde(default)] - project_path: Option, - /// Native path; the key under which retained tool filters are recorded. - path: String, - /// Current text; `null` means the file does not exist yet. - #[serde(default)] - content: Option, - /// Ledger entries the host recorded for this file. - #[serde(default)] - owned: Vec, -} - -impl LibraryFile { - fn is_global(&self) -> bool { - self.scope == "global" - } - - /// Parses the file with the same scope rules as `mcp.parseConfiguration`. - fn servers(&self, content: &str) -> Result, CoreError> { - if content.len() > MAX_CONFIGURATION_BYTES { - return Err(invalid_request()); - } - match (self.source.as_str(), self.scope.as_str()) { - ("codex", "global" | "project") => parse_codex_servers(content), - ("claude", "global") => parse_claude_servers(content, None), - ("claude", "project") => parse_claude_servers( - content, - Some(self.project_path.as_deref().ok_or_else(invalid_request)?), - ), - _ => Err(invalid_request()), - } - } - - fn ledger_owns(&self, name: &str, source_hash: &str) -> bool { - self.owned - .iter() - .any(|entry| entry.name == name && entry.source_hash.as_deref() == Some(source_hash)) - } -} - -/// Host ledger record: live ownership of one entry, or retained native tool -/// filters without a `sourceHash` after Lithe removed the entry. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct LedgerEntry { - name: String, - definition_hash: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - source_hash: Option, - /// Retained filters keyed by native path, so profile roots stay distinct. - #[serde(default, skip_serializing_if = "Option::is_none")] - tool_filters: Option>, -} - -impl LedgerEntry { - fn owned_entry(&self) -> OwnedEntry { - OwnedEntry { - name: self.name.clone(), - definition_hash: self.definition_hash.clone(), - source_hash: self.source_hash.clone(), - } - } -} - -fn invalid_request() -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP library request") -} - -fn decode Deserialize<'de>>(payload: Value) -> Result { - serde_json::from_value(payload).map_err(|_| invalid_request()) -} - -fn bounded(files: &[LibraryFile]) -> Result<(), CoreError> { - let mut ids = BTreeSet::new(); - if files.len() > MAX_LIBRARY_FILES || !files.iter().all(|file| ids.insert(file.id.as_str())) { - return Err(invalid_request()); - } - Ok(()) -} - -// MARK: - Snapshot - -#[derive(Deserialize)] -struct SnapshotRequest { - files: Vec, -} - -/// One discovered definition in one file. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SnapshotSource { - file: String, - definition_hash: String, - source_hash: Option, - /// The ledger records this exact complete entry as written by Lithe. - owned_by_lithe: bool, -} - -/// A definition merged across files by name and definition hash. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SnapshotRow { - id: String, - /// Definition parsed from the first source in host file order. - definition: McpServer, - sources: Vec, - /// Another global entry with the same name has a different definition. - has_definition_conflict: bool, - /// No global source exists; the row is owned by a project grant. - project_only: bool, -} - -/// Merges the servers of every supplied file into library rows. -/// -/// Response: `{ rows, failures: [{ file, code, message }] }`. Rows share an `id` -/// of `name#definitionHash`, so the same server registered with two Agents is -/// one row while a same-name variant stays separate and is flagged as a global -/// definition conflict. Rows are ordered by natural, case-insensitive name, -/// then global before project-only, then definition hash. A file that fails to -/// parse is reported without echoing its content and contributes no rows. -pub fn library_snapshot(payload: Value) -> Result { - let request: SnapshotRequest = decode(payload)?; - bounded(&request.files)?; - let mut rows: Vec = Vec::new(); - let mut global_hashes: BTreeMap> = BTreeMap::new(); - let mut failures = Vec::new(); - for file in &request.files { - let servers = match file.servers(file.content.as_deref().unwrap_or("")) { - Ok(servers) => servers, - Err(error) => { - failures.push(json!({ - "file": file.id, "code": error.code, "message": error.message, - })); - continue; - } - }; - for server in servers { - let id = format!("{}#{}", server.name, server.definition_hash); - let source = SnapshotSource { - file: file.id.clone(), - definition_hash: server.definition_hash.clone(), - source_hash: server.source_hash.clone(), - owned_by_lithe: server - .source_hash - .as_deref() - .is_some_and(|hash| file.ledger_owns(&server.name, hash)), - }; - if file.is_global() { - global_hashes - .entry(server.name.clone()) - .or_default() - .insert(server.definition_hash.clone()); - } - match rows.iter_mut().find(|row| row.id == id) { - Some(row) => { - row.project_only &= !file.is_global(); - row.sources.push(source); - } - None => rows.push(SnapshotRow { - id, - definition: server, - sources: vec![source], - has_definition_conflict: false, - project_only: !file.is_global(), - }), - } - } - } - for row in &mut rows { - row.has_definition_conflict = global_hashes - .get(&row.definition.name) - .is_some_and(|hashes| hashes.len() > 1); - } - rows.sort_by(|left, right| { - natural_compare(&left.definition.name, &right.definition.name) - .then(left.project_only.cmp(&right.project_only)) - .then_with(|| { - left.definition - .definition_hash - .cmp(&right.definition.definition_hash) - }) - }); - Ok(json!({ "rows": rows, "failures": failures })) -} - -/// Finder-like ordering: case-insensitive, digit runs compared numerically, -/// with a byte comparison as the final tie-break so the order is total. -fn natural_compare(left: &str, right: &str) -> Ordering { - let mut a = left.chars().peekable(); - let mut b = right.chars().peekable(); - loop { - match (a.peek().copied(), b.peek().copied()) { - (None, None) => return left.cmp(right), - (None, Some(_)) => return Ordering::Less, - (Some(_), None) => return Ordering::Greater, - (Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => { - let take = |chars: &mut std::iter::Peekable| { - let mut digits = String::new(); - while let Some(c) = chars.peek().copied().filter(char::is_ascii_digit) { - digits.push(c); - chars.next(); - } - digits - }; - let (x, y) = (take(&mut a), take(&mut b)); - let (x, y) = (x.trim_start_matches('0'), y.trim_start_matches('0')); - let order = x.len().cmp(&y.len()).then_with(|| x.cmp(y)); - if order != Ordering::Equal { - return order; - } - } - (Some(x), Some(y)) => { - let order = x.to_lowercase().cmp(y.to_lowercase()); - if order != Ordering::Equal { - return order; - } - a.next(); - b.next(); - } - } - } -} - -// MARK: - Draft validation - -/// Editor state for one server. Only the active transport's fields are written. -#[derive(Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct Draft { - #[serde(default)] - name: String, - transport: McpTransport, - #[serde(default)] - command: String, - #[serde(default)] - url: String, - /// Exact argv; the host owns the line-editor projection. - #[serde(default)] - arguments: Vec, - #[serde(default)] - env: Vec, - #[serde(default)] - headers: Vec, - /// Agent identities (`codex`, `claude`) the server should be enabled in. - #[serde(default)] - enabled_agents: Vec, - /// Featured template requirement still under review. - #[serde(default)] - setup: Option, -} - -#[derive(Clone, Copy, Deserialize)] -#[serde(rename_all = "kebab-case")] -enum DraftSetup { - BearerToken, - AllowedDirectories, -} - -/// One field-level reason a draft cannot be saved. -#[derive(Clone, Debug, Serialize, PartialEq, Eq)] -struct DraftIssue { - /// Draft field: `name`, `enabledAgents`, `env`, `headers`, `command`, - /// `url` or `arguments`. - field: &'static str, - /// Stable code the host localizes. - code: &'static str, -} - -const fn issue(field: &'static str, code: &'static str) -> DraftIssue { - DraftIssue { field, code } -} - -impl Draft { - /// Issues in display priority; hosts show the first one. - fn issues(&self) -> Vec { - let mut issues = Vec::new(); - let name = self.name.trim(); - if name.is_empty() { - issues.push(issue("name", "nameRequired")); - } else if !valid_name(name) { - issues.push(issue("name", "nameInvalid")); - } - if self.enabled_agents.is_empty() { - issues.push(issue("enabledAgents", "agentsRequired")); - } - // Entries become an object or table, which cannot repeat a key. Only - // the active transport's entries are written, so only they can collide. - let (field, entries) = self.active_entries(); - if has_duplicate_keys(entries) { - issues.push(issue(field, "duplicateKeys")); - } - match self.transport { - McpTransport::Stdio if self.command.trim().is_empty() => { - issues.push(issue("command", "commandRequired")); - } - McpTransport::Http | McpTransport::Sse if !complete_url(self.url.trim()) => { - issues.push(issue("url", "urlInvalid")); - } - _ => {} - } - match self.setup { - Some(DraftSetup::BearerToken) if !self.has_bearer_header() => { - issues.push(issue("headers", "bearerTokenRequired")); - } - Some(DraftSetup::AllowedDirectories) if !self.has_allowed_directories() => { - issues.push(issue("arguments", "allowedDirectoriesRequired")); - } - _ => {} - } - issues - } - - fn active_entries(&self) -> (&'static str, &[McpKeyValue]) { - match self.transport { - McpTransport::Stdio => ("env", &self.env), - McpTransport::Http | McpTransport::Sse => ("headers", &self.headers), - } - } - - fn has_bearer_header(&self) -> bool { - if self.transport == McpTransport::Stdio { - return false; - } - let value = self - .headers - .iter() - .find(|entry| entry.key.eq_ignore_ascii_case("authorization")) - .map_or("", |entry| entry.value.as_str()); - let parts: Vec<&str> = value.split_whitespace().collect(); - parts.len() == 2 && parts[0].eq_ignore_ascii_case("bearer") - } - - /// Filesystem's fixed arguments are the runner flag and package; every - /// later argument must be an absolute folder. - fn has_allowed_directories(&self) -> bool { - let folders = self.arguments.get(2..).unwrap_or_default(); - self.transport == McpTransport::Stdio - && !folders.is_empty() - && folders.iter().all(|folder| absolute_path(folder)) - } - - /// Normalized definition written to every enabled Agent. Fields unused by - /// the transport are dropped so the written entry hashes like the draft. - fn definition(&self) -> McpServer { - let keep = |entries: &[McpKeyValue]| { - entries - .iter() - .filter(|entry| !entry.key.trim().is_empty()) - .cloned() - .collect::>() - }; - let name = self.name.trim().to_string(); - match self.transport { - McpTransport::Stdio => McpServer::new( - name, - self.transport, - Some(self.command.trim().to_string()), - self.arguments.clone(), - keep(&self.env), - None, - Vec::new(), - ), - McpTransport::Http | McpTransport::Sse => McpServer::new( - name, - self.transport, - None, - Vec::new(), - Vec::new(), - Some(self.url.trim().to_string()), - keep(&self.headers), - ), - } - } -} - -fn valid_name(name: &str) -> bool { - let mut chars = name.chars(); - chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) - && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) -} - -/// Empty keys are dropped when saving, so only written names can collide. -fn has_duplicate_keys(entries: &[McpKeyValue]) -> bool { - let mut keys = BTreeSet::new(); - entries - .iter() - .filter(|entry| !entry.key.trim().is_empty()) - .any(|entry| !keys.insert(entry.key.as_str())) -} - -fn complete_url(url: &str) -> bool { - url::Url::parse(url).is_ok_and(|parsed| parsed.host_str().is_some_and(|host| !host.is_empty())) -} - -/// POSIX absolute paths plus Windows drive and UNC paths, independent of the -/// platform Core runs on, so the same draft validates identically everywhere. -fn absolute_path(path: &str) -> bool { - let bytes = path.as_bytes(); - path.starts_with('/') - || path.starts_with("\\\\") - || (bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'\\' | b'/')) -} - -#[derive(Deserialize)] -struct ValidateRequest { - draft: Draft, -} - -/// Validates one editor draft. -/// -/// Response: `{ issues: [{ field, code }], definition }`. `issues` is ordered by -/// display priority; `definition` is the normalized server a save would write, -/// or `null` while issues remain. Values are never echoed in issues. -pub fn validate_draft(payload: Value) -> Result { - let request: ValidateRequest = decode(payload)?; - let issues = request.draft.issues(); - let definition = issues.is_empty().then(|| request.draft.definition()); - Ok(json!({ "issues": issues, "definition": definition })) -} - -// MARK: - Batch planning - -/// Source captured when the editor opened; never refreshed afterwards. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct OriginalSource { - file: String, - #[serde(default)] - source_hash: Option, - #[serde(default)] - owned_by_lithe: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Original { - name: String, - /// Global sources of the edited row. - #[serde(default)] - sources: Vec, -} - -#[derive(Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -enum Intent { - /// Create or update a server in every enabled Agent's global file. - Save { - draft: Draft, - #[serde(default)] - original: Option, - }, - /// Add or remove one definition in one Agent's global file. - #[serde(rename_all = "camelCase")] - SetAgent { - definition: McpServer, - agent: String, - enabled: bool, - }, - /// Remove a server from the listed global files. - Delete { name: String, files: Vec }, -} - -#[derive(Deserialize)] -struct BatchRequest { - files: Vec, - intent: Intent, -} - -enum Operation { - /// `expected` is the complete source version shown when editing. - Set { - server: McpServer, - expected: Option, - previous: Option, - }, - /// `expected` authorizes removing the displayed original during a rename. - Remove { - name: String, - expected: Option, - }, -} - -/// Why the batch was refused before any file was touched. -struct Rejection { - /// `notOwned`, `externallyModified` or `agentHasNoGlobalConfiguration`. - code: &'static str, - name: String, - /// File whose plan conflicted; absent for draft-level ownership checks. - file: Option, -} - -impl Rejection { - fn new(code: &'static str, name: &str, file: Option<&str>) -> Self { - Self { - code, - name: name.to_string(), - file: file.map(str::to_string), - } - } - - fn into_value(self) -> Value { - json!({ "status": "rejected", "rejection": { - "code": self.code, "name": self.name, "file": self.file, - }}) - } -} - -/// Plans a library edit across files as one all-or-nothing batch. -/// -/// Response is one of: -/// - `{ status: "invalid", issues }` when a save draft fails validation; -/// - `{ status: "rejected", rejection: { code, name, file } }` when ownership or -/// a captured source version forbids the edit. Conflicts from `mcp.planWrite` -/// use its codes (`ownedByUser` is reported as `notOwned`); -/// - `{ status: "ready", changes: [{ file, changed, content?, owned, renames }] }` -/// listing every file with operations in host order. `content` is present -/// only when `changed`; `owned` is the file's complete next ledger (the input -/// ledger when unchanged), to be stored only after every changed file was -/// written; `renames` lists -/// `{ from, to }` moves the host forwards to per-name state. -pub fn batch_plan(payload: Value) -> Result { - let request: BatchRequest = decode(payload)?; - bounded(&request.files)?; - let operations = match operations(&request.files, &request.intent) { - Ok(operations) => operations, - Err(Ok(issues)) => return Ok(json!({ "status": "invalid", "issues": issues })), - Err(Err(rejection)) => return Ok(rejection.into_value()), - }; - let mut changes = Vec::new(); - for file in &request.files { - let file_operations: Vec<&Operation> = operations - .iter() - .filter(|(id, _)| *id == file.id) - .map(|(_, operation)| operation) - .collect(); - if file_operations.is_empty() { - continue; - } - match plan_file(file, &file_operations)? { - Ok(change) => changes.push(change), - Err(rejection) => return Ok(rejection.into_value()), - } - } - Ok(json!({ "status": "ready", "changes": changes })) -} - -type Planned = Result, Result, Rejection>>; - -/// Expands an intent into ordered per-file operations. -fn operations(files: &[LibraryFile], intent: &Intent) -> Planned { - let global = |agent: &str| { - files - .iter() - .find(|file| file.is_global() && file.source == agent) - }; - match intent { - Intent::Save { draft, original } => { - let issues = draft.issues(); - if !issues.is_empty() { - return Err(Ok(issues)); - } - save_operations(files, draft, original.as_ref()).map_err(Err) - } - Intent::SetAgent { - definition, - agent, - enabled, - } => { - let Some(file) = global(agent) else { - return Err(Err(Rejection::new( - "agentHasNoGlobalConfiguration", - &definition.name, - None, - ))); - }; - let operation = if *enabled { - Operation::Set { - server: definition.clone(), - expected: None, - previous: None, - } - } else { - Operation::Remove { - name: definition.name.clone(), - expected: None, - } - }; - Ok(vec![(file.id.clone(), operation)]) - } - Intent::Delete { name, files: ids } => { - if ids.is_empty() { - return Err(Err(Rejection::new("notOwned", name, None))); - } - Ok(ids - .iter() - .map(|id| { - ( - id.clone(), - Operation::Remove { - name: name.clone(), - expected: None, - }, - ) - }) - .collect()) - } - } -} - -fn save_operations( - files: &[LibraryFile], - draft: &Draft, - original: Option<&Original>, -) -> Result, Rejection> { - let definition = draft.definition(); - let enabled = |agent: &str| draft.enabled_agents.iter().any(|value| value == agent); - let file = |id: &str| files.iter().find(|file| file.id == id); - let mut operations = Vec::new(); - let original_name = original.map(|original| original.name.as_str()); - let renaming = original_name.is_some_and(|name| name != definition.name); - if let Some(original) = original { - // Disabling an Agent still requires explicit ownership. The captured - // version stays mandatory: a newer ledger entry alone must not - // authorize removing an entry this draft never saw. - for source in &original.sources { - let Some(source_file) = file(&source.file) else { - return Err(Rejection::new("externallyModified", &original.name, None)); - }; - if enabled(&source_file.source) { - continue; - } - let Some(expected) = source.source_hash.as_deref() else { - return Err(Rejection::new("notOwned", &original.name, None)); - }; - if !source_file.ledger_owns(&original.name, expected) { - // A newer complete version, or a record this draft saw as owned - // that is now gone, means the entry changed after capture. A - // legacy record without a hash cannot be refreshed into - // ownership, so it keeps the take-ownership reason. - let changed = source.owned_by_lithe - || source_file - .owned - .iter() - .any(|entry| entry.name == original.name && entry.source_hash.is_some()); - let code = if changed { - "externallyModified" - } else { - "notOwned" - }; - return Err(Rejection::new(code, &original.name, None)); - } - operations.push(( - source.file.clone(), - Operation::Remove { - name: original.name.clone(), - expected: Some(expected.to_string()), - }, - )); - } - } - for target in files - .iter() - .filter(|file| file.is_global() && enabled(&file.source)) - { - let source = original.and_then(|original| { - original - .sources - .iter() - .find(|source| source.file == target.id) - }); - // A source version must remain the one captured when the editor opened. - if source.is_some_and(|source| source.source_hash.is_none()) { - return Err(Rejection::new( - "externallyModified", - original_name.unwrap_or(&definition.name), - None, - )); - } - let expected = source.and_then(|source| source.source_hash.clone()); - let previous = (renaming && source.is_some()) - .then(|| original_name.map(str::to_string)) - .flatten(); - operations.push(( - target.id.clone(), - Operation::Set { - server: definition.clone(), - expected: expected.clone(), - previous: previous.clone(), - }, - )); - // Removing the old name after the set lets Core copy the old entry, - // including unmodeled fields, before it disappears. The displayed hash - // authorizes removing an original Lithe did not create. - if let Some(previous) = previous { - operations.push(( - target.id.clone(), - Operation::Remove { - name: previous, - expected, - }, - )); - } - } - Ok(operations) -} - -/// Applies a file's operations in order, each planned against the previous -/// output, and derives the file's next ledger. -fn plan_file( - file: &LibraryFile, - operations: &[&Operation], -) -> Result, CoreError> { - let source = file.content.as_deref(); - let mut content = source.unwrap_or("").to_string(); - let mut entries = file.owned.clone(); - let mut completed_renames: BTreeMap = BTreeMap::new(); - let mut renames = Vec::new(); - let mut changed = false; - for operation in operations { - let request = |operation: &str, - server: Option, - name: Option, - previous: Option, - expected: Option, - content: &str, - entries: &[LedgerEntry]| { - let tool_filters = server.as_ref().and_then(|server| { - entries - .iter() - .find(|entry| entry.name == server.name) - .and_then(|entry| entry.tool_filters.as_ref()) - .and_then(|filters| filters.get(&file.path)) - .cloned() - }); - WriteRequest { - source: file.source.clone(), - scope: file.scope.clone(), - project_path: file.project_path.clone(), - content: Some(content.to_string()), - operation: operation.to_string(), - server, - name, - previous_name: previous, - expected_hash: expected, - owned: entries.iter().map(LedgerEntry::owned_entry).collect(), - tool_filters, - } - }; - match operation { - Operation::Set { - server, - expected, - previous, - } => { - let plan = plan_write_request(&request( - "set", - Some(server.clone()), - None, - previous.clone(), - expected.clone(), - &content, - &entries, - ))?; - if let Some(conflict) = plan["conflict"].as_str() { - return Ok(Err(conflict_rejection(conflict, &server.name, file))); - } - content = plan["content"].as_str().unwrap_or_default().to_string(); - changed |= plan["changed"].as_bool().unwrap_or(false); - if let (Some(previous), Some(expected)) = (previous, expected) { - // Codex moves the original while planning the set; the - // later removal of the old name is then already satisfied. - if find_server(&file.servers(&content)?, previous).is_none() { - completed_renames.insert(previous.clone(), expected.clone()); - } - } - if let Some(hash) = plan["definitionHash"].as_str() { - let filters = - take_entry(&mut entries, &server.name).and_then(|entry| entry.tool_filters); - entries.push(LedgerEntry { - name: server.name.clone(), - definition_hash: hash.to_string(), - source_hash: plan["sourceHash"].as_str().map(str::to_string), - tool_filters: filters, - }); - } - if let Some(previous) = previous { - renames.push(json!({ "from": previous, "to": server.name })); - } - } - Operation::Remove { name, expected } => { - if expected.is_some() && completed_renames.get(name) == expected.as_ref() { - take_entry(&mut entries, name); - continue; - } - let plan = plan_write_request(&request( - "remove", - None, - Some(name.clone()), - None, - expected.clone(), - &content, - &entries, - ))?; - if let Some(conflict) = plan["conflict"].as_str() { - return Ok(Err(conflict_rejection(conflict, name, file))); - } - content = plan["content"].as_str().unwrap_or_default().to_string(); - changed |= plan["changed"].as_bool().unwrap_or(false); - // Ordinary removal keeps only non-secret restrictions, without a - // source hash that could authorize a future edit. - if plan["action"] == "removed" { - let mut filters = take_entry(&mut entries, name) - .and_then(|entry| entry.tool_filters) - .unwrap_or_default(); - match serde_json::from_value::>(plan["toolFilters"].clone()) - .map_err(|_| invalid_request())? - { - Some(retained) => { - filters.insert(file.path.clone(), retained); - } - None => { - filters.remove(&file.path); - } - } - if let (false, Some(hash)) = - (filters.is_empty(), plan["definitionHash"].as_str()) - { - entries.push(LedgerEntry { - name: name.clone(), - definition_hash: hash.to_string(), - source_hash: None, - tool_filters: Some(filters), - }); - } - } - } - } - } - // An unchanged file publishes nothing: an explicit no-op save of an entry - // Lithe did not write must not adopt it into the ledger. - if !changed { - entries = file.owned.clone(); - } - Ok(Ok(json!({ - "file": file.id, - "changed": changed, - "content": changed.then_some(content), - "owned": entries, - "renames": renames, - }))) -} - -fn take_entry(entries: &mut Vec, name: &str) -> Option { - let index = entries.iter().position(|entry| entry.name == name)?; - let entry = entries.remove(index); - // Any duplicate records for the name are stale and dropped as well. - entries.retain(|entry| entry.name != name); - Some(entry) -} - -fn conflict_rejection(conflict: &str, name: &str, file: &LibraryFile) -> Rejection { - let code = if conflict == "externallyModified" { - "externallyModified" - } else { - "notOwned" - }; - Rejection::new(code, name, Some(&file.id)) -} diff --git a/rust/lithe-core/src/mcp/library_tests.rs b/rust/lithe-core/src/mcp/library_tests.rs deleted file mode 100644 index 2a08bede..00000000 --- a/rust/lithe-core/src/mcp/library_tests.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! Shared-fixture regressions for MCP library merging, draft validation and -//! multi-file batch planning (`rust/fixtures/mcp/library-v1.json`). - -use super::{batch_plan, library_snapshot, parse_configuration, validate_draft}; -use serde_json::{json, Value}; - -fn fixture() -> Value { - serde_json::from_str(include_str!("../../../fixtures/mcp/library-v1.json")) - .expect("library fixture is JSON") -} - -/// Resolves `{{sourceHash::}}` and `{{definitionHash:...}}` -/// placeholders against what Core parses from the fixture files. -fn resolve(value: &Value, files: &Value) -> Value { - match value { - Value::String(text) if text.starts_with("{{") && text.ends_with("}}") => { - let inner = &text[2..text.len() - 2]; - let (kind, rest) = inner.split_once(':').expect("placeholder kind"); - let (file_id, name) = rest.rsplit_once(':').expect("placeholder file and name"); - let file = files - .as_object() - .unwrap() - .values() - .find(|file| file["id"] == file_id && file["content"].is_string()) - .expect("placeholder file"); - let mut request = json!({ - "source": file["source"], "scope": file["scope"], "content": file["content"], - }); - if let Some(path) = file.get("projectPath") { - request["projectPath"] = path.clone(); - } - let parsed = parse_configuration(request).expect("fixture file parses"); - parsed["servers"] - .as_array() - .unwrap() - .iter() - .find(|server| server["name"] == name) - .and_then(|server| server[kind].as_str()) - .map(|hash| json!(hash)) - .expect("placeholder server") - } - Value::Array(items) => Value::Array(items.iter().map(|v| resolve(v, files)).collect()), - Value::Object(map) => Value::Object( - map.iter() - .map(|(key, v)| (key.clone(), resolve(v, files))) - .collect(), - ), - other => other.clone(), - } -} - -/// Builds the request `files` array: fixture files with their ledger slices. -fn library_files(fixture: &Value, keys: &Value, ledger: &Value) -> Value { - let files = &fixture["files"]; - let ledger = resolve(ledger, files); - Value::Array( - keys.as_array() - .unwrap() - .iter() - .map(|key| { - let mut file = files[key.as_str().unwrap()].clone(); - let id = file["id"].as_str().unwrap().to_string(); - file["owned"] = ledger.get(&id).cloned().unwrap_or_else(|| json!([])); - file - }) - .collect(), - ) -} - -#[test] -fn snapshot_merges_sources_flags_conflicts_and_orders_naturally() { - let fixture = fixture(); - let case = &fixture["snapshot"]; - let files = library_files(&fixture, &case["files"], &case["ledger"]); - let snapshot = library_snapshot(json!({ "files": files })).expect("snapshot"); - let rows = snapshot["rows"].as_array().unwrap(); - let expected = case["expectedRows"].as_array().unwrap(); - assert_eq!(rows.len(), expected.len(), "{snapshot:#}"); - for (row, expected) in rows.iter().zip(expected) { - assert_eq!(row["definition"]["name"], expected["name"]); - assert_eq!( - row["hasDefinitionConflict"], expected["hasDefinitionConflict"], - "{row:#}" - ); - assert_eq!(row["projectOnly"], expected["projectOnly"]); - assert_eq!( - row["id"], - json!(format!( - "{}#{}", - row["definition"]["name"].as_str().unwrap(), - row["definition"]["definitionHash"].as_str().unwrap() - )) - ); - if let Some(files) = expected["files"].as_array() { - let sources = row["sources"].as_array().unwrap(); - let ids: Vec<&Value> = sources.iter().map(|source| &source["file"]).collect(); - assert_eq!(ids, files.iter().collect::>()); - let owned: Vec<&Value> = sources - .iter() - .map(|source| &source["ownedByLithe"]) - .collect(); - assert_eq!( - owned, - expected["owned"] - .as_array() - .unwrap() - .iter() - .collect::>() - ); - } - } - assert_eq!(snapshot["failures"], json!([])); -} - -#[test] -fn snapshot_reports_unparseable_files_without_echoing_content() { - let fixture = fixture(); - let case = &fixture["snapshot"]["failureFiles"]; - let files = library_files(&fixture, &case["files"], &json!({})); - let snapshot = library_snapshot(json!({ "files": files })).expect("snapshot"); - let failures = snapshot["failures"].as_array().unwrap(); - let expected = case["expectedFailures"].as_array().unwrap(); - assert_eq!(failures.len(), expected.len()); - for (failure, expected) in failures.iter().zip(expected) { - assert_eq!(failure["file"], expected["file"]); - assert_eq!(failure["code"], expected["code"]); - } - assert!(!snapshot - .to_string() - .contains("fixture-secret-should-not-echo")); - assert_eq!( - snapshot["rows"].as_array().unwrap().len(), - case["expectedRowCount"].as_u64().unwrap() as usize - ); -} - -#[test] -fn snapshot_rejects_duplicate_file_identities() { - let fixture = fixture(); - let files = library_files( - &fixture, - &json!(["codexGlobal", "codexMissing"]), - &json!({}), - ); - assert!(library_snapshot(json!({ "files": files })).is_err()); -} - -#[test] -fn draft_validation_matches_fixture_issues() { - let fixture = fixture(); - for case in fixture["validateDraft"].as_array().unwrap() { - let result = validate_draft(json!({ "draft": case["draft"] })).expect("validate"); - assert_eq!(result["issues"], case["issues"], "{}", case["case"]); - match case.get("definition") { - Some(expected) => { - let definition = &result["definition"]; - assert_eq!(definition["name"], expected["name"], "{}", case["case"]); - assert_eq!(definition["command"], expected["command"]); - assert_eq!(definition["args"], expected["args"]); - let keys: Vec<&Value> = definition["env"] - .as_array() - .unwrap() - .iter() - .map(|entry| &entry["key"]) - .collect(); - assert_eq!( - keys, - expected["envKeys"] - .as_array() - .unwrap() - .iter() - .collect::>() - ); - assert_eq!(definition["url"], Value::Null); - assert_eq!(definition["headers"], json!([])); - } - None if case["issues"] != json!([]) => { - assert_eq!(result["definition"], Value::Null, "{}", case["case"]); - } - None => {} - } - // Issues are codes only: no draft value may leak into the response. - if case["issues"] != json!([]) { - assert!( - !result.to_string().contains("placeholder"), - "{}", - case["case"] - ); - } - } -} - -#[test] -fn batch_plans_match_fixture_outcomes() { - let fixture = fixture(); - for case in fixture["batchPlan"].as_array().unwrap() { - let name = case["case"].as_str().unwrap(); - let files = library_files(&fixture, &case["files"], &case["ledger"]); - let intent = resolve(&case["intent"], &fixture["files"]); - let result = batch_plan(json!({ "files": files, "intent": intent })).expect(name); - let expect = &case["expect"]; - assert_eq!(result["status"], expect["status"], "{name}: {result:#}"); - match expect["status"].as_str().unwrap() { - "rejected" => assert_eq!(result["rejection"], expect["rejection"], "{name}"), - "invalid" => assert_eq!(result["issues"], expect["issues"], "{name}"), - _ => { - let changes = result["changes"].as_array().unwrap(); - let expected = expect["changes"].as_array().unwrap(); - assert_eq!(changes.len(), expected.len(), "{name}: {result:#}"); - for (change, expected) in changes.iter().zip(expected) { - assert_eq!(change["file"], expected["file"], "{name}"); - assert_eq!(change["changed"], expected["changed"], "{name}"); - let content = change["content"].as_str().unwrap_or(""); - for needle in expected["contentContains"].as_array().into_iter().flatten() { - assert!( - content.contains(needle.as_str().unwrap()), - "{name}: {content}" - ); - } - for needle in expected["contentExcludes"].as_array().into_iter().flatten() { - assert!( - !content.contains(needle.as_str().unwrap()), - "{name}: {content}" - ); - } - if !expected["changed"].as_bool().unwrap() { - assert_eq!(change["content"], Value::Null, "{name}"); - } - let owned: Vec<&Value> = change["owned"] - .as_array() - .unwrap() - .iter() - .map(|entry| &entry["name"]) - .collect(); - assert_eq!( - owned, - expected["ownedNames"] - .as_array() - .unwrap() - .iter() - .collect::>(), - "{name}" - ); - if let Some(renames) = expected.get("renames") { - assert_eq!(&change["renames"], renames, "{name}"); - } - } - } - } - } -} - -#[test] -fn batch_owned_entries_record_the_written_hashes() { - // The ledger must describe the bytes that reached the file, so a refresh - // after the write recognizes the entry as Lithe-owned. - let fixture = fixture(); - let files = library_files(&fixture, &json!(["codexMissing"]), &json!({})); - let result = batch_plan(json!({ "files": files, "intent": { - "kind": "setAgent", "agent": "codex", "enabled": true, - "definition": {"name": "fresh", "transport": "stdio", "command": "fresh-mcp", - "args": [], "env": [], "url": null, "headers": [], "definitionHash": "stale"}, - }})) - .expect("plan"); - let change = &result["changes"][0]; - let content = change["content"].as_str().unwrap(); - let mut file = fixture["files"]["codexMissing"].clone(); - file["content"] = json!(content); - file["owned"] = change["owned"].clone(); - let snapshot = library_snapshot(json!({ "files": [file] })).expect("snapshot"); - assert_eq!( - snapshot["rows"][0]["sources"][0]["ownedByLithe"], - json!(true) - ); - assert_ne!(change["owned"][0]["definitionHash"], json!("stale")); -} - -#[test] -fn removal_retains_codex_tool_filters_without_ownership() { - let content = "[mcp_servers.docs]\ncommand = \"npx\"\ndisabled_tools = [\"write\"]\n"; - let parsed = parse_configuration(json!({ - "source": "codex", "scope": "global", "content": content, - })) - .unwrap(); - let server = &parsed["servers"][0]; - let file = json!({ - "id": "codex|global|", "source": "codex", "scope": "global", - "path": "/fixture/home/.codex/config.toml", "content": content, - "owned": [{"name": "docs", "definitionHash": server["definitionHash"], - "sourceHash": server["sourceHash"]}], - }); - let result = batch_plan(json!({ "files": [file], "intent": { - "kind": "delete", "name": "docs", "files": ["codex|global|"], - }})) - .unwrap(); - let owned = &result["changes"][0]["owned"]; - assert_eq!( - owned, - &json!([{"name": "docs", "definitionHash": server["definitionHash"], - "toolFilters": {"/fixture/home/.codex/config.toml": {"disabledTools": ["write"]}}}]) - ); -} diff --git a/rust/lithe-core/src/mcp/mod.rs b/rust/lithe-core/src/mcp/mod.rs deleted file mode 100644 index 73f88873..00000000 --- a/rust/lithe-core/src/mcp/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Deterministic MCP server configuration parsing and format-preserving edits. -//! -//! Hosts read and write the actual configuration files; this module only turns -//! bounded text into normalized server definitions and computes the next file -//! content. It never consults the filesystem, the environment, or the network. - -mod json_edit; -mod library; -mod model; -mod parse; -mod plan; -mod registry; -mod source_hash; -mod toml_edit; -mod tools; - -pub use library::{batch_plan, library_snapshot, validate_draft}; -pub use model::{McpKeyValue, McpServer, McpTransport}; -pub use parse::parse_configuration; -pub use plan::plan_write; -pub use registry::{featured_registry, parse_registry}; -pub use tools::{command_line, tool_policy}; - -#[cfg(test)] -mod library_tests; -#[cfg(test)] -mod tests; diff --git a/rust/lithe-core/src/mcp/model.rs b/rust/lithe-core/src/mcp/model.rs deleted file mode 100644 index 8349ede3..00000000 --- a/rust/lithe-core/src/mcp/model.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Normalized MCP server definitions shared by discovery and editing. - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -/// Transport an Agent uses to reach an MCP server. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum McpTransport { - /// A child process spoken to over standard input and output. - Stdio, - /// A streamable HTTP endpoint. - Http, - /// A legacy server-sent-events endpoint. - Sse, -} - -impl McpTransport { - /// Stable wire value used in configuration and identity hashing. - pub fn as_str(self) -> &'static str { - match self { - Self::Stdio => "stdio", - Self::Http => "http", - Self::Sse => "sse", - } - } -} - -/// One environment variable or HTTP header entry. -/// -/// Entries stay ordered by key so configuration text and identity hashes are -/// deterministic regardless of the order the user typed them. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct McpKeyValue { - /// Variable or header name. - pub key: String, - /// Variable or header value. Never logged by Core. - pub value: String, -} - -/// A server definition normalized across Agent configuration formats. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct McpServer { - /// Library key and configuration entry name. - pub name: String, - /// Transport used to start or reach the server. - pub transport: McpTransport, - /// Executable for `stdio` servers. - pub command: Option, - /// Arguments passed to `command`. - #[serde(default)] - pub args: Vec, - /// Environment entries for `stdio` servers. - #[serde(default)] - pub env: Vec, - /// Endpoint for `http` and `sse` servers. - pub url: Option, - /// Request headers for `http` and `sse` servers. - #[serde(default)] - pub headers: Vec, - /// SHA-256 of the normalized definition, used for library grouping. - #[serde(default)] - pub definition_hash: String, - /// SHA-256 of the complete parsed entry, including unknown fields and name. - /// Absent on older host requests or definitions not yet written to a source. - #[serde(default)] - pub source_hash: Option, -} - -impl McpServer { - /// Builds a definition and fills in its identity hash. - pub fn new( - name: String, - transport: McpTransport, - command: Option, - mut args: Vec, - mut env: Vec, - url: Option, - mut headers: Vec, - ) -> Self { - args.shrink_to_fit(); - env.sort_by(|left, right| left.key.cmp(&right.key)); - headers.sort_by(|left, right| left.key.cmp(&right.key)); - let mut server = Self { - name, - transport, - command, - args, - env, - url, - headers, - definition_hash: String::new(), - source_hash: None, - }; - server.definition_hash = server.compute_hash(); - server - } - - /// Recomputes and stores the identity hash after a definition changed. - pub fn refresh_hash(&mut self) { - self.definition_hash = self.compute_hash(); - } - - /// Grouping identity: name plus modeled values used by the active transport. - /// - /// Env and header entries are sorted here rather than only at construction: - /// the write path deserializes definitions from the host, and its hash must - /// match what the parser computes from the written configuration no matter - /// what order the UI sent the entries in. - /// - /// Only fields the active transport actually uses are hashed. Parsers can - /// see stray fields on an entry (an HTTP server with `command`, `args` or - /// `env`, a stdio server with `url` or `headers`), but the write path drops - /// them, so hashing them would make the ledger disagree with the file - /// immediately after a write. - fn compute_hash(&self) -> String { - let mut canonical = String::new(); - canonical.push_str(&self.name); - canonical.push('\n'); - canonical.push_str(self.transport.as_str()); - canonical.push('\n'); - match self.transport { - McpTransport::Stdio => { - canonical.push_str(self.command.as_deref().unwrap_or("")); - canonical.push('\n'); - for arg in &self.args { - canonical.push_str(arg); - canonical.push('\u{1f}'); - } - canonical.push('\n'); - let mut env: Vec<&McpKeyValue> = self.env.iter().collect(); - env.sort_by(|left, right| { - (left.key.as_str(), left.value.as_str()) - .cmp(&(right.key.as_str(), right.value.as_str())) - }); - for entry in env { - canonical.push_str(&entry.key); - canonical.push('='); - canonical.push_str(&entry.value); - canonical.push('\u{1f}'); - } - } - McpTransport::Http | McpTransport::Sse => { - canonical.push_str(self.url.as_deref().unwrap_or("")); - canonical.push('\n'); - let mut headers: Vec<&McpKeyValue> = self.headers.iter().collect(); - headers.sort_by(|left, right| { - (left.key.as_str(), left.value.as_str()) - .cmp(&(right.key.as_str(), right.value.as_str())) - }); - for entry in headers { - canonical.push_str(&entry.key); - canonical.push(':'); - canonical.push_str(&entry.value); - canonical.push('\u{1f}'); - } - } - } - hex(&Sha256::digest(canonical.as_bytes())) - } -} - -/// Lowercase hexadecimal encoding for identity hashes. -pub fn hex(bytes: &[u8]) -> String { - const DIGITS: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(DIGITS[(byte >> 4) as usize] as char); - output.push(DIGITS[(byte & 0x0f) as usize] as char); - } - output -} diff --git a/rust/lithe-core/src/mcp/parse.rs b/rust/lithe-core/src/mcp/parse.rs deleted file mode 100644 index 74467984..00000000 --- a/rust/lithe-core/src/mcp/parse.rs +++ /dev/null @@ -1,255 +0,0 @@ -//! Bounded parsing of Codex TOML and Claude JSON MCP registrations. -//! -//! Every failure is reported without echoing configuration content: entries can -//! carry API keys and headers, and those values must not reach logs or errors. - -use super::model::{McpKeyValue, McpServer, McpTransport}; -use super::source_hash::{claude_source_hash, codex_source_hash}; -use crate::protocol::{CoreError, ErrorCode}; -use serde::Deserialize; -use serde_json::Value; - -/// Maximum configuration text accepted from a host, matching other bounded -/// configuration parsers in Core. -pub(crate) const MAX_CONFIGURATION_BYTES: usize = 1024 * 1024; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ParseRequest { - /// Configuration owner: `codex` or `claude`. - source: String, - /// `global` or `project`. - scope: String, - /// Raw file text. - #[serde(default)] - content: String, - /// Absolute project path for Claude project-scoped entries. - #[serde(default)] - project_path: Option, -} - -/// Parses one Agent configuration file and returns its normalized servers. -/// -/// The response shape is `{ "servers": [...] }`; server order follows the -/// configuration text. Each entry carries a grouping definition hash and a -/// complete source hash the host can store in its ownership ledger. -pub fn parse_configuration(payload: Value) -> Result { - let request: ParseRequest = serde_json::from_value(payload).map_err(|_| invalid_request())?; - if request.content.len() > MAX_CONFIGURATION_BYTES { - return Err(invalid_request()); - } - let servers = match (request.source.as_str(), request.scope.as_str()) { - ("codex", "global" | "project") => parse_codex_servers(&request.content)?, - ("claude", "global") => parse_claude_servers(&request.content, None)?, - ("claude", "project") => { - let path = request - .project_path - .as_deref() - .ok_or_else(invalid_request)?; - parse_claude_servers(&request.content, Some(path))? - } - _ => return Err(invalid_request()), - }; - Ok(serde_json::json!({ "servers": servers })) -} - -fn invalid_request() -> CoreError { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid MCP configuration request", - ) -} - -fn invalid_configuration() -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP configuration") -} - -/// Reads Claude's `mcpServers` map, optionally inside `projects.`. -pub(crate) fn parse_claude_servers( - content: &str, - project_path: Option<&str>, -) -> Result, CoreError> { - if content.trim().is_empty() { - return Ok(Vec::new()); - } - let root: Value = serde_json::from_str(content).map_err(|_| invalid_configuration())?; - let base = match project_path { - Some(path) => root - .get("projects") - .and_then(|projects| projects.get(path)) - .unwrap_or(&Value::Null), - None => &root, - }; - let Some(entries) = base.get("mcpServers").and_then(Value::as_object) else { - return Ok(Vec::new()); - }; - let mut servers = Vec::new(); - for (name, entry) in entries { - let Some(object) = entry.as_object() else { - continue; - }; - let command = object.get("command").and_then(Value::as_str); - let url = object.get("url").and_then(Value::as_str); - let transport = object - .get("type") - .and_then(Value::as_str) - .and_then(|value| match value { - "http" => Some(McpTransport::Http), - "sse" => Some(McpTransport::Sse), - "stdio" => Some(McpTransport::Stdio), - _ => None, - }) - .unwrap_or(if url.is_some() { - McpTransport::Http - } else { - McpTransport::Stdio - }); - let Some(mut server) = build_server( - name, - transport, - command, - string_array(object.get("args")), - key_values(object.get("env")), - url, - key_values(object.get("headers")), - ) else { - continue; - }; - server.source_hash = Some(claude_source_hash(name, entry)); - servers.push(server); - } - Ok(servers) -} - -/// Reads Codex's `[mcp_servers.]` tables. -pub(crate) fn parse_codex_servers(content: &str) -> Result, CoreError> { - if content.trim().is_empty() { - return Ok(Vec::new()); - } - let root: toml::Value = toml::from_str(content).map_err(|_| invalid_configuration())?; - let Some(entries) = root.get("mcp_servers").and_then(toml::Value::as_table) else { - return Ok(Vec::new()); - }; - let mut servers = Vec::new(); - for (name, entry) in entries { - let Some(table) = entry.as_table() else { - continue; - }; - let command = table.get("command").and_then(toml::Value::as_str); - let url = table.get("url").and_then(toml::Value::as_str); - let transport = if url.is_some() { - McpTransport::Http - } else { - McpTransport::Stdio - }; - let Some(mut server) = build_server( - name, - transport, - command, - toml_string_array(table.get("args")), - toml_key_values(table.get("env")), - url, - toml_key_values(table.get("http_headers")), - ) else { - continue; - }; - server.source_hash = Some(codex_source_hash(name, entry)); - servers.push(server); - } - Ok(servers) -} - -#[allow(clippy::too_many_arguments)] -fn build_server( - name: &str, - transport: McpTransport, - command: Option<&str>, - args: Vec, - env: Vec, - url: Option<&str>, - headers: Vec, -) -> Option { - // An entry with neither a command nor a URL cannot be started or reached; - // keep it out of the library rather than presenting a broken row. - if command.is_none() && url.is_none() { - return None; - } - Some(McpServer::new( - name.to_string(), - transport, - command.map(str::to_string), - args, - env, - url.map(str::to_string), - headers, - )) -} - -fn string_array(value: Option<&Value>) -> Vec { - value - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn key_values(value: Option<&Value>) -> Vec { - value - .and_then(Value::as_object) - .map(|entries| { - let mut values: Vec = entries - .iter() - .filter_map(|(key, value)| { - value.as_str().map(|value| McpKeyValue { - key: key.clone(), - value: value.to_string(), - }) - }) - .collect(); - values.sort_by(|left, right| left.key.cmp(&right.key)); - values - }) - .unwrap_or_default() -} - -fn toml_string_array(value: Option<&toml::Value>) -> Vec { - value - .and_then(toml::Value::as_array) - .map(|items| { - items - .iter() - .filter_map(toml::Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn toml_key_values(value: Option<&toml::Value>) -> Vec { - value - .and_then(toml::Value::as_table) - .map(|entries| { - let mut values: Vec = entries - .iter() - .filter_map(|(key, value)| { - value.as_str().map(|value| McpKeyValue { - key: key.clone(), - value: value.to_string(), - }) - }) - .collect(); - values.sort_by(|left, right| left.key.cmp(&right.key)); - values - }) - .unwrap_or_default() -} - -/// Returns the first definition for `name`, if present. -pub(crate) fn find_server<'a>(servers: &'a [McpServer], name: &str) -> Option<&'a McpServer> { - servers.iter().find(|server| server.name == name) -} diff --git a/rust/lithe-core/src/mcp/plan.rs b/rust/lithe-core/src/mcp/plan.rs deleted file mode 100644 index 7eb20e23..00000000 --- a/rust/lithe-core/src/mcp/plan.rs +++ /dev/null @@ -1,602 +0,0 @@ -//! Plans one MCP configuration write and enforces the host's ownership ledger. -//! -//! Automatic changes require the ledger to match the complete current entry. -//! Explicit edits can instead present the source hash of the displayed original; -//! that precondition is checked before no-op or ownership decisions. - -use super::json_edit::JsonDocument; -use super::parse::{ - find_server, parse_claude_servers, parse_codex_servers, MAX_CONFIGURATION_BYTES, -}; -use super::toml_edit; -use super::{McpKeyValue, McpServer}; -use crate::protocol::{CoreError, ErrorCode}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct OwnedEntry { - /// Entry name recorded when Lithe wrote it. - pub(super) name: String, - /// Grouping definition hash recorded at write time. - pub(super) definition_hash: String, - /// Complete entry hash recorded at write time; legacy records omit it. - #[serde(default)] - pub(super) source_hash: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct WriteRequest { - /// Configuration owner: `codex` or `claude`. - pub(super) source: String, - /// `global` or `project`. - pub(super) scope: String, - #[serde(default)] - pub(super) project_path: Option, - /// Current file text; `null` means the file does not exist yet. - #[serde(default)] - pub(super) content: Option, - /// `set` or `remove`. - pub(super) operation: String, - #[serde(default)] - pub(super) server: Option, - #[serde(default)] - pub(super) name: Option, - /// Existing name this set renames from. The old entry is used as the base - /// for the new one so settings Lithe does not model survive the rename. - #[serde(default)] - pub(super) previous_name: Option, - /// Complete source hash of the displayed original entry. On rename this - /// refers to `previousName`, and never authorizes the destination entry. - #[serde(default)] - pub(super) expected_hash: Option, - /// Entries the host recorded as written by Lithe. - #[serde(default)] - pub(super) owned: Vec, - /// Non-secret native filters retained when this server was removed. - #[serde(default)] - pub(super) tool_filters: Option, -} - -/// Native Codex restrictions, kept separately from credentials and connection fields. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(super) struct ToolFilters { - /// Absence differs from an empty allow-list, which allows no tools. - #[serde(skip_serializing_if = "Option::is_none")] - enabled_tools: Option>, - /// Preserve external exclusions as well as those owned by Lithe. - #[serde(skip_serializing_if = "Option::is_none")] - disabled_tools: Option>, -} - -fn invalid_request() -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP write request") -} - -/// Computes the next configuration text for one set or remove operation. -/// -/// The response is `{ content, changed, action, conflict }`. `conflict` is -/// `ownedByUser` when the entry was never written by Lithe and -/// `externallyModified` when the on-disk definition no longer matches the -/// ledger or the explicit `expectedHash`; in both cases `content` is unchanged. -/// Successful set plans also return the actual entry's grouping and source hashes. -pub fn plan_write(payload: Value) -> Result { - let request: WriteRequest = serde_json::from_value(payload).map_err(|_| invalid_request())?; - plan_write_request(&request) -} - -/// Typed form of [`plan_write`] used by library batch planning, which feeds -/// each operation the output of the previous one without a JSON round trip. -pub(super) fn plan_write_request(request: &WriteRequest) -> Result { - let content = request.content.clone().unwrap_or_default(); - if content.len() > MAX_CONFIGURATION_BYTES { - return Err(invalid_request()); - } - let project_path = request.project_path.as_deref(); - let valid_scope = matches!(request.scope.as_str(), "global" | "project") - && (request.scope != "project" || project_path.is_some()); - if !valid_scope { - return Err(invalid_request()); - } - match request.operation.as_str() { - "set" => { - let mut server = request.server.clone().ok_or_else(invalid_request)?; - // The client may send a stale hash from an edited draft; identity is - // always derived from the definition actually being written. - server.refresh_hash(); - plan_set(request, &server, &content, project_path) - } - "remove" => { - let name = request.name.as_deref().ok_or_else(invalid_request)?; - plan_remove(request, name, &content, project_path) - } - _ => Err(invalid_request()), - } -} - -fn plan_set( - request: &WriteRequest, - server: &McpServer, - content: &str, - project_path: Option<&str>, -) -> Result { - // Configuration maps and tables can hold only one value per key, while the - // host sends entries as a list. Reject duplicates instead of hashing one - // shape and writing another, which would lose ownership after a refresh. - // Only the active transport's entries are written, so only they can collide. - let entries = match server.transport { - super::McpTransport::Stdio => &server.env, - super::McpTransport::Http | super::McpTransport::Sse => &server.headers, - }; - if has_duplicate_keys(entries) { - return Err(invalid_request()); - } - let existing = existing_servers(request, content, project_path)?; - let previous = request - .previous_name - .as_deref() - .filter(|name| *name != server.name); - let original = find_server(&existing, previous.unwrap_or(&server.name)); - if expected_hash_conflicts(request, original) { - return Ok(unchanged(content, "conflict", Some("externallyModified"))); - } - let explicitly_authorized = request.expected_hash.is_some(); - for name in [previous, Some(server.name.as_str())].into_iter().flatten() { - if find_server(&existing, name).is_none() - && entry_exists(request, content, project_path, name)? - { - // Incomplete entries have no parsed ownership proof but still hold - // user data, including when the original lost its command or URL. - return Ok(unchanged( - content, - "conflict", - Some(unparsed_entry_conflict(&request.owned, name)), - )); - } - } - if let Some(current) = find_server(&existing, &server.name) { - // An original-entry precondition must not authorize replacing a different - // destination, even when its modeled definition already matches. - // A no-op set returns hashes the host records, so it needs ownership - // too; otherwise registration would silently adopt external changes. - let requires_ownership = previous.is_some() || !explicitly_authorized; - if requires_ownership { - if let Some(conflict) = ownership_conflict(&request.owned, current) { - return Ok(unchanged(content, "conflict", Some(conflict))); - } - } - if previous.is_none() && current.definition_hash == server.definition_hash { - return Ok(with_hashes(unchanged(content, "unchanged", None), current)); - } - } - if let Some(current) = previous.and_then(|name| find_server(&existing, name)) { - if !explicitly_authorized { - if let Some(conflict) = ownership_conflict(&request.owned, current) { - return Ok(unchanged(content, "conflict", Some(conflict))); - } - } - } - let action = if find_server(&existing, &server.name).is_some() - || previous.is_some_and(|name| find_server(&existing, name).is_some()) - { - "updated" - } else { - "inserted" - }; - let next = match request.source.as_str() { - "claude" => write_claude(content, project_path, &server.name, server, previous)?, - "codex" => write_codex( - content, - server, - previous, - if action == "inserted" { - request.tool_filters.as_ref() - } else { - None - }, - )?, - _ => return Err(invalid_request()), - }; - // Unknown fields survive edits, so hash the actual output rather than the - // normalized draft supplied by the host. - let written = existing_servers(request, &next, project_path)?; - let written = find_server(&written, &server.name).ok_or_else(invalid_request)?; - Ok(with_hashes( - json!({ - "content": next, - "changed": next != content, - "action": action, - "conflict": Value::Null, - }), - written, - )) -} - -fn plan_remove( - request: &WriteRequest, - name: &str, - content: &str, - project_path: Option<&str>, -) -> Result { - let existing = existing_servers(request, content, project_path)?; - let current = find_server(&existing, name); - if expected_hash_conflicts(request, current) { - return Ok(unchanged(content, "conflict", Some("externallyModified"))); - } - let Some(current) = current else { - if entry_exists(request, content, project_path, name)? { - return Ok(unchanged( - content, - "conflict", - Some(unparsed_entry_conflict(&request.owned, name)), - )); - } - return Ok(json!({ - "content": content, - "changed": false, - "action": "absent", - "conflict": Value::Null, - })); - }; - if request.expected_hash.is_none() { - if let Some(conflict) = ownership_conflict(&request.owned, current) { - return Ok(unchanged(content, "conflict", Some(conflict))); - } - } - let next = match request.source.as_str() { - "claude" => remove_claude(content, project_path, name)?, - "codex" => remove_codex(content, name)?, - _ => return Err(invalid_request()), - }; - // The parsed entry must also be removable from the document text. An - // inline-table root or an unsupported layout can parse but not delete; - // reporting `removed` while returning identical content would make the - // ledger forget an entry that still exists. - if next == content { - return Ok(with_hashes(unchanged(content, "absent", None), current)); - } - // Capture before deletion; malformed native restrictions must fail closed - // rather than disappearing when an Agent is switched back on. - let tool_filters = if request.source == "codex" { - codex_tool_filters(content, name)? - } else { - None - }; - Ok(json!({ - "content": next, - "changed": true, - "action": "removed", - "conflict": Value::Null, - "definitionHash": current.definition_hash, - "toolFilters": tool_filters, - })) -} - -/// Extract only the native tool lists; never archive env, headers or auth values. -fn codex_tool_filters(content: &str, name: &str) -> Result, CoreError> { - let document: toml::Value = toml::from_str(content).map_err(|_| invalid_request())?; - let entry = document - .get("mcp_servers") - .and_then(|v| v.get(name)) - .ok_or_else(invalid_request)?; - let list = |key: &str| -> Result>, CoreError> { - entry - .get(key) - .map(|value| { - value - .as_array() - .ok_or_else(invalid_request)? - .iter() - .map(|v| v.as_str().map(str::to_owned).ok_or_else(invalid_request)) - .collect() - }) - .transpose() - }; - let filters = ToolFilters { - enabled_tools: list("enabled_tools")?, - disabled_tools: list("disabled_tools")?, - }; - Ok((filters.enabled_tools.is_some() || filters.disabled_tools.is_some()).then_some(filters)) -} - -fn existing_servers( - request: &WriteRequest, - content: &str, - project_path: Option<&str>, -) -> Result, CoreError> { - match request.source.as_str() { - "claude" => parse_claude_servers(content, project_path), - "codex" => parse_codex_servers(content), - _ => Err(invalid_request()), - } -} - -/// An existing raw entry omitted from discovery cannot prove ownership. -fn unparsed_entry_conflict(owned: &[OwnedEntry], name: &str) -> &'static str { - if owned.iter().any(|entry| entry.name == name) { - "externallyModified" - } else { - "ownedByUser" - } -} - -/// Discovery omits entries without a usable transport, but they still occupy -/// their source key and cannot be overwritten by an unrelated rename. -fn entry_exists( - request: &WriteRequest, - content: &str, - project_path: Option<&str>, - name: &str, -) -> Result { - // Missing and whitespace-only files contain no entry; the writer creates - // their first container rather than parsing them as existing JSON. - if content.trim().is_empty() { - return Ok(false); - } - match request.source.as_str() { - "claude" => { - let document = JsonDocument::parse(content)?; - let path: Vec<&str> = match project_path { - Some(project) => vec!["projects", project, "mcpServers"], - None => vec!["mcpServers"], - }; - let Some(object) = document.object_at(&path)? else { - return Ok(false); - }; - Ok(document.member_value(object, name)?.is_some()) - } - "codex" => { - let document: toml::Value = toml::from_str(content).map_err(|_| invalid_request())?; - Ok(document - .get("mcp_servers") - .and_then(|entries| entries.get(name)) - .is_some()) - } - _ => Err(invalid_request()), - } -} - -/// A supplied precondition is mandatory even when a newer ledger owns the entry. -fn expected_hash_conflicts(request: &WriteRequest, current: Option<&McpServer>) -> bool { - request.expected_hash.as_deref().is_some_and(|expected| { - current.and_then(|server| server.source_hash.as_deref()) != Some(expected) - }) -} - -fn is_owned(owned: &[OwnedEntry], current: &McpServer) -> bool { - owned.iter().any(|entry| { - entry.name == current.name - && entry.definition_hash == current.definition_hash - && entry.source_hash.is_some() - && entry.source_hash == current.source_hash - }) -} - -fn with_hashes(mut response: Value, current: &McpServer) -> Value { - response["definitionHash"] = json!(current.definition_hash); - response["sourceHash"] = json!(current.source_hash); - response -} - -/// Entries become a JSON object or TOML table, which cannot repeat a key. -fn has_duplicate_keys(entries: &[McpKeyValue]) -> bool { - let mut keys: Vec<&str> = entries.iter().map(|entry| entry.key.as_str()).collect(); - keys.sort_unstable(); - keys.windows(2).any(|pair| pair[0] == pair[1]) -} - -/// Returns why `current` cannot be replaced, or `None` when Lithe owns it. -fn ownership_conflict(owned: &[OwnedEntry], current: &McpServer) -> Option<&'static str> { - if is_owned(owned, current) { - return None; - } - // A ledger entry with this name but a different hash means Lithe wrote an - // older definition that changed outside Lithe, which is a different - // situation from an entry Lithe never wrote. - if owned.iter().any(|entry| entry.name == current.name) { - Some("externallyModified") - } else { - Some("ownedByUser") - } -} - -fn unchanged(content: &str, action: &str, conflict: Option<&str>) -> Value { - json!({ - "content": content, - "changed": false, - "action": action, - "conflict": conflict, - }) -} - -fn write_claude( - content: &str, - project_path: Option<&str>, - name: &str, - server: &McpServer, - previous: Option<&str>, -) -> Result { - let base = if content.trim().is_empty() { - "{}" - } else { - content - }; - let mut document = JsonDocument::parse(base)?; - let path: Vec<&str> = match project_path { - Some(project) => vec!["projects", project, "mcpServers"], - None => vec!["mcpServers"], - }; - let object = document.ensure_object(&path)?; - // A rename starts from the existing entry so Claude-specific fields Lithe - // does not model move with the server; modeled keys are overwritten. If a - // target with the new name already exists, its own fields take precedence, - // matching the Codex path. - let base = match document.member_value(object, name)? { - Some(target) => Some(target), - None => match previous { - Some(previous) => document.member_value(object, previous)?, - None => None, - }, - }; - let value = match base { - Some(existing) => merge_claude_value(&existing, server)?, - None => server_value(server), - }; - let value = serde_json::to_string(&value).map_err(|_| invalid_request())?; - document.set_member(object, name, &value)?; - document.into_string() -} - -fn remove_claude( - content: &str, - project_path: Option<&str>, - name: &str, -) -> Result { - let mut document = JsonDocument::parse(content)?; - let path: Vec<&str> = match project_path { - Some(project) => vec!["projects", project, "mcpServers"], - None => vec!["mcpServers"], - }; - // Removal never creates missing containers; the caller already established - // that the entry exists in this exact path. - let Some(object) = document.object_at(&path)? else { - return Ok(content.to_string()); - }; - document.remove_member(object, name)?; - document.into_string() -} - -fn write_codex( - content: &str, - server: &McpServer, - previous: Option<&str>, - filters: Option<&ToolFilters>, -) -> Result { - let mut document = toml_edit::document(content)?; - toml_edit::set_server(&mut document, server, previous)?; - if let Some(filters) = filters { - let entry = document - .get_mut("mcp_servers") - .and_then(|v| v.get_mut(&server.name)) - .and_then(::toml_edit::Item::as_table_like_mut) - .ok_or_else(invalid_request)?; - for (key, values) in [ - ("enabled_tools", &filters.enabled_tools), - ("disabled_tools", &filters.disabled_tools), - ] { - if let Some(values) = values { - let mut array = ::toml_edit::Array::new(); - for value in values { - array.push(value.as_str()); - } - entry.insert(key, ::toml_edit::value(array)); - } - } - } - Ok(document.to_string()) -} - -fn remove_codex(content: &str, name: &str) -> Result { - let mut document = toml_edit::document(content)?; - toml_edit::remove_server(&mut document, name); - Ok(document.to_string()) -} - -/// Converts a normalized definition into the JSON entry Claude Code reads. -/// Hashes are Lithe bookkeeping values and are never written. -fn server_value(server: &McpServer) -> Value { - let mut object = Map::new(); - apply_claude_value(&mut object, server); - Value::Object(object) -} - -/// Copies modeled fields onto an existing entry, keeping keys Lithe does not -/// understand so a rename does not erase Claude-specific options. -fn merge_claude_value(existing: &str, server: &McpServer) -> Result { - let mut object = serde_json::from_str::(existing) - .map_err(|_| invalid_request())? - .as_object() - .cloned() - .ok_or_else(invalid_request)?; - apply_claude_value(&mut object, server); - Ok(Value::Object(object)) -} - -fn apply_claude_value(object: &mut Map, server: &McpServer) { - match server.transport { - super::McpTransport::Stdio => { - object.remove("url"); - object.remove("headers"); - object.remove("type"); - match &server.command { - Some(command) => { - object.insert("command".into(), Value::String(command.clone())); - } - None => { - object.remove("command"); - } - } - if server.args.is_empty() { - object.remove("args"); - } else { - object.insert("args".into(), json!(server.args)); - } - apply_entries_value(object, "env", &server.env); - } - super::McpTransport::Http | super::McpTransport::Sse => { - object.remove("command"); - object.remove("args"); - object.remove("env"); - object.insert( - "type".into(), - Value::String(server.transport.as_str().into()), - ); - match &server.url { - Some(url) => { - object.insert("url".into(), Value::String(url.clone())); - } - None => { - object.remove("url"); - } - } - apply_entries_value(object, "headers", &server.headers); - } - } -} - -/// Applies modeled env or header entries while keeping values Lithe could not -/// represent. Parsing filters non-string values out of the library model, so -/// replacing the whole object would delete configuration the user never saw. -fn apply_entries_value(object: &mut Map, key: &str, entries: &[McpKeyValue]) { - match object.get(key).and_then(Value::as_object).cloned() { - Some(mut existing) => { - existing.retain(|name, value| { - !value.is_string() || entries.iter().any(|entry| entry.key == *name) - }); - for entry in entries { - existing.insert(entry.key.clone(), Value::String(entry.value.clone())); - } - if existing.is_empty() { - object.remove(key); - } else { - object.insert(key.to_string(), Value::Object(existing)); - } - } - None => { - if !entries.is_empty() { - object.insert(key.to_string(), entries_value(entries)); - } - } - } -} - -fn entries_value(entries: &[super::McpKeyValue]) -> Value { - let mut object = Map::new(); - for entry in entries { - object.insert(entry.key.clone(), Value::String(entry.value.clone())); - } - Value::Object(object) -} diff --git a/rust/lithe-core/src/mcp/registry.rs b/rust/lithe-core/src/mcp/registry.rs deleted file mode 100644 index 1007c78a..00000000 --- a/rust/lithe-core/src/mcp/registry.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! Normalize public MCP Registry metadata without fetching or executing servers. - -use super::model::{McpServer, McpTransport}; -use crate::protocol::{CoreError, ErrorCode}; -use serde_json::{json, Value}; - -/// Returns the release-owned catalog without reading files or using the network. -/// Entries pass through the same draft validation as public Registry responses. -pub fn featured_registry() -> Result { - let content = include_str!("featured.json"); - let catalog: Value = serde_json::from_str(content) - .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid featured MCP catalog"))?; - let mut page = parse_registry(json!({"content": content}))?; - if let Some(entries) = page.get_mut("entries").and_then(Value::as_array_mut) { - for entry in entries { - entry["source"] = json!("featured"); - // Only the release-owned catalog may declare an incomplete template. - // Public Registry input must still contain a complete launch recipe. - let setup = catalog["servers"] - .as_array() - .and_then(|servers| { - servers - .iter() - .find(|item| item["server"]["name"] == entry["id"]) - }) - .and_then(|item| item.get("setup")) - .and_then(Value::as_str); - if let Some(setup) = setup { - if !matches!(setup, "bearer-token" | "allowed-directories") - || entry["draft"].is_null() - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid featured MCP setup", - )); - } - entry["setup"] = json!(setup); - } - } - } - Ok(page) -} - -/// Reads one bounded v0.1 registry page. Only simple, fully specified launch -/// recipes become drafts; complex inputs remain documentation-only entries. -pub fn parse_registry(payload: Value) -> Result { - let content = payload.get("content").and_then(Value::as_str).unwrap_or(""); - let invalid = || CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP registry response"); - if content.len() > 1024 * 1024 { - return Err(invalid()); - } - let page: Value = serde_json::from_str(content).map_err(|_| invalid())?; - let servers = page - .get("servers") - .and_then(Value::as_array) - .ok_or_else(invalid)?; - if servers.len() > 100 { - return Err(invalid()); - } - let mut entries = Vec::new(); - for item in servers { - let status = item - .pointer("/_meta/io.modelcontextprotocol.registry~1official/status") - .and_then(Value::as_str); - if status.is_some_and(|s| s != "active") { - continue; - } - let Some(server) = item.get("server").filter(|s| s.is_object()) else { - continue; - }; - let Some(name) = server - .get("name") - .and_then(Value::as_str) - .filter(|s| !s.is_empty() && s.len() < 256) - else { - continue; - }; - let title = server.get("title").and_then(Value::as_str).unwrap_or(name); - let mut draft_name: String = name - .rsplit('/') - .next() - .unwrap_or(name) - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || "._-".contains(c) { - c - } else { - '-' - } - }) - .collect(); - if !draft_name.starts_with(|c: char| c.is_ascii_alphanumeric()) { - draft_name.insert_str(0, "mcp-"); - } - let mut draft = None; - if let Some(remotes) = server.get("remotes").and_then(Value::as_array) { - for remote in remotes { - let transport = match remote.get("type").and_then(Value::as_str) { - Some("streamable-http") => McpTransport::Http, - Some("sse") => McpTransport::Sse, - _ => continue, - }; - // Dynamic URLs, headers and authentication require the publisher's - // setup instructions; never turn unresolved placeholders into a config. - if !empty_array(remote, "headers") - || remote - .get("variables") - .is_some_and(|v| v.as_object().is_none_or(|o| !o.is_empty())) - { - continue; - } - if let Some(url) = safe_url(remote.get("url")) { - draft = Some(McpServer::new( - draft_name.clone(), - transport, - None, - vec![], - vec![], - Some(url), - vec![], - )); - break; - } - } - } - if draft.is_none() { - if let Some(packages) = server.get("packages").and_then(Value::as_array) { - for package in packages { - if package.pointer("/transport/type").and_then(Value::as_str) != Some("stdio") - || [ - "runtimeArguments", - "packageArguments", - "environmentVariables", - ] - .iter() - .any(|key| !empty_array(package, key)) - { - continue; - } - let Some(id) = package.get("identifier").and_then(Value::as_str) else { - continue; - }; - let Some(version) = package.get("version").and_then(Value::as_str) else { - continue; - }; - // Dist-tags such as "latest" are mutable and cannot be pinned. - if !safe_package(id) - || !safe_package(version) - || !version.starts_with(|c: char| c.is_ascii_digit()) - { - continue; - } - let (command, args, base) = - match package.get("registryType").and_then(Value::as_str) { - Some("npm") if exact_npm_version(version) => ( - "npx", - vec!["-y".into(), format!("{id}@{version}")], - "https://registry.npmjs.org", - ), - Some("pypi") => { - ("uvx", vec![format!("{id}=={version}")], "https://pypi.org") - } - _ => continue, - }; - // A package on another registry must not be resolved from the - // public default merely because its identifier looks familiar. - if package.get("registryBaseUrl").is_some_and(|url| { - safe_url(Some(url)).is_none_or(|url| url.trim_end_matches('/') != base) - }) { - continue; - } - if package - .get("runtimeHint") - .is_some_and(|hint| hint.as_str() != Some(command)) - { - continue; - } - draft = Some(McpServer::new( - draft_name.clone(), - McpTransport::Stdio, - Some(command.into()), - args, - vec![], - None, - vec![], - )); - break; - } - } - } - entries.push(json!({"id": name, "title": title, "summary": server.get("description").and_then(Value::as_str).unwrap_or(""), - "publisher": server.get("publisher").and_then(Value::as_str), "source": "registry", - "version": server.get("version").and_then(Value::as_str).unwrap_or(""), - "website": safe_url(server.get("websiteUrl")).or_else(|| safe_url(server.pointer("/repository/url"))), "draft": draft})); - } - let cursor = page - .pointer("/metadata/nextCursor") - .and_then(Value::as_str) - .filter(|s| !s.is_empty() && s.len() <= 2048); - Ok(json!({"entries": entries, "nextCursor": cursor})) -} - -fn empty_array(value: &Value, key: &str) -> bool { - value - .get(key) - .is_none_or(|v| v.as_array().is_some_and(Vec::is_empty)) -} - -fn safe_package(value: &str) -> bool { - !value.is_empty() - && value.len() < 256 - && !value.starts_with('-') - && value - .bytes() - .all(|c| c.is_ascii_alphanumeric() || b"@/._-+".contains(&c)) -} - -fn exact_npm_version(version: &str) -> bool { - let components: Vec<_> = version - .split(['-', '+']) - .next() - .unwrap_or("") - .split('.') - .collect(); - components.len() == 3 - && components - .iter() - .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) - && version - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b".-+".contains(&b)) -} - -fn safe_url(value: Option<&Value>) -> Option { - let raw = value?.as_str()?; - if raw.contains(['{', '}']) { - return None; - } - let url = url::Url::parse(raw).ok()?; - (url.scheme() == "https" - && url.host_str().is_some() - && url.username().is_empty() - && url.password().is_none()) - .then(|| url.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn featured_catalog_is_available_offline_with_pinned_reviewable_drafts() { - let page = featured_registry().unwrap(); - let entries = page["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 10); - assert!(page["nextCursor"].is_null()); - assert!(entries - .iter() - .all(|e| e["source"] == "featured" && e["draft"].is_object())); - assert_eq!(entries[0]["publisher"], "Upstash"); - assert_eq!( - entries[1]["draft"]["args"], - json!(["-y", "@playwright/mcp@0.0.83"]) - ); - assert_eq!( - entries[2]["draft"]["args"], - json!(["-y", "chrome-devtools-mcp@1.11.0"]) - ); - let find = |name: &str| entries.iter().find(|e| e["draft"]["name"] == name).unwrap(); - assert_eq!(find("github")["setup"], "bearer-token"); - assert_eq!( - find("github")["draft"]["url"], - "https://api.githubcopilot.com/mcp/" - ); - assert_eq!( - find("deepwiki")["draft"]["url"], - "https://mcp.deepwiki.com/mcp" - ); - assert_eq!(find("exa")["draft"]["url"], "https://mcp.exa.ai/mcp"); - assert_eq!(find("filesystem")["setup"], "allowed-directories"); - assert_eq!( - find("filesystem")["draft"]["args"], - json!(["-y", "@modelcontextprotocol/server-filesystem@2.0.0"]) - ); - assert_eq!( - find("memory")["draft"]["args"], - json!(["-y", "@modelcontextprotocol/server-memory@2.0.0"]) - ); - assert_eq!( - find("fetch")["draft"]["args"], - json!(["mcp-server-fetch==2026.10.19"]) - ); - } - - #[test] - fn public_registry_cannot_declare_trusted_featured_setup() { - let page = parse_registry(json!({"content": json!({"servers": [{ - "setup": "bearer-token", "server": {"name": "lithe.featured/github", - "setup": "bearer-token", "remotes": [{"type": "streamable-http", "url": "https://example.invalid/mcp"}]} - }]}).to_string()})).unwrap(); - assert_eq!(page["entries"][0]["source"], "registry"); - assert!(page["entries"][0].get("setup").is_none()); - } - - #[test] - fn registry_page_keeps_versions_cursor_and_only_complete_drafts() { - let content = include_str!("../../../contracts/mcp-registry-page.json"); - let page = parse_registry(json!({"content": content})).unwrap(); - assert_eq!(page["entries"].as_array().unwrap().len(), 3); - assert_eq!(page["nextCursor"], "opaque/+ cursor?"); - assert_eq!(page["entries"][0]["draft"]["transport"], "http"); - assert_eq!( - page["entries"][1]["draft"]["args"], - json!(["-y", "@example/browser@2.0.0"]) - ); - assert!(page["entries"][2]["draft"].is_null()); - } - - #[test] - fn untrusted_links_dynamic_urls_and_package_options_are_not_launch_recipes() { - for url in [ - "javascript:alert(1)", - "https://user:secret@example.invalid/mcp", - "https://example.invalid/{tenant}", - ] { - let page = parse_registry(json!({"content": json!({"servers": [{"server": { - "name": "io.example/untrusted", "websiteUrl": url, - "remotes": [{"type": "streamable-http", "url": url}], - "packages": [{"registryType":"npm", "identifier":"--eval", "version":"1", "transport":{"type":"stdio"}}] - }}]}).to_string()})).unwrap(); - assert!(page["entries"][0]["draft"].is_null()); - assert!(page["entries"][0]["website"].is_null()); - } - for (version, hint, base) in [ - ("latest", "npx", "https://registry.npmjs.org"), - ("1", "npx", "https://registry.npmjs.org"), - ("1.0.0", "node", "https://registry.npmjs.org"), - ("1.0.0", "npx", "https://example.invalid/npm"), - ] { - let page = parse_registry(json!({"content": json!({"servers": [{"server": { - "name": "io.example/needs-setup", - "packages": [{"registryType":"npm", "identifier":"example", "version":version, - "runtimeHint":hint, "registryBaseUrl":base, "transport":{"type":"stdio"}}] - }}]}).to_string()})) - .unwrap(); - assert!(page["entries"][0]["draft"].is_null()); - } - } - - #[test] - fn malformed_and_oversized_pages_fail_without_echoing_contents() { - for content in [ - "secret invalid json".to_string(), - "x".repeat(1024 * 1024 + 1), - "{}".to_string(), - ] { - let error = parse_registry(json!({"content": content})).unwrap_err(); - assert!(!error.message.contains("secret")); - } - } -} diff --git a/rust/lithe-core/src/mcp/source_hash.rs b/rust/lithe-core/src/mcp/source_hash.rs deleted file mode 100644 index eea4563e..00000000 --- a/rust/lithe-core/src/mcp/source_hash.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Canonical semantic hashes for complete Agent-owned MCP entries. - -use super::model::hex; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; - -/// Hashes a Claude entry, including its name and every nested unknown field. -pub(crate) fn claude_source_hash(name: &str, entry: &Value) -> String { - digest(name, canonical_json(entry)) -} - -/// Hashes a Codex entry without losing TOML value types or unknown fields. -pub(crate) fn codex_source_hash(name: &str, entry: &toml::Value) -> String { - digest(name, canonical_toml(entry)) -} - -fn digest(name: &str, entry: Value) -> String { - // Length-delimited JSON avoids delimiter collisions in arbitrary user data. - let bytes = serde_json::to_vec(&json!([name, entry])).expect("canonical values serialize"); - hex(&Sha256::digest(bytes)) -} - -fn canonical_json(value: &Value) -> Value { - match value { - Value::Object(entries) => { - let mut entries: Vec<_> = entries.iter().collect(); - entries.sort_by(|left, right| left.0.cmp(right.0)); - // An array of pairs preserves sorted order even with preserve_order. - json!([ - "object", - entries - .into_iter() - .map(|(key, value)| { json!([key, canonical_json(value)]) }) - .collect::>() - ]) - } - Value::Array(values) => json!([ - "array", - values.iter().map(canonical_json).collect::>() - ]), - _ => json!(["scalar", value]), - } -} - -fn canonical_toml(value: &toml::Value) -> Value { - // Type tags distinguish datetimes from strings and integers from floats. - match value { - toml::Value::String(value) => json!(["string", value]), - toml::Value::Integer(value) => json!(["integer", value]), - toml::Value::Float(value) => json!(["float", value.to_bits()]), - toml::Value::Boolean(value) => json!(["boolean", value]), - toml::Value::Datetime(value) => json!(["datetime", value.to_string()]), - toml::Value::Array(values) => json!([ - "array", - values.iter().map(canonical_toml).collect::>() - ]), - toml::Value::Table(entries) => { - let mut entries: Vec<_> = entries.iter().collect(); - entries.sort_by(|left, right| left.0.cmp(right.0)); - json!([ - "table", - entries - .into_iter() - .map(|(key, value)| { json!([key, canonical_toml(value)]) }) - .collect::>() - ]) - } - } -} diff --git a/rust/lithe-core/src/mcp/tests.rs b/rust/lithe-core/src/mcp/tests.rs deleted file mode 100644 index 7aaa7089..00000000 --- a/rust/lithe-core/src/mcp/tests.rs +++ /dev/null @@ -1,1534 +0,0 @@ -//! Regression tests for MCP parsing, format preservation and ownership rules. - -use super::{parse_configuration, plan_write}; -use crate::protocol::ErrorCode; -use serde_json::{json, Value}; - -fn parse(source: &str, scope: &str, content: &str, project: Option<&str>) -> Value { - let mut request = json!({ - "source": source, - "scope": scope, - "content": content, - }); - if let Some(project) = project { - request["projectPath"] = json!(project); - } - parse_configuration(request).expect("configuration should parse") -} - -fn server_hash(parsed: &Value, name: &str) -> String { - parsed["servers"] - .as_array() - .expect("servers array") - .iter() - .find(|server| server["name"] == name) - .and_then(|server| server["definitionHash"].as_str()) - .expect("definition hash") - .to_string() -} - -fn source_hash(parsed: &Value, name: &str) -> String { - parsed["servers"] - .as_array() - .expect("servers array") - .iter() - .find(|server| server["name"] == name) - .and_then(|server| server["sourceHash"].as_str()) - .expect("source hash") - .to_string() -} - -fn owned_entry(parsed: &Value, name: &str) -> Value { - json!({ - "name": name, - "definitionHash": server_hash(parsed, name), - "sourceHash": source_hash(parsed, name), - }) -} - -#[test] -fn removed_codex_filters_restore_without_credentials_or_allowlist_widening() { - for allowed in ["[]", "['read', 'write']"] { - let content = format!("model = 'keep'\n[mcp_servers.alpha]\ncommand = 'echo'\nenabled_tools = {allowed}\ndisabled_tools = ['external', 'write']\nenv = {{ TOKEN = 'secret' }}\n"); - let parsed = parse("codex", "global", &content, None); - let removed = plan(json!({"source":"codex","scope":"global","content":content, - "operation":"remove","name":"alpha","owned":[owned_entry(&parsed,"alpha")]})); - assert_eq!(removed["action"], "removed"); - let filters = &removed["toolFilters"]; - assert_eq!(filters["disabledTools"], json!(["external", "write"])); - assert!(!filters.to_string().contains("secret")); - let restored = plan( - json!({"source":"codex","scope":"global","content":removed["content"], - "operation":"set","server":parsed["servers"][0],"owned":[],"toolFilters":filters}), - ); - let next: toml::Value = toml::from_str(restored["content"].as_str().unwrap()).unwrap(); - let original: toml::Value = toml::from_str(&content).unwrap(); - assert_eq!( - next["mcp_servers"]["alpha"], - original["mcp_servers"]["alpha"] - ); - assert_eq!(next["model"].as_str(), Some("keep")); - let reparsed = parse( - "codex", - "global", - restored["content"].as_str().unwrap(), - None, - ); - assert_eq!(restored["sourceHash"], source_hash(&reparsed, "alpha")); - } -} - -#[test] -fn retained_codex_filters_never_replace_live_native_policy() { - let content = "[mcp_servers.alpha]\ncommand = 'echo'\ndisabled_tools = ['external']\nenabled_tools = []\n"; - let parsed = parse("codex", "global", content, None); - let restored = plan(json!({"source":"codex","scope":"global","content":content, - "operation":"set","server":parsed["servers"][0],"owned":[owned_entry(&parsed,"alpha")], - "toolFilters":{"disabledTools":[],"enabledTools":["write"]}})); - assert_eq!(restored["content"], content); - assert_eq!(restored["changed"], false); -} - -#[test] -fn malformed_native_tool_lists_cannot_be_deleted_and_forgotten() { - let content = "[mcp_servers.alpha]\ncommand = 'echo'\ndisabled_tools = ['read', 1]\n"; - let parsed = parse("codex", "global", content, None); - let result = plan_write(json!({"source":"codex","scope":"global","content":content, - "operation":"remove","name":"alpha","owned":[owned_entry(&parsed,"alpha")]})); - assert!(matches!( - result.unwrap_err().code, - ErrorCode::InvalidRequest - )); -} - -fn plan(request: Value) -> Value { - plan_write(request).expect("write plan should succeed") -} - -#[test] -fn parses_claude_stdio_and_http_servers() { - let content = r#"{ - "mcpServers": { - "dbx": { - "command": "/Users/test/.dbx/bin/dbx-mcp", - "args": ["--stdio"], - "env": {"DBX_TOKEN": "secret"} - }, - "notion": { - "type": "http", - "url": "https://example.test/mcp", - "headers": {"Authorization": "Bearer secret"} - } - } - }"#; - let parsed = parse("claude", "global", content, None); - let servers = parsed["servers"].as_array().unwrap(); - assert_eq!(servers.len(), 2); - let dbx = servers - .iter() - .find(|server| server["name"] == "dbx") - .unwrap(); - assert_eq!(dbx["transport"], "stdio"); - assert_eq!(dbx["env"][0]["key"], "DBX_TOKEN"); - let notion = servers - .iter() - .find(|server| server["name"] == "notion") - .unwrap(); - assert_eq!(notion["transport"], "http"); - assert_eq!(notion["headers"][0]["key"], "Authorization"); -} - -#[test] -fn parses_only_the_requested_claude_project() { - let content = r#"{ - "mcpServers": {"global": {"command": "global-cmd"}}, - "projects": { - "/work/a": {"mcpServers": {"a-server": {"command": "a-cmd"}}}, - "/work/b": {"mcpServers": {"b-server": {"command": "b-cmd"}}} - } - }"#; - let parsed = parse("claude", "project", content, Some("/work/a")); - let servers = parsed["servers"].as_array().unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0]["name"], "a-server"); -} - -#[test] -fn parses_codex_tables_and_ignores_unrelated_keys() { - let content = r#" -model = "gpt" -[model_providers.custom] -base_url = "https://example.test" - -[mcp_servers.node_repl] -command = "npx" -args = ["-y", "node-repl"] -startup_timeout_sec = 20 - -[mcp_servers.node_repl.env] -NODE_OPTIONS = "--max-old-space-size=512" - -[mcp_servers.remote] -url = "https://example.test/mcp" -bearer_token_env_var = "REMOTE_TOKEN" -http_headers = { X-Client = "lithe" } -"#; - let parsed = parse("codex", "global", content, None); - let servers = parsed["servers"].as_array().unwrap(); - assert_eq!(servers.len(), 2); - let remote = servers - .iter() - .find(|server| server["name"] == "remote") - .unwrap(); - assert_eq!(remote["transport"], "http"); - assert_eq!(remote["headers"][0]["key"], "X-Client"); -} - -#[test] -fn rejects_invalid_configuration_without_echoing_content() { - let error = parse_configuration(json!({ - "source": "claude", - "scope": "global", - "content": "{\"mcpServers\": {broken", - })) - .unwrap_err(); - assert!(!error.message.contains("broken")); -} - -#[test] -fn inserts_json_entry_and_preserves_unrelated_formatting() { - let content = "{\n \"theme\": \"dark\",\n \"mcpServers\": {\n \"dbx\": {\n \"command\": \"dbx-mcp\"\n }\n }\n}\n"; - let server = json!({ - "name": "lithe", - "transport": "stdio", - "command": "/Applications/Lithe.app/Contents/Helpers/lithe-mcp", - "args": ["--connection", "/tmp/connection.json"], - "env": [], - "headers": [] - }); - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "set", - "server": server, - "owned": [] - })); - let next = result["content"].as_str().unwrap(); - assert_eq!(result["action"], "inserted"); - assert!(next.contains("\"theme\": \"dark\"")); - assert!(next.contains("\"dbx\"")); - let parsed: Value = serde_json::from_str(next).unwrap(); - assert_eq!( - parsed["mcpServers"]["lithe"]["command"], - "/Applications/Lithe.app/Contents/Helpers/lithe-mcp" - ); -} - -#[test] -fn creates_claude_project_container_when_missing() { - let result = plan(json!({ - "source": "claude", - "scope": "project", - "projectPath": "/work/a", - "content": "{\n \"theme\": \"dark\"\n}", - "operation": "set", - "server": { - "name": "lithe", - "transport": "stdio", - "command": "lithe-mcp", - "args": [], - "env": [], - "headers": [] - }, - "owned": [] - })); - let next = result["content"].as_str().unwrap(); - let parsed: Value = serde_json::from_str(next).unwrap(); - assert_eq!(parsed["theme"], "dark"); - assert_eq!( - parsed["projects"]["/work/a"]["mcpServers"]["lithe"]["command"], - "lithe-mcp" - ); -} - -#[test] -fn removes_only_the_requested_json_entry() { - let content = "{\n \"mcpServers\": {\n \"a\": {\"command\": \"a\"},\n \"b\": {\"command\": \"b\"}\n }\n}"; - let parsed = parse("claude", "global", content, None); - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "remove", - "name": "a", - "owned": [owned_entry(&parsed, "a")] - })); - let next = result["content"].as_str().unwrap(); - let parsed: Value = serde_json::from_str(next).unwrap(); - assert!(parsed["mcpServers"].get("a").is_none()); - assert_eq!(parsed["mcpServers"]["b"]["command"], "b"); -} - -fn remove_entry(content: &str, name: &str) -> String { - let parsed = parse("claude", "global", content, None); - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "remove", - "name": name, - "owned": [owned_entry(&parsed, &name)] - })); - let next = result["content"].as_str().unwrap().to_string(); - // Every plan must stay valid for Claude Code to read. - serde_json::from_str::(&next).expect("removal must keep valid JSON"); - next -} - -#[test] -fn removing_the_last_json_entry_also_removes_its_comma() { - let content = "{\n \"mcpServers\": {\n \"a\": {\"command\": \"a\"},\n \"b\": {\"command\": \"b\"}\n }\n}"; - let next = remove_entry(content, "b"); - let parsed: Value = serde_json::from_str(&next).unwrap(); - assert_eq!(parsed["mcpServers"]["a"]["command"], "a"); - assert!(parsed["mcpServers"].get("b").is_none()); - assert!(!next.contains(",\n }")); -} - -#[test] -fn removing_a_middle_json_entry_keeps_neighbours_valid() { - let content = "{\n \"mcpServers\": {\n \"a\": {\"command\": \"a\"},\n \"b\": {\"command\": \"b\"},\n \"c\": {\"command\": \"c\"}\n }\n}"; - let next = remove_entry(content, "b"); - let parsed: Value = serde_json::from_str(&next).unwrap(); - assert_eq!(parsed["mcpServers"]["a"]["command"], "a"); - assert_eq!(parsed["mcpServers"]["c"]["command"], "c"); - assert!(parsed["mcpServers"].get("b").is_none()); -} - -#[test] -fn removing_a_shared_line_entry_keeps_the_previous_entry() { - // `a` and `b` share a line while `c` starts the next one. Deleting from - // the line start would silently drop `a`, which is still valid JSON and - // therefore invisible to output validation. - let content = "{\n \"mcpServers\": {\n \"a\": {\"command\": \"a\"}, \"b\": {\"command\": \"b\"},\n \"c\": {\"command\": \"c\"}\n }\n}"; - let next = remove_entry(content, "b"); - let parsed: Value = serde_json::from_str(&next).unwrap(); - assert_eq!(parsed["mcpServers"]["a"]["command"], "a"); - assert_eq!(parsed["mcpServers"]["c"]["command"], "c"); - assert!(parsed["mcpServers"].get("b").is_none()); - assert_eq!(parsed["mcpServers"].as_object().unwrap().len(), 2); -} - -#[test] -fn removing_entries_handles_single_line_and_mixed_layouts() { - let single_line = - "{\"mcpServers\": {\"a\": {\"command\": \"a\"}, \"b\": {\"command\": \"b\"}}}"; - let parsed: Value = serde_json::from_str(&remove_entry(single_line, "b")).unwrap(); - assert_eq!(parsed["mcpServers"]["a"]["command"], "a"); - assert!(parsed["mcpServers"].get("b").is_none()); - - let mixed = "{\n \"mcpServers\": {\n \"a\": {\"command\": \"a\"}, \"b\": {\"command\": \"b\"}\n }\n}"; - let parsed: Value = serde_json::from_str(&remove_entry(mixed, "b")).unwrap(); - assert_eq!(parsed["mcpServers"]["a"]["command"], "a"); - assert!(parsed["mcpServers"].get("b").is_none()); - - let multiline_value = "{\n \"mcpServers\": {\n \"a\": {\"command\": \"a\"},\n \"b\": {\n \"command\": \"b\"\n }\n }\n}"; - let parsed: Value = serde_json::from_str(&remove_entry(multiline_value, "b")).unwrap(); - assert_eq!(parsed["mcpServers"]["a"]["command"], "a"); - assert!(parsed["mcpServers"].get("b").is_none()); -} - -#[test] -fn removing_a_multiline_entry_keeps_the_next_entry_on_its_closing_line() { - // `b` spans several lines and `c` starts right after b's closing brace on - // the same line. Trimming to a line boundary would cut into `c`. - let content = "{\n \"mcpServers\": {\n \"a\": {\"command\": \"a\"},\n \"b\": {\n \"command\": \"b\"\n }, \"c\": {\"command\": \"c\"}\n }\n}"; - let next = remove_entry(content, "b"); - let parsed: Value = serde_json::from_str(&next).unwrap(); - assert_eq!(parsed["mcpServers"]["a"]["command"], "a"); - assert_eq!(parsed["mcpServers"]["c"]["command"], "c"); - assert!(parsed["mcpServers"].get("b").is_none()); - assert_eq!(parsed["mcpServers"].as_object().unwrap().len(), 2); -} - -#[test] -fn toml_edits_preserve_comments_and_unrelated_tables() { - let content = - "# keep this comment\nmodel = \"gpt\"\n\n[mcp_servers.dbx]\ncommand = \"dbx-mcp\"\n"; - let result = plan(json!({ - "source": "codex", - "scope": "global", - "content": content, - "operation": "set", - "server": { - "name": "lithe", - "transport": "stdio", - "command": "/Applications/Lithe.app/Contents/Helpers/lithe-mcp", - "args": ["--connection", "/tmp/connection.json"], - "env": [], - "headers": [] - }, - "owned": [] - })); - let next = result["content"].as_str().unwrap(); - assert!(next.contains("# keep this comment")); - assert!(next.contains("[mcp_servers.dbx]")); - assert!(next.contains("[mcp_servers.lithe]")); - let parsed: toml::Value = toml::from_str(next).unwrap(); - assert_eq!( - parsed["mcp_servers"]["lithe"]["command"].as_str(), - Some("/Applications/Lithe.app/Contents/Helpers/lithe-mcp") - ); -} - -/// Plans an update of an existing Codex entry, using the ledger hash of the -/// complete parsed entry as the host would. -fn update_codex(content: &str, server: Value) -> String { - let name = server["name"].as_str().expect("server name").to_string(); - let parsed = parse("codex", "global", content, None); - let result = plan(json!({ - "source": "codex", - "scope": "global", - "content": content, - "operation": "set", - "server": server, - "owned": [owned_entry(&parsed, &name)] - })); - result["content"].as_str().unwrap().to_string() -} - -#[test] -fn toml_http_updates_keep_codex_specific_fields() { - let content = r#" -[mcp_servers.remote] -url = "https://old.example/mcp" -bearer_token_env_var = "REMOTE_TOKEN" -startup_timeout_sec = 20 -http_headers = { X-Client = "lithe" } -"#; - let next = update_codex( - content, - json!({ - "name": "remote", - "transport": "http", - "args": [], - "env": [], - "url": "https://new.example/mcp", - "headers": [{"key": "X-Client", "value": "lithe"}] - }), - ); - assert!(next.contains("bearer_token_env_var = \"REMOTE_TOKEN\"")); - assert!(next.contains("startup_timeout_sec = 20")); - assert!(next.contains("https://new.example/mcp")); - let parsed: toml::Value = toml::from_str(&next).unwrap(); - assert_eq!( - parsed["mcp_servers"]["remote"]["bearer_token_env_var"].as_str(), - Some("REMOTE_TOKEN") - ); -} - -#[test] -fn toml_stdio_updates_keep_unknown_keys_comments_and_unmodelled_entries() { - let content = r#" -[mcp_servers.local] -command = "old" -# Codex throttles startup with this. -startup_timeout_sec = 30 -API_ENV = "LOCAL_TOKEN" - -[mcp_servers.local.env] -TOKEN = "old" -COUNT = 3 -"#; - let next = update_codex( - content, - json!({ - "name": "local", - "transport": "stdio", - "command": "new", - "args": ["--flag"], - "env": [{"key": "TOKEN", "value": "new"}], - "headers": [] - }), - ); - assert!(next.contains("command = \"new\"")); - assert!(next.contains("# Codex throttles startup with this.")); - assert!(next.contains("API_ENV = \"LOCAL_TOKEN\"")); - let parsed: toml::Value = toml::from_str(&next).unwrap(); - let table = &parsed["mcp_servers"]["local"]; - assert_eq!(table["startup_timeout_sec"].as_integer(), Some(30)); - assert_eq!(table["env"]["TOKEN"].as_str(), Some("new")); - // Non-string entries are not part of the normalized model and survive. - assert_eq!(table["env"]["COUNT"].as_integer(), Some(3)); -} - -#[test] -fn toml_clearing_modeled_entries_keeps_non_string_env_values() { - let content = r#" -[mcp_servers.local] -command = "old" - -[mcp_servers.local.env] -TOKEN = "x" -COUNT = 3 -"#; - let next = update_codex( - content, - json!({ - "name": "local", - "transport": "stdio", - "command": "new", - "args": [], - "env": [], - "headers": [] - }), - ); - let parsed: toml::Value = toml::from_str(&next).unwrap(); - let env = parsed["mcp_servers"]["local"]["env"].as_table().unwrap(); - // The string entry the UI showed is removed; the value Lithe cannot - // represent is not silently deleted along with it. - assert!(!env.contains_key("TOKEN")); - assert_eq!(env["COUNT"].as_integer(), Some(3)); -} - -#[test] -fn toml_clearing_modeled_entries_keeps_non_string_inline_env_values() { - let content = r#" -[mcp_servers.local] -command = "old" -env = { TOKEN = "x", COUNT = 3 } -"#; - let next = update_codex( - content, - json!({ - "name": "local", - "transport": "stdio", - "command": "new", - "args": [], - "env": [], - "headers": [] - }), - ); - let parsed: toml::Value = toml::from_str(&next).unwrap(); - let env = parsed["mcp_servers"]["local"]["env"].as_table().unwrap(); - assert!(!env.contains_key("TOKEN")); - assert_eq!(env["COUNT"].as_integer(), Some(3)); -} - -#[test] -fn toml_updates_keep_comments_on_modeled_keys_and_values() { - let content = r#" -[mcp_servers.local] -# Starts the local helper. -command = "old" # trailing command note -args = ["--old"] # args note - -[mcp_servers.local.env] -# Token note -TOKEN = "old" # value note -"#; - let next = update_codex( - content, - json!({ - "name": "local", - "transport": "stdio", - "command": "new", - "args": ["--new"], - "env": [{"key": "TOKEN", "value": "new"}], - "headers": [] - }), - ); - for comment in [ - "# Starts the local helper.", - "# trailing command note", - "# args note", - "# Token note", - "# value note", - ] { - assert!(next.contains(comment), "missing {comment} in {next}"); - } - let parsed: toml::Value = toml::from_str(&next).unwrap(); - let table = &parsed["mcp_servers"]["local"]; - assert_eq!(table["command"].as_str(), Some("new")); - assert_eq!(table["env"]["TOKEN"].as_str(), Some("new")); -} - -#[test] -fn transport_identity_ignores_entries_the_transport_does_not_write() { - let http_with_env = parse( - "claude", - "global", - r#"{"mcpServers": {"remote": {"type": "http", "url": "https://example.test/mcp", "env": {"STRAY": "1"}}}}"#, - None, - ); - let http_plain = parse( - "claude", - "global", - r#"{"mcpServers": {"remote": {"type": "http", "url": "https://example.test/mcp"}}}"#, - None, - ); - assert_eq!( - server_hash(&http_with_env, "remote"), - server_hash(&http_plain, "remote") - ); - - let stdio_with_headers = parse( - "claude", - "global", - r#"{"mcpServers": {"local": {"command": "/bin/echo", "headers": {"STRAY": "1"}}}}"#, - None, - ); - let stdio_plain = parse( - "claude", - "global", - r#"{"mcpServers": {"local": {"command": "/bin/echo"}}}"#, - None, - ); - assert_eq!( - server_hash(&stdio_with_headers, "local"), - server_hash(&stdio_plain, "local") - ); - - let http_with_command = parse( - "claude", - "global", - r#"{"mcpServers": {"remote": {"type": "http", "url": "https://example.test/mcp", "command": "/bin/echo", "args": ["--stray"]}}}"#, - None, - ); - assert_eq!( - server_hash(&http_with_command, "remote"), - server_hash(&http_plain, "remote") - ); - - let stdio_with_url = parse( - "claude", - "global", - r#"{"mcpServers": {"local": {"type": "stdio", "command": "/bin/echo", "url": "https://example.test/stray"}}}"#, - None, - ); - assert_eq!( - server_hash(&stdio_with_url, "local"), - server_hash(&stdio_plain, "local") - ); - - // The write path drops env, command and args from an HTTP entry, so the - // planned hash must equal the hash of the file it writes. - let result = plan(json!({ - "source": "codex", - "scope": "global", - "content": "", - "operation": "set", - "server": { - "name": "remote", - "transport": "http", - "command": "stray", - "args": ["--stray"], - "env": [{"key": "STRAY", "value": "1"}], - "url": "https://example.test/mcp", - "headers": [] - }, - "owned": [] - })); - let written = parse("codex", "global", result["content"].as_str().unwrap(), None); - assert_eq!( - result["definitionHash"].as_str().unwrap(), - server_hash(&written, "remote") - ); -} - -#[test] -fn refuses_to_edit_a_json_object_with_duplicate_keys() { - // serde_json keeps the last duplicate while the offset editor finds the - // first; accepting the edit would update a definition the Agent ignores. - let content = r#"{"mcpServers": {"a": {"command": "first"}, "a": {"command": "second"}}}"#; - let parsed = parse("claude", "global", content, None); - let error = plan_write(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "remove", - "name": "a", - "owned": [owned_entry(&parsed, "a")] - })) - .unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - assert!(error.message.contains("duplicate")); -} - -#[test] -fn codex_updates_entries_in_an_inline_mcp_servers_table() { - let content = r#"mcp_servers = { local = { command = "old" } }"#; - let parsed = parse("codex", "global", content, None); - let result = plan(json!({ - "source": "codex", - "scope": "global", - "content": content, - "operation": "set", - "server": { - "name": "local", - "transport": "stdio", - "command": "new", - "args": [], - "env": [], - "headers": [] - }, - "owned": [owned_entry(&parsed, "local")] - })); - assert_eq!(result["action"], "updated"); - let next = result["content"].as_str().unwrap(); - let parsed: toml::Value = toml::from_str(next).unwrap(); - assert_eq!( - parsed["mcp_servers"]["local"]["command"].as_str(), - Some("new") - ); -} - -#[test] -fn toml_updates_leave_an_unchanged_args_array_untouched() { - let content = r#" -[mcp_servers.local] -command = "old" -args = [ - # connection file - "--connection", - "/tmp/c.json", -] -"#; - let next = update_codex( - content, - json!({ - "name": "local", - "transport": "stdio", - "command": "new", - "args": ["--connection", "/tmp/c.json"], - "env": [], - "headers": [] - }), - ); - assert!(next.contains("command = \"new\"")); - // Editing another field must not rebuild an identical array, which would - // drop comments between its elements. - assert!(next.contains("# connection file")); -} - -#[test] -fn codex_removes_entries_from_an_inline_mcp_servers_table() { - let content = r#"mcp_servers = { lithe = { command = "old" }, other = { command = "keep" } }"#; - let parsed = parse("codex", "global", content, None); - let result = plan(json!({ - "source": "codex", - "scope": "global", - "content": content, - "operation": "remove", - "name": "lithe", - "owned": [owned_entry(&parsed, "lithe")] - })); - assert_eq!(result["action"], "removed"); - assert_eq!(result["changed"], true); - let next = result["content"].as_str().unwrap(); - let parsed: toml::Value = toml::from_str(next).unwrap(); - assert!(parsed["mcp_servers"].get("lithe").is_none()); - assert_eq!( - parsed["mcp_servers"]["other"]["command"].as_str(), - Some("keep") - ); -} - -#[test] -fn rejects_duplicate_entry_names_that_a_map_cannot_hold() { - let error = plan_write(json!({ - "source": "claude", - "scope": "global", - "content": "{\"mcpServers\": {}}", - "operation": "set", - "server": { - "name": "duplicates", - "transport": "stdio", - "command": "/bin/echo", - "args": [], - "env": [ - {"key": "TOKEN", "value": "first"}, - {"key": "TOKEN", "value": "second"} - ], - "headers": [] - }, - "owned": [] - })) - .unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); -} - -#[test] -fn toml_transport_change_removes_only_the_keys_lithe_models() { - let content = r#" -[mcp_servers.switch] -command = "old" -args = ["--stdio"] -env = { TOKEN = "x" } -startup_timeout_sec = 5 -"#; - let next = update_codex( - content, - json!({ - "name": "switch", - "transport": "http", - "args": [], - "env": [], - "url": "https://example.test/mcp", - "headers": [] - }), - ); - let parsed: toml::Value = toml::from_str(&next).unwrap(); - let table = parsed["mcp_servers"]["switch"].as_table().unwrap(); - assert!(!table.contains_key("command")); - assert!(!table.contains_key("args")); - assert!(!table.contains_key("env")); - assert_eq!(table["startup_timeout_sec"].as_integer(), Some(5)); - assert_eq!(table["url"].as_str(), Some("https://example.test/mcp")); -} - -#[test] -fn codex_rename_preserves_unmodelled_fields() { - let content = r#" -# Remote MCP server. -[mcp_servers.remote] -url = "https://example.test/mcp" -bearer_token_env_var = "REMOTE_TOKEN" -startup_timeout_sec = 20 -"#; - let parsed = parse("codex", "global", content, None); - let result = plan(json!({ - "source": "codex", - "scope": "global", - "content": content, - "operation": "set", - "previousName": "remote", - "server": { - "name": "renamed", - "transport": "http", - "args": [], - "env": [], - "url": "https://example.test/mcp", - "headers": [] - }, - "owned": [owned_entry(&parsed, "remote")] - })); - let next = result["content"].as_str().unwrap(); - let parsed: toml::Value = toml::from_str(next).unwrap(); - // The Codex edit moves the table to its new key, carrying unknown fields - // and comments with it. - assert!(parsed["mcp_servers"].get("remote").is_none()); - assert!(next.contains("# Remote MCP server.")); - assert_eq!( - parsed["mcp_servers"]["renamed"]["bearer_token_env_var"].as_str(), - Some("REMOTE_TOKEN") - ); - assert_eq!( - parsed["mcp_servers"]["renamed"]["startup_timeout_sec"].as_integer(), - Some(20) - ); -} - -#[test] -fn claude_rename_preserves_unmodelled_fields() { - let content = r#"{"mcpServers": {"remote": {"type": "http", "url": "https://example.test/mcp", "headers": {"X-Client": "lithe"}, "alwaysAllow": ["search"]}}}"#; - let parsed = parse("claude", "global", content, None); - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "set", - "previousName": "remote", - "server": { - "name": "renamed", - "transport": "http", - "args": [], - "env": [], - "url": "https://example.test/mcp", - "headers": [{"key": "X-Client", "value": "lithe"}] - }, - "owned": [owned_entry(&parsed, "remote")] - })); - let next = result["content"].as_str().unwrap(); - let parsed: Value = serde_json::from_str(next).unwrap(); - assert_eq!(parsed["mcpServers"]["renamed"]["alwaysAllow"][0], "search"); - assert_eq!( - parsed["mcpServers"]["renamed"]["headers"]["X-Client"], - "lithe" - ); - // Claude's edit leaves the old name in place; the host removes it next. - assert!(parsed["mcpServers"].get("remote").is_some()); -} - -#[test] -fn claude_clearing_modeled_entries_keeps_non_string_env_values() { - let content = - r#"{"mcpServers": {"local": {"command": "old", "env": {"TOKEN": "x", "COUNT": 3}}}}"#; - let parsed = parse("claude", "global", content, None); - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "set", - "server": { - "name": "local", - "transport": "stdio", - "command": "new", - "args": [], - "env": [], - "headers": [] - }, - "owned": [owned_entry(&parsed, "local")] - })); - let next = result["content"].as_str().unwrap(); - let parsed: Value = serde_json::from_str(next).unwrap(); - let env = &parsed["mcpServers"]["local"]["env"]; - assert!(env.get("TOKEN").is_none()); - assert_eq!(env["COUNT"], 3); -} - -#[test] -fn write_hash_matches_the_reparsed_definition_for_unsorted_entries() { - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": "{\"mcpServers\": {}}", - "operation": "set", - "server": { - "name": "ordered", - "transport": "stdio", - "command": "/bin/echo", - "args": [], - "env": [ - {"key": "TOKEN2", "value": "b"}, - {"key": "TOKEN10", "value": "a"} - ], - "headers": [] - }, - "owned": [] - })); - let hash = result["definitionHash"].as_str().unwrap(); - let reparsed = parse( - "claude", - "global", - result["content"].as_str().unwrap(), - None, - ); - // The host stores this hash in the ledger; discovery parses the written - // file and sorts entries, so both paths must agree. - assert_eq!(server_hash(&reparsed, "ordered"), hash); - assert_eq!(source_hash(&reparsed, "ordered"), result["sourceHash"]); -} - -#[test] -fn refuses_to_overwrite_a_user_owned_entry() { - let content = "{\"mcpServers\": {\"lithe\": {\"command\": \"user-command\"}}}"; - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "set", - "server": { - "name": "lithe", - "transport": "stdio", - "command": "lithe-mcp", - "args": [], - "env": [], - "headers": [] - }, - "owned": [] - })); - assert_eq!(result["conflict"], "ownedByUser"); - assert_eq!(result["changed"], false); - assert_eq!(result["content"], content); -} - -#[test] -fn explicit_expected_hash_allows_replacing_an_unowned_entry() { - let content = "{\"mcpServers\": {\"server\": {\"command\": \"user-command\"}}}"; - let parsed = parse("claude", "global", content, None); - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "set", - "server": { - "name": "server", - "transport": "stdio", - "command": "edited-by-user-in-lithe", - "args": [], - "env": [], - "headers": [] - }, - "expectedHash": source_hash(&parsed, "server"), - "owned": [] - })); - assert_eq!(result["action"], "updated"); - assert_eq!(result["conflict"], Value::Null); - assert!(result["content"] - .as_str() - .unwrap() - .contains("edited-by-user-in-lithe")); -} - -#[test] -fn set_detects_external_edits_when_the_ledger_hash_is_stale() { - let content = "{\"mcpServers\": {\"lithe\": {\"command\": \"edited-outside\"}}}"; - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "set", - "server": { - "name": "lithe", - "transport": "stdio", - "command": "lithe-mcp", - "args": [], - "env": [], - "headers": [] - }, - "owned": [{"name": "lithe", "definitionHash": "stale-hash"}] - })); - assert_eq!(result["conflict"], "externallyModified"); - assert_eq!(result["changed"], false); -} - -#[test] -fn updates_an_entry_lithe_wrote() { - let content = "{\"mcpServers\": {\"lithe\": {\"command\": \"old-command\"}}}"; - let parsed = parse("claude", "global", content, None); - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "set", - "server": { - "name": "lithe", - "transport": "stdio", - "command": "new-command", - "args": [], - "env": [], - "headers": [] - }, - "owned": [owned_entry(&parsed, "lithe")] - })); - assert_eq!(result["action"], "updated"); - assert!(result["content"].as_str().unwrap().contains("new-command")); -} - -#[test] -fn removal_detects_external_edits() { - let content = "{\"mcpServers\": {\"lithe\": {\"command\": \"edited-by-user\"}}}"; - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "remove", - "name": "lithe", - "owned": [{"name": "lithe", "definitionHash": "stale-hash"}] - })); - assert_eq!(result["conflict"], "externallyModified"); - assert_eq!(result["changed"], false); -} - -#[test] -fn identical_definition_is_a_no_op() { - let content = "{\"mcpServers\": {\"lithe\": {\"command\": \"lithe-mcp\"}}}"; - let parsed = parse("claude", "global", content, None); - let result = plan(json!({ - "source": "claude", - "scope": "global", - "content": content, - "operation": "set", - "server": parsed["servers"][0], - "owned": [owned_entry(&parsed, "lithe")] - })); - assert_eq!(result["action"], "unchanged"); - assert_eq!(result["changed"], false); -} - -/// Unknown authentication, timeout and non-string values must all remain part -/// of the deletion/edit guard, even though the library cannot edit them. -fn full_entry_content(source: &str, scope: &str) -> String { - match source { - "claude" => { - let entry = json!({ - "command": "old", - "bearer_token_env_var": "TOKEN_A", - "tool_timeout_sec": 20, - "auth": {"enabled": true, "providers": ["one", "two"]}, - "env": {"COUNT": 3}, - }); - if scope == "project" { - json!({"projects": {"/work/project": {"mcpServers": {"lithe": entry}}}}).to_string() - } else { - json!({"mcpServers": {"lithe": entry}}).to_string() - } - } - "codex" => concat!( - "[mcp_servers.lithe]\ncommand = \"old\"\n", - "bearer_token_env_var = \"TOKEN_A\"\ntool_timeout_sec = 20\n", - "auth = { enabled = true, providers = [\"one\", \"two\"] }\n", - "env = { COUNT = 3 }\n", - ) - .to_string(), - _ => unreachable!(), - } -} - -#[test] -fn unknown_entry_mutations_block_automatic_edit_delete_and_project_cleanup() { - for source in ["claude", "codex"] { - for scope in ["global", "project"] { - let content = full_entry_content(source, scope); - let original = parse(source, scope, &content, Some("/work/project")); - let owned = owned_entry(&original, "lithe"); - for (before, after) in [ - ("TOKEN_A", "TOKEN_B"), - ("20", "21"), - ("true", "false"), - ("two", "three"), - ("3", "4"), - ] { - let changed = content.replace(before, after); - let current = parse(source, scope, &changed, Some("/work/project")); - assert_eq!( - server_hash(&original, "lithe"), - server_hash(¤t, "lithe") - ); - assert_ne!( - source_hash(&original, "lithe"), - source_hash(¤t, "lithe") - ); - for operation in ["set", "remove"] { - let mut desired = original["servers"][0].clone(); - desired["command"] = json!("updated"); - let result = plan(json!({ - "source": source, - "scope": scope, - "projectPath": if scope == "project" { json!("/work/project") } else { Value::Null }, - "content": changed, - "operation": operation, - "name": "lithe", - "server": desired, - "owned": [owned], - })); - assert_eq!( - result["conflict"], "externallyModified", - "{source} {scope} {operation} {before}" - ); - assert_eq!(result["content"], changed); - assert_eq!(result["changed"], false); - } - } - } - } -} - -#[test] -fn source_hash_ignores_formatting_comments_and_object_key_order() { - for (source, first, second) in [ - ( - "claude", - r#"{"mcpServers":{"lithe":{"command":"old","auth":{"a":1,"b":[true,"x"]}}}}"#, - r#"{ "mcpServers": { "lithe": { "auth": { "b": [true, "x"], "a": 1 }, "command": "old" } } }"#, - ), - ( - "codex", - "[mcp_servers.lithe]\ncommand = 'old'\nauth = { a = 1, b = [true, 'x'] }\n", - "# comment\n[mcp_servers.lithe]\nauth = { b = [true, \"x\"], a = 1 } # inline\ncommand = \"old\"\n", - ), - ] { - let first = parse(source, "global", first, None); - let second = parse(source, "global", second, None); - assert_eq!(source_hash(&first, "lithe"), source_hash(&second, "lithe")); - assert_eq!(server_hash(&first, "lithe"), server_hash(&second, "lithe")); - } -} - -#[test] -fn source_hash_distinguishes_unknown_toml_value_types() { - let hash = |value: &str| { - let content = format!("[mcp_servers.lithe]\ncommand = 'old'\nunknown = {value}\n"); - source_hash(&parse("codex", "global", &content, None), "lithe") - }; - assert_ne!(hash("1"), hash("1.0")); - assert_ne!(hash("1979-05-27"), hash("'1979-05-27'")); - assert_ne!(hash("true"), hash("'true'")); -} - -#[test] -fn legacy_ownership_without_source_hash_refuses_automatic_replace_and_remove() { - for source in ["claude", "codex"] { - let content = full_entry_content(source, "global"); - let parsed = parse(source, "global", &content, None); - let mut desired = parsed["servers"][0].clone(); - desired["command"] = json!("updated"); - for operation in ["set", "remove"] { - let result = plan(json!({ - "source": source, - "scope": "global", - "content": content, - "operation": operation, - "name": "lithe", - "server": desired, - "owned": [{"name": "lithe", "definitionHash": server_hash(&parsed, "lithe")}], - })); - assert_eq!(result["conflict"], "externallyModified"); - assert_eq!(result["content"], content); - } - } -} - -#[test] -fn stale_expected_hash_refuses_latest_ledger_and_unchanged_desired_definition() { - for source in ["claude", "codex"] { - let displayed = full_entry_content(source, "global"); - let original = parse(source, "global", &displayed, None); - let changed = displayed.replace("TOKEN_A", "TOKEN_B"); - let latest = parse(source, "global", &changed, None); - for operation in ["set", "remove"] { - for desired_command in ["old", "updated"] { - let mut desired = latest["servers"][0].clone(); - desired["command"] = json!(desired_command); - let result = plan(json!({ - "source": source, - "scope": "global", - "content": changed, - "operation": operation, - "name": "lithe", - "server": desired, - "expectedHash": source_hash(&original, "lithe"), - "owned": [owned_entry(&latest, "lithe")], - })); - assert_eq!(result["conflict"], "externallyModified"); - assert_eq!(result["action"], "conflict"); - assert_eq!(result["content"], changed); - } - } - } -} - -#[test] -fn expected_hash_requires_the_original_entry_to_still_exist() { - for source in ["claude", "codex"] { - let content = full_entry_content(source, "global"); - let parsed = parse(source, "global", &content, None); - for operation in ["set", "remove"] { - for previous in [Value::Null, json!("lithe")] { - let mut desired = parsed["servers"][0].clone(); - if !previous.is_null() { - desired["name"] = json!("renamed"); - } - let result = plan(json!({ - "source": source, - "scope": "global", - "content": "", - "operation": operation, - "previousName": previous, - "name": "lithe", - "server": desired, - "expectedHash": source_hash(&parsed, "lithe"), - "owned": [owned_entry(&parsed, "lithe")], - })); - assert_eq!(result["conflict"], "externallyModified"); - assert_eq!(result["content"], ""); - } - } - } -} - -#[test] -fn exact_expected_source_hash_authorizes_unowned_edit_and_remove() { - for source in ["claude", "codex"] { - let content = full_entry_content(source, "global"); - let parsed = parse(source, "global", &content, None); - for operation in ["set", "remove"] { - let mut desired = parsed["servers"][0].clone(); - desired["command"] = json!("updated"); - let result = plan(json!({ - "source": source, - "scope": "global", - "content": content, - "operation": operation, - "name": "lithe", - "server": desired, - "expectedHash": source_hash(&parsed, "lithe"), - "owned": [], - })); - assert_eq!(result["conflict"], Value::Null); - assert_eq!(result["changed"], true); - let written = parse(source, "global", result["content"].as_str().unwrap(), None); - if operation == "set" { - assert_eq!(result["sourceHash"], source_hash(&written, "lithe")); - assert_eq!(result["definitionHash"], server_hash(&written, "lithe")); - assert!(result["content"].as_str().unwrap().contains("TOKEN_A")); - // The next automatic cleanup must accept the actual output hash, - // including the unknown fields preserved from the original. - let removed = plan(json!({ - "source": source, - "scope": "global", - "content": result["content"], - "operation": "remove", - "name": "lithe", - "owned": [{"name": "lithe", "definitionHash": result["definitionHash"], "sourceHash": result["sourceHash"]}], - })); - assert_eq!(removed["action"], "removed"); - } else { - assert!(written["servers"].as_array().unwrap().is_empty()); - } - } - } -} - -#[test] -fn expected_hash_authorizes_only_the_original_of_a_rename() { - for source in ["claude", "codex"] { - let content = full_entry_content(source, "global"); - let original = parse(source, "global", &content, None); - let mut desired = original["servers"][0].clone(); - desired["name"] = json!("renamed"); - let renamed = plan(json!({ - "source": source, - "scope": "global", - "content": content, - "operation": "set", - "previousName": "lithe", - "server": desired, - "expectedHash": source_hash(&original, "lithe"), - "owned": [], - })); - assert_eq!(renamed["action"], "updated"); - assert_eq!(renamed["conflict"], Value::Null); - let written = parse(source, "global", renamed["content"].as_str().unwrap(), None); - assert_eq!(renamed["sourceHash"], source_hash(&written, "renamed")); - // Add an unrelated destination whose modeled definition already equals - // the rename draft. It must still require its own ownership proof. - let with_target = if source == "claude" { - let mut root: Value = serde_json::from_str(&content).unwrap(); - root["mcpServers"]["renamed"] = json!({"command": "old"}); - root.to_string() - } else { - format!("{content}\n[mcp_servers.renamed]\ncommand = 'old'\n") - }; - let target_parsed = parse(source, "global", &with_target, None); - for owned in [ - json!([]), - json!([{ - "name": "renamed", - "definitionHash": server_hash(&target_parsed, "renamed"), - }]), - ] { - let result = plan(json!({ - "source": source, - "scope": "global", - "content": with_target, - "operation": "set", - "previousName": "lithe", - "server": desired, - "expectedHash": source_hash(&original, "lithe"), - "owned": owned, - })); - assert_eq!(result["action"], "conflict"); - assert_eq!( - result["conflict"], - if owned.as_array().unwrap().is_empty() { - "ownedByUser" - } else { - "externallyModified" - } - ); - assert_eq!(result["content"], with_target); - } - } -} - -#[test] -fn unchanged_set_returns_hashes_of_the_actual_complete_entry() { - for source in ["claude", "codex"] { - let content = full_entry_content(source, "global"); - let parsed = parse(source, "global", &content, None); - let mut desired = parsed["servers"][0].clone(); - desired["sourceHash"] = json!("untrusted-draft-hash"); - let result = plan(json!({ - "source": source, - "scope": "global", - "content": content, - "operation": "set", - "server": desired, - "expectedHash": source_hash(&parsed, "lithe"), - "owned": [], - })); - assert_eq!(result["action"], "unchanged"); - assert_eq!(result["content"], content); - assert_eq!(result["sourceHash"], source_hash(&parsed, "lithe")); - assert_eq!(result["definitionHash"], server_hash(&parsed, "lithe")); - } -} - -#[test] -fn empty_configurations_still_allow_first_insert_and_absent_removal() { - for source in ["claude", "codex"] { - for scope in ["global", "project"] { - let project = (scope == "project").then_some("/work/project"); - for content in [None, Some(""), Some("\n \t")] { - let request = json!({ - "source": source, "scope": scope, "projectPath": project, - "content": content, "operation": "set", - "server": { "name": "alpha", "transport": "stdio", "command": "mcp" }, - "owned": [], - }); - let inserted = plan(request.clone()); - assert_eq!( - inserted["action"], "inserted", - "{source} {scope} {content:?}" - ); - let parsed = parse( - source, - scope, - inserted["content"].as_str().unwrap(), - project, - ); - assert_eq!(inserted["sourceHash"], source_hash(&parsed, "alpha")); - - let mut removal = request.clone(); - removal["operation"] = json!("remove"); - removal["name"] = json!("alpha"); - let absent = plan(removal); - assert_eq!(absent["action"], "absent"); - assert_eq!(absent["content"], content.unwrap_or("")); - - let mut stale = request; - stale["expectedHash"] = json!("previous-entry-version"); - let conflict = plan(stale); - assert_eq!(conflict["conflict"], "externallyModified"); - assert_eq!(conflict["content"], content.unwrap_or("")); - } - } - } -} - -#[test] -fn automatic_unchanged_set_cannot_adopt_external_or_legacy_entries() { - for source in ["claude", "codex"] { - for scope in ["global", "project"] { - let original_content = full_entry_content(source, scope); - let original = parse(source, scope, &original_content, Some("/work/project")); - let content = original_content.replace("20", "21"); - for owned in [ - json!([]), - json!([owned_entry(&original, "lithe")]), - json!([{"name": "lithe", "definitionHash": server_hash(&original, "lithe")}]), - ] { - let result = plan(json!({ - "source": source, "scope": scope, - "projectPath": if scope == "project" { Some("/work/project") } else { None }, - "content": content, "operation": "set", - "server": original["servers"][0], "owned": owned, - })); - assert_eq!(result["action"], "conflict"); - assert_eq!(result["content"], content); - assert_eq!(result["changed"], false); - assert_eq!( - result["conflict"], - if owned.as_array().unwrap().is_empty() { - "ownedByUser" - } else { - "externallyModified" - } - ); - } - } - } -} - -#[test] -fn externally_removed_transport_fields_cannot_bypass_ownership() { - for source in ["claude", "codex"] { - let original_content = full_entry_content(source, "global"); - let original = parse(source, "global", &original_content, None); - let content = if source == "claude" { - let mut root: Value = serde_json::from_str(&original_content).unwrap(); - root["mcpServers"]["lithe"] - .as_object_mut() - .unwrap() - .remove("command"); - root.to_string() - } else { - original_content.replace("command = \"old\"\n", "") - }; - assert_eq!( - parse(source, "global", &content, None)["servers"], - json!([]) - ); - for operation in ["set", "remove", "rename"] { - let mut desired = original["servers"][0].clone(); - if operation == "rename" { - desired["name"] = json!("renamed"); - } - for owned in [json!([]), json!([owned_entry(&original, "lithe")])] { - let result = plan(json!({ - "source": source, "scope": "global", "content": content, - "operation": if operation == "remove" { "remove" } else { "set" }, - "name": "lithe", "server": desired, "owned": owned, - "previousName": if operation == "rename" { Some("lithe") } else { None }, - })); - assert_eq!(result["action"], "conflict"); - assert_eq!(result["content"], content); - assert_eq!(result["changed"], false); - } - } - } -} - -#[test] -fn expected_hash_does_not_authorize_an_incomplete_rename_destination() { - for source in ["claude", "codex"] { - let content = full_entry_content(source, "global"); - let original = parse(source, "global", &content, None); - let mut desired = original["servers"][0].clone(); - desired["name"] = json!("renamed"); - let content = if source == "claude" { - let mut root: Value = serde_json::from_str(&content).unwrap(); - root["mcpServers"]["renamed"] = json!({"auth": {"enabled": true}}); - root.to_string() - } else { - format!("{content}\n[mcp_servers.renamed]\nauth = {{ enabled = true }}\n") - }; - assert_eq!( - parse(source, "global", &content, None)["servers"] - .as_array() - .unwrap() - .len(), - 1 - ); - for owned in [ - json!([]), - json!([{ - "name": "renamed", "definitionHash": "older-definition", "sourceHash": "older-source", - }]), - ] { - let result = plan(json!({ - "source": source, - "scope": "global", - "content": content, - "operation": "set", - "previousName": "lithe", - "server": desired, - "expectedHash": source_hash(&original, "lithe"), - "owned": owned, - })); - assert_eq!(result["action"], "conflict"); - assert_eq!( - result["conflict"], - if owned.as_array().unwrap().is_empty() { - "ownedByUser" - } else { - "externallyModified" - } - ); - assert_eq!(result["content"], content); - } - } -} diff --git a/rust/lithe-core/src/mcp/toml_edit.rs b/rust/lithe-core/src/mcp/toml_edit.rs deleted file mode 100644 index 39e108eb..00000000 --- a/rust/lithe-core/src/mcp/toml_edit.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Comment-preserving Codex TOML edits for `[mcp_servers.]` tables. -//! -//! Codex keeps models, providers, profiles and project trust in the same file, -//! so edits must leave unrelated tables, comments and ordering untouched. -//! `toml_edit` owns the document model and formatting; this module only maps one -//! normalized server definition onto a table and back. -//! -//! Codex supports settings Lithe does not model (`bearer_token_env_var`, -//! `startup_timeout_sec`, `tool_timeout_sec`, ...). Replacing the table would -//! silently drop them, so updates edit only the keys Lithe owns and leave every -//! other key and comment in place. - -use super::model::{McpKeyValue, McpServer, McpTransport}; -use crate::protocol::{CoreError, ErrorCode}; -use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Key, Table, Value}; - -type Result = std::result::Result; - -fn edit_error() -> CoreError { - CoreError::new( - ErrorCode::InvalidRequest, - "MCP configuration is not editable TOML", - ) -} - -/// Parses a Codex configuration document; empty input starts a new document. -pub(crate) fn document(content: &str) -> Result { - if content.trim().is_empty() { - return Ok(DocumentMut::new()); - } - content.parse::().map_err(|_| edit_error()) -} - -/// Sets or updates `[mcp_servers.]` and returns the new file text. -/// -/// `previous` is set when the host renames an entry: the old table is moved to -/// the new name before modeled keys are applied, so Codex-specific fields and -/// comments survive. The host still issues its remove step for the old name; -/// it becomes a no-op once the rename has moved the table. -pub(crate) fn set_server( - document: &mut DocumentMut, - server: &McpServer, - previous: Option<&str>, -) -> Result<()> { - if server.transport == McpTransport::Sse { - // Codex has no server-sent-events transport; the host refuses to write - // such servers instead of silently converting them to HTTP. - return Err(edit_error()); - } - let servers = document - .entry("mcp_servers") - .or_insert_with(|| Item::Table(Table::new())); - match servers { - Item::Table(servers) => set_table_server(servers, server, previous), - Item::Value(Value::InlineTable(servers)) => set_inline_server(servers, server, previous), - _ => Err(edit_error()), - } -} - -/// Applies a server to the usual `[mcp_servers.]` table layout. -fn set_table_server(servers: &mut Table, server: &McpServer, previous: Option<&str>) -> Result<()> { - // When a target with the new name already exists, keep that table: the - // user is merging into it, and its own unmodeled fields take precedence. - if let Some(previous) = previous.filter(|name| *name != server.name) { - if servers.get(server.name.as_str()).is_none() { - if let Some((key, item)) = servers.remove_entry(previous) { - // Reuse the key decorations so comments attached to the old - // header move with the table. - let renamed = Key::new(server.name.as_str()) - .with_leaf_decor(key.leaf_decor().clone()) - .with_dotted_decor(key.dotted_decor().clone()); - servers.insert_formatted(&renamed, item); - } - } - } - let item = servers - .entry(server.name.as_str()) - .or_insert_with(|| Item::Table(Table::new())); - match item { - Item::Table(table) => { - apply_table(table, server); - Ok(()) - } - Item::Value(Value::InlineTable(table)) => { - apply_inline_table(table, server); - Ok(()) - } - _ => Err(edit_error()), - } -} - -/// Applies a server when `mcp_servers` itself is an inline table. -/// -/// Inline tables cannot hold comments, so a rename simply moves the existing -/// value to the new key before modeled fields are applied. -fn set_inline_server( - servers: &mut InlineTable, - server: &McpServer, - previous: Option<&str>, -) -> Result<()> { - if let Some(previous) = previous.filter(|name| *name != server.name) { - if servers.get(server.name.as_str()).is_none() { - if let Some(item) = servers.remove(previous) { - servers.insert(server.name.as_str(), item); - } - } - } - if servers.get(server.name.as_str()).is_none() { - servers.insert(server.name.as_str(), Value::InlineTable(InlineTable::new())); - } - match servers.get_mut(server.name.as_str()) { - Some(Value::InlineTable(table)) => { - apply_inline_table(table, server); - Ok(()) - } - _ => Err(edit_error()), - } -} - -/// Removes `[mcp_servers.]`, returning whether it existed. -/// -/// Codex accepts both a table section and an inline table for `mcp_servers`; -/// both parse into the library, so both must be removable. -pub(crate) fn remove_server(document: &mut DocumentMut, name: &str) -> bool { - match document.get_mut("mcp_servers") { - Some(Item::Table(servers)) => servers.remove(name).is_some(), - Some(Item::Value(Value::InlineTable(servers))) => servers.remove(name).is_some(), - _ => false, - } -} - -/// Updates only the keys Lithe models; Codex-specific settings survive. -fn apply_table(table: &mut Table, server: &McpServer) { - match server.transport { - McpTransport::Stdio => { - table.remove("url"); - table.remove("http_headers"); - set_string(table, "command", server.command.as_deref()); - set_args(table, &server.args); - set_entries(table, "env", &server.env); - } - McpTransport::Http => { - table.remove("command"); - table.remove("args"); - table.remove("env"); - set_string(table, "url", server.url.as_deref()); - set_entries(table, "http_headers", &server.headers); - } - McpTransport::Sse => {} - } -} - -fn apply_inline_table(table: &mut InlineTable, server: &McpServer) { - match server.transport { - McpTransport::Stdio => { - table.remove("url"); - table.remove("http_headers"); - set_inline_string(table, "command", server.command.as_deref()); - set_inline_args(table, &server.args); - set_inline_entries(table, "env", &server.env); - } - McpTransport::Http => { - table.remove("command"); - table.remove("args"); - table.remove("env"); - set_inline_string(table, "url", server.url.as_deref()); - set_inline_entries(table, "http_headers", &server.headers); - } - McpTransport::Sse => {} - } -} - -fn set_string(table: &mut Table, key: &str, text: Option<&str>) { - match text { - Some(text) => { - set_table_value(table, key, Value::from(text.to_string())); - } - None => { - table.remove(key); - } - } -} - -fn set_args(table: &mut Table, args: &[String]) { - // Rebuilding an identical array would discard comments between its - // elements. When only another field changed, leave the array untouched. - if array_matches(table.get("args").and_then(Item::as_value), args) { - return; - } - if args.is_empty() { - table.remove("args"); - return; - } - let mut array = Array::new(); - for argument in args { - array.push(argument.as_str()); - } - set_table_value(table, "args", Value::Array(array)); -} - -fn array_matches(existing: Option<&Value>, args: &[String]) -> bool { - let Some(array) = existing.and_then(Value::as_array) else { - return false; - }; - array.len() == args.len() - && array - .iter() - .zip(args) - .all(|(value, argument)| value.as_str() == Some(argument.as_str())) -} - -/// Replaces a value while keeping the decor attached to it. -/// -/// Decor carries the comment before a key and the inline comment after a -/// value. Replacing the whole entry would drop both, so only the value itself -/// is swapped when the key already exists. -fn set_table_value(table: &mut Table, key: &str, new_value: Value) { - match table.get_mut(key) { - Some(item) => { - let decor = item.as_value().map(|value| value.decor().clone()); - let mut new_value = new_value; - if let Some(decor) = decor { - *new_value.decor_mut() = decor; - } - *item = Item::Value(new_value); - } - None => { - table.insert(key, Item::Value(new_value)); - } - } -} - -/// Writes `env` or `http_headers`, preserving entries Lithe cannot represent. -/// -/// Keys Lithe parses and shows are replaced as a set, so removing an entry in -/// the UI removes it here. Non-string values are not part of the normalized -/// model and stay untouched instead of being silently deleted. -fn set_entries(table: &mut Table, key: &str, entries: &[McpKeyValue]) { - match table.get_mut(key) { - Some(Item::Table(child)) => { - let removable: Vec = child - .iter() - .filter(|(name, item)| { - item.as_value().is_some_and(Value::is_str) - && !entries.iter().any(|entry| entry.key == *name) - }) - .map(|(name, _)| name.to_string()) - .collect(); - for stale in removable { - child.remove(&stale); - } - for entry in entries { - set_table_value(child, entry.key.as_str(), Value::from(entry.value.clone())); - } - if child.is_empty() { - table.remove(key); - } - } - Some(Item::Value(Value::InlineTable(child))) => { - let removable: Vec = child - .iter() - .filter(|(name, value)| { - value.is_str() && !entries.iter().any(|entry| entry.key == *name) - }) - .map(|(name, _)| name.to_string()) - .collect(); - for stale in removable { - child.remove(&stale); - } - for entry in entries { - child.insert(entry.key.as_str(), Value::from(entry.value.clone())); - } - if child.is_empty() { - table.remove(key); - } - } - _ => { - if !entries.is_empty() { - table.insert(key, Item::Table(entries_table(entries))); - } - } - } -} - -fn entries_table(entries: &[McpKeyValue]) -> Table { - let mut child = Table::new(); - for entry in entries { - child[entry.key.as_str()] = value(entry.value.as_str()); - } - child -} - -fn set_inline_string(table: &mut InlineTable, key: &str, text: Option<&str>) { - match text { - Some(text) => { - table.insert(key, Value::from(text.to_string())); - } - None => { - table.remove(key); - } - } -} - -fn set_inline_args(table: &mut InlineTable, args: &[String]) { - if array_matches(table.get("args"), args) { - return; - } - if args.is_empty() { - table.remove("args"); - return; - } - let mut array = Array::new(); - for argument in args { - array.push(argument.as_str()); - } - table.insert("args", Value::Array(array)); -} - -/// Inline-table counterpart of [`set_entries`]. Inline tables cannot hold -/// comments, but they can still hold non-string values that Lithe does not -/// model, so those are preserved the same way. -fn set_inline_entries(table: &mut InlineTable, key: &str, entries: &[McpKeyValue]) { - match table.get_mut(key) { - Some(Value::InlineTable(child)) => { - let removable: Vec = child - .iter() - .filter(|(name, value)| { - value.is_str() && !entries.iter().any(|entry| entry.key == *name) - }) - .map(|(name, _)| name.to_string()) - .collect(); - for stale in removable { - child.remove(&stale); - } - for entry in entries { - child.insert(entry.key.as_str(), Value::from(entry.value.clone())); - } - if child.is_empty() { - table.remove(key); - } - } - _ => { - if !entries.is_empty() { - let mut child = InlineTable::new(); - for entry in entries { - child.insert(entry.key.as_str(), Value::from(entry.value.clone())); - } - table.insert(key, Value::InlineTable(child)); - } - } - } -} diff --git a/rust/lithe-core/src/mcp/tools.rs b/rust/lithe-core/src/mcp/tools.rs deleted file mode 100644 index b239797c..00000000 --- a/rust/lithe-core/src/mcp/tools.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! Deterministic command-line editing and native per-tool exclusion planning. -//! Hosts own file snapshots and remember only the exact exclusions they added. - -use super::{json_edit::JsonDocument, parse::MAX_CONFIGURATION_BYTES}; -use crate::protocol::{CoreError, ErrorCode}; -use serde::Deserialize; -use serde_json::{json, Value}; - -fn invalid() -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP tool configuration") -} - -/// Parses or formats one terminal-like argv projection without invoking a shell. -pub fn command_line(payload: Value) -> Result { - let line = if payload["action"] == "format" { - let command = payload["command"].as_str().ok_or_else(invalid)?; - let args = strings(payload.get("args"))?; - if command.is_empty() && args.is_empty() { - return Ok(json!({"line":""})); - } - if command.len() + args.iter().map(String::len).sum::() > MAX_CONFIGURATION_BYTES { - return Err(invalid()); - } - let all = std::iter::once(command).chain(args.iter().map(String::as_str)); - return Ok(json!({"line": shell_words::join(all)})); - } else if payload["action"] == "parse" { - payload["line"].as_str().ok_or_else(invalid)? - } else { - return Err(invalid()); - }; - if line.len() > MAX_CONFIGURATION_BYTES { - return Err(invalid()); - } - let parts = shell_words::split(line).map_err(|_| invalid())?; - Ok( - json!({"command": parts.first().cloned().unwrap_or_default(), "args": parts.into_iter().skip(1).collect::>() }), - ) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PolicyRequest { - /// Native owner, `codex` or `claude`. - source: String, - /// Current bounded UTF-8 text from the platform snapshot. - content: String, - /// Native MCP entry name. - server: String, - /// Names reported by this server, never a wildcard expression. - tools: Vec, - /// Exclusions previously added by this Lithe library for this native path. - #[serde(default)] - owned: Vec, - /// Optional explicit one-tool mutation; absence reads policy only. - tool: Option, - enabled: Option, -} - -/// Returns effective local exclusions and an optional format-preserving edit. -/// External exact/wildcard denies and allow-lists are never relaxed here. -pub fn tool_policy(payload: Value) -> Result { - let r: PolicyRequest = serde_json::from_value(payload).map_err(|_| invalid())?; - if r.content.len() > MAX_CONFIGURATION_BYTES - || r.tools.len() > 1000 - || r.owned.len() > 1000 - || !valid_name(&r.server) - || r.tools.iter().chain(&r.owned).any(|s| !valid_name(s)) - || r.tool.as_ref().is_some_and(|s| !r.tools.contains(s)) - || r.tool.is_some() != r.enabled.is_some() - { - return Err(invalid()); - } - let (mut denied, allow) = match r.source.as_str() { - "codex" => { - let doc: toml::Value = toml::from_str(&r.content).map_err(|_| invalid())?; - let entry = doc - .get("mcp_servers") - .and_then(|v| v.get(&r.server)) - .ok_or_else(invalid)?; - let list = |key: &str| -> Result, CoreError> { - match entry.get(key) { - None => Ok(vec![]), - Some(v) => v - .as_array() - .ok_or_else(invalid)? - .iter() - .map(|s| s.as_str().map(str::to_owned).ok_or_else(invalid)) - .collect(), - } - }; - ( - list("disabled_tools")?, - if entry.get("enabled_tools").is_some() { - Some(list("enabled_tools")?) - } else { - None - }, - ) - } - "claude" => { - let doc: Value = serde_json::from_str(if r.content.is_empty() { - "{}" - } else { - &r.content - }) - .map_err(|_| invalid())?; - if !doc.is_object() || doc.get("permissions").is_some_and(|v| !v.is_object()) { - return Err(invalid()); - } - (strings(doc.pointer("/permissions/deny"))?, None) - } - _ => return Err(invalid()), - }; - let rule = |tool: &str| -> String { - if r.source == "claude" { - format!("mcp__{}__{}", normalize(&r.server), normalize(tool)) - } else { - tool.to_owned() - } - }; - if r.source == "claude" - && r.tool.as_ref().is_some_and(|tool| { - r.tools - .iter() - .any(|other| other != tool && normalize(other) == normalize(tool)) - }) - { - return Err(invalid()); - } - let is_blocked = |tool: &str, list: &[String]| { - if r.source == "codex" { - list.iter().any(|s| s == tool) - } else { - let exact = rule(tool); - list.iter() - .any(|s| wildcard(s, &exact) || s == &format!("mcp__{}", normalize(&r.server))) - } - }; - let mut owned = r.owned.clone(); - let mut changed = false; - if let (Some(tool), Some(enabled)) = (&r.tool, r.enabled) { - let exact = rule(tool); - if enabled { - if allow.as_ref().is_some_and(|a| !a.contains(tool)) - || (is_blocked(tool, &denied) && !owned.contains(tool)) - { - return Err(CoreError::new(ErrorCode::PermissionDenied, "This tool is restricted outside Lithe. Edit the native Agent policy to enable it.")); - } - let length = denied.len(); - denied.retain(|s| s != &exact); - changed = length != denied.len(); - owned.retain(|s| s != tool); - } else if !is_blocked(tool, &denied) { - denied.push(exact); - if !owned.contains(tool) { - owned.push(tool.clone()); - } - changed = true; - } - } - let content = if changed { - if r.source == "codex" { - let mut doc: toml_edit::DocumentMut = r.content.parse().map_err(|_| invalid())?; - let entry = doc - .get_mut("mcp_servers") - .and_then(|v| v.get_mut(&r.server)) - .and_then(toml_edit::Item::as_table_like_mut) - .ok_or_else(invalid)?; - let mut array = toml_edit::Array::new(); - for name in &denied { - array.push(name.as_str()); - } - entry.insert("disabled_tools", toml_edit::value(array)); - doc.to_string() - } else { - let mut doc = JsonDocument::parse(if r.content.is_empty() { - "{}" - } else { - &r.content - })?; - let object = doc.ensure_object(&["permissions"])?; - doc.set_member( - object, - "deny", - &serde_json::to_string(&denied).map_err(|_| invalid())?, - )?; - doc.into_string()? - } - } else { - r.content.clone() - }; - let tools: Vec<_> = r.tools.iter().map(|tool| { - let blocked = is_blocked(tool, &denied); - let outside_allow = allow.as_ref().is_some_and(|a| !a.contains(tool)); - // An owned exact rule does not authorize removing a broader user deny. - let other_deny = denied.iter().any(|s| s != &rule(tool) && is_blocked(tool, std::slice::from_ref(s))); - let ambiguous = r.source == "claude" && r.tools.iter().any(|other| other != tool && normalize(other) == normalize(tool)); - json!({"name":tool,"enabled":!blocked && !outside_allow,"editable":!ambiguous && !outside_allow && !other_deny && (!blocked || owned.contains(tool))}) - }).collect(); - let has_restrictions = if r.source == "claude" { - denied - .iter() - .any(|s| s.starts_with("mcp") || s.starts_with('*')) - } else { - !denied.is_empty() || allow.is_some() - }; - Ok( - json!({"content":content,"changed":changed,"owned":owned,"tools":tools,"hasRestrictions":has_restrictions}), - ) -} - -fn strings(value: Option<&Value>) -> Result, CoreError> { - match value { - None => Ok(vec![]), - Some(v) => v - .as_array() - .ok_or_else(invalid)? - .iter() - .map(|s| s.as_str().map(str::to_owned).ok_or_else(invalid)) - .collect(), - } -} - -fn valid_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= 256 - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c)) -} - -fn normalize(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || "_-".contains(c) { - c - } else { - '_' - } - }) - .collect() -} - -fn wildcard(pattern: &str, value: &str) -> bool { - let pattern = pattern - .split('*') - .map(regex::escape) - .collect::>() - .join(".*"); - regex::Regex::new(&format!("^{pattern}$")).is_ok_and(|re| re.is_match(value)) -} - -#[cfg(test)] -mod tests; diff --git a/rust/lithe-core/src/mcp/tools/tests.rs b/rust/lithe-core/src/mcp/tools/tests.rs deleted file mode 100644 index e336b90f..00000000 --- a/rust/lithe-core/src/mcp/tools/tests.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Per-tool policy and command projections protect native ownership boundaries. -use super::*; - -fn request(source: &str, content: &str) -> Value { - json!({"source":source,"content":content,"server":"docs","tools":["read","write"],"owned":[]}) -} - -#[test] -fn quoted_command_line_preserves_every_argument_without_shell_execution() { - let args = vec![ - "", - "a b", - "it's quoted", - "line\nnext", - "$(touch forbidden)", - "a;rm -f file", - "é", - ]; - let formatted = - command_line(json!({"action":"format","command":"/fake tools/server","args":args})) - .unwrap(); - let parsed = command_line(json!({"action":"parse","line":formatted["line"]})).unwrap(); - assert_eq!(parsed["command"], "/fake tools/server"); - assert_eq!(parsed["args"], json!(args)); - assert!(command_line(json!({"action":"parse","line":"npx 'unterminated"})).is_err()); -} - -#[test] -fn codex_only_removes_own_deny_and_retains_unknown_settings() { - let content = "# user formatting\n[mcp_servers.docs]\ncommand = 'node'\nargs = ['server']\ncustom = 9\ndisabled_tools = ['write']\n"; - let mut r = request("codex", content); - let read = tool_policy(r.clone()).unwrap(); - assert_eq!( - read["tools"][1], - json!({"name":"write","enabled":false,"editable":false}) - ); - r["tool"] = json!("write"); - r["enabled"] = json!(true); - assert!(tool_policy(r.clone()).is_err()); - r["tool"] = json!("read"); - r["enabled"] = json!(false); - let off = tool_policy(r.clone()).unwrap(); - assert!(off["content"].as_str().unwrap().contains("custom = 9")); - assert!(off["content"] - .as_str() - .unwrap() - .starts_with("# user formatting\n")); - r["content"] = off["content"].clone(); - r["owned"] = off["owned"].clone(); - r["enabled"] = json!(true); - let on = tool_policy(r).unwrap(); - let doc: toml::Value = toml::from_str(on["content"].as_str().unwrap()).unwrap(); - assert_eq!( - doc["mcp_servers"]["docs"]["disabled_tools"] - .as_array() - .unwrap()[0] - .as_str(), - Some("write") - ); - assert_eq!(on["tools"][0]["enabled"], true); -} - -#[test] -fn claude_exact_and_wildcard_denies_are_not_relaxed() { - let content = "{\n \"user\": {\"keep\": [1, 2]},\n \"permissions\": {\"deny\": [\"Bash(rm *)\", \"mcp__docs__write\"],\"allow\": [\"Read\"]}\n}"; - let mut r = request("claude", content); - r["tool"] = json!("read"); - r["enabled"] = json!(false); - let off = tool_policy(r.clone()).unwrap(); - assert!(off["content"] - .as_str() - .unwrap() - .contains("\"user\": {\"keep\": [1, 2]}")); - r["content"] = off["content"].clone(); - r["owned"] = off["owned"].clone(); - r["enabled"] = json!(true); - let on = tool_policy(r).unwrap(); - assert_eq!( - serde_json::from_str::(on["content"].as_str().unwrap()).unwrap()["permissions"] - ["deny"], - json!(["Bash(rm *)", "mcp__docs__write"]) - ); - let mut broad = request( - "claude", - r#"{"permissions":{"deny":["mcp__d*__*write", "mcp__docs__read"]}}"#, - ); - broad["owned"] = json!(["write"]); - let state = tool_policy(broad).unwrap(); - assert_eq!(state["tools"][1]["editable"], false); - assert_eq!(state["tools"][1]["enabled"], false); -} - -#[test] -fn allow_lists_ambiguous_names_and_malformed_policy_fail_closed() { - let r = request( - "codex", - "[mcp_servers.docs]\ncommand='node'\nenabled_tools=['write']\n", - ); - assert_eq!(tool_policy(r).unwrap()["tools"][0]["editable"], false); - let mut r = request("claude", "{}"); - r["tools"] = json!(["read.one", "read_one"]); - assert!(tool_policy(r).unwrap()["tools"] - .as_array() - .unwrap() - .iter() - .all(|t| t["editable"] == false)); - for content in [r#"{"permissions":[]}"#, r#"{"permissions":{"deny":[1]}}"#] { - assert!(tool_policy(request("claude", content)).is_err()); - } - let mut r = request("claude", "{}"); - r["tool"] = json!("*"); - r["enabled"] = json!(false); - assert!(tool_policy(r).is_err()); -} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index c2c0abe7..6ae3747d 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -75,24 +75,6 @@ pub enum CoreCommand { AiInlineEditPlan, /// Decodes the replacement text of an inline edit response (`ai.inlineEdit.decode`). AiInlineEditDecode, - /// Parses one Agent MCP configuration file into normalized servers (`mcp.parseConfiguration`). - McpParseConfiguration, - /// Normalizes a public Registry response into discoverable server drafts (`mcp.parseRegistry`). - McpParseRegistry, - /// Returns the bundled, offline discovery catalog (`mcp.featuredRegistry`). - McpFeaturedRegistry, - /// Plans a format-preserving MCP entry write or removal (`mcp.planWrite`). - McpPlanWrite, - /// Reads or plans native per-tool exclusion lists. - McpToolPolicy, - /// Round-trips a quoted command line into exact argv boundaries. - McpCommandLine, - /// Merges discovered MCP sources into library rows with ownership (`mcp.librarySnapshot`). - McpLibrarySnapshot, - /// Returns field-level issues for an MCP server editor draft (`mcp.validateDraft`). - McpValidateDraft, - /// Plans an all-or-nothing multi-file MCP library edit (`mcp.batchPlan`). - McpBatchPlan, /// Validates and normalizes a declarative plugin manifest (`plugins.declarative.validate`). PluginsDeclarativeValidate, /// Validates native plugin UI data with declaration identity and node budgets. @@ -834,15 +816,6 @@ impl CoreCommand { "ai.pullRequest.decode" => Some(Self::AiPullRequestDecode), "ai.inlineEdit.plan" => Some(Self::AiInlineEditPlan), "ai.inlineEdit.decode" => Some(Self::AiInlineEditDecode), - "mcp.parseConfiguration" => Some(Self::McpParseConfiguration), - "mcp.parseRegistry" => Some(Self::McpParseRegistry), - "mcp.featuredRegistry" => Some(Self::McpFeaturedRegistry), - "mcp.planWrite" => Some(Self::McpPlanWrite), - "mcp.toolPolicy" => Some(Self::McpToolPolicy), - "mcp.commandLine" => Some(Self::McpCommandLine), - "mcp.librarySnapshot" => Some(Self::McpLibrarySnapshot), - "mcp.validateDraft" => Some(Self::McpValidateDraft), - "mcp.batchPlan" => Some(Self::McpBatchPlan), "plugins.declarative.validate" => Some(Self::PluginsDeclarativeValidate), "plugins.nativeUI.validateData" => Some(Self::PluginsNativeUIValidateData), "plugins.web.open" => Some(Self::PluginsWebOpen), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index ae4b828f..4179161f 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -219,44 +219,6 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { CoreCommand::AiInlineEditDecode => { plugin_response(id, parsed.payload, crate::ai::decode_inline_edit_command) } - CoreCommand::McpParseConfiguration => { - match crate::mcp::parse_configuration(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::McpParseRegistry => match crate::mcp::parse_registry(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::McpFeaturedRegistry => match crate::mcp::featured_registry() { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::McpToolPolicy => match crate::mcp::tool_policy(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::McpCommandLine => match crate::mcp::command_line(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::McpPlanWrite => match crate::mcp::plan_write(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::McpLibrarySnapshot => match crate::mcp::library_snapshot(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::McpValidateDraft => match crate::mcp::validate_draft(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::McpBatchPlan => match crate::mcp::batch_plan(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, CoreCommand::PluginsNativeUIValidateData => plugin_response( id, parsed.payload,