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: 369

Dependents: 3

Suggesters: 0

Stars: 0

Open Issues: 0

v2.2.0 2026-07-29 18:46 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. Projects using the llm/skills Composer plugin also get this package's agent skill synced into .agents/skills/ automatically on install.

Requirements

Requirement Version
PHP 8.3 – 8.5
mcp/sdk ~0.7.0 (experimental until 1.0 — hence the tilde pin)
MCP protocol 2025-11-25 (the SDK's default; it advertises this in initialize regardless of what the client asks for)
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.

Tool behavior hints

Use the SDK's ToolAnnotations directly when a client should know whether a tool reads, mutates or reaches outside its closed domain. No yii3-mcp-specific attributes are needed:

use Mcp\Schema\ToolAnnotations;

#[McpTool(
    name: 'order.cancel',
    annotations: new ToolAnnotations(
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: true,
        openWorldHint: false,
    ),
)]
public function cancel(string $orderId): string
{
    $this->orders->cancel($orderId);

    return 'cancelled';
}
Hint Meaning
readOnlyHint true when the tool does not modify its environment
destructiveHint for a mutating tool, distinguishes destructive changes from additive-only changes
idempotentHint for a mutating tool, says repeated calls with the same arguments add no further effect
openWorldHint true when the tool may interact with external entities outside a closed domain

Annotations are advisory MCP metadata. A client may ignore them, so never use them in place of authorization, validation, safe_methods_only, visibility or server-side confirmation. In particular, idempotentHint alone is not enough to enable automatic retries: keep the server's retry allow-list explicit.

Server-initiated communication

An attribute tool may accept the SDK's request-scoped RequestContext as a parameter. The SDK creates it for the current MCP request and omits it from the generated input schema, so the client supplies only real domain arguments. Obtain its ClientGateway to send progress/log notifications or to initiate sampling and elicitation:

use Mcp\Schema\Elicitation\BooleanSchemaDefinition;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Server\RequestContext;

#[McpTool(
    name: 'release.deploy',
    annotations: new ToolAnnotations(
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: false,
    ),
)]
public function deploy(string $version, RequestContext $context): string
{
    $client = $context->getClientGateway();
    $client->progress(progress: 1, total: 2, message: 'Validation complete');

    if (!$client->supportsElicitation()) {
        throw new RuntimeException('Client does not support required deployment confirmation');
    }

    $confirmation = $client->elicit(
        message: sprintf('Deploy version %s?', $version),
        requestedSchema: new ElicitationSchema(
            properties: [
                'confirmed' => new BooleanSchemaDefinition(
                    title: 'Confirm deployment',
                    description: 'Allow this deployment to proceed',
                ),
            ],
            required: ['confirmed'],
        ),
    );

    if (!$confirmation->isAccepted() || ($confirmation->content['confirmed'] ?? false) !== true) {
        throw new RuntimeException('Deployment was not confirmed');
    }

    return sprintf('Deployment %s queued', $version);
}

progress() is a no-op when the caller did not provide a progress token. log() emits client-visible log notifications; sample() and elicit() are round trips that suspend the tool Fiber until the client responds or the SDK timeout expires. Check supportsElicitation() before requiring elicitation, choose a fail-closed fallback for destructive operations, and assume that not every MCP client supports every server-initiated capability. Keep RequestContext as a method parameter rather than a constructor dependency: it belongs to one request/session.

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 listing is the default (unauthenticated) view — the command drives a synthetic session with no client identity, and says so in its output: with per-session visibility or RBAC wired in, a real client may see a different capability set.

The command (like McpTester) needs PSR-17 factories in the container. Keep these services in every config group that builds Mcp\Server, including the console group:

Entry point / feature Required services
McpAction ResponseFactoryInterface, StreamFactoryInterface
McpListCommand, McpTester ServerRequestFactoryInterface, ResponseFactoryInterface, StreamFactoryInterface
URL OpenAPI spec PSR-18 ClientInterface, PSR-17 RequestFactoryInterface
OpenAPI operation execution PSR-18 ClientInterface, PSR-17 RequestFactoryInterface, StreamFactoryInterface

ServerRequestFactoryInterface and RequestFactoryInterface are distinct PSR-17 contracts; binding one does not satisfy the other.

Diagnostics: mcp:doctor

mcp:doctor checks the MCP server configuration end-to-end and reports each check as pass/skip/fail — the output never contains the configured secret or header values, and printed URLs are stripped of userinfo credentials (exception messages from application services pass through truncated, so treat the report as operator-facing diagnostics):

./yii mcp:doctor           # human-readable table
./yii mcp:doctor --json    # machine-readable report
./yii mcp:doctor --probe   # also fetch a URL OpenAPI spec over the network

Checks include endpoint secret, the optional expected_http_host allow-list, every PSR service required by enabled entry points/features, session storage (including confidentiality: a session directory readable by group/others fails the check, not just an unwritable one), the OpenAPI spec, the MCP Apps configuration (every declarative definition is parsed, so a malformed one is reported even when the server build check is skipped) and a real server build. Missing services are reported by their exact interface. Exit codes are stable for scripting: 0 healthy, 2 config error, 3 storage error, 4 upstream error — the category of the first failing check (checks run root-causes-first, so a broken config reports as config even though it also breaks the server build).

Without --probe the command never touches the network: with a URL spec_path both the spec fetch and the server build (which loads the spec eagerly) are reported as skipped.

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 — the shipped Session\PrivateFileSessionStore keeps it owner-only: the directory is created 0700 (an application-specific default under sys_get_temp_dir(), derived from server_name; override via session.dir) and every session file is clamped to 0600, because session JSON carries client metadata and everything needed to replay a session id. 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),
];

Sessions are bound to the client that created them

The SDK itself only checks that a presented Mcp-Session-Id exists — any authenticated client could otherwise act inside (or DELETE) another client's session by replaying its id, which leaks into proxy and client logs via the HTTP header. When client_secrets (or endpoint_secret) is configured, McpAction stamps the resolved client id into the session as an immutable owner at initialize and verifies it on every POST/DELETE before the transport runs. A foreign — or ownerless — session is answered with the same 404 the SDK uses for a missing one, indistinguishable from an expired session. Deployments without client identity (network-ACL-only) are unaffected.

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.

Substitution amplifies caller input — one argument value is inserted at every occurrence of its placeholder — so the expanded prompt is bounded by the limits.prompt_result_bytes param (default 1 MiB, 0 = unlimited). The size is computed arithmetically and checked before the substituted string is built: an over-budget prompts/get fails without performing the allocation it refuses.

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.

Argument autocompletion (completion/complete)

Clients offer values as the user types a prompt argument or a resource-template variable. Declare the source with the SDK's #[CompletionProvider] — no yii3-mcp API is involved:

use Mcp\Capability\Attribute\CompletionProvider;

#[McpPrompt(name: 'review')]
public function review(
    #[CompletionProvider(values: ['security', 'performance'])] string $focus,
    #[CompletionProvider(enum: Environment::class)] string $environment,
): string { /* … */ }

#[McpResourceTemplate(uriTemplate: 'app://reports/{region}', name: 'report')]
public function report(
    #[CompletionProvider(provider: RegionCompletionProvider::class)] string $region,
): string { /* … */ }
Form Source
values: [...] a fixed list, prefix-matched
enum: BackedEnum::class the enum's cases
provider: Foo::class a Mcp\Capability\Completion\ProviderInterface, resolved through the DI container — so it can query a repository or a feature-flag service

Exactly one of the three per argument; the capability is advertised automatically. Completions obey prompt_visibility / resource_visibility: a prompt or template the session cannot see completes nothing and is reported as not found, indistinguishable from a missing one (see Tool visibility). Interceptors do not wrap completion/complete — it is a metadata lookup, not a capability call, so put authorization in the visibility filter, not in an interceptor.

Server-wide knobs

'rasuvaeff/yii3-mcp' => [
    // free-form "how to use this server" text served in the initialize result —
    // the agent reads it before its first call. Empty = omitted.
    'instructions' => 'Prefer order.status over reading app://orders/{id}.',
    // page size for tools/resources/templates/prompts lists. Applies to the
    // SDK's handlers AND this package's filtering ones, so they can never page
    // differently depending on whether visibility is configured.
    'pagination_limit' => 50,
    // pins the revision advertised in initialize; empty keeps the SDK's default
    // (2025-11-25). An unsupported value fails at config load, not at runtime.
    'protocol_version' => '',
],

Resource subscriptions

The SDK advertises resources.subscribe whenever the server has any resource and records resources/subscribe per session. Nothing emits notifications/resources/updated on its own — but the tool that causes the change can, inside the same request, through Resource\ResourceUpdateNotifier:

public function __construct(private ResourceUpdateNotifier $notifier) {}

#[McpTool(name: 'order.cancel')]
public function cancel(string $orderId, RequestContext $context): string
{
    $this->orders->cancel($orderId);
    $this->notifier->notify($context, 'app://orders/' . $orderId);

    return 'cancelled';
}

notify() returns whether the caller was subscribed; a session that never subscribed is never sent anything, so an unsolicited notification cannot appear on the wire. Bind a custom Mcp\Server\Resource\SubscriptionManagerInterface and both the subscribe handler and the notifier follow it — the default binding is the SDK's session-backed manager.

Only the calling session is reached. Other sessions subscribed to the same URI are not: that would need a connection this process does not hold. Under PHP-FPM nothing outlives the request, so out-of-band push remains impossible — clients that need to observe changes they did not cause must poll.

Framework-agnostic usage

Despite the package name, the code has no yiisoft/* runtime dependency — composer.json's require is PSR interfaces (psr/container, psr/http-message, psr/http-server-middleware, psr/http-factory, psr/simple-cache, psr/log) plus mcp/sdk and symfony/console/yaml. McpServerFactory takes a plain Psr\Container\ContainerInterface; McpAction and SharedSecretMiddleware are plain PSR-15. config/di.php + config/params.php are only the yiisoft/config-plugin convenience layer — outside Yii3, wire the same classes by hand with whatever PSR-11 container and router the application already uses (Laravel, Symfony, Mezzio, Slim, …):

use Mcp\Server\Session\FileSessionStore;
use Rasuvaeff\Yii3Mcp\{McpAction, McpServerFactory, SharedSecretMiddleware};

// any PSR-11 container — Yii3's, PHP-DI, Laravel's, a hand-rolled one
$container = /* ... */;

$sessionStore = new FileSessionStore(directory: sys_get_temp_dir() . '/mcp-sessions', ttl: 3600);
$factory = new McpServerFactory(container: $container, sessionStore: $sessionStore, name: 'my-app', version: '1.0.0');
$server = $factory->create([OrderTools::class]);

$psr17 = /* any PSR-17 factory, e.g. nyholm/psr7 or guzzlehttp/psr7 */;
$action = new McpAction(server: $server, responseFactory: $psr17, streamFactory: $psr17);
$middleware = new SharedSecretMiddleware(secret: getenv('MCP_SECRET'), responseFactory: $psr17);

// route POST/GET/DELETE/OPTIONS /mcp through $middleware -> $action
// in whatever middleware-dispatch shape the framework's router expects

McpServeCommand (stdio) extends Symfony\Component\Console\Command\Command directly and needs no Yii3 console app — add it to any Symfony\Component\Console\Application. What's lost without yiisoft/config: the automatic array-merge across packages (interceptors, visibility, the OpenAPI bridge) — construct Interceptor\*/Visibility\*/OpenApi\OpenApiServerConfigurator instances directly and pass them to McpServerFactory::create(), the same objects config/di.php builds from params, just without the config-plugin assembling them for you.

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/pass/pwd, secret, token/bearer/jwt, auth/authorization, cookie, api_key/apikey/ api-key/x-api-key, access_token/refresh_token/id_token/ session_token/auth_token (and the kebab spelling access-token), client_secret, private_key, credit_card by default; case-insensitive, so ApiKey/X-Api-Key match too, 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.

Result size limit and caching

A tool result has no natural upper bound — a bridged GET against a real API, or a hand-written tool over a large table, can return megabytes of JSON and burn an agent's context window. limits.tool_result_bytes truncates an over-limit string result with an explicit marker; any other result (array, object) is rejected outright instead, because a truncated JSON payload is invalid JSON, not a smaller valid one:

'rasuvaeff/yii3-mcp' => [
    'limits' => ['tool_result_bytes' => 0],   // 0 = unlimited (default)
],

The limit is a byte budget applied to the content: a multi-byte character that does not fit whole is dropped rather than split (a broken UTF-8 sequence would make the JSON-RPC response unencodable, and the Streamable HTTP transport drops such a response silently), and the marker itself is appended on top of the budget. The marker reports the bytes actually kept. For a string result the budget counts the raw string's bytes, before JSON encoding (control characters expand on the wire, up to ~6x); for arrays/objects it counts the JSON-encoded bytes. It bounds what reaches the agent's context window — worker memory during production of the result is bounded separately by openapi.max_response_bytes.

For read-heavy tools called repeatedly with the same arguments inside a session (a lookup table, an OpenAPI GET), cache.tools skips the handler entirely on a hit — opt in by tool name (for the OpenAPI bridge, the served name, after any tool_names rename) with a TTL in seconds:

'rasuvaeff/yii3-mcp' => [
    'cache' => [
        'tools' => ['blog_tags_list' => 60],
    ],
],

Requires a PSR-16 CacheInterface in the container. The cache key always includes the resolved client id — a shared cache between distinct clients would leak one client's result to another. The key material is typed: an absent client id (stdio) encodes as null and can never collide with a real client that happens to be named anonymous. The key also carries a mandatory namespace (cache.namespace param, defaulting to server_name): two applications sharing one cache backend — a common Redis — with same-named tools must never read each other's results, and an external PSR-16 prefix is defence in depth, not the primary isolation. When openapi.identity_provider is configured, the resolved ExecutionIdentity is part of the key too: delegated upstream credentials mean the same tool and arguments can produce identity-specific results, and the identity may be finer-grained than the client id (many end users behind one MCP client). Only successful results are cached; a thrown exception never is. A cache read/write failure fails open (the tool runs) — this is an availability optimization, not a security gate. An identity provider failure, by contrast, fails closed for cached tools: serving a result without knowing whose it is would be exactly the leak the key exists to prevent.

The key identifies the MCP client, not the application user behind it. A tool whose result depends on who is logged in (reading the current user from the application, not from the MCP identity) must therefore not be cached unless openapi.identity_provider resolves that user — "idempotent read" is not the same as "same answer for everyone". Bridged operations are covered by the identity provider; hand-written tools are yours to judge.

Interceptor order is fixed: session budget (outermost) → configured interceptors → caching → result size limit (innermost, closest to the actual tool call). Configured interceptors (RBAC, audit) always run, even on a cache hit — caching cannot be used to bypass them. The size limit only runs on a cache miss; the value it already limited is what gets cached, so a hit never needs re-limiting.

Client identity and secret rotation

One endpoint can serve several MCP clients, each with its own secret — and each client may hold several active secrets during a rotation window (add the new secret, roll the clients, remove the old one; a removed secret is revoked immediately):

'rasuvaeff/yii3-mcp' => [
    'client_secrets' => [
        'ci' => getenv('MCP_SECRET_CI'),
        'claude' => [getenv('MCP_SECRET_CLAUDE_OLD'), getenv('MCP_SECRET_CLAUDE_NEW')],
    ],
],

SharedSecretMiddleware resolves the presented header through a Identity\SecretResolverInterface (every comparison is hash_equals(), constant-time) and passes the resolved client id — never the raw secret — down the pipeline: interceptors see it as ToolCallContext::$clientId, and it is stamped into the session as its immutable owner for audit/telemetry bridges and session-ownership enforcement. The single endpoint_secret form keeps working unchanged as one client named default; configuring both forms at once is a fail-fast error, and so is a secret shared by two different client ids — resolution returns the first match, so a duplicate would silently attribute one client's calls to the other. On stdio (mcp:serve) there is no HTTP request, so $clientId is null.

Per-client rate limits (bring your own limiter)

This package deliberately ships no limiter storage. Implement Interceptor\ToolCallLimiterInterface over the rate limiter your application already runs (yiisoft/rate-limiter, Redis, …) and wire Interceptor\RateLimitInterceptor into the interceptors list:

final readonly class AppToolCallLimiter implements ToolCallLimiterInterface
{
    public function __construct(private CounterInterface $counter) {}

    public function allow(?string $clientId, string $toolName): bool
    {
        return $this->counter->hit(($clientId ?? 'no-client') . ':' . $toolName)->isAllowed();
    }
}

// params
'rasuvaeff/yii3-mcp' => [
    'interceptors' => [RateLimitInterceptor::class],
],
// di: bind ToolCallLimiterInterface => AppToolCallLimiter

The interceptor keys calls by the resolved client id plus the tool name, so per-client and per-tool limits come from your limiter's configuration. A transport without identity (stdio) passes null — typed absence, never a reserved string a real client id could collide with; how anonymous calls are bucketed is the limiter's decision. Fail-closed: when the limiter backend throws, the call is rejected — an enforced quota must not silently become "unlimited" during an outage.

Retrying transient failures (bring your own retry)

This package ships no retry logic — a naive blanket retry duplicates side effects on a non-idempotent tool (double-charging a payment, resubmitting a form). Scope any retry to an explicit allow-list of tools you have verified are idempotent, and to transient failure types only, using rasuvaeff/retry:

use Rasuvaeff\Retry\Retry;
use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallContext;
use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallInterceptorInterface;
use Rasuvaeff\Yii3Mcp\OpenApi\Exception\OperationFailedException;

final readonly class RetryInterceptor implements ToolCallInterceptorInterface
{
    /** @param list<string> $idempotentTools verified idempotent — never blanket-retry */
    public function __construct(private array $idempotentTools) {}

    public function intercept(ToolCallContext $context, callable $next): mixed
    {
        if (!in_array($context->toolName, $this->idempotentTools, true)) {
            return $next();
        }

        return Retry::new()
            ->maxAttempts(3)
            ->withExponential(baseMs: 100, multiplier: 2.0, capMs: 2_000)
            ->retryOn(OperationFailedException::class)   // transient failures only
            ->run($next);
    }
}

Place it near the end of your interceptors list (closer to the tool call) — any interceptor listed before it (e.g. RateLimitInterceptor) wraps around the whole retry loop and is checked once per outer call, not once per attempt; listed after it, it would be re-triggered on every retry.

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
    ],
],

A tag: prefix matches against the tool's tags instead of its name — the OpenAPI bridge propagates OpenAPI tags into the tool's _meta, so 'deny' => ['tag:admin'] hides every bridged operation tagged admin regardless of its operationId/tool_names name. A tool with no tags never matches a tag: pattern.

Note where a tag comes from: the OpenAPI document. With a URL spec (openapi.spec_path pointing at http(s)), a document that drops the admin tag disarms a deny: ['tag:admin'] rule — the exposure stays bounded by your operations allow-list, but the deny rule itself is only as trustworthy as the spec source. Prefer name patterns for deny rules over a remote spec, and keep tag: for allow-listing and for local spec files.

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.

Hooks for prompts and resources

The same seams exist for the other capabilities. prompts/get and resources/read (static resources and templates alike) each have their own interceptor chain and visibility filter — separate interfaces, so a tool policy never accidentally applies to a prompt:

// config/params.php — each list resolved through the container, first = outermost
'rasuvaeff/yii3-mcp' => [
    'prompt_interceptors' => [PromptAuditInterceptor::class],     // Interceptor\PromptGetInterceptorInterface
    'resource_interceptors' => [ResourceAclInterceptor::class],   // Interceptor\ResourceReadInterceptorInterface
    'prompt_visibility' => PlanBasedPromptVisibility::class,      // Visibility\PromptVisibilityInterface
    'resource_visibility' => PlanBasedResourceVisibility::class,  // Visibility\ResourceVisibilityInterface
],
  • Interceptor\PromptGetContext carries the prompt name, arguments, session and client id; Interceptor\ResourceReadContext carries the URI, the RFC 6570 variables extracted from a template (with the matched uriTemplate) and the same identity fields.
  • Rejecting: throw Mcp\Exception\PromptGetException / Mcp\Exception\ResourceReadException — the client sees the message. Hiding: visibility (or a thrown *NotFoundException) reports the capability as not found, indistinguishable from a missing one — a client that guesses a hidden prompt name or resource URI learns nothing, and the call never reaches the interceptors or the handler.
  • Visibility filters prompts/list, resources/list and resources/templates/list with the same implementation that guards the direct calls, so listing and fetching can never disagree.
  • completion/complete obeys the same filters. Argument autocompletion (#[CompletionProvider] on a prompt argument or a resource-template variable) is served by the SDK straight off the registry, so it used to answer for prompts and templates a session could not see — leaking both the suggested values and the capability's existence. It is now decorated with the configured prompt/resource visibility and reports a hidden ref as not found, exactly like a missing one.

For bridges (audit, telemetry) the core ships one shared outcome vocabulary — Interceptor\CallOutcome (success / rejected / error, with CallOutcome::fromThrowable()): a rate-limit or ACL rejection is classified rejected and never pollutes error-rate metrics.

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 3.0.x or 3.1.x document, allow-listed operations can be bridged as MCP tools with zero duplication — names come from operationId (or tool_names, see below), descriptions from summary/description, input schemas from parameters/request body, output schemas from the success response (see below). 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 `spec_headers`, NOT with `headers`
        'spec_path' => 'https://api.example.com/rest/json-url',
        'base_url' => 'https://api.example.com',
        'operations' => ['getBlogTags', 'getPage'],   // allow-list, empty = nothing
        // rename an ugly generated operationId into an LLM-friendly tool
        // name; unmapped operations keep their operationId
        'tool_names' => ['getBlogTags' => 'blog_tags_list'],
        // operation-call credentials, sent to base_url only
        'headers' => ['Authorization' => 'Bearer ' . getenv('MCP_API_TOKEN')],
        // spec-fetch credentials, sent to spec_path only (empty by default)
        'spec_headers' => [],
        'cache_ttl' => 60,             // PSR-16 URL-spec cache; 0 = fetch every build
        'safe_methods_only' => true,   // read-only bridge: non-GET in the list => build error
        'max_response_bytes' => 4_194_304, // upstream body cap, read incrementally
        'opaque_errors' => false,      // true = suppress upstream error bodies
    ],
],

Credential scopes are separate on purpose: headers authenticates operation calls against base_url, spec_headers authenticates the spec fetch against spec_path — when the two point at different origins, a shared header set would hand the API token to the spec host. A spec URL embedding credentials (userinfo) is rejected outright: such a URL ends up in diagnostics and exception messages.

tool_names only renames what MCP clients see as the tool name — the allow-list, handler execution and delegated-header calls all stay keyed by operationId. Interceptors, visibility rules and any audit/RBAC bridge must reference the renamed name. An operationId in tool_names that is not in operations throws InvalidArgumentException at build time (a likely typo); a rename that is invalid as an MCP tool name or collides with another tool's name throws InvalidSpecException. The collision check covers attribute tools too: the names of #[McpTool] methods registered on the same server are reserved, so renaming a bridged operation onto one of them is a build-time error rather than a bridged tool that silently never reaches tools/list (the SDK's registry is last-write-wins and registers attribute tools last).

Every GET operation is advertised with readOnlyHint: true — no configuration needed. OpenAPI tags on an operation are propagated into the served tool's _meta ({"rasuvaeff/yii3-mcp": {"tags": [...]}}), which the declarative tag: visibility pattern (see Tool visibility) reads directly.

The DI wiring requires PSR-18/PSR-17 services (ClientInterface, RequestFactoryInterface, StreamFactoryInterface) in the container and a PSR-16 CacheInterface when cache_ttl > 0. The cache stores the raw document; allow-listing and validation run on every server build. Cache failures fall back to HTTP, while HTTP/spec failures remain fail-closed. A removed operation can remain callable for up to the TTL, so use a local file or a short TTL for security-sensitive specs. 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 for request-body schemas. URL parameters are deliberately limited to scalar string, integer, number and boolean schemas with the OpenAPI defaults (simple path, form query). Header/cookie parameters, external or non-scalar parameter schemas, custom serialization, non-default explode and allowReserved=true throw InvalidSpecException when the operation is selected. A path argument is rejected at call time when it is empty or ., or when it contains .., / or \rawurlencode keeps dots verbatim and encodes / as %2F, which upstreams that decode before normalizing the path (Apache with AllowEncodedSlashes, some proxies and servlet containers) hand back as a real separator, so a value like ../.. could climb out of the allow-listed route with the bridge's credentials; an empty value is the same escape one level up (/users/ is typically the collection route, not the allow-listed item route). Single dots are fine (v1.2 is a valid slug); a value that needs a slash or .. inside it cannot be bridged as a path argument. The base URL must not embed credentials (userinfo) or carry a query string/fragment — dry-run previews return the full URL to the caller, so the base URL is never allowed to be a credential carrier. Fixed upstream headers belong in headers/ HttpOperationExecutor::defaultHeaders. Bridged operations execute with the configured upstream credentials. The upstream API does not automatically inherit the MCP caller identity or RBAC decision. Do not expose user/tenant- scoped operations with a broader service token.

For delegated authorization configure both identity_provider and delegated_header_provider. The first returns an immutable ExecutionIdentity; the second exchanges it for headers on every operation call and receives only operation id/method/path plus that identity, never the raw MCP shared secret. Do not forward the inbound Authorization header verbatim. Provider failures stop the call before HTTP (fail-closed), and dynamic headers override matching static headers without cross-call reuse.

Duplicate operationId values also fail while indexing the document. 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.

Resource bounds are enforced before allocation, not after: the upstream response body is read incrementally and the call fails the moment it crosses max_response_bytes (an advertised Content-Length over the cap is rejected without reading at all); JSON decoding is depth-capped; the OpenAPI document itself is size-bounded (10 MiB) for URL and file sources alike, and $ref inlining runs under an explicit depth + node budget, so a hostile or degenerate remote spec cannot make indexing recurse or allocate without bound. Upstream error bodies are excerpted (bounded, UTF-8-safe) into the tool error — or suppressed entirely with opaque_errors when upstream error details are not the MCP caller's to see.

An operationId that cannot serve as an MCP tool name (space, unicode, over 64 characters — ^[A-Za-z0-9._/-]{1,64}$) throws InvalidSpecException when the operation is selected; mcp/sdk itself only logs a warning and would otherwise register the tool anyway, surfacing only as an opaque tools/list rejection on the client. A null argument for a path or query parameter is treated the same as an omitted one (skipped, not sent as an empty value) — matching OpenAPI 3.1's nullable union notation ({"type": ["string", "null"]}) on scalar parameter schemas, which the bridge accepts alongside the plain 3.0 type string.

Output schema from responses

A bridged tool also advertises outputSchema in tools/list when the operation declares a matching success response: the lowest concrete 2xx response with an application/json schema of type: object — OpenAPI 3.1's nullable union notation (type: ["object", "null"]) is accepted the same way (local $refs resolved, top-level keywords canonicalized to type/properties/required/additionalProperties/description, always to the plain "object" type). Agents see the response shape before calling and MCP clients validate the returned structuredContent against it. Array/scalar responses and 2XX wildcards are not advertised — JSON object payloads still arrive as structuredContent, just without the upfront contract. If the advertised schema must match reality, keep the OpenAPI document honest: a spec that diverges from the API surfaces as client-side validation errors.

For custom scenarios use the pieces directly: SpecIndex + HttpOperationExecutor + OpenApiServerConfigurator (a ServerConfiguratorInterface — the generic extension point accepted by McpServerFactory::create(tools, configurators)).

Per-operation customization

OperationModifierInterface is a per-operation hook, applied after the tool_names rename — for changing a description, adding annotations, or renaming further, without writing a whole ServerConfiguratorInterface:

'rasuvaeff/yii3-mcp' => [
    'openapi' => [
        'operation_modifier' => MyOperationModifier::class,   // DI-resolved
    ],
],
use Mcp\Schema\Tool;
use Rasuvaeff\Yii3Mcp\OpenApi\Operation;
use Rasuvaeff\Yii3Mcp\OpenApi\OperationModifierInterface;

final readonly class MyOperationModifier implements OperationModifierInterface
{
    public function modify(Operation $operation, Tool $tool): Tool
    {
        return new Tool(
            name: $tool->name,
            title: $tool->title,
            inputSchema: $tool->inputSchema,
            description: $tool->description . ' (read-only bridge)',
            annotations: $tool->annotations,
            outputSchema: $tool->outputSchema,
        );
    }
}

A name change from the modifier is validated and checked for collisions the same way as a tool_names rename — fail-closed, same as everywhere else in the bridge.

Dry-run: preview a call without executing it

'rasuvaeff/yii3-mcp' => [
    'openapi' => [
        // operationIds that get an extra `dryRun` boolean argument
        'dry_run' => ['createSubscriber'],
    ],
],

A dry-run-enabled operation's inputSchema gains a dryRun: boolean argument. Calling the tool with dryRun: true returns the request that would be sent (operationId, method, url, body) as text — never as structuredContent, so it never conflicts with the operation's declared outputSchema — without sending it, and without any upstream credentials leaving the process (headers are never included in the preview). The flag is checked twice, fail-closed: an operationId absent from dry_run ignores a dryRun argument entirely and always executes for real, even if a client sends it anyway. On a dry-run-enabled operation a non-boolean dryRun value (1, "true") is rejected with an error instead of being executed — a malformed flag must never turn an intended preview into a real call.

Dry-run is orthogonal to safe_methods_only: it does not expose an operation the safety gate would otherwise reject — a write operation still needs safe_methods_only: false (or omitted) to be exposed at all, dry-run or not. A dry-run call still passes through the full interceptor chain (session budget, RBAC/audit, caching, size limit) like any other call — previewing a write action requires the same permission as actually calling it.

MCP Apps: interactive UI in the conversation

MCP Apps (io.modelcontextprotocol/ui) are HTML documents served as ui:// resources and rendered by the client in a sandboxed iframe, inside the conversation. The extension must be announced during the handshake — a ui:// resource on a server that does not announce it is just text to the client.

'rasuvaeff/yii3-mcp' => [
    'apps' => [
        // announce the extension (enough for attribute-based apps)
        'enable' => true,
        // declarative apps — no PHP class needed
        'definitions' => [
            [
                'uri' => 'ui://dashboard',        // required, must start with ui://
                'name' => 'dashboard',            // required, unique
                'html' => '<!DOCTYPE html>…',     // string, or Closure(): string
                'title' => 'Dashboard',
                'description' => 'Sales overview',
                'csp' => ['connect_domains' => ['api.example.com']],
                'permissions' => ['geolocation' => true],
                'prefers_border' => true,
            ],
        ],
    ],
],

A non-empty definitions list enables the extension on its own. html as a Closure(): string is re-evaluated on every resources/read — the hook for templating and DI-provided data, and the reason an expensive render costs that much per read.

Attribute-based apps

For an app with logic behind it, declare a ui:// resource the usual way and return the content yourself:

#[McpResource(
    uri: 'ui://report',
    name: 'report',
    mimeType: McpApps::MIME_TYPE,
    meta: ['ui' => new \stdClass()],        // descriptor marker
)]
public function report(): TextResourceContents
{
    return new TextResourceContents(
        uri: 'ui://report',
        mimeType: McpApps::MIME_TYPE,
        text: '<!DOCTYPE html><h1>Report</h1>',
        meta: ['ui' => new UiResourceContentMeta(  // sandbox contract
            csp: new UiResourceCsp(connectDomains: ['api.example.com']),
            prefersBorder: true,
        )],
    );
}

This path still needs 'apps' => ['enable' => true] — that is what announces the extension. Returning a plain string works too, but only a returned TextResourceContents can carry _meta.ui.

Where _meta.ui goes

Level Value Carries
Descriptor (resources/list) McpApps::resourceMarker() — an empty {} "this resource is an app" and nothing else
Content (resources/read) UiResourceContentMeta csp, permissions, domain, prefersBorder

Mixing them up is the one easy mistake here: a sandbox policy placed on the descriptor is ignored, and a marker placed on the content tells the host nothing.

Sandbox: CSP and permissions

UiResourceCsp allow-lists what the iframe may reach (connect_domains for fetch/XHR/WebSocket, resource_domains for images/scripts/styles, frame_domains, base_uri_domains); omitting the CSP entirely leaves the host's own restrictive default in place. UiResourcePermissions requests sandbox capabilities (camera, microphone, geolocation, clipboard_write) — in params these are plain booleans, and only true ones are sent.

Domains are passed to the client verbatim: the policy is enforced by the host, and definitions is application-owned configuration, not client input. The app HTML itself is served as-is — it is your responsibility not to interpolate untrusted data into it.

Linking a tool to an app

#[McpTool(
    name: 'refresh_report',
    meta: ['ui' => new UiToolMeta(resourceUri: 'ui://report')],
)]
public function refresh(): string { /* … */ }

UiToolMeta::$visibility (ToolVisibility::Model / ToolVisibility::App) declares who may call it — an app-only tool is hidden from the model's tools/list by the host; the server only states the intent, so do not treat it as an access control boundary. For a server-side guarantee use Visibility\ToolVisibilityInterface, which fail-closed rejects the call itself.

App resources are ordinary resources otherwise: ResourceVisibilityInterface filters them, resource_interceptors wrap their reads, and a ui:// URI colliding with an attribute resource fails the build like any other duplicate. McpAppsConfigurator is the single enabler of the extension — a second enableExtension(new McpApps()) from an application configurator fails the build (the SDK rejects a duplicate extension id).

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; stamps the immutable session owner at initialize and rejects foreign-session POST/DELETE with a 404
Session\PrivateFileSessionStore owner-only file session store: directory created 0700, session files clamped 0600 (the shipped default)
Exception\SessionOwnershipException a capability call arrived with a client identity different from the session's immutable owner (fail-closed)
Exception\DuplicateCapabilityException two capability registrations resolved to one identity — the build fails instead of the SDK's silent last-write-wins
SharedSecretMiddleware fail-closed hash_equals() guard; an empty secret rejects every request with an explanatory 503 — an unprotected endpoint must be an explicit decision; resolves the client id via Identity\SecretResolverInterface
Identity\SecretResolverInterface / Identity\StaticSecretResolver several clients per endpoint + secret rotation (multiple active secrets per client id); constant-time comparison, the raw secret never travels past the middleware
Interceptor\ToolCallLimiterInterface / Interceptor\RateLimitInterceptor port + adapter delegating per-client/per-tool limits to the application's rate limiter; fail-closed on limiter outage
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
McpDoctorCommand mcp:doctor — configuration health check (secret, session storage, OpenAPI spec, server build) with stable exit codes (0/2/3/4 = healthy/config/storage/upstream); --json, --probe
Doctor\McpDoctor the diagnostics service behind mcp:doctor; returns an immutable DoctorReport of CheckResults (CheckStatus pass/skip/fail, CheckCategory config/storage/upstream)
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/list all paginated capabilities/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\ResponseSizeLimitInterceptor caps tool result size (limits.tool_result_bytes param) — truncates strings, rejects oversized arrays/objects
Interceptor\CachingToolCallInterceptor PSR-16 cache for successful tool results, per tool name with a TTL (cache.tools param); typed key includes a mandatory application namespace, the client id and, with delegated auth, the ExecutionIdentity
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
Interceptor\PromptGetInterceptorInterface / Interceptor\ResourceReadInterceptorInterface wrap every prompts/get and resources/read (prompt_interceptors / resource_interceptors params) with PromptGetContext / ResourceReadContext
Visibility\PromptVisibilityInterface / Visibility\ResourceVisibilityInterface per-session prompt/resource filters (prompt_visibility / resource_visibility params): lists omit, direct get/read reports not-found
Interceptor\CallOutcome shared success/rejected/error vocabulary for audit/telemetry bridges (fromThrowable())
OpenApi\OpenApiServerConfigurator bridges allow-listed OpenAPI operations as tools (HTTP execution)
OpenApi\OperationModifierInterface per-operation customization hook, applied after the tool_names rename
OpenApi\Operation read-only operation context passed to OperationModifierInterface::modify()
OpenApi\Exception\* InvalidSpecException, UnknownOperationException, UnsafeOperationException, OperationFailedException
Resource\ResourceUpdateNotifier sends notifications/resources/updated to the calling session from inside the request that changed the resource; a session that never subscribed is never notified
Apps\McpAppsConfigurator announces the MCP Apps extension and registers declarative ui:// app resources (apps params)
Apps\AppDefinition one declarative app: ui:// URI, name, HTML (string or Closure(): string) and its UiResourceContentMeta

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'].
  • Sessions are bound to the client that created them (immutable owner stamped at initialize, verified before every POST/DELETE): a leaked Mcp-Session-Id alone does not let another authenticated client act in — or destroy — a foreign session. Session files are owner-only (0700 dir, 0600 files) in an application-specific directory.
  • Capability names are unique across the whole server, enforced. A collision between any registration paths (attribute tools, configurators, the OpenAPI bridge, Markdown prompts) fails the build with DuplicateCapabilityException — never the SDK's silent last-write-wins, which would leave visibility/cache/RBAC/audit rules describing a vanished handler.
  • All caller-influenced output is size-bounded before allocation: upstream response bodies (openapi.max_response_bytes, incremental read), substituted prompts (limits.prompt_result_bytes, arithmetic pre-check), spec documents (10 MiB + $ref depth/node budget) and tool results (limits.tool_result_bytes).
  • 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, with tool_names and OperationModifierInterface no
interceptors.php Tracing interceptor (with ArgumentMasker) + session budget guard + result size limit no
visibility.php Tool visibility: per-session interface + declarative deny patterns, fail-closed call no
structured-output.php outputSchema + structuredContent on a tool no
server-initiated.php Official ToolAnnotations and a schema-safe RequestContext parameter for progress/elicitation no
completions.php completion/complete: value list / enum / container-resolved provider, and visibility applied to completions no
mcp-apps.php MCP Apps: declarative and attribute-based ui:// apps, _meta.ui placement, CSP/permissions, a tool linked to an app 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();                 // every paginated tool definition
$tester->listResources();             // every resource definition
$tester->listResourceTemplates();     // every resource-template definition
$tester->listPrompts();               // every prompt definition
$tester->readResource('app://x');     // resource contents
$tester->request('custom/method');     // 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:

SchemaSnapshot::verify($tester, __DIR__ . '/mcp-schema.json');
// a mismatch throws with a per-section summary:
// "tools: changed [order.status]; prompts: added [code-review]"

verify() treats a missing snapshot as an error, so a deleted or never-committed file cannot yield a green CI build. To create or deliberately regenerate the snapshot, run once with the environment flag (or call SchemaSnapshot::record()), then commit the file:

MCP_SNAPSHOT_RECORD=1 vendor/bin/testo --suite=Unit

assert() remains as the migration-friendly mode: a missing file is generated on the first run, then compared exactly like verify().

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.