Skip to content

yii3-mcp

MCP (Model Context Protocol) server integration for Yii3 over the official mcp/sdk: PSR-15 Streamable HTTP endpoint, DI tool registry, stdio transport.

Install

bash
composer require rasuvaeff/yii3-mcp

Rules

  • The MCP endpoint is trusted-only: route it behind SharedSecretMiddleware (empty or wrong secret => explanatory JSON 401/503 — fail-closed) or an explicit network ACL.
  • No protocol structures are invented here: capability attributes (#[McpTool], #[McpResource]) and all JSON-RPC handling come from mcp/sdk (~0.7.0, experimental — tilde pin, minor = breaking).
  • The core registers no tools; the application lists tool FQCNs in params.
  • Default session store is file-based (FPM-safe): MCP sessions span requests, the SDK in-memory default loses them between FPM workers. It is owner-only (Session\PrivateFileSessionStore: 0700 dir, 0600 files, app-specific default dir derived from server_name).
  • Sessions are BOUND to the client that created them: McpAction stamps the resolved client id as an immutable owner at initialize and answers 404 to a POST/DELETE presenting another client's (or an ownerless) Mcp-Session-Id; InterceptingReferenceHandler re-checks it before every capability call and throws Exception\SessionOwnershipException on a mismatch.
  • Capability names are unique across the whole server, enforced: any collision (attribute tools, configurators, OpenAPI bridge, prompts) fails the build with Exception\DuplicateCapabilityException.
  • Protocol revision served is 2025-11-25 (the SDK's default, MessageInterface::PROTOCOL_VERSION); the SDK does NOT negotiate — it answers with its own version whatever the client asks for. Testing\McpTester reads the same constant.
  • resources/subscribe is advertised by the SDK whenever any resource exists and subscriptions ARE recorded per session. Resource\ResourceUpdateNotifier sends notifications/resources/updated to the CALLING session from inside the request that changed the resource (needs the tool's RequestContext); an unsubscribed session gets nothing. Other sessions cannot be reached — under FPM no process outlives the request, so out-of-band push stays impossible.
  • Server-wide params knobs: instructions (text served in initialize, '' omits), pagination_limit (default 50; applied to BOTH the SDK's list handlers and this package's filtering ones so they never page differently), protocol_version ('' = SDK default; an unsupported value throws at config load).
  • Caller-influenced output is bounded BEFORE allocation: upstream bodies (openapi.max_response_bytes, incremental read), substituted prompts (limits.prompt_result_bytes, arithmetic pre-check), spec documents (10 MiB + $ref depth/node budget), tool results (limits.tool_result_bytes).

API reference

Declaring tools (SDK attributes on ordinary Yii3 services)

php
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\McpResource;

final readonly class OrderTools
{
    public function __construct(private OrderRepository $orders) {} // DI works

    #[McpTool(name: 'order.status')]
    public function status(string $orderId): string { ... }   // schema from signature+DocBlock

    // Structured output: declare outputSchema + return an array — the SDK
    // serves the schema in tools/list and mirrors the return value into the
    // result's structuredContent (alongside the text content). An array/object
    // return produces structuredContent even without outputSchema.
    #[McpTool(name: 'order.details', outputSchema: [
        'type' => 'object',
        'properties' => ['status' => ['type' => 'string'], 'total' => ['type' => 'integer']],
        'required' => ['status', 'total'],
    ])]
    public function details(string $orderId): array { ... }

    #[McpResource(uri: 'app://health', name: 'health', mimeType: 'text/plain')]
    public function health(): string { ... }

    #[McpResourceTemplate(uriTemplate: 'app://users/{id}')]
    public function user(string $id): string { ... }

    #[McpPrompt(name: 'style-guide')]
    public function styleGuide(): string { ... }
}

// Markdown prompts, via Prompts\MarkdownPromptsConfigurator (format
// compatible with / inspired by vjik/my-prompts-mcp):
// params 'prompts_path' => '/path/to/prompts' — every *.md file becomes a prompt.
// Frontmatter: name (default: file name), title, description,
// arguments: [{name, description, required} | plain-string-name].
// Body: prompt text with {{argument}} placeholders (missing arg => '',
// undeclared placeholder stays intact). Broken file/duplicate name =>
// Prompts\Exception\InvalidPromptFileException at build time.

// Conditional registration: implement Rasuvaeff\Yii3Mcp\ConditionalToolInterface;
// shouldRegister() false at build time => the whole class is skipped
// (resolve FeatureFlags etc. through the constructor).

// Testing (in-process, no HTTP):
use Rasuvaeff\Yii3Mcp\Testing\McpTester;
$tester = new McpTester($server, $psr17RequestFactory, $psr17ResponseFactory, $psr17StreamFactory);
$tester->callTool('greet', ['name' => 'Yii']);   // decoded result envelope
$tester->listTools(); $tester->listResources(); $tester->listResourceTemplates(); $tester->listPrompts();
// list* methods follow nextCursor and return every page
$tester->readResource('app://x'); $tester->request('custom/method');
// JSON-RPC error => RuntimeException

// Schema contract canary (drift in served schemas fails the build):
use Rasuvaeff\Yii3Mcp\Testing\SchemaSnapshot;
SchemaSnapshot::verify($tester, __DIR__ . '/mcp-schema.json');
// verify(): missing file OR mismatch => RuntimeException with per-section
// drift summary ("tools: changed [greet]") — use in CI.
// assert(): missing file => generated (first run passes), else like verify().
// record(): deliberately (re)writes the file. Env MCP_SNAPSHOT_RECORD=1
// (any value except ''/'0') switches assert()/verify() into record mode —
// the regeneration path; CI must not set it.
// SchemaSnapshot::capture($tester) returns the normalized array
// {tools, resources, resourceTemplates, prompts}.

Tool annotations and server-initiated communication

Use the official SDK metadata directly; yii3-mcp does not define parallel hint attributes. Hints are advisory client metadata, never authorization or a server-side safety policy:

php
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 { ... }

For progress, client logging, sampling or elicitation, accept the SDK's request-scoped Mcp\Server\RequestContext as a method parameter. The SDK injects it and omits it from the generated input schema. Do not constructor- inject or persist it across requests.

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

public function deploy(string $version, RequestContext $context): string
{
    $client = $context->getClientGateway();
    $client->progress(progress: 1, total: 2, message: 'Validated'); // no-op without progress token

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

    $result = $client->elicit(
        message: "Deploy {$version}?",
        requestedSchema: new ElicitationSchema(
            properties: ['confirmed' => new BooleanSchemaDefinition(title: 'Confirm deployment')],
            required: ['confirmed'],
        ),
    );

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

    return 'queued';
}

ClientGateway::log() emits a notification. sample() and elicit() suspend the tool Fiber until a supporting client responds or the SDK timeout expires. For destructive operations, check capability support and fail closed.

McpServerFactory

php
use Rasuvaeff\Yii3Mcp\McpServerFactory;

$factory = new McpServerFactory(
    container: $psr11Container,        // resolves tool instances lazily
    sessionStore: $sessionStore,       // Mcp\Server\Session\SessionStoreInterface
    name: 'my-app',
    version: '1.0.0',
    logger: null,                      // ?LoggerInterface
);
$server = $factory->create([OrderTools::class]);  // Mcp\Server
// throws Rasuvaeff\Yii3Mcp\Exception\InvalidToolClassException on unknown
// class or class without capability attributes

McpAction (PSR-15)

php
use Rasuvaeff\Yii3Mcp\McpAction;

new McpAction(server: $server, responseFactory: $psr17, streamFactory: $psr17);
// handle(ServerRequestInterface): ResponseInterface — Streamable HTTP transport
// route POST/GET/DELETE/OPTIONS /mcp to it

SharedSecretMiddleware

php
use Rasuvaeff\Yii3Mcp\SharedSecretMiddleware;

new SharedSecretMiddleware(
    secret: 's3cret',                  // '' => every request gets 503 + explanation (fail-closed)
    responseFactory: $psr17,
    headerName: 'X-Mcp-Secret',        // default
    resolver: null,                    // OR Identity\SecretResolverInterface (mutually exclusive with secret)
);
// wrong/missing header => 401, никогда не пропускает без секрета

// Several clients + secret rotation (params 'client_secrets' builds this):
use Rasuvaeff\Yii3Mcp\Identity\StaticSecretResolver;
new StaticSecretResolver([
    'ci' => 'ci-secret',
    'claude' => ['old-secret', 'new-secret'],  // both ACTIVE during rotation
]);
// hash_equals per candidate; resolve(presented) => client id | null.
// Resolved id => request attribute SharedSecretMiddleware::CLIENT_ID_ATTRIBUTE,
// carried per-request by internal Identity\ClientIdentityContext (the SDK hands
// handlers the JSON-RPC request, not the PSR-7 one), exposed as
// ToolCallContext::$clientId and mirrored into the session
// (InterceptingReferenceHandler::CLIENT_ID_SESSION_KEY). Raw secret never
// travels past the middleware. Single secret = client id "default".
// stdio => $clientId null.

McpServeCommand (stdio, symfony/console)

mcp:serve — runs the server on StdioTransport for Claude Code/Desktop.

McpListCommand (console introspection)

mcp:list — prints every served tool/resource/resource-template/prompt with argument summaries (name* = required) via the in-process JSON-RPC path (no MCP client needed). Constructor: Server + PSR-17 ServerRequestFactory/ ResponseFactory/StreamFactory (must be in the container). mcp:list --json — full capability definitions (input/output schemas included) as normalized JSON (SchemaSnapshot format: stable item order, sorted object keys) for CI diffs and external automation.

McpDoctorCommand (configuration health check)

mcp:doctor — runs Doctor\McpDoctor: endpoint secret, optional expected HTTP host, exact conditional PSR-17/18/16 bindings, session storage, OpenAPI spec, and real server build. Output never contains the secret or header values. Exit codes (stable): 0 healthy, 2 config, 3 storage, 4 upstream — the category of the FIRST failing check (diagnosis order = root causes first). --json — machine-readable {healthy, exitCode, checks: [{name, category, status, details}]} (Doctor\CheckStatus: pass|skip|fail; Doctor\CheckCategory: config|storage|upstream; DoctorReport/CheckResult expose toArray()). --probe — allow network: fetch a URL OpenAPI spec; without it the command is fully local (URL spec fetch and the eager-loading server build are reported as skipped). DI: Doctor\McpDoctor is bound in config/di.php from the package params.

OpenAPI bridge (expose an existing REST API)

php
use Rasuvaeff\Yii3Mcp\OpenApi\HttpOperationExecutor;
use Rasuvaeff\Yii3Mcp\OpenApi\OpenApiServerConfigurator;
use Rasuvaeff\Yii3Mcp\OpenApi\SpecIndex;

$configurator = new OpenApiServerConfigurator(
    spec: SpecIndex::fromFile('/path/openapi.json'),   // or fromJson()/new SpecIndex(array)
    // or over HTTP (auth headers included): (new SpecLoader($psr18, $psr17, $headers))->fromUrl($url)
    executor: new HttpOperationExecutor(
        httpClient: $psr18, requestFactory: $psr17, streamFactory: $psr17,
        baseUrl: 'https://api.example.com',
        defaultHeaders: ['Authorization' => 'Bearer ...'],
    ),
    operations: ['getBlogTags'],   // allow-list of operationIds; empty = nothing
    safeMethodsOnly: true,         // reject non-GET at build time (read-only bridge)
    toolNames: ['getBlogTags' => 'blog_tags_list'],   // optional operationId => tool name
    modifier: $myOperationModifier,   // optional OperationModifierInterface
    dryRunOperations: ['createSubscriber'],   // optional: adds a `dryRun` boolean argument
);
$server = $factory->create([], [$configurator]);  // ServerConfiguratorInterface
  • Tool name = operationId, or its toolNames/tool_names override — allow-list, handler execution and delegated headers stay keyed by operationId, only the served name changes; interceptors/visibility must reference the RENAMED name. description = summary/description, inputSchema from path/query parameters (+ body argument for application/json requestBody). Local #/components/... $refs are resolved inline (chains of up to 32 hops); external (URL/file) $refs pass through unresolved.
  • outputSchema is advertised in tools/list when the operation's lowest concrete 2xx response has an application/json schema of type "object" (OpenAPI 3.1's type: ["object", "null"] accepted the same way; $refs resolved; canonicalized to type/properties/required/ additionalProperties/description, always to plain "object"). Array/scalar responses and 2XX wildcards are not advertised; JSON object payloads still arrive as structuredContent either way.
  • Calls are real HTTP requests against the API — its middleware stack (validation, rate limiting, auth) applies. Non-2xx => OperationFailedException => MCP tool error envelope. The message carries at most 2000 bytes of the upstream body, cut on a character boundary; a body that is not valid UTF-8 is replaced by <non-UTF-8 response body, N bytes> (the envelope must stay JSON-encodable).
  • Static defaultHeaders are service-token mode: upstream does NOT inherit the MCP caller/RBAC identity. Do not expose tenant/user data with a broader token. Delegated mode configures both ExecutionIdentityProviderInterface and DelegatedHeaderProviderInterface; identity is immutable, headers resolve per call, provider failure is fail-closed, raw MCP secrets are never passed.
  • Unknown operationId => UnknownOperationException at build time.
  • Non-GET operation with safeMethodsOnly => UnsafeOperationException at build time.
  • A path and a query parameter sharing one name, or a parameter named body alongside a request body => InvalidSpecException at build time (tool arguments are keyed by name only).
  • URL parameters must have explicit scalar string/integer/number/boolean schemas (an empty schema defaults to string) and standard serialization (simple path, form query). Header/cookie parameters, external/non-scalar schemas, custom style/explode and allowReserved=true => InvalidSpecException when selected. Put fixed upstream headers in HttpOperationExecutor::defaultHeaders; use a custom tool for richer input.
  • Duplicate operationId values => InvalidSpecException while indexing.
  • operationId (or its toolNames rename) must match ^[A-Za-z0-9._/-]{1,64}$ to become a tool name => InvalidSpecException when selected (mcp/sdk itself only logs a warning and registers the tool anyway on a mismatch).
  • toolNames maps operationId => tool name; an operationId in toolNames absent from operations => InvalidArgumentException at build time; two operations resolving to the same final name => InvalidSpecException.
  • OperationModifierInterface::modify(Operation $operation, Tool $tool): Tool runs once per bridged operation, after the toolNames rename — returns a changed Tool (description, annotations, a further name change). A name change is validated and checked for collisions exactly like a toolNames rename (same exceptions). Operation is a read-only @api VO (operationId, method, path, description, parameters, requestBodySchema, requestBodyRequired, outputSchema) — construct it yourself only in tests. Config: openapi.operation_modifier (FQCN, DI-resolved).
  • Every GET operation is served with annotations: {readOnlyHint: true} — no configuration. OpenAPI tags are propagated into the tool's _meta ({"rasuvaeff/yii3-mcp": {"tags": [...]}}); read by declarative tag: visibility patterns (see below).
  • A null argument for a path/query parameter is skipped like an omitted one; scalar parameter schemas also accept OpenAPI 3.1's nullable union notation ({"type": ["string", "null"]}) alongside the plain 3.0 type string.
  • A path argument is rejected at call time when it is empty or ., or when it CONTAINS .., / or \ (route escape out of the allow-list: %2F is decoded back into a separator by some upstreams, so ../.. climbs up too; "" turns an item route into the collection route). Single dots are fine (v1.2). The base URL must not embed credentials (userinfo) or carry a query string/fragment — dry-run previews return the full URL to the caller.
  • Via config-plugin: params openapi.spec_path (file path or http(s) URL) / base_url / operations / tool_names / operation_modifier / headers (operation calls only) / spec_headers (spec fetch only, empty default — separate credential scopes so an API token never reaches a foreign spec host; a spec URL with userinfo is rejected) / cache_ttl (PSR-16, raw document, 0 disables) / max_response_bytes (upstream body cap, incremental read, default 4 MiB) / opaque_errors (suppress upstream error bodies) / identity_provider / delegated_header_provider / dry_run (list of operationIds).
  • Dry-run: an operationId in dry_run (or dryRunOperations) gets an extra dryRun: boolean inputSchema argument. Calling with dryRun: true returns the planned request (operationId, method, url, body) as plain text — never as structuredContent, so it never conflicts with the operation's declared outputSchema — without sending it and without upstream headers leaving the process. Checked twice, fail-closed: an operationId absent from dry_run ignores a dryRun argument entirely and always executes for real. On a dry-run-enabled operation a non-boolean dryRun value is rejected with an error, never executed for real. Orthogonal to safeMethodsOnly — does not expose an operation the safety gate would otherwise reject. An unknown operationId in dry_run => InvalidArgumentException at build time (same as toolNames). A dry-run call still passes through the full interceptor chain (session budget, RBAC/audit, caching, size limit).

Argument autocompletion (completion/complete)

php
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 { ... }
  • Exactly ONE of values / enum / provider per argument (the SDK attribute throws otherwise). provider is a Mcp\Capability\Completion\ProviderInterface FQCN resolved through the DI container, so it can query application services.
  • The completions capability is advertised automatically; no params key.
  • Completions respect prompt_visibility / resource_visibility: a hidden prompt or template completes nothing and answers "not found", byte-identical to a missing one (Visibility\FilteredCompletionCompleteHandler).
  • Interceptors do NOT wrap completion/complete — it is a metadata lookup, not a capability call. Put authorization in the visibility filter.

Resource update notifications

php
use Rasuvaeff\Yii3Mcp\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);  // true if subscribed

    return 'cancelled';
}
  • Requires the SDK's request-scoped RequestContext (a method parameter; the SDK keeps it out of the generated input schema).
  • Checks the subscription first: an unsolicited notifications/resources/updated never reaches a client that did not subscribe. Returns whether it was sent.
  • Reaches ONLY the calling session. SubscriptionManagerInterface is bound in config/di.php (default SessionSubscriptionManager) and shared with the SDK's subscribe/unsubscribe handlers — swap the binding and both sides follow.
  • Sends by suspending the handler Fiber; outside a Fiber ClientGateway::notify() throws, so test it inside new Fiber(...), not through Testing\McpTester (once a handler notifies, the transport streams to output and the PSR-7 body is empty — a known limit of the tester).

MCP Apps (io.modelcontextprotocol/ui)

Interactive HTML apps served as ui:// resources, rendered by the client in a sandboxed iframe. The extension must be announced during the handshake — without it a ui:// resource is just text to the client.

php
// params: declarative apps (no PHP class), snake_case keys
'rasuvaeff/yii3-mcp' => [
    'apps' => [
        'enable' => true,               // announce the extension
        '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],
            'domain' => null,
            'prefers_border' => true,
        ]],
    ],
],

// programmatic equivalent
use Rasuvaeff\Yii3Mcp\Apps\AppDefinition;
use Rasuvaeff\Yii3Mcp\Apps\McpAppsConfigurator;

new McpAppsConfigurator([
    AppDefinition::create(
        uri: 'ui://dashboard',
        name: 'dashboard',
        html: static fn (): string => renderDashboard(),   // or a plain string
        title: 'Dashboard',
        description: 'Sales overview',
        contentMeta: new UiResourceContentMeta(
            csp: new UiResourceCsp(connectDomains: ['api.example.com']),
            permissions: new UiResourcePermissions(geolocation: true),
            prefersBorder: true,
        ),
    ),
]);

Attribute-based app (logic in PHP): declare #[McpResource(uri: 'ui://…', mimeType: McpApps::MIME_TYPE, meta: ['ui' => new \stdClass()])] and return a TextResourceContents carrying meta: ['ui' => new UiResourceContentMeta(…)]. Still needs apps.enable = true — that is what announces the extension. Returning a plain string works but cannot carry _meta.ui.

  • _meta.ui placement is NOT interchangeable: descriptor (resources/list) gets the bare marker {} (McpApps::resourceMarker()), content (resources/read) gets UiResourceContentMeta (csp, permissions, domain, prefersBorder). A policy on the descriptor is ignored.
  • html as a Closure(): string is re-evaluated on EVERY resources/read.
  • Params permissions are plain booleans ('camera' => false means off) and CSP keys are snake_case; the SDK's own fromArray() factories read presence markers / camelCase and are deliberately not used.
  • McpAppsConfigurator is the single enabler: a second enableExtension(new McpApps()) fails the build (SDK rejects a duplicate extension id).
  • App resources are ordinary resources: ResourceVisibilityInterface filters them, resource_interceptors wrap their reads, a colliding ui:// URI fails the build (DuplicateCapabilityException).
  • Tool↔app link: #[McpTool(meta: ['ui' => new UiToolMeta(resourceUri: 'ui://dashboard')])]; ToolVisibility::App hides a tool from the model BY THE HOST — declarative intent, not an access boundary. For a server-side guarantee use Visibility\ToolVisibilityInterface.
  • CSP domains are passed through verbatim (host enforces the policy; definitions is application-owned config, not client input).
  • mcp:doctor reports an mcp_apps check: skip when disabled, fail with the offending definition index when a definition is malformed.

Tool-call interceptors

php
use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallContext;
use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallInterceptorInterface;

final readonly class MyInterceptor implements ToolCallInterceptorInterface
{
    public function intercept(ToolCallContext $context, callable $next): mixed
    {
        // $context->toolName; $context->arguments (array<string, mixed>);
        // $context->session (?SessionInterface); $context->getClientInfo();
        // $context->clientId (?string — identity resolved from the endpoint
        // secret; null on stdio)
        return $next();   // don't call it to short-circuit
    }
}
// params: 'interceptors' => [MyInterceptor::class]  — DI-resolved, first = outermost.
// Wraps EVERY tools/call (attribute tools, OpenAPI bridge, configurators);
// prompts/resources have their OWN chains (see "Hooks for prompts and resources").
// throw Mcp\Exception\ToolCallException => MCP tool-error envelope (agent sees reason);
// other exceptions => opaque internal error.
// Manual wiring: $factory->create($tools, $configurators, $interceptors).

// Session budget (Interceptor\SessionBudgetInterceptor — anti-loop, NOT a
// client quota — new session = new counter):
// params: 'session' => ['budget' => 50]  // 0 = unlimited (default)
// exhausted => tool error "Session tool-call budget of N is exhausted..."

// Result size limit (Interceptor\ResponseSizeLimitInterceptor —
// context-overflow guard) — a string over the limit is truncated with a
// marker (byte budget, but never mid-character; the marker is added on top
// of the budget and reports the bytes actually kept); any other result
// (array/object) throws instead (truncated JSON is invalid JSON):
// params: 'limits' => ['tool_result_bytes' => 0]  // 0 = unlimited (default)

// Result caching (Interceptor\CachingToolCallInterceptor, PSR-16, opt-in by
// tool name — for the OpenAPI bridge, the SERVED name after any tool_names
// rename):
// params: 'cache' => ['tools' => ['blog_tags_list' => 60]]  // toolName => TTL seconds
//         'cache' => ['namespace' => '']  // '' => server_name; isolates apps sharing one backend
// Typed key: mandatory namespace + resolved client id (null = typed absence,
// never the string 'anonymous'; never shared across clients);
// with openapi.identity_provider configured, the resolved ExecutionIdentity
// is part of the key too (delegated credentials => identity-specific
// results, possibly finer-grained than the client id).
// Only successful results are cached; a thrown exception never is. A cache
// read/write failure fails OPEN (the tool runs) — availability, not a
// security gate. An identity provider failure fails CLOSED for cached
// tools (serving a result without knowing whose it is = the leak the key
// prevents).
// Chain order: session budget → configured interceptors → caching → size
// limit (innermost). Configured interceptors (RBAC, audit) run on EVERY
// call, including a cache hit — no ACL bypass through the cache. The size
// limit only runs on a cache miss; the already-limited value is what gets
// cached.

// Per-client/per-tool limits — delegate to YOUR rate limiter (package ships
// no limiter storage):
use Rasuvaeff\Yii3Mcp\Interceptor\RateLimitInterceptor;
use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallLimiterInterface;
// implement ToolCallLimiterInterface::allow(?string $clientId, string $toolName): bool
// over yiisoft/rate-limiter / Redis; bind it in DI; add RateLimitInterceptor
// to 'interceptors'. Keys: resolved client id + tool; a transport without
// identity (stdio) passes null — typed absence, never a reserved string.
// false => ToolCallException "Rate limit exceeded"; limiter throws =>
// fail-closed ToolCallException (enforced quota never silently unlimited).

// Retrying transient failures — package ships no retry logic (a blanket
// retry duplicates side effects on a non-idempotent tool). Recipe: wrap
// rasuvaeff/retry's Retry::new()->run($next) in your own
// ToolCallInterceptorInterface, scoped to an explicit allow-list of
// verified-idempotent tool names and to transient exception types only
// (retryOn()). Place it near the end of 'interceptors' (closer to the
// tool) so earlier interceptors (e.g. RateLimitInterceptor) wrap the whole
// retry loop instead of re-triggering per attempt.

// Masking sensitive arguments before logging/tracing/auditing:
use Rasuvaeff\Yii3Mcp\Interceptor\ArgumentMasker;
$masker = new ArgumentMasker();               // or new ArgumentMasker(['ssn', ...])
$safe = $masker->mask($context->arguments);   // sensitive keys => '***'
// defaults: password, secret, token, api_key, apikey, api-key, x-api-key,
// credit_card;
// case-insensitive exact key match, applied at EVERY nesting level;
// a sensitive key's whole value (array included) becomes '***'.

Tool visibility

php
// Declarative (typical case — no code): tool-name patterns, '*' = wildcard.
// params: 'visibility' => ['deny' => ['admin.*'], 'allow' => []]
// deny hides matches; non-empty allow hides everything it does not match;
// deny wins over allow; both empty (default) = all visible.
// A 'tag:' prefix matches tags from the tool's _meta instead of its name
// (e.g. 'tag:admin' — set by the OpenAPI bridge from OpenAPI `tags`); a tool
// with no tags never matches a 'tag:' pattern. Tags come from the OpenAPI
// document, so over a URL spec a 'tag:' DENY rule can be disarmed upstream by
// dropping the tag — deny by name there, keep 'tag:' for allow-lists.
// Manual: new Rasuvaeff\Yii3Mcp\Visibility\DeclarativeToolVisibility(deny: [...], allow: [...])

// Per-session (admin vs public client, tenant plans) — implement the interface:
use Mcp\Schema\Tool;
use Mcp\Server\Session\SessionInterface;
use Rasuvaeff\Yii3Mcp\Visibility\ToolVisibilityInterface;

final readonly class MyVisibility implements ToolVisibilityInterface
{
    public function isVisible(Tool $tool, ?SessionInterface $session): bool { ... }
}
// params: 'tool_visibility' => MyVisibility::class  ('' = off, DI-resolved)
// 'tool_visibility' and 'visibility' are mutually exclusive => LogicException at build.
// Applies to tools/list (invisible tools omitted) AND tools/call (fail-closed:
// calling a hidden tool => tool error "not available in this session";
// the call never reaches interceptors or the tool).
// ConditionalToolInterface = build-time global; this = per-session.
// Manual wiring: $factory->create($tools, $configurators, $interceptors, $visibility).

// Multi-tenant (rasuvaeff/yii3-tenancy): route middleware order
// SharedSecretMiddleware -> TenantResolutionMiddleware -> McpAction;
// per-tenant session isolation = bind SessionStoreInterface to a
// FileSessionStore with a per-tenant directory. Secret stays global
// (trusted-only endpoint model); per-tenant secrets are a planned extension.

Hooks for prompts and resources

php
// Same seams for the other capabilities — SEPARATE interfaces and params:
use Rasuvaeff\Yii3Mcp\Interceptor\PromptGetContext;        // ->promptName, ->arguments, ->session, ->clientId, ->getClientInfo()
use Rasuvaeff\Yii3Mcp\Interceptor\PromptGetInterceptorInterface;
use Rasuvaeff\Yii3Mcp\Interceptor\ResourceReadContext;     // ->uri, ->variables (RFC 6570), ->uriTemplate (null = static), ->session, ->clientId
use Rasuvaeff\Yii3Mcp\Interceptor\ResourceReadInterceptorInterface;
use Rasuvaeff\Yii3Mcp\Visibility\PromptVisibilityInterface;    // isVisible(Prompt, ?Session)
use Rasuvaeff\Yii3Mcp\Visibility\ResourceVisibilityInterface;  // isVisible(ResourceDefinition, ?Session) + isTemplateVisible(ResourceTemplate, ?Session)

// params (all DI-resolved; interceptor lists first = outermost):
// 'prompt_interceptors' => [...], 'resource_interceptors' => [...],
// 'prompt_visibility' => FQCN|'', 'resource_visibility' => FQCN|''
// Rejecting: throw Mcp\Exception\PromptGetException / ResourceReadException
//   => client sees the message.
// Hiding: visibility (or throw PromptNotFoundException/ResourceNotFoundException)
//   => "not found", indistinguishable from a missing capability; the call never
//   reaches interceptors or the handler. Visibility also filters prompts/list,
//   resources/list and resources/templates/list with the SAME implementation.
// Manual wiring: $factory->create($tools, $configurators, $interceptors,
//   $toolVisibility, $promptInterceptors, $resourceInterceptors,
//   $promptVisibility, $resourceVisibility).

// Shared outcome vocabulary for audit/telemetry bridges:
use Rasuvaeff\Yii3Mcp\Interceptor\CallOutcome;   // Success|Rejected|Error ('success'/'rejected'/'error')
CallOutcome::fromThrowable($e);   // ToolCallException/PromptGetException/ResourceReadException
                                  // => Rejected; anything else => Error
  • completion/complete (argument autocompletion via #[CompletionProvider] on a prompt argument or resource-template variable) is served by the SDK DIRECTLY off the registry, bypassing the reference handler. yii3-mcp decorates it so the configured prompt_visibility / resource_visibility applies: a hidden prompt/template answers "not found", byte-identical to a missing one. Interceptors still do NOT wrap completion — it is a metadata lookup, not a capability call.

DI (config-plugin)

Ships config/di.php + config/params.php. Binds SessionStoreInterface (Session\PrivateFileSessionStore — 0700 dir/0600 files, dir from session.dir param or an app-specific sys-temp default), Server (via factory + tools param), McpServerFactory, SharedSecretMiddleware, McpAction (with the session store, for session-ownership enforcement). Params key rasuvaeff/yii3-mcp: server_name, server_version, tools, endpoint_secret, client_secrets (client id => secret|list of active secrets; mutually exclusive with endpoint_secret; a secret shared by two client ids is rejected), secret_header, allowed_hosts, session.dir, session.ttl, session.budget, limits.tool_result_bytes, limits.prompt_result_bytes, cache.tools, cache.namespace, interceptors, prompt_interceptors, resource_interceptors, configurators, tool_visibility, prompt_visibility, resource_visibility, visibility.deny, visibility.allow, prompts_path, openapi.spec_path, openapi.base_url, openapi.operations, openapi.tool_names, openapi.operation_modifier, openapi.headers, openapi.spec_headers, openapi.max_response_bytes, openapi.opaque_errors, openapi.cache_ttl, openapi.identity_provider, openapi.delegated_header_provider, openapi.safe_methods_only, expected_http_host.

configurators: list of ServerConfiguratorInterface FQCNs, DI-resolved, applied after the core's prompts/openapi configurators — generic extension point (companion packages, app-specific server setup). A configurator may also implement ReservedToolNamesAwareInterface: McpServerFactory then calls withReservedToolNames() with the names of the #[McpTool] methods before configure(), so it can reject a colliding name instead of losing its own tool (the SDK registry is last-write-wins and registers attribute tools last).