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.6.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-25 16:57:02 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.6.0 (experimental until 1.0 — hence the tilde pin) |
| MCP protocol | 2025-06-18 (via SDK) |
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.
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 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 secret or configured
header values:
./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,
the OpenAPI spec 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 (sys_get_temp_dir(), override via
session.dir param). 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), ];
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.
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.
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, secret, token,
api_key, credit_card by default; case-insensitive, 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.
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 mirrored into the session for
audit/telemetry bridges. The single endpoint_secret form keeps working
unchanged as one client named default; configuring both forms at once is a
fail-fast error. 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 . ':' . $toolName)->isAllowed(); } } // params 'rasuvaeff/yii3-mcp' => [ 'interceptors' => [RateLimitInterceptor::class], ], // di: bind ToolCallLimiterInterface => AppToolCallLimiter
The interceptor keys calls by the resolved client id (falling back to
anonymous on transports without one) plus the tool name, so per-client and
per-tool limits come from your limiter's configuration. Fail-closed: when
the limiter backend throws, the call is rejected — an enforced quota must not
silently become "unlimited" during an outage.
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 ], ],
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.
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 document, allow-listed
operations can be bridged as MCP tools with zero duplication — names come
from operationId, 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 the same `headers` (auth included) 'spec_path' => 'https://api.example.com/rest/json-url', 'base_url' => 'https://api.example.com', 'operations' => ['getBlogTags', 'getPage'], // allow-list, empty = nothing 'headers' => ['Authorization' => 'Bearer ' . getenv('MCP_API_TOKEN')], '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 ], ],
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. 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.
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 (local $refs
resolved, top-level keywords canonicalized to type/properties/required/
additionalProperties/description). 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)).
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 |
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\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\Exception\* |
InvalidSpecException, UnknownOperationException, UnsafeOperationException, OperationFailedException |
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']. - 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 | no |
interceptors.php |
Tracing interceptor (with ArgumentMasker) + session budget guard |
no |
visibility.php |
Tool visibility: per-session interface + declarative deny patterns, fail-closed call | no |
structured-output.php |
outputSchema + structuredContent on a tool |
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.