rasuvaeff/yii3-mcp

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

Maintainers

Package info

github.com/rasuvaeff/yii3-mcp

pkg:composer/rasuvaeff/yii3-mcp

Transparency log

Statistics

Installs: 291

Dependents: 3

Suggesters: 0

Stars: 0

Open Issues: 0

v1.8.0 2026-07-25 16:56 UTC

README

Stable Version Total Downloads Build Static analysis Psalm level PHP License

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

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\PromptGetContext carries the prompt name, arguments, session and client id; Interceptor\ResourceReadContext carries the URI, the RFC 6570 variables extracted from a template (with the matched uriTemplate) and the same identity fields.
  • Rejecting: throw Mcp\Exception\PromptGetException / Mcp\Exception\ResourceReadException — the client sees the message. Hiding: visibility (or a thrown *NotFoundException) reports the capability as not found, indistinguishable from a missing one — a client that guesses a hidden prompt name or resource URI learns nothing, and the call never reaches the interceptors or the handler.
  • Visibility filters prompts/list, resources/list and resources/templates/list with the same implementation that guards the direct calls, so listing and fetching can never disagree.

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.