Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
7 changes: 5 additions & 2 deletions .rulesync/commands/scaffold-plugin.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
targets:
- '*'
description: 'Generate a new overlay extension plugin under extensions/<name>/. Adds routes, providers, event handlers, job workers, and MCP tools without modifying core modules.'
description: 'Generate a new overlay extension plugin under extensions/<name>/. Adds routes, providers, event handlers, job workers, agent tools, and agent action types without modifying core modules.'
---

Run `pnpm gen plugin $ARGUMENTS` in the repo root. Variants: `pnpm gen adapter <name> <TOKEN> <dependsOn>` for a vendor-adapter swap; `pnpm gen job-worker <name>` for a background-job worker.
Expand All @@ -12,7 +12,10 @@ The generator creates `extensions/<name>/plugin.ts` and registers it in `extensi
- `ctx.routers.add(namespace, (c) => router)` - mount oRPC routes.
- `ctx.events.on(topic, handler)` - subscribe to the typed `EventBus`.
- `ctx.jobs.worker({ queue, schema, handler, onDeadLetter? })` - process jobs off the `JOB_QUEUE` seam (ADR-0014). Enqueue from a service via `enqueue(queue('name'), payload, { idempotencyKey, delayMs, attempts, backoff })`. Handlers must be idempotent (at-least-once).
- `ctx.mcp.tool(definition)` - expose a new MCP tool.
- `ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output)` - register an agent tool, a read a model makes during a run.
- `ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute }))` - register an agent action type, a change a model may only propose. `execute` performs nothing on a replay while its effect still holds, and answers `already_applied`.

Both are served only through the `MCP_KERNEL` token, which checks IAM, validates input, applies the output allow-list and audits every tool call and action execution; a shape it cannot serve fails boot with the fix (ADR-0042). The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it.

Tables go in the plugin's own `schema/index.ts` (Drizzle `pgTable`), then `pnpm regen`.

Expand Down
1 change: 1 addition & 0 deletions .rulesync/rules/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Use pure, composable functions and explicit typed wiring. Match local naming and
| SQL, Drizzle, migration, seed, DB tool | `docs/standards/database.md` |
| function, service method, constructor | `docs/standards/functions.md` |
| module, DI wiring, integration, cross-module dependency | `docs/standards/module-structure.md` |
| agent tool or action type | `docs/standards/module-structure.md` |
| error class or catch | `docs/standards/errors.md` |
| money movement or payment settlement | `docs/standards/money.md` |
| wallet module surface or ledger invariant | `docs/modules/wallet.md` |
Expand Down
2 changes: 1 addition & 1 deletion .rulesync/rules/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ This is real-money regulated gambling. A defect here moves a player's money or b

## Orient before reading files

- `docs/catalog.json` is the generated surface: every module, table, route, event, adapter port, config field. Read it, or call the `oss-dev` MCP tools (`catalog-overview`, `list-modules`, `describe-module`, `list-routes`, `list-adapters`, `schema-get`, `docs-search`) instead of grepping.
- `docs/catalog.json` is the generated surface: every module, table, route, event, adapter port, config field, agent tool and action type. Read it, or call the `oss-dev` MCP tools (`catalog-overview`, `list-modules`, `describe-module`, `list-routes`, `list-adapters`, `schema-get`, `docs-search`) instead of grepping.
- `pnpm setup` boots infra, migrates, and prints a summary. `pnpm dev` runs it. `pnpm verify` is the gate CI runs; it ends with `pnpm check:drift`.

## Load the right owner
Expand Down
5 changes: 4 additions & 1 deletion .rulesync/subagents/plugin-author.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ ctx.provide(TOKEN, factory); // bind/override a typed DI token (last registratio
ctx.routers.add(namespace, (c) => router); // mount oRPC routes
ctx.events.on(topic, handler); // subscribe to the typed EventBus
ctx.jobs.worker({ queue, schema, handler, onDeadLetter }); // process JOB_QUEUE jobs (idempotent - at-least-once)
ctx.mcp.tool(definition); // expose a new MCP tool
ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output); // agent tool: a read a model makes during a run
ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute })); // action type: a change a model may only propose
```

Agent tools and action types are served only through the `MCP_KERNEL` token, which checks the IAM grant of the admin behind the run, validates input, returns only the allow-listed output keys and declared error codes, and audits every tool call and action execution. A shape it cannot serve (eg a tool input that is not a top-level `z.object`, or an unbounded string) fails boot with the fix. `execute` must perform nothing on a replay while its effect still holds, and answer `already_applied`. The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it. Contract types: `packages/core/src/contracts/adapters/mcp.ts`; why: ADR-0042.

No decorators, no controllers - `{ id, dependsOn, register } satisfies Plugin<CoreTokenCatalog>` wired by the functional Container (ADR-0009). DB tables: a `pgTable` in the plugin's own `schema/index.ts`, then `pnpm regen`.

## Swapping a vendor adapter
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This is real-money regulated gambling. A defect here moves a player's money or b

## Orient before reading files

- `docs/catalog.json` is the generated surface: every module, table, route, event, adapter port, config field. Read it, or call the `oss-dev` MCP tools (`catalog-overview`, `list-modules`, `describe-module`, `list-routes`, `list-adapters`, `schema-get`, `docs-search`) instead of grepping.
- `docs/catalog.json` is the generated surface: every module, table, route, event, adapter port, config field, agent tool and action type. Read it, or call the `oss-dev` MCP tools (`catalog-overview`, `list-modules`, `describe-module`, `list-routes`, `list-adapters`, `schema-get`, `docs-search`) instead of grepping.
- `pnpm setup` boots infra, migrates, and prints a summary. `pnpm dev` runs it. `pnpm verify` is the gate CI runs; it ends with `pnpm check:drift`.

## Load the right owner
Expand Down Expand Up @@ -91,6 +91,7 @@ Use pure, composable functions and explicit typed wiring. Match local naming and
| SQL, Drizzle, migration, seed, DB tool | `docs/standards/database.md` |
| function, service method, constructor | `docs/standards/functions.md` |
| module, DI wiring, integration, cross-module dependency | `docs/standards/module-structure.md` |
| agent tool or action type | `docs/standards/module-structure.md` |
| error class or catch | `docs/standards/errors.md` |
| money movement or payment settlement | `docs/standards/money.md` |
| wallet module surface or ledger invariant | `docs/modules/wallet.md` |
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ export default {
ctx.provide(MY_ADAPTER, () => new MyAdapter()); // bind a vendor seam
ctx.routers.add('myFeature', (c) => createMyRouter(c)); // oRPC router
ctx.events.on('wallet.deposit.completed', handler);
ctx.mcp.tool({ name: 'my-tool', description: '...', handler });
ctx.mcp.tool(myTool, (c) => async (input, run) => output); // agent tool (defineMcpTool)
ctx.actions.register(myAction, (c) => ({ precondition, execute })); // agent action type
},
} as const satisfies Plugin<CoreTokenCatalog>;
```
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0002-defineplugin-and-overlay-pattern.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
**Date**: 2026-05-18
**Status**: Accepted

> **Update (2026-09-25)**: `ctx.mcp.tool` now takes a contract declared with `defineMcpTool` and a factory, and a second registry, `ctx.actions.register`, takes agent action types declared with `defineActionType`. Both are served only through the `MCP_KERNEL` token, which `createApp` binds after every plugin has registered. The one-argument `ctx.mcp.tool(definition)` form still registers, but the kernel does not serve it. See [ADR-0042](./0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md).

## Context

The platform's primary value proposition is extensibility: operators clone the repo and add their own games, payment providers, custom pages, and business rules without forking core. We need a mechanism that:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# ADR-0042: MCP kernel - two registries behind one enforcing wrapper

**Date**: 2026-09-25
**Status**: Accepted

## Context

An agent hub (a premium package outside this repo) runs models that read platform data and
propose changes for an admin to approve. An MCP HTTP transport will let an admin's own MCP
client call the same reads. Both need to know what the modules offer without importing a
module, and both sit on a real-money path: whatever a model reads or triggers has to pass the
same authorization, audit and compliance rules as a Backoffice route.

A spike ran the candidate runtimes against real models and a stub harness. Four findings shape
the kernel, because no runtime fixes them:

- A tool input whose top level is a discriminated union is sent to the model as `oneOf` without
`type: "object"`. Four different models then sent money amounts as strings and did not
recover after validation feedback. With a top-level object every call was valid first time.
- Tool output and thrown errors reach the model verbatim in every runtime tested. A `pg` error
carrying the SQL text of the failing query went straight to the model. A framework's output
schema is not redaction: on a validation failure it forwards the raw output.
- A crash in the middle of an action followed by a framework restart executed the money step
twice.
- A model proposed a "loss-mitigation" bonus to a player who had lost most of their deposits.
Amount caps do not catch that; a domain rule evaluated before the proposal exists does.

The hub's runtime and its transport authentication were decided downstream (the agent hub
runtime on a thin AI SDK adapter, downstream ADR 0006; MCP clients authenticating with a
Backoffice-issued token rather than OAuth, downstream ADR 0007). Neither decision may leak into
core: the kernel has to serve any runtime and any transport.

## Decision

Modules register two different things, in their own `plugin.ts`, through two registries:

- `ctx.mcp.tool(contract, factory)` registers a **tool**: a call a model makes during a run.
- `ctx.actions.register(contract, factory)` registers an **action type**: a state change a model
may only propose. An executor runs it later, after approval, under the approver's identity.

The contract is static data declared with `defineMcpTool` / `defineActionType` in the module's
`contract/` dir: id, title, description, schema version, the IAM `resource:action` it needs,
Zod schemas, the error codes it may return, and for a tool its class (`read | propose`) and
output allow-list, for an action type its reversible flag. The factory receives the container
once, when the kernel is built after every provider is bound, and returns the handler or the
`{ precondition, execute }` pair. The catalog generator reads the contract literals, so
`docs/catalog.json` and `@openora/mcp` list both registries without running anything.

Consumers reach both registries only through `MCP_KERNEL`, which `createApp` binds sealed
(`Container.registerSealed`) after every plugin and the composition root have registered, so
neither an overlay, the composition root nor a holder of the container `createApp` returns can
replace it: a later registration throws. The kernel wrapper, not the runtime, enforces:

- **Shape at registration.** Tool inputs and action payloads are a top-level `z.object`;
conditional rules go in `.superRefine()`. Every number is `z.coerce.number()` with both bounds,
every string is bounded, every array has a maximum, free-form maps are refused, and schemas
must convert to JSON Schema. A player is referenced by its id (`UuidSchema`); core defines no
pseudonym format, because pseudonymising is the hub's job. A money amount is a decimal string,
as everywhere in the platform (`docs/standards/money.md`), so it needs a maximum length like
any string, and an action type that takes one checks its value in `.superRefine()`. A tool's
output schema may not let undeclared keys through at any depth - no loose or catchall object,
record or untyped field - so its parse strips them under every allowed key. A violation fails
boot with the field path and the fix.
- **IAM per call.** The run's actor (an admin, an agent acting for an admin, or an MCP token
owned by an admin) resolves to one admin, whose grants are checked by `AdminGuard.assertUser`
before a handler, a precondition or an executor runs - the same enforcement point as every
admin route, including the denial audit event.
- **Output allow-list.** The handler's output is parsed by its schema and only allow-listed keys
leave the kernel. A schema mismatch returns `output_invalid`, never the raw output.
- **Error codes, never messages.** A handler returns a declared code by throwing `McpToolError`;
any other exception becomes `internal_error`. Validation failures return field paths and
messages without input values.
- **An audit record per call**, with tool id, schema version, actor, correlation id and SHA-256
hashes of the parsed input and the redacted output. If the record cannot be written the call
fails with `audit_unavailable` instead of returning data. An action execution retries the
write first, because by then the executor's effect is committed. Hashing is total: anything
nested deeper than 64 levels hashes as a fixed marker, and a tool call that fails outside its
handler still writes its record, as `internal_error`.
- **Preconditions before proposals.** Each action type declares a precondition the hub must pass
before a proposal is created. It runs under the action type's IAM check and writes no audit
record. It is not re-run at execution, where the executor guards its own state under its own
locks.
- **Replay-safe execution.** An executor receives `(payload, proposalId, actor)`. A replay while
its effect still holds performs nothing and answers `already_applied`, so a retry after a crash
is safe. Core stores no proposals, so executors key on the resulting state: after someone else
changes that state (an admin removes the tag, resolves the held withdrawal), a replay may apply
again or answer a declared refusal. The store that keeps proposals must not re-execute one it
recorded as executed. An executor that moves money needs the durable idempotency guard
`docs/standards/money.md` requires of every money mutation, keyed on the proposal id under a
database constraint, the way `add_note` keys its note.

`RunContext` (run id, actor, optional player pseudonym, catalog version, correlation id) is one
shared type, so the hub and the transport cannot disagree on it. `PlatformConfig.agents` holds
default run limits, retention and the model allow-list. A model id must be
`<gateway>/<vendor>/<model>` with the gateway in `agents.modelGateways`, which defaults to
`openrouter`: a bare id can be routed to a different gateway by the SDK, and core names no
gateway an operator cannot replace. The trigger catalog is derived from the domain event catalog, not registered separately.

Core returns data as the module has it. Pseudonymising players, stripping personal data and
trimming logs before anything reaches a model is the hub's job; a tool declares which output
keys carry personal data (`redact.personal`) so the hub can do that without knowing the module.
Tool ids may be dotted (`player.summary`); the kernel also publishes a `modelName` with dots
replaced, because the major model APIs reject dots in function names.

Rejected alternatives:

- **One registry for reads and writes.** A write needs approval, an idempotent executor and a
reversible flag; a read needs none of them. One registry would let a model call a write as if
it were a read.
- **Enforcement in the runtime.** Every runtime tested forwarded output and errors verbatim, so
each runtime and each transport would re-implement the same rules, and one would get them
wrong.
- **A rebindable kernel token.** An overlay could swap the wrapper and bypass IAM and audit.
`createApp` binds `MCP_KERNEL` sealed after every plugin, so a later registration throws, and
the kernel factory is exported only from `@openora/core/testing`.
- **Pseudonymisation in core.** It needs a mapping store and belongs at the model boundary, which
only the hub knows.

## Consequences

**Positive:**

- The hub and the transport share one enforcement path, and a module owns its agent surface
exactly as it owns its routes.
- A tool that leaks a field, returns an unbounded input to a model, or skips IAM fails at boot or
in the kernel's own tests, not in production.
- Every model-facing call leaves an audit trail that proves what was read without storing it.

**Negative / trade-offs:**

- Every tool call writes an audit row. That is the price of auditing reads, and it grows with
agent traffic.
- The input rules constrain authors: a free-text field needs a `max`, a number needs coercion
and bounds, and a union must be flattened.
- Without a proposals table in core, replay safety is each executor's responsibility, keyed on
the state it writes. `add_note` keeps the proposal id on the note it writes, under a partial
unique index, so the note table carries a column of agent-proposal state: a replay of that
proposal adds nothing, another proposal with the same text adds its own note, and a note an
admin wrote never stands in for one.
- The earlier untyped `ctx.mcp.tool(definition)` form still registers, for compatibility, but
the kernel does not serve it.

**Neutral:**

- A held withdrawal (`on_hold`) is now reachable through the `hold_withdrawal` action type, and
an admin resolves it with the existing approve and reject routes.
2 changes: 1 addition & 1 deletion docs/guides/downstream-consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ forking core. The root `AGENTS.md` links here; this is the detail an agent loads
actually wiring a consumer.

See the generated `docs/catalog.json` (`pnpm gen:catalog`, also served by `@openora/mcp`) for the machine-readable surface (routes, schemas, adapter
tokens, events, config schema) an agent reads instead of grepping `node_modules`.
tokens, events, config schema, agent tools and action types) an agent reads instead of grepping `node_modules`.

## Fastest path: scaffold the repo

Expand Down
3 changes: 2 additions & 1 deletion docs/guides/mcp-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ pnpm --filter @openora/mcp build # rebuild after any source change

Note: in a `create:app` consumer running in dev/link mode you don't need this - the
generated `.mcp.json` already points at the full `oss-dev` server through the sibling
checkout, which is a superset of `@openora/mcp`.
checkout. The exception is the agent surface: `list-agent-tools` is served only by
`@openora/mcp`.

## Usage pattern for agents

Expand Down
Loading
Loading