MCP Tools, Resources and Prompts: Expose Winter Boot to AI Agents
The Model Context Protocol (MCP) lets AI agents such as Claude call tools on a server. Winter Boot can serve your existing code as MCP tools: add #[McpTool] to a REST endpoint or a service method, and the application answers MCP requests on POST <context-path>/mcp.
There is nothing else to set up. No extra process, port or required configuration, and nothing is exposed unless a method has the attribute.
Quick Start
Section titled “Quick Start”<?php
namespace App\Controller;
use dev\winterframework\stereotype\mcp\McpTool;use dev\winterframework\stereotype\RestController;use dev\winterframework\stereotype\web\GetMapping;use dev\winterframework\stereotype\web\PathVariable;use dev\winterframework\stereotype\web\RequestMapping;
#[RestController]#[RequestMapping(path: '/api/orders')]class OrderController{ /** * Fetch one order with its line items and payment status. * * @param int $id Order number, as printed on the invoice. * @return array{id: int, status: string, totalCents: int} */ #[GetMapping(path: '/{id}')] #[McpTool] public function get(#[PathVariable] int $id): array { return $this->orders->find($id); }}Start the application and point an MCP client at it. For Claude Code:
claude mcp add --transport http orders http://localhost:8080/mcp \ --header "Authorization: Bearer <token>"The client now sees a tool named order_get with an integer id argument, described by your PHPDoc. The @return shape tells agents what the result contains.
How It Works
Section titled “How It Works”-
Discovery.
#[McpTool]is found during the normal class scan. Each tool’s name, argument schema, result schema and hints are derived once, at startup. -
Fail at boot. An argument the framework can’t describe (for example an untyped
arrayparameter) stops the application with an error that names the method and the fix, instead of failing later when an agent calls it. Results are different: an undescribed result only means the tool has no result schema. -
Endpoint. When at least one tool exists, Winter Boot registers
POST /mcp(relative toserver.context-path) on the existing server and workers.GETandDELETEanswer405. -
Calls. A tool on a REST endpoint is called through the normal request pipeline: Winter Boot builds an in-process HTTP request from the tool arguments and dispatches it. Argument binding,
HandlerInterceptors,ControllerInterceptor, AOP (#[Transactional],#[Cacheable], …) and your error controller behave exactly as for an HTTP client. A tool on a service method is called on the bean.
Where #[McpTool] Can Go
Section titled “Where #[McpTool] Can Go”| Target | Called through |
|---|---|
A request-mapped method of a #[RestController] |
The REST pipeline, as an in-process request |
A public method of a #[Service] or #[Component] |
The bean (its AOP applies) |
Anything else (static, private or abstract methods, other classes, controller methods without a mapping) is a boot error.
What Is Derived
Section titled “What Is Derived”Tool arguments
Section titled “Tool arguments”| Parameter | Becomes | Value comes from |
|---|---|---|
#[PathVariable] |
required argument (int gets "minimum": 0) |
tool arguments |
#[RequestParam] (source request, get, post) |
argument, required when required: true (the default) |
tool arguments |
#[RequestParam] (source header, cookie) |
not an argument | the caller’s /mcp request |
#[RequestBody] |
required argument named after the parameter | tool arguments |
HttpRequest, ResponseEntity |
not an argument | the dispatcher |
| service method parameter | argument; required when it has no default and isn’t nullable | tool arguments |
service HttpRequest, McpToolContext |
not an argument | injected |
Unknown arguments are rejected. Header and cookie parameters are never shown to the model: they are part of the caller’s identity, and the values of the /mcp request are used.
PHP types
Section titled “PHP types”| PHP type | JSON Schema |
|---|---|
int, float, string, bool |
integer, number, string, boolean |
nullable (?T) |
null allowed |
| backed enum | enum of the case values |
| unit enum | enum of the case names |
DateTimeInterface |
string, format: date-time |
| DTO class | object of its public properties and every property with #[JsonProperty], using the JSON names |
array |
from PHPDoc, see below |
mixed, object, no type |
argument: boot error; result: no schema |
A DTO property is required when it has no default value and isn’t nullable. Descriptions come from @param text and property doc comments. DTOs appear once under $defs; a DTO that contains itself is supported.
Arrays
Section titled “Arrays”PHP’s array type doesn’t say what is inside, so Winter Boot reads the PHPDoc and never guesses:
| PHPDoc | Schema |
|---|---|
list<T>, T[], array<int, T> |
array of T |
array<string, T> |
object whose values are T |
array{id: int, note?: string} |
object; note optional |
#[JsonProperty(listClass: T::class)] on a DTO property |
array of T |
| nothing usable, on an argument | boot error |
| nothing usable, on a result | no result schema; the result is sent as text |
Class names in PHPDoc resolve through your use imports. The error for an untyped argument tells you the three fixes:
#[McpTool] App\Service\OrderSearch::search(): parameter $filters has no describable type. Describe it with one of: - a PHPDoc type: @param array<string, string> $filters - a DTO class: SomeDto $filters - an explicit schema: #[McpTool(inputSchema: [...])]Results
Section titled “Results”| Return value | Agents receive |
|---|---|
object (DTO, map, array{...}) |
the object |
| list | {"items": [...]} |
| scalar or enum | {"result": value} |
void, ResponseEntity (without outputType), untyped |
text only |
Every result also includes the JSON as text, for clients that don’t read structured results. A result that doesn’t match its declared schema is logged and returned as internal error.
Clients use hints to decide whether to ask the user before calling a tool. They are derived from the HTTP method:
| HTTP method | readOnlyHint |
destructiveHint |
idempotentHint |
|---|---|---|---|
GET, HEAD |
true |
— | — |
PUT |
false |
false |
true |
DELETE |
false |
true |
true |
POST, PATCH |
false |
true |
false |
| service method | false |
true |
false |
A tool is read-only only when its method is GET/HEAD or you set readOnly: true. Hints are advice to clients, not permission checks.
#[McpTool] Arguments
Section titled “#[McpTool] Arguments”Every argument is optional, except that the tool needs a description from the attribute or the PHPDoc. Explicit values always win over derived ones.
description
Section titled “description”string, default ''. What the tool does, written for the model: when to use it, what it returns, units and limits. If empty, the first paragraph of the method’s PHPDoc is used. If both are empty, boot fails.
?string, default derived. The derived name is <prefix>_<method> in snake_case, where the prefix is the class name without a trailing Controller, Service or Component: OrderController::get becomes order_get, InvoiceService::resendEmail becomes invoice_resend_email. Names must match ^[A-Za-z0-9_.-]{1,128}$ and be unique in the application; a clash fails at boot and names both methods. Renaming a tool breaks agents that use it, so set name on tools you publish.
?string, default none. A short label for client UIs, such as "Create order".
inputSchema
Section titled “inputSchema”?array, default derived. A complete JSON Schema for the arguments. Nothing is derived when it is set, so use it for inputs the signature can’t describe. Its top level must be "type": "object", its properties must be exactly the tool’s arguments, and every argument without a default must be in required; otherwise boot fails. At call time type, enum, const, required, properties, additionalProperties, items, anyOf, $ref, minimum and maximum are enforced. Other keywords (format, pattern, oneOf, …) are passed to the client but not enforced, so validate those values in the method.
outputSchema
Section titled “outputSchema”?array, default derived. A complete JSON Schema for the result. The top level must be "type": "object", and it can’t be combined with outputType.
outputType
Section titled “outputType”?string, default none. The DTO class the result really is, for methods that return ResponseEntity, array, mixed, object or nothing typed. Setting it on a method that already returns a DTO fails at boot. For REST endpoints it is your promise about the JSON body, which is checked on every call.
httpMethod
Section titled “httpMethod”?string, default derived. Required when a #[RequestMapping] allows several HTTP methods (or none, which means all). It must be one of the mapping’s methods, and it isn’t allowed on service tools. It also decides the derived hints.
readOnly, destructive, idempotent, openWorld
Section titled “readOnly, destructive, idempotent, openWorld”?bool, default derived (openWorld: not sent). They override the hints in the table above. destructive and idempotent are only sent for tools that aren’t read-only. Set openWorld: true when the tool reaches systems outside your application (an external API, email). readOnly: true together with destructive: true or idempotent: false fails at boot.
Examples
Section titled “Examples”The model classes used below. DTOs that arrive as arguments are built like request bodies, so they need a constructor without required arguments, and nested objects and lists need #[JsonProperty].
enum OrderStatus: string { case Open = 'open'; case Paid = 'paid'; case Refunded = 'refunded';}
enum Carrier { case Dhl; case Ups; case Fedex;}
class Address { /** Street and house number. */ public string $street; public string $city; /** ISO 3166-1 alpha-2 country code, e.g. "DE". */ public string $country; public ?string $postcode = null;}
class OrderLineInput { public string $sku; /** Number of units, at least 1. */ public int $quantity = 1;}
class CreateOrderRequest { public string $customerId;
/** At least one line. */ #[JsonProperty(listClass: OrderLineInput::class)] public array $lines = [];
#[JsonProperty] public Address $shipTo;
/** Defaults to the shipping address. */ #[JsonProperty] public ?Address $billTo = null;
/** * Free-form labels, e.g. {"channel": "phone"}. * @var array<string, string> */ public array $labels = [];
#[JsonProperty('deliver_after')] public ?DateTimeImmutable $deliverAfter = null;}
class OrderLineDto { public string $sku; public int $quantity; /** Unit price in cents. */ #[JsonProperty('unit_price')] public int $unitPrice;}
class OrderDto { public int $id; public OrderStatus $status; public DateTimeImmutable $createdAt; #[JsonProperty(listClass: OrderLineDto::class)] public array $lines = []; public Address $shipTo; public ?string $note = null;}
class CategoryNode { public string $slug; public string $label; #[JsonProperty(listClass: CategoryNode::class)] public array $children = [];}Each JSON block is the exact tools/list entry Winter Boot produces for the code above it.
REST endpoint with outputType
Section titled “REST endpoint with outputType”#[RestController]#[RequestMapping(path: '/api/orders')]class OrderController{ /** * Fetch one order with its line items and payment status. * * @param int $id Order number, as printed on the invoice. */ #[GetMapping(path: '/{id}')] #[McpTool(outputType: OrderDto::class)] public function get(#[PathVariable] int $id): array { /* ... */ }}{ "name": "order_get", "description": "Fetch one order with its line items and payment status.", "inputSchema": { "type": "object", "properties": { "id": { "type": "integer", "minimum": 0, "description": "Order number, as printed on the invoice." } }, "required": ["id"], "additionalProperties": false }, "outputSchema": { "type": "object", "properties": { "id": {"type": "integer"}, "status": {"type": "string", "enum": ["open", "paid", "refunded"]}, "createdAt": {"type": "string", "format": "date-time"}, "lines": {"type": "array", "items": {"$ref": "#/$defs/OrderLineDto"}, "default": []}, "shipTo": {"$ref": "#/$defs/Address"}, "note": {"type": ["string", "null"], "default": null} }, "required": ["id", "status", "createdAt", "shipTo"], "$defs": { "OrderLineDto": { "type": "object", "properties": { "sku": {"type": "string"}, "quantity": {"type": "integer"}, "unit_price": {"type": "integer", "description": "Unit price in cents."} }, "required": ["sku", "quantity", "unit_price"] }, "Address": { "type": "object", "properties": { "street": {"type": "string", "description": "Street and house number."}, "city": {"type": "string"}, "country": { "type": "string", "description": "ISO 3166-1 alpha-2 country code, e.g. \"DE\"." }, "postcode": {"type": ["string", "null"], "default": null} }, "required": ["street", "city", "country"] } } }, "annotations": {"readOnlyHint": true}}The method returns an array, which says nothing about its shape, so outputType describes it. The endpoint must then return arrays shaped like OrderDto, with JSON names and dates as strings.
Query parameters, a hidden header and a list result
Section titled “Query parameters, a hidden header and a list result”/** * Search orders, newest first. * * @param string|null $status Only orders in this status: open, paid or refunded. * @param string|null $customer Only orders of this customer id. * @param int $limit Maximum number of orders to return, 1 to 100. * @return list<array{id: int, status: string, totalCents: int}> */#[GetMapping(path: '/search')]#[McpTool(name: 'search_orders', title: 'Search orders')]public function search( #[RequestParam(source: 'header', name: 'X-Tenant')] string $tenant, #[RequestParam(required: false)] ?string $status = null, #[RequestParam(required: false)] ?string $customer = null, #[RequestParam(required: false)] int $limit = 20,): array { /* ... */ }{ "name": "search_orders", "title": "Search orders", "description": "Search orders, newest first.", "inputSchema": { "type": "object", "properties": { "status": { "type": ["string", "null"], "description": "Only orders in this status: open, paid or refunded." }, "customer": { "type": ["string", "null"], "description": "Only orders of this customer id." }, "limit": { "type": "integer", "default": 20, "description": "Maximum number of orders to return, 1 to 100." } }, "additionalProperties": false }, "outputSchema": { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object", "properties": { "id": {"type": "integer"}, "status": {"type": "string"}, "totalCents": {"type": "integer"} }, "required": ["id", "status", "totalCents"] } } }, "required": ["items"] }, "annotations": {"title": "Search orders", "readOnlyHint": true}}$tenant isn’t an argument: the X-Tenant header of the agent’s /mcp request is passed to the endpoint. #[RequestParam] only takes scalars, so status is a string; list the allowed values in the @param text.
Request body, ResponseEntity and adjusted hints
Section titled “Request body, ResponseEntity and adjusted hints”/** * @param CreateOrderRequest $order The order to create. */#[PostMapping(path: '/')]#[McpTool( description: 'Create a new order. Prices come from the catalogue; the order starts as "open". ' . 'Returns the created order.', name: 'create_order', title: 'Create order', outputType: OrderDto::class, destructive: false,)]public function create(#[RequestBody] CreateOrderRequest $order): ResponseEntity { /* ... */ }{ "name": "create_order", "title": "Create order", "description": "Create a new order. Prices come from the catalogue; the order starts as \"open\". Returns the created order.", "inputSchema": { "type": "object", "properties": { "order": {"$ref": "#/$defs/CreateOrderRequest", "description": "The order to create."} }, "required": ["order"], "additionalProperties": false, "$defs": { "CreateOrderRequest": { "type": "object", "properties": { "customerId": {"type": "string"}, "lines": { "type": "array", "items": {"$ref": "#/$defs/OrderLineInput"}, "description": "At least one line.", "default": [] }, "shipTo": {"$ref": "#/$defs/Address"}, "billTo": { "anyOf": [{"$ref": "#/$defs/Address"}, {"type": "null"}], "description": "Defaults to the shipping address.", "default": null }, "labels": { "type": "object", "additionalProperties": {"type": "string"}, "description": "Free-form labels, e.g. {\"channel\": \"phone\"}.", "default": {} }, "deliver_after": { "type": ["string", "null"], "format": "date-time", "default": null } }, "required": ["customerId", "shipTo"] }, "OrderLineInput": { "type": "object", "properties": { "sku": {"type": "string"}, "quantity": { "type": "integer", "description": "Number of units, at least 1.", "default": 1 } }, "required": ["sku"] }, "Address": { "type": "object", "properties": { "street": {"type": "string", "description": "Street and house number."}, "city": {"type": "string"}, "country": { "type": "string", "description": "ISO 3166-1 alpha-2 country code, e.g. \"DE\"." }, "postcode": {"type": ["string", "null"], "default": null} }, "required": ["street", "city", "country"] } } }, "outputSchema": { "type": "object", "properties": { "id": {"type": "integer"}, "status": {"type": "string", "enum": ["open", "paid", "refunded"]}, "createdAt": {"type": "string", "format": "date-time"}, "lines": {"type": "array", "items": {"$ref": "#/$defs/OrderLineDto"}, "default": []}, "shipTo": {"$ref": "#/$defs/Address"}, "note": {"type": ["string", "null"], "default": null} }, "required": ["id", "status", "createdAt", "shipTo"], "$defs": { "OrderLineDto": { "type": "object", "properties": { "sku": {"type": "string"}, "quantity": {"type": "integer"}, "unit_price": {"type": "integer", "description": "Unit price in cents."} }, "required": ["sku", "quantity", "unit_price"] }, "Address": { "type": "object", "properties": { "street": {"type": "string", "description": "Street and house number."}, "city": {"type": "string"}, "country": { "type": "string", "description": "ISO 3166-1 alpha-2 country code, e.g. \"DE\"." }, "postcode": {"type": ["string", "null"], "default": null} }, "required": ["street", "city", "country"] } } }, "annotations": { "title": "Create order", "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false }}The body is one argument named order. POST would mark the tool destructive; creating an order removes nothing, so destructive: false keeps clients from warning about data loss.
A mapping with several HTTP methods
Section titled “A mapping with several HTTP methods”/** * @param int $id Order number. * @param string|null $reason Shown to the customer in the cancellation email. */#[RequestMapping(path: '/{id}/cancel', method: [RequestMethod::POST, RequestMethod::PUT])]#[McpTool( description: 'Cancel an open order and email the customer. Cancelling an order that is ' . 'already cancelled changes nothing. Paid orders cannot be cancelled; refund them instead.', name: 'cancel_order', httpMethod: RequestMethod::PUT, destructive: true,)]public function cancel( #[PathVariable] int $id, #[RequestParam(required: false)] ?string $reason = null,): array { /* ... */ }{ "name": "cancel_order", "description": "Cancel an open order and email the customer. Cancelling an order that is already cancelled changes nothing. Paid orders cannot be cancelled; refund them instead.", "inputSchema": { "type": "object", "properties": { "id": {"type": "integer", "minimum": 0, "description": "Order number."}, "reason": { "type": ["string", "null"], "description": "Shown to the customer in the cancellation email." } }, "required": ["id"], "additionalProperties": false }, "annotations": {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true}}Without httpMethod the application doesn’t start. PUT derives destructiveHint: false; cancelling loses state, so it was set to true. The method returns an undescribed array, which is allowed for results: the entry simply has no outputSchema.
Explicit schemas for inputs PHP can’t describe
Section titled “Explicit schemas for inputs PHP can’t describe”#[Service]class SalesReportService{ #[McpTool( description: 'Sales totals for a date range, grouped by day, product or country. ' . 'Each row has the group key and the total in cents.', name: 'sales_report', readOnly: true, openWorld: false, inputSchema: [ 'type' => 'object', 'properties' => [ 'groupBy' => ['type' => 'string', 'enum' => ['day', 'product', 'country']], 'range' => [ 'type' => 'object', 'properties' => [ 'from' => ['type' => 'string', 'format' => 'date'], 'to' => ['type' => 'string', 'format' => 'date'], ], 'required' => ['from', 'to'], 'additionalProperties' => false, ], 'countries' => [ 'type' => 'array', 'items' => ['type' => 'string', 'pattern' => '^[A-Z]{2}$'], 'description' => 'Only these countries (ISO codes). Empty means all.', ], ], 'required' => ['groupBy', 'range'], 'additionalProperties' => false, ], outputSchema: [ 'type' => 'object', 'properties' => [ 'rows' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'key' => ['type' => 'string'], 'totalCents' => ['type' => 'integer'], ], 'required' => ['key', 'totalCents'], ], ], ], 'required' => ['rows'], ], )] public function report(string $groupBy, array $range, array $countries = []): array { /* ... */ }}The tools/list entry carries both schemas exactly as written. $range and $countries are untyped arrays, so without inputSchema the application wouldn’t start. pattern and format aren’t enforced: validate dates and country codes in the method. Often a DTO (ReportRange $range) and @param list<string> $countries are simpler than an explicit schema.
Service tool with enums, lists and injected values
Section titled “Service tool with enums, lists and injected values”#[Service]class ShippingService{ /** * @param Carrier $carrier The carrier that shipped the parcels. * @param list<string> $trackingNumbers Up to 20 tracking numbers. * @return array<string, string> Tracking number => current status text. */ #[McpTool( description: 'Get the live delivery status of parcels from the carrier.', title: 'Track parcels', readOnly: true, openWorld: true, )] public function track( Carrier $carrier, array $trackingNumbers, HttpRequest $request, McpToolContext $ctx, ): array { if (count($trackingNumbers) > 20) { throw new McpToolArgumentException('trackingNumbers: at most 20 values'); } // ... }}{ "name": "shipping_track", "title": "Track parcels", "description": "Get the live delivery status of parcels from the carrier.", "inputSchema": { "type": "object", "properties": { "carrier": { "type": "string", "enum": ["Dhl", "Ups", "Fedex"], "description": "The carrier that shipped the parcels." }, "trackingNumbers": { "type": "array", "items": {"type": "string"}, "description": "Up to 20 tracking numbers." } }, "required": ["carrier", "trackingNumbers"], "additionalProperties": false }, "outputSchema": { "type": "object", "additionalProperties": {"type": "string"}, "description": "Tracking number => current status text." }, "annotations": {"title": "Track parcels", "readOnlyHint": true, "openWorldHint": true}}HttpRequest is the agent’s /mcp request and McpToolContext holds the tool name and request id; neither is an argument. Service tools are assumed to change data, so read-only ones set readOnly: true.
A recursive result
Section titled “A recursive result”#[Service]class CatalogService{ #[McpTool( description: 'The full product category tree. Use the slugs as category filters.', name: 'category_tree', readOnly: true, openWorld: false, )] public function tree(): CategoryNode { /* ... */ }}{ "name": "category_tree", "description": "The full product category tree. Use the slugs as category filters.", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}, "outputSchema": { "type": "object", "properties": { "slug": {"type": "string"}, "label": {"type": "string"}, "children": {"type": "array", "items": {"$ref": "#"}, "default": []} }, "required": ["slug", "label"] }, "annotations": {"readOnlyHint": true, "openWorldHint": false}}Errors the Agent Sees
Section titled “Errors the Agent Sees”| Situation | Text of the isError result |
|---|---|
| an argument doesn’t match the schema | the path and expected type, e.g. order.lines[0].quantity: expected integer |
the method throws McpToolArgumentException |
its message |
| the endpoint answers 4xx (except 401/403) | HTTP 400 Bad Request and the response body (cut at 4000 bytes) |
a service method throws an HttpRestException with a 4xx status |
its message |
401/403, McpToolDeniedException, or a McpToolInterceptor refuses |
not permitted |
| 5xx, any other exception, a result that breaks its schema | internal error (details only in the log) |
Throw McpToolArgumentException (namespace dev\winterframework\mcp\exception) for problems the model can fix, such as a value out of range. Its message goes to the model, so keep secrets and large inputs out of it.
Authentication
Section titled “Authentication”MCP doesn’t add its own login. Tool calls are authenticated by the same mechanisms your app already uses, with no MCP-specific code: HandlerInterceptor and ControllerInterceptor.
How the agent’s credentials travel
Section titled “How the agent’s credentials travel”The agent sends credentials as ordinary HTTP headers on its /mcp requests. For Claude Code:
claude mcp add --transport http orders https://orders.example.com/mcp \ --header "Authorization: Bearer <token>"For a REST tool, Winter Boot copies the agent’s headers (Authorization, X-Api-Key, your own headers) and cookies onto the in-process request to your endpoint. Your checks therefore read them exactly as they do for a browser.
Step 1: protect /mcp with your HandlerInterceptor
Section titled “Step 1: protect /mcp with your HandlerInterceptor”Register your existing authentication interceptor for a pattern that also matches /mcp:
<?php
namespace App\Config;
use App\Security\ApiKeyInterceptor;use dev\winterframework\core\web\config\InterceptorRegistry;use dev\winterframework\core\web\config\WebMvcConfigurer;use dev\winterframework\stereotype\Configuration;
#[Configuration(name: 'webMvcConfigurer')]class WebConfig implements WebMvcConfigurer{ public function addInterceptors(InterceptorRegistry $registry): void { // Your REST API and the MCP endpoint $registry->addInterceptor(new ApiKeyInterceptor(), '^\/api\/', '^\/mcp'); }}<?php
namespace App\Security;
use dev\winterframework\core\web\HandlerInterceptor;use dev\winterframework\web\http\HttpRequest;use dev\winterframework\web\http\HttpStatus;use dev\winterframework\web\http\ResponseEntity;
class ApiKeyInterceptor implements HandlerInterceptor{ public function preHandle(HttpRequest $request, ResponseEntity $response): bool { if (!$this->isValid($request->getFirstHeader('Authorization'))) { $response->withStatus(HttpStatus::$UNAUTHORIZED); // always set the status when refusing return false; } return true; }
public function postHandle(HttpRequest $request, ResponseEntity $response): void { }
public function afterCompletion(HttpRequest $request, ResponseEntity $response, ?\Throwable $ex = null): void { }
private function isValid(?string $authorization): bool { /* ... */ }}A refused /mcp request gets 401 before any MCP code runs, so an unauthenticated agent can’t even list the tools.
Step 2: your endpoint checks keep working
Section titled “Step 2: your endpoint checks keep working”A REST tool is called through the normal pipeline, so every check on the endpoint runs again on the in-process request:
| Your existing check | Runs for the tool call? |
|---|---|
HandlerInterceptor whose pattern matches the endpoint ('^\/api\/') |
yes, with the agent’s headers |
ControllerInterceptor implemented by the controller |
yes, before the method |
| AOP on the controller method | yes |
| checks inside the method itself | yes |
For example, a controller that guards itself:
#[RestController]#[RequestMapping(path: '/api/admin')]class AdminController implements ControllerInterceptor{ public function preHandle(HttpRequest $request, ResponseEntity $response, ReflectionMethod $handler): bool { if (!$this->isAdmin($request)) { $response->withStatus(HttpStatus::$FORBIDDEN); return false; } return true; }
public function postHandle(HttpRequest $request, ResponseEntity $response, ReflectionMethod $handler): void { }
#[GetMapping(path: '/stats')] #[McpTool(description: 'Order statistics for administrators.')] public function stats(): array { /* ... */ }}An agent whose credentials aren’t an admin’s gets not permitted, and stats() never runs.
Step 3: service tools
Section titled “Step 3: service tools”A tool on a #[Service] or #[Component] method has no endpoint of its own, so step 2 doesn’t apply: only the interceptors on /mcp (step 1) protect it. For per-tool rules, such as “read-only keys may not call write tools”, use a McpToolInterceptor (below). To read the caller’s credentials inside the method, declare an HttpRequest parameter: it is the agent’s /mcp request.
Good to know
Section titled “Good to know”- Interceptors run twice for REST tools: once for
/mcpand once for the endpoint. A rate limiter can skip the second pass with$request instanceof \dev\winterframework\web\http\InternalHttpRequest. - Context path: write patterns without
server.context-path('^\/api\/', not'^\/svc\/api\/'). Routes also answer without the prefix, and a prefixed pattern doesn’t match those requests. - Browsers: a request with an
Originheader is refused (403) unless that exact origin is inwinter.mcp.allowedOrigins. This blocks DNS-rebinding attacks from web pages. Desktop and CLI clients send noOrigin. - Logs name the tool, the outcome and the duration, never arguments, results or headers.
Per-tool rules with McpToolInterceptor
Section titled “Per-tool rules with McpToolInterceptor”Implement dev\winterframework\mcp\McpToolInterceptor on a #[Component] to hide tools, refuse calls or audit them. All implementations are applied, in class-name order.
<?php
namespace App\Security;
use dev\winterframework\mcp\exception\McpToolDeniedException;use dev\winterframework\mcp\McpToolContext;use dev\winterframework\mcp\McpToolDefinition;use dev\winterframework\mcp\McpToolInterceptor;use dev\winterframework\stereotype\Component;use dev\winterframework\web\http\HttpRequest;
#[Component]class ReadOnlyKeyGuard implements McpToolInterceptor{ public function isVisible(McpToolDefinition $tool, HttpRequest $request): bool { // Read-only keys don't even see write tools. return $tool->readOnly || $this->canWrite($request); }
public function beforeCall(McpToolDefinition $tool, McpToolContext $ctx): void { if (!$tool->readOnly && !$this->canWrite($ctx->getRequest())) { throw new McpToolDeniedException('read-only API key'); // logged; the model sees "not permitted" } }
public function afterCall(McpToolDefinition $tool, McpToolContext $ctx, ?\Throwable $error): void { }
private function canWrite(HttpRequest $request): bool { /* ... */ }}A hidden tool answers tools/call exactly like an unknown one.
McpToolContext also gives the call’s validated arguments and, in afterCall(), how it ended (2.1.7+):
$ctx->getArguments(), or$ctx->getArgument('site'): for example for an audit log. Arguments can carry user data, so log only what you need.$ctx->getOutcome():ok,tool_error(an error result the model sees: invalid arguments,McpToolArgumentException, a 4xx),deniedorinternal;$ctx->isOk()is the short form. The$errorparameter ofafterCall()is only set when an exception was thrown, so use the outcome to count failures.
public function afterCall(McpToolDefinition $tool, McpToolContext $ctx, ?\Throwable $error): void{ self::logInfo('MCP audit', [ 'tool' => $tool->name, 'site' => mb_substr((string)$ctx->getArgument('site', ''), 0, 255), 'outcome' => $ctx->getOutcome(), ]);}Resources
Section titled “Resources”Resources are data a client can read and show to the model, such as a report or a configuration, without the model calling a tool. Put #[McpResource] on a #[Service] or #[Component] method (2.1.7+).
use dev\winterframework\mcp\exception\McpResourceNotFoundException;use dev\winterframework\stereotype\mcp\McpResource;use dev\winterframework\stereotype\Service;use dev\winterframework\web\http\HttpRequest;
#[Service]class SiteResources{ /** Public settings of this server. */ #[McpResource(uri: 'config://app', title: 'App configuration')] public function config(): array { return ['version' => '1.4', 'timezone' => 'UTC']; }
/** * Traffic overview for a site; range is "today" or "last-7-days". */ #[McpResource(uri: 'site://{domain}/summary/{range}', name: 'site_summary', listMethod: 'listSummaries')] public function summary(string $domain, string $range, HttpRequest $request): array { if (!$this->canRead($request, $domain) || !in_array($range, ['today', 'last-7-days'], true)) { throw new McpResourceNotFoundException('no such site or range'); } return $this->stats->overview($domain, $range); }
/** The summaries this caller may read, for resources/list. */ public function listSummaries(HttpRequest $request): array { $out = []; foreach ($this->sitesFor($request) as $site) { $out[] = ['uri' => "site://$site/summary/today", 'name' => "$site today", 'title' => "$site: today"]; } return $out; }}| Option | Meaning |
|---|---|
uri (required) |
A fixed URI (config://app), or a URI template with {placeholders}. Each placeholder is passed to the method parameter of the same name, as a string, int, float or bool. A placeholder matches one path segment. |
name |
Resource name; default derived like tool names. |
title, description |
Shown to users and the model. The description defaults to the PHPDoc summary. |
mimeType |
Default text/plain when the method returns string, otherwise application/json. |
listMethod |
A public method of the same bean returning the concrete resources for resources/list: a list of ['uri' => ..., 'name' => ...] arrays, optionally with title, description and mimeType. It may take an HttpRequest parameter to list only what the caller may read. |
How they’re served:
- A fixed URI appears in
resources/list; a template inresources/templates/list, and the entries of itslistMethodinresources/list. resources/readfinds the resource by URI, binds the placeholders, and returns the method’s result: astringas is, anything else as JSON.- A URI that matches nothing, a placeholder that doesn’t fit its parameter type (
item://abcfor anint $id), a method that returnsnullor throwsMcpResourceNotFoundException, and aHttpRestExceptionwith 401, 403 or 404 are all answered with “Resource not found” (-32002). A resource the caller may not see therefore looks the same as one that doesn’t exist. - Declare an
HttpRequestparameter to see the caller’s headers. Resources are protected by the interceptors on/mcp(see Authentication);McpToolInterceptorapplies to tools only. - Startup fails when a placeholder has no parameter of the same name (or the other way round), a placeholder appears twice, a
listMethodis missing or needs arguments, or two resources share a name or URI.
Prompts
Section titled “Prompts”Prompts are reusable message templates that clients offer to users, for example as slash commands. Put #[McpPrompt] on a #[Service] or #[Component] method (2.1.7+):
use dev\winterframework\stereotype\mcp\McpPrompt;use dev\winterframework\stereotype\Service;
#[Service]class ReportPrompts{ /** * Summarise the last 7 days against the week before. * * @param string $site Site domain. * @param string|null $period Period, e.g. 30d. */ #[McpPrompt(name: 'weekly_report', title: 'Weekly report')] public function weekly(string $site, ?string $period = null): string { return "Write a weekly traffic report for $site over " . ($period ?? 'the last 7 days') . '.'; }
/** Find out what caused a spike or drop in traffic. */ #[McpPrompt(name: 'explain_spike')] public function explainSpike(string $site, string $date = 'the recent change'): array { return [ ['role' => 'user', 'text' => "Traffic for $site changed around $date. Find the cause."], ['role' => 'assistant', 'text' => 'I will compare the days before and after, by source and page.'], ]; }}prompts/list shows:
{"name": "weekly_report", "title": "Weekly report", "description": "Summarise the last 7 days against the week before.", "arguments": [ {"name": "site", "description": "Site domain.", "required": true}, {"name": "period", "description": "Period, e.g. 30d.", "required": false} ]}- The prompt’s arguments are the method’s parameters, which must be
string(MCP prompt arguments are strings). An argument is required unless it has a default or is nullable; its description is the@paramtext. AnHttpRequestparameter is injected instead. - The method returns the text of one user message, or a list of
['role' => 'user' | 'assistant', 'text' => ...]messages. prompts/getwith a missing required argument, an unknown argument or a non-string value is rejected (-32602) without calling the method. ThrowMcpToolArgumentExceptionfor other invalid values.- Names default like tool names (
report_prompts_weekly) and must be unique.
Configuration
Section titled “Configuration”All keys are optional:
winter: mcp: path: /mcp # relative to server.context-path serverName: orders # default: winter.application.name serverTitle: Order Service # shown by clients; not sent when empty (2.1.7+) instructions: "Start with search_orders." allowedOrigins: [ "https://inspector.example" ] maxBodyBytes: 1048576With server.context-path: /svc the endpoint is /svc/mcp. The server version reported to clients is winter.application.version. See the application.yml reference.
Protocol Details
Section titled “Protocol Details”- Transport: Streamable HTTP, stateless, plain JSON responses. No sessions and no server-sent events (
GETreturns405). - Protocol versions
2025-11-25,2025-06-18and2025-03-26. An unknown requested version gets2025-11-25, and an unsupportedMCP-Protocol-Versionheader gets400. - Supported methods:
initialize,ping,tools/list,tools/call; with resources (2.1.7+)resources/list,resources/templates/list,resources/read; with prompts (2.1.7+)prompts/list,prompts/get.initializeadvertises only what the application has. Notifications get202. JSON-RPC batches get400. - Requests must be
Content-Type: application/json. AnAcceptheader, when sent, must allow JSON. Bodies overmaxBodyBytesget413. - Metrics:
mcp_tool_calls(labelstool,outcome) andmcp_tool_duration(labeltool), next tohttp_request_duration. - Resource subscriptions, progress notifications and cancellation aren’t supported yet.
Startup Errors
Section titled “Startup Errors”| Mistake | Fix |
|---|---|
no description and no PHPDoc summary |
add one |
| invalid or duplicate tool name | set a unique name |
| method is static, private, abstract or a constructor | use a public instance method |
class isn’t a #[RestController], #[Service] or #[Component] |
move the method or add the attribute |
#[RestController] method without a request mapping |
add the mapping, or move it to a service |
mapping with several methods and no httpMethod; httpMethod not in the mapping or on a service |
fix httpMethod |
untyped array, mixed or object argument |
add a PHPDoc type, use a DTO, or set inputSchema |
| abstract DTO, or DTOs nested deeper than 8 levels | use concrete, flatter DTOs or an explicit schema |
inputSchema that doesn’t match the arguments; a schema override whose top level isn’t an object |
align it with the signature |
outputSchema together with outputType, or outputType on a method returning a DTO |
keep one |
readOnly: true with destructive: true or idempotent: false |
remove the contradiction |
variadic parameter, or a ResponseEntity parameter on a service tool |
change the signature |
| another route already uses the MCP path | set winter.mcp.path |