Skip to content

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:

TierRequests/HourQueries/Day
Free60500
Pro60010,000
EnterpriseUnlimitedUnlimited

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

Released under the EUPL-1.2 License.