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