ladder-mcp 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ All notable changes to Ladder_mcp are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.0.2] - 2026-06-28
8
+
9
+ Security and robustness patch release addressing findings from an external
10
+ adversarial review of `src/`.
11
+
12
+ ### Fixed
13
+
14
+ - **`isAuthenticated` false-positive on corrupt credentials** (`environment.ts`):
15
+ a credentials file that exists but is unreadable or invalid JSON was previously
16
+ reported as authenticated. It now fails closed and returns `false`, satisfying
17
+ NFR-5 honest diagnostics.
18
+ - **Unbounded `stdout`/`stderr` capture in `runKimi`** (`kimi-runner.ts`): CLI
19
+ output is now accumulated through `appendCapped` with a hard ceiling
20
+ (`MAX_CAPTURE_CHARS`), preventing memory exhaustion on runaway output.
21
+ - **Unvalidated `max_output_tokens` and `work_dir`** (`input-validation.ts`):
22
+ non-finite, zero, or negative token counts are clamped to the default budget;
23
+ `work_dir` must be an absolute path to an existing directory before Kimi is
24
+ spawned. Used by `kimi_analyze` and `kimi_resume`.
25
+
26
+ ### Security
27
+
28
+ - **Arbitrary command persisted to `mcp.json`** (`kimi-mcp-config.ts`):
29
+ `kimi_generate_mcp_config` now validates `command` against an allow-list of
30
+ `node`, `npx`, or an absolute path to an existing file. Other values (e.g.
31
+ `powershell`) are rejected and nothing is written.
32
+
33
+ ## [1.0.1] - 2026-06-28
34
+
35
+ Bug-fix release. Issues found by exercising all 20 tools live against a real
36
+ Kimi CLI (the mock-only unit tests had missed them).
37
+
38
+ ### Fixed
39
+
40
+ - **ACP responses lost spaces** (`kimi_chat`): streamed token chunks were each
41
+ trimmed and newline-joined, so `"Two plus two equals four."` came back as
42
+ `"Twoplustwoequalsfour."`. Text fragments are now concatenated as-is and only
43
+ the final string is trimmed. (CLI tools `kimi_analyze`/`kimi_resume` were never
44
+ affected.)
45
+ - **`kimi_export_session` silently no-op'd** while reporting `ok: true`: when no
46
+ session id was given, `kimi export` defaults to the most recent session and asks
47
+ `Export previous session …? [Y/n]` — a prompt that `-y` does not suppress in Kimi
48
+ CLI 0.20.1, so with stdin closed it exited 0 without writing. The tool now
49
+ resolves the most recent session id itself and passes it explicitly (skipping the
50
+ prompt), always passes `-y`, and verifies the output file exists before reporting
51
+ success.
52
+
53
+ ### Changed
54
+
55
+ - `kimi_acp_sessions` now accepts `limit` (default 20) and `work_dir` filters,
56
+ for parity with `kimi_list_sessions`; previously it dumped every ACP session
57
+ across all projects in one response.
58
+
7
59
  ## [1.0.0] - 2026-06-27
8
60
 
9
61
  First release. Windows-first MCP bridge for Kimi Code CLI v24, rebuilt in a
package/README.md CHANGED
@@ -6,7 +6,8 @@ client like Claude Code can run codebase analysis, native sessions, API
6
6
  queries, ACP chat, background tasks, and CLI admin/diagnostics — all on Windows
7
7
  without hardcoded POSIX assumptions.
8
8
 
9
- > Status: **v1.0.0**. Supported platform is **Windows 11 only**.
9
+ > Status: **v1.0.1** ([npm](https://www.npmjs.com/package/ladder-mcp)).
10
+ > Supported platform is **Windows 11 only**.
10
11
 
11
12
  ## Requirements
12
13
 
@@ -15,40 +16,58 @@ without hardcoded POSIX assumptions.
15
16
  - Kimi Code CLI installed (`kimi.exe` on PATH or at `~/.kimi-code/bin/kimi.exe`),
16
17
  authenticated (`~/.kimi-code/`)
17
18
 
18
- ## Install & build
19
+ ## Quick start (from npm)
19
20
 
20
- ```bash
21
- npm install
22
- npm run build # compiles src/ -> dist/ (tests excluded)
23
- ```
21
+ You don't need to clone or build — the package is published on npm and your MCP
22
+ client launches it via `npx`.
24
23
 
25
- Quick checks:
24
+ **Claude Code (one command):**
26
25
 
27
26
  ```bash
28
- npm test # vitest (44 tests)
29
- npm run typecheck # tsc --noEmit (incl. tests)
30
- npm run dev # run the server from source via tsx
27
+ claude mcp add kimi-code -- npx -y ladder-mcp
31
28
  ```
32
29
 
33
- ## Run as an MCP server
34
-
35
- The server speaks MCP over stdio. Point your MCP client at the built entry:
30
+ **Or add it manually to your MCP config:**
36
31
 
37
32
  ```jsonc
38
- // e.g. Claude Code MCP config
39
33
  {
40
34
  "mcpServers": {
41
35
  "kimi-code": {
42
- "command": "node",
43
- "args": ["C:\\path\\to\\Ladder_mcp\\dist\\index.js"]
36
+ "command": "npx",
37
+ "args": ["-y", "ladder-mcp"]
44
38
  }
45
39
  }
46
40
  }
47
41
  ```
48
42
 
43
+ Then in Claude Code run `/mcp` (should show `kimi-code: connected`) and call
44
+ `kimi_status` to confirm the environment is detected.
45
+
46
+ > The server speaks MCP over **stdio**: it is launched and managed by the client,
47
+ > not run by hand. Running `npx ladder-mcp` directly will appear to "hang" — that
48
+ > is the server correctly waiting for a client. Exit with Ctrl+C.
49
+
50
+ Prefer a global install? `npm install -g ladder-mcp`, then use `ladder-mcp` as the
51
+ command instead of `npx -y ladder-mcp`.
52
+
49
53
  To let Kimi Code itself host this server, use the `kimi_generate_mcp_config`
50
54
  tool to produce/merge a `.kimi-code/mcp.json` entry.
51
55
 
56
+ ## Build from source (contributors)
57
+
58
+ ```bash
59
+ npm install
60
+ npm run build # compiles src/ -> dist/ (tests excluded)
61
+ ```
62
+
63
+ Quick checks:
64
+
65
+ ```bash
66
+ npm test # vitest (50 tests)
67
+ npm run typecheck # tsc --noEmit (incl. tests)
68
+ npm run dev # run the server from source via tsx
69
+ ```
70
+
52
71
  ## Tools
53
72
 
54
73
  **Core (v1)** — `kimi_analyze`, `kimi_query`, `kimi_verify`, `kimi_resume`,
@@ -126,7 +126,10 @@ export function isAuthenticated(options) {
126
126
  return Boolean(data.access_token || data.refresh_token || data.id_token || data.token);
127
127
  }
128
128
  catch {
129
- return true;
129
+ // A corrupt or unreadable credentials file is NOT proof of authentication.
130
+ // Returning true here produced a false-positive "authenticated" status that
131
+ // misled diagnostics (NFR-5). Fail closed.
132
+ return false;
130
133
  }
131
134
  }
132
135
  export function isKimiInstalled(options) {
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
+ import { maxChars, validateWorkDir } from './input-validation.js';
5
6
  import { getKimiStatus } from './environment.js';
6
7
  import { runKimiApi, isApiConfigured } from './kimi-api.js';
7
8
  import { runKimi } from './kimi-runner.js';
@@ -13,9 +14,8 @@ import { generateMcpConfig } from './kimi-mcp-config.js';
13
14
  import { buildBudgetProbeGuide, getDesktopStatus } from './desktop-work.js';
14
15
  const server = new McpServer({
15
16
  name: 'kimi-code',
16
- version: '1.0.0',
17
+ version: '1.0.2',
17
18
  });
18
- const DEFAULT_MAX_OUTPUT_CHARS = 60_000;
19
19
  const FORMAT_INSTRUCTIONS = {
20
20
  summary: `
21
21
  OUTPUT FORMAT CONSTRAINTS:
@@ -42,9 +42,6 @@ IMPORTANT: Your response will be consumed by another AI model with limited conte
42
42
  function wrapPrompt(prompt, detailLevel) {
43
43
  return `${prompt}\n${FORMAT_INSTRUCTIONS[detailLevel]}\n${AI_CONSUMER_NOTICE}`;
44
44
  }
45
- function maxChars(maxOutputTokens) {
46
- return maxOutputTokens ? maxOutputTokens * 4 : DEFAULT_MAX_OUTPUT_CHARS;
47
- }
48
45
  function textResponse(text, isError = false) {
49
46
  return { content: [{ type: 'text', text }], isError };
50
47
  }
@@ -60,6 +57,9 @@ server.tool('kimi_analyze', 'Send a prompt to Kimi Code for codebase analysis. D
60
57
  max_output_tokens: z.number().optional(),
61
58
  include_thinking: z.boolean().optional(),
62
59
  }, async ({ prompt, work_dir, session_id, new_session, detail_level, max_output_tokens, include_thinking }) => {
60
+ const workDirError = validateWorkDir(work_dir);
61
+ if (workDirError)
62
+ return textResponse(`Error: ${workDirError}`, true);
63
63
  const result = await runKimi({
64
64
  prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
65
65
  workDir: work_dir,
@@ -107,6 +107,9 @@ server.tool('kimi_resume', 'Resume an explicit Kimi Code session with a new prom
107
107
  max_output_tokens: z.number().optional(),
108
108
  include_thinking: z.boolean().optional(),
109
109
  }, async ({ session_id, prompt, work_dir, detail_level, max_output_tokens, include_thinking }) => {
110
+ const workDirError = validateWorkDir(work_dir);
111
+ if (workDirError)
112
+ return textResponse(`Error: ${workDirError}`, true);
110
113
  const result = await runKimi({
111
114
  prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
112
115
  workDir: work_dir,
@@ -219,8 +222,11 @@ server.tool('kimi_chat', 'Send a prompt through Kimi ACP over stdio. Set backgro
219
222
  });
220
223
  return textResponse(JSON.stringify(result, null, 2), !result.ok);
221
224
  });
222
- server.tool('kimi_acp_sessions', 'List Kimi ACP sessions through `session/list`.', {}, async () => {
223
- const result = await listAcpSessions();
225
+ server.tool('kimi_acp_sessions', 'List Kimi ACP sessions through `session/list`.', {
226
+ limit: z.number().int().positive().optional(),
227
+ work_dir: z.string().optional(),
228
+ }, async ({ limit, work_dir }) => {
229
+ const result = await listAcpSessions({ limit, workDir: work_dir });
224
230
  return textResponse(result.ok ? result.text : `Error: ${result.error}`, !result.ok);
225
231
  });
226
232
  server.tool('kimi_cancel', 'Cancel an active Ladder task by task_id or a Kimi ACP session by session_id.', {
@@ -0,0 +1,29 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ export const DEFAULT_MAX_OUTPUT_CHARS = 60_000;
4
+ // Convert an untrusted max_output_tokens into a character budget. NaN/Infinity/zero/
5
+ // negative/non-integer would otherwise produce a nonsensical budget (e.g. NaN) handed
6
+ // to truncation, so clamp those to the default.
7
+ export function maxChars(maxOutputTokens) {
8
+ if (typeof maxOutputTokens !== 'number' || !Number.isFinite(maxOutputTokens) || maxOutputTokens <= 0) {
9
+ return DEFAULT_MAX_OUTPUT_CHARS;
10
+ }
11
+ return Math.floor(maxOutputTokens) * 4;
12
+ }
13
+ // Reject a work_dir before spawning Kimi: a relative or non-existent directory would
14
+ // otherwise be passed to the CLI as cwd and fail opaquely. Returns an error string when
15
+ // invalid, or undefined when the path is an existing absolute directory.
16
+ export function validateWorkDir(workDir) {
17
+ if (!path.isAbsolute(workDir))
18
+ return 'work_dir must be an absolute path.';
19
+ let stat;
20
+ try {
21
+ stat = fs.statSync(workDir);
22
+ }
23
+ catch {
24
+ return `work_dir does not exist: ${workDir}`;
25
+ }
26
+ if (!stat.isDirectory())
27
+ return `work_dir is not a directory: ${workDir}`;
28
+ return undefined;
29
+ }
@@ -44,6 +44,19 @@ function assertWritableProjectTarget(projectDir) {
44
44
  throw new Error('Refusing to write MCP config under read-only kimi-code-mcp reference tree.');
45
45
  }
46
46
  }
47
+ const ALLOWED_BARE_COMMANDS = new Set(['node', 'npx']);
48
+ // Constrain the launcher written into mcp.json. Without this, an arbitrary `command`
49
+ // (e.g. "powershell") would be persisted and later executed when Kimi loads the
50
+ // server config. Allow only the known Node launchers by bare name, or an absolute
51
+ // path to an existing file (a vetted local binary).
52
+ function assertSafeCommand(command) {
53
+ const value = command.trim();
54
+ if (ALLOWED_BARE_COMMANDS.has(value))
55
+ return;
56
+ if (path.isAbsolute(value) && fs.existsSync(value) && fs.statSync(value).isFile())
57
+ return;
58
+ throw new Error(`command must be one of [${[...ALLOWED_BARE_COMMANDS].join(', ')}] or an absolute path to an existing file; got "${command}".`);
59
+ }
47
60
  function readMcpServers(existing) {
48
61
  const value = existing.mcpServers;
49
62
  if (value === undefined || value === null)
@@ -66,8 +79,10 @@ export function generateMcpConfig(options = {}) {
66
79
  throw new Error('server_name must contain only letters, digits, underscores, and hyphens.');
67
80
  }
68
81
  const defaults = defaultServerCommand();
82
+ const command = options.command ?? defaults.command;
83
+ assertSafeCommand(command);
69
84
  const serverConfig = {
70
- command: options.command ?? defaults.command,
85
+ command,
71
86
  args: options.args ?? defaults.args,
72
87
  env: {},
73
88
  };
@@ -1,6 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import * as path from 'node:path';
3
3
  import { buildKimiEnv, resolveKimiPaths } from './environment.js';
4
+ const MAX_CAPTURE_CHARS = 16 * 1024 * 1024;
5
+ // Append to a captured stream while enforcing a hard ceiling so a runaway CLI cannot
6
+ // grow the buffer without bound. Once the cap is reached, further data is dropped (the
7
+ // stream is still drained by Node, just not stored).
8
+ export function appendCapped(current, addition, max) {
9
+ if (current.length >= max)
10
+ return current;
11
+ const next = current + addition;
12
+ return next.length > max ? next.slice(0, max) : next;
13
+ }
4
14
  export function buildKimiArgs(config) {
5
15
  const args = ['-p', config.prompt, '--output-format', 'stream-json'];
6
16
  if (config.sessionId) {
@@ -120,8 +130,8 @@ export function runKimi(config) {
120
130
  killProcessTree(proc.pid);
121
131
  finish({ ok: false, text: '', error: `Kimi timed out after ${Math.round(timeoutMs / 1000)}s` });
122
132
  }, timeoutMs);
123
- proc.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf-8'); });
124
- proc.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf-8'); });
133
+ proc.stdout?.on('data', (chunk) => { stdout = appendCapped(stdout, chunk.toString('utf-8'), MAX_CAPTURE_CHARS); });
134
+ proc.stderr?.on('data', (chunk) => { stderr = appendCapped(stderr, chunk.toString('utf-8'), MAX_CAPTURE_CHARS); });
125
135
  proc.on('error', (err) => {
126
136
  finish({ ok: false, text: '', error: err instanceof Error ? err.message : String(err) });
127
137
  });
@@ -153,7 +153,11 @@ function extractTextDeep(value) {
153
153
  return [...direct, ...nested];
154
154
  }
155
155
  export function extractAcpText(value) {
156
- return extractTextDeep(value).map((part) => part.trim()).filter(Boolean).join('\n');
156
+ // Concatenate text fragments as-is. Kimi streams the answer as many small
157
+ // agent_message_chunk tokens (e.g. "Two", " plus", " two"); trimming each
158
+ // fragment or joining on newlines would drop the spaces between tokens and
159
+ // run words together. Callers trim the final assembled string.
160
+ return extractTextDeep(value).join('');
157
161
  }
158
162
  // Keep the trailing portion of `text` within a UTF-8 *byte* budget. Slicing by
159
163
  // String length (UTF-16 code units) under-counts multibyte characters, so the
@@ -217,7 +221,7 @@ export class AcpClient extends EventEmitter {
217
221
  async initialize() {
218
222
  return this.request('initialize', {
219
223
  protocolVersion: 1,
220
- clientInfo: { name: 'ladder-mcp', version: '1.0.0' },
224
+ clientInfo: { name: 'ladder-mcp', version: '1.0.2' },
221
225
  capabilities: {},
222
226
  }, 30_000);
223
227
  }
@@ -357,7 +361,9 @@ export async function runAcpPrompt(options) {
357
361
  sessionId = extractSessionId(await client.newSession(options.workDir));
358
362
  }
359
363
  const result = await client.prompt(sessionId, options.prompt, options.workDir);
360
- let text = extractAcpText(result) || client.getUpdateText();
364
+ // extractAcpText no longer trims fragments (to preserve inter-token spaces),
365
+ // so trim the fully assembled string here. getUpdateText already trims.
366
+ let text = extractAcpText(result).trim() || client.getUpdateText();
361
367
  if (!text) {
362
368
  try {
363
369
  text = JSON.stringify(result, null, 2);
@@ -385,11 +391,28 @@ export async function runAcpPrompt(options) {
385
391
  client.close();
386
392
  }
387
393
  }
388
- export async function listAcpSessions() {
394
+ const DEFAULT_ACP_SESSION_LIMIT = 20;
395
+ function normalizePath(value) {
396
+ return value.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
397
+ }
398
+ export async function listAcpSessions(options = {}) {
389
399
  const client = new AcpClient(60_000);
390
400
  try {
391
401
  await client.initialize();
392
402
  const result = await client.listSessions();
403
+ // Filter by working directory and cap the count for parity with kimi_list_sessions;
404
+ // session/list otherwise returns every ACP session across all projects in one blob.
405
+ const sessions = result?.sessions;
406
+ if (Array.isArray(sessions)) {
407
+ let filtered = sessions;
408
+ if (options.workDir) {
409
+ const target = normalizePath(options.workDir);
410
+ filtered = filtered.filter((s) => typeof s?.cwd === 'string' && normalizePath(s.cwd) === target);
411
+ }
412
+ const limit = options.limit ?? DEFAULT_ACP_SESSION_LIMIT;
413
+ const limited = limit > 0 ? filtered.slice(0, limit) : filtered;
414
+ return { ok: true, text: JSON.stringify({ sessions: limited, total: filtered.length }, null, 2) };
415
+ }
393
416
  return { ok: true, text: JSON.stringify(result, null, 2) };
394
417
  }
395
418
  catch (error) {
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { buildKimiEnv, getKimiStatus, resolveKimiPaths } from '../environment.js';
6
+ import { listSessions } from '../session-store.js';
6
7
  const execFileAsync = promisify(execFile);
7
8
  const ADMIN_TIMEOUT_MS = 30_000;
8
9
  // Quote a single argument for the human-readable preview command. This string is
@@ -76,8 +77,12 @@ export function buildExportArgs(options) {
76
77
  if (options.sessionId)
77
78
  args.push(options.sessionId);
78
79
  args.push('-o', options.outputPath);
79
- if (options.overwriteExisting === true)
80
- args.push('-y');
80
+ // Always auto-confirm. `kimi export` prompts "Export previous session …? [Y/n]"
81
+ // even for a brand-new path; since we close stdin, that prompt would get EOF and
82
+ // the export would silently no-op while still exiting 0. Overwrite safety is
83
+ // enforced by our own pre-check (assertSafeOutputPath + statSync + overwrite_existing),
84
+ // so unconditionally passing -y is safe.
85
+ args.push('-y');
81
86
  if (options.includeGlobalLog !== true)
82
87
  args.push('--no-include-global-log');
83
88
  return args;
@@ -179,6 +184,18 @@ export async function exportKimiSession(options) {
179
184
  error: error instanceof Error ? error.message : String(error),
180
185
  };
181
186
  }
187
+ // Resolve an explicit session id ourselves when the caller omitted one. `kimi export`
188
+ // otherwise defaults to "the most recent session" and asks "Export previous session …?
189
+ // [Y/n]" — a prompt that `-y` does NOT suppress in Kimi CLI 0.20.1, so with stdin closed
190
+ // it hangs/aborts and writes nothing. Passing an explicit id skips that prompt entirely.
191
+ let sessionId = options.sessionId;
192
+ if (!sessionId) {
193
+ const recent = listSessions({ limit: 1 })[0];
194
+ if (!recent) {
195
+ return { ok: false, stdout: '', stderr: '', error: 'No Kimi session found to export. Provide an explicit session_id.' };
196
+ }
197
+ sessionId = recent.id;
198
+ }
182
199
  const outputPath = path.resolve(options.outputPath);
183
200
  let existingStat;
184
201
  try {
@@ -207,7 +224,18 @@ export async function exportKimiSession(options) {
207
224
  };
208
225
  }
209
226
  }
210
- return runKimiCommand(buildExportArgs({ ...options, outputPath }), 120_000);
227
+ const result = await runKimiCommand(buildExportArgs({ ...options, sessionId, outputPath }), 120_000);
228
+ // The CLI can exit 0 without producing the archive (e.g. a declined/aborted prompt).
229
+ // Verify the file actually exists so we never report success for a no-op export.
230
+ if (result.ok && !fs.existsSync(outputPath)) {
231
+ return {
232
+ ok: false,
233
+ stdout: result.stdout,
234
+ stderr: result.stderr,
235
+ error: 'Kimi export exited without creating the output file. No archive was written.',
236
+ };
237
+ }
238
+ return result;
211
239
  }
212
240
  export function visualizeSession(options) {
213
241
  const binary = requireBinary();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ladder-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Windows-first MCP bridge for Kimi Code CLI v24",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,8 @@
19
19
  "build": "tsc -p tsconfig.build.json",
20
20
  "start": "node dist/index.js",
21
21
  "test": "vitest run",
22
- "typecheck": "tsc --noEmit"
22
+ "typecheck": "tsc --noEmit",
23
+ "prepublishOnly": "npm run build && npm test"
23
24
  },
24
25
  "engines": {
25
26
  "node": ">=18.0.0"