Skip to content

Exposing your app as an MCP server

quioteframework/mcp turns a Quiote app into a Model Context Protocol server, so an AI agent can call your app’s capabilities as tools, read its data as resources, and use prompts. It’s built on the official PHP MCP SDK (mcp/sdk) rather than reimplementing the protocol — Quiote owns the binding (transports, DI, attribute discovery, auth), the SDK owns the wire format.

The Quiote Assistant MCP is a full reference app built on this package — read it alongside this page if you want a complete, working example rather than just the mechanism.

Terminal window
composer require quioteframework/mcp
Config/plugins.php
return [
['class' => \Quiote\Mcp\McpPlugin::class, 'enabled' => true],
];
Config/settings.php
'mcp.enabled' => true,

McpPlugin publishes every mcp.* default (all opt-in-safe — mcp.enabled itself defaults to false), registers the mcp:serve/mcp:warmup console commands, and — only when http is in mcp.transports — splices McpEndpointMiddleware (and, unless mcp.auth = 'none', McpAuthMiddleware ahead of it) into the pipeline just before SecurityMiddleware. See Settings reference for the full key set.

Quiote\Mcp\McpCatalog is a static, process-global registry of tools/resources/prompts (the same pattern as MiddlewareCatalog/DatabaseDriverRegistry), fed from three sources:

  1. Manual registration — any plugin’s register() calls McpCatalog::addTool()/addResource()/addPrompt() directly. There’s no PluginRegistrar convenience method for this (a generic registrar shouldn’t grow a method per optional package) — depend on quioteframework/mcp and call McpCatalog yourself.
  2. Attribute discovery on plain classes — opt in with mcp.discover_attributes, and Quiote scans one {Module}/Mcp/ subdirectory per module (across core.module_dir plus every plugin’s contributed module directories) for classes carrying the SDK’s own #[McpTool], #[McpResource], #[McpPrompt], or #[McpResourceTemplate] attributes — delegating the actual scan to the SDK’s Mcp\Capability\Discovery\Discoverer rather than reimplementing attribute scanning.
  3. Existing #[Route] actions exposed as tools — the headline feature, below.

The killer feature: #[McpTool] on an existing action

Section titled “The killer feature: #[McpTool] on an existing action”

Add the SDK’s #[McpTool] attribute to an action that already carries #[Route], and set mcp.expose_actions = true:

<?php
namespace App\Modules\Blog\Actions;
use Mcp\Capability\Attribute\McpTool;
use Quiote\Action\Action;
use Quiote\Request\WebRequest;
use Quiote\Routing\Attribute\Route;
#[Route('/posts/{id}', name: 'post_show')]
#[McpTool(name: 'get_post', description: 'Fetch a blog post by id.')]
class PostAction extends Action
{
public function executeRead(WebRequest $rd)
{
// unchanged — the same action still serves real HTTP requests
}
}

Quiote\Mcp\Compiler\ActionToolScanner finds every route-carrying action additionally decorated with #[McpTool]. Each match is wired via Quiote\Mcp\Bridge\ActionToolAdapter, which drives a synthetic request through $context->getRequestHandler()->handle() — the same entry point a real HTTP request goes through, not ActionExecutor::execute() directly — so the tool call gets the exact same DI resolution, verb dispatch (executeRead/executeWrite/…), and validation a normal request would get, for free. Path parameters are split from extra arguments using the route’s own compiled path variables; extra arguments ride as query params (GET/HEAD) or a JSON body otherwise. A non-2xx response or any exception surfaces as a tool error (isError: true), not a JSON-RPC protocol error.

A forwarded request fails the tool call. Status alone isn’t enough to tell success from failure here: a security forward renders the login or secure system action and returns HTTP 200, so a tool call against a protected action used to report success and hand the connected model the login page’s markup as though it were the action’s output — plausibly with a “session expired” narrative the model would then act on. The adapter attaches its own ExecutionState to the synthetic request, which the pipeline mutates in place, and raises a ToolCallException naming the action actually reached if the request was forwarded. Any forward fails the call, not only a security one: if the action you asked for is not the action that ran, the body is not that action’s output.

Input schema, derived from your validators

Section titled “Input schema, derived from your validators”

Quiote\Validator\Compiler\JsonSchema\ValidatorSchemaMapper turns the action’s existing validator rules — scoped to whichever verb the route actually dispatches to — into the tool’s inputSchema, so a schema-violating tools/call is rejected before your action ever runs:

ValidatorMaps to
StringminLength/maxLength
Numberinteger/number + minimum/maximum
Emailformat: email
Inarrayenum
Regexpattern (positive, unflagged matches only)
Boolean, Json, DateTime, IsNotEmptymapped
required flagreflected in the schema’s required list

This is deliberately descriptive, not a faithful re-encoding — the schema always keeps additionalProperties: true, operator groups (and/or/not/xor) flatten to a union of their fields rather than allOf/anyOf, and anything unmappable (a negative/flagged regex, an unrecognized validator class) degrades to a looser description instead of being dropped. Real enforcement still happens when the action’s validators run again during dispatch — this schema is a pre-dispatch filter, not a replacement for it. If an action declares no validators at all, parsing fails, or nothing describable comes out, the tool falls back to a permissive {"type":"object","properties":{},"additionalProperties":true} schema. Output schema, if you want one, comes through as-is from #[McpTool(outputSchema: ...)] — nothing is derived automatically from the route’s outputType.

Terminal window
php bin/quiote mcp:serve

The default (mcp.transports = ['stdio']) and the simplest to ship — no HTTP surface, no auth, what a local client (Claude Desktop, an IDE) launches as a subprocess.

Add 'http' to mcp.transports to register Quiote\Mcp\Middleware\McpEndpointMiddleware in the pipeline, listening on mcp.path (default /mcp). It sits before SecurityMiddleware — MCP does its own bearer/token auth, not session credentials — reads the already-parsed JSON body, and returns transport-level errors as Problem Details with JSON-RPC error objects for protocol-level failures.

Config/settings.php
'mcp.transports' => ['stdio', 'http'],

Only the stateful 2025-11-25 protocol mode is implemented (mcp.protocol_version defaults to it); the installed SDK version’s StreamableHttpTransport doesn’t implement GET/SSE streaming at all, only OPTIONS/POST/DELETE. mcp.stateless exists as a config knob for the forthcoming stateless 2026-07-28 mode but that mode isn’t implemented yet — see What isn’t built yet.

The HTTP endpoint is safe by default: with no mcp.auth_token configured, Quiote\Mcp\Auth\StaticTokenAuthenticator rejects every request rather than silently allowing them through. Set a token, typically from the environment:

Config/settings.php
'mcp.auth_token' => getenv('QUIOTE_MCP_TOKEN') ?: null,

mcp.auth_token is an ordinary setting, so it can also live in Config/settings.yaml or Config/settings.xml (see Configuration) — reading it from an environment variable, as above, is a PHP idiom the other formats don’t offer.

For a trusted network, or a reverse proxy that already authenticates, mcp.auth = 'none' is the explicit opt-out — a deliberate, last-resort override that also skips registering McpAuthMiddleware entirely.

The third mode makes the endpoint an OAuth2 resource server: bearer tokens are validated as JWTs against the issuer’s JWKS, and RFC 9728 protected-resource metadata is served at the well-known path so a client can discover where to get a token.

KeyEffect
mcp.oauth.issuerThe authorization server’s issuer URL. JWKS is discovered from it via OIDC.
mcp.oauth.audienceThe audience this resource server accepts.
mcp.oauth.jwks_uriExplicit JWKS URI, when discovery isn’t available or wanted.
mcp.oauth.scopes_supportedAdvertised in the protected-resource metadata.
mcp.oauth.cache_ttlHow long a fetched JWKS is cached.

This composes the MCP SDK’s own OIDC discovery, JWKS provider, token validator and authorization middleware rather than adding validation code of its own, so enforcement lives inside the SDK transport’s middleware stack — which is why McpAuthMiddleware is not registered in this mode. There’s still no RBAC-gated tool listing; see what isn’t built yet.

Over stdio there’s no pipeline — mcp:serve runs the SDK’s loop directly against standard in/out. Over HTTP, an MCP call is just another request through the app’s middleware pipeline, so it helps to see exactly where the two MCP middlewares sit and what each does.

How the framework registers them. When http is in mcp.transports, McpPlugin::register() splices two middlewares in, both anchored before: SecurityMiddleware (MCP does its own bearer auth, so it must run ahead of session-based security and CSRF):

  • McpEndpointMiddleware — registered first, so its anchor exists for the next one to reference.
  • McpAuthMiddleware — registered only when mcp.auth != 'none', anchored before: McpEndpointMiddleware.

Once the topological resolver orders the pipeline, the two land in this runtime order: McpAuthMiddlewareMcpEndpointMiddlewareSecurityMiddleware.

The path a request takes. Both middlewares first check the request path against mcp.path (default /mcp); anything else falls straight through to normal MVC dispatch. For a request that does target the endpoint:

Request enters the pipeline → parsed by PayloadParsingMiddlewareMcpAuthMiddleware validates the Authorization: Bearer token via McpAuthenticatorInterface (default StaticTokenAuthenticator), returning a 401 Problem Details response on failure → McpEndpointMiddleware builds an McpServer and calls handleHttp(), which re-serialises the already-parsed body and hands it to the SDK’s Mcp\Server over a StreamableHttpTransport → the SDK dispatches the JSON-RPC call to the matching tool handler → the response is written back.

The tool handler is either a capability you registered on McpCatalog, or — for an action exposed with #[McpTool] — the ActionToolAdapter, which runs the call through the context’s PSR-15 request handler so it gets the same DI resolution, verb dispatch, and validation a real HTTP request would (see the actions-as-tools bridge above). See Request lifecycle for the full pipeline this plugs into.

Scanning {Module}/Mcp/ directories for attributed classes on every McpServer::build() is wasted work once you’re past development. With mcp.discover_attributes on, mcp.discovery_cache (default true) backs discovery with a file-based PSR-16 cache (Symfony\Component\Cache\Adapter\FilesystemAdapter, under {core.cache_dir}/mcp-discovery), and mcp:warmup pre-populates it offline, ahead of a worker or process starting:

Terminal window
php bin/quiote mcp:warmup

This only affects plain-class attribute discovery — the actions-as-tools scan (ActionToolScanner) isn’t yet covered by cache:warmup/mcp:warmup and still scans lazily per McpServer::build().

KeyDefaultEffect
mcp.enabledfalseMaster switch.
mcp.transports['stdio']Any of stdio, http.
mcp.path'/mcp'HTTP endpoint path (only relevant with http transport).
mcp.protocol_version'2025-11-25'Advertised MCP protocol version.
mcp.statelesstrueReserved for the 2026-07-28 stateless HTTP mode — not yet implemented.
mcp.server_name'quiote-app'Advertised server name.
mcp.server_version'1.0.0'Advertised server version.
mcp.auth'bearer''bearer', 'oauth2', or 'none'.
mcp.auth_tokennullRequired for 'bearer' auth to accept any request.
mcp.oauth.issuernull'oauth2' only — the authorization server’s issuer URL; JWKS is discovered from it.
mcp.oauth.audiencenull'oauth2' only — the audience this resource server accepts.
mcp.oauth.jwks_urinull'oauth2' only — explicit JWKS URI, bypassing discovery.
mcp.oauth.scopes_supported[]'oauth2' only — advertised in the RFC 9728 metadata.
mcp.oauth.cache_ttl3600'oauth2' only — how long a fetched JWKS is cached, in seconds.
mcp.expose_actionsfalseScan #[Route] actions for #[McpTool].
mcp.module_dirs[]Extra scan roots; defaults to core.module_dir + plugin module directories.
mcp.discover_attributesfalseScan plain (non-#[Route]) classes under {Module}/Mcp/ for SDK attributes.
mcp.discovery_cachetrueCache attribute discovery (only takes effect when mcp.discover_attributes is on).

Being direct about the gaps, since this feature is still filling in:

  • OTel spans per tool/resource/prompt call and bridging MCP logging notifications to Quiote\Logging\Log — not implemented.
  • RBAC-gated tool listing — a caller’s roles don’t yet filter which tools tools/list returns.
  • Rate limiting on the HTTP endpoint — not implemented.
  • Resource/prompt attribute discovery — attribute discovery (§ above) covers tools; resources and prompts still require manual McpCatalog registration.
  • Stateless HTTP (2026-07-28)mcp.stateless is a config placeholder; the transport itself is still stateful 2025-11-25 only.

Drive mcp:serve with a real MCP client, or call McpCatalog::tools()/resources()/prompts() directly in a test to assert what’s registered. The Quiote Assistant MCP ships real subprocess/HTTP smoke-test clients you can use as a template for testing your own server.