11.5.3 The HTTP transport core — `fetch` + bearer + the boundary parse, the status→typed-error map, and the version-skew gate
The layer every typed method will sit on: one request() that speaks /api/v1, validates what comes back, and turns an HTTP status into the CLI error the top-level runner already knows how to print. It replaces callStructured + mapCallError + isUnauthorized — the three functions that exist only because MCP reports failures in band.
What to build
- The request primitive —
fetchagainst the resolved server base URL withAuthorization: Bearer <PAT>, JSON in and out, per-request path + query building. The token and base URL come from where they already come from (.motir.json+ the user config,normalizeServerUrl); this card changes no credential storage and no login flow. - The boundary PARSE, not a cast. Every response body goes through 11.5.2's validator for its operation before any caller sees it. A mismatch raises an error naming the offending field and its path. This is the single line that makes the "never a silently-blank rendered cell" criterion true, and it is the deliberate opposite of the shipped
result.structuredContent as T. - The status map, exactly as 11.5.1 Q5 pins it — 401 →
AuthError, 403 → the scope-hint error naming the missing scope, 429 → the rate-limit error reportingX-RateLimit-Reset, 404 → not-found, 4xx-with-envelope → the server's own{ code, error }sentence, 5xx and transport failure →CliError. Read the machinecode, never parse the humanerrorsentence (the ADR's own instruction to clients). - The version-skew gate, as Q3 pins it: read the server's advertised contract version, compare MAJORs against the one the client was generated against, and on incompatibility raise ONE clear upgrade error rather than letting N field-level parses fail confusingly. Fires at most once per invocation.
- Cursor pass-through. A
nextCursoris opaque and collection-scoped; the transport carries it in both directions and never constructs, parses, merges or reuses one across collections.
Scope BOUNDARY
Ends at the transport primitive. It does NOT port a single typed method — whoami(), getWorkItem() and the rest still call MCP when this card lands (11.5.4 / 11.5.5 port them). It does NOT delete the MCP transport or the SDK dependency (11.5.6). It does NOT touch render.ts, any command file, or the adapter layer (that arrives with its first consumer). It does NOT change auth login, the device-code flow, or how a token is stored. It does NOT add or change any server route.
Acceptance criteria
- A
request()primitive issues a realfetchwith the bearer header, builds paths and query strings from typed inputs, and returns a validated body — driven in tests against a stub HTTP server, not a mockedfetchmodule. - A response failing validation raises an error whose message names the field and its path; asserted with a payload corrupted in three different ways (missing required key, wrong scalar type, wrong enum value).
- Each of 401 / 403 / 404 / 429 / 500 / a connection failure produces its mapped error class with the pinned message and hint, asserted per status; the 403 names the missing scope and the 429 reports the reset time from the header.
- The error path reads the envelope's machine
codeand never branches on the humanerrorstring — asserted by driving two different sentences under one code. - An incompatible advertised contract major produces exactly one upgrade error, and a COMPATIBLE-but-newer minor produces none (§8 is additive-only within a major, so a newer minor is normal and must be silent).
- An opaque cursor round-trips unmodified; a cursor is never inspected or rebuilt by the client.
- No
ascast on a wire payload anywhere in the new code. - The per-file coverage floor (≥90%) holds on every new file, and the CLI's existing command behaviour is unchanged.
Context refs
- 11.5.1 — Q3 (skew) and Q5 (the status→error table) are this card's spec; do not re-derive either.
- 11.5.2 — the generated types + validators this consumes.
packages/cli/src/mcpClient.ts—callStructured,mapCallError,isUnauthorized,normalizeServerUrlusage: what this replaces and what it keeps.packages/cli/src/errors.ts—CliError/AuthErrorand theexitCode/hintcontract the top-level runner prints.packages/cli/src/config/userConfig.ts+packages/cli/src/serverResolve.ts— where the base URL and token come from, unchanged by this card.lib/api/v1/errors.ts+lib/api/v1/rateLimit.ts— the shipped{ code, error }envelope and theX-RateLimit-*headers this maps from.docs/decisions/public-api-conventions.md§4 (errors), §5 (cursors), §6 (rate limits), §8 (additive-only).- Story: 11.5.