php-mcp/docs/creating-mcp-tools.md
Snider 1a95091b9c docs: add package documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:47:52 +00:00

787 lines
19 KiB
Markdown

# Guide: Creating MCP Tools
This guide covers everything you need to create MCP tools for AI agents, from basic tools to advanced patterns with workspace context, dependencies, and security best practices.
## Overview
MCP (Model Context Protocol) tools allow AI agents to interact with your application. Each tool:
- Has a unique name and description
- Defines input parameters with JSON Schema
- Executes logic and returns structured responses
- Can require workspace context for multi-tenant isolation
- Can declare dependencies on other tools
## Tool Interface
All MCP tools extend `Laravel\Mcp\Server\Tool` and implement two required methods:
```php
<?php
declare(strict_types=1);
namespace Mod\Blog\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListPostsTool extends Tool
{
protected string $description = 'List all blog posts with optional filters';
public function handle(Request $request): Response
{
// Tool logic here
$posts = Post::limit(10)->get();
return Response::text(json_encode($posts->toArray(), JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'status' => $schema->string('Filter by post status'),
'limit' => $schema->integer('Maximum posts to return')->default(10),
];
}
}
```
### Key Methods
| Method | Purpose |
|--------|---------|
| `$description` | Tool description shown to AI agents |
| `handle(Request)` | Execute the tool and return a Response |
| `schema(JsonSchema)` | Define input parameters |
## Parameter Validation
Define parameters using the `JsonSchema` builder in the `schema()` method:
### String Parameters
```php
public function schema(JsonSchema $schema): array
{
return [
// Basic string
'title' => $schema->string('Post title')->required(),
// Enum values
'status' => $schema->string('Post status: draft, published, archived'),
// With default
'format' => $schema->string('Output format')->default('json'),
];
}
```
### Numeric Parameters
```php
public function schema(JsonSchema $schema): array
{
return [
// Integer
'limit' => $schema->integer('Maximum results')->default(10),
// Number (float)
'price' => $schema->number('Product price'),
];
}
```
### Boolean Parameters
```php
public function schema(JsonSchema $schema): array
{
return [
'include_drafts' => $schema->boolean('Include draft posts')->default(false),
];
}
```
### Array Parameters
```php
public function schema(JsonSchema $schema): array
{
return [
'tags' => $schema->array('Filter by tags'),
'ids' => $schema->array('Specific post IDs to fetch'),
];
}
```
### Required vs Optional
```php
public function schema(JsonSchema $schema): array
{
return [
// Required - AI agent must provide this
'query' => $schema->string('SQL query to execute')->required(),
// Optional with default
'limit' => $schema->integer('Max rows')->default(100),
// Optional without default
'status' => $schema->string('Filter status'),
];
}
```
### Accessing Parameters
```php
public function handle(Request $request): Response
{
// Get single parameter
$query = $request->input('query');
// Get with default
$limit = $request->input('limit', 10);
// Check if parameter exists
if ($request->has('status')) {
// ...
}
// Get all parameters
$params = $request->all();
}
```
### Custom Validation
For validation beyond schema types, validate in `handle()`:
```php
public function handle(Request $request): Response
{
$email = $request->input('email');
// Custom validation
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return Response::text(json_encode([
'error' => 'Invalid email format',
'code' => 'VALIDATION_ERROR',
]));
}
// Validate limit range
$limit = $request->input('limit', 10);
if ($limit < 1 || $limit > 100) {
return Response::text(json_encode([
'error' => 'Limit must be between 1 and 100',
'code' => 'VALIDATION_ERROR',
]));
}
// Continue with tool logic...
}
```
## Workspace Context
For multi-tenant applications, tools must access data scoped to the authenticated workspace. **Never accept workspace ID as a user-supplied parameter** - this prevents cross-tenant data access.
### Using RequiresWorkspaceContext
```php
<?php
declare(strict_types=1);
namespace Mod\Blog\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class ListWorkspacePostsTool extends Tool
{
use RequiresWorkspaceContext;
protected string $description = 'List posts in your workspace';
public function handle(Request $request): Response
{
// Get workspace from authenticated context (NOT from request params)
$workspace = $this->getWorkspace();
$workspaceId = $this->getWorkspaceId();
$posts = Post::where('workspace_id', $workspaceId)
->limit($request->input('limit', 10))
->get();
return Response::text(json_encode([
'workspace' => $workspace->name,
'posts' => $posts->toArray(),
], JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
// Note: No workspace_id parameter - comes from auth context
return [
'limit' => $schema->integer('Maximum posts to return'),
];
}
}
```
### Trait Methods
The `RequiresWorkspaceContext` trait provides:
| Method | Returns | Description |
|--------|---------|-------------|
| `getWorkspaceContext()` | `WorkspaceContext` | Full context object |
| `getWorkspaceId()` | `int` | Workspace ID only |
| `getWorkspace()` | `Workspace` | Workspace model |
| `hasWorkspaceContext()` | `bool` | Check if context available |
| `validateResourceOwnership(int, string)` | `void` | Validate resource belongs to workspace |
### Setting Workspace Context
Workspace context is set by middleware from authentication (API key or user session):
```php
// In middleware or controller
$tool = new ListWorkspacePostsTool();
$tool->setWorkspaceContext(WorkspaceContext::fromWorkspace($workspace));
// Or from ID
$tool->setWorkspaceId($workspaceId);
// Or from workspace model
$tool->setWorkspace($workspace);
```
### Validating Resource Ownership
When accessing specific resources, validate they belong to the workspace:
```php
public function handle(Request $request): Response
{
$postId = $request->input('post_id');
$post = Post::findOrFail($postId);
// Throws RuntimeException if post doesn't belong to workspace
$this->validateResourceOwnership($post->workspace_id, 'post');
// Safe to proceed
return Response::text(json_encode($post->toArray()));
}
```
## Tool Dependencies
Tools can declare dependencies that must be satisfied before execution. This is useful for workflows where tools must be called in a specific order.
### Declaring Dependencies
Implement `HasDependencies` or use `ValidatesDependencies` trait:
```php
<?php
declare(strict_types=1);
namespace Mod\Blog\Tools;
use Core\Mod\Mcp\Dependencies\DependencyType;
use Core\Mod\Mcp\Dependencies\HasDependencies;
use Core\Mod\Mcp\Dependencies\ToolDependency;
use Laravel\Mcp\Server\Tool;
class UpdateTaskTool extends Tool implements HasDependencies
{
protected string $description = 'Update a task in the current plan';
public function dependencies(): array
{
return [
// Another tool must be called first
ToolDependency::toolCalled(
'plan_create',
'A plan must be created before updating tasks'
),
// Session state must exist
ToolDependency::sessionState(
'active_plan_id',
'An active plan must be selected'
),
// Context value required
ToolDependency::contextExists(
'workspace_id',
'Workspace context is required'
),
];
}
public function handle(Request $request): Response
{
// Dependencies are validated before handle() is called
// ...
}
}
```
### Dependency Types
| Type | Use Case |
|------|----------|
| `TOOL_CALLED` | Another tool must be executed in session |
| `SESSION_STATE` | A session variable must exist |
| `CONTEXT_EXISTS` | A context value must be present |
| `ENTITY_EXISTS` | A database entity must exist |
| `CUSTOM` | Custom validation logic |
### Creating Dependencies
```php
// Tool must be called first
ToolDependency::toolCalled('list_tables');
// Session state required
ToolDependency::sessionState('selected_table');
// Context value required
ToolDependency::contextExists('workspace_id');
// Entity must exist
ToolDependency::entityExists('Plan', 'A plan must exist', [
'id_param' => 'plan_id',
]);
// Custom validation
ToolDependency::custom('billing_active', 'Billing must be active');
```
### Optional Dependencies
Mark dependencies as optional (warns but doesn't block):
```php
public function dependencies(): array
{
return [
ToolDependency::toolCalled('cache_warm')
->asOptional(), // Soft dependency
];
}
```
### Inline Dependency Validation
Use the `ValidatesDependencies` trait for inline validation:
```php
use Core\Mod\Mcp\Tools\Concerns\ValidatesDependencies;
class MyTool extends Tool
{
use ValidatesDependencies;
public function handle(Request $request): Response
{
$context = ['session_id' => $request->input('session_id')];
// Throws if dependencies not met
$this->validateDependencies($context);
// Or check without throwing
if (!$this->dependenciesMet($context)) {
$missing = $this->getMissingDependencies($context);
return Response::text(json_encode([
'error' => 'Dependencies not met',
'missing' => array_map(fn($d) => $d->key, $missing),
]));
}
// Continue...
}
}
```
## Registering Tools
Register tools via the `McpToolsRegistering` event in your module:
```php
<?php
namespace Mod\Blog;
use Core\Events\McpToolsRegistering;
use Mod\Blog\Tools\CreatePostTool;
use Mod\Blog\Tools\ListPostsTool;
class Boot
{
public static array $listens = [
McpToolsRegistering::class => 'onMcpTools',
];
public function onMcpTools(McpToolsRegistering $event): void
{
$event->tool('blog:list-posts', ListPostsTool::class);
$event->tool('blog:create-post', CreatePostTool::class);
}
}
```
### Tool Naming Conventions
Use consistent naming:
```php
// Pattern: module:action-resource
'blog:list-posts' // List resources
'blog:get-post' // Get single resource
'blog:create-post' // Create resource
'blog:update-post' // Update resource
'blog:delete-post' // Delete resource
// Sub-modules
'commerce:billing:get-status'
'commerce:coupon:create'
```
## Response Formats
### Success Response
```php
return Response::text(json_encode([
'success' => true,
'data' => $result,
], JSON_PRETTY_PRINT));
```
### Error Response
```php
return Response::text(json_encode([
'error' => 'Specific error message',
'code' => 'ERROR_CODE',
]));
```
### Paginated Response
```php
$posts = Post::paginate($perPage);
return Response::text(json_encode([
'data' => $posts->items(),
'pagination' => [
'current_page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'per_page' => $posts->perPage(),
'total' => $posts->total(),
],
], JSON_PRETTY_PRINT));
```
### List Response
```php
return Response::text(json_encode([
'count' => $items->count(),
'items' => $items->map(fn($item) => [
'id' => $item->id,
'name' => $item->name,
])->all(),
], JSON_PRETTY_PRINT));
```
## Security Best Practices
### 1. Never Trust User-Supplied IDs for Authorization
```php
// BAD: Using workspace_id from request
public function handle(Request $request): Response
{
$workspaceId = $request->input('workspace_id'); // Attacker can change this!
$posts = Post::where('workspace_id', $workspaceId)->get();
}
// GOOD: Using authenticated workspace context
public function handle(Request $request): Response
{
$workspaceId = $this->getWorkspaceId(); // From auth context
$posts = Post::where('workspace_id', $workspaceId)->get();
}
```
### 2. Validate Resource Ownership
```php
public function handle(Request $request): Response
{
$postId = $request->input('post_id');
$post = Post::findOrFail($postId);
// Always validate ownership before access
$this->validateResourceOwnership($post->workspace_id, 'post');
return Response::text(json_encode($post->toArray()));
}
```
### 3. Sanitize and Limit Input
```php
public function handle(Request $request): Response
{
// Limit result sets
$limit = min($request->input('limit', 10), 100);
// Sanitize string input
$search = strip_tags($request->input('search', ''));
$search = substr($search, 0, 255);
// Validate enum values
$status = $request->input('status');
if ($status && !in_array($status, ['draft', 'published', 'archived'])) {
return Response::text(json_encode(['error' => 'Invalid status']));
}
}
```
### 4. Log Sensitive Operations
```php
public function handle(Request $request): Response
{
Log::info('MCP tool executed', [
'tool' => 'delete-post',
'workspace_id' => $this->getWorkspaceId(),
'post_id' => $request->input('post_id'),
'user' => auth()->id(),
]);
// Perform operation...
}
```
### 5. Use Read-Only Database Connections for Queries
```php
// For query tools, use read-only connection
$connection = config('mcp.database.connection', 'readonly');
$results = DB::connection($connection)->select($query);
```
### 6. Sanitize Error Messages
```php
try {
// Operation...
} catch (\Exception $e) {
// Log full error for debugging
report($e);
// Return sanitized message to client
return Response::text(json_encode([
'error' => 'Operation failed. Please try again.',
'code' => 'OPERATION_FAILED',
]));
}
```
### 7. Implement Rate Limiting
Tools should respect quota limits:
```php
use Core\Mcp\Services\McpQuotaService;
public function handle(Request $request): Response
{
$quota = app(McpQuotaService::class);
$workspace = $this->getWorkspace();
if (!$quota->canExecute($workspace, $this->name())) {
return Response::text(json_encode([
'error' => 'Rate limit exceeded',
'code' => 'QUOTA_EXCEEDED',
]));
}
// Execute tool...
$quota->recordExecution($workspace, $this->name());
}
```
## Testing Tools
```php
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Mod\Blog\Tools\ListPostsTool;
use Mod\Blog\Models\Post;
use Core\Mod\Tenant\Models\Workspace;
use Mod\Mcp\Context\WorkspaceContext;
class ListPostsToolTest extends TestCase
{
public function test_lists_posts(): void
{
$workspace = Workspace::factory()->create();
Post::factory()->count(5)->create([
'workspace_id' => $workspace->id,
]);
$tool = new ListPostsTool();
$tool->setWorkspaceContext(
WorkspaceContext::fromWorkspace($workspace)
);
$request = new \Laravel\Mcp\Request([
'limit' => 10,
]);
$response = $tool->handle($request);
$data = json_decode($response->getContent(), true);
$this->assertCount(5, $data['posts']);
}
public function test_respects_workspace_isolation(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
Post::factory()->count(3)->create(['workspace_id' => $workspace1->id]);
Post::factory()->count(2)->create(['workspace_id' => $workspace2->id]);
$tool = new ListPostsTool();
$tool->setWorkspace($workspace1);
$request = new \Laravel\Mcp\Request([]);
$response = $tool->handle($request);
$data = json_decode($response->getContent(), true);
// Should only see workspace1's posts
$this->assertCount(3, $data['posts']);
}
public function test_throws_without_workspace_context(): void
{
$this->expectException(MissingWorkspaceContextException::class);
$tool = new ListPostsTool();
// Not setting workspace context
$tool->handle(new \Laravel\Mcp\Request([]));
}
}
```
## Complete Example
Here's a complete tool implementation following all best practices:
```php
<?php
declare(strict_types=1);
namespace Mod\Commerce\Tools;
use Core\Mod\Commerce\Models\Invoice;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
/**
* List invoices for the authenticated workspace.
*
* SECURITY: Uses authenticated workspace context to prevent cross-tenant access.
*/
class ListInvoicesTool extends Tool
{
use RequiresWorkspaceContext;
protected string $description = 'List invoices for your workspace with optional status filter';
public function handle(Request $request): Response
{
// Get workspace from auth context (never from request params)
$workspaceId = $this->getWorkspaceId();
// Validate and sanitize inputs
$status = $request->input('status');
if ($status && !in_array($status, ['paid', 'pending', 'overdue', 'void'])) {
return Response::text(json_encode([
'error' => 'Invalid status. Use: paid, pending, overdue, void',
'code' => 'VALIDATION_ERROR',
]));
}
$limit = min($request->input('limit', 10), 50);
// Query with workspace scope
$query = Invoice::with('order')
->where('workspace_id', $workspaceId)
->latest();
if ($status) {
$query->where('status', $status);
}
$invoices = $query->limit($limit)->get();
return Response::text(json_encode([
'workspace_id' => $workspaceId,
'count' => $invoices->count(),
'invoices' => $invoices->map(fn ($invoice) => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'status' => $invoice->status,
'total' => (float) $invoice->total,
'currency' => $invoice->currency,
'issue_date' => $invoice->issue_date?->toDateString(),
'due_date' => $invoice->due_date?->toDateString(),
'is_overdue' => $invoice->isOverdue(),
])->all(),
], JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'status' => $schema->string('Filter by status: paid, pending, overdue, void'),
'limit' => $schema->integer('Maximum invoices to return (default 10, max 50)'),
];
}
}
```
## Learn More
- [SQL Security](/packages/mcp/sql-security) - Safe query patterns
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
- [Tool Analytics](/packages/mcp/analytics) - Usage tracking
- [Quotas](/packages/mcp/quotas) - Rate limiting