rasuvaeff / yii3-mcp-rbac-bridge
Per-user RBAC for MCP servers: yiisoft/rbac permissions on yii3-mcp tool calls, visibility filtering and session-identity binding
Requires
- php: 8.3 - 8.5
- mcp/sdk: ~0.6.0 || ~0.7.0
- rasuvaeff/yii3-mcp: ^1.1 || ^2.0
- yiisoft/access: ^2.0
- yiisoft/user: ^2.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/test-support: ^3.0
Suggests
- yiisoft/rbac: Bind Yiisoft\Access\AccessCheckerInterface to the RBAC manager (with rbac-php or rbac-db storage)
This package is auto-updated.
Last update: 2026-07-27 15:14:01 UTC
README
Per-user authorization for rasuvaeff/yii3-mcp
servers over the Yii3 auth stack — the application-facing alternative to
OAuth 2.1: RBAC permissions enforced on every tools/call, permission-aware
tools/list filtering, and session-identity binding against session
hijacking.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model. Contributors: see AGENTS.md.
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.3 – 8.5 |
rasuvaeff/yii3-mcp |
^1.1 || ^2.0 |
yiisoft/access |
^2.0 (bind AccessCheckerInterface to your RBAC manager) |
yiisoft/user |
^2.0 (identity of the current request) |
Installation
composer require rasuvaeff/yii3-mcp-rbac-bridge
The model: two auth layers
SharedSecretMiddleware (yii3-mcp) stays — it is machine auth: may this
MCP client talk to this endpoint at all. This bridge adds user auth: what
may the authenticated user behind the call actually do. Both layers run;
removing the shared secret does not follow from adding RBAC.
// config/routes.php — secret first (cheap fail-closed), then identity Route::methods(['POST', 'GET', 'DELETE', 'OPTIONS'], '/mcp') ->middleware(SharedSecretMiddleware::class) ->middleware(Authentication::class) // yiisoft/auth: token -> CurrentUser ->action(McpAction::class),
Usage
1. Declare permissions on tools
use Rasuvaeff\Yii3McpRbacBridge\RequiredPermission; final readonly class OrderTools { #[McpTool(name: 'order.status')] #[RequiredPermission('orders.view')] public function status(string $orderId): string { ... } #[McpTool(name: 'ping')] // no attribute = unrestricted public function ping(): string { ... } }
Restriction is explicit and per-tool: tools without a permission stay open
(behind the shared secret). #[RequiredPermission] on a method without
#[McpTool] fails the build — a permission that would never be enforced is
a bug, not a default. One tool name mapped to two different permissions by
attributes fails the build too (a silent last-one-wins would enforce an
arbitrary one); explicit overrides win by design.
Tool names: what the map keys must be
PermissionMap keys are tool names, and the bridge derives each one exactly as
yii3-mcp registers it — so list and call can never key off different names:
| Tool declaration | Registered name = map key |
|---|---|
#[McpTool(name: 'order.status')] |
order.status — the explicit name wins |
#[McpTool] on public function status() |
status — the method name |
#[McpTool] on public function __invoke() |
the class short name (e.g. RefundTool), not __invoke |
fromToolClasses() computes these keys for you. Only an explicit map (the
$overrides argument, or new PermissionMap([...])) is yours to key correctly —
for an invokable tool that key is the class short name:
new PermissionMap(['RefundTool' => 'orders.refund']); // invokable RefundTool::__invoke
A key that matches no registered tool is inert (the tool stays unrestricted), so keep explicit keys in sync with the tool names above.
2. Wire the bridge
// config/common/di/mcp-rbac.php use Rasuvaeff\Yii3McpRbacBridge\ { CurrentUserIdentitySource, IdentitySourceInterface, PermissionMap, StaticIdentitySource, }; return [ IdentitySourceInterface::class => CurrentUserIdentitySource::class, PermissionMap::class => static fn () => PermissionMap::fromToolClasses( [OrderTools::class], // same list as the `tools` params // ['order.status' => 'orders.admin'], // optional explicit overrides ), ];
Split identity wiring by entry point; stdio has no HTTP CurrentUser:
// config/web/di.php return [IdentitySourceInterface::class => CurrentUserIdentitySource::class]; // config/console/di.php return [ IdentitySourceInterface::class => static fn () => new StaticIdentitySource( getenv('MCP_USER_ID') ?: null, ), ];
// config/params.php 'rasuvaeff/yii3-mcp' => [ 'tools' => [OrderTools::class], 'interceptors' => [ SessionIdentityInterceptor::class, // outermost: binding before anything trusts the session RbacToolCallInterceptor::class, ], 'tool_visibility' => RbacToolVisibility::class, ],
AccessCheckerInterface comes from your RBAC setup (yiisoft/rbac manager
with rbac-php/rbac-db storage — see suggest).
What each piece does
| Class | Role |
|---|---|
RbacToolCallInterceptor |
rejects tools/call without the mapped permission (regular MCP tool error, fail-closed for guests) |
RbacToolVisibility |
hides the same tools from tools/list — list and call can never disagree (one PermissionMap) |
SessionIdentityInterceptor |
binds the MCP session to its first identity; a leaked Mcp-Session-Id presented with another user's token is rejected |
PermissionMap |
tool name → permission: #[RequiredPermission] scan + explicit overrides |
CurrentUserIdentitySource |
identity id from yiisoft/user CurrentUser (null = guest); implement IdentitySourceInterface for stdio/custom setups |
StaticIdentitySource |
fixed config/env identity for console/stdio; null means guest |
Security notes
- Two session bindings, two layers. yii3-mcp 2.0 binds every session to
the MCP client that created it (immutable owner stamped at
initialize); this bridge'sSessionIdentityInterceptorbinds the session to the application user on the firsttools/call(the yii3-mcp interceptor chain does not seeinitialize). The layers complement each other: betweeninitializeand the first call the session still carries no user identity — nothing is authorized in that window, so nothing is exposed — but on core 2.0 a foreign MCP client can no longer slip into that window with a leakedMcp-Session-Id; only a race inside the same client remains. On core 1.x the client-owner layer does not exist and the first-call user binding is the only session protection. - Session-ownership rejections bypass this bridge. yii3-mcp 2.0 rejects a
foreign or ownerless session (the SDK-shaped 404 from
McpAction,SessionOwnershipExceptionfromInterceptingReferenceHandler) BEFORE the interceptor chain runs — such a rejection never reaches this bridge's RBAC interceptor or visibility filter. Hijack attempts stopped by the core are therefore visible only in the application/web-server logs, not in anything this bridge observes. - Guests are first-class: a guest binds the session as a guest and is denied
on every permission-mapped tool (
AccessCheckerInterfacereceivesnull). The internal guest marker cannot be forged by a literal"guest"user id. - Permission revocation applies on the next call (fail-closed). Live
notifications/tools/list_changedon revocation is not part of this version — the SDK exposes it only with an event dispatcher, which yii3-mcp's factory does not yet carry. - For stdio (
mcp:serve) there is no HTTP request: bindIdentitySourceInterfacetoStaticIdentitySource, or leave the shared secret as the only (machine) identity. - Guest plus
RbacToolVisibilitycan legally produce an emptytools/list. This is an authorization result, not a coremcp:listfailure; verify the console identity binding before treating zero visible tools as a registry bug.
Examples
See examples/ — runs offline.
| Script | Shows | Needs server? |
|---|---|---|
rbac.php |
Filtered listing, allowed/denied calls, session binding | no |
Dependency analysers
This leaf package is selected by the root application through config-plugin and may legitimately have no class reference in an autoloaded source directory. Keep the direct dependency: the application, not a core package, selects the backend or bridge. Scope the Composer Dependency Analyser exception to this package:
use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; return (new Configuration())->ignoreErrorsOnPackage( 'rasuvaeff/yii3-mcp-rbac-bridge', [ErrorType::UNUSED_DEPENDENCY], );
composer-require-checker detects used but undeclared symbols, not unused
packages, so this config-only dependency needs no require-checker suppression.
Development
No PHP/Composer on the host — run in Docker via the composer:2 image:
docker run --rm -v "$PWD":/app -w /app composer:2 composer build
Or with Make: make build, make cs-fix, make psalm, make test.
License
BSD-3-Clause. See LICENSE.md.