ladder-mcp 1.0.2 → 1.1.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 +242 -115
- package/README.md +21 -15
- package/dist/desktop-work.js +10 -2
- package/dist/index.js +228 -195
- package/dist/kimi-api.js +5 -1
- package/dist/kimi-mcp-config.js +32 -16
- package/dist/kimi-runner.js +194 -15
- package/dist/progress.js +102 -0
- package/dist/session-store.js +25 -9
- package/dist/task-store.js +4 -0
- package/dist/transports/acp.js +307 -15
- package/dist/transports/cli-admin.js +9 -7
- package/dist/version.js +17 -0
- package/package.json +42 -38
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import { z } from 'zod';
|
|
5
7
|
import { maxChars, validateWorkDir } from './input-validation.js';
|
|
6
8
|
import { getKimiStatus } from './environment.js';
|
|
@@ -9,12 +11,14 @@ import { runKimi } from './kimi-runner.js';
|
|
|
9
11
|
import { listSessions } from './session-store.js';
|
|
10
12
|
import { cancelAcpSession, listAcpSessions, runAcpPrompt } from './transports/acp.js';
|
|
11
13
|
import { exportKimiSession, getKimiCapabilities, listKimiProviders, runKimiDoctor, visualizeSession, } from './transports/cli-admin.js';
|
|
12
|
-
import { taskStore } from './task-store.js';
|
|
13
14
|
import { generateMcpConfig } from './kimi-mcp-config.js';
|
|
14
15
|
import { buildBudgetProbeGuide, getDesktopStatus } from './desktop-work.js';
|
|
16
|
+
import { taskStore } from './task-store.js';
|
|
17
|
+
import { combineReporters, createMcpProgressReporter, formatProgressLine } from './progress.js';
|
|
18
|
+
import { VERSION } from './version.js';
|
|
15
19
|
const server = new McpServer({
|
|
16
20
|
name: 'kimi-code',
|
|
17
|
-
version:
|
|
21
|
+
version: VERSION,
|
|
18
22
|
});
|
|
19
23
|
const FORMAT_INSTRUCTIONS = {
|
|
20
24
|
summary: `
|
|
@@ -48,163 +52,83 @@ function textResponse(text, isError = false) {
|
|
|
48
52
|
function buildResponse(text, thinking, includeThinking) {
|
|
49
53
|
return thinking && includeThinking ? `<kimi-thinking>\n${thinking}\n</kimi-thinking>\n\n${text}` : text;
|
|
50
54
|
}
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
function resolveDefaultServerCommand() {
|
|
56
|
+
const distIndex = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
|
|
57
|
+
return { command: process.execPath, args: [distIndex] };
|
|
58
|
+
}
|
|
59
|
+
server.tool('kimi_code', 'Agentic work in a repository: analyze and edit files. Defaults to in-process CLI transport.', {
|
|
60
|
+
prompt: z.string().describe('The analysis or coding prompt for Kimi.'),
|
|
53
61
|
work_dir: z.string().describe('Absolute path to the codebase root directory.'),
|
|
54
|
-
session_id: z.string().optional().describe('Explicit Kimi session id to resume
|
|
55
|
-
new_session: z.boolean().optional().describe('Start fresh
|
|
62
|
+
session_id: z.string().optional().describe('Explicit Kimi session id to resume.'),
|
|
63
|
+
new_session: z.boolean().optional().describe('Start fresh instead of continuing the last session. Default: false.'),
|
|
64
|
+
edit: z.boolean().optional().describe('Allow file modifications. Default: false (analysis-only intent).'),
|
|
65
|
+
background: z.boolean().optional().describe('Track as a long-running background task. Every progress event (including each action) is appended to the task log, readable via kimi_tasks — the full accumulating transcript, unlike the single overwriting live-progress line of a foreground call.'),
|
|
66
|
+
transport: z.enum(['cli', 'acp']).optional().describe("How the server drives Kimi. Both edit files and resume sessions; they differ in robustness and live-progress detail. 'cli' (default): one-shot process, most robust on Windows, but live progress is coarse — only streaming-output volume, no per-action lines. 'acp': persistent JSON-RPC session that emits granular live progress (tool calls, plan steps) and supports interactive permission prompts, but is heavier and more fragile. Prefer 'cli' for plain codegen/analysis; choose 'acp' when you need to watch each action live or handle mid-run prompts."),
|
|
67
|
+
session_mode: z.enum(['new', 'load', 'resume']).optional().describe("ACP session mode when transport='acp'. Default: inferred from session_id/new_session."),
|
|
56
68
|
detail_level: z.enum(['summary', 'normal', 'detailed']).optional(),
|
|
57
69
|
max_output_tokens: z.number().optional(),
|
|
58
70
|
include_thinking: z.boolean().optional(),
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return textResponse(`Error: ${workDirError}`, true);
|
|
63
|
-
const result = await runKimi({
|
|
64
|
-
prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
|
|
65
|
-
workDir: work_dir,
|
|
66
|
-
sessionId: session_id,
|
|
67
|
-
continueLast: !session_id && new_session !== true,
|
|
68
|
-
timeoutMs: 600_000,
|
|
69
|
-
maxOutputChars: maxChars(max_output_tokens),
|
|
70
|
-
includeThinking: include_thinking ?? false,
|
|
71
|
-
});
|
|
72
|
-
if (!result.ok)
|
|
73
|
-
return textResponse(`Error: ${result.error}`, true);
|
|
74
|
-
const sessionLine = result.sessionId ? `\n\nSession: ${result.sessionId}` : '';
|
|
75
|
-
return textResponse(`${buildResponse(result.text, result.thinking, include_thinking ?? false)}${sessionLine}`);
|
|
76
|
-
});
|
|
77
|
-
server.tool('kimi_query', 'Ask Kimi Code a contextless programming question through the Kimi Code API.', {
|
|
78
|
-
prompt: z.string().describe('The question to ask Kimi.'),
|
|
79
|
-
max_output_tokens: z.number().optional(),
|
|
80
|
-
include_thinking: z.boolean().optional(),
|
|
81
|
-
}, async ({ prompt, max_output_tokens, include_thinking }) => {
|
|
82
|
-
const result = await runKimiApi({ prompt, timeoutMs: 120_000, maxOutputChars: maxChars(max_output_tokens) });
|
|
83
|
-
if (!result.ok)
|
|
84
|
-
return textResponse(`Error: ${result.error}`, true);
|
|
85
|
-
return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
|
|
86
|
-
});
|
|
87
|
-
server.tool('kimi_verify', 'Get an independent second opinion from Kimi Code through the Kimi Code API.', {
|
|
88
|
-
context: z.string().describe('Self-contained material for Kimi to examine.'),
|
|
89
|
-
question: z.string().optional().describe('What to verify or focus on.'),
|
|
90
|
-
role: z.string().optional().describe('Override the reviewer persona.'),
|
|
91
|
-
max_output_tokens: z.number().optional(),
|
|
92
|
-
include_thinking: z.boolean().optional(),
|
|
93
|
-
}, async ({ context, question, role, max_output_tokens, include_thinking }) => {
|
|
94
|
-
const focus = question?.trim() || 'Independently verify correctness. Surface bugs, edge cases, security issues, and concrete improvements.';
|
|
95
|
-
const system = role?.trim() || 'You are a meticulous, independent senior engineer. Be skeptical, specific, and actionable.';
|
|
96
|
-
const prompt = `## Your task\n${focus}\n\n## Material to verify\n${context}\n${AI_CONSUMER_NOTICE}`;
|
|
97
|
-
const result = await runKimiApi({ prompt, system, timeoutMs: 300_000, maxOutputChars: maxChars(max_output_tokens) });
|
|
98
|
-
if (!result.ok)
|
|
99
|
-
return textResponse(`Error: ${result.error}`, true);
|
|
100
|
-
return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
|
|
101
|
-
});
|
|
102
|
-
server.tool('kimi_resume', 'Resume an explicit Kimi Code session with a new prompt via -S.', {
|
|
103
|
-
session_id: z.string().describe('Session ID to resume.'),
|
|
104
|
-
prompt: z.string().describe('New prompt to send.'),
|
|
105
|
-
work_dir: z.string().describe('Working directory for the session.'),
|
|
106
|
-
detail_level: z.enum(['summary', 'normal', 'detailed']).optional(),
|
|
107
|
-
max_output_tokens: z.number().optional(),
|
|
108
|
-
include_thinking: z.boolean().optional(),
|
|
109
|
-
}, async ({ session_id, prompt, work_dir, detail_level, max_output_tokens, include_thinking }) => {
|
|
110
|
-
const workDirError = validateWorkDir(work_dir);
|
|
71
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
72
|
+
}, async ({ prompt, work_dir, session_id, new_session, edit, background, transport, session_mode, detail_level, max_output_tokens, include_thinking, timeout_ms }, extra) => {
|
|
73
|
+
const workDirError = work_dir ? validateWorkDir(work_dir) : undefined;
|
|
111
74
|
if (workDirError)
|
|
112
75
|
return textResponse(`Error: ${workDirError}`, true);
|
|
113
|
-
const
|
|
114
|
-
|
|
76
|
+
const includeThinkingValue = include_thinking ?? false;
|
|
77
|
+
const effectiveTransport = transport ?? 'cli';
|
|
78
|
+
const appendReporter = (append) => (event) => append(formatProgressLine(event));
|
|
79
|
+
if (effectiveTransport === 'cli') {
|
|
80
|
+
if (background) {
|
|
81
|
+
const task = taskStore.create('cli-code', async (_signal, append) => {
|
|
82
|
+
const result = await runKimi({
|
|
83
|
+
prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
|
|
84
|
+
workDir: work_dir,
|
|
85
|
+
sessionId: session_id,
|
|
86
|
+
continueLast: !session_id && new_session !== true,
|
|
87
|
+
edit,
|
|
88
|
+
timeoutMs: timeout_ms ?? 600_000,
|
|
89
|
+
maxOutputChars: maxChars(max_output_tokens),
|
|
90
|
+
includeThinking: includeThinkingValue,
|
|
91
|
+
onProgress: appendReporter(append),
|
|
92
|
+
signal: _signal,
|
|
93
|
+
});
|
|
94
|
+
if (!result.ok)
|
|
95
|
+
throw new Error(result.error ?? 'CLI code task failed');
|
|
96
|
+
return { output: result.text, metadata: { sessionId: result.sessionId } };
|
|
97
|
+
});
|
|
98
|
+
return textResponse(JSON.stringify(task, null, 2));
|
|
99
|
+
}
|
|
100
|
+
const result = await runKimi({
|
|
101
|
+
prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
|
|
102
|
+
workDir: work_dir,
|
|
103
|
+
sessionId: session_id,
|
|
104
|
+
continueLast: !session_id && new_session !== true,
|
|
105
|
+
edit,
|
|
106
|
+
timeoutMs: timeout_ms ?? 600_000,
|
|
107
|
+
maxOutputChars: maxChars(max_output_tokens),
|
|
108
|
+
includeThinking: includeThinkingValue,
|
|
109
|
+
onProgress: createMcpProgressReporter(extra),
|
|
110
|
+
signal: extra?.signal,
|
|
111
|
+
});
|
|
112
|
+
if (!result.ok)
|
|
113
|
+
return textResponse(`Error: ${result.error}`, true);
|
|
114
|
+
const sessionLine = result.sessionId ? `\n\nSession: ${result.sessionId}` : '';
|
|
115
|
+
return textResponse(`${buildResponse(result.text, result.thinking, includeThinkingValue)}${sessionLine}`);
|
|
116
|
+
}
|
|
117
|
+
const defaultAcpSessionMode = session_mode ?? (session_id ? 'resume' : 'new');
|
|
118
|
+
const runAcp = async (signal, onProgress) => runAcpPrompt({
|
|
119
|
+
prompt,
|
|
115
120
|
workDir: work_dir,
|
|
116
121
|
sessionId: session_id,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (!result.ok)
|
|
122
|
-
return textResponse(`Error: ${result.error}`, true);
|
|
123
|
-
return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
|
|
124
|
-
});
|
|
125
|
-
server.tool('kimi_list_sessions', 'List existing Kimi Code sessions with ids, titles, working directories, and timestamps.', {
|
|
126
|
-
work_dir: z.string().optional().describe('Filter sessions by working directory path.'),
|
|
127
|
-
limit: z.number().optional().describe('Max sessions to return. Default: 20.'),
|
|
128
|
-
}, async ({ work_dir, limit }) => {
|
|
129
|
-
const sessions = listSessions({ workDir: work_dir, limit: limit ?? 20 });
|
|
130
|
-
return textResponse(sessions.length ? JSON.stringify(sessions, null, 2) : 'No Kimi sessions found.');
|
|
131
|
-
});
|
|
132
|
-
server.tool('kimi_status', 'Check Kimi CLI installation, version, authentication, catalog, and API availability.', {}, async () => {
|
|
133
|
-
const status = await getKimiStatus();
|
|
134
|
-
const lines = [
|
|
135
|
-
'## Kimi CLI Status',
|
|
136
|
-
`- Installed: ${status.installed ? 'Yes' : 'No'}`,
|
|
137
|
-
`- Binary: ${status.binPath ? `\`${status.binPath}\`` : 'Not found'}`,
|
|
138
|
-
`- Version: ${status.version ?? '(unable to detect)'}`,
|
|
139
|
-
`- Authenticated: ${status.authenticated ? 'Yes' : 'No'}`,
|
|
140
|
-
`- Catalog Found: ${status.catalogFound ? 'Yes' : 'No'}`,
|
|
141
|
-
`- Catalog: \`${status.catalogPath}\``,
|
|
142
|
-
`- Credentials Found: ${status.credentialsFound ? 'Yes' : 'No'}`,
|
|
143
|
-
`- Config Found: ${status.configFound ? 'Yes' : 'No'}`,
|
|
144
|
-
'',
|
|
145
|
-
'## Kimi Code API',
|
|
146
|
-
`- Configured: ${isApiConfigured() ? 'Yes' : 'No'}`,
|
|
147
|
-
];
|
|
148
|
-
if (status.error)
|
|
149
|
-
lines.push('', `Action required: ${status.error}`);
|
|
150
|
-
return textResponse(lines.join('\n'), !status.installed && !status.apiConfigured);
|
|
151
|
-
});
|
|
152
|
-
server.tool('kimi_capabilities', 'Report Kimi CLI, admin command, ACP, and experimental desktop-read-only capabilities.', {}, async () => textResponse(JSON.stringify(await getKimiCapabilities(), null, 2)));
|
|
153
|
-
server.tool('kimi_doctor', 'Run Kimi Code configuration doctor for config.toml or tui.toml.', {
|
|
154
|
-
target: z.enum(['config', 'tui']).optional(),
|
|
155
|
-
path: z.string().optional().describe('Optional config/tui path to validate.'),
|
|
156
|
-
}, async ({ target, path }) => {
|
|
157
|
-
const result = await runKimiDoctor(target ?? 'config', path);
|
|
158
|
-
return textResponse(JSON.stringify(result, null, 2), !result.ok);
|
|
159
|
-
});
|
|
160
|
-
server.tool('kimi_provider_list', 'List configured Kimi providers and models using `kimi provider list --json`.', {}, async () => textResponse(JSON.stringify(await listKimiProviders(), null, 2)));
|
|
161
|
-
server.tool('kimi_export_session', 'Export a Kimi session ZIP. Requires explicit output_path and excludes the global diagnostic log by default.', {
|
|
162
|
-
output_path: z.string().describe('Explicit ZIP output path.'),
|
|
163
|
-
session_id: z.string().optional().describe('Session id to export; defaults to most recent Kimi session.'),
|
|
164
|
-
include_global_log: z.boolean().optional().describe('Include active global diagnostic log. Default: false.'),
|
|
165
|
-
overwrite_existing: z.boolean().optional().describe('Overwrite output_path if it already exists. Default: false.'),
|
|
166
|
-
}, async ({ output_path, session_id, include_global_log, overwrite_existing }) => {
|
|
167
|
-
const result = await exportKimiSession({
|
|
168
|
-
outputPath: output_path,
|
|
169
|
-
sessionId: session_id,
|
|
170
|
-
includeGlobalLog: include_global_log ?? false,
|
|
171
|
-
overwriteExisting: overwrite_existing ?? false,
|
|
122
|
+
sessionMode: defaultAcpSessionMode,
|
|
123
|
+
timeoutMs: timeout_ms,
|
|
124
|
+
signal,
|
|
125
|
+
onProgress,
|
|
172
126
|
});
|
|
173
|
-
return textResponse(JSON.stringify(result, null, 2), !result.ok);
|
|
174
|
-
});
|
|
175
|
-
server.tool('kimi_visualize_session', 'Preview or launch Kimi session visualizer on localhost using `kimi vis --no-open`.', {
|
|
176
|
-
session_id: z.string().optional(),
|
|
177
|
-
host: z.enum(['127.0.0.1', 'localhost', '::1']).optional().describe('Localhost host to bind. Default: 127.0.0.1.'),
|
|
178
|
-
port: z.number().int().min(1).max(65535).optional().describe('Port to bind. Default: 58628.'),
|
|
179
|
-
launch: z.boolean().optional().describe('Actually start the visualizer process. Default: false.'),
|
|
180
|
-
}, async ({ session_id, host, port, launch }) => {
|
|
181
|
-
try {
|
|
182
|
-
return textResponse(JSON.stringify(visualizeSession({ sessionId: session_id, host, port, launch }), null, 2));
|
|
183
|
-
}
|
|
184
|
-
catch (error) {
|
|
185
|
-
return textResponse(`Error: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
server.tool('kimi_chat', 'Send a prompt through Kimi ACP over stdio. Set background=true for long-running work tracked by task tools.', {
|
|
189
|
-
prompt: z.string(),
|
|
190
|
-
work_dir: z.string().optional(),
|
|
191
|
-
session_id: z.string().optional(),
|
|
192
|
-
session_mode: z.enum(['new', 'load', 'resume']).optional(),
|
|
193
|
-
background: z.boolean().optional(),
|
|
194
|
-
timeout_ms: z.number().int().positive().optional(),
|
|
195
|
-
}, async ({ prompt, work_dir, session_id, session_mode, background, timeout_ms }) => {
|
|
196
127
|
if (background) {
|
|
197
|
-
const task = taskStore.create('acp-
|
|
198
|
-
const result = await
|
|
199
|
-
prompt,
|
|
200
|
-
workDir: work_dir,
|
|
201
|
-
sessionId: session_id,
|
|
202
|
-
sessionMode: session_mode ?? (session_id ? 'resume' : 'new'),
|
|
203
|
-
timeoutMs: timeout_ms,
|
|
204
|
-
signal,
|
|
205
|
-
});
|
|
128
|
+
const task = taskStore.create('acp-code', async (_signal, append) => {
|
|
129
|
+
const result = await runAcp(_signal, combineReporters(appendReporter(append), createMcpProgressReporter(extra)));
|
|
206
130
|
if (!result.ok)
|
|
207
|
-
throw new Error(result.error ?? 'ACP
|
|
131
|
+
throw new Error(result.error ?? 'ACP code task failed');
|
|
208
132
|
const metadata = { ...(result.metadata ?? {}) };
|
|
209
133
|
if (!('sessionId' in metadata)) {
|
|
210
134
|
metadata.sessionId = result.sessionId;
|
|
@@ -213,28 +137,86 @@ server.tool('kimi_chat', 'Send a prompt through Kimi ACP over stdio. Set backgro
|
|
|
213
137
|
});
|
|
214
138
|
return textResponse(JSON.stringify(task, null, 2));
|
|
215
139
|
}
|
|
216
|
-
const result = await
|
|
217
|
-
prompt,
|
|
218
|
-
workDir: work_dir,
|
|
219
|
-
sessionId: session_id,
|
|
220
|
-
sessionMode: session_mode ?? (session_id ? 'resume' : 'new'),
|
|
221
|
-
timeoutMs: timeout_ms,
|
|
222
|
-
});
|
|
140
|
+
const result = await runAcp(extra?.signal, createMcpProgressReporter(extra));
|
|
223
141
|
return textResponse(JSON.stringify(result, null, 2), !result.ok);
|
|
224
142
|
});
|
|
225
|
-
server.tool('
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
143
|
+
server.tool('kimi_ask', 'Stateless question or independent review. Text only, no repo, no edits.', {
|
|
144
|
+
prompt: z.string().describe('The question to ask or the focus for verification.'),
|
|
145
|
+
context: z.string().optional().describe('Material to examine (switches to verify mode).'),
|
|
146
|
+
role: z.string().optional().describe('Reviewer persona override for verify mode.'),
|
|
147
|
+
max_output_tokens: z.number().optional(),
|
|
148
|
+
include_thinking: z.boolean().optional(),
|
|
149
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
150
|
+
}, async ({ prompt, context, role, max_output_tokens, include_thinking, timeout_ms }) => {
|
|
151
|
+
const includeThinkingValue = include_thinking ?? false;
|
|
152
|
+
if (context) {
|
|
153
|
+
const focus = prompt.trim() || 'Independently verify correctness. Surface bugs, edge cases, security issues, and concrete improvements.';
|
|
154
|
+
const system = role?.trim() || 'You are a meticulous, independent senior engineer. Be skeptical, specific, and actionable.';
|
|
155
|
+
const verifyPrompt = `## Your task\n${focus}\n\n## Material to verify\n${context}\n${AI_CONSUMER_NOTICE}`;
|
|
156
|
+
const result = await runKimiApi({ prompt: verifyPrompt, system, timeoutMs: timeout_ms ?? 300_000, maxOutputChars: maxChars(max_output_tokens) });
|
|
157
|
+
if (!result.ok)
|
|
158
|
+
return textResponse(`Error: ${result.error}`, true);
|
|
159
|
+
return textResponse(buildResponse(result.text, result.thinking, includeThinkingValue));
|
|
160
|
+
}
|
|
161
|
+
const result = await runKimiApi({ prompt, timeoutMs: timeout_ms ?? 120_000, maxOutputChars: maxChars(max_output_tokens) });
|
|
162
|
+
if (!result.ok)
|
|
163
|
+
return textResponse(`Error: ${result.error}`, true);
|
|
164
|
+
return textResponse(buildResponse(result.text, result.thinking, includeThinkingValue));
|
|
165
|
+
});
|
|
166
|
+
server.tool('kimi_sessions', 'List/inspect Kimi sessions from the CLI catalog, ACP, or both.', {
|
|
167
|
+
work_dir: z.string().optional().describe('Filter sessions by working directory path.'),
|
|
168
|
+
limit: z.number().optional().describe('Max sessions to return. Default: 20.'),
|
|
169
|
+
source: z.enum(['cli', 'acp', 'all']).optional().describe("Source: 'cli', 'acp', or 'all'. Default: 'all'."),
|
|
170
|
+
}, async ({ work_dir, limit, source }) => {
|
|
171
|
+
const effectiveSource = source ?? 'all';
|
|
172
|
+
const effectiveLimit = limit ?? 20;
|
|
173
|
+
if (effectiveSource === 'cli') {
|
|
174
|
+
const sessions = listSessions({ workDir: work_dir, limit: effectiveLimit });
|
|
175
|
+
return textResponse(sessions.length ? JSON.stringify(sessions, null, 2) : 'No Kimi sessions found.');
|
|
176
|
+
}
|
|
177
|
+
if (effectiveSource === 'acp') {
|
|
178
|
+
const result = await listAcpSessions({ limit: effectiveLimit, workDir: work_dir });
|
|
179
|
+
return textResponse(result.ok ? result.text : `Error: ${result.error}`, !result.ok);
|
|
180
|
+
}
|
|
181
|
+
const [cliSessions, acpResult] = await Promise.all([
|
|
182
|
+
Promise.resolve(listSessions({ workDir: work_dir, limit: effectiveLimit })),
|
|
183
|
+
listAcpSessions({ limit: effectiveLimit, workDir: work_dir }),
|
|
184
|
+
]);
|
|
185
|
+
let acpValue;
|
|
186
|
+
if (acpResult.ok) {
|
|
187
|
+
try {
|
|
188
|
+
acpValue = JSON.parse(acpResult.text);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
acpValue = { error: 'ACP sessions response is not valid JSON', raw: acpResult.text };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
acpValue = { error: acpResult.error };
|
|
196
|
+
}
|
|
197
|
+
const combined = { cli: cliSessions, acp: acpValue };
|
|
198
|
+
return textResponse(JSON.stringify(combined, null, 2));
|
|
231
199
|
});
|
|
232
|
-
server.tool('
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
200
|
+
server.tool('kimi_tasks', 'Manage background work: status, output, or cancel.', {
|
|
201
|
+
action: z.enum(['status', 'output', 'cancel']).describe("Action: 'status', 'output', or 'cancel'."),
|
|
202
|
+
task_id: z.string().optional().describe('Omit for status to list all tasks. Required for output.'),
|
|
203
|
+
session_id: z.string().optional().describe('Cancel an ACP session instead of a task (action=cancel).'),
|
|
204
|
+
}, async ({ action, task_id, session_id }) => {
|
|
205
|
+
if (action === 'status') {
|
|
206
|
+
if (task_id) {
|
|
207
|
+
const task = taskStore.get(task_id);
|
|
208
|
+
return textResponse(task ? JSON.stringify(task, null, 2) : `Task not found: ${task_id}`, !task);
|
|
209
|
+
}
|
|
210
|
+
return textResponse(JSON.stringify(taskStore.list(), null, 2));
|
|
211
|
+
}
|
|
212
|
+
if (action === 'output') {
|
|
213
|
+
if (!task_id)
|
|
214
|
+
return textResponse('task_id is required for action=output.', true);
|
|
215
|
+
const task = taskStore.get(task_id);
|
|
216
|
+
return textResponse(task
|
|
217
|
+
? JSON.stringify({ id: task.id, status: task.status, output: task.output, outputChunks: task.outputChunks, error: task.error }, null, 2)
|
|
218
|
+
: `Task not found: ${task_id}`, !task);
|
|
219
|
+
}
|
|
238
220
|
if (task_id && session_id) {
|
|
239
221
|
return textResponse('Provide either task_id or session_id, not both.', true);
|
|
240
222
|
}
|
|
@@ -248,28 +230,45 @@ server.tool('kimi_cancel', 'Cancel an active Ladder task by task_id or a Kimi AC
|
|
|
248
230
|
}
|
|
249
231
|
return textResponse('Provide task_id or session_id.', true);
|
|
250
232
|
});
|
|
251
|
-
server.tool('
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
233
|
+
server.tool('kimi_status', 'Installation, auth, and diagnostics.', {
|
|
234
|
+
detail: z.enum(['basic', 'full']).optional().describe("Detail level: 'basic' or 'full'. Default: 'basic'."),
|
|
235
|
+
doctor_target: z.enum(['config', 'tui']).optional().describe("Target for the doctor subcall when detail='full'. Default: 'config'."),
|
|
236
|
+
doctor_path: z.string().optional().describe("Optional config/tui path for the doctor subcall when detail='full'."),
|
|
237
|
+
}, async ({ detail, doctor_target, doctor_path }) => {
|
|
238
|
+
const status = await getKimiStatus();
|
|
239
|
+
const lines = [
|
|
240
|
+
'## Kimi CLI Status',
|
|
241
|
+
`- Installed: ${status.installed ? 'Yes' : 'No'}`,
|
|
242
|
+
`- Binary: ${status.binPath ? `\`${status.binPath}\`` : 'Not found'}`,
|
|
243
|
+
`- Version: ${status.version ?? '(unable to detect)'}`,
|
|
244
|
+
`- Authenticated: ${status.authenticated ? 'Yes' : 'No'}`,
|
|
245
|
+
`- Catalog Found: ${status.catalogFound ? 'Yes' : 'No'}`,
|
|
246
|
+
`- Catalog: \`${status.catalogPath}\``,
|
|
247
|
+
`- Credentials Found: ${status.credentialsFound ? 'Yes' : 'No'}`,
|
|
248
|
+
`- Config Found: ${status.configFound ? 'Yes' : 'No'}`,
|
|
249
|
+
'',
|
|
250
|
+
'## Kimi Code API',
|
|
251
|
+
`- Configured: ${isApiConfigured() ? 'Yes' : 'No'}`,
|
|
252
|
+
];
|
|
253
|
+
if (status.error)
|
|
254
|
+
lines.push('', `Action required: ${status.error}`);
|
|
255
|
+
let subcallsFailed = false;
|
|
256
|
+
if (detail === 'full') {
|
|
257
|
+
const [capabilities, doctor, providers] = await Promise.all([
|
|
258
|
+
getKimiCapabilities(),
|
|
259
|
+
runKimiDoctor(doctor_target ?? 'config', doctor_path),
|
|
260
|
+
listKimiProviders(),
|
|
261
|
+
]);
|
|
262
|
+
const isFailedResult = (value) => typeof value === 'object' && value !== null && 'ok' in value && value.ok === false;
|
|
263
|
+
if (isFailedResult(doctor))
|
|
264
|
+
subcallsFailed = true;
|
|
265
|
+
if (!Array.isArray(providers) && isFailedResult(providers))
|
|
266
|
+
subcallsFailed = true;
|
|
267
|
+
lines.push('', '## Capabilities', JSON.stringify(capabilities, null, 2), '', '## Doctor', JSON.stringify(doctor, null, 2), '', '## Providers', JSON.stringify(providers, null, 2));
|
|
257
268
|
}
|
|
258
|
-
return textResponse(
|
|
259
|
-
});
|
|
260
|
-
server.tool('kimi_task_output', 'Return accumulated output and error for a background task.', {
|
|
261
|
-
task_id: z.string(),
|
|
262
|
-
}, async ({ task_id }) => {
|
|
263
|
-
const task = taskStore.get(task_id);
|
|
264
|
-
return textResponse(task ? JSON.stringify({ id: task.id, status: task.status, output: task.output, outputChunks: task.outputChunks, error: task.error }, null, 2) : `Task not found: ${task_id}`, !task);
|
|
269
|
+
return textResponse(lines.join('\n'), Boolean(status.error) || subcallsFailed);
|
|
265
270
|
});
|
|
266
|
-
server.tool('
|
|
267
|
-
task_id: z.string(),
|
|
268
|
-
}, async ({ task_id }) => {
|
|
269
|
-
const task = await taskStore.cancel(task_id);
|
|
270
|
-
return textResponse(task ? JSON.stringify(task, null, 2) : `Task not found: ${task_id}`, !task);
|
|
271
|
-
});
|
|
272
|
-
server.tool('kimi_generate_mcp_config', 'Generate or write a Kimi Code .kimi-code/mcp.json entry that lets Kimi host Ladder_mcp as an MCP server.', {
|
|
271
|
+
server.tool('kimi_setup', 'Generate the Kimi-hosted MCP config entry for Ladder_mcp.', {
|
|
273
272
|
scope: z.enum(['project', 'user']).optional(),
|
|
274
273
|
project_dir: z.string().optional(),
|
|
275
274
|
server_name: z.string().optional(),
|
|
@@ -277,19 +276,53 @@ server.tool('kimi_generate_mcp_config', 'Generate or write a Kimi Code .kimi-cod
|
|
|
277
276
|
command: z.string().optional(),
|
|
278
277
|
args: z.array(z.string()).optional(),
|
|
279
278
|
}, async ({ scope, project_dir, server_name, write, command, args }) => {
|
|
279
|
+
const defaults = resolveDefaultServerCommand();
|
|
280
280
|
const result = generateMcpConfig({
|
|
281
281
|
scope,
|
|
282
282
|
projectDir: project_dir,
|
|
283
283
|
serverName: server_name,
|
|
284
284
|
write: write ?? false,
|
|
285
|
-
command,
|
|
286
|
-
args,
|
|
285
|
+
command: command ?? defaults.command,
|
|
286
|
+
args: args ?? defaults.args,
|
|
287
287
|
});
|
|
288
288
|
return textResponse(JSON.stringify(result, null, 2));
|
|
289
289
|
});
|
|
290
|
-
|
|
291
|
-
server.tool('
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
290
|
+
if (process.env.LADDER_EXPERIMENTAL === '1') {
|
|
291
|
+
server.tool('kimi_export_session', 'Export a Kimi session ZIP. Requires explicit output_path and excludes the global diagnostic log by default.', {
|
|
292
|
+
output_path: z.string().describe('Explicit ZIP output path.'),
|
|
293
|
+
session_id: z.string().optional().describe('Session id to export; defaults to most recent Kimi session.'),
|
|
294
|
+
include_global_log: z.boolean().optional().describe('Include active global diagnostic log. Default: false.'),
|
|
295
|
+
overwrite_existing: z.boolean().optional().describe('Overwrite output_path if it already exists. Default: false.'),
|
|
296
|
+
}, async ({ output_path, session_id, include_global_log, overwrite_existing }) => {
|
|
297
|
+
const result = await exportKimiSession({
|
|
298
|
+
outputPath: output_path,
|
|
299
|
+
sessionId: session_id,
|
|
300
|
+
includeGlobalLog: include_global_log ?? false,
|
|
301
|
+
overwriteExisting: overwrite_existing ?? false,
|
|
302
|
+
});
|
|
303
|
+
return textResponse(JSON.stringify(result, null, 2), !result.ok);
|
|
304
|
+
});
|
|
305
|
+
server.tool('kimi_visualize_session', 'Preview or launch Kimi session visualizer on localhost using `kimi vis --no-open`.', {
|
|
306
|
+
session_id: z.string().optional(),
|
|
307
|
+
host: z.enum(['127.0.0.1', 'localhost', '::1']).optional().describe('Localhost host to bind. Default: 127.0.0.1.'),
|
|
308
|
+
port: z.number().int().min(1).max(65535).optional().describe('Port to bind. Default: 58628.'),
|
|
309
|
+
launch: z.boolean().optional().describe('Actually start the visualizer process. Default: false.'),
|
|
310
|
+
}, async ({ session_id, host, port, launch }) => {
|
|
311
|
+
try {
|
|
312
|
+
return textResponse(JSON.stringify(visualizeSession({ sessionId: session_id, host, port, launch }), null, 2));
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return textResponse(`Error: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
server.tool('kimi_desktop_status', 'Experimental read-only Kimi Desktop Work status probe. Does not read token stores or replay web auth.', {}, async () => textResponse(JSON.stringify(await getDesktopStatus(), null, 2)));
|
|
319
|
+
server.tool('kimi_budget_probe', 'Experimental guided budget-separation evidence workflow. Does not submit desktop Work tasks.', {
|
|
320
|
+
include_cli_probe_note: z.boolean().optional(),
|
|
321
|
+
}, async ({ include_cli_probe_note }) => textResponse(buildBudgetProbeGuide(include_cli_probe_note ?? false)));
|
|
322
|
+
}
|
|
323
|
+
export { server };
|
|
324
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
325
|
+
if (isMain) {
|
|
326
|
+
const transport = new StdioServerTransport();
|
|
327
|
+
await server.connect(transport);
|
|
328
|
+
}
|
package/dist/kimi-api.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { loadApiAuth } from './environment.js';
|
|
2
2
|
import { truncateAtBoundary } from './kimi-runner.js';
|
|
3
|
+
import { clampTimeout } from './transports/acp.js';
|
|
3
4
|
const KIMI_USER_AGENT = 'KimiCLI/1.0';
|
|
4
5
|
const DEFAULT_MODEL = 'kimi-for-coding';
|
|
6
|
+
const API_DEFAULT_TIMEOUT_MS = 300_000;
|
|
5
7
|
export function isApiConfigured() {
|
|
6
8
|
return loadApiAuth() !== null;
|
|
7
9
|
}
|
|
@@ -15,7 +17,9 @@ export async function runKimiApi(config) {
|
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
19
|
const controller = new AbortController();
|
|
18
|
-
|
|
20
|
+
// Clamp to a positive finite value; a zero/NaN/negative timeout would abort
|
|
21
|
+
// instantly and fail the request before the network round-trip.
|
|
22
|
+
const timeoutMs = clampTimeout(config.timeoutMs, API_DEFAULT_TIMEOUT_MS);
|
|
19
23
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
20
24
|
const maxOutputChars = config.maxOutputChars ?? 60_000;
|
|
21
25
|
const maxTokens = Math.ceil(maxOutputChars / 4) + 4000;
|
package/dist/kimi-mcp-config.js
CHANGED
|
@@ -4,7 +4,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { getWindowsHome } from './environment.js';
|
|
5
5
|
function defaultServerCommand() {
|
|
6
6
|
const distIndex = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
|
|
7
|
-
|
|
7
|
+
// Use the actual Node binary that launched this process instead of a bare `node`
|
|
8
|
+
// name that could be spoofed via PATH. This matches the secure default in
|
|
9
|
+
// src/index.ts resolveDefaultServerCommand.
|
|
10
|
+
return { command: process.execPath, args: [distIndex] };
|
|
8
11
|
}
|
|
9
12
|
export function resolveMcpConfigPath(options = {}) {
|
|
10
13
|
if (options.scope === 'user') {
|
|
@@ -32,30 +35,43 @@ function readExistingConfig(configPath) {
|
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
37
|
function assertWritableProjectTarget(projectDir) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
// Resolve what we can; if the target does not exist yet, fall back to an absolute
|
|
39
|
+
// path so containment still works against the nearest existing ancestor.
|
|
40
|
+
const resolved = (() => {
|
|
41
|
+
try {
|
|
42
|
+
return fs.realpathSync(projectDir);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return path.resolve(projectDir);
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
// Walk up the path and reject if any directory segment is exactly the read-only
|
|
49
|
+
// reference tree name. This is containment-based (basename equality), not a
|
|
50
|
+
// substring match, so names like "my-kimi-code-mcp-project" are not blocked.
|
|
51
|
+
let current = resolved;
|
|
52
|
+
while (true) {
|
|
53
|
+
if (path.basename(current).toLowerCase() === 'kimi-code-mcp') {
|
|
54
|
+
throw new Error('Refusing to write MCP config under read-only kimi-code-mcp reference tree.');
|
|
55
|
+
}
|
|
56
|
+
const parent = path.dirname(current);
|
|
57
|
+
if (parent === current)
|
|
58
|
+
break;
|
|
59
|
+
current = parent;
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
|
-
const ALLOWED_BARE_COMMANDS = new Set(['node', 'npx']);
|
|
48
62
|
// Constrain the launcher written into mcp.json. Without this, an arbitrary `command`
|
|
49
63
|
// (e.g. "powershell") would be persisted and later executed when Kimi loads the
|
|
50
|
-
// server config.
|
|
51
|
-
//
|
|
64
|
+
// server config. Reject bare names like `node` or `npx` because they resolve through
|
|
65
|
+
// the consumer's PATH and can be spoofed. Only accept the actual Node binary that is
|
|
66
|
+
// running this process (process.execPath) or an absolute path to an existing file that
|
|
67
|
+
// has been vetted by the caller.
|
|
52
68
|
function assertSafeCommand(command) {
|
|
53
69
|
const value = command.trim();
|
|
54
|
-
if (
|
|
70
|
+
if (value === process.execPath)
|
|
55
71
|
return;
|
|
56
72
|
if (path.isAbsolute(value) && fs.existsSync(value) && fs.statSync(value).isFile())
|
|
57
73
|
return;
|
|
58
|
-
throw new Error(`command must be
|
|
74
|
+
throw new Error(`command must be process.execPath or an absolute path to an existing file; got "${command}".`);
|
|
59
75
|
}
|
|
60
76
|
function readMcpServers(existing) {
|
|
61
77
|
const value = existing.mcpServers;
|