From e1634f6abe9a6edb1a0ddf2c8a961d71b5a1ff29 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:12:26 +0800 Subject: [PATCH] MCP: multi-line --text goes to the CLI as --from-text (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 --- CHANGELOG.md | 3 +++ cli/mcp.ts | 30 ++++++++++++++++++++++++++++-- skills/store-pitfalls/SKILL.md | 11 +++++++++++ tests/mcp-text-file.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/mcp-text-file.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bb958cb..28a0596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- **MCP `console_verify` reads multi-line page text whole** (it kept only the first line on Windows), and long JSON + output such as `console_plan` keeps its start and names a file with the whole output. Pitfall 100. + - **Windows builds.** `play build` runs `gradlew.bat` through `cmd /c` (it was looked up on PATH only) and takes `C:/...` keystore paths as absolute. Pitfall 98. - **`check` verifies credential files.** Every credential path that does not exist on this machine and an upload keystore diff --git a/cli/mcp.ts b/cli/mcp.ts index 6abbfac..1bf1f64 100644 --- a/cli/mcp.ts +++ b/cli/mcp.ts @@ -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; 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 : 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(); function cancel(id: Id | undefined): void { if (id !== undefined) running.get(id)?.cancel(); } diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 061f84a..4de9ad2 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1209,3 +1209,14 @@ that fails without the fix (see CONTRIBUTING.md). exist and runs `keytool -list` with the stored password and alias (when keytool is on PATH or in JAVA_HOME), naming the bad field (`tests/build-sign.test.ts`). `play build` / `apple build` also write `/latest.json` so CI needs no glob for the AAB / IPA. + +## 100. `console_verify` reported every App Privacy type missing (multi-line page text over MCP on Windows) +- **Symptom:** MCP `console_verify` with the App Privacy page text said "not on the page" for every data type and "no + Published line"; the command line it echoed held only the text's first line. The same text from a file matched. +- **Cause:** the text went to the CLI as one `--text=...` argument; on Windows the child starts through a shell, which + kept only the first line. Separately, `console_plan` output over 60 KB was cut at the start, losing the start URL + and the first steps. +- **Do:** update storebound; meanwhile save the page text to a file and run `storebound console verify
+ --from-text `. +- **Guard:** `textToFile` in `cli/mcp.ts` sends multi-line text as `--from-text `; `clip` keeps the start of + long JSON output (the end of plain text) and names a file with the whole output (`tests/mcp-text-file.test.ts`). diff --git a/tests/mcp-text-file.test.ts b/tests/mcp-text-file.test.ts new file mode 100644 index 0000000..95dd166 --- /dev/null +++ b/tests/mcp-text-file.test.ts @@ -0,0 +1,24 @@ +// MCP calls: multi-line page text reaches the CLI whole (pitfall 100), long JSON output keeps its start. +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { clip, textToFile } from '../cli/mcp'; + +describe('mcp argv and output', () => { + test('a multi-line --text= becomes --from-text with the whole text', () => { + const page = 'App Privacy\nData Types\nCrash Data\r\nUsed for App Functionality\nPublished a few seconds ago'; + const argv = textToFile(['console', 'verify', 'apple-app-privacy', `--text=${page}`, '--json']); + expect(argv.slice(0, 4)).toEqual(['console', 'verify', 'apple-app-privacy', '--from-text']); + expect(argv.some(a => /[\r\n]/.test(a))).toBe(false); + expect(readFileSync(argv[4]!, 'utf8')).toBe(page); + expect(textToFile(['console', 'verify', 'x', '--text=one line'])).toEqual(['console', 'verify', 'x', '--text=one line']); + }); + test('long JSON keeps its start, long text its end; both name the full file', () => { + const json = `{"startUrl":"https://example.com/start",${'"x":1,'.repeat(50)}"end":true}`; + const j = clip(json, true, 40); + expect(j.startsWith('{"startUrl":"https://example.com/start"')).toBe(true); + const file = /the whole output: (.+)\]$/.exec(j)![1]!; + expect(readFileSync(file, 'utf8')).toBe(json); + expect(clip('a'.repeat(100) + 'ERROR at the end', false, 20).endsWith('ERROR at the end')).toBe(true); + expect(clip('short', true, 40)).toBe('short'); + }); +});