rasuvaeff / yii3-mcp
MCP server integration for Yii3: PSR-15 Streamable HTTP endpoint, DI tool registry, and stdio transport over the official mcp/sdk
Requires
- php: 8.3 - 8.5
- mcp/sdk: ~0.6.0
- psr/container: ^2.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^2.0 || ^3.0
- symfony/console: ^7.0 || ^8.0
- symfony/yaml: ^7.0 || ^8.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
- guzzlehttp/guzzle: PSR-18 HTTP client for the OpenAPI bridge executor and spec loader
This package is auto-updated.
Last update: 2026-07-12 08:31:04 UTC
README
Model Context Protocol server integration
for Yii3 over the official mcp/sdk
(PHP Foundation + Symfony): expose your application's domain operations as MCP
tools/resources for AI agents (Claude Code, Claude Desktop, …) through a
PSR-15 Streamable HTTP endpoint, with tools resolved through the Yii3 DI
container.
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 |
mcp/sdk |
~0.6.0 (experimental until 1.0 — hence the tilde pin) |
| MCP protocol | 2025-06-18 (via SDK) |
ext-fileinfo |
required by the SDK |
Installation
composer require rasuvaeff/yii3-mcp
Usage
1. Declare a tool
Tools are ordinary Yii3 services. Capability methods are annotated with the SDK's own attributes — this package invents no protocol structures:
use Mcp\Capability\Attribute\McpTool; final readonly class OrderTools { public function __construct(private OrderRepository $orders) {} /** * Returns the current status of an order. */ #[McpTool(name: 'order.status')] public function status(string $orderId): string { return $this->orders->get($orderId)->status->value; } }
Input schemas are generated by the SDK from the method signature and DocBlock.
#[McpResource], #[McpResourceTemplate] and #[McpPrompt] methods work the
same way — all four SDK capability attributes are recognized.
Structured output
Agents parse typed results far more reliably than prose. Declare an
outputSchema on the attribute and return an array — the SDK serves the
schema in tools/list and mirrors the return value into the result's
structuredContent (alongside the human-readable text content):
/** * @return array{status: string, total: int} */ #[McpTool( name: 'order.status', outputSchema: [ 'type' => 'object', 'properties' => [ 'status' => ['type' => 'string'], 'total' => ['type' => 'integer'], ], 'required' => ['status', 'total'], ], )] public function status(string $orderId): array { $order = $this->orders->get($orderId); return ['status' => $order->status->value, 'total' => $order->total]; }
An array (or JSON-serializable object) return produces structuredContent
even without an outputSchema — declaring the schema is what lets the agent
know the shape up front. Testing\SchemaSnapshot covers output schemas the
same way it covers input schemas, so accidental drift fails the build.
To gate a capability class (feature flag, environment check), implement
ConditionalToolInterface — the instance is resolved through the container
at build time and skipped when shouldRegister() returns false:
final readonly class BetaTools implements ConditionalToolInterface { public function __construct(private FeatureFlags $flags) {} public function shouldRegister(): bool { return $this->flags->isEnabled('mcp-beta-tools'); } #[McpTool(name: 'beta.op')] public function betaOp(): string { ... } }
2. Register it
// config/params.php return [ 'rasuvaeff/yii3-mcp' => [ 'server_name' => 'my-app', 'server_version' => '1.0.0', 'tools' => [OrderTools::class], 'endpoint_secret' => getenv('MCP_SECRET'), ], ];
Handlers are registered as [class, method] references — the SDK resolves the
instance through the Yii3 container on call, so constructor dependencies are
injected the normal way.
3. Route the endpoint
// config/routes.php Route::methods(['POST', 'GET', 'DELETE', 'OPTIONS'], '/mcp') ->middleware(SharedSecretMiddleware::class) ->action(McpAction::class),
An MCP client connects with the secret header:
{
"mcpServers": {
"my-app": {
"type": "http",
"url": "https://example.com/mcp",
"headers": { "X-Mcp-Secret": "..." }
}
}
}
stdio for local development
// add McpServeCommand to your console commands ./yii mcp:serve
Claude Code config: claude mcp add my-app -- ./yii mcp:serve.
Introspection: what is actually served
mcp:list prints every registered tool, resource, resource template and
prompt — with argument summaries (name* = required) — without an MCP client.
It goes through the same in-process JSON-RPC path a real client uses, so
attribute tools, OpenAPI-bridged operations and Markdown prompts all show up:
// add McpListCommand to your console commands ./yii mcp:list ./yii mcp:list --json # full definitions as normalized JSON
--json prints the complete capability definitions (input/output schemas
included) in the SchemaSnapshot normalized form — item order and object
keys are stable, so the output diffs cleanly in CI and feeds external
automation.
The command (like McpTester) needs PSR-17 factories
(ServerRequestFactoryInterface, ResponseFactoryInterface,
StreamFactoryInterface) in the container.
Sessions (important for PHP-FPM)
The MCP Streamable HTTP session spans several HTTP requests (initialize
first, then tools/call with the returned Mcp-Session-Id). The SDK's
default in-memory store would lose the session between FPM workers, so this
package defaults to a file-based store (sys_get_temp_dir(), override via
session.dir param). For multi-host setups rebind the interface:
// config/common/di/mcp.php use Mcp\Server\Session\Psr16SessionStore; use Mcp\Server\Session\SessionStoreInterface; return [ SessionStoreInterface::class => static fn (CacheInterface $cache) => new Psr16SessionStore($cache), ];
Prompts from Markdown files
Prompts are content, not code — keep them in a directory and every *.md
file becomes an MCP prompt (edited without a deployment, versioned like any
other file):
'rasuvaeff/yii3-mcp' => [ 'prompts_path' => __DIR__ . '/../resources/prompts', ],
--- name: code-review # defaults to the file name title: Code review assistant description: Reviews a diff with a given focus arguments: - name: diff description: The diff to review required: true - focus # simple form: optional argument --- Review the following diff focusing on {{focus}}: {{diff}}
Declared {{argument}} placeholders are substituted from the request
(missing ones become empty strings); undeclared placeholders are left
intact. Malformed frontmatter, an unreadable file or a duplicate prompt
name fail the server build with Prompts\Exception\InvalidPromptFileException
— never a silently missing prompt.
The file format is intentionally compatible with — and inspired by — vjik/my-prompts-mcp by Sergei Predvoditelev: the same prompt file works in a personal stdio prompt manager and on an application server.
Interceptors: wrap every tools/call
Interceptor\ToolCallInterceptorInterface is the package's public extension
point around tool execution. The chain wraps every registration path —
attribute tools, OpenAPI-bridged operations, configurator-registered
handlers — so tracing, rate limiting or ACL live in one place, without
touching the tools:
use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallContext; use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallInterceptorInterface; final readonly class TracingInterceptor implements ToolCallInterceptorInterface { public function __construct(private LoggerInterface $logger) {} public function intercept(ToolCallContext $context, callable $next): mixed { // $context->toolName, $context->arguments, $context->session, // $context->getClientInfo() — who is calling what with which input $this->logger->info('tools/call', ['tool' => $context->toolName]); return $next(); // skip $next() to short-circuit } }
// config/params.php — resolved through the container, first = outermost 'rasuvaeff/yii3-mcp' => [ 'interceptors' => [TracingInterceptor::class], ],
Throwing Mcp\Exception\ToolCallException from an interceptor rejects the
call with a regular MCP tool-error envelope (the agent sees the reason);
any other exception becomes an opaque internal error.
Masking sensitive arguments
Anything an interceptor sends out of the process — a log line, a trace span,
an audit record — must not carry secrets. Interceptor\ArgumentMasker
replaces the values of sensitive keys (password, secret, token,
api_key, credit_card by default; case-insensitive, at every nesting
level) with ***:
use Rasuvaeff\Yii3Mcp\Interceptor\ArgumentMasker; $masker = new ArgumentMasker(); // or: new ArgumentMasker(['ssn', 'password']) $safe = $masker->mask($context->arguments); // ['user' => ['name' => 'alice', 'password' => '***']] $this->logger->info('tools/call', ['tool' => $context->toolName, 'arguments' => $safe]);
It is one shared helper so every consumer (audit trail, telemetry, your own interceptors) masks with identical semantics instead of drifting apart.
Session budget: stop agent loops
A hard cap on tools/call per MCP session (from initialize until the TTL
expires). An agent stuck in a loop burns the budget and gets an explanatory
tool error instead of hammering the application:
'rasuvaeff/yii3-mcp' => [ 'session' => ['budget' => 50], // 0 = unlimited (default) ],
This is loop protection inside one session, not a client quota: a re-initialize starts a fresh counter. Client quotas belong to an application-level rate limiter. The budget guard is always the outermost interceptor, so it rejects before any other interceptor does work.
Tool visibility
ConditionalToolInterface gates registration globally at build time. To hide
a subset of the registered tools from the endpoint, the typical case is
declarative — tool-name patterns in params, * matching any run of
characters:
'rasuvaeff/yii3-mcp' => [ 'visibility' => [ 'deny' => ['admin.*'], // hide matches 'allow' => [], // non-empty = hide everything it does not match ], ],
Deny wins over allow; both lists empty (the default) means every tool is
visible. When the decision depends on the session (admin vs public
client, tenant plans), implement Visibility\ToolVisibilityInterface
instead — the decision runs per session, against the handshake data:
use Mcp\Schema\Tool; use Mcp\Server\Session\SessionInterface; use Rasuvaeff\Yii3Mcp\Visibility\ToolVisibilityInterface; final readonly class PlanBasedVisibility implements ToolVisibilityInterface { public function isVisible(Tool $tool, ?SessionInterface $session): bool { // decide from $session->get('client_info'), tenant data, … return !str_starts_with($tool->name, 'admin.') || $this->isAdmin($session); } }
'rasuvaeff/yii3-mcp' => [ 'tool_visibility' => PlanBasedVisibility::class, // DI-resolved ],
The two kinds are mutually exclusive — configuring both is a build-time
error. Either way the filter applies in two places, consistently:
tools/list omits invisible tools, and tools/call fail-closed rejects
them — a client that guesses a hidden name still gets a tool error, and the
call never reaches the interceptor chain or the tool. This is an early
filter, not a replacement for application-level ACL.
Server configurators
Beyond the built-in Markdown-prompts and OpenAPI bridge, register your own
ServerConfiguratorInterface implementations (or a companion package's) to
contribute capabilities to the SDK server builder before it is built. The
core resolves the FQCNs through the container (after its own configurators)
and applies them in order:
'rasuvaeff/yii3-mcp' => [ 'configurators' => [MyServerConfigurator::class], // DI-resolved ],
final readonly class MyServerConfigurator implements ServerConfiguratorInterface { #[\Override] public function configure(Builder $builder): void { // $builder->addTool(...) / addResource(...) / addPrompt(...) … } }
Multi-tenant serving (rasuvaeff/yii3-tenancy)
With rasuvaeff/yii3-tenancy the
MCP endpoint serves every tenant from one route — tools are ordinary Yii3
services, so a constructor-injected CurrentTenant scopes their data access
as anywhere else in the application. The recipe is middleware order: resolve
the tenant before the MCP action runs:
// config/routes.php — secret first (fail-closed), then tenant, then MCP Route::methods(['POST', 'GET', 'DELETE', 'OPTIONS'], '/mcp') ->middleware(SharedSecretMiddleware::class) ->middleware(TenantResolutionMiddleware::class) // e.g. HeaderTenantResolver('X-Tenant-Id') ->action(McpAction::class),
// an MCP client carries both headers "headers": { "X-Mcp-Secret": "...", "X-Tenant-Id": "acme" }
Isolate sessions per tenant so a session id can never cross tenants — bind the session store to a per-tenant directory:
// config/common/di/mcp.php SessionStoreInterface::class => static fn (CurrentTenant $tenant) => new FileSessionStore( directory: sys_get_temp_dir() . '/mcp-sessions/' . $tenant->get()->getId(), ),
Per-tenant tool sets come free with tool_visibility (see above): decide
from the resolved tenant instead of client_info.
Honest scope: the shared secret stays global — anyone holding it may present any
X-Tenant-Id. That fits the trusted-only endpoint model (the secret already grants application access); tenant isolation here protects against accidents, not against a malicious secret holder. Per-tenant secrets (a secret resolver instead of the single-value middleware) are a planned extension — ask if you need it.
OpenAPI bridge: expose an existing REST API
If the application already maintains an OpenAPI document, allow-listed
operations can be bridged as MCP tools with zero duplication — names come
from operationId, descriptions from summary/description, input schemas
from parameters/request body. Calls are executed as real HTTP requests
against the API, passing its full middleware stack (validation, rate
limiting, auth) — unlike hand-written tools that invoke handlers directly.
// config/params.php 'rasuvaeff/yii3-mcp' => [ 'openapi' => [ // file path OR http(s) URL — e.g. the app's own spec endpoint, // always current; fetched with the same `headers` (auth included) 'spec_path' => 'https://api.example.com/rest/json-url', 'base_url' => 'https://api.example.com', 'operations' => ['getBlogTags', 'getPage'], // allow-list, empty = nothing 'headers' => ['Authorization' => 'Bearer ' . getenv('MCP_API_TOKEN')], 'safe_methods_only' => true, // read-only bridge: non-GET in the list => build error ], ],
The DI wiring requires PSR-18/PSR-17 services (ClientInterface,
RequestFactoryInterface, StreamFactoryInterface) in the container.
Request bodies are passed as a single body tool argument; an operationId
missing from the document throws UnknownOperationException at server build
time, a non-GET operation under safe_methods_only throws
UnsafeOperationException (fail-fast). Local #/components/... $refs are
resolved inline (up to 32 chained hops); external (URL/file) $refs pass
through unresolved. Tool arguments are keyed by name, so an operation with a
path and a query parameter sharing one name — or a parameter named body
alongside a request body — cannot be bridged and throws
InvalidSpecException at build time.
For custom scenarios use the pieces directly: SpecIndex +
HttpOperationExecutor + OpenApiServerConfigurator (a
ServerConfiguratorInterface — the generic extension point accepted by
McpServerFactory::create(tools, configurators)).
Components
| Class | Role |
|---|---|
McpServerFactory |
list of tool FQCNs → configured SDK Server (reads #[McpTool]/#[McpResource] attributes, wires the DI container and session store) |
McpAction |
PSR-15 handler running the SDK StreamableHttpTransport for the current request |
SharedSecretMiddleware |
fail-closed hash_equals() guard; an empty secret rejects every request with an explanatory 503 — an unprotected endpoint must be an explicit decision |
McpServeCommand |
mcp:serve — stdio transport for local MCP clients |
McpListCommand |
mcp:list — console introspection of every served tool/resource/prompt with argument summaries; --json for normalized machine-readable definitions |
Exception\InvalidToolClassException |
configured tool class missing or without capability attributes (fail-fast) |
ConditionalToolInterface |
capability class opts out of registration at build time (shouldRegister()) |
Testing\McpTester |
in-process test client: initialize/listTools/callTool/readResource |
Testing\SchemaSnapshot |
contract canary: committed JSON snapshot of all served capability schemas; drift fails the build |
Prompts\MarkdownPromptsConfigurator |
a directory of *.md files as MCP prompts (vjik/my-prompts-mcp-compatible format) |
ServerConfiguratorInterface |
generic extension point for contributing capabilities to the builder; register your own via the configurators params list |
Interceptor\ToolCallInterceptorInterface |
wraps every tools/call (tracing, ACL, rate limits); configured via interceptors params |
Interceptor\ToolCallContext |
what an interceptor sees: tool name, arguments, session, getClientInfo() |
Interceptor\SessionBudgetInterceptor |
per-session tools/call cap (session.budget param) — anti-loop guard |
Interceptor\InterceptingReferenceHandler |
the decorator wiring the chain into the SDK (used by McpServerFactory) |
Interceptor\ArgumentMasker |
shared sensitive-argument masking (password/token/… at every nesting level) for anything leaving the process |
Visibility\ToolVisibilityInterface |
per-session tool filter (tool_visibility param): tools/list omits, tools/call fail-closed rejects |
Visibility\DeclarativeToolVisibility |
deny/allow tool-name patterns with * wildcards (visibility param) — the no-code visibility case |
OpenApi\OpenApiServerConfigurator |
bridges allow-listed OpenAPI operations as tools (HTTP execution) |
OpenApi\Exception\* |
InvalidSpecException, UnknownOperationException, UnsafeOperationException, OperationFailedException |
Security
- The endpoint is trusted-only. MCP tools execute application code;
treat the endpoint like an admin API. Ship it behind
SharedSecretMiddleware(an empty secret rejects every request with an explanatory 503) or an explicit network ACL. - Tool errors are returned as MCP error envelopes by the SDK — internals are not leaked as 500 traces.
- The core registers no tools by default; every exposed operation is an
explicit entry in
params['rasuvaeff/yii3-mcp']['tools']. - OAuth from the MCP authorization spec is deliberately out of scope until it stabilizes; shared-secret/ACL only.
Examples
See examples/ — every script runs offline.
| Script | Shows | Needs server? |
|---|---|---|
http-handshake.php |
Full in-process MCP cycle: initialize + tools/call | no |
stdio-serve.php |
The stdio transport mcp:serve runs, over in-memory streams |
no |
conditional.php |
ConditionalToolInterface registration gating |
no |
prompts.php |
Markdown files served as MCP prompts | no |
openapi-bridge.php |
OpenAPI operations bridged as MCP tools | no |
interceptors.php |
Tracing interceptor (with ArgumentMasker) + session budget guard |
no |
visibility.php |
Tool visibility: per-session interface + declarative deny patterns, fail-closed call | no |
structured-output.php |
outputSchema + structuredContent on a tool |
no |
Testing your tools
Testing\McpTester drives the real Streamable HTTP code path in-process —
no HTTP server, no stdio process:
$tester = new McpTester($server, $psr17, $psr17, $psr17); $result = $tester->callTool('order.status', ['orderId' => '42']); $this->assertSame('paid', $result['content'][0]['text']); $tester->listTools(); // tool definitions $tester->readResource('app://x'); // resource contents $tester->request('prompts/list'); // any raw JSON-RPC method
Schema snapshot: catch accidental contract drift
A changed method signature silently changes the generated inputSchema — and
breaks agents mid-flight. Testing\SchemaSnapshot snapshots every served
capability definition into a committed JSON file; drift fails the test until
the snapshot is regenerated deliberately (delete the file and re-run):
SchemaSnapshot::assert($tester, __DIR__ . '/mcp-schema.json'); // first run writes the file; a mismatch throws with a per-section summary: // "tools: changed [order.status]; prompts: added [code-review]"
When bumping the mcp/sdk pin, expect to regenerate: schema serialization
may legitimately change between SDK minors.
For interactive debugging use the official MCP Inspector:
npx @modelcontextprotocol/inspector # transport: Streamable HTTP, URL: https://your-app/rest/mcp, # header: X-Mcp-Secret: <secret>
Roadmap
Planned direction (tool-call interceptors, AI audit trail, session budgets, tenant-scoped serving, per-session tool visibility): see ROADMAP.md.
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.