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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,14 @@ jobs:
key: turbo-code-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
restore-keys: |
turbo-code-${{ hashFiles('package-lock.json') }}-
# @loopover/contract's "types" resolve to packages/loopover-contract/dist/index.d.ts, and src/ +
# packages/loopover-mcp both import it -- so like the engine below, typecheck cannot run until it
# has been built. Unconditional rather than gated on a `contract` path filter: every consumer of it
# (backend, mcp, miner, ui) can pull it into the typecheck surface, and building a zod-only leaf
# package with no dependencies of its own is cheap enough that gating it would buy nothing but a
# class of skipped-build failures.
- name: Build contract package
run: npx turbo run build --filter=@loopover/contract
# mcp/miner are in this gate because "Typecheck" below already runs for them, and typecheck's real
# surface reaches @loopover/engine -- test/** imports it directly, and its "types" resolve to
# packages/loopover-engine/dist/index.d.ts, which only exists once this step has run. An mcp-only or
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ jobs:
# That package's dist/ is gitignored and only exists after this build step -- the regular CI smoke
# test's narrower (non --all) build never hits this import chain, so it never caught the gap that
# ci.yml's own validate-code job hit for ordinary backend PRs (fixed there separately).
# Built before the engine for the same reason the Dockerfile does: src/'s import graph reaches
# @loopover/contract, whose package exports resolve to dist/, so anything type-checking or
# bundling src/ needs it emitted first. Zod-only leaf, no workspace dependencies of its own.
- name: Build contract package
run: npm run build --workspace @loopover/contract
- name: Build engine package
run: npm run build --workspace @loopover/engine

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ jobs:
# dist/ is gitignored and only exists after this build step, so the test fails to resolve the
# package's exports without it -- this workflow never needed the engine package built before, so it
# never had this step; it does now.
# Built before the engine for the same reason the Dockerfile does: src/'s import graph reaches
# @loopover/contract, whose package exports resolve to dist/, so anything type-checking or
# bundling src/ needs it emitted first. Zod-only leaf, no workspace dependencies of its own.
- name: Build contract package
run: npm run build --workspace @loopover/contract
- name: Build engine package
run: npm run build --workspace @loopover/engine

Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ COPY . .
# --ignore-scripts: no native builds are needed (SQLite is the built-in node:sqlite; @hono/node-server is
# pure JS; esbuild ships its binary as an optional dependency, not a script).
RUN npm ci --ignore-scripts
# @loopover/contract before the engine: src/ imports it, and its package exports resolve to dist/,
# so esbuild in build-selfhost.ts below cannot resolve the import until it has been emitted. A
# zod-only leaf with no workspace dependencies, so it builds first and standalone.
RUN npm --workspace @loopover/contract run build
RUN npm --workspace @loopover/engine run build
# --all: bundle every dependency into one self-contained dist/server.mjs, so the runtime image needs no
# node_modules (≈10× smaller). The bundle has zero `cloudflare:*` imports (stubbed at build), so no loader.
Expand Down
67 changes: 66 additions & 1 deletion apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1417,15 +1417,27 @@
"sampled_cache",
"authoritative"
]
},
"draftPullRequests": {
"type": "number"
},
"slopFlaggedPullRequests": {
"type": "number"
},
"duplicateFlaggedPullRequests": {
"type": "number"
}
},
"required": [
"openIssues",
"openPullRequests",
"unlinkedPullRequests",
"stalePullRequests",
"draftPullRequests",
"maintainerAuthoredPullRequests",
"collisionClusters",
"slopFlaggedPullRequests",
"duplicateFlaggedPullRequests",
"ageBuckets",
"likelyReviewablePullRequests"
]
Expand All @@ -1435,6 +1447,32 @@
"items": {
"$ref": "#/components/schemas/Finding"
}
},
"rankedPullRequests": {
"type": "array",
"items": {
"type": "object",
"properties": {
"number": {
"type": "number"
},
"title": {
"type": "string"
},
"authorLogin": {
"type": "string"
},
"recommendation": {
"type": "string"
}
},
"required": [
"number",
"title",
"authorLogin",
"recommendation"
]
}
}
},
"required": [
Expand Down Expand Up @@ -1527,7 +1565,8 @@
"type": "string",
"enum": [
"issue",
"pull_request"
"pull_request",
"recent_merged_pull_request"
]
},
"number": {
Expand All @@ -1543,6 +1582,32 @@
"htmlUrl": {
"type": "string",
"nullable": true
},
"labels": {
"type": "array",
"items": {
"type": "string"
}
},
"linkedIssues": {
"type": "array",
"items": {
"type": "number"
}
},
"linkedIssueClaimedAt": {
"type": "string",
"nullable": true
},
"changedFiles": {
"type": "array",
"items": {
"type": "string"
}
},
"body": {
"type": "string",
"nullable": true
}
},
"required": [
Expand Down
36 changes: 36 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@cloudflare/puppeteer": "^1.1.0",
"@hono/node-server": "^2.0.11",
"@loopover/contract": "^0.1.0",
"@loopover/engine": "*",
"@modelcontextprotocol/sdk": "1.29.0",
"@octokit/core": "^7.0.6",
Expand Down
87 changes: 87 additions & 0 deletions packages/loopover-contract/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# @loopover/contract

The single zod source of truth for LoopOver's MCP tool and API contracts.

LoopOver runs three MCP servers — the hosted/self-host remote server (`src/mcp/server.ts`), the
stdio contributor wrapper (`@loopover/mcp`), and the AMS miner server (`@loopover/miner`) — plus a
REST API and a UI that all describe the same data. Before this package, each of those declared its
own zod shapes, and the copies drifted: the stdio server's shapes were hand-mirrored from the remote
server's (their own comments said so), enum literals were hand-copied out of the engine, and
responses were consumed as `any`.

This package is the one place those contracts live. **A shape declared here is never restated
elsewhere.**

## Why a separate package

It is a **leaf**: its only runtime dependency is `zod`, and it imports no node builtins, so it is
safe in the Cloudflare Workers bundle. That is what lets every surface depend on it — the Worker,
both published stdio bins, the miner, the control plane, and the UI — without dragging the engine
along behind it. Sharing these schemas through `@loopover/engine` was considered and rejected:
`@loopover/mcp` resolves the engine through its *published* export map, which never surfaced the
enums, so importing them would have meant widening the engine's public API (#6153).

## Layout

| Path | Holds |
|---|---|
| `src/tool-definition.ts` | The `ToolContract` model, `defineTool`, and `projectToolDefinitions` — the single projection point |
| `src/tools/*.ts` | One file per tool family; the contracts themselves |
| `src/tools/index.ts` | `TOOL_CONTRACTS`, `listToolDefinitions()`, `getToolContract()` |
| `src/enums.ts` | Shared enum vocabularies (autonomy levels, action classes, …) |
| `src/shared.ts` | Shapes reused by **three or more** contracts |
| `src/agent-specs.ts` | OpenAI / Anthropic / agent-index projections |

## Conventions

These are enforced by meta-tests in `test/unit/contract-registry.test.ts`, not just documented.

**Naming.** One file per tool family. Within it, export `<ToolNamePascal>Input` and
`<ToolNamePascal>Output` schemas plus the `defineTool(...)` contract. Derive types with
`z.infer<typeof X>` — never hand-write an interface that mirrors a schema.

**Inputs are closed; outputs are open.** Input schemas use `z.object`, which emits
`additionalProperties: false`. Output schemas use `z.looseObject`, which emits open
`additionalProperties`. An MCP output schema is a *floor*, not a fence: a server that starts
returning an extra field must not retroactively invalidate a client validating against the older
schema.

> **Known gap:** zod's `z.object` *strips* unknown keys at runtime rather than rejecting them, so a
> typo'd argument is silently dropped even though the advertised JSON Schema says it should be
> refused. Switching to `z.strictObject` would close the gap but is a wire-visible tightening, so it
> is a recorded decision on #9518 rather than a drive-by change. A meta-test pins the current
> behavior so the switch cannot happen by accident.

**Output schemas may be shallower than their REST counterparts, and that is deliberate.** Reusing a
strict REST response schema for an MCP tool *tightens* the wire contract and is a regression — the
exact constraint metagraphed hit during its own migration. Reuse a REST schema only when it is
field-for-field equal to what the tool actually returns. What is never acceptable is a top-level
`z.unknown()` standing in for a real object.

**Hoist to `shared.ts` at the third consumer, not the second.** Two contracts sharing fields today
is usually coincidence; coupling them early means a later divergence has to be un-shared under
pressure.

**Metadata is a declaration, not a hint.** Every contract states its `auth`, `locality`, and
`availability`, and runtimes enforce them:

- `locality` — where the state physically lives (`remote`, `local-git`, `miner`). This is why
LoopOver cannot collapse to one MCP process: `local-git` tools read the caller's uncommitted
working tree and `miner` tools read the miner box's stores, neither reachable from a Worker.
- `availability` — `cloud`, `selfhost`, or `both`. Self-host-only tools depend on capabilities the
Workers bundle cannot provide (fs-backed config, a redeploy socket).
- `auth` — the identity kind `src/auth/security.ts` must authenticate before the tool runs.

**Nothing reads `TOOL_CONTRACTS` directly.** Consumers call `listToolDefinitions()` (optionally
filtered), so cross-cutting concerns are applied exactly once.

## Adding a tool

1. Add `src/tools/<family>.ts` with input + output schemas and a `defineTool(...)` entry.
2. Export it from `src/tools/index.ts`.
3. Register it in whichever runtimes can serve its locality, using `contract.input.shape` /
`contract.output.shape` for the MCP SDK.
4. The contract validator (#9520) requires a smoke call per tool — a tool with no call fails CI.

Generated docs, agent tool specs, and the tool-reference tables pick it up automatically. If you
find yourself hand-editing a tool table, that table is a bug.
64 changes: 64 additions & 0 deletions packages/loopover-contract/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"name": "@loopover/contract",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"type": "module",
"description": "Single zod source of truth for LoopOver's MCP tool and API contracts — schemas, tool metadata, and the projections every server and client derives from.",
"repository": {
"type": "git",
"url": "git+https://github.com/JSONbored/loopover.git",
"directory": "packages/loopover-contract"
},
"homepage": "https://github.com/JSONbored/loopover#readme",
"bugs": {
"url": "https://github.com/JSONbored/loopover/issues"
},
"keywords": [
"loopover",
"mcp",
"model-context-protocol",
"zod",
"openapi",
"schema"
],
"publishConfig": {
"access": "public"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./enums": {
"types": "./dist/enums.d.ts",
"default": "./dist/enums.js"
},
"./tools": {
"types": "./dist/tools/index.d.ts",
"default": "./dist/tools/index.js"
},
"./agent-specs": {
"types": "./dist/agent-specs.d.ts",
"default": "./dist/agent-specs.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"engines": {
"node": ">=22.0.0 <23.0.0"
}
}
Loading
Loading