diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 0000000..2ad38f7 --- /dev/null +++ b/docs/analytics.md @@ -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 + $analytics->totalExecutions(), + 'topTools' => $analytics->mostUsedTools(['limit' => 10]), + 'errorRate' => $analytics->errorRate(), + 'avgExecutionTime' => $analytics->averageExecutionTime(), + ]); + } +} +``` + +**View:** + +```blade + + +

MCP Tool Analytics

+
+ +
+ + + + + + + +
+ +
+

Most Used Tools

+ + + Tool + Executions + + + @foreach($topTools as $tool) + + {{ $tool['tool_name'] }} + {{ number_format($tool['count']) }} + + @endforeach + +
+
+``` + +## Tool Detail View + +Detailed analytics for specific tool: + +```blade + + +

{{ $toolName }} Analytics

+
+ +
+ + + + + +
+ +
+

Performance Trend

+ +
+ +
+

Recent Errors

+ @foreach($recentErrors as $error) + + {{ $error->created_at->diffForHumans() }} + {{ $error->error_message }} + + @endforeach +
+
+``` + +## 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) diff --git a/docs/creating-mcp-tools.md b/docs/creating-mcp-tools.md new file mode 100644 index 0000000..f309801 --- /dev/null +++ b/docs/creating-mcp-tools.md @@ -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 +get(); + + return Response::text(json_encode($posts->toArray(), JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'status' => $schema->string('Filter by post status'), + 'limit' => $schema->integer('Maximum posts to return')->default(10), + ]; + } +} +``` + +### Key Methods + +| Method | Purpose | +|--------|---------| +| `$description` | Tool description shown to AI agents | +| `handle(Request)` | Execute the tool and return a Response | +| `schema(JsonSchema)` | Define input parameters | + +## Parameter Validation + +Define parameters using the `JsonSchema` builder in the `schema()` method: + +### String Parameters + +```php +public function schema(JsonSchema $schema): array +{ + return [ + // Basic string + 'title' => $schema->string('Post title')->required(), + + // Enum values + 'status' => $schema->string('Post status: draft, published, archived'), + + // With default + 'format' => $schema->string('Output format')->default('json'), + ]; +} +``` + +### Numeric Parameters + +```php +public function schema(JsonSchema $schema): array +{ + return [ + // Integer + 'limit' => $schema->integer('Maximum results')->default(10), + + // Number (float) + 'price' => $schema->number('Product price'), + ]; +} +``` + +### Boolean Parameters + +```php +public function schema(JsonSchema $schema): array +{ + return [ + 'include_drafts' => $schema->boolean('Include draft posts')->default(false), + ]; +} +``` + +### Array Parameters + +```php +public function schema(JsonSchema $schema): array +{ + return [ + 'tags' => $schema->array('Filter by tags'), + 'ids' => $schema->array('Specific post IDs to fetch'), + ]; +} +``` + +### Required vs Optional + +```php +public function schema(JsonSchema $schema): array +{ + return [ + // Required - AI agent must provide this + 'query' => $schema->string('SQL query to execute')->required(), + + // Optional with default + 'limit' => $schema->integer('Max rows')->default(100), + + // Optional without default + 'status' => $schema->string('Filter status'), + ]; +} +``` + +### Accessing Parameters + +```php +public function handle(Request $request): Response +{ + // Get single parameter + $query = $request->input('query'); + + // Get with default + $limit = $request->input('limit', 10); + + // Check if parameter exists + if ($request->has('status')) { + // ... + } + + // Get all parameters + $params = $request->all(); +} +``` + +### Custom Validation + +For validation beyond schema types, validate in `handle()`: + +```php +public function handle(Request $request): Response +{ + $email = $request->input('email'); + + // Custom validation + if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + return Response::text(json_encode([ + 'error' => 'Invalid email format', + 'code' => 'VALIDATION_ERROR', + ])); + } + + // Validate limit range + $limit = $request->input('limit', 10); + if ($limit < 1 || $limit > 100) { + return Response::text(json_encode([ + 'error' => 'Limit must be between 1 and 100', + 'code' => 'VALIDATION_ERROR', + ])); + } + + // Continue with tool logic... +} +``` + +## Workspace Context + +For multi-tenant applications, tools must access data scoped to the authenticated workspace. **Never accept workspace ID as a user-supplied parameter** - this prevents cross-tenant data access. + +### Using RequiresWorkspaceContext + +```php +getWorkspace(); + $workspaceId = $this->getWorkspaceId(); + + $posts = Post::where('workspace_id', $workspaceId) + ->limit($request->input('limit', 10)) + ->get(); + + return Response::text(json_encode([ + 'workspace' => $workspace->name, + 'posts' => $posts->toArray(), + ], JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + // Note: No workspace_id parameter - comes from auth context + return [ + 'limit' => $schema->integer('Maximum posts to return'), + ]; + } +} +``` + +### Trait Methods + +The `RequiresWorkspaceContext` trait provides: + +| Method | Returns | Description | +|--------|---------|-------------| +| `getWorkspaceContext()` | `WorkspaceContext` | Full context object | +| `getWorkspaceId()` | `int` | Workspace ID only | +| `getWorkspace()` | `Workspace` | Workspace model | +| `hasWorkspaceContext()` | `bool` | Check if context available | +| `validateResourceOwnership(int, string)` | `void` | Validate resource belongs to workspace | + +### Setting Workspace Context + +Workspace context is set by middleware from authentication (API key or user session): + +```php +// In middleware or controller +$tool = new ListWorkspacePostsTool(); +$tool->setWorkspaceContext(WorkspaceContext::fromWorkspace($workspace)); + +// Or from ID +$tool->setWorkspaceId($workspaceId); + +// Or from workspace model +$tool->setWorkspace($workspace); +``` + +### Validating Resource Ownership + +When accessing specific resources, validate they belong to the workspace: + +```php +public function handle(Request $request): Response +{ + $postId = $request->input('post_id'); + $post = Post::findOrFail($postId); + + // Throws RuntimeException if post doesn't belong to workspace + $this->validateResourceOwnership($post->workspace_id, 'post'); + + // Safe to proceed + return Response::text(json_encode($post->toArray())); +} +``` + +## Tool Dependencies + +Tools can declare dependencies that must be satisfied before execution. This is useful for workflows where tools must be called in a specific order. + +### Declaring Dependencies + +Implement `HasDependencies` or use `ValidatesDependencies` trait: + +```php + 'plan_id', +]); + +// Custom validation +ToolDependency::custom('billing_active', 'Billing must be active'); +``` + +### Optional Dependencies + +Mark dependencies as optional (warns but doesn't block): + +```php +public function dependencies(): array +{ + return [ + ToolDependency::toolCalled('cache_warm') + ->asOptional(), // Soft dependency + ]; +} +``` + +### Inline Dependency Validation + +Use the `ValidatesDependencies` trait for inline validation: + +```php +use Core\Mod\Mcp\Tools\Concerns\ValidatesDependencies; + +class MyTool extends Tool +{ + use ValidatesDependencies; + + public function handle(Request $request): Response + { + $context = ['session_id' => $request->input('session_id')]; + + // Throws if dependencies not met + $this->validateDependencies($context); + + // Or check without throwing + if (!$this->dependenciesMet($context)) { + $missing = $this->getMissingDependencies($context); + return Response::text(json_encode([ + 'error' => 'Dependencies not met', + 'missing' => array_map(fn($d) => $d->key, $missing), + ])); + } + + // Continue... + } +} +``` + +## Registering Tools + +Register tools via the `McpToolsRegistering` event in your module: + +```php + 'onMcpTools', + ]; + + public function onMcpTools(McpToolsRegistering $event): void + { + $event->tool('blog:list-posts', ListPostsTool::class); + $event->tool('blog:create-post', CreatePostTool::class); + } +} +``` + +### Tool Naming Conventions + +Use consistent naming: + +```php +// Pattern: module:action-resource +'blog:list-posts' // List resources +'blog:get-post' // Get single resource +'blog:create-post' // Create resource +'blog:update-post' // Update resource +'blog:delete-post' // Delete resource + +// Sub-modules +'commerce:billing:get-status' +'commerce:coupon:create' +``` + +## Response Formats + +### Success Response + +```php +return Response::text(json_encode([ + 'success' => true, + 'data' => $result, +], JSON_PRETTY_PRINT)); +``` + +### Error Response + +```php +return Response::text(json_encode([ + 'error' => 'Specific error message', + 'code' => 'ERROR_CODE', +])); +``` + +### Paginated Response + +```php +$posts = Post::paginate($perPage); + +return Response::text(json_encode([ + 'data' => $posts->items(), + 'pagination' => [ + 'current_page' => $posts->currentPage(), + 'last_page' => $posts->lastPage(), + 'per_page' => $posts->perPage(), + 'total' => $posts->total(), + ], +], JSON_PRETTY_PRINT)); +``` + +### List Response + +```php +return Response::text(json_encode([ + 'count' => $items->count(), + 'items' => $items->map(fn($item) => [ + 'id' => $item->id, + 'name' => $item->name, + ])->all(), +], JSON_PRETTY_PRINT)); +``` + +## Security Best Practices + +### 1. Never Trust User-Supplied IDs for Authorization + +```php +// BAD: Using workspace_id from request +public function handle(Request $request): Response +{ + $workspaceId = $request->input('workspace_id'); // Attacker can change this! + $posts = Post::where('workspace_id', $workspaceId)->get(); +} + +// GOOD: Using authenticated workspace context +public function handle(Request $request): Response +{ + $workspaceId = $this->getWorkspaceId(); // From auth context + $posts = Post::where('workspace_id', $workspaceId)->get(); +} +``` + +### 2. Validate Resource Ownership + +```php +public function handle(Request $request): Response +{ + $postId = $request->input('post_id'); + $post = Post::findOrFail($postId); + + // Always validate ownership before access + $this->validateResourceOwnership($post->workspace_id, 'post'); + + return Response::text(json_encode($post->toArray())); +} +``` + +### 3. Sanitize and Limit Input + +```php +public function handle(Request $request): Response +{ + // Limit result sets + $limit = min($request->input('limit', 10), 100); + + // Sanitize string input + $search = strip_tags($request->input('search', '')); + $search = substr($search, 0, 255); + + // Validate enum values + $status = $request->input('status'); + if ($status && !in_array($status, ['draft', 'published', 'archived'])) { + return Response::text(json_encode(['error' => 'Invalid status'])); + } +} +``` + +### 4. Log Sensitive Operations + +```php +public function handle(Request $request): Response +{ + Log::info('MCP tool executed', [ + 'tool' => 'delete-post', + 'workspace_id' => $this->getWorkspaceId(), + 'post_id' => $request->input('post_id'), + 'user' => auth()->id(), + ]); + + // Perform operation... +} +``` + +### 5. Use Read-Only Database Connections for Queries + +```php +// For query tools, use read-only connection +$connection = config('mcp.database.connection', 'readonly'); +$results = DB::connection($connection)->select($query); +``` + +### 6. Sanitize Error Messages + +```php +try { + // Operation... +} catch (\Exception $e) { + // Log full error for debugging + report($e); + + // Return sanitized message to client + return Response::text(json_encode([ + 'error' => 'Operation failed. Please try again.', + 'code' => 'OPERATION_FAILED', + ])); +} +``` + +### 7. Implement Rate Limiting + +Tools should respect quota limits: + +```php +use Core\Mcp\Services\McpQuotaService; + +public function handle(Request $request): Response +{ + $quota = app(McpQuotaService::class); + $workspace = $this->getWorkspace(); + + if (!$quota->canExecute($workspace, $this->name())) { + return Response::text(json_encode([ + 'error' => 'Rate limit exceeded', + 'code' => 'QUOTA_EXCEEDED', + ])); + } + + // Execute tool... + + $quota->recordExecution($workspace, $this->name()); +} +``` + +## Testing Tools + +```php +create(); + Post::factory()->count(5)->create([ + 'workspace_id' => $workspace->id, + ]); + + $tool = new ListPostsTool(); + $tool->setWorkspaceContext( + WorkspaceContext::fromWorkspace($workspace) + ); + + $request = new \Laravel\Mcp\Request([ + 'limit' => 10, + ]); + + $response = $tool->handle($request); + $data = json_decode($response->getContent(), true); + + $this->assertCount(5, $data['posts']); + } + + public function test_respects_workspace_isolation(): void + { + $workspace1 = Workspace::factory()->create(); + $workspace2 = Workspace::factory()->create(); + + Post::factory()->count(3)->create(['workspace_id' => $workspace1->id]); + Post::factory()->count(2)->create(['workspace_id' => $workspace2->id]); + + $tool = new ListPostsTool(); + $tool->setWorkspace($workspace1); + + $request = new \Laravel\Mcp\Request([]); + $response = $tool->handle($request); + $data = json_decode($response->getContent(), true); + + // Should only see workspace1's posts + $this->assertCount(3, $data['posts']); + } + + public function test_throws_without_workspace_context(): void + { + $this->expectException(MissingWorkspaceContextException::class); + + $tool = new ListPostsTool(); + // Not setting workspace context + + $tool->handle(new \Laravel\Mcp\Request([])); + } +} +``` + +## Complete Example + +Here's a complete tool implementation following all best practices: + +```php +getWorkspaceId(); + + // Validate and sanitize inputs + $status = $request->input('status'); + if ($status && !in_array($status, ['paid', 'pending', 'overdue', 'void'])) { + return Response::text(json_encode([ + 'error' => 'Invalid status. Use: paid, pending, overdue, void', + 'code' => 'VALIDATION_ERROR', + ])); + } + + $limit = min($request->input('limit', 10), 50); + + // Query with workspace scope + $query = Invoice::with('order') + ->where('workspace_id', $workspaceId) + ->latest(); + + if ($status) { + $query->where('status', $status); + } + + $invoices = $query->limit($limit)->get(); + + return Response::text(json_encode([ + 'workspace_id' => $workspaceId, + 'count' => $invoices->count(), + 'invoices' => $invoices->map(fn ($invoice) => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'status' => $invoice->status, + 'total' => (float) $invoice->total, + 'currency' => $invoice->currency, + 'issue_date' => $invoice->issue_date?->toDateString(), + 'due_date' => $invoice->due_date?->toDateString(), + 'is_overdue' => $invoice->isOverdue(), + ])->all(), + ], JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'status' => $schema->string('Filter by status: paid, pending, overdue, void'), + 'limit' => $schema->integer('Maximum invoices to return (default 10, max 50)'), + ]; + } +} +``` + +## Learn More + +- [SQL Security](/packages/mcp/sql-security) - Safe query patterns +- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation +- [Tool Analytics](/packages/mcp/analytics) - Usage tracking +- [Quotas](/packages/mcp/quotas) - Rate limiting diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1424588 --- /dev/null +++ b/docs/index.md @@ -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 + '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 + [ + '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 +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 +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 +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) diff --git a/docs/query-database.md b/docs/query-database.md new file mode 100644 index 0000000..b6438b5 --- /dev/null +++ b/docs/query-database.md @@ -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 +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) diff --git a/docs/quotas.md b/docs/quotas.md new file mode 100644 index 0000000..4556a1d --- /dev/null +++ b/docs/quotas.md @@ -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 + + + Workspace + Tier + Hourly Usage + Daily Usage + + + @foreach($workspaces as $workspace) + + {{ $workspace['name'] }} + + + {{ ucfirst($workspace['tier']) }} + + + + {{ $workspace['hourly_usage'] }} / {{ $workspace['hourly_limit'] ?? '∞' }} + + + + {{ $workspace['daily_usage'] }} / {{ $workspace['daily_limit'] ?? '∞' }} + + + @endforeach + +``` + +### 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) diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..61ecd3e --- /dev/null +++ b/docs/security.md @@ -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) diff --git a/docs/sql-security.md b/docs/sql-security.md new file mode 100644 index 0000000..fead675 --- /dev/null +++ b/docs/sql-security.md @@ -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 diff --git a/docs/tools-reference.md b/docs/tools-reference.md new file mode 100644 index 0000000..1dd1d52 --- /dev/null +++ b/docs/tools-reference.md @@ -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 diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..d9cd02b --- /dev/null +++ b/docs/tools.md @@ -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 + [ + '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 + '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 +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 + 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 +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) diff --git a/docs/workspace.md b/docs/workspace.md new file mode 100644 index 0000000..0736654 --- /dev/null +++ b/docs/workspace.md @@ -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 +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)