docs: add package documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 10:47:52 +00:00
parent 80ab88d330
commit 1a95091b9c
10 changed files with 5160 additions and 0 deletions

436
docs/analytics.md Normal file
View file

@ -0,0 +1,436 @@
# Tool Analytics
Track MCP tool usage, performance, and patterns with comprehensive analytics.
## Overview
The MCP analytics system provides insights into:
- Tool execution frequency
- Performance metrics
- Error rates
- User patterns
- Workspace usage
## Recording Metrics
### Automatic Tracking
Tool executions are automatically tracked:
```php
use Core\Mcp\Listeners\RecordToolExecution;
use Core\Mcp\Events\ToolExecuted;
// Automatically recorded on tool execution
event(new ToolExecuted(
tool: 'query_database',
workspace: $workspace,
user: $user,
duration: 5.23,
success: true
));
```
### Manual Recording
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
$analytics->record([
'tool_name' => 'query_database',
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'execution_time_ms' => 5.23,
'success' => true,
'error_message' => null,
'metadata' => [
'query_rows' => 42,
'connection' => 'mysql',
],
]);
```
## Querying Analytics
### Tool Stats
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Get stats for specific tool
$stats = $analytics->getToolStats('query_database', [
'workspace_id' => $workspace->id,
'start_date' => now()->subDays(30),
'end_date' => now(),
]);
```
**Returns:**
```php
use Core\Mcp\DTO\ToolStats;
$stats = new ToolStats(
tool_name: 'query_database',
total_executions: 1234,
successful_executions: 1200,
failed_executions: 34,
avg_execution_time_ms: 5.23,
p95_execution_time_ms: 12.45,
p99_execution_time_ms: 24.67,
error_rate: 2.76, // percentage
);
```
### Most Used Tools
```php
$topTools = $analytics->mostUsedTools([
'workspace_id' => $workspace->id,
'limit' => 10,
'start_date' => now()->subDays(7),
]);
// Returns array:
[
['tool_name' => 'query_database', 'count' => 500],
['tool_name' => 'list_workspaces', 'count' => 120],
['tool_name' => 'get_billing_status', 'count' => 45],
]
```
### Error Analysis
```php
// Get failed executions
$errors = $analytics->getErrors([
'workspace_id' => $workspace->id,
'tool_name' => 'query_database',
'start_date' => now()->subDays(7),
]);
foreach ($errors as $error) {
echo "Error: {$error->error_message}\n";
echo "Occurred: {$error->created_at->diffForHumans()}\n";
echo "User: {$error->user->name}\n";
}
```
### Performance Trends
```php
// Get daily execution counts
$trend = $analytics->dailyTrend([
'tool_name' => 'query_database',
'workspace_id' => $workspace->id,
'days' => 30,
]);
// Returns:
[
'2026-01-01' => 45,
'2026-01-02' => 52,
'2026-01-03' => 48,
// ...
]
```
## Admin Dashboard
View analytics in admin panel:
```php
<?php
namespace Core\Mcp\View\Modal\Admin;
use Livewire\Component;
use Core\Mcp\Services\ToolAnalyticsService;
class ToolAnalyticsDashboard extends Component
{
public function render()
{
$analytics = app(ToolAnalyticsService::class);
return view('mcp::admin.analytics.dashboard', [
'totalExecutions' => $analytics->totalExecutions(),
'topTools' => $analytics->mostUsedTools(['limit' => 10]),
'errorRate' => $analytics->errorRate(),
'avgExecutionTime' => $analytics->averageExecutionTime(),
]);
}
}
```
**View:**
```blade
<x-admin::card>
<x-slot:header>
<h3>MCP Tool Analytics</h3>
</x-slot:header>
<div class="grid grid-cols-4 gap-4">
<x-admin::stat
label="Total Executions"
:value="$totalExecutions"
icon="heroicon-o-play-circle"
/>
<x-admin::stat
label="Error Rate"
:value="number_format($errorRate, 2) . '%'"
icon="heroicon-o-exclamation-triangle"
:color="$errorRate > 5 ? 'red' : 'green'"
/>
<x-admin::stat
label="Avg Execution Time"
:value="number_format($avgExecutionTime, 2) . 'ms'"
icon="heroicon-o-clock"
/>
<x-admin::stat
label="Active Tools"
:value="count($topTools)"
icon="heroicon-o-cube"
/>
</div>
<div class="mt-6">
<h4>Most Used Tools</h4>
<x-admin::table>
<x-slot:header>
<x-admin::table.th>Tool</x-admin::table.th>
<x-admin::table.th>Executions</x-admin::table.th>
</x-slot:header>
@foreach($topTools as $tool)
<x-admin::table.tr>
<x-admin::table.td>{{ $tool['tool_name'] }}</x-admin::table.td>
<x-admin::table.td>{{ number_format($tool['count']) }}</x-admin::table.td>
</x-admin::table.tr>
@endforeach
</x-admin::table>
</div>
</x-admin::card>
```
## Tool Detail View
Detailed analytics for specific tool:
```blade
<x-admin::card>
<x-slot:header>
<h3>{{ $toolName }} Analytics</h3>
</x-slot:header>
<div class="grid grid-cols-3 gap-4">
<x-admin::stat
label="Total Executions"
:value="$stats->total_executions"
/>
<x-admin::stat
label="Success Rate"
:value="number_format((1 - $stats->error_rate / 100) * 100, 1) . '%'"
:color="$stats->error_rate < 5 ? 'green' : 'red'"
/>
<x-admin::stat
label="P95 Latency"
:value="number_format($stats->p95_execution_time_ms, 2) . 'ms'"
/>
</div>
<div class="mt-6">
<h4>Performance Trend</h4>
<canvas id="performance-chart"></canvas>
</div>
<div class="mt-6">
<h4>Recent Errors</h4>
@foreach($recentErrors as $error)
<x-admin::alert type="error">
<strong>{{ $error->created_at->diffForHumans() }}</strong>
{{ $error->error_message }}
</x-admin::alert>
@endforeach
</div>
</x-admin::card>
```
## Pruning Old Metrics
```bash
# Prune metrics older than 90 days
php artisan mcp:prune-metrics --days=90
# Dry run
php artisan mcp:prune-metrics --days=90 --dry-run
```
**Scheduled Pruning:**
```php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->command('mcp:prune-metrics --days=90')
->daily()
->at('02:00');
}
```
## Alerting
Set up alerts for anomalies:
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Check error rate
$errorRate = $analytics->errorRate([
'tool_name' => 'query_database',
'start_date' => now()->subHours(1),
]);
if ($errorRate > 10) {
// Alert: High error rate
Notification::route('slack', config('slack.webhook'))
->notify(new HighErrorRateNotification('query_database', $errorRate));
}
// Check slow executions
$p99 = $analytics->getToolStats('query_database')->p99_execution_time_ms;
if ($p99 > 1000) {
// Alert: Slow performance
Notification::route('slack', config('slack.webhook'))
->notify(new SlowToolNotification('query_database', $p99));
}
```
## Export Analytics
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Export to CSV
$csv = $analytics->exportToCsv([
'workspace_id' => $workspace->id,
'start_date' => now()->subDays(30),
'end_date' => now(),
]);
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, 'mcp-analytics.csv');
```
## Best Practices
### 1. Set Retention Policies
```php
// config/mcp.php
return [
'analytics' => [
'retention_days' => 90, // Keep 90 days
'prune_schedule' => 'daily',
],
];
```
### 2. Monitor Error Rates
```php
// ✅ Good - alert on high error rate
if ($errorRate > 10) {
$this->alert('High error rate');
}
// ❌ Bad - ignore errors
// (problems go unnoticed)
```
### 3. Track Performance
```php
// ✅ Good - measure execution time
$start = microtime(true);
$result = $tool->execute($params);
$duration = (microtime(true) - $start) * 1000;
$analytics->record([
'execution_time_ms' => $duration,
]);
```
### 4. Use Aggregated Queries
```php
// ✅ Good - use analytics service
$stats = $analytics->getToolStats('query_database');
// ❌ Bad - query metrics table directly
$count = ToolMetric::where('tool_name', 'query_database')->count();
```
## Testing
```php
use Tests\TestCase;
use Core\Mcp\Services\ToolAnalyticsService;
class AnalyticsTest extends TestCase
{
public function test_records_tool_execution(): void
{
$analytics = app(ToolAnalyticsService::class);
$analytics->record([
'tool_name' => 'test_tool',
'workspace_id' => 1,
'success' => true,
]);
$this->assertDatabaseHas('mcp_tool_metrics', [
'tool_name' => 'test_tool',
'workspace_id' => 1,
]);
}
public function test_calculates_error_rate(): void
{
$analytics = app(ToolAnalyticsService::class);
// Record 100 successful, 10 failed
for ($i = 0; $i < 100; $i++) {
$analytics->record(['tool_name' => 'test', 'success' => true]);
}
for ($i = 0; $i < 10; $i++) {
$analytics->record(['tool_name' => 'test', 'success' => false]);
}
$errorRate = $analytics->errorRate(['tool_name' => 'test']);
$this->assertEquals(9.09, round($errorRate, 2)); // 10/110 = 9.09%
}
}
```
## Learn More
- [Quotas →](/packages/mcp/quotas)
- [Creating Tools →](/packages/mcp/tools)

787
docs/creating-mcp-tools.md Normal file
View file

@ -0,0 +1,787 @@
# 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

436
docs/index.md Normal file
View file

@ -0,0 +1,436 @@
# MCP Package
The MCP (Model Context Protocol) package provides AI agent tools for database queries, commerce operations, and workspace management with built-in security and quota enforcement.
## Installation
```bash
composer require host-uk/core-mcp
```
## Quick Start
```php
<?php
namespace Mod\Blog;
use Core\Events\McpToolsRegistering;
class Boot
{
public static array $listens = [
McpToolsRegistering::class => 'onMcpTools',
];
public function onMcpTools(McpToolsRegistering $event): void
{
$event->tool('blog:create-post', Tools\CreatePostTool::class);
$event->tool('blog:list-posts', Tools\ListPostsTool::class);
}
}
```
## Key Features
### Database Tools
- **[Query Database](/packages/mcp/query-database)** - SQL query execution with validation and security
- **[SQL Validation](/packages/mcp/security#sql-validation)** - Prevent destructive queries and SQL injection
- **[EXPLAIN Plans](/packages/mcp/query-database#explain)** - Query optimization analysis
### Commerce Tools
- **[Get Billing Status](/packages/mcp/commerce#billing)** - Current billing and subscription status
- **[List Invoices](/packages/mcp/commerce#invoices)** - Invoice history and details
- **[Upgrade Plan](/packages/mcp/commerce#upgrades)** - Tier upgrades with entitlement validation
### Workspace Tools
- **[Workspace Context](/packages/mcp/workspace)** - Automatic workspace/namespace resolution
- **[Quota Enforcement](/packages/mcp/quotas)** - Tool usage limits and monitoring
- **[Tool Analytics](/packages/mcp/analytics)** - Usage tracking and statistics
### Developer Tools
- **[Tool Discovery](/packages/mcp/tools#discovery)** - Automatic tool registration
- **[Dependency Management](/packages/mcp/tools#dependencies)** - Tool dependency resolution
- **[Error Handling](/packages/mcp/tools#errors)** - Consistent error responses
## Creating Tools
### Basic Tool
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
class ListPostsTool extends BaseTool
{
public function getName(): string
{
return 'blog:list-posts';
}
public function getDescription(): string
{
return 'List all blog posts with optional filters';
}
public function getParameters(): array
{
return [
'status' => [
'type' => 'string',
'description' => 'Filter by status',
'enum' => ['published', 'draft'],
'required' => false,
],
'limit' => [
'type' => 'integer',
'description' => 'Number of posts to return',
'default' => 10,
'required' => false,
],
];
}
public function execute(array $params): array
{
$query = Post::query();
if (isset($params['status'])) {
$query->where('status', $params['status']);
}
$posts = $query->limit($params['limit'] ?? 10)->get();
return [
'posts' => $posts->map(fn ($post) => [
'id' => $post->id,
'title' => $post->title,
'slug' => $post->slug,
'status' => $post->status,
])->toArray(),
];
}
}
```
### Tool with Workspace Context
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class CreatePostTool extends BaseTool
{
use RequiresWorkspaceContext;
public function getName(): string
{
return 'blog:create-post';
}
public function execute(array $params): array
{
// Workspace context automatically validated
$workspace = $this->getWorkspaceContext();
$post = Post::create([
'title' => $params['title'],
'content' => $params['content'],
'workspace_id' => $workspace->id,
]);
return [
'success' => true,
'post_id' => $post->id,
];
}
}
```
### Tool with Dependencies
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Dependencies\HasDependencies;
use Core\Mcp\Dependencies\ToolDependency;
class ImportPostsTool extends BaseTool
{
use HasDependencies;
public function getDependencies(): array
{
return [
new ToolDependency('blog:list-posts', DependencyType::REQUIRED),
new ToolDependency('media:upload', DependencyType::OPTIONAL),
];
}
public function execute(array $params): array
{
// Dependencies automatically validated
// ...
}
}
```
## Query Database Tool
Execute SQL queries with built-in security:
```php
use Core\Mcp\Tools\QueryDatabase;
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
'connection' => 'mysql',
]);
// Returns:
// [
// 'rows' => [...],
// 'count' => 10,
// 'execution_time_ms' => 5.23
// ]
```
### Security Features
- **Whitelist-based validation** - Only SELECT queries allowed by default
- **No destructive operations** - DROP, TRUNCATE, DELETE blocked
- **Binding enforcement** - Prevents SQL injection
- **Connection validation** - Only allowed connections accessible
- **EXPLAIN analysis** - Query optimization insights
[Learn more about SQL Security →](/packages/mcp/security)
## Quota System
Enforce tool usage limits per workspace:
```php
// config/mcp.php
'quotas' => [
'enabled' => true,
'limits' => [
'free' => ['calls' => 100, 'per' => 'day'],
'pro' => ['calls' => 1000, 'per' => 'day'],
'business' => ['calls' => 10000, 'per' => 'day'],
'enterprise' => ['calls' => null], // Unlimited
],
],
```
Check quota before execution:
```php
use Core\Mcp\Services\McpQuotaService;
$quotaService = app(McpQuotaService::class);
if (!$quotaService->canExecute($workspace, 'blog:create-post')) {
throw new QuotaExceededException('Daily tool quota exceeded');
}
$quotaService->recordExecution($workspace, 'blog:create-post');
```
[Learn more about Quotas →](/packages/mcp/quotas)
## Tool Analytics
Track tool usage and performance:
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Get tool stats
$stats = $analytics->getToolStats('blog:create-post', period: 'week');
// Returns: ToolStats with executions, errors, avg_duration_ms
// Get workspace usage
$usage = $analytics->getWorkspaceUsage($workspace, period: 'month');
// Get most used tools
$topTools = $analytics->getTopTools(limit: 10, period: 'week');
```
[Learn more about Analytics →](/packages/mcp/analytics)
## Configuration
```php
// config/mcp.php
return [
'enabled' => true,
'tools' => [
'auto_discover' => true,
'cache_enabled' => true,
],
'query_database' => [
'allowed_connections' => ['mysql', 'pgsql'],
'forbidden_keywords' => [
'DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'INSERT',
'ALTER', 'CREATE', 'GRANT', 'REVOKE',
],
'max_execution_time' => 5000, // ms
'enable_explain' => true,
],
'quotas' => [
'enabled' => true,
'limits' => [
'free' => ['calls' => 100, 'per' => 'day'],
'pro' => ['calls' => 1000, 'per' => 'day'],
'business' => ['calls' => 10000, 'per' => 'day'],
'enterprise' => ['calls' => null],
],
],
'analytics' => [
'enabled' => true,
'retention_days' => 90,
],
];
```
## Middleware
```php
use Core\Mcp\Middleware\ValidateWorkspaceContext;
use Core\Mcp\Middleware\CheckMcpQuota;
use Core\Mcp\Middleware\ValidateToolDependencies;
Route::middleware([
ValidateWorkspaceContext::class,
CheckMcpQuota::class,
ValidateToolDependencies::class,
])->group(function () {
// MCP tool routes
});
```
## Best Practices
### 1. Use Workspace Context
```php
// ✅ Good - workspace aware
class CreatePostTool extends BaseTool
{
use RequiresWorkspaceContext;
}
// ❌ Bad - no workspace context
class CreatePostTool extends BaseTool
{
public function execute(array $params): array
{
$post = Post::create($params); // No workspace_id!
}
}
```
### 2. Validate Parameters
```php
// ✅ Good - strict validation
public function getParameters(): array
{
return [
'title' => [
'type' => 'string',
'required' => true,
'maxLength' => 255,
],
];
}
```
### 3. Handle Errors Gracefully
```php
// ✅ Good - clear error messages
public function execute(array $params): array
{
try {
return ['success' => true, 'data' => $result];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'code' => 'TOOL_EXECUTION_FAILED',
];
}
}
```
### 4. Document Tools Well
```php
// ✅ Good - comprehensive description
public function getDescription(): string
{
return 'Create a new blog post with title, content, and optional metadata. '
. 'Requires workspace context. Validates entitlements before creation.';
}
```
## Testing
```php
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Mod\Blog\Tools\ListPostsTool;
class ListPostsToolTest extends TestCase
{
public function test_lists_posts(): void
{
Post::factory()->count(5)->create(['status' => 'published']);
$tool = new ListPostsTool();
$result = $tool->execute([
'status' => 'published',
'limit' => 10,
]);
$this->assertArrayHasKey('posts', $result);
$this->assertCount(5, $result['posts']);
}
}
```
## Learn More
- [Query Database →](/packages/mcp/query-database)
- [SQL Security →](/packages/mcp/security)
- [Workspace Context →](/packages/mcp/workspace)
- [Tool Analytics →](/packages/mcp/analytics)
- [Quota System →](/packages/mcp/quotas)

452
docs/query-database.md Normal file
View file

@ -0,0 +1,452 @@
# Query Database Tool
The MCP package provides a secure SQL query execution tool with validation, connection management, and EXPLAIN plan analysis.
## Overview
The Query Database tool allows AI agents to:
- Execute SELECT queries safely
- Analyze query performance
- Access multiple database connections
- Prevent destructive operations
- Enforce workspace context
## Basic Usage
```php
use Core\Mcp\Tools\QueryDatabase;
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
'connection' => 'mysql',
]);
// Returns:
// [
// 'rows' => [...],
// 'count' => 10,
// 'execution_time_ms' => 5.23
// ]
```
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | SQL SELECT query |
| `bindings` | array | No | Query parameters (prevents SQL injection) |
| `connection` | string | No | Database connection name (default: default) |
| `explain` | bool | No | Include EXPLAIN plan analysis |
## Security Validation
### Allowed Operations
✅ Only SELECT queries are allowed:
```php
// ✅ Allowed
'SELECT * FROM posts'
'SELECT id, title FROM posts WHERE status = ?'
'SELECT COUNT(*) FROM users'
// ❌ Blocked
'DELETE FROM posts'
'UPDATE posts SET status = ?'
'DROP TABLE posts'
'TRUNCATE posts'
```
### Forbidden Keywords
The following are automatically blocked:
- `DROP`
- `TRUNCATE`
- `DELETE`
- `UPDATE`
- `INSERT`
- `ALTER`
- `CREATE`
- `GRANT`
- `REVOKE`
### Required WHERE Clauses
Queries on large tables must include WHERE clauses:
```php
// ✅ Good - has WHERE clause
'SELECT * FROM posts WHERE user_id = ?'
// ⚠️ Warning - no WHERE clause
'SELECT * FROM posts'
// Returns warning if table has > 10,000 rows
```
### Connection Validation
Only whitelisted connections are accessible:
```php
// config/mcp.php
'query_database' => [
'allowed_connections' => ['mysql', 'pgsql', 'analytics'],
],
```
## EXPLAIN Plan Analysis
Enable query optimization insights:
```php
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
'explain' => true,
]);
// Returns additional 'explain' key:
// [
// 'rows' => [...],
// 'explain' => [
// 'type' => 'ref',
// 'key' => 'idx_status',
// 'rows_examined' => 150,
// 'analysis' => 'Query uses index. Performance: Good',
// 'recommendations' => []
// ]
// ]
```
### Performance Analysis
The EXPLAIN analyzer provides human-readable insights:
**Good Performance:**
```
"Query uses index. Performance: Good"
```
**Index Missing:**
```
"Warning: Full table scan detected. Consider adding an index on 'status'"
```
**High Row Count:**
```
"Warning: Query examines 50,000 rows. Consider adding WHERE clause to limit results"
```
## Examples
### Basic SELECT
```php
$result = $tool->execute([
'query' => 'SELECT id, title, created_at FROM posts LIMIT 10',
]);
foreach ($result['rows'] as $row) {
echo "{$row['title']}\n";
}
```
### With Parameters
```php
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
'bindings' => [42, 'published'],
]);
```
### Aggregation
```php
$result = $tool->execute([
'query' => 'SELECT status, COUNT(*) as count FROM posts GROUP BY status',
]);
// Returns: [
// ['status' => 'draft', 'count' => 15],
// ['status' => 'published', 'count' => 42],
// ]
```
### Join Query
```php
$result = $tool->execute([
'query' => '
SELECT posts.title, users.name
FROM posts
JOIN users ON posts.user_id = users.id
WHERE posts.status = ?
LIMIT 10
',
'bindings' => ['published'],
]);
```
### Date Filtering
```php
$result = $tool->execute([
'query' => '
SELECT *
FROM posts
WHERE created_at >= ?
AND created_at < ?
ORDER BY created_at DESC
',
'bindings' => ['2024-01-01', '2024-02-01'],
]);
```
## Multiple Connections
Query different databases:
```php
// Main application database
$posts = $tool->execute([
'query' => 'SELECT * FROM posts',
'connection' => 'mysql',
]);
// Analytics database
$stats = $tool->execute([
'query' => 'SELECT * FROM page_views',
'connection' => 'analytics',
]);
// PostgreSQL database
$data = $tool->execute([
'query' => 'SELECT * FROM logs',
'connection' => 'pgsql',
]);
```
## Error Handling
### Forbidden Query
```php
$result = $tool->execute([
'query' => 'DELETE FROM posts WHERE id = 1',
]);
// Returns:
// [
// 'success' => false,
// 'error' => 'Forbidden query: DELETE operations not allowed',
// 'code' => 'FORBIDDEN_QUERY'
// ]
```
### Invalid Connection
```php
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
'connection' => 'unknown',
]);
// Returns:
// [
// 'success' => false,
// 'error' => 'Connection "unknown" not allowed',
// 'code' => 'INVALID_CONNECTION'
// ]
```
### SQL Error
```php
$result = $tool->execute([
'query' => 'SELECT * FROM nonexistent_table',
]);
// Returns:
// [
// 'success' => false,
// 'error' => 'Table "nonexistent_table" doesn\'t exist',
// 'code' => 'SQL_ERROR'
// ]
```
## Configuration
```php
// config/mcp.php
'query_database' => [
// Allowed database connections
'allowed_connections' => [
'mysql',
'pgsql',
'analytics',
],
// Forbidden SQL keywords
'forbidden_keywords' => [
'DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'INSERT',
'ALTER', 'CREATE', 'GRANT', 'REVOKE',
],
// Maximum execution time (milliseconds)
'max_execution_time' => 5000,
// Enable EXPLAIN plan analysis
'enable_explain' => true,
// Warn on queries without WHERE clause for tables larger than:
'warn_no_where_threshold' => 10000,
],
```
## Workspace Context
Queries are automatically scoped to the current workspace:
```php
// When workspace context is set
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
]);
// Equivalent to:
// 'SELECT * FROM posts WHERE workspace_id = ?'
// with workspace_id automatically added
```
Disable automatic scoping:
```php
$result = $tool->execute([
'query' => 'SELECT * FROM global_settings',
'ignore_workspace_scope' => true,
]);
```
## Best Practices
### 1. Always Use Bindings
```php
// ✅ Good - prevents SQL injection
$tool->execute([
'query' => 'SELECT * FROM posts WHERE user_id = ?',
'bindings' => [$userId],
]);
// ❌ Bad - vulnerable to SQL injection
$tool->execute([
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
]);
```
### 2. Limit Results
```php
// ✅ Good - limits results
'SELECT * FROM posts LIMIT 100'
// ❌ Bad - could return millions of rows
'SELECT * FROM posts'
```
### 3. Use EXPLAIN for Optimization
```php
// ✅ Good - analyze slow queries
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
'explain' => true,
]);
if (isset($result['explain']['recommendations'])) {
foreach ($result['explain']['recommendations'] as $rec) {
error_log("Query optimization: {$rec}");
}
}
```
### 4. Handle Errors Gracefully
```php
// ✅ Good - check for errors
$result = $tool->execute([...]);
if (!($result['success'] ?? true)) {
return [
'error' => $result['error'],
'code' => $result['code'],
];
}
return $result['rows'];
```
## Testing
```php
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Core\Mcp\Tools\QueryDatabase;
class QueryDatabaseTest extends TestCase
{
public function test_executes_select_query(): void
{
Post::factory()->create(['title' => 'Test Post']);
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE title = ?',
'bindings' => ['Test Post'],
]);
$this->assertTrue($result['success'] ?? true);
$this->assertCount(1, $result['rows']);
}
public function test_blocks_delete_query(): void
{
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'DELETE FROM posts WHERE id = 1',
]);
$this->assertFalse($result['success']);
$this->assertEquals('FORBIDDEN_QUERY', $result['code']);
}
public function test_validates_connection(): void
{
$tool = new QueryDatabase();
$result = $tool->execute([
'query' => 'SELECT 1',
'connection' => 'invalid',
]);
$this->assertFalse($result['success']);
$this->assertEquals('INVALID_CONNECTION', $result['code']);
}
}
```
## Learn More
- [SQL Security →](/packages/mcp/security)
- [Workspace Context →](/packages/mcp/workspace)
- [Tool Analytics →](/packages/mcp/analytics)

405
docs/quotas.md Normal file
View file

@ -0,0 +1,405 @@
# Usage Quotas
Tier-based rate limiting and usage quotas for MCP tools.
## Overview
The quota system enforces usage limits based on workspace subscription tiers:
**Tiers:**
- **Free:** 60 requests/hour, 500 queries/day
- **Pro:** 600 requests/hour, 10,000 queries/day
- **Enterprise:** Unlimited
## Quota Enforcement
### Middleware
```php
use Core\Mcp\Middleware\CheckMcpQuota;
Route::middleware([CheckMcpQuota::class])
->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
```
**Process:**
1. Identifies workspace from context
2. Checks current usage against tier limits
3. Allows or denies request
4. Records usage on success
### Manual Checking
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
// Check if within quota
if (!$quota->withinLimit($workspace)) {
return response()->json([
'error' => 'Quota exceeded',
'message' => 'You have reached your hourly limit',
'reset_at' => $quota->resetTime($workspace),
], 429);
}
// Record usage
$quota->recordUsage($workspace, 'query_database');
```
## Quota Configuration
```php
// config/mcp.php
return [
'quotas' => [
'free' => [
'requests_per_hour' => 60,
'queries_per_day' => 500,
'max_query_rows' => 1000,
],
'pro' => [
'requests_per_hour' => 600,
'queries_per_day' => 10000,
'max_query_rows' => 10000,
],
'enterprise' => [
'requests_per_hour' => null, // Unlimited
'queries_per_day' => null,
'max_query_rows' => 100000,
],
],
];
```
## Usage Tracking
### Current Usage
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
// Get current hour's usage
$hourlyUsage = $quota->getHourlyUsage($workspace);
// Get current day's usage
$dailyUsage = $quota->getDailyUsage($workspace);
// Get usage percentage
$percentage = $quota->usagePercentage($workspace);
```
### Usage Response Headers
```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1706274000
X-RateLimit-Reset-At: 2026-01-26T13:00:00Z
```
**Implementation:**
```php
use Core\Mcp\Middleware\CheckMcpQuota;
class CheckMcpQuota
{
public function handle($request, Closure $next)
{
$workspace = $request->workspace;
$quota = app(McpQuotaService::class);
$response = $next($request);
// Add quota headers
$response->headers->set('X-RateLimit-Limit', $quota->getLimit($workspace));
$response->headers->set('X-RateLimit-Remaining', $quota->getRemaining($workspace));
$response->headers->set('X-RateLimit-Reset', $quota->resetTime($workspace)->timestamp);
return $response;
}
}
```
## Quota Exceeded Response
```json
{
"error": "quota_exceeded",
"message": "You have exceeded your hourly request limit",
"current_usage": 60,
"limit": 60,
"reset_at": "2026-01-26T13:00:00Z",
"upgrade_url": "https://example.com/billing/upgrade"
}
```
**HTTP Status:** 429 Too Many Requests
## Upgrading Tiers
```php
use Mod\Tenant\Models\Workspace;
$workspace = Workspace::find($id);
// Upgrade to Pro
$workspace->update([
'subscription_tier' => 'pro',
]);
// New limits immediately apply
$quota = app(McpQuotaService::class);
$newLimit = $quota->getLimit($workspace); // 600
```
## Quota Monitoring
### Admin Dashboard
```php
class QuotaUsage extends Component
{
public function render()
{
$quota = app(McpQuotaService::class);
$workspaces = Workspace::all()->map(function ($workspace) use ($quota) {
return [
'name' => $workspace->name,
'tier' => $workspace->subscription_tier,
'hourly_usage' => $quota->getHourlyUsage($workspace),
'hourly_limit' => $quota->getLimit($workspace, 'hourly'),
'daily_usage' => $quota->getDailyUsage($workspace),
'daily_limit' => $quota->getLimit($workspace, 'daily'),
];
});
return view('mcp::admin.quota-usage', compact('workspaces'));
}
}
```
**View:**
```blade
<x-admin::table>
<x-slot:header>
<x-admin::table.th>Workspace</x-admin::table.th>
<x-admin::table.th>Tier</x-admin::table.th>
<x-admin::table.th>Hourly Usage</x-admin::table.th>
<x-admin::table.th>Daily Usage</x-admin::table.th>
</x-slot:header>
@foreach($workspaces as $workspace)
<x-admin::table.tr>
<x-admin::table.td>{{ $workspace['name'] }}</x-admin::table.td>
<x-admin::table.td>
<x-admin::badge :color="$workspace['tier'] === 'enterprise' ? 'purple' : 'blue'">
{{ ucfirst($workspace['tier']) }}
</x-admin::badge>
</x-admin::table.td>
<x-admin::table.td>
{{ $workspace['hourly_usage'] }} / {{ $workspace['hourly_limit'] ?? '∞' }}
<progress
value="{{ $workspace['hourly_usage'] }}"
max="{{ $workspace['hourly_limit'] ?? 100 }}"
></progress>
</x-admin::table.td>
<x-admin::table.td>
{{ $workspace['daily_usage'] }} / {{ $workspace['daily_limit'] ?? '∞' }}
</x-admin::table.td>
</x-admin::table.tr>
@endforeach
</x-admin::table>
```
### Alerts
Send notifications when nearing limits:
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
$usage = $quota->usagePercentage($workspace);
if ($usage >= 80) {
// Alert: 80% of quota used
$workspace->owner->notify(
new QuotaWarningNotification($workspace, $usage)
);
}
if ($usage >= 100) {
// Alert: Quota exceeded
$workspace->owner->notify(
new QuotaExceededNotification($workspace)
);
}
```
## Custom Quotas
Override default quotas for specific workspaces:
```php
use Core\Mcp\Models\McpUsageQuota;
// Set custom quota
McpUsageQuota::create([
'workspace_id' => $workspace->id,
'requests_per_hour' => 1000, // Custom limit
'queries_per_day' => 50000,
'expires_at' => now()->addMonths(3), // Temporary increase
]);
// Custom quota takes precedence over tier defaults
```
## Resetting Quotas
```bash
# Reset all quotas
php artisan mcp:reset-quotas
# Reset specific workspace
php artisan mcp:reset-quotas --workspace=123
# Reset specific period
php artisan mcp:reset-quotas --period=hourly
```
## Bypass Quotas (Admin)
```php
// Bypass quota enforcement
$result = $tool->execute($params, [
'bypass_quota' => true, // Requires admin permission
]);
```
**Use cases:**
- Internal tools
- Admin operations
- System maintenance
- Testing
## Testing
```php
use Tests\TestCase;
use Core\Mcp\Services\McpQuotaService;
class QuotaTest extends TestCase
{
public function test_enforces_hourly_limit(): void
{
$workspace = Workspace::factory()->create(['tier' => 'free']);
$quota = app(McpQuotaService::class);
// Exhaust quota
for ($i = 0; $i < 60; $i++) {
$quota->recordUsage($workspace, 'test');
}
$this->assertFalse($quota->withinLimit($workspace));
}
public function test_resets_after_hour(): void
{
$workspace = Workspace::factory()->create();
$quota = app(McpQuotaService::class);
// Use quota
$quota->recordUsage($workspace, 'test');
// Travel 1 hour
$this->travel(1)->hour();
$this->assertTrue($quota->withinLimit($workspace));
}
public function test_enterprise_has_no_limit(): void
{
$workspace = Workspace::factory()->create(['tier' => 'enterprise']);
$quota = app(McpQuotaService::class);
// Use quota 1000 times
for ($i = 0; $i < 1000; $i++) {
$quota->recordUsage($workspace, 'test');
}
$this->assertTrue($quota->withinLimit($workspace));
}
}
```
## Best Practices
### 1. Check Quotas Early
```php
// ✅ Good - check before processing
if (!$quota->withinLimit($workspace)) {
return response()->json(['error' => 'Quota exceeded'], 429);
}
$result = $tool->execute($params);
// ❌ Bad - check after processing
$result = $tool->execute($params);
if (!$quota->withinLimit($workspace)) {
// Too late!
}
```
### 2. Provide Clear Feedback
```php
// ✅ Good - helpful error message
return response()->json([
'error' => 'Quota exceeded',
'reset_at' => $quota->resetTime($workspace),
'upgrade_url' => route('billing.upgrade'),
], 429);
// ❌ Bad - generic error
return response()->json(['error' => 'Too many requests'], 429);
```
### 3. Monitor Usage Patterns
```php
// ✅ Good - alert at 80%
if ($usage >= 80) {
$this->notifyUser();
}
// ❌ Bad - only alert when exhausted
if ($usage >= 100) {
// User already hit limit
}
```
### 4. Use Appropriate Limits
```php
// ✅ Good - reasonable limits
'free' => ['requests_per_hour' => 60],
'pro' => ['requests_per_hour' => 600],
// ❌ Bad - too restrictive
'free' => ['requests_per_hour' => 5], // Unusable
```
## Learn More
- [Analytics →](/packages/mcp/analytics)
- [Security →](/packages/mcp/security)
- [Multi-Tenancy →](/packages/core/tenancy)

363
docs/security.md Normal file
View file

@ -0,0 +1,363 @@
# MCP Security
Security features for protecting database access and preventing SQL injection in MCP tools.
## SQL Query Validation
### Validation Rules
The `SqlQueryValidator` enforces strict rules on all queries:
**Allowed:**
- `SELECT` statements only
- Table/column qualifiers
- WHERE clauses
- JOINs
- ORDER BY, GROUP BY
- LIMIT clauses
- Subqueries (SELECT only)
**Forbidden:**
- `INSERT`, `UPDATE`, `DELETE`, `DROP`, `CREATE`, `ALTER`
- `TRUNCATE`, `GRANT`, `REVOKE`
- Database modification operations
- System table access
- Multiple statements (`;` separated)
### Usage
```php
use Core\Mcp\Services\SqlQueryValidator;
$validator = app(SqlQueryValidator::class);
// Valid query
$result = $validator->validate('SELECT * FROM posts WHERE id = ?');
// Returns: ['valid' => true]
// Invalid query
$result = $validator->validate('DROP TABLE users');
// Returns: ['valid' => false, 'error' => 'Only SELECT queries are allowed']
```
### Forbidden Patterns
```php
// ❌ Data modification
DELETE FROM users WHERE id = 1
UPDATE posts SET status = 'published'
INSERT INTO logs VALUES (...)
// ❌ Schema changes
DROP TABLE posts
ALTER TABLE users ADD COLUMN...
CREATE INDEX...
// ❌ Permission changes
GRANT ALL ON *.* TO user
REVOKE SELECT ON posts FROM user
// ❌ Multiple statements
SELECT * FROM posts; DROP TABLE users;
// ❌ System tables
SELECT * FROM information_schema.tables
SELECT * FROM mysql.user
```
### Parameterized Queries
Always use bindings to prevent SQL injection:
```php
// ✅ Good - parameterized
$tool->execute([
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
'bindings' => [$userId, 'published'],
]);
// ❌ Bad - SQL injection risk
$tool->execute([
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
]);
```
## Workspace Context Security
### Automatic Scoping
Queries are automatically scoped to the current workspace:
```php
use Core\Mcp\Context\WorkspaceContext;
// Get workspace context from request
$context = WorkspaceContext::fromRequest($request);
// Queries automatically filtered by workspace_id
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
], $context);
// Internally becomes:
// SELECT * FROM posts WHERE status = ? AND workspace_id = ?
```
### Validation
Tools validate workspace context before execution:
```php
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class MyTool
{
use RequiresWorkspaceContext;
public function execute(array $params)
{
// Throws MissingWorkspaceContextException if context missing
$this->validateWorkspaceContext();
// Safe to proceed
$workspace = $this->workspaceContext->workspace;
}
}
```
### Bypassing (Admin Only)
```php
// Requires admin permission
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
'bypass_workspace_scope' => true, // Admin only
]);
```
## Connection Security
### Allowed Connections
Only specific connections can be queried:
```php
// config/mcp.php
return [
'database' => [
'allowed_connections' => [
'mysql', // Primary database
'analytics', // Read-only analytics
'logs', // Application logs
],
'default_connection' => 'mysql',
],
];
```
### Read-Only Connections
Use read-only database users for MCP:
```php
// config/database.php
'connections' => [
'mcp_readonly' => [
'driver' => 'mysql',
'host' => env('DB_HOST'),
'database' => env('DB_DATABASE'),
'username' => env('MCP_DB_USER'), // Read-only user
'password' => env('MCP_DB_PASSWORD'),
'charset' => 'utf8mb4',
],
],
```
**Database Setup:**
```sql
-- Create read-only user
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
-- Grant SELECT only
GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
-- Explicitly deny modifications
REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER ON app_database.* FROM 'mcp_readonly'@'%';
FLUSH PRIVILEGES;
```
### Connection Validation
```php
use Core\Mcp\Services\ConnectionValidator;
$validator = app(ConnectionValidator::class);
// Check if connection is allowed
if (!$validator->isAllowed('mysql')) {
throw new ForbiddenConnectionException();
}
// Check if connection exists
if (!$validator->exists('mysql')) {
throw new InvalidConnectionException();
}
```
## Rate Limiting
Prevent abuse with rate limits:
```php
use Core\Mcp\Middleware\CheckMcpQuota;
Route::middleware([CheckMcpQuota::class])
->post('/mcp/query', [McpApiController::class, 'query']);
```
**Limits:**
| Tier | Requests/Hour | Queries/Day |
|------|--------------|-------------|
| Free | 60 | 500 |
| Pro | 600 | 10,000 |
| Enterprise | Unlimited | Unlimited |
### Quota Enforcement
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
// Check if within quota
if (!$quota->withinLimit($workspace)) {
throw new QuotaExceededException();
}
// Record usage
$quota->recordUsage($workspace, 'query_database');
```
## Query Logging
All queries are logged for audit:
```php
// storage/logs/mcp-queries.log
[2026-01-26 12:00:00] Query executed
Workspace: acme-corp
User: john@example.com
Query: SELECT * FROM posts WHERE status = ?
Bindings: ["published"]
Rows: 42
Duration: 5.23ms
```
### Log Configuration
```php
// config/logging.php
'channels' => [
'mcp' => [
'driver' => 'daily',
'path' => storage_path('logs/mcp-queries.log'),
'level' => 'info',
'days' => 90, // Retain for 90 days
],
],
```
## Best Practices
### 1. Always Use Bindings
```php
// ✅ Good - parameterized
'query' => 'SELECT * FROM posts WHERE id = ?',
'bindings' => [$id],
// ❌ Bad - SQL injection risk
'query' => "SELECT * FROM posts WHERE id = {$id}",
```
### 2. Limit Result Sets
```php
// ✅ Good - limited results
'query' => 'SELECT * FROM posts LIMIT 100',
// ❌ Bad - unbounded query
'query' => 'SELECT * FROM posts',
```
### 3. Use Read-Only Connections
```php
// ✅ Good - read-only user
'connection' => 'mcp_readonly',
// ❌ Bad - admin connection
'connection' => 'mysql_admin',
```
### 4. Validate Workspace Context
```php
// ✅ Good - validate context
$this->validateWorkspaceContext();
// ❌ Bad - no validation
// (workspace boundary bypass risk)
```
## Testing
```php
use Tests\TestCase;
use Core\Mcp\Services\SqlQueryValidator;
class SecurityTest extends TestCase
{
public function test_blocks_destructive_queries(): void
{
$validator = app(SqlQueryValidator::class);
$result = $validator->validate('DROP TABLE users');
$this->assertFalse($result['valid']);
$this->assertStringContainsString('Only SELECT', $result['error']);
}
public function test_allows_select_queries(): void
{
$validator = app(SqlQueryValidator::class);
$result = $validator->validate('SELECT * FROM posts WHERE id = ?');
$this->assertTrue($result['valid']);
}
public function test_enforces_workspace_scope(): void
{
$workspace = Workspace::factory()->create();
$context = new WorkspaceContext($workspace);
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
], $context);
// Should only return workspace's posts
$this->assertEquals($workspace->id, $result['rows'][0]['workspace_id']);
}
}
```
## Learn More
- [Query Database →](/packages/mcp/query-database)
- [Workspace Context →](/packages/mcp/workspace)
- [Quotas →](/packages/mcp/quotas)

605
docs/sql-security.md Normal file
View file

@ -0,0 +1,605 @@
# Guide: SQL Security
This guide documents the security controls for the Query Database MCP tool, including allowed SQL patterns, forbidden operations, and parameterized query requirements.
## Overview
The MCP Query Database tool provides AI agents with read-only SQL access. Multiple security layers protect against:
- SQL injection attacks
- Data modification/destruction
- Cross-tenant data access
- Resource exhaustion
- Information leakage
## Allowed SQL Patterns
### SELECT-Only Queries
Only `SELECT` statements are permitted. All queries must begin with `SELECT`:
```sql
-- Allowed: Basic SELECT
SELECT * FROM posts WHERE status = 'published';
-- Allowed: Specific columns
SELECT id, title, created_at FROM posts;
-- Allowed: COUNT queries
SELECT COUNT(*) FROM users WHERE active = 1;
-- Allowed: Aggregation
SELECT status, COUNT(*) as count FROM posts GROUP BY status;
-- Allowed: JOIN queries
SELECT posts.title, users.name
FROM posts
JOIN users ON posts.user_id = users.id;
-- Allowed: ORDER BY and LIMIT
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
-- Allowed: WHERE with multiple conditions
SELECT * FROM posts
WHERE status = 'published'
AND user_id = 42
AND created_at > '2024-01-01';
```
### Supported Operators
WHERE clauses support these operators:
| Operator | Example |
|----------|---------|
| `=` | `WHERE status = 'active'` |
| `!=`, `<>` | `WHERE status != 'deleted'` |
| `>`, `>=` | `WHERE created_at > '2024-01-01'` |
| `<`, `<=` | `WHERE views < 1000` |
| `LIKE` | `WHERE title LIKE '%search%'` |
| `IN` | `WHERE status IN ('draft', 'published')` |
| `BETWEEN` | `WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'` |
| `IS NULL` | `WHERE deleted_at IS NULL` |
| `IS NOT NULL` | `WHERE email IS NOT NULL` |
| `AND` | `WHERE a = 1 AND b = 2` |
| `OR` | `WHERE status = 'draft' OR status = 'review'` |
## Forbidden Operations
### Data Modification (Blocked)
```sql
-- BLOCKED: INSERT
INSERT INTO users (name) VALUES ('attacker');
-- BLOCKED: UPDATE
UPDATE users SET role = 'admin' WHERE id = 1;
-- BLOCKED: DELETE
DELETE FROM users WHERE id = 1;
-- BLOCKED: REPLACE
REPLACE INTO users (id, name) VALUES (1, 'changed');
```
### Schema Modification (Blocked)
```sql
-- BLOCKED: DROP
DROP TABLE users;
DROP DATABASE production;
-- BLOCKED: TRUNCATE
TRUNCATE TABLE logs;
-- BLOCKED: ALTER
ALTER TABLE users ADD COLUMN backdoor TEXT;
-- BLOCKED: CREATE
CREATE TABLE malicious_table (...);
-- BLOCKED: RENAME
RENAME TABLE users TO users_backup;
```
### Permission Operations (Blocked)
```sql
-- BLOCKED: GRANT
GRANT ALL ON *.* TO 'attacker'@'%';
-- BLOCKED: REVOKE
REVOKE SELECT ON database.* FROM 'user'@'%';
-- BLOCKED: FLUSH
FLUSH PRIVILEGES;
```
### System Operations (Blocked)
```sql
-- BLOCKED: File operations
SELECT * FROM posts INTO OUTFILE '/tmp/data.csv';
SELECT LOAD_FILE('/etc/passwd');
LOAD DATA INFILE '/etc/passwd' INTO TABLE users;
-- BLOCKED: Execution
EXECUTE prepared_statement;
CALL stored_procedure();
PREPARE stmt FROM 'SELECT ...';
-- BLOCKED: Variables
SET @var = (SELECT password FROM users);
SET GLOBAL max_connections = 1;
```
### Complete Blocked Keywords List
```php
// Data modification
'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'TRUNCATE'
// Schema changes
'DROP', 'ALTER', 'CREATE', 'RENAME'
// Permissions
'GRANT', 'REVOKE', 'FLUSH'
// System
'KILL', 'RESET', 'PURGE'
// File operations
'INTO OUTFILE', 'INTO DUMPFILE', 'LOAD_FILE', 'LOAD DATA'
// Execution
'EXECUTE', 'EXEC', 'PREPARE', 'DEALLOCATE', 'CALL'
// Variables
'SET '
```
## SQL Injection Prevention
### Dangerous Patterns (Detected and Blocked)
The validator detects and blocks common injection patterns:
#### Stacked Queries
```sql
-- BLOCKED: Multiple statements
SELECT * FROM posts; DROP TABLE users;
SELECT * FROM posts; DELETE FROM logs;
```
#### UNION Injection
```sql
-- BLOCKED: UNION attacks
SELECT * FROM posts WHERE id = 1 UNION SELECT password FROM users;
SELECT * FROM posts UNION ALL SELECT * FROM secrets;
```
#### Comment Obfuscation
```sql
-- BLOCKED: Comments hiding keywords
SELECT * FROM posts WHERE id = 1 /**/UNION/**/SELECT password FROM users;
SELECT * FROM posts; -- DROP TABLE users
SELECT * FROM posts # DELETE FROM logs
```
#### Hex Encoding
```sql
-- BLOCKED: Hex-encoded strings
SELECT * FROM posts WHERE id = 0x313B44524F50205441424C4520757365727320;
```
#### Time-Based Attacks
```sql
-- BLOCKED: Timing attacks
SELECT * FROM posts WHERE id = 1 AND SLEEP(10);
SELECT * FROM posts WHERE BENCHMARK(10000000, SHA1('test'));
```
#### System Table Access
```sql
-- BLOCKED: Information schema
SELECT * FROM information_schema.tables;
SELECT * FROM information_schema.columns WHERE table_name = 'users';
-- BLOCKED: MySQL system tables
SELECT * FROM mysql.user;
SELECT * FROM performance_schema.threads;
SELECT * FROM sys.session;
```
#### Subquery in WHERE
```sql
-- BLOCKED: Potential data exfiltration
SELECT * FROM posts WHERE id = (SELECT user_id FROM admins LIMIT 1);
```
### Detection Patterns
The validator uses these regex patterns to detect attacks:
```php
// Stacked queries
'/;\s*\S/i'
// UNION injection
'/\bUNION\b/i'
// Hex encoding
'/0x[0-9a-f]+/i'
// Dangerous functions
'/\bCHAR\s*\(/i'
'/\bBENCHMARK\s*\(/i'
'/\bSLEEP\s*\(/i'
// System tables
'/\bINFORMATION_SCHEMA\b/i'
'/\bmysql\./i'
'/\bperformance_schema\./i'
'/\bsys\./i'
// Subquery in WHERE
'/WHERE\s+.*\(\s*SELECT/i'
// Comment obfuscation
'/\/\*[^*]*\*\/\s*(?:UNION|SELECT|INSERT|UPDATE|DELETE|DROP)/i'
```
## Parameterized Queries
**Always use parameter bindings** instead of string interpolation:
### Correct Usage
```php
// SAFE: Parameterized query
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
'bindings' => [$userId, 'published'],
]);
// SAFE: Multiple parameters
$result = $tool->execute([
'query' => 'SELECT * FROM orders WHERE created_at BETWEEN ? AND ? AND total > ?',
'bindings' => ['2024-01-01', '2024-12-31', 100.00],
]);
```
### Incorrect Usage (Vulnerable)
```php
// VULNERABLE: String interpolation
$result = $tool->execute([
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
]);
// VULNERABLE: Concatenation
$query = "SELECT * FROM posts WHERE status = '" . $status . "'";
$result = $tool->execute(['query' => $query]);
// VULNERABLE: sprintf
$query = sprintf("SELECT * FROM posts WHERE id = %d", $id);
$result = $tool->execute(['query' => $query]);
```
### Why Bindings Matter
With bindings, malicious input is escaped automatically:
```php
// User input
$userInput = "'; DROP TABLE users; --";
// With bindings: SAFE (input is escaped)
$tool->execute([
'query' => 'SELECT * FROM posts WHERE title = ?',
'bindings' => [$userInput],
]);
// Executed as: SELECT * FROM posts WHERE title = '\'; DROP TABLE users; --'
// Without bindings: VULNERABLE
$tool->execute([
'query' => "SELECT * FROM posts WHERE title = '$userInput'",
]);
// Executed as: SELECT * FROM posts WHERE title = ''; DROP TABLE users; --'
```
## Whitelist-Based Validation
The validator uses a whitelist approach, only allowing queries matching known-safe patterns:
### Default Whitelist Patterns
```php
// Simple SELECT with optional WHERE
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+`?\w+`?
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
(\s+ORDER\s+BY\s+[\w\s,`]+)?
(\s+LIMIT\s+\d+)?;?\s*$/i'
// COUNT queries
'/^\s*SELECT\s+COUNT\s*\(\s*\*?\s*\)
\s+FROM\s+`?\w+`?
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*;?\s*$/i'
// Explicit column list
'/^\s*SELECT\s+`?\w+`?(\s*,\s*`?\w+`?)*
\s+FROM\s+`?\w+`?
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
(\s+ORDER\s+BY\s+[\w\s,`]+)?
(\s+LIMIT\s+\d+)?;?\s*$/i'
```
### Adding Custom Patterns
```php
// config/mcp.php
'database' => [
'use_whitelist' => true,
'whitelist_patterns' => [
// Allow specific JOIN pattern
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+posts\s+JOIN\s+users\s+ON\s+posts\.user_id\s*=\s*users\.id/i',
],
],
```
## Connection Security
### Allowed Connections
Only whitelisted database connections can be queried:
```php
// config/mcp.php
'database' => [
'allowed_connections' => [
'mysql', // Primary database
'analytics', // Read-only analytics
'logs', // Application logs
],
'connection' => 'mcp_readonly', // Default MCP connection
],
```
### Read-Only Database User
Create a dedicated read-only user for MCP:
```sql
-- Create read-only user
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
-- Grant SELECT only
GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
-- Explicitly deny write operations
REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER
ON app_database.* FROM 'mcp_readonly'@'%';
FLUSH PRIVILEGES;
```
Configure in Laravel:
```php
// config/database.php
'connections' => [
'mcp_readonly' => [
'driver' => 'mysql',
'host' => env('DB_HOST'),
'database' => env('DB_DATABASE'),
'username' => env('MCP_DB_USER', 'mcp_readonly'),
'password' => env('MCP_DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
],
],
```
## Blocked Tables
Configure tables that cannot be queried:
```php
// config/mcp.php
'database' => [
'blocked_tables' => [
'users', // User credentials
'password_resets', // Password tokens
'sessions', // Session data
'api_keys', // API credentials
'oauth_access_tokens', // OAuth tokens
'personal_access_tokens', // Sanctum tokens
'failed_jobs', // Job queue data
],
],
```
The validator checks for table references in multiple formats:
```php
// All these are blocked for 'users' table:
'SELECT * FROM users'
'SELECT * FROM `users`'
'SELECT posts.*, users.name FROM posts JOIN users...'
'SELECT users.email FROM ...'
```
## Row Limits
Automatic row limits prevent data exfiltration:
```php
// config/mcp.php
'database' => [
'max_rows' => 1000, // Maximum rows per query
],
```
If query doesn't include LIMIT, one is added automatically:
```php
// Query without LIMIT
$tool->execute(['query' => 'SELECT * FROM posts']);
// Becomes: SELECT * FROM posts LIMIT 1000
// Query with smaller LIMIT (preserved)
$tool->execute(['query' => 'SELECT * FROM posts LIMIT 10']);
// Stays: SELECT * FROM posts LIMIT 10
```
## Error Handling
### Forbidden Query Response
```json
{
"error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
}
```
### Invalid Structure Response
```json
{
"error": "Query rejected: Query must begin with SELECT"
}
```
### Not Whitelisted Response
```json
{
"error": "Query rejected: Query does not match any allowed pattern"
}
```
### Sanitized SQL Errors
Database errors are sanitized to prevent information leakage:
```php
// Original error (logged for debugging)
"SQLSTATE[42S02]: Table 'production.secret_table' doesn't exist at 192.168.1.100"
// Sanitized response (returned to client)
"Query execution failed: Table '[path]' doesn't exist at [ip]"
```
## Configuration Reference
```php
// config/mcp.php
return [
'database' => [
// Database connection for MCP queries
'connection' => env('MCP_DB_CONNECTION', 'mcp_readonly'),
// Use whitelist validation (recommended: true)
'use_whitelist' => true,
// Custom whitelist patterns (regex)
'whitelist_patterns' => [],
// Tables that cannot be queried
'blocked_tables' => [
'users',
'password_resets',
'sessions',
'api_keys',
],
// Maximum rows per query
'max_rows' => 1000,
// Query execution timeout (milliseconds)
'timeout' => 5000,
// Enable EXPLAIN analysis
'enable_explain' => true,
],
];
```
## Testing Security
```php
use Tests\TestCase;
use Core\Mod\Mcp\Services\SqlQueryValidator;
use Core\Mod\Mcp\Exceptions\ForbiddenQueryException;
class SqlSecurityTest extends TestCase
{
private SqlQueryValidator $validator;
protected function setUp(): void
{
parent::setUp();
$this->validator = new SqlQueryValidator();
}
public function test_blocks_delete(): void
{
$this->expectException(ForbiddenQueryException::class);
$this->validator->validate('DELETE FROM users');
}
public function test_blocks_union_injection(): void
{
$this->expectException(ForbiddenQueryException::class);
$this->validator->validate("SELECT * FROM posts UNION SELECT password FROM users");
}
public function test_blocks_stacked_queries(): void
{
$this->expectException(ForbiddenQueryException::class);
$this->validator->validate("SELECT * FROM posts; DROP TABLE users");
}
public function test_blocks_system_tables(): void
{
$this->expectException(ForbiddenQueryException::class);
$this->validator->validate("SELECT * FROM information_schema.tables");
}
public function test_allows_safe_select(): void
{
$this->validator->validate("SELECT id, title FROM posts WHERE status = 'published'");
$this->assertTrue(true); // No exception = pass
}
public function test_allows_count(): void
{
$this->validator->validate("SELECT COUNT(*) FROM posts");
$this->assertTrue(true);
}
}
```
## Best Practices Summary
1. **Always use parameterized queries** - Never interpolate values into SQL strings
2. **Use a read-only database user** - Database-level protection against modifications
3. **Configure blocked tables** - Prevent access to sensitive data
4. **Enable whitelist validation** - Only allow known-safe query patterns
5. **Set appropriate row limits** - Prevent large data exports
6. **Review logs regularly** - Monitor for suspicious query patterns
7. **Test security controls** - Include injection tests in your test suite
## Learn More
- [Query Database Tool](/packages/mcp/query-database) - Tool usage
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Tool development

739
docs/tools-reference.md Normal file
View file

@ -0,0 +1,739 @@
# API Reference: MCP Tools
Complete reference for all MCP tools including parameters, response formats, and error handling.
## Database Tools
### query_database
Execute read-only SQL queries against the database.
**Description:** Execute a read-only SQL SELECT query against the database
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `query` | string | Yes | SQL SELECT query to execute. Only read-only SELECT queries are permitted. |
| `explain` | boolean | No | If true, runs EXPLAIN on the query instead of executing it. Useful for query optimization. Default: `false` |
**Example Request:**
```json
{
"tool": "query_database",
"arguments": {
"query": "SELECT id, title, status FROM posts WHERE status = 'published' LIMIT 10"
}
}
```
**Success Response:**
```json
[
{"id": 1, "title": "First Post", "status": "published"},
{"id": 2, "title": "Second Post", "status": "published"}
]
```
**With EXPLAIN:**
```json
{
"tool": "query_database",
"arguments": {
"query": "SELECT * FROM posts WHERE status = 'published'",
"explain": true
}
}
```
**EXPLAIN Response:**
```json
{
"explain": [
{
"id": 1,
"select_type": "SIMPLE",
"table": "posts",
"type": "ref",
"key": "idx_status",
"rows": 150,
"Extra": "Using index"
}
],
"query": "SELECT * FROM posts WHERE status = 'published' LIMIT 1000",
"interpretation": [
{
"table": "posts",
"analysis": [
"GOOD: Using index: idx_status"
]
}
]
}
```
**Error Response - Forbidden Query:**
```json
{
"error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
}
```
**Error Response - Invalid Structure:**
```json
{
"error": "Query rejected: Query must begin with SELECT"
}
```
**Security Notes:**
- Only SELECT queries are allowed
- Blocked keywords: INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE
- UNION queries are blocked
- System tables (information_schema, mysql.*) are blocked
- Automatic LIMIT applied if not specified
- Use read-only database connection
---
### list_tables
List all database tables in the application.
**Description:** List all database tables
**Parameters:** None
**Example Request:**
```json
{
"tool": "list_tables",
"arguments": {}
}
```
**Success Response:**
```json
[
"users",
"posts",
"comments",
"tags",
"categories",
"media",
"migrations",
"jobs"
]
```
**Security Notes:**
- Returns table names only, not structure
- Some tables may be filtered based on configuration
---
## Commerce Tools
### get_billing_status
Get billing status for the authenticated workspace.
**Description:** Get billing status for your workspace including subscription, current plan, and billing period
**Parameters:** None (workspace from authentication context)
**Requires:** Workspace Context
**Example Request:**
```json
{
"tool": "get_billing_status",
"arguments": {}
}
```
**Success Response:**
```json
{
"workspace": {
"id": 42,
"name": "Acme Corp"
},
"subscription": {
"id": 123,
"status": "active",
"gateway": "stripe",
"billing_cycle": "monthly",
"current_period_start": "2024-01-01T00:00:00+00:00",
"current_period_end": "2024-02-01T00:00:00+00:00",
"days_until_renewal": 15,
"cancel_at_period_end": false,
"on_trial": false,
"trial_ends_at": null
},
"packages": [
{
"code": "professional",
"name": "Professional Plan",
"status": "active",
"expires_at": null
}
]
}
```
**Response Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `workspace.id` | integer | Workspace ID |
| `workspace.name` | string | Workspace name |
| `subscription.status` | string | active, trialing, past_due, canceled |
| `subscription.billing_cycle` | string | monthly, yearly |
| `subscription.days_until_renewal` | integer | Days until next billing |
| `subscription.on_trial` | boolean | Currently in trial period |
| `packages` | array | Active feature packages |
**Error Response - No Workspace Context:**
```json
{
"error": "MCP tool 'get_billing_status' requires workspace context. Authenticate with an API key or user session."
}
```
---
### list_invoices
List invoices for the authenticated workspace.
**Description:** List invoices for your workspace with optional status filter
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `status` | string | No | Filter by status: paid, pending, overdue, void |
| `limit` | integer | No | Maximum invoices to return. Default: 10, Max: 50 |
**Requires:** Workspace Context
**Example Request:**
```json
{
"tool": "list_invoices",
"arguments": {
"status": "paid",
"limit": 5
}
}
```
**Success Response:**
```json
{
"workspace_id": 42,
"count": 5,
"invoices": [
{
"id": 1001,
"invoice_number": "INV-2024-001",
"status": "paid",
"subtotal": 99.00,
"discount_amount": 0.00,
"tax_amount": 19.80,
"total": 118.80,
"amount_paid": 118.80,
"amount_due": 0.00,
"currency": "GBP",
"issue_date": "2024-01-01",
"due_date": "2024-01-15",
"paid_at": "2024-01-10T14:30:00+00:00",
"is_overdue": false,
"order_number": "ORD-2024-001"
}
]
}
```
**Response Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `invoice_number` | string | Unique invoice identifier |
| `status` | string | paid, pending, overdue, void |
| `total` | number | Total amount including tax |
| `amount_due` | number | Remaining amount to pay |
| `is_overdue` | boolean | Past due date with unpaid balance |
---
### upgrade_plan
Preview or execute a plan upgrade/downgrade.
**Description:** Preview or execute a plan upgrade/downgrade for your workspace subscription
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `package_code` | string | Yes | Code of the new package (e.g., agency, enterprise) |
| `preview` | boolean | No | If true, only preview without executing. Default: `true` |
| `immediate` | boolean | No | If true, apply immediately; false schedules for period end. Default: `true` |
**Requires:** Workspace Context
**Example Request - Preview:**
```json
{
"tool": "upgrade_plan",
"arguments": {
"package_code": "enterprise",
"preview": true
}
}
```
**Preview Response:**
```json
{
"preview": true,
"current_package": "professional",
"new_package": "enterprise",
"proration": {
"is_upgrade": true,
"is_downgrade": false,
"current_plan_price": 99.00,
"new_plan_price": 299.00,
"credit_amount": 49.50,
"prorated_new_cost": 149.50,
"net_amount": 100.00,
"requires_payment": true,
"days_remaining": 15,
"currency": "GBP"
}
}
```
**Execute Response:**
```json
{
"success": true,
"immediate": true,
"current_package": "professional",
"new_package": "enterprise",
"proration": {
"is_upgrade": true,
"net_amount": 100.00
},
"subscription_status": "active"
}
```
**Error Response - Package Not Found:**
```json
{
"error": "Package not found",
"available_packages": ["starter", "professional", "agency", "enterprise"]
}
```
---
### create_coupon
Create a new discount coupon code.
**Description:** Create a new discount coupon code
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `code` | string | Yes | Unique coupon code (uppercase letters, numbers, hyphens, underscores) |
| `name` | string | Yes | Display name for the coupon |
| `type` | string | No | Discount type: percentage or fixed_amount. Default: percentage |
| `value` | number | Yes | Discount value (1-100 for percentage, or fixed amount) |
| `duration` | string | No | How long discount applies: once, repeating, forever. Default: once |
| `max_uses` | integer | No | Maximum total uses (null for unlimited) |
| `valid_until` | string | No | Expiry date in YYYY-MM-DD format |
**Example Request:**
```json
{
"tool": "create_coupon",
"arguments": {
"code": "SUMMER25",
"name": "Summer Sale 2024",
"type": "percentage",
"value": 25,
"duration": "once",
"max_uses": 100,
"valid_until": "2024-08-31"
}
}
```
**Success Response:**
```json
{
"success": true,
"coupon": {
"id": 42,
"code": "SUMMER25",
"name": "Summer Sale 2024",
"type": "percentage",
"value": 25.0,
"duration": "once",
"max_uses": 100,
"valid_until": "2024-08-31",
"is_active": true
}
}
```
**Error Response - Invalid Code:**
```json
{
"error": "Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores."
}
```
**Error Response - Duplicate Code:**
```json
{
"error": "A coupon with this code already exists."
}
```
**Error Response - Invalid Percentage:**
```json
{
"error": "Percentage value must be between 1 and 100."
}
```
---
## System Tools
### list_sites
List all sites managed by the platform.
**Description:** List all sites managed by Host Hub
**Parameters:** None
**Example Request:**
```json
{
"tool": "list_sites",
"arguments": {}
}
```
**Success Response:**
```json
[
{
"name": "BioHost",
"domain": "link.host.uk.com",
"type": "WordPress"
},
{
"name": "SocialHost",
"domain": "social.host.uk.com",
"type": "Laravel"
},
{
"name": "AnalyticsHost",
"domain": "analytics.host.uk.com",
"type": "Node.js"
}
]
```
---
### list_routes
List all web routes in the application.
**Description:** List all web routes in the application
**Parameters:** None
**Example Request:**
```json
{
"tool": "list_routes",
"arguments": {}
}
```
**Success Response:**
```json
[
{
"uri": "/",
"methods": ["GET", "HEAD"],
"name": "home"
},
{
"uri": "/login",
"methods": ["GET", "HEAD"],
"name": "login"
},
{
"uri": "/api/posts",
"methods": ["GET", "HEAD"],
"name": "api.posts.index"
},
{
"uri": "/api/posts/{post}",
"methods": ["GET", "HEAD"],
"name": "api.posts.show"
}
]
```
---
### get_stats
Get current system statistics.
**Description:** Get current system statistics for Host Hub
**Parameters:** None
**Example Request:**
```json
{
"tool": "get_stats",
"arguments": {}
}
```
**Success Response:**
```json
{
"total_sites": 6,
"active_users": 128,
"page_views_30d": 12500,
"server_load": "23%"
}
```
---
## Common Error Responses
### Missing Workspace Context
Tools requiring workspace context return this when no API key or session is provided:
```json
{
"error": "MCP tool 'tool_name' requires workspace context. Authenticate with an API key or user session."
}
```
**HTTP Status:** 403
### Missing Dependency
When a tool's dependencies aren't satisfied:
```json
{
"error": "dependency_not_met",
"message": "Dependencies not satisfied for tool 'update_task'",
"missing": [
{
"type": "tool_called",
"key": "create_plan",
"description": "A plan must be created before updating tasks"
}
],
"suggested_order": ["create_plan", "update_task"]
}
```
**HTTP Status:** 422
### Quota Exceeded
When workspace has exceeded their tool usage quota:
```json
{
"error": "quota_exceeded",
"message": "Daily tool quota exceeded for this workspace",
"current_usage": 1000,
"limit": 1000,
"resets_at": "2024-01-16T00:00:00+00:00"
}
```
**HTTP Status:** 429
### Validation Error
When parameters fail validation:
```json
{
"error": "Validation failed",
"code": "VALIDATION_ERROR",
"details": {
"query": ["The query field is required"]
}
}
```
**HTTP Status:** 422
### Internal Error
When an unexpected error occurs:
```json
{
"error": "An unexpected error occurred. Please try again.",
"code": "INTERNAL_ERROR"
}
```
**HTTP Status:** 500
---
## Authentication
### API Key Authentication
Include your API key in the Authorization header:
```bash
curl -X POST https://api.example.com/mcp/tools/call \
-H "Authorization: Bearer sk_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{"tool": "get_billing_status", "arguments": {}}'
```
### Session Authentication
For browser-based access, use session cookies:
```javascript
fetch('/mcp/tools/call', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
tool: 'list_invoices',
arguments: { limit: 10 }
})
});
```
### MCP Session ID
For tracking dependencies across tool calls, include a session ID:
```bash
curl -X POST https://api.example.com/mcp/tools/call \
-H "Authorization: Bearer sk_live_xxxxx" \
-H "X-MCP-Session-ID: session_abc123" \
-H "Content-Type: application/json" \
-d '{"tool": "update_task", "arguments": {...}}'
```
---
## Tool Categories
### Query Tools
- `query_database` - Execute SQL queries
- `list_tables` - List database tables
### Commerce Tools
- `get_billing_status` - Get subscription status
- `list_invoices` - List workspace invoices
- `upgrade_plan` - Change subscription plan
- `create_coupon` - Create discount codes
### System Tools
- `list_sites` - List managed sites
- `list_routes` - List application routes
- `get_stats` - Get system statistics
---
## Response Format
All tools return JSON responses. Success responses vary by tool, but error responses follow a consistent format:
```json
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": {} // Optional additional information
}
```
**Common Error Codes:**
| Code | Description |
|------|-------------|
| `VALIDATION_ERROR` | Invalid parameters |
| `FORBIDDEN_QUERY` | SQL query blocked by security |
| `MISSING_WORKSPACE_CONTEXT` | Workspace authentication required |
| `QUOTA_EXCEEDED` | Usage limit reached |
| `NOT_FOUND` | Resource not found |
| `DEPENDENCY_NOT_MET` | Tool prerequisites not satisfied |
| `INTERNAL_ERROR` | Unexpected server error |
---
## Learn More
- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Build custom tools
- [SQL Security](/packages/mcp/sql-security) - Query security rules
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
- [Quotas](/packages/mcp/quotas) - Usage limits
- [Analytics](/packages/mcp/analytics) - Usage tracking

569
docs/tools.md Normal file
View file

@ -0,0 +1,569 @@
# Creating MCP Tools
Learn how to create custom MCP tools for AI agents with parameter validation, dependency management, and workspace context.
## Tool Structure
Every MCP tool extends `BaseTool`:
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
class ListPostsTool extends BaseTool
{
public function getName(): string
{
return 'blog:list-posts';
}
public function getDescription(): string
{
return 'List all blog posts with optional filters';
}
public function getParameters(): array
{
return [
'status' => [
'type' => 'string',
'description' => 'Filter by status',
'enum' => ['published', 'draft', 'archived'],
'required' => false,
],
'limit' => [
'type' => 'integer',
'description' => 'Number of posts to return',
'default' => 10,
'min' => 1,
'max' => 100,
'required' => false,
],
];
}
public function execute(array $params): array
{
$query = Post::query();
if (isset($params['status'])) {
$query->where('status', $params['status']);
}
$posts = $query->limit($params['limit'] ?? 10)->get();
return [
'success' => true,
'posts' => $posts->map(fn ($post) => [
'id' => $post->id,
'title' => $post->title,
'slug' => $post->slug,
'status' => $post->status,
'created_at' => $post->created_at->toIso8601String(),
])->toArray(),
'count' => $posts->count(),
];
}
}
```
## Registering Tools
Register tools in your module's `Boot.php`:
```php
<?php
namespace Mod\Blog;
use Core\Events\McpToolsRegistering;
use Mod\Blog\Tools\ListPostsTool;
use Mod\Blog\Tools\CreatePostTool;
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);
$event->tool('blog:get-post', GetPostTool::class);
}
}
```
## Parameter Validation
### Parameter Types
```php
public function getParameters(): array
{
return [
// String
'title' => [
'type' => 'string',
'description' => 'Post title',
'minLength' => 1,
'maxLength' => 255,
'required' => true,
],
// Integer
'views' => [
'type' => 'integer',
'description' => 'Number of views',
'min' => 0,
'max' => 1000000,
'required' => false,
],
// Boolean
'published' => [
'type' => 'boolean',
'description' => 'Is published',
'required' => false,
],
// Enum
'status' => [
'type' => 'string',
'enum' => ['draft', 'published', 'archived'],
'description' => 'Post status',
'required' => true,
],
// Array
'tags' => [
'type' => 'array',
'description' => 'Post tags',
'items' => ['type' => 'string'],
'required' => false,
],
// Object
'metadata' => [
'type' => 'object',
'description' => 'Additional metadata',
'properties' => [
'featured' => ['type' => 'boolean'],
'views' => ['type' => 'integer'],
],
'required' => false,
],
];
}
```
### Default Values
```php
'limit' => [
'type' => 'integer',
'default' => 10, // Used if not provided
'required' => false,
]
```
### Custom Validation
```php
public function execute(array $params): array
{
// Additional validation
if (isset($params['email']) && !filter_var($params['email'], FILTER_VALIDATE_EMAIL)) {
return [
'success' => false,
'error' => 'Invalid email address',
'code' => 'INVALID_EMAIL',
];
}
// Tool logic...
}
```
## Workspace Context
### Requiring Workspace
Use the `RequiresWorkspaceContext` trait:
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class CreatePostTool extends BaseTool
{
use RequiresWorkspaceContext;
public function execute(array $params): array
{
// Workspace automatically validated and available
$workspace = $this->getWorkspaceContext();
$post = Post::create([
'title' => $params['title'],
'content' => $params['content'],
'workspace_id' => $workspace->id,
]);
return [
'success' => true,
'post_id' => $post->id,
];
}
}
```
### Optional Workspace
```php
public function execute(array $params): array
{
$workspace = $this->getWorkspaceContext(); // May be null
$query = Post::query();
if ($workspace) {
$query->where('workspace_id', $workspace->id);
}
return ['posts' => $query->get()];
}
```
## Tool Dependencies
### Declaring Dependencies
```php
<?php
namespace Mod\Blog\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Dependencies\HasDependencies;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mcp\Dependencies\DependencyType;
class ImportPostsTool extends BaseTool
{
use HasDependencies;
public function getDependencies(): array
{
return [
// Required dependency
new ToolDependency(
'blog:list-posts',
DependencyType::REQUIRED
),
// Optional dependency
new ToolDependency(
'media:upload',
DependencyType::OPTIONAL
),
];
}
public function execute(array $params): array
{
// Dependencies automatically validated before execution
// ...
}
}
```
### Dependency Types
- `DependencyType::REQUIRED` - Tool cannot execute without this
- `DependencyType::OPTIONAL` - Tool works better with this but not required
## Error Handling
### Standard Error Format
```php
public function execute(array $params): array
{
try {
// Tool logic...
return [
'success' => true,
'data' => $result,
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'code' => 'TOOL_EXECUTION_FAILED',
];
}
}
```
### Specific Error Codes
```php
// Validation error
return [
'success' => false,
'error' => 'Title is required',
'code' => 'VALIDATION_ERROR',
'field' => 'title',
];
// Not found
return [
'success' => false,
'error' => 'Post not found',
'code' => 'NOT_FOUND',
'resource_id' => $params['id'],
];
// Forbidden
return [
'success' => false,
'error' => 'Insufficient permissions',
'code' => 'FORBIDDEN',
'required_permission' => 'posts.create',
];
```
## Advanced Patterns
### Tool with File Processing
```php
public function execute(array $params): array
{
$csvPath = $params['csv_path'];
if (!file_exists($csvPath)) {
return [
'success' => false,
'error' => 'CSV file not found',
'code' => 'FILE_NOT_FOUND',
];
}
$imported = 0;
$errors = [];
if (($handle = fopen($csvPath, 'r')) !== false) {
while (($data = fgetcsv($handle)) !== false) {
try {
Post::create([
'title' => $data[0],
'content' => $data[1],
]);
$imported++;
} catch (\Exception $e) {
$errors[] = "Row {$imported}: {$e->getMessage()}";
}
}
fclose($handle);
}
return [
'success' => true,
'imported' => $imported,
'errors' => $errors,
];
}
```
### Tool with Pagination
```php
public function execute(array $params): array
{
$page = $params['page'] ?? 1;
$perPage = $params['per_page'] ?? 15;
$posts = Post::paginate($perPage, ['*'], 'page', $page);
return [
'success' => true,
'posts' => $posts->items(),
'pagination' => [
'current_page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'per_page' => $posts->perPage(),
'total' => $posts->total(),
],
];
}
```
### Tool with Progress Tracking
```php
public function execute(array $params): array
{
$postIds = $params['post_ids'];
$total = count($postIds);
$processed = 0;
foreach ($postIds as $postId) {
$post = Post::find($postId);
if ($post) {
$post->publish();
$processed++;
// Emit progress event
event(new ToolProgress(
tool: $this->getName(),
progress: ($processed / $total) * 100,
message: "Published post {$postId}"
));
}
}
return [
'success' => true,
'processed' => $processed,
'total' => $total,
];
}
```
## Testing Tools
```php
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Mod\Blog\Tools\ListPostsTool;
use Mod\Blog\Models\Post;
class ListPostsToolTest extends TestCase
{
public function test_lists_all_posts(): void
{
Post::factory()->count(5)->create();
$tool = new ListPostsTool();
$result = $tool->execute([]);
$this->assertTrue($result['success']);
$this->assertCount(5, $result['posts']);
}
public function test_filters_by_status(): void
{
Post::factory()->count(3)->create(['status' => 'published']);
Post::factory()->count(2)->create(['status' => 'draft']);
$tool = new ListPostsTool();
$result = $tool->execute([
'status' => 'published',
]);
$this->assertCount(3, $result['posts']);
}
public function test_respects_limit(): void
{
Post::factory()->count(20)->create();
$tool = new ListPostsTool();
$result = $tool->execute([
'limit' => 5,
]);
$this->assertCount(5, $result['posts']);
}
}
```
## Best Practices
### 1. Clear Naming
```php
// ✅ Good - descriptive name
'blog:create-post'
'blog:list-published-posts'
'blog:delete-post'
// ❌ Bad - vague name
'blog:action'
'do-thing'
```
### 2. Detailed Descriptions
```php
// ✅ Good - explains what and why
public function getDescription(): string
{
return 'Create a new blog post with title, content, and optional metadata. '
. 'Requires workspace context. Validates entitlements before creation.';
}
// ❌ Bad - too brief
public function getDescription(): string
{
return 'Creates post';
}
```
### 3. Validate Parameters
```php
// ✅ Good - strict validation
public function getParameters(): array
{
return [
'title' => [
'type' => 'string',
'required' => true,
'minLength' => 1,
'maxLength' => 255,
],
];
}
```
### 4. Return Consistent Format
```php
// ✅ Good - always includes success
return [
'success' => true,
'data' => $result,
];
return [
'success' => false,
'error' => $message,
'code' => $code,
];
```
## Learn More
- [Query Database →](/packages/mcp/query-database)
- [Workspace Context →](/packages/mcp/workspace)
- [Tool Analytics →](/packages/mcp/analytics)

368
docs/workspace.md Normal file
View file

@ -0,0 +1,368 @@
# Workspace Context
Workspace isolation and context resolution for MCP tools.
## Overview
Workspace context ensures that MCP tools operate within the correct workspace boundary, preventing data leaks and unauthorized access.
## Context Resolution
### From Request Headers
```php
use Core\Mcp\Context\WorkspaceContext;
// Resolve from X-Workspace-ID header
$context = WorkspaceContext::fromRequest($request);
// Returns WorkspaceContext with:
// - workspace: Workspace model
// - user: Current user
// - namespace: Current namespace (if applicable)
```
**Request Example:**
```bash
curl -H "Authorization: Bearer sk_live_..." \
-H "X-Workspace-ID: ws_abc123" \
https://api.example.com/mcp/query
```
### From API Key
```php
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::findByKey($providedKey);
// API key is scoped to workspace
$context = WorkspaceContext::fromApiKey($apiKey);
```
### Manual Creation
```php
use Mod\Tenant\Models\Workspace;
$workspace = Workspace::find($id);
$context = new WorkspaceContext(
workspace: $workspace,
user: $user,
namespace: $namespace
);
```
## Requiring Context
### Tool Implementation
```php
<?php
namespace Mod\Blog\Mcp\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class ListPosts extends BaseTool
{
use RequiresWorkspaceContext;
public function execute(array $params): array
{
// Validates workspace context exists
$this->validateWorkspaceContext();
// Access workspace
$workspace = $this->workspaceContext->workspace;
// Query scoped to workspace
return Post::where('workspace_id', $workspace->id)
->where('status', $params['status'] ?? 'published')
->get()
->toArray();
}
}
```
### Middleware
```php
use Core\Mcp\Middleware\ValidateWorkspaceContext;
Route::middleware([ValidateWorkspaceContext::class])
->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
```
**Validation:**
- Header `X-Workspace-ID` is present
- Workspace exists
- User has access to workspace
- API key is scoped to workspace
## Automatic Query Scoping
### SELECT Queries
```php
// Query without workspace filter
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
]);
// Automatically becomes:
// SELECT * FROM posts
// WHERE status = ?
// AND workspace_id = ?
// With bindings: ['published', $workspaceId]
```
### BelongsToWorkspace Models
```php
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
class Post extends Model
{
use BelongsToWorkspace;
// Automatically scoped to workspace
}
// All queries automatically filtered:
Post::all(); // Only current workspace's posts
Post::where('status', 'published')->get(); // Scoped
Post::find($id); // Returns null if wrong workspace
```
## Context Properties
### Workspace
```php
$workspace = $context->workspace;
$workspace->id; // Workspace ID
$workspace->name; // Workspace name
$workspace->slug; // URL slug
$workspace->settings; // Workspace settings
$workspace->subscription; // Subscription plan
```
### User
```php
$user = $context->user;
$user->id; // User ID
$user->name; // User name
$user->email; // User email
$user->workspace_id; // Primary workspace
$user->permissions; // User permissions
```
### Namespace
```php
$namespace = $context->namespace;
if ($namespace) {
$namespace->id; // Namespace ID
$namespace->name; // Namespace name
$namespace->entitlements; // Feature access
}
```
## Multi-Workspace Access
### Switching Context
```php
// User with access to multiple workspaces
$workspaces = $user->workspaces;
foreach ($workspaces as $workspace) {
$context = new WorkspaceContext($workspace, $user);
// Execute in workspace context
$result = $tool->execute($params, $context);
}
```
### Cross-Workspace Queries (Admin)
```php
// Requires admin permission
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
'bypass_workspace_scope' => true,
], $context);
// Returns posts from all workspaces
```
## Error Handling
### Missing Context
```php
use Core\Mcp\Exceptions\MissingWorkspaceContextException;
try {
$tool->execute($params); // No context provided
} catch (MissingWorkspaceContextException $e) {
return response()->json([
'error' => 'Workspace context required',
'message' => 'Please provide X-Workspace-ID header',
], 400);
}
```
### Invalid Workspace
```php
use Core\Mod\Tenant\Exceptions\WorkspaceNotFoundException;
try {
$context = WorkspaceContext::fromRequest($request);
} catch (WorkspaceNotFoundException $e) {
return response()->json([
'error' => 'Invalid workspace',
'message' => 'Workspace not found',
], 404);
}
```
### Unauthorized Access
```php
use Illuminate\Auth\Access\AuthorizationException;
try {
$context = WorkspaceContext::fromRequest($request);
} catch (AuthorizationException $e) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'You do not have access to this workspace',
], 403);
}
```
## Testing
```php
use Tests\TestCase;
use Core\Mcp\Context\WorkspaceContext;
class WorkspaceContextTest extends TestCase
{
public function test_resolves_from_header(): void
{
$workspace = Workspace::factory()->create();
$response = $this->withHeaders([
'X-Workspace-ID' => $workspace->id,
])->postJson('/mcp/query', [...]);
$response->assertStatus(200);
}
public function test_scopes_queries_to_workspace(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
Post::factory()->create(['workspace_id' => $workspace1->id]);
Post::factory()->create(['workspace_id' => $workspace2->id]);
$context = new WorkspaceContext($workspace1);
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
], $context);
$this->assertCount(1, $result['rows']);
$this->assertEquals($workspace1->id, $result['rows'][0]['workspace_id']);
}
public function test_throws_when_context_missing(): void
{
$this->expectException(MissingWorkspaceContextException::class);
$tool->execute(['query' => 'SELECT * FROM posts']);
}
}
```
## Best Practices
### 1. Always Validate Context
```php
// ✅ Good - validate context
public function execute(array $params)
{
$this->validateWorkspaceContext();
// ...
}
// ❌ Bad - no validation
public function execute(array $params)
{
// Potential workspace bypass
}
```
### 2. Use BelongsToWorkspace Trait
```php
// ✅ Good - automatic scoping
class Post extends Model
{
use BelongsToWorkspace;
}
// ❌ Bad - manual filtering
Post::where('workspace_id', $workspace->id)->get();
```
### 3. Provide Clear Errors
```php
// ✅ Good - helpful error
throw new MissingWorkspaceContextException(
'Please provide X-Workspace-ID header'
);
// ❌ Bad - generic error
throw new Exception('Error');
```
### 4. Test Context Isolation
```php
// ✅ Good - test workspace boundaries
public function test_cannot_access_other_workspace(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
$context = new WorkspaceContext($workspace1);
$post = Post::factory()->create(['workspace_id' => $workspace2->id]);
$result = Post::find($post->id); // Should be null
$this->assertNull($result);
}
```
## Learn More
- [Multi-Tenancy →](/packages/core/tenancy)
- [Security →](/packages/mcp/security)
- [Creating Tools →](/packages/mcp/tools)