MCP: multi-line --text goes to the CLI as --from-text <temp file> (console_verify kept only the first line on Windows); long JSON output keeps its start and names a file with the whole output (console_plan lost its start URL); test, pitfall 100

This commit is contained in:
CRTkafa 2026-10-22 02:12:26 +08:00
parent 73d308164d
commit e1634f6abe
4 changed files with 66 additions and 2 deletions

View file

@ -20,6 +20,9 @@
// Every tool runs the CLI as a child process in the project directory (STOREBOUND_PROJECT or the server's cwd) and
// returns its output. stdout of this process carries protocol messages only; logs go to stderr.
// Tool list and safety rules: mcp-tools.ts (write tools default to dry_run, nothing public or irreversible exposed).
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import pkg from '../package.json' with { type: 'json' };
import { runCommand, type Running } from './lib/runner';
import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools';
@ -107,9 +110,10 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{
const input = (m.params?.arguments ?? {}) as Record<string, unknown>;
const problems = validateInput(tool, input);
if (problems.length) return ok({ content: [{ type: 'text', text: `Invalid arguments: ${problems.join('; ')}` }], isError: true });
const { argv, dryRun } = toArgv(tool, input);
const { argv: raw, dryRun } = toArgv(tool, input);
const argv = textToFile(raw);
const r = await run(argv, m.id);
let out = r.out.length > MAX_OUTPUT ? `[... first ${r.out.length - MAX_OUTPUT} characters cut ...]\n` + r.out.slice(-MAX_OUTPUT) : r.out;
let out = clip(r.out, argv.includes('--json'));
if (dryRun) out += '\n\n(dry run: nothing was changed. Ask the owner before calling again with dry_run: false.)';
const structured = { exitCode: r.code, dryRun, command: `storebound ${argv.join(' ')}`, output: out };
return ok({ content: [{ type: 'text', text: `$ storebound ${argv.join(' ')}\n${out}\n[exit ${r.code}]` }], structuredContent: structured, isError: r.code !== 0 });
@ -118,6 +122,28 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{
}
}
/** A multi-line --text=... goes to the CLI as --from-text <file>: on Windows the child starts through a shell and a
* multi-line argument kept only its first line (console_verify read every App Privacy type as missing). */
export function textToFile(argv: string[]): string[] {
return argv.flatMap(a => {
if (!a.startsWith('--text=') || !/[\r\n]/.test(a)) return [a];
const file = join(mkdtempSync(join(tmpdir(), 'storebound-mcp-')), 'page.txt');
writeFileSync(file, a.slice('--text='.length));
return ['--from-text', file];
});
}
/** Long output: the whole text goes to a file whose path is named; JSON keeps its start (a plan's start URL and first
* steps were cut when the tail was kept), plain text its end (where errors are). */
export function clip(out: string, json: boolean, max = MAX_OUTPUT): string {
if (out.length <= max) return out;
const file = join(mkdtempSync(join(tmpdir(), 'storebound-mcp-')), json ? 'output.json' : 'output.txt');
writeFileSync(file, out);
return json
? `${out.slice(0, max)}\n[... last ${out.length - max} characters cut; the whole output: ${file}]`
: `[... first ${out.length - max} characters cut; the whole output: ${file}]\n${out.slice(-max)}`;
}
const running = new Map<Id, Running>();
function cancel(id: Id | undefined): void { if (id !== undefined) running.get(id)?.cancel(); }