Skip to content

Commit c40b0d1

Browse files
authored
feat(mcp): harden and modernize the MCP server layer (#738)
* feat(mcp): harden and modernize the MCP server layer - Make operation tools stateless per call: tools hold only the repository class string and resolve a fresh Repository in every handle(), fixing the root cause behind #735 (singleton-cached repository state) and removing the store-model band-aid. Collapse the 8 duplicate execute* dispatch methods in McpToolsManager. - Return real MCP errors: all failure paths now set isError=true via Response::error() with a stable {error, code, errors, detail} payload; actions/getters propagate per-field ValidationException detail like CRUD; raw exception messages only surface when app.debug is on; fix the 'getter' vs 'action' key in the action authorization error. - Add tool annotations (#[IsReadOnly]/#[IsDestructive]/#[IsIdempotent]/ #[IsOpenWorld]) to every operation, wrapper, and search tool, and expose the same hints per operation in the wrapper catalog payloads. - Harden the server: default mode flips to 'wrapper' (breaking, 10.x), the per-request ?mode= override is removed, new restify.mcp.read_only mode gates write operations in discovery and execution, and store file ingestion rejects local paths by default, blocks private-IP URLs, and caps download size. - Emit structuredContent alongside JSON text on tool results so hosts can parse responses deterministically. - New restify:skill artisan command generating SKILL.md + openapi.json from MCP-enabled repositories (auth, pagination, filter conventions, per-repository operations and curl examples). BREAKING CHANGE: restify.mcp.mode now defaults to 'wrapper'; set RESTIFY_MCP_MODE=direct to keep the previous per-operation tool exposure. * Fix styling * fix(tests): use a temp file instead of /etc/hosts in the local-path guard test /etc/hosts does not exist on Windows runners, so the allow_local_paths assertion received null instead of an UploadedFile. A test-created temp file also proves the default rejection against a genuinely readable file. --------- Co-authored-by: binaryk <6833714+binaryk@users.noreply.github.com>
1 parent e4b6d97 commit c40b0d1

35 files changed

Lines changed: 2251 additions & 313 deletions

config/restify.php

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,47 @@
326326
| regardless of this setting.
327327
|
328328
*/
329-
'mode' => env('RESTIFY_MCP_MODE', 'direct'),
329+
'mode' => env('RESTIFY_MCP_MODE', 'wrapper'),
330+
331+
/*
332+
|--------------------------------------------------------------------------
333+
| Read-Only Mode
334+
|--------------------------------------------------------------------------
335+
|
336+
| When enabled, the MCP server refuses every write operation (store,
337+
| update, delete, action). In 'direct' mode only read tools (index, show,
338+
| getter, profile) are registered; in 'wrapper' mode write operations are
339+
| hidden from discovery and rejected by execute-operation.
340+
|
341+
*/
342+
'read_only' => env('RESTIFY_MCP_READ_ONLY', false),
343+
344+
/*
345+
|--------------------------------------------------------------------------
346+
| File Ingestion
347+
|--------------------------------------------------------------------------
348+
|
349+
| Controls how AI-supplied file fields are resolved into uploads.
350+
|
351+
*/
352+
'files' => [
353+
/*
354+
| Allow AI-supplied values to reference local filesystem paths. Disabled
355+
| by default: enabling it lets an MCP client read arbitrary server files.
356+
*/
357+
'allow_local_paths' => env('RESTIFY_MCP_FILES_ALLOW_LOCAL_PATHS', false),
358+
359+
/*
360+
| Maximum number of bytes fetched from a remote file URL. Downloads
361+
| larger than this are rejected. Defaults to 10MB.
362+
*/
363+
'max_bytes' => env('RESTIFY_MCP_FILES_MAX_BYTES', 10 * 1024 * 1024),
364+
365+
/*
366+
| Timeout in seconds applied when fetching a remote file URL.
367+
*/
368+
'timeout' => env('RESTIFY_MCP_FILES_TIMEOUT', 10),
369+
],
330370

331371
'tools' => [
332372
'exclude' => [

docs-v3/content/docs/6.mcp/1.mcp.md

Lines changed: 155 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,49 @@ This approach allows you to:
168168
- Set validation rules (required, optional)
169169
- Override automatic type inference when it's incorrect
170170

171+
### Error Responses
172+
173+
When an operation fails, Restify returns a real MCP error result with `isError` set to `true` on the tool result, so agents can reliably distinguish failures from successful calls.
174+
175+
The error content is a JSON payload with a stable shape. The keys are guaranteed to be stable so agents can parse them:
176+
177+
```json
178+
{
179+
"error": "Validation failed",
180+
"code": "VALIDATION_ERROR",
181+
"errors": {
182+
"title": ["The title field is required."]
183+
}
184+
}
185+
```
186+
187+
- `error`: a human-readable message.
188+
- `code`: a machine-readable code such as `MISSING_PARAMETER`, `INVALID_OPERATION`, `VALIDATION_ERROR`, `AUTHORIZATION_ERROR`, `NOT_FOUND`, or `EXECUTION_ERROR`.
189+
- `errors`: present for validation failures, containing per-field messages. Actions and getters surface the same per-field detail as CRUD `store`/`update` operations.
190+
- `detail`: present only for unexpected server errors and only when `app.debug` is enabled. Internal exception messages are never leaked to clients in production.
191+
192+
### Structured Tool Output
193+
194+
Successful tool results are emitted with both the existing JSON `text` content and a machine-parsable `structuredContent` field on the same tool result. This lets hosts read the payload deterministically without re-parsing the text block, while clients that only read `text` continue to work unchanged.
195+
196+
Both the operation tools (index, show, store, update, delete, profile, action, getter) and the 4 wrapper tools (`discover-repositories`, `get-repository-operations`, `get-operation-details`, `execute-operation`) return structured content. The `structuredContent` value is always byte-for-byte identical to the JSON parsed from the `text` block:
197+
198+
```json
199+
{
200+
"result": {
201+
"content": [
202+
{ "type": "text", "text": "{ \"success\": true, \"total\": 1, ... }" }
203+
],
204+
"structuredContent": { "success": true, "total": 1, "...": "..." },
205+
"isError": false
206+
}
207+
}
208+
```
209+
210+
The three read-only wrapper discovery tools also publish an `outputSchema` (their shape is stable), so hosts can validate `structuredContent` up front. Operation tools such as index/show derive their shape from repository fields at runtime and therefore do not declare a static `outputSchema`.
211+
212+
Error results are intentionally **not** given `structuredContent`: they are already flagged with `isError: true` and carry the stable JSON error payload described above in their `text` content.
213+
171214
## Configuration
172215

173216
The MCP integration respects your existing Restify configuration and adds MCP-specific options:
@@ -178,7 +221,13 @@ The MCP integration respects your existing Restify configuration and adds MCP-sp
178221
'server_name' => 'My App MCP Server',
179222
'server_version' => '1.0.0',
180223
'default_pagination' => 25,
181-
'mode' => env('RESTIFY_MCP_MODE', 'direct'), // 'direct' or 'wrapper'
224+
'mode' => env('RESTIFY_MCP_MODE', 'wrapper'), // 'wrapper' (default) or 'direct'
225+
'read_only' => env('RESTIFY_MCP_READ_ONLY', false), // refuse all write operations
226+
'files' => [
227+
'allow_local_paths' => env('RESTIFY_MCP_FILES_ALLOW_LOCAL_PATHS', false),
228+
'max_bytes' => env('RESTIFY_MCP_FILES_MAX_BYTES', 10 * 1024 * 1024),
229+
'timeout' => env('RESTIFY_MCP_FILES_TIMEOUT', 10),
230+
],
182231
'tools' => [
183232
'exclude' => [
184233
// Tools to exclude from discovery
@@ -190,11 +239,15 @@ The MCP integration respects your existing Restify configuration and adds MCP-sp
190239
],
191240
```
192241

242+
::alert{type="warning"}
243+
As of Restify 10, the MCP mode defaults to **`wrapper`** (previously `direct`). Wrapper mode exposes only 4 tools and hides individual operations behind a progressive discovery workflow. Set `RESTIFY_MCP_MODE=direct` to restore the previous behavior.
244+
::
245+
193246
## MCP Mode: Direct vs Wrapper
194247

195248
Laravel Restify offers two modes for exposing your repositories through MCP: **Direct Mode** and **Wrapper Mode**. Each mode has different trade-offs in terms of token usage and discoverability.
196249

197-
### Direct Mode (Default)
250+
### Direct Mode
198251

199252
In direct mode, every repository operation (index, show, store, update, delete) and custom action/getter is exposed as a separate MCP tool. This provides maximum discoverability for AI agents.
200253

@@ -212,9 +265,9 @@ In direct mode, every repository operation (index, show, store, update, delete)
212265
RESTIFY_MCP_MODE=direct
213266
```
214267

215-
### Wrapper Mode (Token-Efficient)
268+
### Wrapper Mode (Default, Token-Efficient)
216269

217-
Wrapper mode uses a progressive discovery pattern that exposes only **4 wrapper tools** regardless of how many repositories you have. AI agents discover and execute operations through a multi-step workflow.
270+
Wrapper mode is the default. It uses a progressive discovery pattern that exposes only **4 wrapper tools** regardless of how many repositories you have. AI agents discover and execute operations through a multi-step workflow.
218271

219272
**When to use Wrapper Mode:**
220273
- You have many repositories (10+)
@@ -279,24 +332,43 @@ Lists all operations, actions, and getters available for a specific repository.
279332
{
280333
"success": true,
281334
"repository": "users",
282-
"crud_operations": ["index", "show", "store", "update", "delete", "profile"],
335+
"operations": [
336+
{
337+
"type": "index",
338+
"name": "users-index-tool",
339+
"title": "Users",
340+
"description": "List User records",
341+
"annotations": { "readOnlyHint": true }
342+
},
343+
{
344+
"type": "delete",
345+
"name": "users-delete-tool",
346+
"title": "Users Delete",
347+
"description": "Delete an existing User record",
348+
"annotations": { "destructiveHint": true, "idempotentHint": true }
349+
}
350+
],
283351
"actions": [
284352
{
285353
"name": "activate-user",
286354
"title": "Activate User",
287-
"description": "Activate a user account"
355+
"description": "Activate a user account",
356+
"annotations": { "openWorldHint": true }
288357
}
289358
],
290359
"getters": [
291360
{
292361
"name": "active-users",
293362
"title": "Active Users",
294-
"description": "Get all active users"
363+
"description": "Get all active users",
364+
"annotations": { "readOnlyHint": true }
295365
}
296366
]
297367
}
298368
```
299369

370+
Each operation, action, and getter carries an `annotations` object with the same [MCP tool safety hints](#tool-safety-annotations) that direct mode exposes on the tool definitions, so agents get consistent safety signals through the wrapper path.
371+
300372
#### 3. `get-operation-details`
301373
Returns the complete JSON schema and documentation for a specific operation, including all parameters, validation rules, and examples.
302374

@@ -316,6 +388,7 @@ Returns the complete JSON schema and documentation for a specific operation, inc
316388
"type": "create",
317389
"title": "Create User",
318390
"description": "Create a new user account",
391+
"annotations": {},
319392
"schema": {
320393
"type": "object",
321394
"properties": {
@@ -367,6 +440,20 @@ Executes a repository operation with the provided parameters. This is the final
367440
}
368441
```
369442

443+
### Tool Safety Annotations
444+
445+
Every operation tool is tagged with the [MCP tool annotations](https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations) that describe its behavior, so a host can warn before running a destructive call or safely cache a read-only one. Restify applies them automatically based on the operation type:
446+
447+
| Operation | Annotation hints |
448+
| --- | --- |
449+
| `index`, `show`, `profile`, getters, `global-search` | `readOnlyHint: true` |
450+
| `store` | *(none — a create is neither idempotent nor read-only)* |
451+
| `update` | `idempotentHint: true` |
452+
| `delete` | `destructiveHint: true`, `idempotentHint: true` |
453+
| actions | `openWorldHint: true` (an action may touch external systems) |
454+
455+
In **direct mode** these appear in the `annotations` field of each tool definition returned by `tools/list`. In **wrapper mode** the per-operation tools are not registered with the host, so the same hints are surfaced inside the `annotations` object of every entry returned by `get-repository-operations` and `get-operation-details`.
456+
370457
### Wrapper Mode Workflow
371458

372459
Here's a typical workflow when an AI agent uses wrapper mode:
@@ -383,14 +470,14 @@ This progressive discovery pattern reduces token usage while maintaining full fu
383470
You can switch between modes at any time by updating your `.env` file:
384471

385472
```bash
386-
# Direct mode (default)
387-
RESTIFY_MCP_MODE=direct
388-
389-
# Wrapper mode (token-efficient)
473+
# Wrapper mode (default, token-efficient)
390474
RESTIFY_MCP_MODE=wrapper
475+
476+
# Direct mode
477+
RESTIFY_MCP_MODE=direct
391478
```
392479

393-
No code changes are required. The MCP server automatically adapts to the configured mode.
480+
No code changes are required. The MCP server automatically adapts to the configured mode. The mode is read from configuration only — a request parameter cannot override it.
394481

395482
### Performance Considerations
396483

@@ -408,12 +495,66 @@ No code changes are required. The MCP server automatically adapts to the configu
408495

409496
### Best Practices
410497

411-
1. **Start with Direct Mode** during development to verify all tools are working correctly
412-
2. **Switch to Wrapper Mode** in production if you have 10+ repositories or token efficiency is important
498+
1. **Keep the default Wrapper Mode** in production if you have 10+ repositories or token efficiency is important
499+
2. **Switch to Direct Mode** during development when you want every tool visible immediately
413500
3. **Use wrapper mode** when working with AI agents that have limited context windows
414501
4. **Monitor token usage** to determine which mode is best for your application
415502
5. **Document your choice** so team members understand which mode is active
416503

504+
## Read-Only Mode
505+
506+
When you want to give an AI agent access to your data without letting it mutate anything, enable read-only mode:
507+
508+
```bash
509+
RESTIFY_MCP_READ_ONLY=true
510+
```
511+
512+
With read-only mode enabled, every write operation (`store`, `update`, `delete`, and custom actions) is refused:
513+
514+
- In **direct mode**, only read tools (`index`, `show`, getters, and `profile`) are registered with the host. Write tools are never exposed.
515+
- In **wrapper mode**, write operations are hidden from `discover-repositories`, `get-repository-operations`, and `get-operation-details`, and `execute-operation` rejects them with an authorization-style `isError` response.
516+
517+
Enforcement happens centrally, so an agent cannot re-enable a hidden operation by calling it directly.
518+
519+
## Generating an Agent Skill
520+
521+
Agents increasingly consume APIs through [Agent Skills](https://www.anthropic.com/news/skills) and code execution rather than raw MCP calls. Restify can emit a ready-to-ship skill straight from your MCP-enabled repositories, so the documentation an agent reads never drifts from what your API actually exposes:
522+
523+
```bash
524+
php artisan restify:skill
525+
```
526+
527+
By default the files are written to `./restify-skill/`. Pass `--path` to choose another directory:
528+
529+
```bash
530+
php artisan restify:skill --path=resources/skills/api
531+
```
532+
533+
The command produces two files:
534+
535+
- **`SKILL.md`** — a Claude Agent Skill with YAML front matter and markdown that teaches the base URL convention (`/api/restify/{repository}`), Sanctum bearer authentication, pagination, sorting, related resources, search, and Restify's filter convention (`filterName=value`, **not** `filter[name]=value`). It then documents one section per MCP-enabled repository: available operations, the field schema, matchables/sortables/searchables, and copy-paste `curl` examples.
536+
- **`openapi.json`** — an OpenAPI 3.1 description with a path per enabled operation (`index`, `show`, `store`, `update`, `delete`, actions, and getters), request bodies derived from your store/update validation rules, and a `bearerAuth` security scheme.
537+
538+
Only repositories using the `HasMcpTools` trait and the operations they expose are included. [Read-only mode](#read-only-mode) is honored too: when it's enabled, write operations and actions are omitted from both files.
539+
540+
## File Ingestion Safety
541+
542+
Repository `File` fields accept an AI-supplied value that Restify turns into an upload. To avoid server-side request forgery (SSRF) and local file disclosure, ingestion is locked down by default:
543+
544+
- **Local filesystem paths are disabled.** A value like `/etc/hosts` is ignored unless you explicitly opt in with `RESTIFY_MCP_FILES_ALLOW_LOCAL_PATHS=true`.
545+
- **Only `http`/`https` URLs are fetched.** Other schemes (`file://`, `ftp://`, …) are rejected.
546+
- **Private, loopback, link-local, and reserved IPs are rejected**, including hosts that resolve to them, so an agent cannot reach internal services (for example `http://169.254.169.254` or `http://127.0.0.1`).
547+
- **Downloads are capped and time-limited** by `RESTIFY_MCP_FILES_MAX_BYTES` (default 10MB) and `RESTIFY_MCP_FILES_TIMEOUT` (default 10 seconds). Redirects are not followed.
548+
549+
```bash
550+
# Opt in to local paths (use with care)
551+
RESTIFY_MCP_FILES_ALLOW_LOCAL_PATHS=true
552+
553+
# Tune the remote fetch guardrails
554+
RESTIFY_MCP_FILES_MAX_BYTES=10485760
555+
RESTIFY_MCP_FILES_TIMEOUT=10
556+
```
557+
417558
## Fine-Grained Tool Permissions
418559

419560
Laravel Restify's MCP integration includes a powerful permission system that allows you to control which tools each API token can access. This is essential for multi-tenant applications or when you need to restrict AI agent capabilities.

src/Commands/SkillCommand.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Commands;
4+
5+
use Binaryk\LaravelRestify\MCP\Skill\RestifySkillGenerator;
6+
use Binaryk\LaravelRestify\Restify;
7+
use Illuminate\Console\Command;
8+
use Illuminate\Support\Facades\File;
9+
10+
class SkillCommand extends Command
11+
{
12+
protected $signature = 'restify:skill {--path= : Directory where the skill files are written (default ./restify-skill)}';
13+
14+
protected $description = 'Generate an Agent Skill (SKILL.md) and OpenAPI description from the MCP-enabled repositories';
15+
16+
public function handle(RestifySkillGenerator $generator): int
17+
{
18+
Restify::ensureRepositoriesLoaded();
19+
20+
$repositories = $generator->repositories();
21+
22+
if ($repositories->isEmpty()) {
23+
$this->warn('No MCP-enabled repositories found. Add the HasMcpTools trait to a repository first.');
24+
25+
return self::SUCCESS;
26+
}
27+
28+
$path = rtrim($this->option('path') ?: getcwd().'/restify-skill', '/');
29+
30+
File::ensureDirectoryExists($path);
31+
32+
$this->info("Generating skill for {$repositories->count()} repository(ies)...");
33+
34+
$repositories->each(function (array $repository): void {
35+
$this->line(" - {$repository['name']}");
36+
});
37+
38+
$skillPath = $path.'/SKILL.md';
39+
$openApiPath = $path.'/openapi.json';
40+
41+
File::put($skillPath, $generator->markdown());
42+
File::put($openApiPath, json_encode($generator->openApi(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL);
43+
44+
$this->comment("Wrote {$skillPath}");
45+
$this->comment("Wrote {$openApiPath}");
46+
47+
return self::SUCCESS;
48+
}
49+
}

src/LaravelRestifyServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Binaryk\LaravelRestify\Commands\RestifyRouteListCommand;
1919
use Binaryk\LaravelRestify\Commands\SetupAuthCommand;
2020
use Binaryk\LaravelRestify\Commands\SetupCommand;
21+
use Binaryk\LaravelRestify\Commands\SkillCommand;
2122
use Binaryk\LaravelRestify\Commands\StoreCommand;
2223
use Binaryk\LaravelRestify\Commands\StubCommand;
2324
use Binaryk\LaravelRestify\Commands\ToolCommand;
@@ -54,6 +55,7 @@ public function configurePackage(Package $package): void
5455
ToolCommand::class,
5556
McpResourceCommand::class,
5657
GraphqlGenerateCommand::class,
58+
SkillCommand::class,
5759
]);
5860
}
5961

0 commit comments

Comments
 (0)