Skip to content

Capabilities

Every capability method is a public method on an ordinary Yii3 service, annotated with one of the SDK's own attributes — this package invents no protocol structures on top of mcp/sdk.

Tools

php
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;
    }
}

The input schema is generated by the SDK from the method signature and DocBlock — no separate schema to keep in sync.

Structured output

Declare an outputSchema 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.

php
/** @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 (see Cookbook: your first MCP server).

Behavior hints

Use the SDK's ToolAnnotations directly — no yii3-mcp-specific attribute is needed:

php
use Mcp\Schema\ToolAnnotations;

#[McpTool(
    name: 'order.cancel',
    annotations: new ToolAnnotations(
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: true,
        openWorldHint: false,
    ),
)]
public function cancel(string $orderId): string { /* … */ }
HintMeaning
readOnlyHinttrue when the tool does not modify its environment.
destructiveHintFor a mutating tool, distinguishes destructive changes from additive-only ones.
idempotentHintFor a mutating tool, says repeated calls with the same arguments add no further effect.
openWorldHinttrue when the tool may interact with external entities outside a closed domain.

Annotations are advisory metadata a client may ignore — never substitute them for authorization, validation, safe_methods_only (OpenAPI bridge), visibility, or server-side confirmation. idempotentHint alone does not license automatic retries — see Interceptors: retrying transient failures.

Server-initiated communication

An attribute tool may accept the SDK's request-scoped RequestContext as a parameter — the SDK creates it per MCP request and omits it from the generated input schema:

php
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(/* … */);

    if (!$confirmation->isAccepted()) {
        throw new RuntimeException('Deployment was not confirmed');
    }

    return sprintf('Deployment %s queued', $version);
}

progress() is a no-op when the caller supplied no progress token; log() emits client-visible log notifications; sample() and elicit() suspend the tool's Fiber until the client responds or the SDK times out. Check supportsElicitation() before requiring it, and keep RequestContext as a method parameter, not a constructor dependency — it belongs to one request.

Conditional registration

php
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 { /* … */ }
}

The instance is resolved through the container at build time; a false skips registration entirely — no tools/resources/prompts from that class reach tools/list.

Resources and resource templates

#[McpResource] (a fixed URI) and #[McpResourceTemplate] (an RFC 6570 template, e.g. app://reports/{region}) work the same way as tools — public methods on a DI-resolved service, returning resource contents.

Subscriptions

The SDK advertises resources.subscribe whenever the server has any resource and records resources/subscribe per session, but nothing emits notifications/resources/updated on its own. The tool that causes a change sends it, inside the same request, through Resource\ResourceUpdateNotifier:

php
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 can never appear on the wire. Only the calling session is reached — reaching other subscribers would need a connection this process does not hold, and under PHP-FPM nothing outlives the request. Clients that need to observe changes they did not cause must poll.

Prompts from Markdown files

Prompts are content, not code. Point prompts_path at a directory and every *.md file becomes an MCP prompt:

php
'rasuvaeff/yii3-mcp' => [
    'prompts_path' => __DIR__ . '/../resources/prompts',
],
markdown
---
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 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 is inserted at every occurrence of its placeholder — so the expanded prompt is bounded by limits.prompt_result_bytes (default 1 MiB, 0 = unlimited), checked arithmetically 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: the same prompt file works in a personal stdio prompt manager and on an application server.

Argument autocompletion (completion/complete)

Declare the source with the SDK's #[CompletionProvider] — no yii3-mcp API is involved:

php
use Mcp\Capability\Attribute\CompletionProvider;

#[McpPrompt(name: 'review')]
public function review(
    #[CompletionProvider(values: ['security', 'performance'])] string $focus,
    #[CompletionProvider(enum: Environment::class)] string $environment,
): string { /* … */ }

#[McpResourceTemplate(uriTemplate: 'app://reports/{region}', name: 'report')]
public function report(
    #[CompletionProvider(provider: RegionCompletionProvider::class)] string $region,
): string { /* … */ }
FormSource
values: [...]A fixed list, prefix-matched.
enum: BackedEnum::classThe enum's cases.
provider: Foo::classA Mcp\Capability\Completion\ProviderInterface, resolved through the DI container — it may query a repository or a feature-flag service.

Exactly one of the three per argument. Completions obey prompt_visibility / resource_visibility — see Visibility: completions. Interceptors do not wrap completion/complete — it is a metadata lookup, not a capability call, so authorization for it belongs in the visibility filter, never in an interceptor.

Server-wide knobs

php
'rasuvaeff/yii3-mcp' => [
    'instructions' => 'Prefer order.status over reading app://orders/{id}.',
    'pagination_limit' => 50,
    'protocol_version' => '',
],

instructions is free-form "how to use this server" text served in the initialize result — the agent reads it before its first call. pagination_limit applies to every list method (tools/resources/templates/ prompts), both the SDK's own handlers and this package's filtering ones — so paging can never differ depending on whether visibility is configured. See Protocol for protocol_version.