Skip to content

feat(core): serve mcp tools over http to admin-issued tokens - #270

Merged
zaxovaiko merged 42 commits into
devfrom
feat/mcp-transport
Oct 9, 2026
Merged

zaxovaiko merged 42 commits into
devfrom
feat/mcp-transport

Conversation

@mp-blurify

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

Copy link
Copy Markdown
Collaborator

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 feat(core): add the mcp kernel with agent tool and action-type registries #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.

Mateusz Paś added 26 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.
Agents config gains an mcp block (off by default) for the HTTP transport.
IAM gains mcp-access (use MCP, manage own tokens) and mcp-token (oversee
and revoke any token). Contracts gain the token authenticator and
revocation ports, rate-limit keys and audit actions. AdminGuard.assertUser
now refuses a deactivated account and filterGranted narrows a listing
without denial events. The kernel descriptor's output schema is projected
to the keys a tool may return, so a client validating structured output
no longer rejects redacted results.
The iam module now owns the bearer tokens an admin's MCP client sends. A
token is bound to its admin, stored only as a SHA-256 hash, and always
expires within the configured cap; issuing or revoking one writes an
audit record in the same transaction. An admin with MCP access manages
their own tokens, and token oversight lists and revokes anyone's,
including an emergency revoke of all. The module binds the authenticator
and revocation ports the transport and identity rely on.
Deactivating an account, demoting it from admin, or revoking all of a
user's sessions now revokes every active MCP token they hold, so
restoring the account later does not bring the tokens back. Identity
calls the revocation port after its own write.
With agents.mcp enabled, createApp mounts a stateless MCP Streamable
HTTP endpoint ahead of CORS, sessions and oRPC. Each request passes the
host binding, the origin check, bearer authentication, the token's
minute and day limits and the admin's MCP access before the SDK sees it.
Only read-class tools are listed, narrowed to the admin's grants, and
the output keys a tool marks as personal are dropped unless the operator
includes them. Every call still runs through the kernel, which audits it
under the token. The SDK is loaded only when the endpoint is enabled.
…ient

ADR-0043 records why the transport authenticates with admin-bound tokens
in the iam module, drops personal fields by default and rejects the
better-auth plugins and OAuth for now. ADR-0042 gains an update block for
the projected output schema and the active-account check. A new guide
covers enabling the endpoint, granting access and connecting Claude Code.
The kernel's input hashing is now depth-capped and an unexpected failure
still writes the call's audit record, so a crafted tools/call can no
longer skip the audit trail. A call naming a tool the endpoint does not
expose is audited too. The kernel drops personal output keys when the run
asks, so the audited hash covers what the client received, and records
the client's address and agent. The transport requires a bound host while
enabled, limits each client address before any token lookup, answers 503
when the rate limiter is unreachable and logs errors without their query
parameters. The MCP SDK becomes an optional peer dependency.
Automatic revocation now 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 it. Tokens are also revoked on a password
reset or change, and when an IAM change takes away MCP access. Players
never reach the token table from session revocation. Issuing a token
needs an active admin owner, respects a per-admin cap and an hourly
throttle, and a bulk revocation locks the table so a concurrent issue
cannot survive it. Token errors carry a typed reason, the table gains a
consistency check and a status index, and the seeded admin role holds the
MCP permissions.
The registration helper draws a random handle, and the username filter
occasionally refuses one as prohibited language, which failed unrelated
E2E tests at random. A refused generated handle is now replaced with a
fresh one; a handle the test chose is still sent once.
…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.
# Conflicts:
#	packages/core/src/server/mcp/__tests__/kernel.test.ts
#	packages/core/src/server/mcp/kernel.ts
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.
# Conflicts:
#	packages/testing/src/__tests__/mcp-kernel.e2e.test.ts
@zaxovaiko

Copy link
Copy Markdown
Member

/codex all

@blurify-codex blurify-codex Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocker: token issuance can race with grant removal, and a failed revocation leaves MCP tokens usable after a password change.

Reviewed security, compliance, the whole change.

Comment thread packages/core/src/iam/service/mcp-token.service.ts
Comment thread packages/core/src/pam/identity/service/identity.service.ts Outdated

@blurify-codex blurify-codex Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not quite yet - the previously raised token revocation issues remain; this pass found no new security or compliance issues. One scale risk remains: bulk revocation writes each audit row serially while holding the token-table lock.

Reviewed security, compliance, conventions, performance, reliability, rollout, spec, the whole change.

Comment thread packages/core/src/iam/service/mcp-token.service.ts Outdated
Mateusz Paś and others added 12 commits October 8, 2026 08:27
… token

An issue whose guard passed before a grant change committed could wait
behind the change's token-table lock and insert after its revocation
scan, leaving an active token for an admin without MCP access.

Issuance now takes the revocation lock after the owner's row lock and
reads the owner's mcp-access grant again under it, uncached, through the
predicate the grant-loss revocation uses, now shared from
iam/shared/admin-grants.ts. The refusal is owner_ineligible with the
unauthorized_access signal, as for an inactive owner.

The owner's row is locked first because identity's deactivation locks
the user row and then the token table; the reverse order could deadlock.
The rate-limit call stays before the table lock, so a slow limiter never
holds it, and the same requests consume a slot as before.

The lock-waiter test helpers take any handle with a drizzle service and
gain a table-lock waiter, so the end-to-end suite can hold a grant change
open and see the issue wait on it.
…rd change

A password change or reset commits through better-auth before the
identity module revokes the admin's MCP tokens, so a failed revocation
left the old bearer usable until it expired.

The token authenticator now refuses any token issued at or before the
updatedAt of its admin's credential account, which better-auth stamps in
the statement that writes the new hash and leaves alone on a sign-in or a
second factor. The refusal carries a new credentials_changed reason on
the authenticator port. The explicit revocation still runs and records
the reason.

A revocation that fails inside better-auth's reset hook is now logged
instead of rethrown: the hook runs before better-auth revokes the
account's sessions, so a throw kept every session alive, and a stolen
session could mint a fresh token the new check accepts.

The end-to-end suite binds a failing revocation adapter and shows that
after a change and after a reset the old token is refused, the old
session cannot issue another, a token issued afterwards works, and that
the stamp moves on a change and a reset but not on a sign-in or a
two-factor sign-in.
A caller writing one audit row per changed row repeats the chain-head
read, the sequence reservation and the insert for each of them while its
transaction holds the platform-wide audit chain lock.

AuditWritePort gains recordManyInTransaction. Under the same advisory
lock it reads the chain head once, reserves every sequence number in one
statement in ascending order, computes the chain in memory with the row
builder and computeHash the single path uses, and inserts the rows in
statements of at most 1,000, under Postgres' bind-parameter limit. The
rows match what single appends of the same entries in the same order
would store, apart from ids and timestamps. An empty list writes nothing.

The sealed binding, makeAuditWriter, the transport kernel fixture and
the chat expiry test's real-writer wrapper implement the new method.
markRevoked wrote one audit row per revoked token while its transaction
held the token-table lock and the audit chain lock, so a global revoke
over many tokens kept token issuance, other revocations and every audit
writer waiting for the whole loop.

It now hands every revoked token's entry to recordManyInTransaction in
one call. The tests read the revocation entries from that call, and the
end-to-end revoke-all checks one row per revoked token, with its actor
and reason, and a valid chain.
…waited

An issue whose guard passed on a session that a concurrent revoke-all,
forced logout, two-factor reset or email change then ended could wait on
the token-table lock and insert after the revocation's scan, leaving an
active token minted from a dead session.

The issue route passes the caller's session id to create(), which, under
the revocation lock and after the grant recheck, refuses the issue unless
that session still exists, belongs to the owner and has not expired.
Expiry is compared with statement_timestamp(): now() is the issuing
transaction's start and can predate the expiry a revocation that
committed while it waited wrote with its own now(). A missing or ended
session is refused as owner_ineligible with the unauthorized_access
signal.

The session read is a plain select and takes no row lock, so the order
stays the owner's row, then the token-table lock, then lock-free reads,
and the issue never waits on the session rows a revocation holds.
… fails

changePassword rethrew a failed MCP token revocation after better-auth
had committed the new password and revoked the other sessions, so the
admin saw a 500 for a change that had happened. The authenticator
already refuses every token issued before the change, so the change path
now logs the failure and answers success, through the helper the reset
hook uses.
…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.
… the mcp host

ADR-0043 now says that proposing over MCP waits for the hub's review
queue and a proposal-create permission, that both rate limits count per
token, and that every proxy or load balancer able to reach the app, not
only the backoffice one, must set the Host header and refuse
absolute-form targets; the guide says the same. ADR-0042's transport
update no longer claims the input hashing depth cap, which the kernel
change itself now records.
The consumer guide said paths in extensions.config.ts resolve against the
config file. The plugin host import()s each entry from inside
@openora/core, so a relative path or a bare package name resolves from
core's location, and an add-on package installed only in the consumer
fails at boot with a module-not-found error. The guide, the PluginEntry
path doc and the consumer template now say so; the template shows an
add-on package resolved in the app with createRequire. The load-pattern
paragraph names corePlugins() instead of hand-written dist paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# 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
Base automatically changed from feat/mcp-kernel to dev October 9, 2026 17:58
…ule (BF-595)

An admin with no role assignment holds nothing once the permission resolver is bound, so an MCP token owner needs a real role granting mcp-access and the super-admin-only role calls need an assigned caller. Also formats two files oxfmt flagged.
A staff account holds nothing until a role is assigned, so an MCP token owner needs one, and a test about a single narrow role needs an account that holds no other assignment - the seeded moderator now carries the default admin role.
@zaxovaiko
zaxovaiko merged commit 6038c1c into dev Oct 9, 2026
6 checks passed
@zaxovaiko
zaxovaiko deleted the feat/mcp-transport branch October 9, 2026 18:48
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