rasuvaeff / yii3-mcp
MCP server integration for Yii3: PSR-15 Streamable HTTP endpoint, DI tool registry, and stdio transport over the official mcp/sdk
Requires
- php: 8.3 - 8.5
- mcp/sdk: ~0.7.0
- psr/container: ^2.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^2.0 || ^3.0
- psr/simple-cache: ^2.0 || ^3.0
- symfony/console: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
- symfony/yaml: ^7.0 || ^8.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/test-support: ^3.0
Suggests
- guzzlehttp/guzzle: PSR-18 HTTP client for the OpenAPI bridge executor and spec loader
This package is auto-updated.
Last update: 2026-07-29 18:56:23 UTC
README
Model Context Protocol server integration
for Yii3 over the official mcp/sdk
(PHP Foundation + Symfony): expose your application's domain operations as MCP
tools/resources for AI agents (Claude Code, Claude Desktop, …) through a
PSR-15 Streamable HTTP endpoint, with tools resolved through the Yii3 DI
container.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model. Contributors: see AGENTS.md. Projects using the llm/skills Composer plugin also get this package's agent skill synced into
.agents/skills/automatically on install.
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.3 – 8.5 |
mcp/sdk |
~0.7.0 (experimental until 1.0 — hence the tilde pin) |
| MCP protocol | 2025-11-25 (the SDK's default; it advertises this in initialize regardless of what the client asks for) |
ext-fileinfo |
required by the SDK |
Installation
composer require rasuvaeff/yii3-mcp
Usage
1. Declare a tool
Tools are ordinary Yii3 services. Capability methods are annotated with the SDK's own attributes — this package invents no protocol structures:
use Mcp\Capability\Attribute\McpTool; final readonly class OrderTools { public function __construct(private OrderRepository $orders) {} /** * Returns the current status of an order. */ #[McpTool(name: 'order.status')] public function status(string $orderId): string { return $this->orders->get($orderId)->status->value; } }
Input schemas are generated by the SDK from the method signature and DocBlock.
#[McpResource], #[McpResourceTemplate] and #[McpPrompt] methods work the
same way — all four SDK capability attributes are recognized.
Structured output
Agents parse typed results far more reliably than prose. Declare an
outputSchema on the attribute and return an array — the SDK serves the
schema in tools/list and mirrors the return value into the result's
structuredContent (alongside the human-readable text content):
/** * @return array{status: string, total: int} */ #[McpTool( name: 'order.status', outputSchema: [ 'type' => 'object', 'properties' => [ 'status' => ['type' => 'string'], 'total' => ['type' => 'integer'], ], 'required' => ['status', 'total'], ], )] public function status(string $orderId): array { $order = $this->orders->get($orderId); return ['status' => $order->status->value, 'total' => $order->total]; }
An array (or JSON-serializable object) return produces structuredContent
even without an outputSchema — declaring the schema is what lets the agent
know the shape up front. Testing\SchemaSnapshot covers output schemas the
same way it covers input schemas, so accidental drift fails the build.
Tool behavior hints
Use the SDK's ToolAnnotations directly when a client should know whether a
tool reads, mutates or reaches outside its closed domain. No yii3-mcp-specific
attributes are needed:
use Mcp\Schema\ToolAnnotations; #[McpTool( name: 'order.cancel', annotations: new ToolAnnotations( readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, ), )] public function cancel(string $orderId): string { $this->orders->cancel($orderId); return 'cancelled'; }
| Hint | Meaning |
|---|---|
readOnlyHint |
true when the tool does not modify its environment |
destructiveHint |
for a mutating tool, distinguishes destructive changes from additive-only changes |
idempotentHint |
for a mutating tool, says repeated calls with the same arguments add no further effect |
openWorldHint |
true when the tool may interact with external entities outside a closed domain |
Annotations are advisory MCP metadata. A client may ignore them, so never use
them in place of authorization, validation, safe_methods_only, visibility or
server-side confirmation. In particular, idempotentHint alone is not enough
to enable automatic retries: keep the server's retry allow-list explicit.
Server-initiated communication
An attribute tool may accept the SDK's request-scoped RequestContext as a
parameter. The SDK creates it for the current MCP request and omits it from the
generated input schema, so the client supplies only real domain arguments.
Obtain its ClientGateway to send progress/log notifications or to initiate
sampling and elicitation:
use Mcp\Schema\Elicitation\BooleanSchemaDefinition; use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Server\RequestContext; #[McpTool( name: 'release.deploy', annotations: new ToolAnnotations( readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, ), )] public function deploy(string $version, RequestContext $context): string { $client = $context->getClientGateway(); $client->progress(progress: 1, total: 2, message: 'Validation complete'); if (!$client->supportsElicitation()) { throw new RuntimeException('Client does not support required deployment confirmation'); } $confirmation = $client->elicit( message: sprintf('Deploy version %s?', $version), requestedSchema: new ElicitationSchema( properties: [ 'confirmed' => new BooleanSchemaDefinition( title: 'Confirm deployment', description: 'Allow this deployment to proceed', ), ], required: ['confirmed'], ), ); if (!$confirmation->isAccepted() || ($confirmation->content['confirmed'] ?? false) !== true) { throw new RuntimeException('Deployment was not confirmed'); } return sprintf('Deployment %s queued', $version); }
progress() is a no-op when the caller did not provide a progress token.
log() emits client-visible log notifications; sample() and elicit() are
round trips that suspend the tool Fiber until the client responds or the SDK
timeout expires. Check supportsElicitation() before requiring elicitation,
choose a fail-closed fallback for destructive operations, and assume that not
every MCP client supports every server-initiated capability. Keep
RequestContext as a method parameter rather than a constructor dependency:
it belongs to one request/session.
To gate a capability class (feature flag, environment check), implement
ConditionalToolInterface — the instance is resolved through the container
at build time and skipped when shouldRegister() returns false:
final readonly class BetaTools implements ConditionalToolInterface { public function __construct(private FeatureFlags $flags) {} public function shouldRegister(): bool { return $this->flags->isEnabled('mcp-beta-tools'); } #[McpTool(name: 'beta.op')] public function betaOp(): string { ... } }
2. Register it
// config/params.php return [ 'rasuvaeff/yii3-mcp' => [ 'server_name' => 'my-app', 'server_version' => '1.0.0', 'tools' => [OrderTools::class], 'endpoint_secret' => getenv('MCP_SECRET'), ], ];
Handlers are registered as [class, method] references — the SDK resolves the
instance through the Yii3 container on call, so constructor dependencies are
injected the normal way.
3. Route the endpoint
// config/routes.php Route::methods(['POST', 'GET', 'DELETE', 'OPTIONS'], '/mcp') ->middleware(SharedSecretMiddleware::class) ->action(McpAction::class),
An MCP client connects with the secret header:
{
"mcpServers": {
"my-app": {
"type": "http",
"url": "https://example.com/mcp",
"headers": { "X-Mcp-Secret": "..." }
}
}
}
stdio for local development
// add McpServeCommand to your console commands ./yii mcp:serve
Claude Code config: claude mcp add my-app -- ./yii mcp:serve.
Introspection: what is actually served
mcp:list prints every registered tool, resource, resource template and
prompt — with argument summaries (name* = required) — without an MCP client.
It goes through the same in-process JSON-RPC path a real client uses, so
attribute tools, OpenAPI-bridged operations and Markdown prompts all show up:
// add McpListCommand to your console commands ./yii mcp:list ./yii mcp:list --json # full definitions as normalized JSON
--json prints the complete capability definitions (input/output schemas
included) in the SchemaSnapshot normalized form — item order and object
keys are stable, so the output diffs cleanly in CI and feeds external
automation.
The listing is the default (unauthenticated) view — the command drives a synthetic session with no client identity, and says so in its output: with per-session visibility or RBAC wired in, a real client may see a different capability set.
The command (like McpTester) needs PSR-17 factories in the container. Keep
these services in every config group that builds Mcp\Server, including the
console group:
| Entry point / feature | Required services |
|---|---|
McpAction |
ResponseFactoryInterface, StreamFactoryInterface |
McpListCommand, McpTester |
ServerRequestFactoryInterface, ResponseFactoryInterface, StreamFactoryInterface |
| URL OpenAPI spec | PSR-18 ClientInterface, PSR-17 RequestFactoryInterface |
| OpenAPI operation execution | PSR-18 ClientInterface, PSR-17 RequestFactoryInterface, StreamFactoryInterface |
ServerRequestFactoryInterface and RequestFactoryInterface are distinct
PSR-17 contracts; binding one does not satisfy the other.
Diagnostics: mcp:doctor
mcp:doctor checks the MCP server configuration end-to-end and reports each
check as pass/skip/fail — the output never contains the configured secret or
header values, and printed URLs are stripped of userinfo credentials
(exception messages from application services pass through truncated, so
treat the report as operator-facing diagnostics):
./yii mcp:doctor # human-readable table ./yii mcp:doctor --json # machine-readable report ./yii mcp:doctor --probe # also fetch a URL OpenAPI spec over the network
Checks include endpoint secret, the optional expected_http_host allow-list,
every PSR service required by enabled entry points/features, session storage
(including confidentiality: a session directory readable by group/others
fails the check, not just an unwritable one), the OpenAPI spec, the MCP Apps
configuration (every declarative definition is parsed, so a malformed one is
reported even when the server build check is skipped) and a real server build. Missing services are reported by
their exact interface. Exit codes are stable for scripting: 0 healthy,
2 config error, 3 storage error, 4 upstream error — the category of the
first failing check (checks run root-causes-first, so a broken config
reports as config even though it also breaks the server build).
Without --probe the command never touches the network: with a URL
spec_path both the spec fetch and the server build (which loads the spec
eagerly) are reported as skipped.
Sessions (important for PHP-FPM)
The MCP Streamable HTTP session spans several HTTP requests (initialize
first, then tools/call with the returned Mcp-Session-Id). The SDK's
default in-memory store would lose the session between FPM workers, so this
package defaults to a file-based store — the shipped
Session\PrivateFileSessionStore keeps it owner-only: the directory is
created 0700 (an application-specific default under sys_get_temp_dir(),
derived from server_name; override via session.dir) and every session
file is clamped to 0600, because session JSON carries client metadata and
everything needed to replay a session id. For multi-host setups rebind the
interface:
// config/common/di/mcp.php use Mcp\Server\Session\Psr16SessionStore; use Mcp\Server\Session\SessionStoreInterface; return [ SessionStoreInterface::class => static fn (CacheInterface $cache) => new Psr16SessionStore($cache), ];
Sessions are bound to the client that created them
The SDK itself only checks that a presented Mcp-Session-Id exists — any
authenticated client could otherwise act inside (or DELETE) another
client's session by replaying its id, which leaks into proxy and client
logs via the HTTP header. When client_secrets (or endpoint_secret) is
configured, McpAction stamps the resolved client id into the session as an
immutable owner at initialize and verifies it on every POST/DELETE
before the transport runs. A foreign — or ownerless — session is answered
with the same 404 the SDK uses for a missing one, indistinguishable from an
expired session. Deployments without client identity (network-ACL-only) are
unaffected.
Prompts from Markdown files
Prompts are content, not code — keep them in a directory and every *.md
file becomes an MCP prompt (edited without a deployment, versioned like any
other file):
'rasuvaeff/yii3-mcp' => [ 'prompts_path' => __DIR__ . '/../resources/prompts', ],
--- name: code-review # defaults to the file name title: Code review assistant description: Reviews a diff with a given focus arguments: - name: diff description: The diff to review required: true - focus # simple form: optional argument --- Review the following diff focusing on {{focus}}: {{diff}}
Declared {{argument}} placeholders are substituted from the request
(missing ones become empty strings); undeclared placeholders are left
intact. Malformed frontmatter, an unreadable file or a duplicate prompt
name fail the server build with Prompts\Exception\InvalidPromptFileException
— never a silently missing prompt.
Substitution amplifies caller input — one argument value is inserted at
every occurrence of its placeholder — so the expanded prompt is bounded by
the limits.prompt_result_bytes param (default 1 MiB, 0 = unlimited).
The size is computed arithmetically and checked before the substituted
string is built: an over-budget prompts/get fails without performing the
allocation it refuses.
The file format is intentionally compatible with — and inspired by — vjik/my-prompts-mcp by Sergei Predvoditelev: the same prompt file works in a personal stdio prompt manager and on an application server.
Argument autocompletion (completion/complete)
Clients offer values as the user types a prompt argument or a resource-template
variable. Declare the source with the SDK's #[CompletionProvider] — no
yii3-mcp API is involved:
use Mcp\Capability\Attribute\CompletionProvider; #[McpPrompt(name: 'review')] public function review( #[CompletionProvider(values: ['security', 'performance'])] string $focus, #[CompletionProvider(enum: Environment::class)] string $environment, ): string { /* … */ } #[McpResourceTemplate(uriTemplate: 'app://reports/{region}', name: 'report')] public function report( #[CompletionProvider(provider: RegionCompletionProvider::class)] string $region, ): string { /* … */ }
| Form | Source |
|---|---|
values: [...] |
a fixed list, prefix-matched |
enum: BackedEnum::class |
the enum's cases |
provider: Foo::class |
a Mcp\Capability\Completion\ProviderInterface, resolved through the DI container — so it can query a repository or a feature-flag service |
Exactly one of the three per argument; the capability is advertised
automatically. Completions obey prompt_visibility / resource_visibility:
a prompt or template the session cannot see completes nothing and is reported
as not found, indistinguishable from a missing one (see
Tool visibility). Interceptors do not wrap
completion/complete — it is a metadata lookup, not a capability call, so put
authorization in the visibility filter, not in an interceptor.
Server-wide knobs
'rasuvaeff/yii3-mcp' => [ // free-form "how to use this server" text served in the initialize result — // the agent reads it before its first call. Empty = omitted. 'instructions' => 'Prefer order.status over reading app://orders/{id}.', // page size for tools/resources/templates/prompts lists. Applies to the // SDK's handlers AND this package's filtering ones, so they can never page // differently depending on whether visibility is configured. 'pagination_limit' => 50, // pins the revision advertised in initialize; empty keeps the SDK's default // (2025-11-25). An unsupported value fails at config load, not at runtime. 'protocol_version' => '', ],
Resource subscriptions
The SDK advertises resources.subscribe whenever the server has any resource
and records resources/subscribe per session. Nothing emits
notifications/resources/updated on its own — but the tool that causes the
change can, inside the same request, through Resource\ResourceUpdateNotifier:
public function __construct(private ResourceUpdateNotifier $notifier) {} #[McpTool(name: 'order.cancel')] public function cancel(string $orderId, RequestContext $context): string { $this->orders->cancel($orderId); $this->notifier->notify($context, 'app://orders/' . $orderId); return 'cancelled'; }
notify() returns whether the caller was subscribed; a session that never
subscribed is never sent anything, so an unsolicited notification cannot appear
on the wire. Bind a custom Mcp\Server\Resource\SubscriptionManagerInterface
and both the subscribe handler and the notifier follow it — the default binding
is the SDK's session-backed manager.
Only the calling session is reached. Other sessions subscribed to the same URI are not: that would need a connection this process does not hold. Under PHP-FPM nothing outlives the request, so out-of-band push remains impossible — clients that need to observe changes they did not cause must poll.
Framework-agnostic usage
Despite the package name, the code has no yiisoft/* runtime dependency —
composer.json's require is PSR interfaces (psr/container,
psr/http-message, psr/http-server-middleware, psr/http-factory,
psr/simple-cache, psr/log) plus mcp/sdk and symfony/console/yaml.
McpServerFactory takes a plain Psr\Container\ContainerInterface;
McpAction and SharedSecretMiddleware are plain PSR-15. config/di.php +
config/params.php are only the yiisoft/config-plugin convenience layer —
outside Yii3, wire the same classes by hand with whatever PSR-11 container
and router the application already uses (Laravel, Symfony, Mezzio, Slim, …):
use Mcp\Server\Session\FileSessionStore; use Rasuvaeff\Yii3Mcp\{McpAction, McpServerFactory, SharedSecretMiddleware}; // any PSR-11 container — Yii3's, PHP-DI, Laravel's, a hand-rolled one $container = /* ... */; $sessionStore = new FileSessionStore(directory: sys_get_temp_dir() . '/mcp-sessions', ttl: 3600); $factory = new McpServerFactory(container: $container, sessionStore: $sessionStore, name: 'my-app', version: '1.0.0'); $server = $factory->create([OrderTools::class]); $psr17 = /* any PSR-17 factory, e.g. nyholm/psr7 or guzzlehttp/psr7 */; $action = new McpAction(server: $server, responseFactory: $psr17, streamFactory: $psr17); $middleware = new SharedSecretMiddleware(secret: getenv('MCP_SECRET'), responseFactory: $psr17); // route POST/GET/DELETE/OPTIONS /mcp through $middleware -> $action // in whatever middleware-dispatch shape the framework's router expects
McpServeCommand (stdio) extends Symfony\Component\Console\Command\Command
directly and needs no Yii3 console app — add it to any
Symfony\Component\Console\Application. What's lost without
yiisoft/config: the automatic array-merge across packages (interceptors,
visibility, the OpenAPI bridge) — construct
Interceptor\*/Visibility\*/OpenApi\OpenApiServerConfigurator instances
directly and pass them to McpServerFactory::create(), the same objects
config/di.php builds from params, just without the config-plugin
assembling them for you.
Interceptors: wrap every tools/call
Interceptor\ToolCallInterceptorInterface is the package's public extension
point around tool execution. The chain wraps every registration path —
attribute tools, OpenAPI-bridged operations, configurator-registered
handlers — so tracing, rate limiting or ACL live in one place, without
touching the tools:
use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallContext; use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallInterceptorInterface; final readonly class TracingInterceptor implements ToolCallInterceptorInterface { public function __construct(private LoggerInterface $logger) {} public function intercept(ToolCallContext $context, callable $next): mixed { // $context->toolName, $context->arguments, $context->session, // $context->getClientInfo() — who is calling what with which input $this->logger->info('tools/call', ['tool' => $context->toolName]); return $next(); // skip $next() to short-circuit } }
// config/params.php — resolved through the container, first = outermost 'rasuvaeff/yii3-mcp' => [ 'interceptors' => [TracingInterceptor::class], ],
Throwing Mcp\Exception\ToolCallException from an interceptor rejects the
call with a regular MCP tool-error envelope (the agent sees the reason);
any other exception becomes an opaque internal error.
Masking sensitive arguments
Anything an interceptor sends out of the process — a log line, a trace span,
an audit record — must not carry secrets. Interceptor\ArgumentMasker
replaces the values of sensitive keys (password/pass/pwd, secret,
token/bearer/jwt, auth/authorization, cookie, api_key/apikey/
api-key/x-api-key, access_token/refresh_token/id_token/
session_token/auth_token (and the kebab spelling access-token),
client_secret, private_key, credit_card by default; case-insensitive, so
ApiKey/X-Api-Key match too, at every nesting level) with ***:
use Rasuvaeff\Yii3Mcp\Interceptor\ArgumentMasker; $masker = new ArgumentMasker(); // or: new ArgumentMasker(['ssn', 'password']) $safe = $masker->mask($context->arguments); // ['user' => ['name' => 'alice', 'password' => '***']] $this->logger->info('tools/call', ['tool' => $context->toolName, 'arguments' => $safe]);
It is one shared helper so every consumer (audit trail, telemetry, your own interceptors) masks with identical semantics instead of drifting apart.
Session budget: stop agent loops
A hard cap on tools/call per MCP session (from initialize until the TTL
expires). An agent stuck in a loop burns the budget and gets an explanatory
tool error instead of hammering the application:
'rasuvaeff/yii3-mcp' => [ 'session' => ['budget' => 50], // 0 = unlimited (default) ],
This is loop protection inside one session, not a client quota: a re-initialize starts a fresh counter. Client quotas belong to an application-level rate limiter. The budget guard is always the outermost interceptor, so it rejects before any other interceptor does work.
Result size limit and caching
A tool result has no natural upper bound — a bridged GET against a real API,
or a hand-written tool over a large table, can return megabytes of JSON and
burn an agent's context window. limits.tool_result_bytes truncates an
over-limit string result with an explicit marker; any other result (array,
object) is rejected outright instead, because a truncated JSON payload is
invalid JSON, not a smaller valid one:
'rasuvaeff/yii3-mcp' => [ 'limits' => ['tool_result_bytes' => 0], // 0 = unlimited (default) ],
The limit is a byte budget applied to the content: a multi-byte character
that does not fit whole is dropped rather than split (a broken UTF-8
sequence would make the JSON-RPC response unencodable, and the Streamable
HTTP transport drops such a response silently), and the marker itself is
appended on top of the budget. The marker reports the bytes actually kept.
For a string result the budget counts the raw string's bytes, before
JSON encoding (control characters expand on the wire, up to ~6x); for
arrays/objects it counts the JSON-encoded bytes. It bounds what reaches the
agent's context window — worker memory during production of the result is
bounded separately by openapi.max_response_bytes.
For read-heavy tools called repeatedly with the same arguments inside a
session (a lookup table, an OpenAPI GET), cache.tools skips the handler
entirely on a hit — opt in by tool name (for the OpenAPI bridge, the
served name, after any tool_names rename) with a TTL in seconds:
'rasuvaeff/yii3-mcp' => [ 'cache' => [ 'tools' => ['blog_tags_list' => 60], ], ],
Requires a PSR-16 CacheInterface in the container. The cache key always
includes the resolved client id — a shared cache between distinct clients
would leak one client's result to another. The key material is typed: an
absent client id (stdio) encodes as null and can never collide with a
real client that happens to be named anonymous. The key also carries a
mandatory namespace (cache.namespace param, defaulting to
server_name): two applications sharing one cache backend — a common
Redis — with same-named tools must never read each other's results, and an
external PSR-16 prefix is defence in depth, not the primary isolation.
When openapi.identity_provider
is configured, the resolved ExecutionIdentity is part of the key too:
delegated upstream credentials mean the same tool and arguments can produce
identity-specific results, and the identity may be finer-grained than the
client id (many end users behind one MCP client). Only successful results
are cached; a thrown exception never is. A cache read/write failure fails
open (the tool runs) — this is an availability optimization, not a
security gate. An identity provider failure, by contrast, fails
closed for cached tools: serving a result without knowing whose it is
would be exactly the leak the key exists to prevent.
The key identifies the MCP client, not the application user behind it.
A tool whose result depends on who is logged in (reading the current user
from the application, not from the MCP identity) must therefore not be
cached unless openapi.identity_provider resolves that user — "idempotent
read" is not the same as "same answer for everyone". Bridged operations are
covered by the identity provider; hand-written tools are yours to judge.
Interceptor order is fixed: session budget (outermost) → configured
interceptors → caching → result size limit (innermost, closest to the
actual tool call). Configured interceptors (RBAC, audit) always run, even on
a cache hit — caching cannot be used to bypass them. The size limit only
runs on a cache miss; the value it already limited is what gets cached, so a
hit never needs re-limiting.
Client identity and secret rotation
One endpoint can serve several MCP clients, each with its own secret — and each client may hold several active secrets during a rotation window (add the new secret, roll the clients, remove the old one; a removed secret is revoked immediately):
'rasuvaeff/yii3-mcp' => [ 'client_secrets' => [ 'ci' => getenv('MCP_SECRET_CI'), 'claude' => [getenv('MCP_SECRET_CLAUDE_OLD'), getenv('MCP_SECRET_CLAUDE_NEW')], ], ],
SharedSecretMiddleware resolves the presented header through a
Identity\SecretResolverInterface (every comparison is hash_equals(),
constant-time) and passes the resolved client id — never the raw
secret — down the pipeline: interceptors see it as
ToolCallContext::$clientId, and it is stamped into the session as its
immutable owner for audit/telemetry bridges and session-ownership
enforcement. The single endpoint_secret form keeps working
unchanged as one client named default; configuring both forms at once is a
fail-fast error, and so is a secret shared by two different client ids —
resolution returns the first match, so a duplicate would silently attribute
one client's calls to the other. On stdio (mcp:serve) there is no HTTP
request, so $clientId is null.
Per-client rate limits (bring your own limiter)
This package deliberately ships no limiter storage. Implement
Interceptor\ToolCallLimiterInterface over the rate limiter your application
already runs (yiisoft/rate-limiter, Redis, …) and wire
Interceptor\RateLimitInterceptor into the interceptors list:
final readonly class AppToolCallLimiter implements ToolCallLimiterInterface { public function __construct(private CounterInterface $counter) {} public function allow(?string $clientId, string $toolName): bool { return $this->counter->hit(($clientId ?? 'no-client') . ':' . $toolName)->isAllowed(); } } // params 'rasuvaeff/yii3-mcp' => [ 'interceptors' => [RateLimitInterceptor::class], ], // di: bind ToolCallLimiterInterface => AppToolCallLimiter
The interceptor keys calls by the resolved client id plus the tool name, so
per-client and per-tool limits come from your limiter's configuration. A
transport without identity (stdio) passes null — typed absence, never a
reserved string a real client id could collide with; how anonymous calls are
bucketed is the limiter's decision. Fail-closed: when
the limiter backend throws, the call is rejected — an enforced quota must not
silently become "unlimited" during an outage.
Retrying transient failures (bring your own retry)
This package ships no retry logic — a naive blanket retry duplicates side
effects on a non-idempotent tool (double-charging a payment, resubmitting a
form). Scope any retry to an explicit allow-list of tools you have verified
are idempotent, and to transient failure types only, using
rasuvaeff/retry:
use Rasuvaeff\Retry\Retry; use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallContext; use Rasuvaeff\Yii3Mcp\Interceptor\ToolCallInterceptorInterface; use Rasuvaeff\Yii3Mcp\OpenApi\Exception\OperationFailedException; final readonly class RetryInterceptor implements ToolCallInterceptorInterface { /** @param list<string> $idempotentTools verified idempotent — never blanket-retry */ public function __construct(private array $idempotentTools) {} public function intercept(ToolCallContext $context, callable $next): mixed { if (!in_array($context->toolName, $this->idempotentTools, true)) { return $next(); } return Retry::new() ->maxAttempts(3) ->withExponential(baseMs: 100, multiplier: 2.0, capMs: 2_000) ->retryOn(OperationFailedException::class) // transient failures only ->run($next); } }
Place it near the end of your interceptors list (closer to the tool call)
— any interceptor listed before it (e.g. RateLimitInterceptor) wraps
around the whole retry loop and is checked once per outer call, not once
per attempt; listed after it, it would be re-triggered on every retry.
Tool visibility
ConditionalToolInterface gates registration globally at build time. To hide
a subset of the registered tools from the endpoint, the typical case is
declarative — tool-name patterns in params, * matching any run of
characters:
'rasuvaeff/yii3-mcp' => [ 'visibility' => [ 'deny' => ['admin.*'], // hide matches 'allow' => [], // non-empty = hide everything it does not match ], ],
A tag: prefix matches against the tool's tags instead of its name — the
OpenAPI bridge propagates OpenAPI tags into the tool's _meta, so
'deny' => ['tag:admin'] hides every bridged operation tagged admin
regardless of its operationId/tool_names name. A tool with no tags never
matches a tag: pattern.
Note where a tag comes from: the OpenAPI document. With a URL spec
(openapi.spec_path pointing at http(s)), a document that drops the admin
tag disarms a deny: ['tag:admin'] rule — the exposure stays bounded by your
operations allow-list, but the deny rule itself is only as trustworthy as
the spec source. Prefer name patterns for deny rules over a remote spec, and
keep tag: for allow-listing and for local spec files.
Deny wins over allow; both lists empty (the default) means every tool is
visible. When the decision depends on the session (admin vs public
client, tenant plans), implement Visibility\ToolVisibilityInterface
instead — the decision runs per session, against the handshake data:
use Mcp\Schema\Tool; use Mcp\Server\Session\SessionInterface; use Rasuvaeff\Yii3Mcp\Visibility\ToolVisibilityInterface; final readonly class PlanBasedVisibility implements ToolVisibilityInterface { public function isVisible(Tool $tool, ?SessionInterface $session): bool { // decide from $session->get('client_info'), tenant data, … return !str_starts_with($tool->name, 'admin.') || $this->isAdmin($session); } }
'rasuvaeff/yii3-mcp' => [ 'tool_visibility' => PlanBasedVisibility::class, // DI-resolved ],
The two kinds are mutually exclusive — configuring both is a build-time
error. Either way the filter applies in two places, consistently:
tools/list omits invisible tools, and tools/call fail-closed rejects
them — a client that guesses a hidden name still gets a tool error, and the
call never reaches the interceptor chain or the tool. This is an early
filter, not a replacement for application-level ACL.
Hooks for prompts and resources
The same seams exist for the other capabilities. prompts/get and
resources/read (static resources and templates alike) each have their own
interceptor chain and visibility filter — separate interfaces, so a tool
policy never accidentally applies to a prompt:
// config/params.php — each list resolved through the container, first = outermost 'rasuvaeff/yii3-mcp' => [ 'prompt_interceptors' => [PromptAuditInterceptor::class], // Interceptor\PromptGetInterceptorInterface 'resource_interceptors' => [ResourceAclInterceptor::class], // Interceptor\ResourceReadInterceptorInterface 'prompt_visibility' => PlanBasedPromptVisibility::class, // Visibility\PromptVisibilityInterface 'resource_visibility' => PlanBasedResourceVisibility::class, // Visibility\ResourceVisibilityInterface ],
Interceptor\PromptGetContextcarries the prompt name, arguments, session and client id;Interceptor\ResourceReadContextcarries the URI, the RFC 6570 variables extracted from a template (with the matcheduriTemplate) 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/listandresources/templates/listwith the same implementation that guards the direct calls, so listing and fetching can never disagree. completion/completeobeys the same filters. Argument autocompletion (#[CompletionProvider]on a prompt argument or a resource-template variable) is served by the SDK straight off the registry, so it used to answer for prompts and templates a session could not see — leaking both the suggested values and the capability's existence. It is now decorated with the configured prompt/resource visibility and reports a hidden ref as not found, exactly like a missing one.
For bridges (audit, telemetry) the core ships one shared outcome
vocabulary — Interceptor\CallOutcome (success / rejected / error,
with CallOutcome::fromThrowable()): a rate-limit or ACL rejection is
classified rejected and never pollutes error-rate metrics.
Server configurators
Beyond the built-in Markdown-prompts and OpenAPI bridge, register your own
ServerConfiguratorInterface implementations (or a companion package's) to
contribute capabilities to the SDK server builder before it is built. The
core resolves the FQCNs through the container (after its own configurators)
and applies them in order:
'rasuvaeff/yii3-mcp' => [ 'configurators' => [MyServerConfigurator::class], // DI-resolved ],
final readonly class MyServerConfigurator implements ServerConfiguratorInterface { #[\Override] public function configure(Builder $builder): void { // $builder->addTool(...) / addResource(...) / addPrompt(...) … } }
Multi-tenant serving (rasuvaeff/yii3-tenancy)
With rasuvaeff/yii3-tenancy the
MCP endpoint serves every tenant from one route — tools are ordinary Yii3
services, so a constructor-injected CurrentTenant scopes their data access
as anywhere else in the application. The recipe is middleware order: resolve
the tenant before the MCP action runs:
// config/routes.php — secret first (fail-closed), then tenant, then MCP Route::methods(['POST', 'GET', 'DELETE', 'OPTIONS'], '/mcp') ->middleware(SharedSecretMiddleware::class) ->middleware(TenantResolutionMiddleware::class) // e.g. HeaderTenantResolver('X-Tenant-Id') ->action(McpAction::class),
// an MCP client carries both headers "headers": { "X-Mcp-Secret": "...", "X-Tenant-Id": "acme" }
Isolate sessions per tenant so a session id can never cross tenants — bind the session store to a per-tenant directory:
// config/common/di/mcp.php SessionStoreInterface::class => static fn (CurrentTenant $tenant) => new FileSessionStore( directory: sys_get_temp_dir() . '/mcp-sessions/' . $tenant->get()->getId(), ),
Per-tenant tool sets come free with tool_visibility (see above): decide
from the resolved tenant instead of client_info.
Honest scope: the shared secret stays global — anyone holding it may present any
X-Tenant-Id. That fits the trusted-only endpoint model (the secret already grants application access); tenant isolation here protects against accidents, not against a malicious secret holder. Per-tenant secrets (a secret resolver instead of the single-value middleware) are a planned extension — ask if you need it.
OpenAPI bridge: expose an existing REST API
If the application already maintains an OpenAPI 3.0.x or 3.1.x document,
allow-listed operations can be bridged as MCP tools with zero duplication —
names come from operationId (or tool_names, see below), descriptions
from summary/description, input schemas from parameters/request body,
output schemas from the success response (see below). Calls are executed as
real HTTP requests against the API, passing its full middleware stack
(validation, rate limiting, auth) — unlike hand-written tools that invoke
handlers directly.
// config/params.php 'rasuvaeff/yii3-mcp' => [ 'openapi' => [ // file path OR http(s) URL — e.g. the app's own spec endpoint, // always current; fetched with `spec_headers`, NOT with `headers` 'spec_path' => 'https://api.example.com/rest/json-url', 'base_url' => 'https://api.example.com', 'operations' => ['getBlogTags', 'getPage'], // allow-list, empty = nothing // rename an ugly generated operationId into an LLM-friendly tool // name; unmapped operations keep their operationId 'tool_names' => ['getBlogTags' => 'blog_tags_list'], // operation-call credentials, sent to base_url only 'headers' => ['Authorization' => 'Bearer ' . getenv('MCP_API_TOKEN')], // spec-fetch credentials, sent to spec_path only (empty by default) 'spec_headers' => [], 'cache_ttl' => 60, // PSR-16 URL-spec cache; 0 = fetch every build 'safe_methods_only' => true, // read-only bridge: non-GET in the list => build error 'max_response_bytes' => 4_194_304, // upstream body cap, read incrementally 'opaque_errors' => false, // true = suppress upstream error bodies ], ],
Credential scopes are separate on purpose: headers authenticates operation
calls against base_url, spec_headers authenticates the spec fetch against
spec_path — when the two point at different origins, a shared header set
would hand the API token to the spec host. A spec URL embedding credentials
(userinfo) is rejected outright: such a URL ends up in diagnostics and
exception messages.
tool_names only renames what MCP clients see as the tool name — the
allow-list, handler execution and delegated-header calls all stay keyed by
operationId. Interceptors, visibility rules and any audit/RBAC bridge must
reference the renamed name. An operationId in tool_names that is not
in operations throws InvalidArgumentException at build time (a likely
typo); a rename that is invalid as an MCP tool name or collides with another
tool's name throws InvalidSpecException. The collision check covers
attribute tools too: the names of #[McpTool] methods registered on the same
server are reserved, so renaming a bridged operation onto one of them is a
build-time error rather than a bridged tool that silently never reaches
tools/list (the SDK's registry is last-write-wins and registers attribute
tools last).
Every GET operation is advertised with readOnlyHint: true — no
configuration needed. OpenAPI tags on an operation are propagated into the
served tool's _meta ({"rasuvaeff/yii3-mcp": {"tags": [...]}}), which the
declarative tag: visibility pattern (see Tool visibility)
reads directly.
The DI wiring requires PSR-18/PSR-17 services (ClientInterface,
RequestFactoryInterface, StreamFactoryInterface) in the container and a
PSR-16 CacheInterface when cache_ttl > 0. The cache stores the raw document;
allow-listing and validation run on every server build. Cache failures fall back
to HTTP, while HTTP/spec failures remain fail-closed. A removed operation can
remain callable for up to the TTL, so use a local file or a short TTL for
security-sensitive specs.
Request bodies are passed as a single body tool argument; an operationId
missing from the document throws UnknownOperationException at server build
time, a non-GET operation under safe_methods_only throws
UnsafeOperationException (fail-fast). Local #/components/... $refs are
resolved inline (up to 32 chained hops); external (URL/file) $refs pass
through unresolved for request-body schemas. URL parameters are deliberately
limited to scalar string, integer, number and boolean schemas with the
OpenAPI defaults (simple path, form query). Header/cookie parameters,
external or non-scalar parameter schemas, custom serialization, non-default
explode and allowReserved=true throw InvalidSpecException when the
operation is selected. A path argument is rejected at call time when it is
empty or ., or when it contains .., / or \ — rawurlencode keeps
dots verbatim and encodes / as %2F, which upstreams that decode before
normalizing the path (Apache with AllowEncodedSlashes, some proxies and
servlet containers) hand back as a real separator, so a value like ../..
could climb out of the allow-listed route with the bridge's credentials; an
empty value is the same escape one level up (/users/ is typically the
collection route, not the allow-listed item route). Single dots are fine
(v1.2 is a valid slug); a value that needs a slash or .. inside it cannot
be bridged as a path argument. The base URL must not
embed credentials (userinfo) or carry a query string/fragment — dry-run
previews return the full URL to the caller, so the base URL is never allowed
to be a credential carrier. Fixed upstream headers belong in headers/
HttpOperationExecutor::defaultHeaders. Bridged operations execute with the
configured upstream credentials. The upstream API does not automatically
inherit the MCP caller identity or RBAC decision. Do not expose user/tenant-
scoped operations with a broader service token.
For delegated authorization configure both identity_provider and
delegated_header_provider. The first returns an immutable
ExecutionIdentity; the second exchanges it for headers on every operation
call and receives only operation id/method/path plus that identity, never the
raw MCP shared secret. Do not forward the inbound Authorization header
verbatim. Provider failures stop the call before HTTP (fail-closed), and
dynamic headers override matching static headers without cross-call reuse.
Duplicate operationId values also fail while indexing the document.
Tool arguments are keyed by name, so an operation with a
path and a query parameter sharing one name — or a parameter named body
alongside a request body — cannot be bridged and throws
InvalidSpecException at build time.
Resource bounds are enforced before allocation, not after: the upstream
response body is read incrementally and the call fails the moment it crosses
max_response_bytes (an advertised Content-Length over the cap is rejected
without reading at all); JSON decoding is depth-capped; the OpenAPI document
itself is size-bounded (10 MiB) for URL and file sources alike, and $ref
inlining runs under an explicit depth + node budget, so a hostile or
degenerate remote spec cannot make indexing recurse or allocate without
bound. Upstream error bodies are excerpted (bounded, UTF-8-safe) into the
tool error — or suppressed entirely with opaque_errors when upstream error
details are not the MCP caller's to see.
An operationId that cannot serve as an MCP tool name (space, unicode,
over 64 characters — ^[A-Za-z0-9._/-]{1,64}$) throws InvalidSpecException
when the operation is selected; mcp/sdk itself only logs a warning and
would otherwise register the tool anyway, surfacing only as an opaque
tools/list rejection on the client. A null argument for a path or query
parameter is treated the same as an omitted one (skipped, not sent as an
empty value) — matching OpenAPI 3.1's nullable union notation
({"type": ["string", "null"]}) on scalar parameter schemas, which the
bridge accepts alongside the plain 3.0 type string.
Output schema from responses
A bridged tool also advertises outputSchema in tools/list when the
operation declares a matching success response: the lowest concrete 2xx
response with an application/json schema of type: object — OpenAPI 3.1's
nullable union notation (type: ["object", "null"]) is accepted the same
way (local $refs resolved, top-level keywords canonicalized to
type/properties/required/additionalProperties/description, always
to the plain "object" type). Agents see the response shape before
calling and MCP clients validate the returned structuredContent against it.
Array/scalar responses and 2XX wildcards are not advertised — JSON object
payloads still arrive as structuredContent, just without the upfront
contract. If the advertised schema must match reality, keep the OpenAPI
document honest: a spec that diverges from the API surfaces as client-side
validation errors.
For custom scenarios use the pieces directly: SpecIndex +
HttpOperationExecutor + OpenApiServerConfigurator (a
ServerConfiguratorInterface — the generic extension point accepted by
McpServerFactory::create(tools, configurators)).
Per-operation customization
OperationModifierInterface is a per-operation hook, applied after the
tool_names rename — for changing a description, adding annotations, or
renaming further, without writing a whole ServerConfiguratorInterface:
'rasuvaeff/yii3-mcp' => [ 'openapi' => [ 'operation_modifier' => MyOperationModifier::class, // DI-resolved ], ],
use Mcp\Schema\Tool; use Rasuvaeff\Yii3Mcp\OpenApi\Operation; use Rasuvaeff\Yii3Mcp\OpenApi\OperationModifierInterface; final readonly class MyOperationModifier implements OperationModifierInterface { public function modify(Operation $operation, Tool $tool): Tool { return new Tool( name: $tool->name, title: $tool->title, inputSchema: $tool->inputSchema, description: $tool->description . ' (read-only bridge)', annotations: $tool->annotations, outputSchema: $tool->outputSchema, ); } }
A name change from the modifier is validated and checked for collisions the
same way as a tool_names rename — fail-closed, same as everywhere else in
the bridge.
Dry-run: preview a call without executing it
'rasuvaeff/yii3-mcp' => [ 'openapi' => [ // operationIds that get an extra `dryRun` boolean argument 'dry_run' => ['createSubscriber'], ], ],
A dry-run-enabled operation's inputSchema gains a dryRun: boolean
argument. Calling the tool with dryRun: true returns the request that
would be sent (operationId, method, url, body) as text — never as
structuredContent, so it never conflicts with the operation's declared
outputSchema — without sending it, and without any upstream credentials
leaving the process (headers are never included in the preview). The flag is
checked twice, fail-closed: an operationId absent from dry_run ignores a
dryRun argument entirely and always executes for real, even if a client
sends it anyway. On a dry-run-enabled operation a non-boolean dryRun
value (1, "true") is rejected with an error instead of being executed —
a malformed flag must never turn an intended preview into a real call.
Dry-run is orthogonal to safe_methods_only: it does not expose an operation
the safety gate would otherwise reject — a write operation still needs
safe_methods_only: false (or omitted) to be exposed at all, dry-run or not.
A dry-run call still passes through the full interceptor chain (session
budget, RBAC/audit, caching, size limit) like any other call — previewing a
write action requires the same permission as actually calling it.
MCP Apps: interactive UI in the conversation
MCP Apps
(io.modelcontextprotocol/ui) are HTML documents served as ui:// resources
and rendered by the client in a sandboxed iframe, inside the conversation.
The extension must be announced during the handshake — a ui:// resource on
a server that does not announce it is just text to the client.
'rasuvaeff/yii3-mcp' => [ 'apps' => [ // announce the extension (enough for attribute-based apps) 'enable' => true, // declarative apps — no PHP class needed 'definitions' => [ [ 'uri' => 'ui://dashboard', // required, must start with ui:// 'name' => 'dashboard', // required, unique 'html' => '<!DOCTYPE html>…', // string, or Closure(): string 'title' => 'Dashboard', 'description' => 'Sales overview', 'csp' => ['connect_domains' => ['api.example.com']], 'permissions' => ['geolocation' => true], 'prefers_border' => true, ], ], ], ],
A non-empty definitions list enables the extension on its own. html as a
Closure(): string is re-evaluated on every resources/read — the hook
for templating and DI-provided data, and the reason an expensive render costs
that much per read.
Attribute-based apps
For an app with logic behind it, declare a ui:// resource the usual way and
return the content yourself:
#[McpResource(
uri: 'ui://report',
name: 'report',
mimeType: McpApps::MIME_TYPE,
meta: ['ui' => new \stdClass()], // descriptor marker
)]
public function report(): TextResourceContents
{
return new TextResourceContents(
uri: 'ui://report',
mimeType: McpApps::MIME_TYPE,
text: '<!DOCTYPE html><h1>Report</h1>',
meta: ['ui' => new UiResourceContentMeta( // sandbox contract
csp: new UiResourceCsp(connectDomains: ['api.example.com']),
prefersBorder: true,
)],
);
}
This path still needs 'apps' => ['enable' => true] — that is what announces
the extension. Returning a plain string works too, but only a returned
TextResourceContents can carry _meta.ui.
Where _meta.ui goes
| Level | Value | Carries |
|---|---|---|
Descriptor (resources/list) |
McpApps::resourceMarker() — an empty {} |
"this resource is an app" and nothing else |
Content (resources/read) |
UiResourceContentMeta |
csp, permissions, domain, prefersBorder |
Mixing them up is the one easy mistake here: a sandbox policy placed on the descriptor is ignored, and a marker placed on the content tells the host nothing.
Sandbox: CSP and permissions
UiResourceCsp allow-lists what the iframe may reach (connect_domains for
fetch/XHR/WebSocket, resource_domains for images/scripts/styles,
frame_domains, base_uri_domains); omitting the CSP entirely leaves the
host's own restrictive default in place. UiResourcePermissions requests
sandbox capabilities (camera, microphone, geolocation,
clipboard_write) — in params these are plain booleans, and only true ones
are sent.
Domains are passed to the client verbatim: the policy is enforced by the host,
and definitions is application-owned configuration, not client input. The
app HTML itself is served as-is — it is your responsibility not to interpolate
untrusted data into it.
Linking a tool to an app
#[McpTool(
name: 'refresh_report',
meta: ['ui' => new UiToolMeta(resourceUri: 'ui://report')],
)]
public function refresh(): string { /* … */ }
UiToolMeta::$visibility (ToolVisibility::Model / ToolVisibility::App)
declares who may call it — an app-only tool is hidden from the model's
tools/list by the host; the server only states the intent, so do not
treat it as an access control boundary. For a server-side guarantee use
Visibility\ToolVisibilityInterface, which fail-closed rejects the call
itself.
App resources are ordinary resources otherwise: ResourceVisibilityInterface
filters them, resource_interceptors wrap their reads, and a ui:// URI
colliding with an attribute resource fails the build like any other duplicate.
McpAppsConfigurator is the single enabler of the extension — a second
enableExtension(new McpApps()) from an application configurator fails the
build (the SDK rejects a duplicate extension id).
Components
| Class | Role |
|---|---|
McpServerFactory |
list of tool FQCNs → configured SDK Server (reads #[McpTool]/#[McpResource] attributes, wires the DI container and session store) |
McpAction |
PSR-15 handler running the SDK StreamableHttpTransport for the current request; stamps the immutable session owner at initialize and rejects foreign-session POST/DELETE with a 404 |
Session\PrivateFileSessionStore |
owner-only file session store: directory created 0700, session files clamped 0600 (the shipped default) |
Exception\SessionOwnershipException |
a capability call arrived with a client identity different from the session's immutable owner (fail-closed) |
Exception\DuplicateCapabilityException |
two capability registrations resolved to one identity — the build fails instead of the SDK's silent last-write-wins |
SharedSecretMiddleware |
fail-closed hash_equals() guard; an empty secret rejects every request with an explanatory 503 — an unprotected endpoint must be an explicit decision; resolves the client id via Identity\SecretResolverInterface |
Identity\SecretResolverInterface / Identity\StaticSecretResolver |
several clients per endpoint + secret rotation (multiple active secrets per client id); constant-time comparison, the raw secret never travels past the middleware |
Interceptor\ToolCallLimiterInterface / Interceptor\RateLimitInterceptor |
port + adapter delegating per-client/per-tool limits to the application's rate limiter; fail-closed on limiter outage |
McpServeCommand |
mcp:serve — stdio transport for local MCP clients |
McpListCommand |
mcp:list — console introspection of every served tool/resource/prompt with argument summaries; --json for normalized machine-readable definitions |
McpDoctorCommand |
mcp:doctor — configuration health check (secret, session storage, OpenAPI spec, server build) with stable exit codes (0/2/3/4 = healthy/config/storage/upstream); --json, --probe |
Doctor\McpDoctor |
the diagnostics service behind mcp:doctor; returns an immutable DoctorReport of CheckResults (CheckStatus pass/skip/fail, CheckCategory config/storage/upstream) |
Exception\InvalidToolClassException |
configured tool class missing or without capability attributes (fail-fast) |
ConditionalToolInterface |
capability class opts out of registration at build time (shouldRegister()) |
Testing\McpTester |
in-process test client: initialize/list all paginated capabilities/callTool/readResource |
Testing\SchemaSnapshot |
contract canary: committed JSON snapshot of all served capability schemas; drift fails the build |
Prompts\MarkdownPromptsConfigurator |
a directory of *.md files as MCP prompts (vjik/my-prompts-mcp-compatible format) |
ServerConfiguratorInterface |
generic extension point for contributing capabilities to the builder; register your own via the configurators params list |
Interceptor\ToolCallInterceptorInterface |
wraps every tools/call (tracing, ACL, rate limits); configured via interceptors params |
Interceptor\ToolCallContext |
what an interceptor sees: tool name, arguments, session, getClientInfo() |
Interceptor\SessionBudgetInterceptor |
per-session tools/call cap (session.budget param) — anti-loop guard |
Interceptor\ResponseSizeLimitInterceptor |
caps tool result size (limits.tool_result_bytes param) — truncates strings, rejects oversized arrays/objects |
Interceptor\CachingToolCallInterceptor |
PSR-16 cache for successful tool results, per tool name with a TTL (cache.tools param); typed key includes a mandatory application namespace, the client id and, with delegated auth, the ExecutionIdentity |
Interceptor\InterceptingReferenceHandler |
the decorator wiring the chain into the SDK (used by McpServerFactory) |
Interceptor\ArgumentMasker |
shared sensitive-argument masking (password/token/… at every nesting level) for anything leaving the process |
Visibility\ToolVisibilityInterface |
per-session tool filter (tool_visibility param): tools/list omits, tools/call fail-closed rejects |
Visibility\DeclarativeToolVisibility |
deny/allow tool-name patterns with * wildcards (visibility param) — the no-code visibility case |
Interceptor\PromptGetInterceptorInterface / Interceptor\ResourceReadInterceptorInterface |
wrap every prompts/get and resources/read (prompt_interceptors / resource_interceptors params) with PromptGetContext / ResourceReadContext |
Visibility\PromptVisibilityInterface / Visibility\ResourceVisibilityInterface |
per-session prompt/resource filters (prompt_visibility / resource_visibility params): lists omit, direct get/read reports not-found |
Interceptor\CallOutcome |
shared success/rejected/error vocabulary for audit/telemetry bridges (fromThrowable()) |
OpenApi\OpenApiServerConfigurator |
bridges allow-listed OpenAPI operations as tools (HTTP execution) |
OpenApi\OperationModifierInterface |
per-operation customization hook, applied after the tool_names rename |
OpenApi\Operation |
read-only operation context passed to OperationModifierInterface::modify() |
OpenApi\Exception\* |
InvalidSpecException, UnknownOperationException, UnsafeOperationException, OperationFailedException |
Resource\ResourceUpdateNotifier |
sends notifications/resources/updated to the calling session from inside the request that changed the resource; a session that never subscribed is never notified |
Apps\McpAppsConfigurator |
announces the MCP Apps extension and registers declarative ui:// app resources (apps params) |
Apps\AppDefinition |
one declarative app: ui:// URI, name, HTML (string or Closure(): string) and its UiResourceContentMeta |
Security
- The endpoint is trusted-only. MCP tools execute application code;
treat the endpoint like an admin API. Ship it behind
SharedSecretMiddleware(an empty secret rejects every request with an explanatory 503) or an explicit network ACL. - Tool errors are returned as MCP error envelopes by the SDK — internals are not leaked as 500 traces.
- The core registers no tools by default; every exposed operation is an
explicit entry in
params['rasuvaeff/yii3-mcp']['tools']. - Sessions are bound to the client that created them (immutable owner
stamped at
initialize, verified before every POST/DELETE): a leakedMcp-Session-Idalone does not let another authenticated client act in — or destroy — a foreign session. Session files are owner-only (0700dir,0600files) 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 +$refdepth/node budget) and tool results (limits.tool_result_bytes). - OAuth from the MCP authorization spec is deliberately out of scope until it stabilizes; shared-secret/ACL only.
Examples
See examples/ — every script runs offline.
| Script | Shows | Needs server? |
|---|---|---|
http-handshake.php |
Full in-process MCP cycle: initialize + tools/call | no |
stdio-serve.php |
The stdio transport mcp:serve runs, over in-memory streams |
no |
conditional.php |
ConditionalToolInterface registration gating |
no |
prompts.php |
Markdown files served as MCP prompts | no |
openapi-bridge.php |
OpenAPI operations bridged as MCP tools, with tool_names and OperationModifierInterface |
no |
interceptors.php |
Tracing interceptor (with ArgumentMasker) + session budget guard + result size limit |
no |
visibility.php |
Tool visibility: per-session interface + declarative deny patterns, fail-closed call | no |
structured-output.php |
outputSchema + structuredContent on a tool |
no |
server-initiated.php |
Official ToolAnnotations and a schema-safe RequestContext parameter for progress/elicitation |
no |
completions.php |
completion/complete: value list / enum / container-resolved provider, and visibility applied to completions |
no |
mcp-apps.php |
MCP Apps: declarative and attribute-based ui:// apps, _meta.ui placement, CSP/permissions, a tool linked to an app |
no |
Testing your tools
Testing\McpTester drives the real Streamable HTTP code path in-process —
no HTTP server, no stdio process:
$tester = new McpTester($server, $psr17, $psr17, $psr17); $result = $tester->callTool('order.status', ['orderId' => '42']); $this->assertSame('paid', $result['content'][0]['text']); $tester->listTools(); // every paginated tool definition $tester->listResources(); // every resource definition $tester->listResourceTemplates(); // every resource-template definition $tester->listPrompts(); // every prompt definition $tester->readResource('app://x'); // resource contents $tester->request('custom/method'); // any raw JSON-RPC method
Schema snapshot: catch accidental contract drift
A changed method signature silently changes the generated inputSchema — and
breaks agents mid-flight. Testing\SchemaSnapshot snapshots every served
capability definition into a committed JSON file; drift fails the test until
the snapshot is regenerated deliberately:
SchemaSnapshot::verify($tester, __DIR__ . '/mcp-schema.json'); // a mismatch throws with a per-section summary: // "tools: changed [order.status]; prompts: added [code-review]"
verify() treats a missing snapshot as an error, so a deleted or
never-committed file cannot yield a green CI build. To create or deliberately
regenerate the snapshot, run once with the environment flag (or call
SchemaSnapshot::record()), then commit the file:
MCP_SNAPSHOT_RECORD=1 vendor/bin/testo --suite=Unit
assert() remains as the migration-friendly mode: a missing file is generated
on the first run, then compared exactly like verify().
When bumping the mcp/sdk pin, expect to regenerate: schema serialization
may legitimately change between SDK minors.
For interactive debugging use the official MCP Inspector:
npx @modelcontextprotocol/inspector # transport: Streamable HTTP, URL: https://your-app/rest/mcp, # header: X-Mcp-Secret: <secret>
Roadmap
Planned direction (tool-call interceptors, AI audit trail, session budgets, tenant-scoped serving, per-session tool visibility): see ROADMAP.md.
Development
No PHP/Composer on the host — run in Docker via the composer:2 image:
docker run --rm -v "$PWD":/app -w /app composer:2 composer build
Or with Make: make build, make cs-fix, make psalm, make test.
License
BSD-3-Clause. See LICENSE.md.