Search by

rasuvaeff / yii3-mcp

rasuvaeff

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

Package info

github.com/rasuvaeff/yii3-mcp

pkg:composer/rasuvaeff/yii3-mcp

Statistics

Installs: 1 622

Dependents: 3

Suggesters: 0

Stars: 1

Open Issues: 1

v4.1.1 2026-10-10 09:54 UTC

README

Stable Version Total Downloads Build Static analysis Psalm level PHP License Docs

Русская версия

Documentation — full guide, generated API reference across all four packages, and cookbook.

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.8.1 (experimental until 1.0 — hence the tilde pin)
MCP protocol 2024-11-05 … 2025-11-25 over initialize (negotiated), 2026-07-28 stateless
ext-fileinfo required by the SDK

Protocol revisions

One endpoint serves both protocol eras; each request is classified by the SDK.

Era Revisions Lifecycle State
handshake 2024-11-05 … 2025-11-25 initialize → Mcp-Session-Id on every request in the session store (session.*)
stateless 2026-07-28 none: every request carries its own _meta (revision, client info, capabilities) and Mcp-Method/Mcp-Name headers none — the SDK hands each request a throwaway session
  • initialize negotiates: the client's revision when supported, otherwise 2025-11-25. protocol_version pins the handshake to one revision (handshake revisions only).
  • modern_era (default true) serves the stateless era. Switched off, its requests get Unsupported protocol version — and a client speaking only 2026-07-28 does not fall back to initialize by itself.
  • header_validation (default true) rejects a stateless request whose standard headers contradict its body (-32020), so a proxy can route and authorize on the headers alone.
  • Anything that keeps state "per session" means per request on the stateless era. The package's own session-bound guards are era-aware: the tool-call budget counts per client there, session ownership has nothing to bind (the client id of each request is the identity), and clientInfo() reads the request's _meta.
  • Not found is -32602 for tools, prompts and completion refs on every revision, and for resources from 2026-07-28 on (-32002 before) — hidden capabilities answer exactly the same.

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.

On the stateless 2026-07-28 era there is nobody to ask while the request is open, so an ask works differently — and the same handler serves both eras unchanged:

  • elicit()/elicitUrl() end the request with resultType: input_required; the client re-sends the call with the answer and the SDK runs the handler again from the top, where the ask now returns it. Anything a handler does before its last ask happens once per round — keep side effects after it.
  • More than one ask per call carries earlier answers between rounds in a signed requestState: set request_state.key (at least 32 bytes, from the environment). Without it the first ask works and a second fails the call.
  • Every round is a tools/call: interceptors run each round and the tool-call budget counts each one (one ask costs two). The answers are not signed, so a budget skipping "retries" could be bypassed by any client.
  • The result cache never stores an ask or serves a later round.
  • sample()/listRoots() throw on 2026-07-28, which removed them (Sampling, Roots and Logging are deprecated by SEP-2577).
  • progress()/log() stream over SSE when the client accepts text/event-stream.
'rasuvaeff/yii3-mcp' => [
    'request_state' => ['key' => $_ENV['MCP_REQUEST_STATE_KEY'] ?? '', 'ttl' => 600],
],

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

./yii mcp:serve

Claude Code config: claude mcp add my-app -- ./yii mcp:serve.

The mcp:serve, mcp:list and mcp:doctor commands are registered through the package's yiisoft/yii-console params, so they show up in ./yii list right after composer require — no application-side configuration. An application that maintains its own commands map merges with it recursively (the application layer wins on a name conflict, so a command can be renamed or rebound by re-declaring its name); an application not using yiisoft/config can still list the three classes in its console params the way previous versions documented it.

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:

./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
OpenAPI operation execution (executor => 'psr15') PSR-17 ServerRequestFactoryInterface, StreamFactoryInterface, the configured openapi.handler

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), the protocol eras (what the stateless era changes for this configuration: a single endpoint_secret makes every stateless caller share one tool-call budget, no notifications.bus leaves listen streams empty, no request_state.key limits a call to one ask; a too-short key fails) 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 initialize handshake to one revision, e.g. '2025-06-18'; empty
    // negotiates (the client's revision when supported, else 2025-11-25).
    // Handshake revisions only; anything else fails at config load.
    'protocol_version' => '',
    // serve the stateless 2026-07-28 era too (see "Protocol revisions")
    'modern_era' => true,
    // stateless era: reject requests whose standard headers contradict the body
    'header_validation' => true,
    // how array/object tool results are encoded into the text the agent
    // reads: 'pretty' (default, the SDK's own formatting) or 'compact' —
    // no indentation, ~3x fewer bytes; structuredContent is unaffected.
    // An unsupported value fails at config load.
    'result_json' => 'pretty',
],

Resource subscriptions

Subscribers are reached differently on the two protocol eras, and Resource\ResourceUpdateNotifier covers both from one call:

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('app://orders/' . $orderId, $context);

    return 'cancelled';
}
Era Subscription Who is reached
stateless 2026-07-28 a subscriptions/listen stream, fed from the notification bus every listener whose filter names the URI, from any process sharing the bus — $context is optional, so a queue worker or console command can notify too
handshake resources/subscribe, recorded in the caller's session only the calling session, from inside the request that changed the resource — no process holds the other sessions' connections
'rasuvaeff/yii3-mcp' => [
    'notifications' => [
        // '' = none (listen streams carry nothing), 'psr16' = through the
        // container's PSR-16 cache (PHP-FPM: publisher and listener are
        // different workers), 'memory' = one process (stdio, persistent runtimes)
        'bus' => 'psr16',
        // seconds a listen stream is held open; under PHP-FPM it holds a worker
        // that long — keep it below max_execution_time
        'subscription_lifetime' => 30,
    ],
],

An unsolicited notification never reaches the wire: the session path checks the subscription first, and a listen stream carries only the URIs its own filter asked for. Bind a custom Mcp\Server\Resource\SubscriptionManagerInterface and both the subscribe handler and the notifier follow it; the bus is bound once, so McpServerFactory and the notifier always share it.

Caching hints (stateless era)

Stateless answers to server/discover, tools/list, prompts/list, resources/list, resources/templates/list and resources/read carry SEP-2549 caching hints. The default is the SDK's: nothing fresh (ttlMs: 0), nothing shared (private).

'rasuvaeff/yii3-mcp' => [
    'cache_policy' => [
        'ttl_ms' => 0,
        'scope' => 'private',
        'methods' => [
            // the tool list changes only with a deploy
            'tools/list' => ['ttl_ms' => 300000, 'scope' => 'private'],
        ],
    ],
],

public lets a shared cache (a proxy) serve one caller's answer to another — an operator decision. With any visibility filter configured, a public hint on a list or resources/read fails the build: those answers are per caller. A method that carries no hint, a misspelled one, or an unknown scope fails at config load instead of being ignored.

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->clientInfo() (?Implementation) — who is calling what with which input
        $this->logger->info('tools/call', ['tool' => $context->toolName]);

        return $next();   // skip $next() to short-circuit
    }
}

$context->isStateless() tells the stateless era apart: there $context->session is a throwaway built for the one request, so its id names nothing (do not log it as a session) and nothing stored in it survives. $context->requestContext is the SDK's RequestContext for the call (null outside a server request): getClientGateway() lets an interceptor ask the user before the call — the way to confirm a costly operation on a tool whose handler the application does not own, such as an OpenAPI-bridged one — and getTraceContext() carries the caller's W3C traceparent/tracestate/ baggage (stateless era). On the stateless era an ask ends the request and the whole chain runs again on the retry: interceptors outside the asking one run once per round (see "Server-initiated communication").

// 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.

Tool-call budget: stop agent loops

A hard cap on tools/call in a row. An agent stuck in a loop burns the budget and gets an explanatory tool error instead of hammering the application:

'rasuvaeff/yii3-mcp' => [
    'tool_call_budget' => [
        'calls' => 50,     // 0 = unlimited (default)
        'window' => 3600,  // seconds, stateless era only
    ],
],

"In a row" depends on the protocol era:

Era Counted per Reset by
handshake (initialize) MCP session, in the session itself a new session (re-initialize) or the session TTL
stateless 2026-07-28 client id, in the container's PSR-16 cache the fixed window turning (floor(now / window))

The stateless era has no session to count in — a session counter would start at zero on every request — so with modern_era on, a budget requires a Psr\SimpleCache\CacheInterface in the container (a missing one fails the build). Without SharedSecretMiddleware there is no client id, and every anonymous stateless caller shares one budget. A cache outage rejects the call (fail-closed).

This is loop protection, not a client quota. 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. 3.x's session.budget fails the build with a pointer to the new key.

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.

Compact JSON tool results

The SDK serializes an array or object tool result with JSON_PRETTY_PRINT, which is ~3x the bytes of the same payload compact — a search page of 20 cards costs the agent tens of kilobytes of pure indentation. Opting into compact text:

'rasuvaeff/yii3-mcp' => [
    // 'pretty' (default, byte-identical to the SDK's own formatting) or
    // 'compact': array/object results are encoded without indentation, with
    // JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE. Anything else fails
    // at config load.
    'result_json' => 'compact',
],

What changes — and what deliberately does not:

  • only the text content of array/object results is re-encoded; string, int, float, bool and null results, and results that are already SDK Content objects, are served exactly as before;
  • structuredContent is still produced for array results (an outputSchema-declaring tool keeps its structured output) — the compact text is simply the JSON encoding of that same payload;
  • interceptors, the tool-result cache and limits.tool_result_bytes all keep seeing the raw handler result: the conversion happens after the interceptor chain, right before the SDK would have formatted the result. The size limit's byte budget therefore measures the same thing it did before — the encoded result simply ends up smaller.

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: tool-call 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.

Decorating tool results

Interceptors see the raw handler result, so they cannot add content next to it without re-implementing result formatting. A result decorator gets the formatted CallToolResult instead — text in the configured result_json mode, structuredContent by the rules of the request's protocol revision — and returns it with whatever it adds: a ResourceLink per item of a search result, an image, a note.

use Mcp\Schema\Content\ResourceLink;
use Mcp\Schema\Result\CallToolResult;
use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallContext;
use Rasuvaeff\Yii3Mcp\Interceptor\ToolResultDecoratorInterface;

final readonly class CreatorLinks implements ToolResultDecoratorInterface
{
    public function decorate(CallToolResult $result, ToolCallContext $context): CallToolResult
    {
        $items = is_array($result->structuredContent) ? ($result->structuredContent['items'] ?? []) : [];
        if ($context->toolName !== 'search_creators' || $result->isError || !is_array($items)) {
            return $result;
        }

        $links = [];
        foreach ($items as $item) {
            $links[] = new ResourceLink(uri: 'app://creator/' . $item['slug'], name: $item['slug']);
        }

        return new CallToolResult([...$result->content, ...$links], $result->isError, $result->structuredContent, $result->meta);
    }
}
'rasuvaeff/yii3-mcp' => [
    // FQCNs resolved through the container, applied in order; a class that
    // does not implement ToolResultDecoratorInterface fails the build
    'result_decorators' => [CreatorLinks::class],
],
  • Decorators run after the whole interceptor chain (budget, interceptors, the result cache, the size limit) on every successful tools/call — cache hits included, since the cache stores the raw result.
  • What a decorator adds does not count against limits.tool_result_bytes, which measures the raw result inside the chain.
  • Keep structuredContent unless the tool's outputSchema still describes the new value: clients validate it.
  • A multi round-trip ask (input_required) is never decorated; the final round's result is. A ToolCallException never reaches a decorator (the SDK turns it into an error envelope after the handler); a handler that returns CallToolResult::error() itself does — check $result->isError.
  • In pretty mode the package builds the result exactly as the SDK would (formatResult() + extractStructuredContent() for the request's revision), but only when at least one decorator is configured; the SDK's warning about a value that cannot be structuredContent despite an outputSchema is not logged on that path (nor in compact mode).
  • McpServerFactory::create(resultDecorators: [...]) takes them directly without yiisoft/config.

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 gets exactly the JSON-RPC error a missing tool gets (-32602, Tool not found: "…", checked before the arguments are validated, so an invalid call does not reveal the schema either), and the call never reaches the interceptor chain or the tool. The same holds for hidden prompts, resources and completion refs. 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; when the API is the application itself, an in-process executor avoids the loopback HTTP round-trip (see In-process execution below).

// 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
        // opt a path parameter into "/"-separated values (GitLab's
        // "group/project"); empty default = every path argument is
        // single-segment, exactly as before
        'multi_segment_path_params' => ['id' => 3],
        // array-valued query parameters (`type: array` in the spec): how they
        // are written on the wire — 'repeat' (name=a&name=b, the OpenAPI
        // default), 'brackets' (name[]=a&name[]=b, what PHP reads back as an
        // array) or 'comma' (name=a,b); empty = per parameter from its own
        // `explode` (false => comma, else repeat)
        'array_query_style' => 'brackets',
        // per-operation, per-parameter override of the above
        'array_query_params' => ['getCreators' => ['platforms' => 'comma']],
        // how bridged operations are executed: 'http' (default) sends a real
        // PSR-18 request to base_url; 'psr15' runs the application's own
        // request handler in-process — see "In-process execution" below
        'executor' => 'http',
    ],
],

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 .. inside it cannot be bridged as a path argument.

Array-valued query parameters

List/search operations take array filters (regions, platforms, niches). A type: array parameter in in: query is bridged as an array argument: the tool's input schema keeps items, enum, minItems and maxItems exactly as the spec declares them, and the executor validates them again before sending (a direct caller cannot smuggle an oversized or non-scalar array past the SDK's schema check). Items must be scalars — objects and nested arrays fail the server build, as do arrays in a path segment; style other than form and allowReserved: true fail it too.

The wire format is the application's contract, so it is configurable. With the default (array_query_style empty) each parameter follows its own declared explode: explode: false is written name=a,b, anything else repeats the key (name=a&name=b, the OpenAPI default). PHP applications that read arrays as name[]=a&name[]=b set array_query_style to brackets — a plain repeated key is read back by PHP's parse_str as the LAST value only. array_query_params overrides the style for one parameter of one operation; an unknown style fails the server build. An empty array sends nothing (an empty filter constrains nothing); minItems rejects it before the call. The same options exist on OpenApiBridgeFactory::create() as arrayQueryStyle and arrayQueryParams.

A parameter listed in multi_segment_path_params may carry that many "/"-separated segments — for an upstream that identifies a resource by a nested path and accepts it percent-encoded (GitLab takes group/project as group%2Fproject). The opt-in buys the separator and nothing else: .., a backslash, an empty value and a bare . stay rejected, every segment must match [A-Za-z0-9_][A-Za-z0-9_.-]*, and the limit must be an integer from 1 to 20 — a string, float or out-of-range value fails at server build time. Opt a parameter in deliberately — the value comes from the MCP client, and a multi-segment value no longer keeps the request on the allow-listed route (1/repository/archive under /projects/{id} reaches an operation the allow-list never exposed). The segment cap is what bounds that, so keep it as low as the upstream's real nesting depth. 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.

In-process execution: bridge the application's own API

When base_url points at the application itself, every bridged call is a real HTTP request back into the same process pool: under php-fpm one call occupies two workers (the MCP request waits for the nested one) and can starve a small pm.max_children, on top of loopback TLS/DNS configuration and per-call latency. executor => 'psr15' removes the network from the path entirely:

'openapi' => [
    // ...
    'executor' => 'psr15',
    // Psr\Http\Server\RequestHandlerInterface FQCN, resolved through the DI
    // container — the application's own handler (typically the console
    // application's). Required for psr15 mode.
    'handler' => App\RequestHandler::class,
    // optional: OpenApi\ExecutionRequestAttributesInterface FQCN — maps the
    // resolved ExecutionIdentity to request attributes on the nested
    // request, so the stack authenticates the user behind the MCP client
    // the usual way (delegated headers are still sent as headers)
    'request_attributes' => App\ExecutionRequestAttributes::class,
    // optional: OpenApi\InProcessScopeInterface FQCN — enter()/leave() hooks
    // the executor runs around the nested handle()
    'in_process_scope' => App\InProcessScope::class,
],

The nested request carries the same method, URI (base_url + substituted path + query), headers (configured defaults, delegated ones, Accept, Content-Type) and JSON body the HTTP executor would send; the response goes through the same incremental size cap, error mapping and opaque_errors handling. base_url keeps shaping the request URI and the dry-run preview — nothing leaves the process, and the PSR-18 client is only touched by a URL spec_path fetch.

Two contracts are the application's to honour:

  • handler is responsible for its own re-entrancy. The outer MCP request's scoped state — current route, current user, a URI already set on the shared request — is still in place when the nested request runs; re-entering the Yii3 stack naively fails with Can not set URI since it was already set or, far worse, serves the nested call under the outer request's identity. Reset what your stack globalizes in InProcessScopeInterface::enter() and restore it in leave(); the executor calls leave() in a finally, so it runs even when the nested call throws, and a stateless scope implementation may be shared.
  • delegated mode keeps working: the delegated header provider's output arrives as headers, and request_attributes (when configured) adds the identity as attributes — both derive from the SAME per-call identity resolution.

Misconfiguration fails the server build: psr15 without handler, any of the three psr15-only keys in http mode, or an unsupported executor value. mcp:doctor reflects the mode — it requires the PSR-17 server-request factory and the configured handler instead of the PSR-18 client for operation execution.

Without a Yii3 application

The bridge is a package feature, not an application one: OpenApiBridgeFactory builds a ready configurator from what any consumer already has, so nothing @internal has to be reached for.

use Rasuvaeff\Yii3Mcp\OpenApi\OpenApiBridgeFactory;

$server = (new McpServerFactory($container, $sessionStore))->create([], [
    OpenApiBridgeFactory::create(
        spec: __DIR__ . '/openapi.json',   // path, http(s) URL, or a decoded document
        baseUrl: 'https://api.example.com',
        httpClient: $httpClient,           // PSR-18
        requestFactory: $psr17,            // PSR-17
        streamFactory: $psr17,
        operations: ['getBlogTags'],
        headers: ['Authorization' => 'Bearer ' . $token],
        safeMethodsOnly: true,
        toolNames: ['getBlogTags' => 'blog_tags_list'],
    ),
]);

A URL spec is fetched with specHeaders (its own credential scope) and cached through specCache when specCacheTtl is above zero. Every other option the openapi params carry — modifier, dryRunOperations, identityProvider, delegatedHeaderProvider, maxResponseBytes, opaqueErrors, multiSegmentPathParams, and the in-process quartet inProcessHandler, serverRequestFactory, requestAttributes, inProcessScope — is a named argument here. The config-plugin path calls this same factory, so both assemble the bridge identically.

What a failing bridged call tells the client

Runtime messages name the tool the client actually called, not the upstream operationId — under tool_names those differ, and the rename is exactly what hid the operationId from the agent:

{"content":[{"type":"text","text":"Tool \"blog_tags_list\" failed with HTTP 404: {\"message\":\"Not Found\"}"}],"isError":true}

The upstream excerpt is suppressed by opaque_errors; argument guards (non-scalar, path-separator, missing path parameter, malformed dryRun) throw InvalidToolArgumentException and are phrased the same way. Only those two types reach the caller — a plain InvalidArgumentException from the PSR-17/PSR-18 stack underneath (an unparseable request URI, a delegated header name that is not RFC 7230 compatible) describes the deployment, not the call, and stays behind the SDK's generic internal error. The one place that still reports the operationId is the dry-run preview payload — it documents the upstream request that would be sent, and the operationId is how a caller looks it up in the OpenAPI document.

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\McpErrorException a JSON-RPC error answered to McpTester: errorCode, errorMessage, errorData
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, clientInfo() (?Mcp\Schema\Implementation, both protocol eras)
Interceptor\ToolCallBudgetInterceptor tools/call cap per session (handshake era) or per client id and window (stateless era): tool_call_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 or in-process execution)
OpenApi\ExecutionRequestAttributesInterface application-implemented contract mapping the resolved ExecutionIdentity to request attributes on a nested in-process call (openapi.request_attributes param, psr15 mode)
OpenApi\InProcessScopeInterface application-implemented contract resetting request-scoped state around a nested in-process call (openapi.in_process_scope param, psr15 mode); leave() runs even when the nested call throws
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 notify($uri, ?$context): publishes notifications/resources/updated to the notification bus (stateless listen streams) and to the calling handshake session if subscribed; never unsolicited
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) + tool-call 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

Pass a revision as the fifth argument to test a specific protocol era, and ClientCapabilities as the sixth to test a tool that asks the user (new ClientCapabilities(elicitation: true); the first call returns the input_required result, answer with request('tools/call', [... 'inputResponses' => [...]])). A handshake revision (ProtocolVersion::V2025_06_18, …) is negotiated through initialize; ProtocolVersion::V2026_07_28 speaks the stateless era — no initialize, no session id, every request carries its own _meta envelope and the Mcp-Method/Mcp-Name/Mcp-Param-* headers, built by the SDK's own client classes (initialize() asks server/discover there). The server must serve that era (McpServerFactory(modernEra: true)):

$modern = new McpTester($server, $psr17, $psr17, $psr17, ProtocolVersion::V2026_07_28);
$modern->callTool('order.status', ['orderId' => '42']);

A JSON-RPC error is thrown as Testing\McpErrorException with the whole envelope — errorCode, errorMessage, errorData — so a test can assert the code, not only the text (-32602 for an unknown or hidden capability).

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.