Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,27 @@ Branch on the code, not on the prose. On 7, call `miakapp upload <uploadId>` or
`miakapp release <sha256>` 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
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sha256>` | Reads one finalized release record. |
| `upload <uploadId>` | 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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/bin/miakapp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
38 changes: 34 additions & 4 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>;
}

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<string, unknown>;
}

interface Invocation {
export interface Invocation {
readonly command: string;
readonly options: ReadonlyMap<string, string>;
readonly flags: ReadonlySet<string>;
Expand All @@ -92,6 +94,7 @@ Commands
rollback Alias of activate, for returning to a known-good digest
release <sha256> Read one finalized release record
upload <uploadId> 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

Expand All @@ -112,6 +115,11 @@ activate / rollback options
discover options
--flows <path> 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 <homeId> Home ID to write into ${PROJECT_FILE} (required)
--control-plane <https url> Control-plane issuer (required)
Expand All @@ -131,7 +139,8 @@ Exit codes
const GLOBAL_FLAGS = ['json'] as const;
const GLOBAL_OPTIONS = ['project'] as const;

const COMMAND_OPTIONS: Record<string, readonly string[]> = {
/** Exported so the MCP surface can be proved to expose every option, and no other. */
export const COMMAND_OPTIONS: Record<string, readonly string[]> = {
init: ['home', 'control-plane', 'artifact', 'release'],
discover: ['flows'],
check: [],
Expand All @@ -140,6 +149,7 @@ const COMMAND_OPTIONS: Record<string, readonly string[]> = {
rollback: ['sha256', 'expected-generation', 'generation'],
release: [],
upload: [],
mcp: [],
help: [],
version: [],
};
Expand Down Expand Up @@ -602,7 +612,13 @@ async function runUpload(host: CliHost, invocation: Invocation): Promise<Command
};
}

async function dispatch(host: CliHost, invocation: Invocation): Promise<CommandResult> {
/**
* 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<CommandResult> {
switch (invocation.command) {
case 'init':
return await runInit(host, invocation);
Expand Down Expand Up @@ -666,6 +682,20 @@ export async function run(argv: readonly string[], host: CliHost): Promise<numbe
host.write(json ? `${JSON.stringify({ ok: true, version: CLI_VERSION })}\n` : `${CLI_VERSION}\n`);
return EXIT_CODE.success;
}
if (invocation.command === 'mcp') {
if (json) throw usageError('mcp does not take --json; the protocol is already JSON-RPC');
const input = host.input;
if (input === undefined) {
throw usageError(
'mcp needs a request stream on stdin',
'An MCP client starts this command as a subprocess and speaks JSON-RPC over the pipe.',
);
}
// Imported here, not at the top: mcp.ts is built on this module, and the
// other commands must not pay for a protocol they never speak.
const { serve } = await import('./mcp.js');
return await serve(host, input);
}
const result = await dispatch(host, invocation);
host.write(
json
Expand Down
Loading
Loading