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

19 KiB

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

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

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

public function schema(JsonSchema $schema): array
{
    return [
        // Integer
        'limit' => $schema->integer('Maximum results')->default(10),

        // Number (float)
        'price' => $schema->number('Product price'),
    ];
}

Boolean Parameters

public function schema(JsonSchema $schema): array
{
    return [
        'include_drafts' => $schema->boolean('Include draft posts')->default(false),
    ];
}

Array Parameters

public function schema(JsonSchema $schema): array
{
    return [
        'tags' => $schema->array('Filter by tags'),
        'ids' => $schema->array('Specific post IDs to fetch'),
    ];
}

Required vs Optional

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

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():

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

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):

// 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:

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

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

// 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):

public function dependencies(): array
{
    return [
        ToolDependency::toolCalled('cache_warm')
            ->asOptional(), // Soft dependency
    ];
}

Inline Dependency Validation

Use the ValidatesDependencies trait for inline validation:

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

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:

// 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

return Response::text(json_encode([
    'success' => true,
    'data' => $result,
], JSON_PRETTY_PRINT));

Error Response

return Response::text(json_encode([
    'error' => 'Specific error message',
    'code' => 'ERROR_CODE',
]));

Paginated Response

$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

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

// 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

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

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

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

// For query tools, use read-only connection
$connection = config('mcp.database.connection', 'readonly');
$results = DB::connection($connection)->select($query);

6. Sanitize Error Messages

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:

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

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

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