|
| 1 | +# 11 — Machine-Readable Output |
| 2 | + |
| 3 | +mcpp writes for two audiences. This chapter is the contract for the second one: |
| 4 | +programs. If you are building an editor extension, a CI script, or anything |
| 5 | +that parses mcpp's output, this is what you may rely on. |
| 6 | + |
| 7 | +Design and the measurements behind it: |
| 8 | +`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`. |
| 9 | + |
| 10 | +## 1. The rule that matters most |
| 11 | + |
| 12 | +> **Detect the protocol by parsing stdout. Never by exit code, and never by |
| 13 | +> "the command did not fail".** |
| 14 | +
|
| 15 | +Read stdout, try to parse it as JSON, and require `schemaVersion` and `kind` |
| 16 | +to be present. If either is missing, this mcpp does not speak the protocol you |
| 17 | +asked for. |
| 18 | + |
| 19 | +This is not a stylistic preference. `mcpp --protocol-version` looks like it |
| 20 | +should be the entry point, and on a version that has it, it is a useful |
| 21 | +shortcut. But on **every mcpp released before it existed**, that command is |
| 22 | +itself an unknown option — and an unknown option used to print human text to |
| 23 | +*stdout* with exit code 1 and an empty stderr. Success and failure arrived on |
| 24 | +the same channel. Spelling it `--json` instead changes nothing; both hit the |
| 25 | +same path. |
| 26 | + |
| 27 | +So positive detection is the only rule that works across versions. Everything |
| 28 | +below is designed around it. |
| 29 | + |
| 30 | +## 2. The envelope |
| 31 | + |
| 32 | +Every enveloped response has this shape: |
| 33 | + |
| 34 | +```jsonc |
| 35 | +{ |
| 36 | + "schemaVersion": 1, // the ENVELOPE's version |
| 37 | + "kind": "mcpp.env", // which document this is |
| 38 | + "kindVersion": 1, // this kind's own data version |
| 39 | + "effects": [], // what running the command did — see §4 |
| 40 | + "mcpp": { |
| 41 | + "version": "2026.8.8.3", |
| 42 | + "protocol": { "min": 1, "max": 1 } |
| 43 | + }, |
| 44 | + "data": { /* specific to `kind` */ }, |
| 45 | + "diagnostics": [] |
| 46 | +} |
| 47 | +``` |
| 48 | + |
| 49 | +`schemaVersion` and `kindVersion` are separate on purpose. One global number |
| 50 | +would mean that adding a field to `mcpp.env` moves the version a client reads |
| 51 | +for `mcpp.xpkg`, with no way to tell which actually changed. |
| 52 | + |
| 53 | +`effects` is always present. An empty array means "nothing"; an absent array |
| 54 | +would mean "unknown", which is a different claim. |
| 55 | + |
| 56 | +### Diagnostics |
| 57 | + |
| 58 | +```jsonc |
| 59 | +{ |
| 60 | + "code": "MCPP_MANIFEST_UNKNOWN_KEY", |
| 61 | + "severity": "error" | "warning" | "note", |
| 62 | + "source": "mcpp", |
| 63 | + "message": "unknown key 'standrad'", |
| 64 | + "path": "mcpp.toml", // omitted when there is none |
| 65 | + "range": { "start": {"line": 3, "column": 1}, |
| 66 | + "end": {"line": 3, "column": 9} } // omitted when there is none |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +Positions are 1-based. `column` counts UTF-8 **bytes**, so it indexes the same |
| 71 | +file mcpp read. |
| 72 | + |
| 73 | +A diagnostic without a location omits `path` and `range` rather than sending |
| 74 | +zeros — `line: 0` would point at a position that does not exist. |
| 75 | + |
| 76 | +`code` is always present. Parse `code`; never parse `message`. |
| 77 | + |
| 78 | +## 3. Asking for machine output |
| 79 | + |
| 80 | +``` |
| 81 | +mcpp <command> --format json |
| 82 | +``` |
| 83 | + |
| 84 | +`json` is the only supported value today. `ndjson` is reserved for a future |
| 85 | +streaming case and is **not** accepted — asking for it is an error, not a |
| 86 | +silent fallback. |
| 87 | + |
| 88 | +### Unsupported values and unknown options |
| 89 | + |
| 90 | +Both go to **stderr** with **exit code 2**, and write nothing to stdout: |
| 91 | + |
| 92 | +``` |
| 93 | +$ mcpp self env --format yaml |
| 94 | +error: unsupported --format 'yaml'; expected: json # stderr |
| 95 | +$ echo $? |
| 96 | +2 |
| 97 | +``` |
| 98 | + |
| 99 | +A request that does not yet know what it will be given must not write into the |
| 100 | +channel the protocol owns. Combined with §1, a client's rule is complete: no |
| 101 | +JSON on stdout means "not supported", whatever the reason. |
| 102 | + |
| 103 | +Exit codes: |
| 104 | + |
| 105 | +| code | meaning | |
| 106 | +|---|---| |
| 107 | +| 0 | success | |
| 108 | +| 2 | usage error — unknown option, unsupported value | |
| 109 | +| 70 | internal error (uncaught exception) | |
| 110 | +| 127 | unknown command | |
| 111 | + |
| 112 | +## 4. Effects — what a command does before it prints |
| 113 | + |
| 114 | +An IDE with an untrusted-workspace gate has to decide **before** running. |
| 115 | +By the time an envelope arrives, whatever it describes has already happened. |
| 116 | +So the same information is available statically: |
| 117 | + |
| 118 | +``` |
| 119 | +mcpp --protocol-version |
| 120 | +``` |
| 121 | + |
| 122 | +```jsonc |
| 123 | +{ |
| 124 | + "schemaVersion": 1, |
| 125 | + "kind": "mcpp.protocol", |
| 126 | + "envelope": { "min": 1, "max": 1 }, |
| 127 | + "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 }, |
| 128 | + "commands": { |
| 129 | + "self env": { "effects": ["init-mcpp-home"] }, |
| 130 | + "xpkg parse": { "effects": [] }, |
| 131 | + "cache list": { "effects": [] } |
| 132 | + } |
| 133 | +} |
| 134 | +``` |
| 135 | + |
| 136 | +Effects are named rather than a `destructive: true|false`, because a boolean |
| 137 | +cannot separate the harmless from the thing a gate exists for: |
| 138 | + |
| 139 | +| effect | meaning | |
| 140 | +|---|---| |
| 141 | +| `init-mcpp-home` | may create `$MCPP_HOME` on first use. **Outside your project.** | |
| 142 | +| `read-project` | reads the manifest and sources | |
| 143 | +| `write-project` | writes into the project tree (`target/`, the compile DB) | |
| 144 | +| `write-global-cache` | writes the shared build cache | |
| 145 | +| `network` | may fetch | |
| 146 | +| `exec-build-script` | **runs code from the workspace** (`build.mcpp`) | |
| 147 | + |
| 148 | +Most gates care about `exec-build-script` and `write-project`, and can ignore |
| 149 | +`init-mcpp-home` — mcpp setting itself up is not the workspace acting. |
| 150 | + |
| 151 | +## 5. `--json` is not `--format json` |
| 152 | + |
| 153 | +Two commands shipped a `--json` flag before this protocol existed: |
| 154 | + |
| 155 | +``` |
| 156 | +mcpp xpkg parse <file> --json -> {"namespace": …, "name": …, …} |
| 157 | +mcpp cache list --json -> {"root": …, "entries": [ … ]} |
| 158 | +``` |
| 159 | + |
| 160 | +Those payloads are **bare** — no envelope — and consumers already read them. |
| 161 | +So: |
| 162 | + |
| 163 | +> **`--json` keeps its payload for ever. `--format json` is the enveloped one.** |
| 164 | +
|
| 165 | +`--json` is not deprecated, and using it prints no warning: clients parse this |
| 166 | +output, and a warning would land in the middle of it. |
| 167 | + |
| 168 | +Both spellings are produced from the same source, so they always describe the |
| 169 | +same thing — one answer, two shapes. |
| 170 | + |
| 171 | +## 6. What you may rely on, and what changes |
| 172 | + |
| 173 | +For each `kind`, within a `kindVersion`: |
| 174 | + |
| 175 | +- fields are **added**, never removed |
| 176 | +- the meaning of a field never changes |
| 177 | +- a breaking change bumps the version and, where a window is needed, |
| 178 | + `protocol.min`/`max` overlap so both are readable |
| 179 | + |
| 180 | +That promise is only worth something if it is enforced, so each kind has a |
| 181 | +test that fails when a field name changes. A schema nobody can break is not a |
| 182 | +schema — `xlings interface --list` declares 20 capabilities whose |
| 183 | +`outputSchema` is, for all 20, only `{"exitCode": integer}`, and a client that |
| 184 | +sees a version number assumes there is a contract behind it. |
| 185 | + |
| 186 | +## 7. Kinds |
| 187 | + |
| 188 | +### `mcpp.env` — where mcpp keeps things |
| 189 | + |
| 190 | +``` |
| 191 | +mcpp self env --format json |
| 192 | +``` |
| 193 | + |
| 194 | +```jsonc |
| 195 | +{ |
| 196 | + "initialized": false, // is there a config.toml yet? |
| 197 | + "mcppHome": "/home/u/.mcpp", |
| 198 | + "registry": "/home/u/.mcpp/registry", |
| 199 | + "xlingsHome": "/home/u/.mcpp/registry", |
| 200 | + "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", |
| 201 | + "config": "/home/u/.mcpp/config.toml", |
| 202 | + "buildCache": "/home/u/.mcpp/build-cache/v1", |
| 203 | + "mcppVersion": "2026.8.8.3" |
| 204 | +} |
| 205 | +``` |
| 206 | + |
| 207 | +This path is read-only, deliberately. The human `mcpp self env` initialises |
| 208 | +`$MCPP_HOME` if it is missing — someone typing it at a prompt expects that — |
| 209 | +but a client asking *where things are* should not be what puts them there. On |
| 210 | +a machine that has never run mcpp you get the paths it **would** use and |
| 211 | +`initialized: false`, and the disk is untouched. |
| 212 | + |
| 213 | +That is why this exists at all: without it a client has to reimplement mcpp's |
| 214 | +home resolution, including the part where the `mcpp` on `PATH` may be an |
| 215 | +xlings shim rather than the real binary. |
| 216 | + |
| 217 | +### `mcpp.xpkg` — a parsed descriptor |
| 218 | + |
| 219 | +``` |
| 220 | +mcpp xpkg parse <file.lua> --format json |
| 221 | +``` |
| 222 | + |
| 223 | +`data` is the same document `--json` prints bare. |
| 224 | + |
| 225 | +### `mcpp.cache` — the global build cache |
| 226 | + |
| 227 | +``` |
| 228 | +mcpp cache list --format json |
| 229 | +``` |
| 230 | + |
| 231 | +`data` is `{root, entries[]}`, the same document `--json` prints bare. |
0 commit comments