# contractpin.lock — specification

**Version:** 1
**Status:** draft, stable enough to build against
**Format identifier:** `contractpin-canonical-json/1`
**License:** MIT (this document and the reference implementation)

This document is written so that an independent implementation, in any
language, produces byte-identical digests. If your implementation reproduces
every vector in [`tests/vectors.json`](tests/vectors.json), it is conformant.

---

## 1. What is pinned, and what is not

contractpin pins the **contract** of an MCP tool: the text and schemas a model reads
when it decides whether and how to call that tool.

contractpin does **not** pin, verify, or attest to:

- the code that implements the tool,
- the package or container the server ships in,
- the server's identity, ownership, or trustworthiness,
- what the tool actually does when called.

This is a deliberate boundary, not an omission. The contract is the only part of
a remote MCP server that a client can observe, reproduce, and compare. Anything
beyond it would require trusting a claim rather than checking a fact. See
[`SEP_DRAFT.md`](SEP_DRAFT.md) §4 for the argument in full.

The threat this closes: a server whose *package version does not change* rewrites
a tool's description to carry an injected instruction, widens an input schema to
capture extra data, flips `readOnlyHint` from `true` to `false`, or adds a new
tool to an already-trusted server. A package lockfile sees none of this. A
contract digest sees all of it.

---

## 2. The contract object

Given an MCP tool definition as returned in the `tools` array of a `tools/list`
result, the **contract object** is built as follows.

**Required fields** — always present in the contract, in every case:

| Field | Source |
|---|---|
| `name` | the tool's `name` |
| `description` | the tool's `description`, or JSON `null` if absent |
| `inputSchema` | the tool's `inputSchema`, or JSON `null` if absent |

**Optional fields** — included **if and only if** the tool defines them with a
value that is not `null`:

| Field | Source |
|---|---|
| `outputSchema` | the tool's `outputSchema` |
| `annotations` | the tool's `annotations` |

Every other member of the tool object is **ignored**, including `title` and any
vendor extension. Rationale: `title` is a display string for human UI surfaces
and does not enter the model's decision; ignoring unknown members means a
future MCP revision that adds fields does not invalidate every existing lock.

> **Absent vs. null.** A required field that is absent and a required field
> explicitly set to `null` produce the **same** contract and therefore the same
> digest. An optional field that is absent and one explicitly set to `null`
> likewise produce the same contract. This is the only normalisation contractpin
> performs on tool data; there is no other coercion.

A conforming implementation MUST NOT reorder, rewrite, prune, or otherwise
normalise the *contents* of `inputSchema`, `outputSchema`, or `annotations`.
They are pinned exactly as served, modulo canonicalization (§3).

---

## 3. Canonicalization

The contract object is serialised to bytes by `contractpin-canonical-json/1`, which
is [RFC 8785 (JSON Canonicalization Scheme)](https://www.rfc-editor.org/rfc/rfc8785)
applied to the contract object. The rules, stated in full so this document
stands alone:

**3.1 Encoding.** Output is UTF-8. No byte order mark. No trailing newline.

**3.2 Whitespace.** None. No space after `:`, none after `,`, no indentation, no
line breaks anywhere outside string literals.

**3.3 Object members.** Sorted by key, ascending, comparing the keys' **UTF-16
code units**. Equivalently: compare `key.encode("utf-16-be")` byte-wise. This
matters above the BMP — `U+1F600` sorts *before* `U+FFFF` under UTF-16 ordering
and *after* it under code-point ordering. Vector `key-order-utf16` pins the
behaviour. Duplicate keys are impossible in a parsed JSON object; if your parser
can produce them, reject the input.

**3.4 Array elements.** Order is preserved exactly. Arrays are data, never sorted.

**3.5 Strings.** Escape, and only escape:

| Character | Escape |
|---|---|
| `"` U+0022 | `\"` |
| `\` U+005C | `\\` |
| U+0008 | `\b` |
| U+0009 | `\t` |
| U+000A | `\n` |
| U+000C | `\f` |
| U+000D | `\r` |
| other U+0000–U+001F | `\u00xx`, **lowercase** hex |

Every other code point, including all non-ASCII, is emitted literally as UTF-8.
`/` is not escaped. `\uXXXX` escapes are not used for anything a literal can
express.

**3.6 Numbers.** Serialised per RFC 8785 §3.2.2.3, i.e. ECMAScript
`Number::toString`. Concretely, for a finite value: take the shortest decimal
digit string `s` (length `k`) and integer `n` with `value = ±s × 10^(n−k)` and
`s` not ending in `0` unless `k = 1`; then

- if `k ≤ n ≤ 21` → digits of `s` followed by `n − k` zeros (`1e20` → `100000000000000000000`)
- else if `0 < n ≤ 21` → `s` with a decimal point after `n` digits (`1.5` → `1.5`)
- else if `−6 < n ≤ 0` → `0.` + `−n` zeros + `s` (`1e-6` → `0.000001`)
- else → `s[0]` (+ `.` + `s[1:]` if `k > 1`) + `e` + `+`/`−` + `|n − 1|` (`1e21` → `1e+21`)

with a leading `-` for negatives. `1.0` serialises as `1`. `-0.0` serialises as
`0`. `NaN` and `±Infinity` are not JSON and MUST be rejected.

**3.7 Literals.** `true`, `false`, `null`, lowercase.

**3.8 Rejection.** Non-string object keys, and any value that is not a JSON
value, MUST cause the implementation to fail loudly rather than guess.

---

## 4. Digests

**4.1 Tool digest.**

```
tool_digest = "sha256:" + lowercase_hex( SHA-256( canonical_json( contract ) ) )
```

Exactly 71 characters: the literal `sha256:` followed by 64 lowercase hex digits.

**4.2 Field digests (advisory).** For each key `f` present in the contract, the
field digest is `"sha256:" + hex(SHA-256(canonical_json(contract[f])))`. These
are recorded so a verifier can say *which* part of a tool moved. They are
**never** an input to the tool digest and an implementation MAY omit them.

**4.3 Server digest.** Build the object mapping every tool name to its tool
digest, then digest that:

```
server_digest = "sha256:" + hex( SHA-256( canonical_json( {name: tool_digest, …} ) ) )
```

A server digest therefore changes if any tool changes, is added, or is removed.
It is the single value a human can eyeball in a diff.

**4.4 Duplicate tool names.** A `tools/list` result containing two tools with
the same `name` is malformed. Implementations MUST fail rather than pick one.

---

## 5. The lock file

Default filename `contractpin.lock`, in the repository root, committed to version
control. It is a JSON object:

```json
{
  "contractpin": "1",
  "algorithm": "sha256",
  "canonicalization": "contractpin-canonical-json/1",
  "generated_at": "2026-08-14T09:31:07Z",
  "servers": {
    "acme-search": {
      "transport": "stdio",
      "command": ["npx", "-y", "@acme/mcp-search"],
      "tool_count": 2,
      "digest": "sha256:6e0f…",
      "tools": {
        "search_web": {
          "digest": "sha256:1c9a…",
          "fields": {
            "name": "sha256:…",
            "description": "sha256:…",
            "inputSchema": "sha256:…"
          }
        },
        "read_file": { "digest": "sha256:44b1…", "fields": { "…": "…" } }
      }
    }
  }
}
```

**Top level**

| Key | Required | Meaning |
|---|---|---|
| `contractpin` | yes | lock format version, currently `"1"`. A verifier MUST refuse a version it does not implement. |
| `algorithm` | yes | `"sha256"`. |
| `canonicalization` | yes | `"contractpin-canonical-json/1"`. |
| `generated_at` | yes | UTC timestamp, `YYYY-MM-DDTHH:MM:SSZ`. Informational. |
| `servers` | yes | object keyed by a local server name chosen by the adopter. |

**Per server**

| Key | Required | Meaning |
|---|---|---|
| `transport` | yes | `"stdio"`, `"http"`, or `"file"`. |
| `command` | if stdio | argv array. |
| `env`, `cwd` | no | extra process setup for stdio. |
| `url` | if http | the endpoint. |
| `path` | if file | a local `tools/list` JSON dump. |
| `tool_count` | yes | length of `tools`; a cheap consistency check. |
| `digest` | yes | server digest (§4.3). |
| `tools` | yes | object keyed by tool name. |

**Secrets.** A conforming writer MUST NOT persist credentials into the lock.
The reference implementation accepts `--header 'Authorization: …'` at the
command line and deliberately drops it before writing.

**Formatting.** The lock file is written pretty-printed, keys sorted, two-space
indent, LF endings, trailing newline — for readable git diffs. **The lock file's
own formatting is not part of any digest.** Reformat it freely; only the values
matter.

---

## 6. Verification

Given a lock and a live server:

1. Enumerate the live tools via `tools/list`, following `nextCursor` to the end.
   Pagination MUST NOT affect the result: page boundaries are not data.
2. Compute a contract digest for each live tool (§2–§4).
3. Classify by tool name:
   - in lock, not live → **removed**
   - live, not in lock → **added**
   - in both, digests differ → **drifted** (report the changed fields from §4.2)
   - in both, digests equal → unchanged

**Exit codes.** These are the API; CI depends on them.

| Code | Meaning |
|---|---|
| `0` | every selected server matches the lock |
| `1` | drift: at least one tool drifted, was added, or was removed — or a pinned server could not be reached |
| `2` | the tool could not run: bad arguments, missing/corrupt lock, unknown lock version |

`added` is a failure **by default**. A new tool on a server you already trust is
the cheapest possible place to put a poisoned description, and the cost of
acknowledging a legitimate addition is one `contractpin init --force`. An
implementation MAY offer an opt-out (the reference CLI spells it `--allow-new`);
it MUST NOT be the default.

An unreachable pinned server is exit `1`, not `0`. "I could not check" must not
be reported as "nothing is wrong."

---

## 7. Conformance

`tests/vectors.json` is the normative test suite. Each vector gives an `input`
value, a flag for whether to project it onto the contract fields first, the
expected `canonical` serialisation, and the expected `digest`. An implementation
is conformant when it reproduces both for every vector.

Regenerate with `python tests/make_vectors.py`. Changing a vector's expected
output is a **breaking format change** and requires bumping
`contractpin-canonical-json/1`.

---

## 8. Versioning

- `contractpin` (lock format) changes when the file's shape changes.
- `contractpin-canonical-json/N` changes when identical inputs would produce
  different bytes. Consumers MUST refuse an unknown canonicalization rather than
  attempt a best-effort comparison — a silently wrong digest is worse than no
  digest.

## 9. Prior art and relationship to MCP

The MCP registry pins package identity, not tool contracts. SEP-1766 proposed
digest-based verification and was closed procedurally with an explicit
invitation to resubmit; the design conclusion carried out of that thread — that
only the contract is verifiable, never code archives — is exactly what §2
implements. `contractpin.lock` is designed to become redundant: if MCP adopts
server-signed contract digests, a lock file is a strictly weaker version of the
same guarantee and should be retired. See [`SEP_DRAFT.md`](SEP_DRAFT.md).
