Skip to content

feat(core): add the mcp kernel with agent tool and action-type registries - #264

Merged
zaxovaiko merged 17 commits into
devfrom
feat/mcp-kernel
Oct 9, 2026
Merged

zaxovaiko merged 17 commits into
devfrom
feat/mcp-kernel

Conversation

@mp-blurify

@mp-blurify mp-blurify commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds the MCP kernel to @openora/core: modules register agent read tools (ctx.mcp.tool) and proposable action types (ctx.actions.register) in their own plugin.ts, and consumers reach both
only through MCP_KERNEL. First registrations: tools player.summary, wallet.activity, kyc.status, ggr.summary; action types add_tag, add_note, hold_withdrawal,
send_to_manual_review, request_enhanced_kyc.

Why

An agent runtime and an MCP transport need to call module capabilities without importing modules, under the same IAM and audit rules as the Backoffice. A spike on real models showed what no
runtime enforces: a top-level oneOf made models send amounts as strings, tool output and thrown errors (including SQL text) reached the model verbatim, a restart executed an action twice, and
a model proposed a bonus to a player with harm signals.

So the kernel enforces it:

  • it rejects unsafe input schemas at boot;
  • it checks IAM per call through AdminGuard.assertUser;
  • it returns only allow-listed output and maps exceptions to declared codes;
  • it writes one audit record per call, with hashes rather than data;
  • it requires a precondition hook before a proposal and replay-safe executors.

Also included:

  • RunContext;
  • PlatformConfig.agents: limits, retention, and a model allow-list of <gateway>/<vendor>/<model>, with the gateway list defaulting to openrouter;
  • a trigger catalog derived from domain events;
  • the agent, agent-proposal and agent-config IAM resources;
  • a withdrawal hold (on_hold);
  • the agent surface in the catalog and @openora/mcp (list-agent-tools);
  • Container.registerSealed, which binds MCP_KERNEL so nothing can replace it after boot.

See ADR-0042.

Alternatives considered

  • One registry for reads and writes: a write needs approval, an idempotent executor and a reversible flag; a read needs none.
  • Enforcement in the runtime: every runtime tested forwarded output and errors verbatim, so each consumer would re-implement the rules.
  • A rebindable kernel token: an overlay could bypass IAM and audit, so createApp binds it sealed after every plugin, and a later registration throws.
  • Pseudonymisation in core: it needs a mapping store and belongs at the model boundary. Tools declare personal keys (redact.personal) instead.

Risks

  • Breaking (types):
    • A hand-written ModuleRegistry needs actions, mcp.getTools and the two-argument mcp.tool.
    • A TypeScript PlatformConfig literal needs agents; config files parse unchanged.
    • The one-argument ctx.mcp.tool still registers, but the kernel does not serve it.
  • Behaviour: admin approve and reject now accept an on_hold withdrawal, and an on_hold withdrawal blocks deleting its wallet asset. Only hold_withdrawal sets on_hold. Approving a held withdrawal clears the hold's reason; the hold's audit record keeps it.
  • Registration: a tool output schema that lets undeclared keys through at any depth (a loose or catchall object, a record, an untyped field) fails boot, and checkPrecondition checks the caller's grant like an execution, so a hub calls it with an actor that holds the action type's permission.
  • Migrations: wallet 0028 adds an index on wallet_transaction (wallet_id, status, created_at) with a plain CREATE INDEX, which blocks ledger writes while it builds, so apply it in a quiet window on a large ledger; player-note 0001 adds a nullable proposal_id under a partial unique index, which is instant.
  • Roles: only Super Admin and the static admin role get the new IAM resources. Grant them to other roles before gating a screen on them.
  • Replays: executors are replay-safe on the state they write, because core stores no proposals; only add_note keeps the proposal id, on its note under a partial unique index, so it adds one note per approved proposal. The hub must not re-execute a proposal it recorded as executed.
  • Audit: every kernel call writes an audit row, so volume grows with agent traffic. Input hashes are deterministic, so a hash of a low-entropy input such as a player id is pseudonymous, not
    anonymous.
  • Custom identity reader: an operator's own IDENTITY_READER without the new optional getUserIdByPlayerId gets a boot warning, and four registrations answer internal_error.

Mateusz Paś added 5 commits September 28, 2026 07:47
…ries

Modules register agent tools with ctx.mcp.tool(defineMcpTool(...), factory)
and action types with ctx.actions.register(defineActionType(...), factory).
Consumers reach both only through MCP_KERNEL, which validates input shape at
registration, checks IAM per call through AdminGuard.assertUser, returns only
allow-listed output, maps exceptions to declared codes and audits every call.

- RunContext, PlatformConfig.agents, trigger catalog, agent IAM resources
- first tools: player.summary, wallet.activity, kyc.status, ggr.summary
- first action types: add_tag, add_note, hold_withdrawal,
  send_to_manual_review, request_enhanced_kyc
- withdrawals can be held; admins approve or reject a held withdrawal
- catalog and @openora/mcp list the agent surface; ADR-0041
Conflicts resolved by keeping both sides: the new promo audit actions next
to the mcp ones, the moved KYC enum schemas next to PositiveMoneyAmountSchema,
the impact tool next to list-agent-tools, and dev's kycPolicy and grantLedger
wallet dependencies inside the shared WalletService factory.

Adapted to dev:
- wallet.withdrawal.held is filed under the player with after.transactionId,
  like the other withdrawal lifecycle rows since the player-scoped audit change
- the mcp kernel ADR is renumbered to 0042; dev took 0041
The agent hub adds behaviour and leaves existing flows alone, so the daily
cap reads the auto-approved marker by the reason alone again. A held
withdrawal whose reason reads "auto-approved" counts towards the cap, which
fails closed.
A replace loses the race only when its first read precedes the winner's
commit. The test left that to timing, so a slow runner started the second
replace after the first committed and both succeeded. A third transaction
now holds the player row until both replaces wait on it.
@mp-blurify mp-blurify changed the title Feat/mcp kernel feat(core): add the mcp kernel with agent tool and action-type registries Oct 6, 2026
@mp-blurify
mp-blurify marked this pull request as ready for review October 6, 2026 06:51

@zaxovaiko zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite yet - the precondition IAM gap, the rebindable MCP_KERNEL and the shallow output allow-list. Minor:

  • recordExecutionAudit returns audit_unavailable after the effect is already committed, so the caller sees a failure for an applied action.
  • hold_withdrawal writes the proposed reason into reviewReason, so approving a held row keeps the hold text, and a reason of auto-approved counts toward the cap.
  • The changeset says breaking types but bumps minor.
  • No retention job for the audit row every kernel call writes. But that's for later.

Comment thread packages/core/src/server/mcp/kernel.ts
Comment thread packages/core/src/server/runtime/create-app.ts Outdated
Comment thread packages/core/src/server/mcp/contract-validation.ts
Comment thread packages/core/src/pam/player-note/service/player-note.service.ts Outdated
Comment thread packages/core/src/server/mcp/kernel.ts
Comment thread packages/core/src/wallet/service/wallet.service.ts
Mateusz Paś added 8 commits October 7, 2026 14:33
…ide its handler

The canonical JSON behind every input and output hash recursed without a
limit, so an input nested a few hundred thousand levels deep threw a
RangeError while the kernel hashed it, outside any catch, and the call
rejected without its audit record. Nesting past 64 levels now serializes
as a fixed marker, and a tool call that throws anywhere outside its
handler answers internal_error and still writes its one mcp.tool.failed
record.

The change matches the one already on the stacked transport branch, so
the two merge without rework.
…dition

checkPrecondition answered any well-formed run actor without the IAM
check a tool call and an execution make, so a caller without the action
type's grant could learn from add_tag whether a player is self-excluded
or on a break, from hold_withdrawal whether a withdrawal exists, and
from request_enhanced_kyc where a player's verification stands. The
precondition now resolves the actor's admin and checks the action
type's grant through AdminGuard first, answering forbidden, or
internal_error when the check itself fails, before the payload is parsed
or the precondition runs. AdminGuard records the denial as it does for
every route; the kernel still writes no record of its own for a
precondition.
…r boot

createApp binds MCP_KERNEL after every plugin and configure(), but it
returns the container, and a later register() of the token dropped the
cached kernel and served the replacement, without the IAM check or the
audit record the kernel enforces. The container gains registerSealed(),
which binds a token for good: every later registration of it throws.
createApp binds the kernel that way, so a binding made in configure()
is still overridden and a rebind after boot is refused.
…rough

The output allow-list picks top-level keys only, and registration checked
the output schema's top level only, so a tool allowing "player" with a
z.looseObject, a catchall, a record or an untyped field under it booted
and returned whatever the handler put there, personal data included.
Registration now runs the input walker's record, loose, catchall,
untyped and recursive rules over the whole output schema, at every
depth, without the bounds an input needs. The parse then strips every
key a schema does not declare at each level below an allowed key.
…thdrawal

hold_withdrawal writes the proposed reason into the withdrawal's review
reason, and approval rewrote the reviewer and review time but kept the
reason, so an approved withdrawal showed its approver next to the text
it had been held for. An admin approval now clears the reason, as a
rejection replaces it with its own; the hold's reason stays on the
wallet.withdrawal.held audit record. A pending withdrawal carries no
reason, so approving one is unchanged.
wallet.activity sums a wallet's completed deposits, withdrawals, bets
and wins over the last 1 to 365 days, and a model may call it as often
as its run allows. No index leads on (wallet_id, status, created_at), so
the planner read every completed row of those types the wallet ever had
through (wallet_id, type, status) and dropped the ones outside the
window in the heap. The new index bounds the scan by the window.

The generated migration is a plain CREATE INDEX, because the migrator
applies each file inside a transaction. It holds a SHARE lock on
wallet_transaction while it builds, which blocks every ledger write but
no read, so a large deployment applies it in a quiet window.
The changeset lists breaking type changes for core but asked for a minor
bump, so the release notes would file them under minor changes. Core now
bumps major, as a breaking change does elsewhere; @openora/mcp only adds
tools and stays minor. The summary also covers what the review changed:
the sealed MCP_KERNEL binding, the output schema rule, the precondition's
IAM check, the cleared hold reason on approval, and the wallet index the
migration builds while it blocks ledger writes.
add_note recognised a replay by a note with the same player, author and
text, so an approver who approved two proposals saying "reviewed, no
issues" got one note and an already_applied for the second, and a note
the same admin had typed in the Backoffice suppressed a proposal's note
too. The note now keeps the proposal id in a nullable proposal_id column
under a partial unique index, and the executor inserts with ON CONFLICT
DO NOTHING on it: nothing inserted means a replay. The unique index also
serialises two executions of one proposal, so the per-proposal advisory
lock and the content lookup are gone. Backoffice notes keep proposal_id
null and are never matched, and the note routes still do not return it.
ADR-0042's trade-off, the executor contract wording and the changeset
describe the new key.
@mp-blurify

Copy link
Copy Markdown
Collaborator Author

On the minor points:

  • audit_unavailable after a committed execution is on purpose (ADR-0042, audit.md): an execution retries the write three times, then fails rather than return without its record. A replay answers already_applied and writes it, and the executor's own audit row commits with the effect either way. Writing the kernel's record in the executor's transaction would need a tx-aware executor contract, which is bigger than this PR.
  • hold_withdrawal: approving a held withdrawal now clears the hold's reason (735431e); the hold's audit record keeps it. A held reason reading auto-approved still counts toward the daily cap on purpose (b93d5b2): the cap query stays as dev has it, and that case only fails closed.
  • Changeset: core now bumps major (70b818a); @openora/mcp stays minor since it only adds tools. The summary also covers this round's fixes and the index migration's write lock.
  • Retention: agreed, later. audit_log is append-only by trigger, so it needs an archive or partition design rather than a delete job.

@mp-blurify
mp-blurify requested a review from zaxovaiko October 7, 2026 13:49
@mp-blurify
mp-blurify enabled auto-merge October 8, 2026 05:50
Mateusz Paś and others added 4 commits October 8, 2026 12:44
…ules

ADR-0042 still said binding MCP_KERNEL after every plugin keeps it from
being replaced, which the returned container disproved. It now names the
sealed binding, the output-schema rule at every depth, the IAM check
before a precondition, and total input hashing. It also records how a
tool references a player (its id, no pseudonym format in core) and a
money amount (a bounded decimal string, value checked in superRefine),
and that a money-moving executor keys durably on the proposal id.
# Conflicts:
#	packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts
#	packages/core/src/wallet/drizzle/migrations/meta/0028_snapshot.json
#	packages/core/src/wallet/drizzle/migrations/meta/_journal.json
@zaxovaiko
zaxovaiko merged commit 02aa4c4 into dev Oct 9, 2026
6 checks passed
@zaxovaiko
zaxovaiko deleted the feat/mcp-kernel branch October 9, 2026 17:58
zaxovaiko added a commit that referenced this pull request Oct 9, 2026
## Summary

Exposes the in-process MCP kernel from #264 to an admin's own MCP client
(Claude Code and similar) over MCP Streamable HTTP. Every call is
authenticated by an admin-issued bearer token stored only as a hash in
the iam module, checked per tool by `AdminGuard`, and audited. The
endpoint is off by default. This PR is stacked on #264.

## Why

The kernel serves only in-process callers such as the agent hub. Admins
and analysts need to query platform data from their own MCP clients,
under their own grants, with the same redaction and audit as the
Backoffice, and without anything appearing on the player domain. A
static bearer token with a mandatory lifetime replaces an OAuth
authorization server; every current MCP client supports a static header
(ADR-0043).

What ships:

- `agents.mcp` platform config: off by default, a bound host required
while enabled, per-address and per-token rate limits, a per-admin token
cap and hourly issuance throttle, and a personal-field policy (dropped
by default).
- iam: an `mcp_token` table (hash, prefix, mandatory expiry, revocation
reason, last use, call count), routes for an admin's own tokens and for
oversight (list, revoke, revoke all), and two permissions: `mcp-access`
(use MCP, own tokens) and `mcp-token` (oversee and revoke any token).
- Transport: a stateless SDK endpoint mounted ahead of CORS, sessions
and oRPC; only read-class tools, narrowed to the admin's grants; every
call goes through the kernel, which audits it with the token id and the
client's address; refused tool names are audited too.
- Revocation: manual, and automatic on deactivation, demotion,
all-sessions revocation, password reset or change, and loss of MCP
access through IAM. Each automatic revocation commits in the same
transaction as the change that causes it, except a password change or
reset, which better-auth commits first. For those, the authenticator
refuses every token issued before the admin's latest password change, so
a failed revocation write fails closed.
- Issuance: takes the revocation lock and re-reads the admin's MCP
access and the issuing session under it, so a grant change, a forced
logout or a revoke-all that commits while the request waits leaves no
token behind.
- Audit: the audit writer gains a batch append (one chain-head read, one
sequence reservation, the hash chain unchanged), and a bulk revocation
writes its per-token rows with it.
- `AdminGuard.assertUser` refuses a deactivated account, which also
stops hub runs acting for that admin.
- ADR-0043 and `docs/guides/mcp-transport.md`.

Verification: the transport was exercised end to end from Claude Code
against a local platform: tool listing and calls, dropped personal
fields, one audit record per call, refusals (origin, method, host,
token, batch, rate limit, expiry), automatic revocation on deactivation,
and Claude Code's handling of a revoked token (the header is rejected,
no OAuth attempt). The review hardening that followed is covered by the
E2E suite.

## Alternatives considered

- **better-auth API-key plugin:** deletes expired keys, which loses the
history the token list shows, and a misconfigured session hook turns a
key into a session for every route.
- **better-auth MCP and OAuth-provider plugins:** require OAuth with
login and consent. OAuth stays the next step when a client cannot send a
static header, a third party needs access, an agent runtime moves out of
process, or MCP gains writes; it plugs in behind the same authenticator
port.
- **better-auth agent-auth plugin:** experimental, signs a fresh
short-lived JWT per request, and reaches Claude Code only through a
local proxy that exposes its own tools.
- **Stateful MCP sessions, serving MCP through the oRPC handler, the
SDK's high-level tool registration:** rejected for the reasons in
ADR-0043.

## Risks

- **Public API changes:**
  - `createIamRouter` gains a required argument.
  - `IamService` and `McpTokenService` take a deps object.
  - The iam plugin now depends on the audit module.
  - `AdminGuard.assertUser` refuses deactivated accounts.
- Kernel descriptors publish `outputJsonSchema` projected to
`redact.allow`.
  - `RunContext` gains optional `clientMeta` and `dropPersonal`.
  - `RateLimitResult` gains `unavailable`.
- `AuditWritePort` gains a required `recordManyInTransaction`; a
hand-written implementation or test double needs it.
  - `McpTokenAuthentication` gains the `credentials_changed` reason.
- **Enabling the endpoint** requires installing
`@modelcontextprotocol/sdk@1.29.0` (an optional peer dependency) and
listing `agents.mcp.allowedHosts`. Every proxy or load balancer that can
reach the app must set the `Host` header itself and reject absolute-form
request targets.
- **Migrations:** iam `0001` (enum, table, indexes) and `0002` (status
index, consistency CHECK), both additive.
- **Seed:** the predefined admin role gains both MCP permissions; roles
created earlier need them granted in the role matrix.
- **Locking:** IAM grant changes, bulk token revocations and token
issuance take a table lock on `mcp_token` and run one at a time.
- **Residual risks:**
- On a password reset, better-auth deletes the sessions a few
milliseconds after the token revocation commits; a token issued from an
old session in that gap survives.
- After a failed revocation write, the tokens the authenticator refuses
still show as active and count towards the cap until revoked or expired.
- The password-change check compares application clocks, so clock skew
between replicas can put a token issued at the moment of a change on the
wrong side of it.
- **Deferred:** issuing a token does not require a fresh second factor
yet; it needs a new identity port and a Backoffice step.
- **Stacked PR:** the base moves to `dev` after #264 merges, by merging
`dev` into this branch, with no force push.
- **No changeset in this PR**, per the delivery standard; the release PR
should list the API changes above.

---------

Co-authored-by: Mateusz Paś <mateusz.pas@studiosoftware.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Volodymyr Zakhovaiko <zaxovaiko@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants