Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b92ad59
feat(core): add the mcp kernel with agent tool and action-type regist…
Sep 28, 2026
be59c39
Merge branch 'dev'
Oct 5, 2026
b93d5b2
refactor(wallet): keep the auto-approval cap marker as dev has it
Oct 5, 2026
d5d4056
docs(core): accept the mcp kernel adr
Oct 5, 2026
8b38652
test(tag): hold the player row until both concurrent replaces queue
Oct 6, 2026
2094fb1
feat(core): add the mcp transport config, iam resources and token ports
Oct 6, 2026
7a6aa96
feat(iam): issue and revoke admin mcp tokens
Oct 6, 2026
a091273
feat(identity): revoke mcp tokens when an admin loses access
Oct 6, 2026
3aae698
feat(core): serve the mcp kernel to admin clients over streamable http
Oct 6, 2026
6a0813c
docs(core): record the mcp transport decision and how to connect a cl…
Oct 6, 2026
92871cd
fix(core): audit every mcp tool call and harden the transport
Oct 7, 2026
cf64d98
fix(iam): make mcp token revocation atomic and complete
Oct 7, 2026
ce7af74
test(testing): cover the mcp token routes and revocation paths end to…
Oct 7, 2026
bf54305
docs(core): describe the hardened mcp transport and token lifecycle
Oct 7, 2026
278d050
test(testing): replace a generated username the filter refuses
Oct 7, 2026
711c63f
test(testing): drop the narrating comment from the mcp transport e2e
Oct 7, 2026
b39a19a
fix(core): hash any input depth and audit a tool call that fails outs…
Oct 7, 2026
e0c2fa5
fix(core): check the caller's grant before answering an action precon…
Oct 7, 2026
ef11c52
fix(core): seal the mcp kernel binding so nothing can replace it afte…
Oct 7, 2026
d8d6f84
fix(core): refuse a tool output schema that passes undeclared keys th…
Oct 7, 2026
735431e
fix(wallet): clear a hold's reason when an admin approves the held wi…
Oct 7, 2026
3711e02
perf(wallet): index completed activity by wallet, status and time
Oct 7, 2026
70b818a
chore(release): bump core a major for the mcp kernel's breaking types
Oct 7, 2026
782fee6
Merge branch 'feat/mcp-kernel' into feat/mcp-transport
Oct 7, 2026
88502cc
fix(player-note): key an agent's note on its proposal, not on its text
Oct 7, 2026
5a958e7
Merge branch 'feat/mcp-kernel' into feat/mcp-transport
Oct 7, 2026
eccd6eb
fix(iam): recheck mcp access under the revocation lock when issuing a…
Oct 8, 2026
83e9022
fix(iam): refuse an mcp token issued before its admin's latest passwo…
Oct 8, 2026
49ec9d7
perf(audit): append a batch of audit rows with one chain-head read
Oct 8, 2026
4432365
perf(iam): audit a bulk mcp token revocation in one batch
Oct 8, 2026
58e4ad7
fix(iam): refuse an mcp token issued from a session revoked while it …
Oct 8, 2026
fb6c1a2
fix(identity): answer a password change once its mcp token revocation…
Oct 8, 2026
4ddbb5a
docs(core): record the sealed kernel binding and the kernel's newer r…
Oct 8, 2026
ab37de1
Merge branch 'feat/mcp-kernel' into feat/mcp-transport
Oct 8, 2026
aa77799
docs(core): record the deferred propose path and the ingress duty for…
Oct 8, 2026
3973b83
docs(core): require an absolute path for every plugin entry
Oct 9, 2026
dbc0db4
Merge remote-tracking branch 'origin/dev' into wt/mcp-kernel
zaxovaiko Oct 9, 2026
bea309b
Merge branch 'feat/mcp-kernel' into feat/mcp-transport
zaxovaiko Oct 9, 2026
88a42d5
Merge branch 'dev' into feat/mcp-transport
zaxovaiko Oct 9, 2026
bd755a8
test(iam): align the mcp token suites with the resolver-bound grant r…
zaxovaiko Oct 9, 2026
14d3b6b
test(iam): drop the ticket ids from the mcp token test comments
zaxovaiko Oct 9, 2026
3095ec9
test(mcp): give the mcp token e2e owners a real role assignment
zaxovaiko Oct 9, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
**Date**: 2026-09-25
**Status**: Accepted

> **Update (2026-10-06)**: The MCP HTTP transport in core ([ADR-0043](./0043-mcp-http-transport-with-admin-bound-tokens.md)) is a second consumer with no hub in front of it, so it asks the kernel to drop the output keys a tool marks as personal, unless the operator switches that off; a run can now request that drop, and its audit record then hashes exactly what was returned and carries the client's address and agent. Without the request the kernel still returns allow-listed data as the module has it. A tool descriptor's output JSON Schema now describes only the allow-listed keys, because MCP clients validate structured output against the published schema. `AdminGuard.assertUser` now also refuses a deactivated account.

## Context

An agent hub (a premium package outside this repo) runs models that read platform data and
Expand Down
179 changes: 179 additions & 0 deletions docs/adr/0043-mcp-http-transport-with-admin-bound-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# ADR-0043: MCP HTTP transport with admin-bound tokens

**Date**: 2026-10-06
**Status**: Proposed

## Context

[ADR-0042](./0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md) gave the platform
one MCP kernel that every consumer goes through. The agent hub calls it in-process: its runs
build their own run context, so they need no network transport and no credential. An admin's
own MCP client is different. Claude Code, Claude Desktop or an analyst's tool runs on the
admin's machine and reaches the platform over HTTP, so the platform has to authenticate the
client, bind every call to one admin, and keep the endpoint off the player-facing surface.

The credential was decided downstream (downstream ADR 0007): an admin generates a token with a
mandatory lifetime in the Backoffice and pastes it into the client as a bearer header. There is
no authorization server, consent screen or client registry. The spike behind that decision
found the OAuth path workable but costly - a better-auth upgrade, mounting its HTTP handler,
consent and client-management screens, several new tables - while the MCP specification makes
authorization on the HTTP transport optional and every current client accepts a static header.

Building the transport surfaced three problems the kernel alone did not solve:

- The MCP SDK client validates structured tool output against the output schema the server
publishes. The kernel published the schema of a tool's full output while returning only its
allow-listed keys, so a client rejected a correct, redacted result.
- Tool output reaches whatever model sits behind the client. With no hub in front of this
transport, nothing else strips the keys a tool marks as personal.
- `AdminGuard` did not check whether an account is still active. A session ends with the
account; a token does not.

## Decision

**Tokens live in the iam module.** It owns a table of MCP tokens: the admin a token belongs to,
a label, a SHA-256 hash of the token and never the token itself, a short display prefix, the
creation time and a mandatory expiry, the revocation time, actor and reason, the last use and a
call count. The plaintext is returned once, when the token is created. The lifetime defaults to
30 days and is capped by configuration, at 90 days by default and never more than a year. Two
ports in contracts keep the transport and the identity module independent of iam: one
authenticates a bearer and records a call, the other revokes every token a user holds inside the
caller's transaction. Only an active admin who holds the MCP permission can be issued a token,
and only from a live session. The issuing transaction locks the owner's row, so it cannot
interleave with a deactivation, then takes the token-table lock that grant changes and bulk
revocations hold. Under it the transaction reads the owner's MCP permission again, uncached, and
checks by the database clock that the session issuing the token has not ended, so an issue that
waited on a change taking the permission away, or on a revocation of that session, is refused
instead of leaving a token behind. Each admin holds a configured number of active tokens at most
and can issue a configured number per hour.

**Two permissions.** One IAM resource lets an admin use MCP and manage their own tokens; another
lets an admin see every admin's tokens and revoke any of them, including an emergency revoke of
all. They are separate resources because the role matrix stores levels: on one resource, the
level that allows creating a token would also allow revoking everyone else's.

**The transport is part of core and off by default.** `createApp` mounts the MCP Streamable
HTTP endpoint only when `agents.mcp.enabled` is on, and mounts it ahead of CORS, cookie sessions,
caching and the oRPC handler, so none of them touches the request. It runs the official SDK
without session state - a fresh server and transport per request, JSON responses - and imports
the SDK, an optional peer dependency, only when the endpoint is enabled. Each request passes, in
order:

1. The host binding. The config refuses to enable the endpoint without an allowed host, and on
any other host the request falls through to the normal 404, so the endpoint does not exist on
the player domain.
2. The origin check. A request carrying a browser origin is refused unless that origin is
configured; desktop clients send none.
3. A per-client-address limit, before any token is looked up.
4. Bearer authentication. A missing, unknown, expired or revoked token, or one issued before its
admin's latest password change or reset, gets a 401 whose challenge names no OAuth metadata,
so a client does not start an OAuth discovery.
5. Per-token rate limits for a minute and a day on the shared rate limiter, failing closed: an
unreachable limiter answers 503.
6. `AdminGuard.assertUser` for the MCP permission, which now also refuses a deactivated account.

Only read-class tools are listed, narrowed to the ones the admin's grants allow; propose-class
tools and action types are never exposed. Proposing over MCP waits for the agent hub's review
queue, which receives proposals, and for a permission to create one, which the IAM catalog does
not have yet. Every call still goes through the kernel, which checks
the tool's own permission and writes the audit record with the token id and the client's address
and agent. A call naming a tool the endpoint does not expose is refused and audited too, and the
kernel's input hashing is total, so no request can reach a tool and skip its audit record.

**Personal data is dropped by default.** The transport asks the kernel to drop the output keys a
tool marks as personal, so the audited output hash covers exactly what the client received, and
publishes output schemas without them. An operator can switch that off. The kernel's descriptor
now publishes the output schema projected to the allow-listed keys, so what a client validates is
what the kernel returns.

**Revocation is manual and automatic.** The owner or an overseer revokes a token by hand.
Automatic revocation commits in the same transaction as the change that causes it and keys on the
resulting state, so a failure rolls the change back and a retry finishes the job:

- the identity module revokes all of an admin's tokens when it deactivates the account, demotes
it from admin, or revokes all of its sessions (which also covers a forced logout, a two-factor
reset and an email change);
- the iam module revokes the tokens of every admin who loses MCP access when a role loses the permission, is deleted or is unassigned, or when a first role assignment replaces the static admin role without it; grant changes and bulk revocations take the same lock, so they apply one at a time.

A password change or reset cannot share a transaction with the revocation: better-auth commits
the new password itself, and the identity module revokes the admin's tokens after it. The
authenticator therefore also refuses every token issued at or before its admin's latest password
change, read from the update stamp better-auth writes to the credential account with the new
hash, so a revocation that fails leaves the old tokens refused rather than usable. A failed
revocation is therefore logged rather than failing the change or the reset, which has already
committed; on a reset a failure must not abort the hook either, because better-auth revokes the
account's sessions only once its reset hook returns.

Players can never hold tokens, so their session revocations skip the token table. A bulk
revocation locks the token table first and issuance takes the same lock before it inserts, so a
token issued concurrently either commits before the revocation reads the table and is revoked
with the rest, or is checked against what the revocation committed. The per-request check stops
a token at once; the persisted revocation keeps it dead after the account or the access is
restored.

**The kill switch is configuration plus revocation.** Turning the endpoint off removes it at the
next start. Revoking all tokens, or removing the MCP permission from a role, stops use without a
restart.

Rejected alternatives:

- **better-auth plugins.** The API-key plugin deletes expired keys, losing the history the
Backoffice screen lists, and one misconfiguration turns a key into a session for every route.
The MCP and OAuth-provider plugins require OAuth with login and consent. The agent-auth plugin
is experimental, signs a fresh short-lived JWT per request, and reaches Claude Code only
through a local proxy that exposes its own protocol tools instead of the platform's.
- **Stateful MCP sessions.** A read-only tool catalog needs no server state, and stateless
requests scale across replicas without sticky routing.
- **Serving MCP through the oRPC handler.** MCP is its own JSON-RPC protocol with its own content
negotiation, and that path would add credentialed CORS, cookie sessions and caching the
endpoint must not have.
- **Describing tools with the SDK's high-level registration.** It rebuilds tool schemas from zod;
the kernel already publishes validated JSON Schemas, and the low-level handlers keep the kernel
the only enforcement path.
- **OAuth now.** It is the next step when a client cannot send a static header (Claude Desktop
and claude.ai remote connectors), when a third party needs access, when an agent runtime moves
out of process, or when MCP gains writes. It plugs in as a second authenticator behind the same
port, without touching the transport or the audit.

## Consequences

**Positive:**

- An admin can query the platform from an MCP client under the same grants, audit trail and
redaction as the Backoffice and the agent hub.
- The endpoint is off until an operator enables it, absent on hosts it is not bound to, and
every token expires, can be revoked, and leaves an audit record per call.
- The active-account check in `assertUser` also stops agent runs acting for a deactivated admin.

**Negative / trade-offs:**

- An operator who enables the endpoint installs the MCP SDK next to core; a deployment that
never enables it installs nothing extra.
- A stolen token reads what its admin can read until it expires or is revoked. The lifetime cap,
the per-token limits, hashed storage and the per-call audit bound that exposure.
- Claude Desktop's remote connectors accept only OAuth, so Desktop needs a local bridge that adds
the header; Claude Code sends it directly.
- The daily limit is a fixed 24-hour window that opens with the first request, not a calendar
day, and every authenticated request counts towards both limits. Both limits are per token, so
an admin who holds several tokens can exceed them in total.
- Issuing a token needs a session with the MCP permission but no fresh second factor. A step-up
needs a cross-module port into identity and a Backoffice flow, and is left to a later change.
- Issuance holds the token-table lock from its permission and session checks to its commit, so
issues run one at a time, wait for a bulk revocation or grant change in flight, and briefly
hold back the call counter of tokens in use. Issuing is rare enough for that serialization.
- On a password reset better-auth deletes the account's sessions only after the token revocation
has committed, so a token issued from one of those sessions in that interval of a few
milliseconds survives the reset until it expires or is revoked by hand.
- A token whose revocation after a password change or reset failed is refused at once but stays
listed as active, and counts towards its admin's active-token cap, until it is revoked by hand
or expires. The password-change check compares two application-clock stamps, so a token issued
within the clock skew between replicas of a password change can fall on either side of it.
- The host binding reads the request's host, so every proxy or load balancer that can reach the
app, not only the backoffice one, has to set the `Host` header itself and reject absolute-form
request targets.

**Neutral:**

- A deactivated admin keeps a live Backoffice session until it expires, because the session-based
`assert` does not check the active flag. That gap predates this decision and is left to a
separate change.
16 changes: 10 additions & 6 deletions docs/guides/downstream-consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,9 @@ on every change), `file:` (snapshot copy on install, no live source).

## Consumer load pattern

Consumer `extensions.config.ts` points at the built plugin files inside `@openora/core`
(`.../packages/core/dist/<domain>/<module>/src/plugin.js`), not source, because tsx in the
consumer's API entry can't reliably resolve the tsconfig. Always build `@openora/core`
before booting the consumer:
Consumer `extensions.config.ts` loads core's modules through `corePlugins()`, which resolves the
built plugin files inside `@openora/core`, not source, because tsx in the consumer's API entry
can't reliably resolve the tsconfig. Always build `@openora/core` before booting the consumer:

```bash
pnpm -F @openora/core build
Expand All @@ -212,8 +211,13 @@ For a watch loop during development:
pnpm -F @openora/core --parallel build --watch
```

Paths in the consumer's `extensions.config.ts` resolve relative to that config file's own
directory.
Every path in the consumer's `extensions.config.ts` must be absolute. The plugin host imports each
entry from inside `@openora/core`, so a relative path or a bare package name resolves from core's
location, not from the config file. A bare package name works only when core itself can resolve
that package. An add-on package installed in the consumer (a premium or third-party module
published on its own scope) therefore fails with a module-not-found error at boot. Resolve such a
package in the consumer and pass the resulting absolute path. The consumer template's
`extensions.config.ts` shows both cases: a local overlay and an add-on package.

## Keeping the agent files in sync

Expand Down
4 changes: 4 additions & 0 deletions docs/guides/mcp-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ The `oss-dev` MCP server gives any AI agent read-only inspection of the platform
schemas, modules, events, and Drizzle tables - plus write tools to scaffold and verify. It
works with any MCP-compatible editor.

This page is about that development server, for coding agents working on this repository. To
query a running platform's data from an admin's MCP client, see
[Connect an MCP client to the platform](./mcp-transport.md).

## Zero-config setup: `pnpm setup:mcp`

The fastest way to wire MCP for Claude Code. Run it right after `pnpm install`:
Expand Down
Loading
Loading