Model Context Protocol (MCP)

Note

Not part of core. Install it separately:

composer require kinetis/mcp

kinetis/mcp lets an AI agent discover and call your application’s code over the Model Context Protocol. Mark a method with #[McpTool] or #[McpResource], and the kinetis mcp:serve stdio command and the POST /mcp HTTP route serve it; installing the package registers both. The server implements the 2025-06-18 protocol revision, over the JSON-RPC and stdio mechanics in kinetis/mcp-protocol. Appendix: MCP Reference holds the wire contract behind this page. To give an agent Kinetis’s own documentation instead, see MCP Documentation Server — a separate package, served on its own or through Orbitron.

Expose a tool and a resource

use Kinetis\Mcp\Attributes\McpResource;
use Kinetis\Mcp\Attributes\McpTool;
use Kinetis\Validation\Constraints\GreaterThan;
use Kinetis\Validation\Constraints\In;

final readonly class ShippingTools
{
    private const array EUR_PER_KG = ['DE' => 4.5, 'FR' => 5.0, 'US' => 12.0];

    #[McpTool(name: 'quote_shipping', description: 'Quote the shipping price of a parcel')]
    public function quote(
        #[In(['DE', 'FR', 'US'])] string $country,
        #[GreaterThan(0)] int $grams,
    ): array {
        return [
            'country' => $country,
            'priceEur' => round(self::EUR_PER_KG[$country] * $grams / 1000, 2),
        ];
    }

    #[McpResource(
        uri: 'shipping://rates',
        name: 'shipping-rates',
        description: 'Shipping price per kilogram by destination country',
        mimeType: 'application/json',
    )]
    public function rates(): array
    {
        return self::EUR_PER_KG;
    }
}

Put the class under any production autoload.psr-4 root in your composer.json. Discovery finds every public method carrying either attribute, with no directory convention and nothing to register. The class resolves from the request’s container like a controller, so it constructor-injects the services it needs. A tool’s return value reaches the agent JSON-encoded as text, except a ToolResult, which reaches it as built (see Error handling). A resource that returns a string is served as-is, and anything else is JSON-encoded, under its mimeType (text/plain by default). Tool names and resource URIs are unique across the application and its packages; a duplicate fails registration with DuplicateDefinitionException. CLI’s “Restricting discovery” bounds the scan, and production reads tools and resources from the cache kinetis build compiles (see Caching & AOT Compilation).

A call’s arguments are one JSON object with a member per parameter. The tool’s published inputSchema is generated from the parameters and their constraint attributes — the ones an HTTP request body uses (see Routing & Validation) — so the agent sees country as one of three strings and grams as an integer above zero. Values keep their JSON types: grams accepts 2500, not "2500". A class-typed parameter receives a JSON object hydrated and validated as that DTO; an #[ObjectMap] array parameter accepts a JSON object of any keys as a plain PHP array. A missing argument, an unknown key and a broken rule come back together in one tool result with isError: true, which the agent can correct its call from (see Error handling). A parameter declares one named type; a union is refused at registration. Appendix: MCP Reference’s “Argument binding” has the complete rules.

Run over stdio

php vendor/bin/kinetis mcp:serve

A local MCP client registers this command and launches it as a subprocess: one JSON-RPC message per line on stdin, one response per line on stdout, until stdin closes. initialize always answers with 2025-06-18, whichever revision the client asked for, and the client decides whether to continue (see Appendix: MCP Reference’s “Protocol revision”).

stdio has no origin check, no authentication and no MCP_HTTP_PUBLIC. The process runs with the application’s configuration and belongs to whoever launched it, so give the command only to a client you trust with the application.

HTTP middleware does not run on this transport. A tool that requires an authenticated caller must therefore constructor-inject CurrentUserInterface, even if it also injects a concrete user for provider-specific claims. Without authentication the interface cannot be resolved and the tool fails closed; a concrete autowirable user by itself can instead become a new, disconnected object.

Serve over HTTP

POST /mcp is an ordinary route of your application, served by the same runtime as the rest of it (see Runtime Adapters). It answers 401 to every request until you decide how callers are identified. GET and DELETE answer 405.

Securing the HTTP transport

Establish the caller’s identity. /mcp runs the mcp middleware group, whose last member lets a request through only when a middleware ahead of it has registered Kinetis\Http\CurrentUserInterface on the request’s scope. The authentication middleware in kinetis/auth and kinetis/auth-jwt both do, so joining the group with an empty subclass is the whole integration:

composer require kinetis/auth
use Kinetis\Auth\BearerAuthMiddleware;
use Kinetis\Http\Attributes\AsMiddlewareGroup;

#[AsMiddlewareGroup('mcp')]
final readonly class McpAuthMiddleware extends BearerAuthMiddleware {}

BearerAuthMiddleware accepts no token on its own. It hands the Authorization: Bearer credential to the UserProviderInterface your application binds, and issuing and storing tokens is the application’s job (see Authentication). Extend JwtAuthMiddleware instead to verify signed JWTs (see JWT Authentication), or join the group with a middleware of your own that registers CurrentUserInterface; registering only a concrete user class leaves the endpoint closed. A tool constructor-injects CurrentUserInterface and receives the caller of that message. If the tool also needs claims from auth-jwt, inject JwtUser alongside the interface rather than replacing it; the interface keeps the same tool closed when it is reached over stdio, where this middleware group does not run.

With that middleware in place, a request carries a token your UserProviderInterface resolves, plus the protocol-version header:

curl -X POST http://localhost:8080/mcp \
    -H "Authorization: Bearer $MCP_TOKEN" \
    -H "Content-Type: application/json" \
    -H "MCP-Protocol-Version: 2025-06-18" \
    -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'

MCP-Protocol-Version is the transport’s one header, and an MCP client sets it itself. initialize may omit it; every later message must carry it, and must carry exactly 2025-06-18 — anything else is 400 (see Appendix: MCP Reference’s “Streamable HTTP”). Configure the token as the client’s Authorization header. Both authentication middleware read the credential from that header only, so a token never belongs in the endpoint URL, where proxies and access logs record it.

Allow anonymous callers. An endpoint meant for anonymous callers, with no authentication middleware in the group, says so:

.env
MCP_HTTP_PUBLIC=true

Every tool and resource is then callable by anyone who can reach /mcp. A value that is not a boolean is a configuration error and leaves the endpoint closed.

Browser clients: list the origin. A request carrying an Origin header is refused with 403 unless that exact origin is listed:

.env
MCP_ALLOWED_ORIGINS=https://agent.example.com

The list is comma-separated and empty by default. Requests without an Origin header — command-line clients and server-to-server calls — are unaffected. This check guards against DNS rebinding and is not CORS: a page served from another origin also needs the application’s global CorsMiddleware to admit that origin and allow the Authorization and MCP-Protocol-Version request headers (see Middleware).

The origin check runs first, your middleware next, and the identity check last; Appendix: MCP Reference’s “The mcp middleware group” gives the priorities and exact responses. A deployment that pre-warms .kinetis-cache/ runs kinetis build whenever the group’s members change, including a kinetis/mcp upgrade — see “Compiled group membership” there.

Each message is its own request

Both transports handle every message on a fresh request scope. The tool class and its dependencies resolve from it, every request-scope initializer runs on it — so kinetis/database-bridge’s TransactionGuard covers a tool as it covers a controller — and it is disposed once the response is written. The caller’s identity and anything else a middleware or tool registers on the scope never reach the next message, over stdio as over HTTP. A disposal failure is logged and never replaces a response already written; see Appendix: MCP Reference’s “Request scope and disposal”.

Progress

use Kinetis\Mcp\Attributes\McpTool;
use Kinetis\Mcp\ProgressReporter;

// A method on a tool class whose constructor injects $orders and $index.
#[McpTool(name: 'reindex_orders', description: 'Rebuild the search index for every order')]
public function reindexOrders(ProgressReporter $progress): array
{
    $ids = $this->orders->allIds();
    $done = 0;

    foreach ($ids as $id) {
        $this->index->reindex($id);
        $progress->report(++$done, total: count($ids));
    }

    return ['reindexed' => $done];
}

The server injects ProgressReporter; it is not an argument and does not appear in inputSchema. A client asks for progress by sending _meta.progressToken with its tools/call. Over stdio, each report() writes a notifications/progress line before the result. Over HTTP, that request is answered as text/event-stream: progress events as they are reported, then the result as the final event. A stream has already sent status 200, so an error arrives inside that final event. Without a token, report() does nothing, so a tool calls it unconditionally. Appendix: MCP Reference’s “Progress streaming” has the wire shapes.

Error handling

A tool that runs and fails returns an ordinary result with isError: true, so an agent sees a failed tool rather than a broken connection. A failed validation carries every violation — path, code, message and parameters — for the agent to correct its call. Calling quote_shipping with "grams": 0 returns:

{
    "jsonrpc": "2.0",
    "id": 2,
    "result": {
        "content": [{"type": "text", "text": "{\"errors\":[{\"path\":[\"grams\"],\"code\":\"greater_than\",\"message\":\"must be greater than 0.\",\"parameters\":{\"threshold\":0}}]}"}],
        "isError": true
    }
}

Any other exception a tool throws returns the fixed text Tool execution failed., and the exception goes to the application’s logger (see Logging). A resource method that throws answers the JSON-RPC error -32603 with the message Internal error., also logged. No exception message reaches the client.

A tool reports a deliberate refusal by returning Kinetis\McpProtocol\ToolResult::error() itself. The result reaches the agent unchanged and is not logged, so its text must already be safe for the client. An AuthorizationException message is written for the client (see Authorization), so a tool can catch the denial and return it:

use Kinetis\Authorization\Exception\AuthorizationException;
use Kinetis\Mcp\Attributes\McpTool;
use Kinetis\McpProtocol\ToolResult;

// A method on a tool class whose constructor injects $gate,
// $user (CurrentUserInterface), $postPolicy and $posts.
#[McpTool(name: 'publish_post', description: 'Publish a draft post')]
public function publish(int $id): array|ToolResult
{
    $post = $this->posts->find($id);

    try {
        $this->gate->authorize($this->user, $this->postPolicy->publish(...), $post);
    } catch (AuthorizationException $e) {
        return ToolResult::error($e->getMessage());
    }

    $this->posts->publish($post->id);

    return ['id' => $post->id];
}

Problems with the request itself — invalid JSON, an unknown method, an unregistered tool name or resource URI, a malformed parameter — are JSON-RPC error responses. Appendix: MCP Reference’s “Error catalogue” lists each code, and “HTTP status codes” its status.

See also