From 6b24fcc49d61afff6846293ce8efa4f44abf75c6 Mon Sep 17 00:00:00 2001 From: Mathieu2301 <21021423+Mathieu2301@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:53:45 +0000 Subject: [PATCH] feat: let an agent drive the CLI without a shell An agent that speaks MCP natively had to spawn a shell, quote arguments and parse output to publish a component. `miakapp mcp` serves the same commands as tools over newline-delimited JSON-RPC on stdio. The server is a translation layer and nothing else: a tool call becomes the exact argv a person would have typed, then runs the same dispatch. There is no second implementation of an option, a default or a validation rule, and a test proves the tool surface exposes every CLI option and invents none. Two deliberate departures from the command line: - publish, activate and rollback refuse to run without `confirm: true`. The guard is checked before anything else and never reaches the argv, so a model that hallucinated a publication spends the mistake on an argument check rather than on a generation; - a command failure comes back as a tool result carrying `isError`, not as a JSON-RPC error. A protocol error means the call never happened; a publication that reached the control plane and failed did happen, and only `kind` tells the caller whether to reconcile. Nothing but framed JSON-RPC reaches stdout: the dispatch runs against a host that captures its own rendering, because one stray line would desynchronize the stream for the rest of the session. Zero new dependencies. 37 tests, including a live handshake shape, a message split across chunks, and a guarded publish proving the control plane was never contacted. --- docs/agent-guide.md | 21 + packages/cli/README.md | 47 +++ packages/cli/bin/miakapp.js | 1 + packages/cli/src/main.ts | 38 +- packages/cli/src/mcp.ts | 619 ++++++++++++++++++++++++++++++ packages/cli/test/mcp.test.ts | 426 ++++++++++++++++++++ packages/cli/test/support/host.ts | 2 + 7 files changed, 1150 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/mcp.ts create mode 100644 packages/cli/test/mcp.test.ts diff --git a/docs/agent-guide.md b/docs/agent-guide.md index f247720..e178d31 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -325,6 +325,27 @@ Branch on the code, not on the prose. On 7, call `miakapp upload ` or `miakapp release ` and reconcile before acting again. Never retry a 7 with a fresh capability. +### If you speak MCP instead of shell + +`miakapp mcp` serves the same commands as tools over JSON-RPC on stdio. It is the +same code: a tool call becomes the argv a person would have typed and runs the +same dispatch, so everything above still holds — the same defaults, the same +validation, the same `kind` on every failure. + +Three differences are worth knowing before you call anything: + +- `miakapp_publish`, `miakapp_activate` and `miakapp_rollback` refuse to run + without `confirm: true`. Set it when the owner asked for that publication, and + not to get past an error; +- a failure arrives as a tool result with `isError: true`, carrying the same + closed object, not as a JSON-RPC error. A JSON-RPC error means your call never + happened; `isError` means it ran and failed, and `kind` says what to do next; +- a tool argument is the option name with `_` instead of `-`. An argument the + tool does not declare is refused, never ignored. + +`packages/cli/README.md` lists the tools. The exit codes above are the +`exit_code` field in every result, so branch on the same table either way. + ## 9. Secrets `MIAKAPP_HOME_KEY` comes from the environment. It is never a command-line diff --git a/packages/cli/README.md b/packages/cli/README.md index b96c1fc..7396242 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -63,6 +63,7 @@ duplicate keys — is rejected with the offending line rather than guessed at. | `rollback` | Alias of `activate`, for returning to a known-good digest. | | `release ` | Reads one finalized release record. | | `upload ` | Reads one upload status, to reconcile a lost request. | +| `mcp` | Serves every command above over MCP on stdio. | `check` is the command to run in CI and before every publication. It costs nothing, touches no network and catches the four artifact rules the broker's @@ -83,6 +84,52 @@ not model, so the reader knows what the inventory missed. It reports that a coordinator secret is present in the export; it never prints the secret itself. `docs/agent-guide.md` §3 explains what to do with each finding. +## MCP + +An agent that already runs a shell does not need this. An agent that speaks the +Model Context Protocol natively does: `miakapp mcp` serves the same commands as +tools over newline-delimited JSON-RPC on stdio. + +```json +{ + "mcpServers": { + "miakapp": { + "command": "bunx", + "args": ["@miakapp/cli", "mcp"], + "env": { "MIAKAPP_HOME_KEY": "${MIAKAPP_HOME_KEY}" } + } + } +} +``` + +| Tool | Command | | +| --- | --- | --- | +| `miakapp_discover` | `discover` | read-only, offline | +| `miakapp_check` | `check` | read-only, offline | +| `miakapp_release` | `release` | read-only | +| `miakapp_upload` | `upload` | read-only | +| `miakapp_init` | `init` | writes `miakapp.yaml`, never overwrites | +| `miakapp_publish` | `publish` | **moves the pointer — needs `confirm: true`** | +| `miakapp_activate` | `activate` | **moves the pointer — needs `confirm: true`** | +| `miakapp_rollback` | `rollback` | **moves the pointer — needs `confirm: true`** | + +The server is a translation layer: a tool call becomes the exact argv a person +would have typed and runs the same dispatch, so a tool and a command line cannot +drift apart. A tool argument is the option name with `_` for `-` +(`expected_generation` → `--expected-generation`); an argument the tool does not +declare is refused rather than ignored. + +The three pointer-moving tools additionally require `confirm: true`. It is +checked before anything else and never reaches the command line, so a model that +hallucinated a publication spends the mistake on an argument check instead of on +a generation. + +A command that fails comes back as a tool result carrying `isError: true` and +the same closed object the CLI prints — `kind`, `exit_code`, `message` and a +remedy — not as a JSON-RPC error. That distinction matters: a protocol error +means the call never happened, while a publication that reached the control +plane and failed did happen, and only `kind` says whether to reconcile. + ## Authorization The Home Key is read from `MIAKAPP_HOME_KEY` and from nowhere else. No command diff --git a/packages/cli/bin/miakapp.js b/packages/cli/bin/miakapp.js index b32e688..f48ba44 100755 --- a/packages/cli/bin/miakapp.js +++ b/packages/cli/bin/miakapp.js @@ -6,4 +6,5 @@ process.exitCode = await run(process.argv.slice(2), { writeError: (text) => void process.stderr.write(text), cwd: () => process.cwd(), env: (name) => process.env[name], + input: process.stdin, }); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 137806f..e93442d 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -61,17 +61,19 @@ export interface CliHost { files?: FileSystem; /** Injected by tests; defaults to the platform `fetch`. */ fetch?: FetchLike; + /** Read only by `mcp`, which serves a request stream instead of one command. */ + input?: AsyncIterable; } type Field = readonly [key: string, value: string | number | readonly string[]]; -interface CommandResult { +export interface CommandResult { readonly summary: string; readonly fields: readonly Field[]; readonly json: Record; } -interface Invocation { +export interface Invocation { readonly command: string; readonly options: ReadonlyMap; readonly flags: ReadonlySet; @@ -92,6 +94,7 @@ Commands rollback Alias of activate, for returning to a known-good digest release Read one finalized release record upload Read one upload status, to reconcile a lost request + mcp Serve these commands over MCP on stdio help Print this text version Print the CLI version @@ -112,6 +115,11 @@ activate / rollback options discover options --flows Node-RED flows export to read (required) +mcp options + (none) Reads JSON-RPC on stdin, writes it on stdout. Every + command above becomes one tool; publish, activate + and rollback additionally require confirm: true. + init options --home Home ID to write into ${PROJECT_FILE} (required) --control-plane Control-plane issuer (required) @@ -131,7 +139,8 @@ Exit codes const GLOBAL_FLAGS = ['json'] as const; const GLOBAL_OPTIONS = ['project'] as const; -const COMMAND_OPTIONS: Record = { +/** Exported so the MCP surface can be proved to expose every option, and no other. */ +export const COMMAND_OPTIONS: Record = { init: ['home', 'control-plane', 'artifact', 'release'], discover: ['flows'], check: [], @@ -140,6 +149,7 @@ const COMMAND_OPTIONS: Record = { rollback: ['sha256', 'expected-generation', 'generation'], release: [], upload: [], + mcp: [], help: [], version: [], }; @@ -602,7 +612,13 @@ async function runUpload(host: CliHost, invocation: Invocation): Promise { +/** + * Runs one parsed invocation. + * + * Exported for `mcp`, which reaches the same commands without a process: a + * tool call and a command line must not be able to diverge. + */ +export async function dispatch(host: CliHost, invocation: Invocation): Promise { switch (invocation.command) { case 'init': return await runInit(host, invocation); @@ -666,6 +682,20 @@ export async function run(argv: readonly string[], host: CliHost): Promise All flows download.', + }], + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_init', + title: 'Write the project file', + command: 'init', + description: + 'Write miakapp.yaml in the project directory. Refuses to overwrite an existing one, so it ' + + 'is safe to call when unsure. Declares no requirements: grant them one at a time, as the ' + + 'component earns them.', + args: [ + { + name: 'home', + type: 'string', + required: true, + description: 'Home ID the component is published to.', + }, + { + name: 'control_plane', + type: 'string', + required: true, + description: 'Control-plane issuer, an https URL.', + }, + { + name: 'artifact', + type: 'string', + required: false, + description: 'Built artifact path. Defaults to dist/component.js.', + }, + { + name: 'release', + type: 'string', + required: false, + description: 'Initial release name. Defaults to 0.1.0.', + }, + PROJECT_ARGUMENT, + ], + readOnly: false, + guarded: false, + }, + { + name: 'miakapp_check', + title: 'Validate the project and the artifact', + command: 'check', + description: + 'Parse miakapp.yaml, verify the built artifact against the four ABI 1 rules the broker ' + + 'would reject anyway, and report the digest a publication would bind. Offline and free: ' + + 'run it in CI and before every publication. It never builds the component itself.', + args: [PROJECT_ARGUMENT], + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_release', + title: 'Read one finalized release', + command: 'release', + description: + 'Read the finalized release record for one digest: release name, ABI, size, requirements ' + + 'and finalization instant. This is the reconciliation read after a lost finalize ' + + 'response — call it before deciding that a publication did not happen.', + args: [PROJECT_ARGUMENT], + positional: { + name: 'sha256', + description: 'Artifact digest, 43 base64url characters.', + }, + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_upload', + title: 'Read one upload status', + command: 'upload', + description: + 'Read the status of one upload: awaiting_upload, delivered or finalized. This is the read ' + + 'that tells a lost PUT from an upload that never arrived. Call it after any ' + + 'unknown_outcome, before touching the control plane again.', + args: [PROJECT_ARGUMENT], + positional: { + name: 'upload_id', + description: 'Upload ID returned when the capability was issued, 22 characters.', + }, + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_publish', + title: 'Publish and activate the built artifact', + command: 'publish', + description: + 'Upload the built artifact, finalize it and activate it as the new generation, in one run. ' + + 'Changes what every device in the home runs. Requires MIAKAPP_HOME_KEY in the ' + + 'environment. Run miakapp_check first; this tool does not build the component.', + args: [ + EXPECTED_GENERATION, + GENERATION, + { + name: 'release', + type: 'string', + required: false, + description: 'Release name for this publication. Defaults to component.release.', + }, + PROJECT_ARGUMENT, + CONFIRM, + ], + readOnly: false, + guarded: true, + }, + { + name: 'miakapp_activate', + title: 'Activate an already finalized digest', + command: 'activate', + description: + 'Point the home at a digest that was already finalized, at a new generation. Uploads ' + + 'nothing. Use it to promote a release that was published but not activated.', + args: [ + { + name: 'sha256', + type: 'string', + required: true, + description: 'Finalized artifact digest, 43 base64url characters.', + }, + EXPECTED_GENERATION, + GENERATION, + PROJECT_ARGUMENT, + CONFIRM, + ], + readOnly: false, + guarded: true, + }, + { + name: 'miakapp_rollback', + title: 'Return the home to a known-good digest', + command: 'rollback', + description: + 'The same operation as miakapp_activate, named for the moment it matters: put the home ' + + 'back on a digest that was working. A rollback is a forward activation of an older ' + + 'artifact, so it takes a new generation too — generations never go backwards.', + args: [ + { + name: 'sha256', + type: 'string', + required: true, + description: 'Digest of the release to return to, 43 base64url characters.', + }, + EXPECTED_GENERATION, + GENERATION, + PROJECT_ARGUMENT, + CONFIRM, + ], + readOnly: false, + guarded: true, + }, +]; + +/** The CLI option a tool argument stands for. */ +export function optionName(argument: string): string { + return argument.replaceAll('_', '-'); +} + +function schemaProperty(argument: ToolArgument): Record { + if (argument.type === 'integer') { + return { type: 'integer', minimum: 0, description: argument.description }; + } + if (argument.type === 'boolean') { + return { type: 'boolean', description: argument.description }; + } + return { type: 'string', minLength: 1, description: argument.description }; +} + +export function inputSchema(tool: ToolDefinition): Record { + const properties: Record = {}; + const required: string[] = []; + if (tool.positional !== undefined) { + properties[tool.positional.name] = { + type: 'string', + minLength: 1, + description: tool.positional.description, + }; + required.push(tool.positional.name); + } + for (const argument of tool.args) { + properties[argument.name] = schemaProperty(argument); + if (argument.required) required.push(argument.name); + } + return { + type: 'object', + properties, + required, + additionalProperties: false, + }; +} + +function descriptor(tool: ToolDefinition): Record { + return { + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: inputSchema(tool), + annotations: { + title: tool.title, + readOnlyHint: tool.readOnly, + destructiveHint: tool.guarded, + idempotentHint: false, + openWorldHint: !tool.readOnly || tool.command === 'release' || tool.command === 'upload', + }, + }; +} + +/** + * Turns tool arguments into the argv a person would have typed. + * + * Unknown keys are rejected here rather than dropped: a model that invented an + * argument has misunderstood the tool, and silently ignoring it would publish + * something other than what it asked for. + */ +export function buildArgv( + tool: ToolDefinition, + args: Record, +): readonly string[] { + const byName = new Map(tool.args.map((argument) => [argument.name, argument])); + const argv: string[] = [tool.command]; + const positional = tool.positional; + + for (const key of Object.keys(args)) { + if (key === positional?.name) continue; + if (!byName.has(key)) { + throw usageError( + `Unknown argument ${key} for ${tool.name}`, + `${tool.name} accepts ${[...byName.keys()].join(', ')}.`, + ); + } + } + + if (positional !== undefined) { + const value = args[positional.name]; + if (typeof value !== 'string' || value === '') { + throw usageError(`${positional.name} is required and must be a non-empty string`); + } + argv.push(value); + } + + for (const argument of tool.args) { + const value = args[argument.name]; + if (argument.type === 'boolean') { + // A guard is not an option: it is checked here and never reaches the argv, + // so the command line keeps exactly the shape it had before MCP existed. + if (value !== true) { + throw usageError( + `${argument.name} must be set to true`, + 'This tool changes what every device in the home runs, and will not act without an ' + + 'explicit confirmation from the caller.', + ); + } + continue; + } + if (value === undefined) { + if (argument.required) throw usageError(`${argument.name} is required`); + continue; + } + if (argument.type === 'integer') { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw usageError(`${argument.name} must be a non-negative integer`); + } + argv.push(`--${optionName(argument.name)}`, String(value)); + continue; + } + if (typeof value !== 'string' || value === '') { + throw usageError(`${argument.name} must be a non-empty string`); + } + argv.push(`--${optionName(argument.name)}`, value); + } + return argv; +} + +function failureJson(error: CliError): Record { + return { + ok: false, + kind: error.kind, + exit_code: error.exitCode, + message: error.message, + ...(error.remedy === undefined ? {} : { remedy: error.remedy }), + }; +} + +function toolResult(payload: Record, isError: boolean): Record { + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + structuredContent: payload, + isError, + }; +} + +/** + * Runs one tool and returns its MCP result. + * + * The host handed to the dispatch captures output instead of writing it: the + * command's own rendering never reaches stdout, which belongs to the protocol. + */ +export async function callTool( + host: CliHost, + name: unknown, + rawArguments: unknown, +): Promise> { + const tool = TOOLS.find((candidate) => candidate.name === name); + if (tool === undefined) { + return toolResult( + failureJson(usageError( + `Unknown tool: ${typeof name === 'string' ? name : 'a non-string name'}`, + `This server exposes ${TOOLS.map((item) => item.name).join(', ')}.`, + )), + true, + ); + } + const args = rawArguments === undefined || rawArguments === null ? {} : rawArguments; + if (typeof args !== 'object' || Array.isArray(args)) { + return toolResult(failureJson(usageError('arguments must be a JSON object')), true); + } + + let result: CommandResult; + try { + const argv = buildArgv(tool, args as Record); + result = await dispatch(silentHost(host), parseArguments(argv)); + } catch (error) { + if (error instanceof CliError) return toolResult(failureJson(error), true); + const message = error instanceof Error ? error.message : 'Unrecognized failure'; + return toolResult( + failureJson(new CliError( + 'unknown_outcome', + `The command ended in an unhandled failure: ${message}`, + 'Reconcile with miakapp_release or miakapp_upload before publishing again.', + )), + true, + ); + } + return toolResult( + { ok: true, command: tool.command, summary: result.summary, ...result.json }, + false, + ); +} + +/** The dispatch never prints; the protocol owns both streams of this process. */ +function silentHost(host: CliHost): CliHost { + return { ...host, write: () => {}, writeError: () => {} }; +} + +export interface Message { + /** + * Declared because every conforming client sends it, and not policed: the + * method name is what routes a message, so rejecting a mislabelled version + * would buy an interop failure and no safety property. + */ + readonly jsonrpc?: unknown; + readonly id?: unknown; + readonly method?: unknown; + readonly params?: unknown; +} + +function response(id: unknown, result: Record): Record { + return { jsonrpc: '2.0', id, result }; +} + +function errorResponse(id: unknown, code: number, message: string): Record { + return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; +} + +const INSTRUCTIONS = + 'Publish and roll back one Miakapp home component. Start with miakapp_discover on a house ' + + 'that already exists, then miakapp_check before every publication. A publication is a ' + + 'compare-and-set on the home generation: when a call fails with kind "conflict" the state ' + + 'moved under you, so read it again rather than retrying. When a call fails with kind ' + + '"unknown_outcome" the effect is undetermined — call miakapp_upload or miakapp_release to ' + + 'find out what happened before acting. Every result is a closed JSON object with a stable ' + + `"kind"; branch on that, never on the prose. Publishing needs ${HOME_KEY_VARIABLE} in this ` + + 'server\'s environment; it is never an argument and never printed.'; + +/** + * Handles one decoded message. + * + * Returns the response to write, or `undefined` for a notification — a + * JSON-RPC notification carries no id and must never be answered, not even to + * report that it was not understood. + */ +export async function handleMessage( + host: CliHost, + message: Message, +): Promise | undefined> { + const { method, id } = message; + const isNotification = id === undefined || id === null; + if (typeof method !== 'string') { + return isNotification ? undefined : errorResponse(id, -32600, 'Missing method'); + } + if (isNotification) return undefined; + + switch (method) { + case 'initialize': + return response(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, title: 'Miakapp', version: CLI_VERSION }, + instructions: INSTRUCTIONS, + }); + case 'ping': + return response(id, {}); + case 'tools/list': + return response(id, { tools: TOOLS.map(descriptor) }); + case 'tools/call': { + const params = message.params; + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + return errorResponse(id, -32602, 'tools/call requires a params object'); + } + const { name, arguments: args } = params as { name?: unknown; arguments?: unknown }; + return response(id, await callTool(host, name, args)); + } + default: + return errorResponse(id, -32601, `Unknown method: ${method}`); + } +} + +/** + * Splits a byte stream into JSON-RPC messages on newline boundaries. + * + * A message longer than {@link MAXIMUM_MESSAGE_BYTES} ends the session instead + * of growing the buffer: the peer is either broken or hostile, and neither is + * worth the memory. + */ +export async function* messages( + input: AsyncIterable, +): AsyncGenerator { + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + for await (const chunk of input) { + buffer += decoder.decode(chunk, { stream: true }); + let newline = buffer.indexOf('\n'); + while (newline !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line !== '') yield line; + newline = buffer.indexOf('\n'); + } + if (buffer.length > MAXIMUM_MESSAGE_BYTES) { + throw new CliError( + 'contract', + `A single JSON-RPC message exceeded ${MAXIMUM_MESSAGE_BYTES} bytes`, + ); + } + } + const last = buffer.trim(); + if (last !== '') yield last; +} + +/** + * Serves MCP until the input stream ends. + * + * Returns an exit code, like every other command. A closed stdin is the normal + * way an MCP client shuts a server down, so it is success, not failure. + */ +export async function serve( + host: CliHost, + input: AsyncIterable, +): Promise { + const write = (payload: Record): void => { + host.write(`${JSON.stringify(payload)}\n`); + }; + try { + for await (const line of messages(input)) { + let message: unknown; + try { + message = JSON.parse(line); + } catch { + write(errorResponse(null, -32700, 'Parse error')); + continue; + } + if (typeof message !== 'object' || message === null || Array.isArray(message)) { + write(errorResponse(null, -32600, 'A JSON-RPC message must be an object')); + continue; + } + const reply = await handleMessage(host, message as Message); + if (reply !== undefined) write(reply); + } + return EXIT_CODE.success; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unrecognized failure'; + host.writeError(`miakapp: mcp: ${message}\n`); + return error instanceof CliError ? error.exitCode : EXIT_CODE.unknown_outcome; + } +} diff --git a/packages/cli/test/mcp.test.ts b/packages/cli/test/mcp.test.ts new file mode 100644 index 0000000..e08d6e5 --- /dev/null +++ b/packages/cli/test/mcp.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from 'bun:test'; +import { EXIT_CODE } from '../src/errors.js'; +import { COMMAND_OPTIONS, HOME_KEY_VARIABLE, run } from '../src/main.js'; +import { + MCP_PROTOCOL_VERSION, + TOOLS, + buildArgv, + callTool, + handleMessage, + inputSchema, + messages, + optionName, + serve, +} from '../src/mcp.js'; +import { digestOf, fakeControlPlane, homeKey } from './support/control-plane.js'; +import { ARTIFACT_SOURCE, MemoryFiles, PROJECT_ROOT, standardProject, testHost } from './support/host.js'; +import { FLOWS_PATH, flowsProject } from './support/flows.js'; + +const HOME_ID = 'test-home'; +const ARTIFACT_DIGEST = digestOf(new TextEncoder().encode(ARTIFACT_SOURCE)); + +function publisherEnvironment(): Record { + return { [HOME_KEY_VARIABLE]: homeKey() }; +} + +/** Feeds a server one chunk per string, the way a pipe delivers them. */ +function stream(...chunks: readonly string[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + const encoder = new TextEncoder(); + for (const chunk of chunks) yield encoder.encode(chunk); + }, + }; +} + +function line(payload: Record): string { + return `${JSON.stringify(payload)}\n`; +} + +function frames(text: string): Record[] { + return text + .split('\n') + .filter((entry) => entry !== '') + .map((entry) => JSON.parse(entry) as Record); +} + +function tool(name: string) { + const found = TOOLS.find((candidate) => candidate.name === name); + if (found === undefined) throw new Error(`No such tool: ${name}`); + return found; +} + +function payload(result: Record): Record { + return result['structuredContent'] as Record; +} + +describe('the tool surface mirrors the command surface', () => { + test('every command except help, version and mcp itself is a tool', () => { + const commands = Object.keys(COMMAND_OPTIONS) + .filter((name) => !['help', 'version', 'mcp'].includes(name)) + .sort(); + expect(TOOLS.map((entry) => entry.command).sort()).toEqual(commands); + }); + + test('no tool hides an option the command accepts', () => { + for (const entry of TOOLS) { + const exposed = new Set(entry.args.map((argument) => optionName(argument.name))); + for (const option of COMMAND_OPTIONS[entry.command] ?? []) { + expect([entry.name, option, exposed.has(option)]).toEqual([entry.name, option, true]); + } + } + }); + + test('no tool invents an option the command would reject', () => { + for (const entry of TOOLS) { + const allowed = new Set([...COMMAND_OPTIONS[entry.command] ?? [], 'project']); + for (const argument of entry.args) { + if (argument.type === 'boolean') continue; // confirm never reaches the argv + const option = optionName(argument.name); + expect([entry.name, option, allowed.has(option)]).toEqual([entry.name, option, true]); + } + } + }); + + test('exactly the pointer-moving tools are guarded and declared destructive', () => { + const guarded = TOOLS.filter((entry) => entry.guarded).map((entry) => entry.command).sort(); + expect(guarded).toEqual(['activate', 'publish', 'rollback']); + for (const entry of TOOLS) { + const confirms = entry.args.some((argument) => argument.name === 'confirm'); + expect([entry.name, confirms]).toEqual([entry.name, entry.guarded]); + expect([entry.name, entry.readOnly && entry.guarded]).toEqual([entry.name, false]); + } + }); + + test('each schema declares every argument and requires the mandatory ones', () => { + const listed = TOOLS.map((entry) => entry.name); + expect(new Set(listed).size).toBe(listed.length); + for (const entry of TOOLS) { + const schema = inputSchema(entry); + const declared = Object.keys(schema['properties'] as Record).sort(); + const expected = entry.args.map((argument) => argument.name); + if (entry.positional !== undefined) expected.push(entry.positional.name); + expect([entry.name, declared]).toEqual([entry.name, expected.sort()]); + + const required = entry.args.filter((argument) => argument.required).map((a) => a.name); + if (entry.positional !== undefined) required.unshift(entry.positional.name); + expect([entry.name, schema['required']]).toEqual([entry.name, required]); + } + }); +}); + +describe('argument translation', () => { + test('an integer becomes the decimal option the parser expects', () => { + expect(buildArgv(tool('miakapp_publish'), { expected_generation: 4, confirm: true })) + .toEqual(['publish', '--expected-generation', '4']); + }); + + test('an underscore in a tool argument is the CLI hyphen', () => { + expect(optionName('expected_generation')).toBe('expected-generation'); + expect(buildArgv(tool('miakapp_init'), { + home: 'lumiere', + control_plane: 'https://control.example.test/api', + })).toEqual([ + 'init', + '--home', 'lumiere', + '--control-plane', 'https://control.example.test/api', + ]); + }); + + test('a positional argument is passed as a positional, not an option', () => { + expect(buildArgv(tool('miakapp_release'), { sha256: ARTIFACT_DIGEST })) + .toEqual(['release', ARTIFACT_DIGEST]); + }); + + test('an invented argument is refused rather than dropped', () => { + expect(() => buildArgv(tool('miakapp_check'), { force: true })).toThrow(/Unknown argument/); + }); + + test('a missing required argument is refused before anything runs', () => { + expect(() => buildArgv(tool('miakapp_publish'), { confirm: true })).toThrow(/required/); + }); + + test('a negative generation is refused before the parser sees it', () => { + expect(() => buildArgv(tool('miakapp_publish'), { expected_generation: -1, confirm: true })) + .toThrow(/non-negative integer/); + }); + + test('a generation given as a string is refused, not coerced', () => { + expect(() => buildArgv(tool('miakapp_publish'), { expected_generation: '4', confirm: true })) + .toThrow(/non-negative integer/); + }); +}); + +describe('protocol', () => { + test('initialize announces the protocol revision and the tools capability', async () => { + const host = testHost(); + const reply = await handleMessage(host, { jsonrpc: '2.0', id: 1, method: 'initialize' }); + const result = reply?.['result'] as Record; + expect(result['protocolVersion']).toBe(MCP_PROTOCOL_VERSION); + expect(result['capabilities']).toEqual({ tools: { listChanged: false } }); + expect((result['serverInfo'] as Record)['name']).toBe('miakapp'); + expect(result['instructions']).toContain('unknown_outcome'); + }); + + test('tools/list describes every tool with a closed schema', async () => { + const host = testHost(); + const reply = await handleMessage(host, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const tools = (reply?.['result'] as { tools: Record[] }).tools; + expect(tools).toHaveLength(TOOLS.length); + for (const descriptor of tools) { + const schema = descriptor['inputSchema'] as Record; + expect(schema['type']).toBe('object'); + expect(schema['additionalProperties']).toBe(false); + expect(descriptor['description']).toBeString(); + expect((descriptor['annotations'] as Record)['readOnlyHint']).toBeBoolean(); + } + }); + + test('a notification is never answered', async () => { + const host = testHost(); + expect(await handleMessage(host, { jsonrpc: '2.0', method: 'notifications/initialized' })) + .toBeUndefined(); + }); + + test('an unknown method is a method-not-found error', async () => { + const host = testHost(); + const reply = await handleMessage(host, { jsonrpc: '2.0', id: 3, method: 'resources/list' }); + expect((reply?.['error'] as Record)['code']).toBe(-32601); + }); + + test('unparseable input is a parse error that does not end the session', async () => { + const host = testHost(); + const code = await serve(host, stream('{not json\n', line({ jsonrpc: '2.0', id: 1, method: 'ping' }))); + expect(code).toBe(EXIT_CODE.success); + const replies = frames(host.stdout()); + expect((replies[0]?.['error'] as Record)['code']).toBe(-32700); + expect(replies[1]?.['result']).toEqual({}); + }); + + test('a message split across chunks is reassembled', async () => { + const host = testHost(); + const request = line({ jsonrpc: '2.0', id: 7, method: 'ping' }); + await serve(host, stream(request.slice(0, 10), request.slice(10))); + expect(frames(host.stdout())[0]?.['id']).toBe(7); + }); + + test('a final message without a trailing newline is still served', async () => { + const host = testHost(); + await serve(host, stream('{"jsonrpc":"2.0","id":9,"method":"ping"}')); + expect(frames(host.stdout())[0]?.['id']).toBe(9); + }); + + test('the message reader yields one entry per line and ignores blanks', async () => { + const seen: string[] = []; + for await (const entry of messages(stream('a\n\n \nb\n'))) seen.push(entry); + expect(seen).toEqual(['a', 'b']); + }); + + test('a closed stream is a clean shutdown, not a failure', async () => { + const host = testHost(); + expect(await serve(host, stream())).toBe(EXIT_CODE.success); + expect(host.stdout()).toBe(''); + }); +}); + +describe('read-only tools', () => { + test('check returns the digest a publication would bind', async () => { + const host = testHost({ files: standardProject() }); + const result = await callTool(host, 'miakapp_check', { project: PROJECT_ROOT }); + expect(result['isError']).toBe(false); + expect(payload(result)['sha256']).toBe(ARTIFACT_DIGEST); + expect(payload(result)['command']).toBe('check'); + }); + + test('a result carries the same object in text and in structuredContent', async () => { + const host = testHost({ files: standardProject() }); + const result = await callTool(host, 'miakapp_check', { project: PROJECT_ROOT }); + const content = (result['content'] as { type: string; text: string }[])[0]; + expect(content?.type).toBe('text'); + expect(JSON.parse(content?.text ?? '')).toEqual(payload(result)); + }); + + test('discover inventories a flows export without a project or a key', async () => { + const host = testHost({ files: flowsProject() }); + const result = await callTool(host, 'miakapp_discover', { flows: FLOWS_PATH }); + expect(result['isError']).toBe(false); + expect(payload(result)['flows']).toBeArray(); + }); + + test('a failing command is a tool result with isError, not a protocol error', async () => { + const host = testHost({ files: new MemoryFiles({}) }); + const reply = await handleMessage(host, { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'miakapp_check', arguments: { project: PROJECT_ROOT } }, + }); + expect(reply?.['error']).toBeUndefined(); + const result = reply?.['result'] as Record; + expect(result['isError']).toBe(true); + expect(payload(result)['kind']).toBe('project'); + expect(payload(result)['exit_code']).toBe(EXIT_CODE.project); + }); + + test('an unknown tool fails as a usage result the caller can read', async () => { + const host = testHost(); + const result = await callTool(host, 'miakapp_deploy_everything', {}); + expect(result['isError']).toBe(true); + expect(payload(result)['kind']).toBe('usage'); + }); +}); + +describe('guarded tools', () => { + test('publish without confirm touches nothing', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(true); + expect(payload(result)['kind']).toBe('usage'); + expect(payload(result)['message']).toContain('confirm'); + expect(payload(result)['remedy']).toContain('explicit confirmation'); + expect(plane.requests).toEqual([]); + expect(plane.generation).toBe(0); + }); + + test('publish with confirm false is refused, not treated as absent', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: false, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(true); + expect(payload(result)['message']).toContain('confirm'); + expect(plane.requests).toEqual([]); + }); + + test('publish with confirm walks the whole publication', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: true, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(false); + expect(payload(result)['generation']).toBe(1); + expect(payload(result)['sha256']).toBe(ARTIFACT_DIGEST); + expect(plane.generation).toBe(1); + }); + + test('a stale expected generation is a conflict the caller must re-read', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 3 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: true, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(true); + expect(payload(result)['kind']).toBe('conflict'); + expect(plane.generation).toBe(3); + }); + + test('rollback activates a finalized digest at a new generation', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: true, + project: PROJECT_ROOT, + }); + const result = await callTool(host, 'miakapp_rollback', { + sha256: ARTIFACT_DIGEST, + expected_generation: 1, + confirm: true, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(false); + expect(payload(result)['generation']).toBe(2); + }); + + test('a missing Home Key is an authorization failure, and the key never appears', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ files: standardProject(), fetch: plane.fetch }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: true, + project: PROJECT_ROOT, + }); + expect(payload(result)['kind']).toBe('authorization'); + expect(JSON.stringify(result)).not.toContain(homeKey()); + }); +}); + +describe('the mcp command', () => { + test('mcp serves the stream given on the host input', async () => { + const host = testHost({ + files: standardProject(), + input: stream(line({ jsonrpc: '2.0', id: 1, method: 'tools/list' })), + }); + expect(await run(['mcp'], host)).toBe(EXIT_CODE.success); + const tools = (frames(host.stdout())[0]?.['result'] as { tools: unknown[] }).tools; + expect(tools).toHaveLength(TOOLS.length); + }); + + test('mcp writes nothing but framed JSON-RPC to stdout', async () => { + const host = testHost({ + files: standardProject(), + input: stream( + line({ jsonrpc: '2.0', method: 'notifications/initialized' }), + line({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'miakapp_check', arguments: { project: PROJECT_ROOT } }, + }), + ), + }); + await run(['mcp'], host); + const replies = frames(host.stdout()); + expect(replies).toHaveLength(1); + expect(replies[0]?.['id']).toBe(2); + expect(host.stderr()).toBe(''); + }); + + test('mcp without an input stream is a usage failure, not a hang', async () => { + const host = testHost({ files: standardProject() }); + expect(await run(['mcp'], host)).toBe(EXIT_CODE.usage); + expect(host.stderr()).toContain('stdin'); + }); + + test('mcp rejects --json rather than corrupting the stream', async () => { + const host = testHost({ files: standardProject(), input: stream() }); + expect(await run(['mcp', '--json'], host)).toBe(EXIT_CODE.usage); + }); + + test('mcp takes no options of its own', async () => { + const host = testHost({ input: stream() }); + expect(await run(['mcp', '--flows', 'x'], host)).toBe(EXIT_CODE.usage); + }); +}); diff --git a/packages/cli/test/support/host.ts b/packages/cli/test/support/host.ts index ac79033..4f32e5b 100644 --- a/packages/cli/test/support/host.ts +++ b/packages/cli/test/support/host.ts @@ -66,6 +66,7 @@ export function testHost(options: { fetch?: FetchLike; env?: Record; cwd?: string; + input?: AsyncIterable; } = {}): TestHost { const out: string[] = []; const err: string[] = []; @@ -79,6 +80,7 @@ export function testHost(options: { env: (name) => environment[name], ...(options.files === undefined ? {} : { files: options.files }), ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + ...(options.input === undefined ? {} : { input: options.input }), stdout: () => out.join(''), stderr: () => err.join(''), json: () => JSON.parse(out.join('')) as Record,