|
| 1 | +# @loopover/contract |
| 2 | + |
| 3 | +The single zod source of truth for LoopOver's MCP tool and API contracts. |
| 4 | + |
| 5 | +LoopOver runs three MCP servers — the hosted/self-host remote server (`src/mcp/server.ts`), the |
| 6 | +stdio contributor wrapper (`@loopover/mcp`), and the AMS miner server (`@loopover/miner`) — plus a |
| 7 | +REST API and a UI that all describe the same data. Before this package, each of those declared its |
| 8 | +own zod shapes, and the copies drifted: the stdio server's shapes were hand-mirrored from the remote |
| 9 | +server's (their own comments said so), enum literals were hand-copied out of the engine, and |
| 10 | +responses were consumed as `any`. |
| 11 | + |
| 12 | +This package is the one place those contracts live. **A shape declared here is never restated |
| 13 | +elsewhere.** |
| 14 | + |
| 15 | +## Why a separate package |
| 16 | + |
| 17 | +It is a **leaf**: its only runtime dependency is `zod`, and it imports no node builtins, so it is |
| 18 | +safe in the Cloudflare Workers bundle. That is what lets every surface depend on it — the Worker, |
| 19 | +both published stdio bins, the miner, the control plane, and the UI — without dragging the engine |
| 20 | +along behind it. Sharing these schemas through `@loopover/engine` was considered and rejected: |
| 21 | +`@loopover/mcp` resolves the engine through its *published* export map, which never surfaced the |
| 22 | +enums, so importing them would have meant widening the engine's public API (#6153). |
| 23 | + |
| 24 | +## Layout |
| 25 | + |
| 26 | +| Path | Holds | |
| 27 | +|---|---| |
| 28 | +| `src/tool-definition.ts` | The `ToolContract` model, `defineTool`, and `projectToolDefinitions` — the single projection point | |
| 29 | +| `src/tools/*.ts` | One file per tool family; the contracts themselves | |
| 30 | +| `src/tools/index.ts` | `TOOL_CONTRACTS`, `listToolDefinitions()`, `getToolContract()` | |
| 31 | +| `src/enums.ts` | Shared enum vocabularies (autonomy levels, action classes, …) | |
| 32 | +| `src/shared.ts` | Shapes reused by **three or more** contracts | |
| 33 | +| `src/agent-specs.ts` | OpenAI / Anthropic / agent-index projections | |
| 34 | + |
| 35 | +## Conventions |
| 36 | + |
| 37 | +These are enforced by meta-tests in `test/unit/contract-registry.test.ts`, not just documented. |
| 38 | + |
| 39 | +**Naming.** One file per tool family. Within it, export `<ToolNamePascal>Input` and |
| 40 | +`<ToolNamePascal>Output` schemas plus the `defineTool(...)` contract. Derive types with |
| 41 | +`z.infer<typeof X>` — never hand-write an interface that mirrors a schema. |
| 42 | + |
| 43 | +**Inputs are closed; outputs are open.** Input schemas use `z.object`, which emits |
| 44 | +`additionalProperties: false`. Output schemas use `z.looseObject`, which emits open |
| 45 | +`additionalProperties`. An MCP output schema is a *floor*, not a fence: a server that starts |
| 46 | +returning an extra field must not retroactively invalidate a client validating against the older |
| 47 | +schema. |
| 48 | + |
| 49 | +> **Known gap:** zod's `z.object` *strips* unknown keys at runtime rather than rejecting them, so a |
| 50 | +> typo'd argument is silently dropped even though the advertised JSON Schema says it should be |
| 51 | +> refused. Switching to `z.strictObject` would close the gap but is a wire-visible tightening, so it |
| 52 | +> is a recorded decision on #9518 rather than a drive-by change. A meta-test pins the current |
| 53 | +> behavior so the switch cannot happen by accident. |
| 54 | +
|
| 55 | +**Output schemas may be shallower than their REST counterparts, and that is deliberate.** Reusing a |
| 56 | +strict REST response schema for an MCP tool *tightens* the wire contract and is a regression — the |
| 57 | +exact constraint metagraphed hit during its own migration. Reuse a REST schema only when it is |
| 58 | +field-for-field equal to what the tool actually returns. What is never acceptable is a top-level |
| 59 | +`z.unknown()` standing in for a real object. |
| 60 | + |
| 61 | +**Hoist to `shared.ts` at the third consumer, not the second.** Two contracts sharing fields today |
| 62 | +is usually coincidence; coupling them early means a later divergence has to be un-shared under |
| 63 | +pressure. |
| 64 | + |
| 65 | +**Metadata is a declaration, not a hint.** Every contract states its `auth`, `locality`, and |
| 66 | +`availability`, and runtimes enforce them: |
| 67 | + |
| 68 | +- `locality` — where the state physically lives (`remote`, `local-git`, `miner`). This is why |
| 69 | + LoopOver cannot collapse to one MCP process: `local-git` tools read the caller's uncommitted |
| 70 | + working tree and `miner` tools read the miner box's stores, neither reachable from a Worker. |
| 71 | +- `availability` — `cloud`, `selfhost`, or `both`. Self-host-only tools depend on capabilities the |
| 72 | + Workers bundle cannot provide (fs-backed config, a redeploy socket). |
| 73 | +- `auth` — the identity kind `src/auth/security.ts` must authenticate before the tool runs. |
| 74 | + |
| 75 | +**Nothing reads `TOOL_CONTRACTS` directly.** Consumers call `listToolDefinitions()` (optionally |
| 76 | +filtered), so cross-cutting concerns are applied exactly once. |
| 77 | + |
| 78 | +## Adding a tool |
| 79 | + |
| 80 | +1. Add `src/tools/<family>.ts` with input + output schemas and a `defineTool(...)` entry. |
| 81 | +2. Export it from `src/tools/index.ts`. |
| 82 | +3. Register it in whichever runtimes can serve its locality, using `contract.input.shape` / |
| 83 | + `contract.output.shape` for the MCP SDK. |
| 84 | +4. The contract validator (#9520) requires a smoke call per tool — a tool with no call fails CI. |
| 85 | + |
| 86 | +Generated docs, agent tool specs, and the tool-reference tables pick it up automatically. If you |
| 87 | +find yourself hand-editing a tool table, that table is a bug. |
0 commit comments