infolang
v1.1.0
Published
Install and manage the InfoLang MCP server across every AI coding client
Maintainers
Readme
infolang
One CLI to install and manage the InfoLang MCP server across every AI coding client you use.
npx infolang installReplaces @infolang/claude-setup, @infolang/cursor-setup, and @infolang/mcp-setup.
- Node ≥ 22.14.0
- ESM-only, single bundled artifact (
dist/cli.js)
Commands
| Command | What it does |
| --- | --- |
| infolang install | Add the MCP server to one or more clients |
| infolang uninstall | Remove entries this CLI installed (and only those) |
| infolang status | Detect clients, read the manifest, report per-client state |
| infolang tools | Live tools/list against the configured endpoint |
| infolang doctor | Diagnose every client; find and migrate artifacts from the retired setup packages |
| infolang generate | Regenerate the plugin's tool-derived artifacts from a live tools/list |
| infolang login | Sign in via browser (PKCE loopback) or an API key |
| infolang logout | Clear credentials from every storage tier |
| infolang whoami | Verify the stored credential against the API |
install
infolang install [--client <id>...] [--scope user|project] [--api-key]
[--name <server-name>] [--dry-run] [--json] [--yes] [--no-plugin]Run bare, it detects your installed clients, pre-checks them in a multiselect, confirms,
then applies with a per-client spinner and a status table. --yes with --client gives
you a non-interactive run for scripts and CI.
--dry-run prints the exact diff — including the JSON/TOML that would be written — and
touches nothing.
The process exits non-zero if any targeted client failed.
When Claude Code is among the targets, install also drops the bundled plugin (memory
skill + session hooks + its own MCP server entry) into ~/.claude/skills/infolang/, where
Claude Code loads it as infolang@skills-dir on the next session. --no-plugin skips it.
See The Claude Code plugin for why that path and not
claude plugin install.
uninstall
infolang uninstall [--client <id>...] [--all] [--dry-run] [--force]Driven entirely by the install manifest. Before removing an entry it re-hashes the value
currently on disk; if you edited it, the entry is skipped with a warning rather than
silently discarded. --force overrides.
That tamper check applies only to entries the CLI wrote itself. When an entry was written
through a client's own CLI (claude mcp add-json), that client owns the stored
representation and may normalize it, so a hash mismatch is expected rather than evidence of
an edit — those entries are removed without --force, preferring claude mcp remove and
falling back to a direct edit. The manifest records which case an entry is.
Supported clients and their traps
Every one of these is a real failure mode that produces a config the client accepts and then quietly ignores. Each is encoded in the adapter and covered by a unit test.
| Client | Config file | Top-level key | Entry shape | The trap |
| --- | --- | --- | --- | --- |
| claude-code | ~/.claude.json (user) · .mcp.json (project) | mcpServers | {"type":"http","url":…,"headers":…} | type is mandatory. A remote entry with url and no type is silently skipped. Also: reserved names (workspace, claude-in-chrome, computer-use, Claude Preview, Claude Browser) are refused, and names must match [A-Za-z0-9_-]+. |
| claude-desktop | ~/Library/Application Support/Claude/claude_desktop_config.json (mac) · %APPDATA%\Claude\… (win) · ~/.config/Claude/… (linux) | mcpServers | mcp-remote stdio bridge | Never write a bare url entry. Desktop validates mcpServers as stdio-only and strips or breaks on a remote entry. We write an npx -y mcp-remote … bridge and point you at Settings → Connectors, which is the first-class remote path. |
| cursor | ~/.cursor/mcp.json (user) · .cursor/mcp.json (project) | mcpServers | {"url":…,"headers":…} | Transport is inferred from url; adding a type field is not the Cursor schema. |
| vscode | .vscode/mcp.json (project) · ~/Library/Application Support/Code/User/mcp.json (user) | servers | {"type":"http","url":…} | The key is servers, not mcpServers. Writing mcpServers produces a file VS Code loads without complaint and ignores entirely. |
| windsurf | ~/.codeium/windsurf/mcp_config.json | mcpServers | {"serverUrl":…,"headers":…} | Windsurf accepts serverUrl or url, but serverUrl is the documented key honoured by every version. We always write serverUrl. |
| gemini-cli | ~/.gemini/settings.json (user) · .gemini/settings.json (project) | mcpServers | {"httpUrl":…,"headers":…} | url means SSE; httpUrl means streamable HTTP. Writing url gets you an SSE handshake that never completes. |
| codex | $CODEX_HOME/config.toml (default ~/.codex/config.toml) · .codex/config.toml | [mcp_servers.<name>] | url + bearer_token_env_var | TOML edits are surgical: only InfoLang's own table is spliced. A parse-and-reserialize round trip would silently reformat your model settings, profiles, and comments. A literal token is never written — Codex resolves bearer_token_env_var at run time. |
Preferring the client's own CLI
When claude or code is on PATH, install delegates to claude mcp add-json /
code --add-mcp so the entry goes through the client's own schema validation. Spawned
with an argv array, never a shell string. Set INFOLANG_NO_CLIENT_CLI=1 to force the
direct-file-edit path.
Two token domains
This is the part that most MCP installers get wrong, so it is worth being explicit.
- The CLI's own credential. Used for
infolang whoami,infolang tools, and verifying a key at login. Stored on your machine, never written into a client config. A browser login mints anil_oauth_bearer scoped tomemories:read memories:write;--api-keystores anil_live_/il_test_key. Either way it is verified againstGET /v2/whoamibefore being stored. - The client's credential. Client configs receive env-var indirection only —
Bearer ${INFOLANG_API_KEY}for JSON clients,bearer_token_env_varfor Codex — or the client's own OAuth (e.g.claude mcp login).
A literal il_live_… value never reaches a client config, which means a project-scope
.mcp.json, .cursor/mcp.json, .vscode/mcp.json, .gemini/settings.json, or
.codex/config.toml is safe to commit. The install path refuses to write a config
containing an il_live_ literal.
Export the key in whatever environment starts your editor:
export INFOLANG_API_KEY=il_live_…If the variable is unset, the client fails loudly at config-parse or first call — which is the intended behaviour. It is much better than a silent 401 an hour later.
Credential storage precedence
| Tier | Notes |
| --- | --- |
| INFOLANG_API_KEY env var | Read, never persisted. Your session, your business. |
| OS keychain | Via @napi-rs/keyring, service infolang. (keytar is unmaintained and deliberately unused.) |
| ~/.config/infolang/credentials.json | Fallback only. Mode 0600 inside a 0700 directory, with a visible warning at write time. |
infolang logout clears every tier and tells you if INFOLANG_API_KEY is still exported
in the current shell.
Authentication
Browser (default)
Standard OAuth 2.1 authorization code + PKCE against the gateway's own authorization server. Nothing about the endpoints is compiled in — they come from discovery:
GET {INFOLANG_URL}/.well-known/oauth-authorization-server(RFC 8414) → endpoints.- Bind the loopback listener first, so the exact redirect URI
http://127.0.0.1:<port>/callbackis known. POST {registration_endpoint}(RFC 7591 dynamic client registration) with that exact URI, as a public client (token_endpoint_auth_method: "none").- Browser →
{authorization_endpoint}withclient_id,redirect_uri,state, S256code_challenge, andscope=memories:read memories:write. - The gateway routes the user through the console's consent screen and back to the
loopback with
code+state. POST {token_endpoint}withgrant_type=authorization_codeand thecode_verifier→ anil_oauth_bearer.
Why registration happens on every login: the gateway validates redirect_uri by exact
string match against what was registered. Registering fresh, after the port is bound, means
the ephemeral port is baked into the registration — so there is no port to pre-agree on and
none to get wrong. --port remains for corporate setups that need a pinned, firewalled port.
Hardening:
- S256 only.
plainhas no code path, and the browser flow refuses a server that does not advertise S256. (The check runs when that flow starts, not at discovery — a device-only deployment is legitimate and must not be rejected before we look at what we need.) stateverified with a constant-time comparison.- Listener bound to the
127.0.0.1IP literal — neverlocalhost(which can resolve to::1or worse) and never0.0.0.0. - Ephemeral port by default, with a fixed fallback range
51735–51739. - One-shot listener: it stops accepting the moment a valid callback lands. A stray tab
replaying the wrong
stategets a 400 and does not tear the flow down. - The callback URL — the one carrying the authorization code — is never printed or logged.
- Every discovered endpoint must share the deployment's origin, so a misconfigured or tampered metadata document cannot redirect the authorization code elsewhere.
API key
infolang login --api-key # masked prompt
echo "$KEY" | infolang login --api-key # pipedKeys must start with il_live_ or il_test_, and are verified against the API before
being stored. --key <value> still exists for backwards compatibility but prints a
deprecation warning — an argv value lands in your shell history.
Device code (headless machines)
infolang login --device # or just `infolang login` on a headless boxChosen automatically when the session looks headless (no DISPLAY/WAYLAND_DISPLAY on
Linux, SSH_CONNECTION set, CI, or a container marker), and forceable anywhere with
--device — useful in a remote tmux, on a machine whose default browser is broken, or when
you would simply rather approve on your phone.
The CLI prints a URL and an 8-character code, then polls until you approve:
Open https://app.infolang.ai/activate
Code BCDF-GHJK
! Only continue if you just ran `infolang login` yourself.
Never enter a code someone sent you.It tries to open a browser as a convenience, but never depends on one — the printed instructions are the primary path, because the whole premise is a machine that may not be able to open anything.
This replaces the old advice to paste an API key on headless machines, which was the exact
trade the OAuth lane exists to avoid: an il_live_ key never expires and carries your whole
grant set, where a device login yields a scoped 90-day token.
Read the warning. Device-code phishing needs no vulnerability: an attacker runs this command on their machine, gets a genuine code, and talks you into entering it. Every URL you see is real, and the token lands on their box carrying your authority. The console shows you the same code so you can check it matches what your terminal printed — if it does not, or if you did not start this yourself, deny it.
RFC 8628 details, for anyone auditing:
- The
device_authorization_endpointis taken from the discovery document and never guessed. A server that does not advertise it gets a clear "this deployment has no device flow" rather than a 404 halfway through a login. - Registration asks for the device grant and sends no
redirect_uri— this flow has no redirection, and a loopback address registered but never listened on would be a real destination a real authorization code could go to. - Polling honours the server's
intervaland adds 5 seconds on everyslow_down(§3.5). All five poll outcomes arrive as HTTP 400, so "non-2xx means failure" would abort on the first poll of every successful login. - The
device_codeis a bearer secret: it is never printed, never logged, and never put in an error message. Only theuser_codeand the URL are displayed.
Write discipline
Everything below is enforced by unit tests.
- plan/apply split.
plan()is pure: it reads the world, computes the resulting bytes, and writes nothing.apply()is the only code path that touches disk. - jsonc-parser edit API. Comments, key order, and unrelated formatting survive. Existing indentation (2-space, 4-space, tabs) and line endings (LF/CRLF) are detected and matched.
- Atomic writes. Same-directory temp file →
fsync→rename. A crash mid-write cannot leave a truncated config. - Timestamped backups.
<file>.infolang-backup-<ISO8601>before the first modification of any file in a run, with the path printed. A no-op run takes no backup. - Verify after write. The file is re-read, re-parsed, and the entry confirmed present and matching. A failed verification is a failed install.
- An unparseable existing config is a hard error. It is reported per-client and the file
is left byte-for-byte alone. Treating a broken config as empty and overwriting it is
install-mcp's bug and is explicitly banned here. - Idempotent. Running
installtwice produces byte-identical files with unchanged mtimes.
Install manifest
~/.config/infolang/installs.json (respects XDG_CONFIG_HOME):
{
"schemaVersion": 1,
"installs": [
{
"client": "cursor",
"scope": "user",
"path": "/Users/you/.cursor/mcp.json",
"configKey": "mcpServers",
"serverName": "infolang",
"writtenValueHash": "<sha256 of the normalized JSON value>",
"backupPath": null,
"cliVersion": "1.0.0",
"installedAt": "2026-07-27T12:00:00.000Z",
"installMethod": "npx",
"delegated": false
}
]
}Live introspection
infolang tools performs a real MCP initialize + tools/list against the configured
endpoint (streamable HTTP JSON-RPC; both JSON and SSE responses are handled). There is no
hardcoded list of tool names anywhere in src/ — a test asserts it. Anything the CLI says
about server capabilities comes from the live response, so a server-side change shows up
without a CLI release.
infolang tools # table
infolang tools --json # full schemas
infolang tools --markdown # generated rules/instructions blockThe markdown generator lives in src/generate/ and is the seam the plugin's SKILL.md
tool-reference block and hooks.json are rendered from.
The Claude Code plugin
plugin/ ships inside the npm package and is a complete Claude Code plugin:
plugin/
├── .claude-plugin/plugin.json # ONLY plugin.json may live here
├── skills/memory/SKILL.md # when to recall, when to remember, what never to store
├── hooks/hooks.json # SessionStart (recall + drain) / PreCompact / Stop
├── hooks/memory-hook.mjs # zero-dependency shim for the two write hooks
└── .mcp.json # the bundled MCP server.mcp.json uses Claude Code's environment expansion, so no secret is ever committed:
{
"mcpServers": {
"infolang": {
"type": "http",
"url": "${INFOLANG_URL:-https://api.infolang.ai}/v1/mcp/",
"headers": { "Authorization": "Bearer ${INFOLANG_API_KEY}" }
}
}
}The naming trap
Tools from a plugin-bundled server carry both the plugin name and the server key:
mcp__plugin_infolang_infolang__<tool> ← callable name, matchers, allowed-tools
plugin:infolang:infolang ← an mcp_tool hook's `server` fieldA matcher or permission rule written against the bare mcp__infolang__* never fires.
The generated tool-reference block spells out the scoped name for every tool, and a test
asserts nothing outside that block names a tool at all.
Hooks
| Event | Matcher | Handler | Why |
| --- | --- | --- | --- |
| SessionStart | startup\|resume | mcp_tool | Recall for the current workspace, injected as opening context. SessionStart accepts only command and mcp_tool, and an mcp_tool result's text is treated like command stdout — so no process is spawned. |
| SessionStart | startup\|resume | command, sync | Drain coordination events that landed while the session was away. Carries a local read cursor, which a stateless tool call cannot, and must be synchronous: an async hook's stdout is discarded rather than fed back. |
| PreCompact | auto\|manual | command, async | Flush before compaction discards the conversation. An mcp_tool hook cannot carry the session's facts — its input is the hook envelope — so the shim reads transcript_path from stdin. |
| Stop | (none) | command, async | Persist the session outcome. Stop takes no matcher; one written here would never fire. |
The two write hooks have a 5s timeout and are async: true; the drain has a 3s timeout
over a 2s internal budget, because it is the one hook the user waits on.
memory-hook.mjs exits 0 on every failure path — no credential, no network, a 500, a
malformed transcript. A hook must never nag or block a turn.
UserPromptSubmit and PostToolUse are deliberately absent: they fire on every prompt and
every edit, and the latency shows. Opt-in future, not a default.
The write hooks record session markers — where the session ran and what the user asked
for, with credential-shaped strings redacted — not transcripts. A process with no model
cannot judge what is durable; capturing decisions is the skill's job. Stop is rate limited
to one write per 15 minutes per session. Opt out with INFOLANG_HOOKS=off (everything) or
INFOLANG_HOOK_CAPTURE=off (writes only).
The events drain
--mode drain reads GET /coord/v1/events on the configured origin with the same bearer
the MCP server uses, and summarizes what changed into additionalContext. REST rather than
a tool call for three reasons: a tools/call costs three sequential round trips before the
first byte and does not fit a 2s budget; naming a tool here would hardcode a server-owned
name into a shipped client, which is the defect this package exists to not repeat; and a
404 on /coord/v1 is an unambiguous "this deployment has no coordination layer".
Positions live in ~/.config/infolang/events-cursor.json (mode 0600), keyed by endpoint
- credential fingerprint + project directory — the credential is hashed, never stored. The directory is in the key so two checkouts get their own "while you were gone"; the credential is in the key because a cursor is a coordinate in one workspace's log.
The first run reports nothing. With no stored position, starting from the beginning of
the log would open the session by reciting every coordination event the workspace ever
recorded. So a first run pages to the tail, records where the tail was, and stays silent;
only the second run onward has an interval that honestly qualifies as "while you were
gone". A log too large to traverse in one budget keeps advancing silently across sessions
until it catches up, which is why the stored position carries an initialized flag
separate from the cursor itself.
The digest is deduplicated, ordered so lapses and supersessions outrank routine traffic,
and capped at 20 lines plus a remainder count. The position is persisted before the
report is printed, and nothing is printed if that write failed: at-most-once, because a
lost digest is a non-event whereas an unadvanceable cursor would re-announce the same
events forever. Every failure is silent — no credential, no coordination layer, a refused
credential, a 5xx, an unreachable host, a timeout, or a cursor the server rejects (which
re-initializes at the tail rather than failing forever). Opt out with
INFOLANG_HOOK_EVENTS=off.
Why not claude plugin install
claude plugin install <plugin> resolves a name against configured marketplaces; there is
no local-path form (--help offers only --config and --scope). The supported local
route is a skills directory: any folder under one carrying .claude-plugin/plugin.json
loads as <name>@skills-dir next session, with no marketplace and no install step. So
infolang install --client claude-code copies the tree to ~/.claude/skills/infolang/ and
records it in the manifest, which is what lets infolang uninstall remove it again.
Generation and drift
The tool-reference block in SKILL.md and the tool names in hooks.json are generated,
never hand-written:
infolang generate all # regenerate from a live tools/list
infolang generate skill --check # report drift, exit non-zero, write nothing
infolang generate all --from tools.json # render from `infolang tools --json` outputThe block is a function of the tool surface and nothing else — no endpoint, no server version — so "drifted" means "the tools changed", and a self-hosted deployment is not permanently flagged.
Hook tool names are resolved by role, not looked up in a table: the generator scores every advertised tool against generic verb hints and refuses to emit anything when no tool clearly plays a role, or when two tie. A server-side rename becomes a loud regeneration rather than a hook that silently calls nothing.
infolang doctor compares the committed artifacts against the live surface and reports
drift; --fix regenerates them.
Claude Desktop: the .mcpb bundle
npm run build:mcpb # → dist/infolang.mcpbThe bundle is the preferred route for Claude Desktop: it prompts for the API key and stores it in the OS keychain, so no environment variable and no config-file secret are involved. Install it by double-clicking, or via Settings → Extensions → Advanced settings.
manifest.json declares a user_config.api_key field marked sensitive, injected into the
server's environment as ${user_config.api_key} — never into args, because process
arguments are world-readable. The bundled server/launch.mjs spawns mcp-remote as the
stdio bridge and passes the header by env reference (Authorization:${AUTH_HEADER}), which
is also mcp-remote's own workaround for Windows/Cursor mangling spaces inside args.
scripts/build-mcpb.mjs writes the zip directly with node:zlib rather than shelling out
to @anthropic-ai/mcpb, so the release build is hermetic and byte-deterministic. The output
still unpacks and validates with the official tooling.
doctor
infolang doctor # dry run by construction — reads, never writes
infolang doctor --fix # migrate what can be migrated
infolang doctor --jsonPer client it reports whether the config parses, whether the entry is present / modified / missing, whether the endpoint is reachable, whether the credential verifies, and whether the generated artifacts have drifted from the live tool surface.
It also finds what the three retired packages left behind, recognising the shapes from their own sources:
| Artifact | Where | Recognised by |
| --- | --- | --- |
| Server entry with a literal key | ~/.claude.json, ~/.cursor/mcp.json, .mcp.json | il_live_ / il_test_ / il_oauth_ in the entry |
| Server entry using the retired stdio gateway | same | mcp-gateway / mcp-proxy / setup-core in command/args |
| claude-setup hooks | ~/.claude/settings.json, .claude/settings.json | a hook command naming claude-hook.mjs |
| claude-setup managed block | ~/.claude/CLAUDE.md, ./CLAUDE.md | <!-- infolang:begin --> … <!-- infolang:end --> |
| cursor-setup hooks | ~/.cursor/hooks.json, .cursor/hooks.json | [infolang-cursor-setup] in a prompt |
| cursor-setup rules | ~/.cursor/rules/, .cursor/rules/ | infolang-memory.mdc, infolang-output-brevity.mdc |
--fix replaces a legacy server entry by running the ordinary adapter install (same backup,
same atomic write, same verification, same manifest bookkeeping), strips only the hook
entries that carry a legacy marker — leaving anyone else's hooks untouched — swaps the
legacy CLAUDE.md block for a generated one while keeping the surrounding prose, and
regenerates infolang-memory.mdc. Everything is backed up first, and every fix is
idempotent: a second --fix finds nothing and writes nothing.
Configuration
| Variable | Effect |
| --- | --- |
| INFOLANG_URL | Base URL of the deployment. Also --url. Default https://api.infolang.ai. |
| INFOLANG_API_KEY | Credential for the CLI's own API calls, and the variable client configs reference. |
| INFOLANG_BROWSER / BROWSER | Command used to open the login URL, instead of the platform default. |
| INFOLANG_NO_CLIENT_CLI | 1 disables claude mcp add-json / code --add-mcp delegation. |
| INFOLANG_DISABLE_KEYCHAIN | 1 skips the OS keychain and uses the fallback file. |
| INFOLANG_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER / CI | Silence the update notifier. |
| CODEX_HOME | Honoured for the Codex user-scope config path. |
| INFOLANG_DEBUG | 1 prints stack traces on unexpected errors. |
| INFOLANG_PLUGIN_DIR | Overrides where the packaged Claude Code plugin is read from. |
| INFOLANG_HOOKS | off disables every plugin hook. |
| INFOLANG_HOOK_CAPTURE | off disables the PreCompact/Stop writes; SessionStart recall and drain still run. |
| INFOLANG_HOOK_EVENTS | off disables the SessionStart coordination events drain only. |
| INFOLANG_HOOK_MIN_INTERVAL_MS | Rate limit for the Stop hook's writes. Default 15 minutes. |
Update checks
Passive only, and the CLI never mutates itself. Under npx there is zero update machinery
(the next npx infolang already fetches the newest version). Under a global install it does
one non-blocking registry read at most every 24 hours and prints a single line at exit with
the correct upgrade command.
Development
npm install
npm run build # tsdown → dist/cli.js
npm run typecheck
npm test # unit + E2E, then `claude plugin validate --strict`
npm run test:unit
npm run test:e2e # builds first
npm run build:mcpb # → dist/infolang.mcpb
npm run validate:pluginvalidate:plugin runs claude plugin validate plugin/ --strict when the claude binary is
on PATH. When it is not, it skips loudly — printing that the manifest was not validated
— so a green run with no validation is never mistaken for a validated one.
The E2E suite spawns the built binary with a per-test temp HOME/XDG_CONFIG_HOME/
APPDATA/USERPROFILE/CODEX_HOME and a PATH containing only node, so it can never read
or write your real client configs — and never picks up your real claude/code/codex
binaries. No test makes an outbound network request: introspection and login run against a
local fixture HTTP server.
