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
composer require rasuvaeff/yii3-mcpRules
- 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 frommcp/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;
InterceptingReferenceHandlerre-checks it before every capability call and throwsException\SessionOwnershipExceptionon 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\McpTesterreads the same constant. resources/subscribeis advertised by the SDK whenever any resource exists and subscriptions ARE recorded per session.Resource\ResourceUpdateNotifiersendsnotifications/resources/updatedto the CALLING session from inside the request that changed the resource (needs the tool'sRequestContext); 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)
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:
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.
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
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 attributesMcpAction (PSR-15)
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 itSharedSecretMiddleware
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)
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_namesoverride — 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 (+bodyargument 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 and2XXwildcards 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
defaultHeadersare 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 =>
UnknownOperationExceptionat build time. - Non-GET operation with
safeMethodsOnly=>UnsafeOperationExceptionat build time. - A path and a query parameter sharing one name, or a parameter named
bodyalongside a request body =>InvalidSpecExceptionat build time (tool arguments are keyed by name only). - URL parameters must have explicit scalar
string/integer/number/booleanschemas (an empty schema defaults to string) and standard serialization (simplepath,formquery). Header/cookie parameters, external/non-scalar schemas, custom style/explode and allowReserved=true =>InvalidSpecExceptionwhen selected. Put fixed upstream headers inHttpOperationExecutor::defaultHeaders; use a custom tool for richer input. - Duplicate operationId values =>
InvalidSpecExceptionwhile indexing. - operationId (or its
toolNamesrename) must match^[A-Za-z0-9._/-]{1,64}$to become a tool name =>InvalidSpecExceptionwhen selected (mcp/sdk itself only logs a warning and registers the tool anyway on a mismatch). toolNamesmaps operationId => tool name; an operationId intoolNamesabsent fromoperations=>InvalidArgumentExceptionat build time; two operations resolving to the same final name =>InvalidSpecException.OperationModifierInterface::modify(Operation $operation, Tool $tool): Toolruns once per bridged operation, after thetoolNamesrename — returns a changed Tool (description, annotations, a further name change). A name change is validated and checked for collisions exactly like atoolNamesrename (same exceptions).Operationis a read-only@apiVO (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. OpenAPItagsare propagated into the tool's_meta({"rasuvaeff/yii3-mcp": {"tags": [...]}}); read by declarativetag:visibility patterns (see below). - A
nullargument 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:%2Fis 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(ordryRunOperations) gets an extradryRun: booleaninputSchema argument. Calling withdryRun: truereturns the planned request (operationId,method,url,body) as plain text — never asstructuredContent, so it never conflicts with the operation's declaredoutputSchema— without sending it and without upstream headers leaving the process. Checked twice, fail-closed: an operationId absent fromdry_runignores adryRunargument entirely and always executes for real. On a dry-run-enabled operation a non-booleandryRunvalue is rejected with an error, never executed for real. Orthogonal tosafeMethodsOnly— does not expose an operation the safety gate would otherwise reject. An unknown operationId indry_run=>InvalidArgumentExceptionat build time (same astoolNames). A dry-run call still passes through the full interceptor chain (session budget, RBAC/audit, caching, size limit).
Argument autocompletion (completion/complete)
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/providerper argument (the SDK attribute throws otherwise).provideris aMcp\Capability\Completion\ProviderInterfaceFQCN resolved through the DI container, so it can query application services. - The
completionscapability 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
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/updatednever reaches a client that did not subscribe. Returns whether it was sent. - Reaches ONLY the calling session.
SubscriptionManagerInterfaceis bound inconfig/di.php(defaultSessionSubscriptionManager) 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 insidenew Fiber(...), not throughTesting\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.
// 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.uiplacement is NOT interchangeable: descriptor (resources/list) gets the bare marker{}(McpApps::resourceMarker()), content (resources/read) getsUiResourceContentMeta(csp, permissions, domain, prefersBorder). A policy on the descriptor is ignored.htmlas aClosure(): stringis re-evaluated on EVERYresources/read.- Params permissions are plain booleans (
'camera' => falsemeans off) and CSP keys are snake_case; the SDK's ownfromArray()factories read presence markers / camelCase and are deliberately not used. McpAppsConfiguratoris the single enabler: a secondenableExtension(new McpApps())fails the build (SDK rejects a duplicate extension id).- App resources are ordinary resources:
ResourceVisibilityInterfacefilters them,resource_interceptorswrap their reads, a collidingui://URI fails the build (DuplicateCapabilityException). - Tool↔app link:
#[McpTool(meta: ['ui' => new UiToolMeta(resourceUri: 'ui://dashboard')])];ToolVisibility::Apphides a tool from the model BY THE HOST — declarative intent, not an access boundary. For a server-side guarantee useVisibility\ToolVisibilityInterface. - CSP domains are passed through verbatim (host enforces the policy;
definitionsis application-owned config, not client input). mcp:doctorreports anmcp_appscheck: skip when disabled, fail with the offending definition index when a definition is malformed.
Tool-call interceptors
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
// 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
// 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 => Errorcompletion/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 configuredprompt_visibility/resource_visibilityapplies: 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).