Skip to content

Commit 6ef01c9

Browse files
committed
feat(contract): add @loopover/contract, the single zod source for tool and API schemas
LoopOver declares the same contracts in four unshared places: src/openapi/schemas.ts (responses, spec-only), src/api/routes.ts (request bodies), src/mcp/server.ts (tool shapes), and packages/loopover-mcp/bin/loopover-mcp.ts (~85 shapes whose own comments say they mirror the remote server's). This package is the one place they can live. It is a zod-only leaf with no node builtins, which is the property that lets the Worker, both published stdio bins, the miner, and the UI depend on it. Sharing these through @loopover/engine was rejected once already (#6153): @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. The model carries schemas ON each entry rather than in a name-keyed side map, so a tool without an output schema is a type error rather than a silently dropped lookup, and projectToolDefinitions is the single point every consumer derives from -- MCP registration, the OpenAI/Anthropic spec builders that #9183/#9184 need, and the generated docs to come. Each entry declares auth, locality, and availability; locality is what makes explicit why LoopOver cannot collapse to one MCP process. Six pilot contracts, modelled from the engine types the handlers actually return rather than from the placeholder schemas they advertise today -- the remote server declared six of get_repo_context's eight fields, and all of predict_gate's blockers, warnings, and funnel, as bare z.unknown(). Writing them surfaced two divergences that are modelled honestly as unions and left for #9518 to converge: get_repo_context and get_pr_reviewability return DIFFERENT payloads from the remote and stdio servers. Input bounds take the wider of the two servers' historical limits where they disagreed, since a shared contract may widen an input but never tighten one. Refs #9517
1 parent d48651a commit 6ef01c9

20 files changed

Lines changed: 1453 additions & 0 deletions

package-lock.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"name": "@loopover/contract",
3+
"version": "0.1.0",
4+
"license": "AGPL-3.0-only",
5+
"type": "module",
6+
"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.",
7+
"repository": {
8+
"type": "git",
9+
"url": "git+https://github.com/JSONbored/loopover.git",
10+
"directory": "packages/loopover-contract"
11+
},
12+
"homepage": "https://github.com/JSONbored/loopover#readme",
13+
"bugs": {
14+
"url": "https://github.com/JSONbored/loopover/issues"
15+
},
16+
"keywords": [
17+
"loopover",
18+
"mcp",
19+
"model-context-protocol",
20+
"zod",
21+
"openapi",
22+
"schema"
23+
],
24+
"publishConfig": {
25+
"access": "public"
26+
},
27+
"main": "dist/index.js",
28+
"types": "dist/index.d.ts",
29+
"exports": {
30+
".": {
31+
"types": "./dist/index.d.ts",
32+
"default": "./dist/index.js"
33+
},
34+
"./enums": {
35+
"types": "./dist/enums.d.ts",
36+
"default": "./dist/enums.js"
37+
},
38+
"./tools": {
39+
"types": "./dist/tools/index.d.ts",
40+
"default": "./dist/tools/index.js"
41+
},
42+
"./agent-specs": {
43+
"types": "./dist/agent-specs.d.ts",
44+
"default": "./dist/agent-specs.js"
45+
},
46+
"./package.json": "./package.json"
47+
},
48+
"files": [
49+
"dist",
50+
"CHANGELOG.md"
51+
],
52+
"scripts": {
53+
"build": "tsc -p tsconfig.json"
54+
},
55+
"dependencies": {
56+
"zod": "^4.4.3"
57+
},
58+
"devDependencies": {
59+
"typescript": "^5.9.3"
60+
},
61+
"engines": {
62+
"node": ">=22.0.0 <23.0.0"
63+
}
64+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Non-MCP projections of the same tool registry (#9517).
2+
//
3+
// The hosted maintainer chat (#9183) and hosted AMS chat (#9184) both need a "grounding tool
4+
// catalog" to hand an LLM. That catalog is this registry in a different envelope -- not a second
5+
// list to maintain. Each builder is a pure function over already-projected definitions, so a tool
6+
// added to the contract appears in every surface at once, and a validator can assert the served
7+
// bytes equal a fresh build.
8+
import type { JsonSchemaLike, McpToolDefinition } from "./tool-definition.js";
9+
10+
export type OpenAIToolSpec = {
11+
type: "function";
12+
function: {
13+
name: string;
14+
description: string;
15+
parameters: JsonSchemaLike;
16+
};
17+
};
18+
19+
export type AnthropicToolSpec = {
20+
name: string;
21+
description: string;
22+
input_schema: JsonSchemaLike;
23+
};
24+
25+
export function buildOpenAIToolSpecs(tools: readonly McpToolDefinition[]): OpenAIToolSpec[] {
26+
return tools.map((tool) => ({
27+
type: "function",
28+
function: {
29+
name: tool.name,
30+
description: tool.description,
31+
parameters: tool.inputSchema,
32+
},
33+
}));
34+
}
35+
36+
export function buildAnthropicToolSpecs(tools: readonly McpToolDefinition[]): AnthropicToolSpec[] {
37+
return tools.map((tool) => ({
38+
name: tool.name,
39+
description: tool.description,
40+
input_schema: tool.inputSchema,
41+
}));
42+
}
43+
44+
export type AgentToolsIndex = {
45+
schema_version: number;
46+
title: string;
47+
description: string;
48+
executor: {
49+
transport: "mcp-streamable-http";
50+
endpoint: string;
51+
jsonrpc_method: "tools/call";
52+
};
53+
specs: {
54+
openai: OpenAIToolSpec[];
55+
anthropic: AnthropicToolSpec[];
56+
};
57+
tools: string[];
58+
};
59+
60+
/** Bumped only when the index's own envelope changes shape -- not when tools are added. */
61+
export const AGENT_TOOLS_INDEX_SCHEMA_VERSION = 1;
62+
63+
export function buildAgentToolsIndex(tools: readonly McpToolDefinition[], options: { endpoint: string }): AgentToolsIndex {
64+
return {
65+
schema_version: AGENT_TOOLS_INDEX_SCHEMA_VERSION,
66+
title: "LoopOver agent tools",
67+
description:
68+
"Tool specifications for LoopOver's MCP server, projected for OpenAI- and Anthropic-shaped tool-calling clients. Every tool is executed through the same MCP endpoint via tools/call.",
69+
executor: {
70+
transport: "mcp-streamable-http",
71+
endpoint: options.endpoint,
72+
jsonrpc_method: "tools/call",
73+
},
74+
specs: {
75+
openai: buildOpenAIToolSpecs(tools),
76+
anthropic: buildAnthropicToolSpecs(tools),
77+
},
78+
tools: tools.map((tool) => tool.name),
79+
};
80+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Shared enum vocabularies (#9517).
2+
//
3+
// These existed as hand-copied literals in packages/loopover-mcp/bin/loopover-mcp.ts because that
4+
// package resolves @loopover/engine through the PUBLISHED package, whose export map never surfaced
5+
// them -- so importing the canonical list would have meant widening the engine's public API
6+
// (#6153). The comment on those copies noted the drift it invited "has bitten once already": the
7+
// stdio list carried "suggest"/"propose" for the whole life of #4620 after the server dropped
8+
// them, turning a clear client-side error into a confusing 400 from the API.
9+
//
10+
// This package is the answer that was missing: a zod-only leaf with no dependencies, so every
11+
// surface (Worker, both stdio bins, miner, UI) can import the same values without pulling the
12+
// engine in behind them.
13+
//
14+
// SCOPE NOTE: packages/loopover-engine/src/settings/autonomy.ts still declares its own
15+
// AUTONOMY_LEVELS / AGENT_ACTION_CLASSES, because it is an engine-parity twin of
16+
// src/settings/autonomy.ts and inverting that pair to import from here is #9518's batch work, not
17+
// the keystone's. Until then the values below are pinned against the engine's by a meta-test, so
18+
// the two cannot silently disagree.
19+
20+
/**
21+
* Per-action-class autonomy levels. Mirrors the engine's `AUTONOMY_LEVELS`.
22+
*
23+
* `observe` records what it would have done; `auto_with_approval` stages an action for a human
24+
* decision; `auto` acts directly.
25+
*/
26+
export const AUTONOMY_LEVELS = ["observe", "auto_with_approval", "auto"] as const;
27+
export type AutonomyLevel = (typeof AUTONOMY_LEVELS)[number];
28+
29+
/**
30+
* The action classes an operator may configure autonomy for.
31+
*
32+
* Deliberately NOT the engine's full `AGENT_ACTION_CLASSES` -- it is the operator-settable subset
33+
* the maintain surface exposes, matching `MAINTAIN_AUTONOMY_ACTION_CLASSES` in src/mcp/server.ts.
34+
* Do not "sync" this to the engine's list; the difference is the point.
35+
*/
36+
export const MAINTAIN_ACTION_CLASSES = ["review", "request_changes", "approve", "merge", "close", "label"] as const;
37+
export type MaintainActionClass = (typeof MAINTAIN_ACTION_CLASSES)[number];
38+
39+
/**
40+
* Action classes accepted when proposing an action into the approval queue (#6744).
41+
*
42+
* Derived rather than restated: it is exactly the operator-settable set plus `review_state_label`,
43+
* and expressing that as code means the two can never drift apart the way three independent
44+
* literal lists did.
45+
*/
46+
export const PROPOSE_ACTION_CLASSES = [...MAINTAIN_ACTION_CLASSES, "review_state_label"] as const;
47+
export type ProposeActionClass = (typeof PROPOSE_ACTION_CLASSES)[number];
48+
49+
/** Test frameworks the boundary-test and test-evidence surfaces recognize by name. */
50+
export const TEST_FRAMEWORKS = ["vitest", "jest", "pytest", "go-test", "rspec", "cargo-test"] as const;
51+
export type TestFramework = (typeof TEST_FRAMEWORKS)[number];
52+
53+
/** Lifecycle states of a plan step in the stateless plan-DAG tools. */
54+
export const PLAN_STEP_STATUSES = ["pending", "in_progress", "completed", "skipped", "failed"] as const;
55+
export type PlanStepStatus = (typeof PLAN_STEP_STATUSES)[number];
56+
57+
/** Verdicts the pre-start feasibility surfaces return. */
58+
export const FEASIBILITY_VERDICTS = ["go", "raise", "avoid"] as const;
59+
export type FeasibilityVerdict = (typeof FEASIBILITY_VERDICTS)[number];
60+
61+
/**
62+
* Scope selector for WRITING the self-hosted private config: the deployment-wide default layer, or
63+
* one repository's override layer. Only real files are writable.
64+
*/
65+
export const CONFIG_ADMIN_WRITE_SCOPES = ["global", "repo"] as const;
66+
export type ConfigAdminWriteScope = (typeof CONFIG_ADMIN_WRITE_SCOPES)[number];
67+
68+
/**
69+
* Scope selector for READING it. A superset of the write scopes: `effective` is the merged view
70+
* (shared base + global default + per-repo override) that no single file corresponds to, which is
71+
* exactly why it can be read but never written.
72+
*/
73+
export const CONFIG_ADMIN_READ_SCOPES = ["effective", ...CONFIG_ADMIN_WRITE_SCOPES] as const;
74+
export type ConfigAdminReadScope = (typeof CONFIG_ADMIN_READ_SCOPES)[number];

0 commit comments

Comments
 (0)