rasuvaeff/yii3-mcp

MCP server integration for Yii3: PSR-15 Streamable HTTP endpoint, DI tool registry, and stdio transport over the official mcp/sdk

Maintainers

Package info

github.com/rasuvaeff/yii3-mcp

pkg:composer/rasuvaeff/yii3-mcp

Transparency log

Statistics

Installs: 31

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-07-05 08:23 UTC

This package is auto-updated.

Last update: 2026-07-12 08:31:04 UTC


README

Stable Version Total Downloads Build Static analysis Psalm level PHP License

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.