# 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 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 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 '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 '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 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 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