Skip to content

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

MethodPurpose
$descriptionTool 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:

MethodReturnsDescription
getWorkspaceContext()WorkspaceContextFull context object
getWorkspaceId()intWorkspace ID only
getWorkspace()WorkspaceWorkspace model
hasWorkspaceContext()boolCheck if context available
validateResourceOwnership(int, string)voidValidate 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

TypeUse Case
TOOL_CALLEDAnother tool must be executed in session
SESSION_STATEA session variable must exist
CONTEXT_EXISTSA context value must be present
ENTITY_EXISTSA database entity must exist
CUSTOMCustom 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

Released under the EUPL-1.2 License.