docs: add package documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
80ab88d330
commit
1a95091b9c
10 changed files with 5160 additions and 0 deletions
436
docs/analytics.md
Normal file
436
docs/analytics.md
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
# Tool Analytics
|
||||
|
||||
Track MCP tool usage, performance, and patterns with comprehensive analytics.
|
||||
|
||||
## Overview
|
||||
|
||||
The MCP analytics system provides insights into:
|
||||
- Tool execution frequency
|
||||
- Performance metrics
|
||||
- Error rates
|
||||
- User patterns
|
||||
- Workspace usage
|
||||
|
||||
## Recording Metrics
|
||||
|
||||
### Automatic Tracking
|
||||
|
||||
Tool executions are automatically tracked:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Listeners\RecordToolExecution;
|
||||
use Core\Mcp\Events\ToolExecuted;
|
||||
|
||||
// Automatically recorded on tool execution
|
||||
event(new ToolExecuted(
|
||||
tool: 'query_database',
|
||||
workspace: $workspace,
|
||||
user: $user,
|
||||
duration: 5.23,
|
||||
success: true
|
||||
));
|
||||
```
|
||||
|
||||
### Manual Recording
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\ToolAnalyticsService;
|
||||
|
||||
$analytics = app(ToolAnalyticsService::class);
|
||||
|
||||
$analytics->record([
|
||||
'tool_name' => 'query_database',
|
||||
'workspace_id' => $workspace->id,
|
||||
'user_id' => $user->id,
|
||||
'execution_time_ms' => 5.23,
|
||||
'success' => true,
|
||||
'error_message' => null,
|
||||
'metadata' => [
|
||||
'query_rows' => 42,
|
||||
'connection' => 'mysql',
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
## Querying Analytics
|
||||
|
||||
### Tool Stats
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\ToolAnalyticsService;
|
||||
|
||||
$analytics = app(ToolAnalyticsService::class);
|
||||
|
||||
// Get stats for specific tool
|
||||
$stats = $analytics->getToolStats('query_database', [
|
||||
'workspace_id' => $workspace->id,
|
||||
'start_date' => now()->subDays(30),
|
||||
'end_date' => now(),
|
||||
]);
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
```php
|
||||
use Core\Mcp\DTO\ToolStats;
|
||||
|
||||
$stats = new ToolStats(
|
||||
tool_name: 'query_database',
|
||||
total_executions: 1234,
|
||||
successful_executions: 1200,
|
||||
failed_executions: 34,
|
||||
avg_execution_time_ms: 5.23,
|
||||
p95_execution_time_ms: 12.45,
|
||||
p99_execution_time_ms: 24.67,
|
||||
error_rate: 2.76, // percentage
|
||||
);
|
||||
```
|
||||
|
||||
### Most Used Tools
|
||||
|
||||
```php
|
||||
$topTools = $analytics->mostUsedTools([
|
||||
'workspace_id' => $workspace->id,
|
||||
'limit' => 10,
|
||||
'start_date' => now()->subDays(7),
|
||||
]);
|
||||
|
||||
// Returns array:
|
||||
[
|
||||
['tool_name' => 'query_database', 'count' => 500],
|
||||
['tool_name' => 'list_workspaces', 'count' => 120],
|
||||
['tool_name' => 'get_billing_status', 'count' => 45],
|
||||
]
|
||||
```
|
||||
|
||||
### Error Analysis
|
||||
|
||||
```php
|
||||
// Get failed executions
|
||||
$errors = $analytics->getErrors([
|
||||
'workspace_id' => $workspace->id,
|
||||
'tool_name' => 'query_database',
|
||||
'start_date' => now()->subDays(7),
|
||||
]);
|
||||
|
||||
foreach ($errors as $error) {
|
||||
echo "Error: {$error->error_message}\n";
|
||||
echo "Occurred: {$error->created_at->diffForHumans()}\n";
|
||||
echo "User: {$error->user->name}\n";
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Trends
|
||||
|
||||
```php
|
||||
// Get daily execution counts
|
||||
$trend = $analytics->dailyTrend([
|
||||
'tool_name' => 'query_database',
|
||||
'workspace_id' => $workspace->id,
|
||||
'days' => 30,
|
||||
]);
|
||||
|
||||
// Returns:
|
||||
[
|
||||
'2026-01-01' => 45,
|
||||
'2026-01-02' => 52,
|
||||
'2026-01-03' => 48,
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
## Admin Dashboard
|
||||
|
||||
View analytics in admin panel:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Core\Mcp\View\Modal\Admin;
|
||||
|
||||
use Livewire\Component;
|
||||
use Core\Mcp\Services\ToolAnalyticsService;
|
||||
|
||||
class ToolAnalyticsDashboard extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
$analytics = app(ToolAnalyticsService::class);
|
||||
|
||||
return view('mcp::admin.analytics.dashboard', [
|
||||
'totalExecutions' => $analytics->totalExecutions(),
|
||||
'topTools' => $analytics->mostUsedTools(['limit' => 10]),
|
||||
'errorRate' => $analytics->errorRate(),
|
||||
'avgExecutionTime' => $analytics->averageExecutionTime(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**View:**
|
||||
|
||||
```blade
|
||||
<x-admin::card>
|
||||
<x-slot:header>
|
||||
<h3>MCP Tool Analytics</h3>
|
||||
</x-slot:header>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<x-admin::stat
|
||||
label="Total Executions"
|
||||
:value="$totalExecutions"
|
||||
icon="heroicon-o-play-circle"
|
||||
/>
|
||||
|
||||
<x-admin::stat
|
||||
label="Error Rate"
|
||||
:value="number_format($errorRate, 2) . '%'"
|
||||
icon="heroicon-o-exclamation-triangle"
|
||||
:color="$errorRate > 5 ? 'red' : 'green'"
|
||||
/>
|
||||
|
||||
<x-admin::stat
|
||||
label="Avg Execution Time"
|
||||
:value="number_format($avgExecutionTime, 2) . 'ms'"
|
||||
icon="heroicon-o-clock"
|
||||
/>
|
||||
|
||||
<x-admin::stat
|
||||
label="Active Tools"
|
||||
:value="count($topTools)"
|
||||
icon="heroicon-o-cube"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4>Most Used Tools</h4>
|
||||
<x-admin::table>
|
||||
<x-slot:header>
|
||||
<x-admin::table.th>Tool</x-admin::table.th>
|
||||
<x-admin::table.th>Executions</x-admin::table.th>
|
||||
</x-slot:header>
|
||||
|
||||
@foreach($topTools as $tool)
|
||||
<x-admin::table.tr>
|
||||
<x-admin::table.td>{{ $tool['tool_name'] }}</x-admin::table.td>
|
||||
<x-admin::table.td>{{ number_format($tool['count']) }}</x-admin::table.td>
|
||||
</x-admin::table.tr>
|
||||
@endforeach
|
||||
</x-admin::table>
|
||||
</div>
|
||||
</x-admin::card>
|
||||
```
|
||||
|
||||
## Tool Detail View
|
||||
|
||||
Detailed analytics for specific tool:
|
||||
|
||||
```blade
|
||||
<x-admin::card>
|
||||
<x-slot:header>
|
||||
<h3>{{ $toolName }} Analytics</h3>
|
||||
</x-slot:header>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<x-admin::stat
|
||||
label="Total Executions"
|
||||
:value="$stats->total_executions"
|
||||
/>
|
||||
|
||||
<x-admin::stat
|
||||
label="Success Rate"
|
||||
:value="number_format((1 - $stats->error_rate / 100) * 100, 1) . '%'"
|
||||
:color="$stats->error_rate < 5 ? 'green' : 'red'"
|
||||
/>
|
||||
|
||||
<x-admin::stat
|
||||
label="P95 Latency"
|
||||
:value="number_format($stats->p95_execution_time_ms, 2) . 'ms'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4>Performance Trend</h4>
|
||||
<canvas id="performance-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h4>Recent Errors</h4>
|
||||
@foreach($recentErrors as $error)
|
||||
<x-admin::alert type="error">
|
||||
<strong>{{ $error->created_at->diffForHumans() }}</strong>
|
||||
{{ $error->error_message }}
|
||||
</x-admin::alert>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-admin::card>
|
||||
```
|
||||
|
||||
## Pruning Old Metrics
|
||||
|
||||
```bash
|
||||
# Prune metrics older than 90 days
|
||||
php artisan mcp:prune-metrics --days=90
|
||||
|
||||
# Dry run
|
||||
php artisan mcp:prune-metrics --days=90 --dry-run
|
||||
```
|
||||
|
||||
**Scheduled Pruning:**
|
||||
|
||||
```php
|
||||
// app/Console/Kernel.php
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
$schedule->command('mcp:prune-metrics --days=90')
|
||||
->daily()
|
||||
->at('02:00');
|
||||
}
|
||||
```
|
||||
|
||||
## Alerting
|
||||
|
||||
Set up alerts for anomalies:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\ToolAnalyticsService;
|
||||
|
||||
$analytics = app(ToolAnalyticsService::class);
|
||||
|
||||
// Check error rate
|
||||
$errorRate = $analytics->errorRate([
|
||||
'tool_name' => 'query_database',
|
||||
'start_date' => now()->subHours(1),
|
||||
]);
|
||||
|
||||
if ($errorRate > 10) {
|
||||
// Alert: High error rate
|
||||
Notification::route('slack', config('slack.webhook'))
|
||||
->notify(new HighErrorRateNotification('query_database', $errorRate));
|
||||
}
|
||||
|
||||
// Check slow executions
|
||||
$p99 = $analytics->getToolStats('query_database')->p99_execution_time_ms;
|
||||
|
||||
if ($p99 > 1000) {
|
||||
// Alert: Slow performance
|
||||
Notification::route('slack', config('slack.webhook'))
|
||||
->notify(new SlowToolNotification('query_database', $p99));
|
||||
}
|
||||
```
|
||||
|
||||
## Export Analytics
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\ToolAnalyticsService;
|
||||
|
||||
$analytics = app(ToolAnalyticsService::class);
|
||||
|
||||
// Export to CSV
|
||||
$csv = $analytics->exportToCsv([
|
||||
'workspace_id' => $workspace->id,
|
||||
'start_date' => now()->subDays(30),
|
||||
'end_date' => now(),
|
||||
]);
|
||||
|
||||
return response()->streamDownload(function () use ($csv) {
|
||||
echo $csv;
|
||||
}, 'mcp-analytics.csv');
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Set Retention Policies
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
return [
|
||||
'analytics' => [
|
||||
'retention_days' => 90, // Keep 90 days
|
||||
'prune_schedule' => 'daily',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 2. Monitor Error Rates
|
||||
|
||||
```php
|
||||
// ✅ Good - alert on high error rate
|
||||
if ($errorRate > 10) {
|
||||
$this->alert('High error rate');
|
||||
}
|
||||
|
||||
// ❌ Bad - ignore errors
|
||||
// (problems go unnoticed)
|
||||
```
|
||||
|
||||
### 3. Track Performance
|
||||
|
||||
```php
|
||||
// ✅ Good - measure execution time
|
||||
$start = microtime(true);
|
||||
$result = $tool->execute($params);
|
||||
$duration = (microtime(true) - $start) * 1000;
|
||||
|
||||
$analytics->record([
|
||||
'execution_time_ms' => $duration,
|
||||
]);
|
||||
```
|
||||
|
||||
### 4. Use Aggregated Queries
|
||||
|
||||
```php
|
||||
// ✅ Good - use analytics service
|
||||
$stats = $analytics->getToolStats('query_database');
|
||||
|
||||
// ❌ Bad - query metrics table directly
|
||||
$count = ToolMetric::where('tool_name', 'query_database')->count();
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```php
|
||||
use Tests\TestCase;
|
||||
use Core\Mcp\Services\ToolAnalyticsService;
|
||||
|
||||
class AnalyticsTest extends TestCase
|
||||
{
|
||||
public function test_records_tool_execution(): void
|
||||
{
|
||||
$analytics = app(ToolAnalyticsService::class);
|
||||
|
||||
$analytics->record([
|
||||
'tool_name' => 'test_tool',
|
||||
'workspace_id' => 1,
|
||||
'success' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('mcp_tool_metrics', [
|
||||
'tool_name' => 'test_tool',
|
||||
'workspace_id' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_calculates_error_rate(): void
|
||||
{
|
||||
$analytics = app(ToolAnalyticsService::class);
|
||||
|
||||
// Record 100 successful, 10 failed
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$analytics->record(['tool_name' => 'test', 'success' => true]);
|
||||
}
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$analytics->record(['tool_name' => 'test', 'success' => false]);
|
||||
}
|
||||
|
||||
$errorRate = $analytics->errorRate(['tool_name' => 'test']);
|
||||
|
||||
$this->assertEquals(9.09, round($errorRate, 2)); // 10/110 = 9.09%
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Quotas →](/packages/mcp/quotas)
|
||||
- [Creating Tools →](/packages/mcp/tools)
|
||||
787
docs/creating-mcp-tools.md
Normal file
787
docs/creating-mcp-tools.md
Normal file
|
|
@ -0,0 +1,787 @@
|
|||
# Guide: Creating MCP Tools
|
||||
|
||||
This guide covers everything you need to create MCP tools for AI agents, from basic tools to advanced patterns with workspace context, dependencies, and security best practices.
|
||||
|
||||
## Overview
|
||||
|
||||
MCP (Model Context Protocol) tools allow AI agents to interact with your application. Each tool:
|
||||
|
||||
- Has a unique name and description
|
||||
- Defines input parameters with JSON Schema
|
||||
- Executes logic and returns structured responses
|
||||
- Can require workspace context for multi-tenant isolation
|
||||
- Can declare dependencies on other tools
|
||||
|
||||
## Tool Interface
|
||||
|
||||
All MCP tools extend `Laravel\Mcp\Server\Tool` and implement two required methods:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
class ListPostsTool extends Tool
|
||||
{
|
||||
protected string $description = 'List all blog posts with optional filters';
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Tool logic here
|
||||
$posts = Post::limit(10)->get();
|
||||
|
||||
return Response::text(json_encode($posts->toArray(), JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'status' => $schema->string('Filter by post status'),
|
||||
'limit' => $schema->integer('Maximum posts to return')->default(10),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Methods
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `$description` | Tool description shown to AI agents |
|
||||
| `handle(Request)` | Execute the tool and return a Response |
|
||||
| `schema(JsonSchema)` | Define input parameters |
|
||||
|
||||
## Parameter Validation
|
||||
|
||||
Define parameters using the `JsonSchema` builder in the `schema()` method:
|
||||
|
||||
### String Parameters
|
||||
|
||||
```php
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
// Basic string
|
||||
'title' => $schema->string('Post title')->required(),
|
||||
|
||||
// Enum values
|
||||
'status' => $schema->string('Post status: draft, published, archived'),
|
||||
|
||||
// With default
|
||||
'format' => $schema->string('Output format')->default('json'),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Numeric Parameters
|
||||
|
||||
```php
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
// Integer
|
||||
'limit' => $schema->integer('Maximum results')->default(10),
|
||||
|
||||
// Number (float)
|
||||
'price' => $schema->number('Product price'),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Boolean Parameters
|
||||
|
||||
```php
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'include_drafts' => $schema->boolean('Include draft posts')->default(false),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Array Parameters
|
||||
|
||||
```php
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'tags' => $schema->array('Filter by tags'),
|
||||
'ids' => $schema->array('Specific post IDs to fetch'),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Required vs Optional
|
||||
|
||||
```php
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
// Required - AI agent must provide this
|
||||
'query' => $schema->string('SQL query to execute')->required(),
|
||||
|
||||
// Optional with default
|
||||
'limit' => $schema->integer('Max rows')->default(100),
|
||||
|
||||
// Optional without default
|
||||
'status' => $schema->string('Filter status'),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing Parameters
|
||||
|
||||
```php
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Get single parameter
|
||||
$query = $request->input('query');
|
||||
|
||||
// Get with default
|
||||
$limit = $request->input('limit', 10);
|
||||
|
||||
// Check if parameter exists
|
||||
if ($request->has('status')) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Get all parameters
|
||||
$params = $request->all();
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Validation
|
||||
|
||||
For validation beyond schema types, validate in `handle()`:
|
||||
|
||||
```php
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$email = $request->input('email');
|
||||
|
||||
// Custom validation
|
||||
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return Response::text(json_encode([
|
||||
'error' => 'Invalid email format',
|
||||
'code' => 'VALIDATION_ERROR',
|
||||
]));
|
||||
}
|
||||
|
||||
// Validate limit range
|
||||
$limit = $request->input('limit', 10);
|
||||
if ($limit < 1 || $limit > 100) {
|
||||
return Response::text(json_encode([
|
||||
'error' => 'Limit must be between 1 and 100',
|
||||
'code' => 'VALIDATION_ERROR',
|
||||
]));
|
||||
}
|
||||
|
||||
// Continue with tool logic...
|
||||
}
|
||||
```
|
||||
|
||||
## Workspace Context
|
||||
|
||||
For multi-tenant applications, tools must access data scoped to the authenticated workspace. **Never accept workspace ID as a user-supplied parameter** - this prevents cross-tenant data access.
|
||||
|
||||
### Using RequiresWorkspaceContext
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||
|
||||
class ListWorkspacePostsTool extends Tool
|
||||
{
|
||||
use RequiresWorkspaceContext;
|
||||
|
||||
protected string $description = 'List posts in your workspace';
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Get workspace from authenticated context (NOT from request params)
|
||||
$workspace = $this->getWorkspace();
|
||||
$workspaceId = $this->getWorkspaceId();
|
||||
|
||||
$posts = Post::where('workspace_id', $workspaceId)
|
||||
->limit($request->input('limit', 10))
|
||||
->get();
|
||||
|
||||
return Response::text(json_encode([
|
||||
'workspace' => $workspace->name,
|
||||
'posts' => $posts->toArray(),
|
||||
], JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
// Note: No workspace_id parameter - comes from auth context
|
||||
return [
|
||||
'limit' => $schema->integer('Maximum posts to return'),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Trait Methods
|
||||
|
||||
The `RequiresWorkspaceContext` trait provides:
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `getWorkspaceContext()` | `WorkspaceContext` | Full context object |
|
||||
| `getWorkspaceId()` | `int` | Workspace ID only |
|
||||
| `getWorkspace()` | `Workspace` | Workspace model |
|
||||
| `hasWorkspaceContext()` | `bool` | Check if context available |
|
||||
| `validateResourceOwnership(int, string)` | `void` | Validate resource belongs to workspace |
|
||||
|
||||
### Setting Workspace Context
|
||||
|
||||
Workspace context is set by middleware from authentication (API key or user session):
|
||||
|
||||
```php
|
||||
// In middleware or controller
|
||||
$tool = new ListWorkspacePostsTool();
|
||||
$tool->setWorkspaceContext(WorkspaceContext::fromWorkspace($workspace));
|
||||
|
||||
// Or from ID
|
||||
$tool->setWorkspaceId($workspaceId);
|
||||
|
||||
// Or from workspace model
|
||||
$tool->setWorkspace($workspace);
|
||||
```
|
||||
|
||||
### Validating Resource Ownership
|
||||
|
||||
When accessing specific resources, validate they belong to the workspace:
|
||||
|
||||
```php
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$postId = $request->input('post_id');
|
||||
$post = Post::findOrFail($postId);
|
||||
|
||||
// Throws RuntimeException if post doesn't belong to workspace
|
||||
$this->validateResourceOwnership($post->workspace_id, 'post');
|
||||
|
||||
// Safe to proceed
|
||||
return Response::text(json_encode($post->toArray()));
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Dependencies
|
||||
|
||||
Tools can declare dependencies that must be satisfied before execution. This is useful for workflows where tools must be called in a specific order.
|
||||
|
||||
### Declaring Dependencies
|
||||
|
||||
Implement `HasDependencies` or use `ValidatesDependencies` trait:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Core\Mod\Mcp\Dependencies\DependencyType;
|
||||
use Core\Mod\Mcp\Dependencies\HasDependencies;
|
||||
use Core\Mod\Mcp\Dependencies\ToolDependency;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
class UpdateTaskTool extends Tool implements HasDependencies
|
||||
{
|
||||
protected string $description = 'Update a task in the current plan';
|
||||
|
||||
public function dependencies(): array
|
||||
{
|
||||
return [
|
||||
// Another tool must be called first
|
||||
ToolDependency::toolCalled(
|
||||
'plan_create',
|
||||
'A plan must be created before updating tasks'
|
||||
),
|
||||
|
||||
// Session state must exist
|
||||
ToolDependency::sessionState(
|
||||
'active_plan_id',
|
||||
'An active plan must be selected'
|
||||
),
|
||||
|
||||
// Context value required
|
||||
ToolDependency::contextExists(
|
||||
'workspace_id',
|
||||
'Workspace context is required'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Dependencies are validated before handle() is called
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Types
|
||||
|
||||
| Type | Use Case |
|
||||
|------|----------|
|
||||
| `TOOL_CALLED` | Another tool must be executed in session |
|
||||
| `SESSION_STATE` | A session variable must exist |
|
||||
| `CONTEXT_EXISTS` | A context value must be present |
|
||||
| `ENTITY_EXISTS` | A database entity must exist |
|
||||
| `CUSTOM` | Custom validation logic |
|
||||
|
||||
### Creating Dependencies
|
||||
|
||||
```php
|
||||
// Tool must be called first
|
||||
ToolDependency::toolCalled('list_tables');
|
||||
|
||||
// Session state required
|
||||
ToolDependency::sessionState('selected_table');
|
||||
|
||||
// Context value required
|
||||
ToolDependency::contextExists('workspace_id');
|
||||
|
||||
// Entity must exist
|
||||
ToolDependency::entityExists('Plan', 'A plan must exist', [
|
||||
'id_param' => 'plan_id',
|
||||
]);
|
||||
|
||||
// Custom validation
|
||||
ToolDependency::custom('billing_active', 'Billing must be active');
|
||||
```
|
||||
|
||||
### Optional Dependencies
|
||||
|
||||
Mark dependencies as optional (warns but doesn't block):
|
||||
|
||||
```php
|
||||
public function dependencies(): array
|
||||
{
|
||||
return [
|
||||
ToolDependency::toolCalled('cache_warm')
|
||||
->asOptional(), // Soft dependency
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Dependency Validation
|
||||
|
||||
Use the `ValidatesDependencies` trait for inline validation:
|
||||
|
||||
```php
|
||||
use Core\Mod\Mcp\Tools\Concerns\ValidatesDependencies;
|
||||
|
||||
class MyTool extends Tool
|
||||
{
|
||||
use ValidatesDependencies;
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$context = ['session_id' => $request->input('session_id')];
|
||||
|
||||
// Throws if dependencies not met
|
||||
$this->validateDependencies($context);
|
||||
|
||||
// Or check without throwing
|
||||
if (!$this->dependenciesMet($context)) {
|
||||
$missing = $this->getMissingDependencies($context);
|
||||
return Response::text(json_encode([
|
||||
'error' => 'Dependencies not met',
|
||||
'missing' => array_map(fn($d) => $d->key, $missing),
|
||||
]));
|
||||
}
|
||||
|
||||
// Continue...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Registering Tools
|
||||
|
||||
Register tools via the `McpToolsRegistering` event in your module:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog;
|
||||
|
||||
use Core\Events\McpToolsRegistering;
|
||||
use Mod\Blog\Tools\CreatePostTool;
|
||||
use Mod\Blog\Tools\ListPostsTool;
|
||||
|
||||
class Boot
|
||||
{
|
||||
public static array $listens = [
|
||||
McpToolsRegistering::class => 'onMcpTools',
|
||||
];
|
||||
|
||||
public function onMcpTools(McpToolsRegistering $event): void
|
||||
{
|
||||
$event->tool('blog:list-posts', ListPostsTool::class);
|
||||
$event->tool('blog:create-post', CreatePostTool::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Naming Conventions
|
||||
|
||||
Use consistent naming:
|
||||
|
||||
```php
|
||||
// Pattern: module:action-resource
|
||||
'blog:list-posts' // List resources
|
||||
'blog:get-post' // Get single resource
|
||||
'blog:create-post' // Create resource
|
||||
'blog:update-post' // Update resource
|
||||
'blog:delete-post' // Delete resource
|
||||
|
||||
// Sub-modules
|
||||
'commerce:billing:get-status'
|
||||
'commerce:coupon:create'
|
||||
```
|
||||
|
||||
## Response Formats
|
||||
|
||||
### Success Response
|
||||
|
||||
```php
|
||||
return Response::text(json_encode([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
], JSON_PRETTY_PRINT));
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```php
|
||||
return Response::text(json_encode([
|
||||
'error' => 'Specific error message',
|
||||
'code' => 'ERROR_CODE',
|
||||
]));
|
||||
```
|
||||
|
||||
### Paginated Response
|
||||
|
||||
```php
|
||||
$posts = Post::paginate($perPage);
|
||||
|
||||
return Response::text(json_encode([
|
||||
'data' => $posts->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $posts->currentPage(),
|
||||
'last_page' => $posts->lastPage(),
|
||||
'per_page' => $posts->perPage(),
|
||||
'total' => $posts->total(),
|
||||
],
|
||||
], JSON_PRETTY_PRINT));
|
||||
```
|
||||
|
||||
### List Response
|
||||
|
||||
```php
|
||||
return Response::text(json_encode([
|
||||
'count' => $items->count(),
|
||||
'items' => $items->map(fn($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
])->all(),
|
||||
], JSON_PRETTY_PRINT));
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. Never Trust User-Supplied IDs for Authorization
|
||||
|
||||
```php
|
||||
// BAD: Using workspace_id from request
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$workspaceId = $request->input('workspace_id'); // Attacker can change this!
|
||||
$posts = Post::where('workspace_id', $workspaceId)->get();
|
||||
}
|
||||
|
||||
// GOOD: Using authenticated workspace context
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$workspaceId = $this->getWorkspaceId(); // From auth context
|
||||
$posts = Post::where('workspace_id', $workspaceId)->get();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Validate Resource Ownership
|
||||
|
||||
```php
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$postId = $request->input('post_id');
|
||||
$post = Post::findOrFail($postId);
|
||||
|
||||
// Always validate ownership before access
|
||||
$this->validateResourceOwnership($post->workspace_id, 'post');
|
||||
|
||||
return Response::text(json_encode($post->toArray()));
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Sanitize and Limit Input
|
||||
|
||||
```php
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Limit result sets
|
||||
$limit = min($request->input('limit', 10), 100);
|
||||
|
||||
// Sanitize string input
|
||||
$search = strip_tags($request->input('search', ''));
|
||||
$search = substr($search, 0, 255);
|
||||
|
||||
// Validate enum values
|
||||
$status = $request->input('status');
|
||||
if ($status && !in_array($status, ['draft', 'published', 'archived'])) {
|
||||
return Response::text(json_encode(['error' => 'Invalid status']));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Log Sensitive Operations
|
||||
|
||||
```php
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
Log::info('MCP tool executed', [
|
||||
'tool' => 'delete-post',
|
||||
'workspace_id' => $this->getWorkspaceId(),
|
||||
'post_id' => $request->input('post_id'),
|
||||
'user' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Perform operation...
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Use Read-Only Database Connections for Queries
|
||||
|
||||
```php
|
||||
// For query tools, use read-only connection
|
||||
$connection = config('mcp.database.connection', 'readonly');
|
||||
$results = DB::connection($connection)->select($query);
|
||||
```
|
||||
|
||||
### 6. Sanitize Error Messages
|
||||
|
||||
```php
|
||||
try {
|
||||
// Operation...
|
||||
} catch (\Exception $e) {
|
||||
// Log full error for debugging
|
||||
report($e);
|
||||
|
||||
// Return sanitized message to client
|
||||
return Response::text(json_encode([
|
||||
'error' => 'Operation failed. Please try again.',
|
||||
'code' => 'OPERATION_FAILED',
|
||||
]));
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Implement Rate Limiting
|
||||
|
||||
Tools should respect quota limits:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\McpQuotaService;
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$quota = app(McpQuotaService::class);
|
||||
$workspace = $this->getWorkspace();
|
||||
|
||||
if (!$quota->canExecute($workspace, $this->name())) {
|
||||
return Response::text(json_encode([
|
||||
'error' => 'Rate limit exceeded',
|
||||
'code' => 'QUOTA_EXCEEDED',
|
||||
]));
|
||||
}
|
||||
|
||||
// Execute tool...
|
||||
|
||||
$quota->recordExecution($workspace, $this->name());
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Tools
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Mod\Blog\Tools\ListPostsTool;
|
||||
use Mod\Blog\Models\Post;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Mod\Mcp\Context\WorkspaceContext;
|
||||
|
||||
class ListPostsToolTest extends TestCase
|
||||
{
|
||||
public function test_lists_posts(): void
|
||||
{
|
||||
$workspace = Workspace::factory()->create();
|
||||
Post::factory()->count(5)->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
]);
|
||||
|
||||
$tool = new ListPostsTool();
|
||||
$tool->setWorkspaceContext(
|
||||
WorkspaceContext::fromWorkspace($workspace)
|
||||
);
|
||||
|
||||
$request = new \Laravel\Mcp\Request([
|
||||
'limit' => 10,
|
||||
]);
|
||||
|
||||
$response = $tool->handle($request);
|
||||
$data = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertCount(5, $data['posts']);
|
||||
}
|
||||
|
||||
public function test_respects_workspace_isolation(): void
|
||||
{
|
||||
$workspace1 = Workspace::factory()->create();
|
||||
$workspace2 = Workspace::factory()->create();
|
||||
|
||||
Post::factory()->count(3)->create(['workspace_id' => $workspace1->id]);
|
||||
Post::factory()->count(2)->create(['workspace_id' => $workspace2->id]);
|
||||
|
||||
$tool = new ListPostsTool();
|
||||
$tool->setWorkspace($workspace1);
|
||||
|
||||
$request = new \Laravel\Mcp\Request([]);
|
||||
$response = $tool->handle($request);
|
||||
$data = json_decode($response->getContent(), true);
|
||||
|
||||
// Should only see workspace1's posts
|
||||
$this->assertCount(3, $data['posts']);
|
||||
}
|
||||
|
||||
public function test_throws_without_workspace_context(): void
|
||||
{
|
||||
$this->expectException(MissingWorkspaceContextException::class);
|
||||
|
||||
$tool = new ListPostsTool();
|
||||
// Not setting workspace context
|
||||
|
||||
$tool->handle(new \Laravel\Mcp\Request([]));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete tool implementation following all best practices:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Commerce\Tools;
|
||||
|
||||
use Core\Mod\Commerce\Models\Invoice;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||
|
||||
/**
|
||||
* List invoices for the authenticated workspace.
|
||||
*
|
||||
* SECURITY: Uses authenticated workspace context to prevent cross-tenant access.
|
||||
*/
|
||||
class ListInvoicesTool extends Tool
|
||||
{
|
||||
use RequiresWorkspaceContext;
|
||||
|
||||
protected string $description = 'List invoices for your workspace with optional status filter';
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Get workspace from auth context (never from request params)
|
||||
$workspaceId = $this->getWorkspaceId();
|
||||
|
||||
// Validate and sanitize inputs
|
||||
$status = $request->input('status');
|
||||
if ($status && !in_array($status, ['paid', 'pending', 'overdue', 'void'])) {
|
||||
return Response::text(json_encode([
|
||||
'error' => 'Invalid status. Use: paid, pending, overdue, void',
|
||||
'code' => 'VALIDATION_ERROR',
|
||||
]));
|
||||
}
|
||||
|
||||
$limit = min($request->input('limit', 10), 50);
|
||||
|
||||
// Query with workspace scope
|
||||
$query = Invoice::with('order')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->latest();
|
||||
|
||||
if ($status) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$invoices = $query->limit($limit)->get();
|
||||
|
||||
return Response::text(json_encode([
|
||||
'workspace_id' => $workspaceId,
|
||||
'count' => $invoices->count(),
|
||||
'invoices' => $invoices->map(fn ($invoice) => [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'status' => $invoice->status,
|
||||
'total' => (float) $invoice->total,
|
||||
'currency' => $invoice->currency,
|
||||
'issue_date' => $invoice->issue_date?->toDateString(),
|
||||
'due_date' => $invoice->due_date?->toDateString(),
|
||||
'is_overdue' => $invoice->isOverdue(),
|
||||
])->all(),
|
||||
], JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'status' => $schema->string('Filter by status: paid, pending, overdue, void'),
|
||||
'limit' => $schema->integer('Maximum invoices to return (default 10, max 50)'),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [SQL Security](/packages/mcp/sql-security) - Safe query patterns
|
||||
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
|
||||
- [Tool Analytics](/packages/mcp/analytics) - Usage tracking
|
||||
- [Quotas](/packages/mcp/quotas) - Rate limiting
|
||||
436
docs/index.md
Normal file
436
docs/index.md
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
# MCP Package
|
||||
|
||||
The MCP (Model Context Protocol) package provides AI agent tools for database queries, commerce operations, and workspace management with built-in security and quota enforcement.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require host-uk/core-mcp
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog;
|
||||
|
||||
use Core\Events\McpToolsRegistering;
|
||||
|
||||
class Boot
|
||||
{
|
||||
public static array $listens = [
|
||||
McpToolsRegistering::class => 'onMcpTools',
|
||||
];
|
||||
|
||||
public function onMcpTools(McpToolsRegistering $event): void
|
||||
{
|
||||
$event->tool('blog:create-post', Tools\CreatePostTool::class);
|
||||
$event->tool('blog:list-posts', Tools\ListPostsTool::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### Database Tools
|
||||
|
||||
- **[Query Database](/packages/mcp/query-database)** - SQL query execution with validation and security
|
||||
- **[SQL Validation](/packages/mcp/security#sql-validation)** - Prevent destructive queries and SQL injection
|
||||
- **[EXPLAIN Plans](/packages/mcp/query-database#explain)** - Query optimization analysis
|
||||
|
||||
### Commerce Tools
|
||||
|
||||
- **[Get Billing Status](/packages/mcp/commerce#billing)** - Current billing and subscription status
|
||||
- **[List Invoices](/packages/mcp/commerce#invoices)** - Invoice history and details
|
||||
- **[Upgrade Plan](/packages/mcp/commerce#upgrades)** - Tier upgrades with entitlement validation
|
||||
|
||||
### Workspace Tools
|
||||
|
||||
- **[Workspace Context](/packages/mcp/workspace)** - Automatic workspace/namespace resolution
|
||||
- **[Quota Enforcement](/packages/mcp/quotas)** - Tool usage limits and monitoring
|
||||
- **[Tool Analytics](/packages/mcp/analytics)** - Usage tracking and statistics
|
||||
|
||||
### Developer Tools
|
||||
|
||||
- **[Tool Discovery](/packages/mcp/tools#discovery)** - Automatic tool registration
|
||||
- **[Dependency Management](/packages/mcp/tools#dependencies)** - Tool dependency resolution
|
||||
- **[Error Handling](/packages/mcp/tools#errors)** - Consistent error responses
|
||||
|
||||
## Creating Tools
|
||||
|
||||
### Basic Tool
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Core\Mcp\Tools\BaseTool;
|
||||
|
||||
class ListPostsTool extends BaseTool
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'blog:list-posts';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'List all blog posts with optional filters';
|
||||
}
|
||||
|
||||
public function getParameters(): array
|
||||
{
|
||||
return [
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Filter by status',
|
||||
'enum' => ['published', 'draft'],
|
||||
'required' => false,
|
||||
],
|
||||
'limit' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Number of posts to return',
|
||||
'default' => 10,
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $params): array
|
||||
{
|
||||
$query = Post::query();
|
||||
|
||||
if (isset($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
$posts = $query->limit($params['limit'] ?? 10)->get();
|
||||
|
||||
return [
|
||||
'posts' => $posts->map(fn ($post) => [
|
||||
'id' => $post->id,
|
||||
'title' => $post->title,
|
||||
'slug' => $post->slug,
|
||||
'status' => $post->status,
|
||||
])->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool with Workspace Context
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Core\Mcp\Tools\BaseTool;
|
||||
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||
|
||||
class CreatePostTool extends BaseTool
|
||||
{
|
||||
use RequiresWorkspaceContext;
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'blog:create-post';
|
||||
}
|
||||
|
||||
public function execute(array $params): array
|
||||
{
|
||||
// Workspace context automatically validated
|
||||
$workspace = $this->getWorkspaceContext();
|
||||
|
||||
$post = Post::create([
|
||||
'title' => $params['title'],
|
||||
'content' => $params['content'],
|
||||
'workspace_id' => $workspace->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'post_id' => $post->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool with Dependencies
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Core\Mcp\Tools\BaseTool;
|
||||
use Core\Mcp\Dependencies\HasDependencies;
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
|
||||
class ImportPostsTool extends BaseTool
|
||||
{
|
||||
use HasDependencies;
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [
|
||||
new ToolDependency('blog:list-posts', DependencyType::REQUIRED),
|
||||
new ToolDependency('media:upload', DependencyType::OPTIONAL),
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $params): array
|
||||
{
|
||||
// Dependencies automatically validated
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query Database Tool
|
||||
|
||||
Execute SQL queries with built-in security:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Tools\QueryDatabase;
|
||||
|
||||
$tool = new QueryDatabase();
|
||||
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE status = ?',
|
||||
'bindings' => ['published'],
|
||||
'connection' => 'mysql',
|
||||
]);
|
||||
|
||||
// Returns:
|
||||
// [
|
||||
// 'rows' => [...],
|
||||
// 'count' => 10,
|
||||
// 'execution_time_ms' => 5.23
|
||||
// ]
|
||||
```
|
||||
|
||||
### Security Features
|
||||
|
||||
- **Whitelist-based validation** - Only SELECT queries allowed by default
|
||||
- **No destructive operations** - DROP, TRUNCATE, DELETE blocked
|
||||
- **Binding enforcement** - Prevents SQL injection
|
||||
- **Connection validation** - Only allowed connections accessible
|
||||
- **EXPLAIN analysis** - Query optimization insights
|
||||
|
||||
[Learn more about SQL Security →](/packages/mcp/security)
|
||||
|
||||
## Quota System
|
||||
|
||||
Enforce tool usage limits per workspace:
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
'quotas' => [
|
||||
'enabled' => true,
|
||||
'limits' => [
|
||||
'free' => ['calls' => 100, 'per' => 'day'],
|
||||
'pro' => ['calls' => 1000, 'per' => 'day'],
|
||||
'business' => ['calls' => 10000, 'per' => 'day'],
|
||||
'enterprise' => ['calls' => null], // Unlimited
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
Check quota before execution:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\McpQuotaService;
|
||||
|
||||
$quotaService = app(McpQuotaService::class);
|
||||
|
||||
if (!$quotaService->canExecute($workspace, 'blog:create-post')) {
|
||||
throw new QuotaExceededException('Daily tool quota exceeded');
|
||||
}
|
||||
|
||||
$quotaService->recordExecution($workspace, 'blog:create-post');
|
||||
```
|
||||
|
||||
[Learn more about Quotas →](/packages/mcp/quotas)
|
||||
|
||||
## Tool Analytics
|
||||
|
||||
Track tool usage and performance:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\ToolAnalyticsService;
|
||||
|
||||
$analytics = app(ToolAnalyticsService::class);
|
||||
|
||||
// Get tool stats
|
||||
$stats = $analytics->getToolStats('blog:create-post', period: 'week');
|
||||
// Returns: ToolStats with executions, errors, avg_duration_ms
|
||||
|
||||
// Get workspace usage
|
||||
$usage = $analytics->getWorkspaceUsage($workspace, period: 'month');
|
||||
|
||||
// Get most used tools
|
||||
$topTools = $analytics->getTopTools(limit: 10, period: 'week');
|
||||
```
|
||||
|
||||
[Learn more about Analytics →](/packages/mcp/analytics)
|
||||
|
||||
## Configuration
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
return [
|
||||
'enabled' => true,
|
||||
|
||||
'tools' => [
|
||||
'auto_discover' => true,
|
||||
'cache_enabled' => true,
|
||||
],
|
||||
|
||||
'query_database' => [
|
||||
'allowed_connections' => ['mysql', 'pgsql'],
|
||||
'forbidden_keywords' => [
|
||||
'DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'INSERT',
|
||||
'ALTER', 'CREATE', 'GRANT', 'REVOKE',
|
||||
],
|
||||
'max_execution_time' => 5000, // ms
|
||||
'enable_explain' => true,
|
||||
],
|
||||
|
||||
'quotas' => [
|
||||
'enabled' => true,
|
||||
'limits' => [
|
||||
'free' => ['calls' => 100, 'per' => 'day'],
|
||||
'pro' => ['calls' => 1000, 'per' => 'day'],
|
||||
'business' => ['calls' => 10000, 'per' => 'day'],
|
||||
'enterprise' => ['calls' => null],
|
||||
],
|
||||
],
|
||||
|
||||
'analytics' => [
|
||||
'enabled' => true,
|
||||
'retention_days' => 90,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
```php
|
||||
use Core\Mcp\Middleware\ValidateWorkspaceContext;
|
||||
use Core\Mcp\Middleware\CheckMcpQuota;
|
||||
use Core\Mcp\Middleware\ValidateToolDependencies;
|
||||
|
||||
Route::middleware([
|
||||
ValidateWorkspaceContext::class,
|
||||
CheckMcpQuota::class,
|
||||
ValidateToolDependencies::class,
|
||||
])->group(function () {
|
||||
// MCP tool routes
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Workspace Context
|
||||
|
||||
```php
|
||||
// ✅ Good - workspace aware
|
||||
class CreatePostTool extends BaseTool
|
||||
{
|
||||
use RequiresWorkspaceContext;
|
||||
}
|
||||
|
||||
// ❌ Bad - no workspace context
|
||||
class CreatePostTool extends BaseTool
|
||||
{
|
||||
public function execute(array $params): array
|
||||
{
|
||||
$post = Post::create($params); // No workspace_id!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Validate Parameters
|
||||
|
||||
```php
|
||||
// ✅ Good - strict validation
|
||||
public function getParameters(): array
|
||||
{
|
||||
return [
|
||||
'title' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'maxLength' => 255,
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handle Errors Gracefully
|
||||
|
||||
```php
|
||||
// ✅ Good - clear error messages
|
||||
public function execute(array $params): array
|
||||
{
|
||||
try {
|
||||
return ['success' => true, 'data' => $result];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'code' => 'TOOL_EXECUTION_FAILED',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Document Tools Well
|
||||
|
||||
```php
|
||||
// ✅ Good - comprehensive description
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create a new blog post with title, content, and optional metadata. '
|
||||
. 'Requires workspace context. Validates entitlements before creation.';
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Mod\Blog\Tools\ListPostsTool;
|
||||
|
||||
class ListPostsToolTest extends TestCase
|
||||
{
|
||||
public function test_lists_posts(): void
|
||||
{
|
||||
Post::factory()->count(5)->create(['status' => 'published']);
|
||||
|
||||
$tool = new ListPostsTool();
|
||||
|
||||
$result = $tool->execute([
|
||||
'status' => 'published',
|
||||
'limit' => 10,
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('posts', $result);
|
||||
$this->assertCount(5, $result['posts']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Query Database →](/packages/mcp/query-database)
|
||||
- [SQL Security →](/packages/mcp/security)
|
||||
- [Workspace Context →](/packages/mcp/workspace)
|
||||
- [Tool Analytics →](/packages/mcp/analytics)
|
||||
- [Quota System →](/packages/mcp/quotas)
|
||||
452
docs/query-database.md
Normal file
452
docs/query-database.md
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
# Query Database Tool
|
||||
|
||||
The MCP package provides a secure SQL query execution tool with validation, connection management, and EXPLAIN plan analysis.
|
||||
|
||||
## Overview
|
||||
|
||||
The Query Database tool allows AI agents to:
|
||||
- Execute SELECT queries safely
|
||||
- Analyze query performance
|
||||
- Access multiple database connections
|
||||
- Prevent destructive operations
|
||||
- Enforce workspace context
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```php
|
||||
use Core\Mcp\Tools\QueryDatabase;
|
||||
|
||||
$tool = new QueryDatabase();
|
||||
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE status = ?',
|
||||
'bindings' => ['published'],
|
||||
'connection' => 'mysql',
|
||||
]);
|
||||
|
||||
// Returns:
|
||||
// [
|
||||
// 'rows' => [...],
|
||||
// 'count' => 10,
|
||||
// 'execution_time_ms' => 5.23
|
||||
// ]
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `query` | string | Yes | SQL SELECT query |
|
||||
| `bindings` | array | No | Query parameters (prevents SQL injection) |
|
||||
| `connection` | string | No | Database connection name (default: default) |
|
||||
| `explain` | bool | No | Include EXPLAIN plan analysis |
|
||||
|
||||
## Security Validation
|
||||
|
||||
### Allowed Operations
|
||||
|
||||
✅ Only SELECT queries are allowed:
|
||||
|
||||
```php
|
||||
// ✅ Allowed
|
||||
'SELECT * FROM posts'
|
||||
'SELECT id, title FROM posts WHERE status = ?'
|
||||
'SELECT COUNT(*) FROM users'
|
||||
|
||||
// ❌ Blocked
|
||||
'DELETE FROM posts'
|
||||
'UPDATE posts SET status = ?'
|
||||
'DROP TABLE posts'
|
||||
'TRUNCATE posts'
|
||||
```
|
||||
|
||||
### Forbidden Keywords
|
||||
|
||||
The following are automatically blocked:
|
||||
- `DROP`
|
||||
- `TRUNCATE`
|
||||
- `DELETE`
|
||||
- `UPDATE`
|
||||
- `INSERT`
|
||||
- `ALTER`
|
||||
- `CREATE`
|
||||
- `GRANT`
|
||||
- `REVOKE`
|
||||
|
||||
### Required WHERE Clauses
|
||||
|
||||
Queries on large tables must include WHERE clauses:
|
||||
|
||||
```php
|
||||
// ✅ Good - has WHERE clause
|
||||
'SELECT * FROM posts WHERE user_id = ?'
|
||||
|
||||
// ⚠️ Warning - no WHERE clause
|
||||
'SELECT * FROM posts'
|
||||
// Returns warning if table has > 10,000 rows
|
||||
```
|
||||
|
||||
### Connection Validation
|
||||
|
||||
Only whitelisted connections are accessible:
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
'query_database' => [
|
||||
'allowed_connections' => ['mysql', 'pgsql', 'analytics'],
|
||||
],
|
||||
```
|
||||
|
||||
## EXPLAIN Plan Analysis
|
||||
|
||||
Enable query optimization insights:
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE status = ?',
|
||||
'bindings' => ['published'],
|
||||
'explain' => true,
|
||||
]);
|
||||
|
||||
// Returns additional 'explain' key:
|
||||
// [
|
||||
// 'rows' => [...],
|
||||
// 'explain' => [
|
||||
// 'type' => 'ref',
|
||||
// 'key' => 'idx_status',
|
||||
// 'rows_examined' => 150,
|
||||
// 'analysis' => 'Query uses index. Performance: Good',
|
||||
// 'recommendations' => []
|
||||
// ]
|
||||
// ]
|
||||
```
|
||||
|
||||
### Performance Analysis
|
||||
|
||||
The EXPLAIN analyzer provides human-readable insights:
|
||||
|
||||
**Good Performance:**
|
||||
```
|
||||
"Query uses index. Performance: Good"
|
||||
```
|
||||
|
||||
**Index Missing:**
|
||||
```
|
||||
"Warning: Full table scan detected. Consider adding an index on 'status'"
|
||||
```
|
||||
|
||||
**High Row Count:**
|
||||
```
|
||||
"Warning: Query examines 50,000 rows. Consider adding WHERE clause to limit results"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic SELECT
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT id, title, created_at FROM posts LIMIT 10',
|
||||
]);
|
||||
|
||||
foreach ($result['rows'] as $row) {
|
||||
echo "{$row['title']}\n";
|
||||
}
|
||||
```
|
||||
|
||||
### With Parameters
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
|
||||
'bindings' => [42, 'published'],
|
||||
]);
|
||||
```
|
||||
|
||||
### Aggregation
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT status, COUNT(*) as count FROM posts GROUP BY status',
|
||||
]);
|
||||
|
||||
// Returns: [
|
||||
// ['status' => 'draft', 'count' => 15],
|
||||
// ['status' => 'published', 'count' => 42],
|
||||
// ]
|
||||
```
|
||||
|
||||
### Join Query
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => '
|
||||
SELECT posts.title, users.name
|
||||
FROM posts
|
||||
JOIN users ON posts.user_id = users.id
|
||||
WHERE posts.status = ?
|
||||
LIMIT 10
|
||||
',
|
||||
'bindings' => ['published'],
|
||||
]);
|
||||
```
|
||||
|
||||
### Date Filtering
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => '
|
||||
SELECT *
|
||||
FROM posts
|
||||
WHERE created_at >= ?
|
||||
AND created_at < ?
|
||||
ORDER BY created_at DESC
|
||||
',
|
||||
'bindings' => ['2024-01-01', '2024-02-01'],
|
||||
]);
|
||||
```
|
||||
|
||||
## Multiple Connections
|
||||
|
||||
Query different databases:
|
||||
|
||||
```php
|
||||
// Main application database
|
||||
$posts = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts',
|
||||
'connection' => 'mysql',
|
||||
]);
|
||||
|
||||
// Analytics database
|
||||
$stats = $tool->execute([
|
||||
'query' => 'SELECT * FROM page_views',
|
||||
'connection' => 'analytics',
|
||||
]);
|
||||
|
||||
// PostgreSQL database
|
||||
$data = $tool->execute([
|
||||
'query' => 'SELECT * FROM logs',
|
||||
'connection' => 'pgsql',
|
||||
]);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Forbidden Query
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => 'DELETE FROM posts WHERE id = 1',
|
||||
]);
|
||||
|
||||
// Returns:
|
||||
// [
|
||||
// 'success' => false,
|
||||
// 'error' => 'Forbidden query: DELETE operations not allowed',
|
||||
// 'code' => 'FORBIDDEN_QUERY'
|
||||
// ]
|
||||
```
|
||||
|
||||
### Invalid Connection
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts',
|
||||
'connection' => 'unknown',
|
||||
]);
|
||||
|
||||
// Returns:
|
||||
// [
|
||||
// 'success' => false,
|
||||
// 'error' => 'Connection "unknown" not allowed',
|
||||
// 'code' => 'INVALID_CONNECTION'
|
||||
// ]
|
||||
```
|
||||
|
||||
### SQL Error
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM nonexistent_table',
|
||||
]);
|
||||
|
||||
// Returns:
|
||||
// [
|
||||
// 'success' => false,
|
||||
// 'error' => 'Table "nonexistent_table" doesn\'t exist',
|
||||
// 'code' => 'SQL_ERROR'
|
||||
// ]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
'query_database' => [
|
||||
// Allowed database connections
|
||||
'allowed_connections' => [
|
||||
'mysql',
|
||||
'pgsql',
|
||||
'analytics',
|
||||
],
|
||||
|
||||
// Forbidden SQL keywords
|
||||
'forbidden_keywords' => [
|
||||
'DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'INSERT',
|
||||
'ALTER', 'CREATE', 'GRANT', 'REVOKE',
|
||||
],
|
||||
|
||||
// Maximum execution time (milliseconds)
|
||||
'max_execution_time' => 5000,
|
||||
|
||||
// Enable EXPLAIN plan analysis
|
||||
'enable_explain' => true,
|
||||
|
||||
// Warn on queries without WHERE clause for tables larger than:
|
||||
'warn_no_where_threshold' => 10000,
|
||||
],
|
||||
```
|
||||
|
||||
## Workspace Context
|
||||
|
||||
Queries are automatically scoped to the current workspace:
|
||||
|
||||
```php
|
||||
// When workspace context is set
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts',
|
||||
]);
|
||||
|
||||
// Equivalent to:
|
||||
// 'SELECT * FROM posts WHERE workspace_id = ?'
|
||||
// with workspace_id automatically added
|
||||
```
|
||||
|
||||
Disable automatic scoping:
|
||||
|
||||
```php
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM global_settings',
|
||||
'ignore_workspace_scope' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Bindings
|
||||
|
||||
```php
|
||||
// ✅ Good - prevents SQL injection
|
||||
$tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE user_id = ?',
|
||||
'bindings' => [$userId],
|
||||
]);
|
||||
|
||||
// ❌ Bad - vulnerable to SQL injection
|
||||
$tool->execute([
|
||||
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
|
||||
]);
|
||||
```
|
||||
|
||||
### 2. Limit Results
|
||||
|
||||
```php
|
||||
// ✅ Good - limits results
|
||||
'SELECT * FROM posts LIMIT 100'
|
||||
|
||||
// ❌ Bad - could return millions of rows
|
||||
'SELECT * FROM posts'
|
||||
```
|
||||
|
||||
### 3. Use EXPLAIN for Optimization
|
||||
|
||||
```php
|
||||
// ✅ Good - analyze slow queries
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE status = ?',
|
||||
'bindings' => ['published'],
|
||||
'explain' => true,
|
||||
]);
|
||||
|
||||
if (isset($result['explain']['recommendations'])) {
|
||||
foreach ($result['explain']['recommendations'] as $rec) {
|
||||
error_log("Query optimization: {$rec}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Handle Errors Gracefully
|
||||
|
||||
```php
|
||||
// ✅ Good - check for errors
|
||||
$result = $tool->execute([...]);
|
||||
|
||||
if (!($result['success'] ?? true)) {
|
||||
return [
|
||||
'error' => $result['error'],
|
||||
'code' => $result['code'],
|
||||
];
|
||||
}
|
||||
|
||||
return $result['rows'];
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Core\Mcp\Tools\QueryDatabase;
|
||||
|
||||
class QueryDatabaseTest extends TestCase
|
||||
{
|
||||
public function test_executes_select_query(): void
|
||||
{
|
||||
Post::factory()->create(['title' => 'Test Post']);
|
||||
|
||||
$tool = new QueryDatabase();
|
||||
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE title = ?',
|
||||
'bindings' => ['Test Post'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result['success'] ?? true);
|
||||
$this->assertCount(1, $result['rows']);
|
||||
}
|
||||
|
||||
public function test_blocks_delete_query(): void
|
||||
{
|
||||
$tool = new QueryDatabase();
|
||||
|
||||
$result = $tool->execute([
|
||||
'query' => 'DELETE FROM posts WHERE id = 1',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('FORBIDDEN_QUERY', $result['code']);
|
||||
}
|
||||
|
||||
public function test_validates_connection(): void
|
||||
{
|
||||
$tool = new QueryDatabase();
|
||||
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT 1',
|
||||
'connection' => 'invalid',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('INVALID_CONNECTION', $result['code']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [SQL Security →](/packages/mcp/security)
|
||||
- [Workspace Context →](/packages/mcp/workspace)
|
||||
- [Tool Analytics →](/packages/mcp/analytics)
|
||||
405
docs/quotas.md
Normal file
405
docs/quotas.md
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
# Usage Quotas
|
||||
|
||||
Tier-based rate limiting and usage quotas for MCP tools.
|
||||
|
||||
## Overview
|
||||
|
||||
The quota system enforces usage limits based on workspace subscription tiers:
|
||||
|
||||
**Tiers:**
|
||||
- **Free:** 60 requests/hour, 500 queries/day
|
||||
- **Pro:** 600 requests/hour, 10,000 queries/day
|
||||
- **Enterprise:** Unlimited
|
||||
|
||||
## Quota Enforcement
|
||||
|
||||
### Middleware
|
||||
|
||||
```php
|
||||
use Core\Mcp\Middleware\CheckMcpQuota;
|
||||
|
||||
Route::middleware([CheckMcpQuota::class])
|
||||
->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
|
||||
```
|
||||
|
||||
**Process:**
|
||||
1. Identifies workspace from context
|
||||
2. Checks current usage against tier limits
|
||||
3. Allows or denies request
|
||||
4. Records usage on success
|
||||
|
||||
### Manual Checking
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\McpQuotaService;
|
||||
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
// Check if within quota
|
||||
if (!$quota->withinLimit($workspace)) {
|
||||
return response()->json([
|
||||
'error' => 'Quota exceeded',
|
||||
'message' => 'You have reached your hourly limit',
|
||||
'reset_at' => $quota->resetTime($workspace),
|
||||
], 429);
|
||||
}
|
||||
|
||||
// Record usage
|
||||
$quota->recordUsage($workspace, 'query_database');
|
||||
```
|
||||
|
||||
## Quota Configuration
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
return [
|
||||
'quotas' => [
|
||||
'free' => [
|
||||
'requests_per_hour' => 60,
|
||||
'queries_per_day' => 500,
|
||||
'max_query_rows' => 1000,
|
||||
],
|
||||
'pro' => [
|
||||
'requests_per_hour' => 600,
|
||||
'queries_per_day' => 10000,
|
||||
'max_query_rows' => 10000,
|
||||
],
|
||||
'enterprise' => [
|
||||
'requests_per_hour' => null, // Unlimited
|
||||
'queries_per_day' => null,
|
||||
'max_query_rows' => 100000,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Usage Tracking
|
||||
|
||||
### Current Usage
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\McpQuotaService;
|
||||
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
// Get current hour's usage
|
||||
$hourlyUsage = $quota->getHourlyUsage($workspace);
|
||||
|
||||
// Get current day's usage
|
||||
$dailyUsage = $quota->getDailyUsage($workspace);
|
||||
|
||||
// Get usage percentage
|
||||
$percentage = $quota->usagePercentage($workspace);
|
||||
```
|
||||
|
||||
### Usage Response Headers
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 60
|
||||
X-RateLimit-Remaining: 45
|
||||
X-RateLimit-Reset: 1706274000
|
||||
X-RateLimit-Reset-At: 2026-01-26T13:00:00Z
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```php
|
||||
use Core\Mcp\Middleware\CheckMcpQuota;
|
||||
|
||||
class CheckMcpQuota
|
||||
{
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
$workspace = $request->workspace;
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// Add quota headers
|
||||
$response->headers->set('X-RateLimit-Limit', $quota->getLimit($workspace));
|
||||
$response->headers->set('X-RateLimit-Remaining', $quota->getRemaining($workspace));
|
||||
$response->headers->set('X-RateLimit-Reset', $quota->resetTime($workspace)->timestamp);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Quota Exceeded Response
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "quota_exceeded",
|
||||
"message": "You have exceeded your hourly request limit",
|
||||
"current_usage": 60,
|
||||
"limit": 60,
|
||||
"reset_at": "2026-01-26T13:00:00Z",
|
||||
"upgrade_url": "https://example.com/billing/upgrade"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status:** 429 Too Many Requests
|
||||
|
||||
## Upgrading Tiers
|
||||
|
||||
```php
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
$workspace = Workspace::find($id);
|
||||
|
||||
// Upgrade to Pro
|
||||
$workspace->update([
|
||||
'subscription_tier' => 'pro',
|
||||
]);
|
||||
|
||||
// New limits immediately apply
|
||||
$quota = app(McpQuotaService::class);
|
||||
$newLimit = $quota->getLimit($workspace); // 600
|
||||
```
|
||||
|
||||
## Quota Monitoring
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
```php
|
||||
class QuotaUsage extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
$workspaces = Workspace::all()->map(function ($workspace) use ($quota) {
|
||||
return [
|
||||
'name' => $workspace->name,
|
||||
'tier' => $workspace->subscription_tier,
|
||||
'hourly_usage' => $quota->getHourlyUsage($workspace),
|
||||
'hourly_limit' => $quota->getLimit($workspace, 'hourly'),
|
||||
'daily_usage' => $quota->getDailyUsage($workspace),
|
||||
'daily_limit' => $quota->getLimit($workspace, 'daily'),
|
||||
];
|
||||
});
|
||||
|
||||
return view('mcp::admin.quota-usage', compact('workspaces'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**View:**
|
||||
|
||||
```blade
|
||||
<x-admin::table>
|
||||
<x-slot:header>
|
||||
<x-admin::table.th>Workspace</x-admin::table.th>
|
||||
<x-admin::table.th>Tier</x-admin::table.th>
|
||||
<x-admin::table.th>Hourly Usage</x-admin::table.th>
|
||||
<x-admin::table.th>Daily Usage</x-admin::table.th>
|
||||
</x-slot:header>
|
||||
|
||||
@foreach($workspaces as $workspace)
|
||||
<x-admin::table.tr>
|
||||
<x-admin::table.td>{{ $workspace['name'] }}</x-admin::table.td>
|
||||
<x-admin::table.td>
|
||||
<x-admin::badge :color="$workspace['tier'] === 'enterprise' ? 'purple' : 'blue'">
|
||||
{{ ucfirst($workspace['tier']) }}
|
||||
</x-admin::badge>
|
||||
</x-admin::table.td>
|
||||
<x-admin::table.td>
|
||||
{{ $workspace['hourly_usage'] }} / {{ $workspace['hourly_limit'] ?? '∞' }}
|
||||
<progress
|
||||
value="{{ $workspace['hourly_usage'] }}"
|
||||
max="{{ $workspace['hourly_limit'] ?? 100 }}"
|
||||
></progress>
|
||||
</x-admin::table.td>
|
||||
<x-admin::table.td>
|
||||
{{ $workspace['daily_usage'] }} / {{ $workspace['daily_limit'] ?? '∞' }}
|
||||
</x-admin::table.td>
|
||||
</x-admin::table.tr>
|
||||
@endforeach
|
||||
</x-admin::table>
|
||||
```
|
||||
|
||||
### Alerts
|
||||
|
||||
Send notifications when nearing limits:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\McpQuotaService;
|
||||
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
$usage = $quota->usagePercentage($workspace);
|
||||
|
||||
if ($usage >= 80) {
|
||||
// Alert: 80% of quota used
|
||||
$workspace->owner->notify(
|
||||
new QuotaWarningNotification($workspace, $usage)
|
||||
);
|
||||
}
|
||||
|
||||
if ($usage >= 100) {
|
||||
// Alert: Quota exceeded
|
||||
$workspace->owner->notify(
|
||||
new QuotaExceededNotification($workspace)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Quotas
|
||||
|
||||
Override default quotas for specific workspaces:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Models\McpUsageQuota;
|
||||
|
||||
// Set custom quota
|
||||
McpUsageQuota::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'requests_per_hour' => 1000, // Custom limit
|
||||
'queries_per_day' => 50000,
|
||||
'expires_at' => now()->addMonths(3), // Temporary increase
|
||||
]);
|
||||
|
||||
// Custom quota takes precedence over tier defaults
|
||||
```
|
||||
|
||||
## Resetting Quotas
|
||||
|
||||
```bash
|
||||
# Reset all quotas
|
||||
php artisan mcp:reset-quotas
|
||||
|
||||
# Reset specific workspace
|
||||
php artisan mcp:reset-quotas --workspace=123
|
||||
|
||||
# Reset specific period
|
||||
php artisan mcp:reset-quotas --period=hourly
|
||||
```
|
||||
|
||||
## Bypass Quotas (Admin)
|
||||
|
||||
```php
|
||||
// Bypass quota enforcement
|
||||
$result = $tool->execute($params, [
|
||||
'bypass_quota' => true, // Requires admin permission
|
||||
]);
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Internal tools
|
||||
- Admin operations
|
||||
- System maintenance
|
||||
- Testing
|
||||
|
||||
## Testing
|
||||
|
||||
```php
|
||||
use Tests\TestCase;
|
||||
use Core\Mcp\Services\McpQuotaService;
|
||||
|
||||
class QuotaTest extends TestCase
|
||||
{
|
||||
public function test_enforces_hourly_limit(): void
|
||||
{
|
||||
$workspace = Workspace::factory()->create(['tier' => 'free']);
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
// Exhaust quota
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
$quota->recordUsage($workspace, 'test');
|
||||
}
|
||||
|
||||
$this->assertFalse($quota->withinLimit($workspace));
|
||||
}
|
||||
|
||||
public function test_resets_after_hour(): void
|
||||
{
|
||||
$workspace = Workspace::factory()->create();
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
// Use quota
|
||||
$quota->recordUsage($workspace, 'test');
|
||||
|
||||
// Travel 1 hour
|
||||
$this->travel(1)->hour();
|
||||
|
||||
$this->assertTrue($quota->withinLimit($workspace));
|
||||
}
|
||||
|
||||
public function test_enterprise_has_no_limit(): void
|
||||
{
|
||||
$workspace = Workspace::factory()->create(['tier' => 'enterprise']);
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
// Use quota 1000 times
|
||||
for ($i = 0; $i < 1000; $i++) {
|
||||
$quota->recordUsage($workspace, 'test');
|
||||
}
|
||||
|
||||
$this->assertTrue($quota->withinLimit($workspace));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Check Quotas Early
|
||||
|
||||
```php
|
||||
// ✅ Good - check before processing
|
||||
if (!$quota->withinLimit($workspace)) {
|
||||
return response()->json(['error' => 'Quota exceeded'], 429);
|
||||
}
|
||||
|
||||
$result = $tool->execute($params);
|
||||
|
||||
// ❌ Bad - check after processing
|
||||
$result = $tool->execute($params);
|
||||
if (!$quota->withinLimit($workspace)) {
|
||||
// Too late!
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Provide Clear Feedback
|
||||
|
||||
```php
|
||||
// ✅ Good - helpful error message
|
||||
return response()->json([
|
||||
'error' => 'Quota exceeded',
|
||||
'reset_at' => $quota->resetTime($workspace),
|
||||
'upgrade_url' => route('billing.upgrade'),
|
||||
], 429);
|
||||
|
||||
// ❌ Bad - generic error
|
||||
return response()->json(['error' => 'Too many requests'], 429);
|
||||
```
|
||||
|
||||
### 3. Monitor Usage Patterns
|
||||
|
||||
```php
|
||||
// ✅ Good - alert at 80%
|
||||
if ($usage >= 80) {
|
||||
$this->notifyUser();
|
||||
}
|
||||
|
||||
// ❌ Bad - only alert when exhausted
|
||||
if ($usage >= 100) {
|
||||
// User already hit limit
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Appropriate Limits
|
||||
|
||||
```php
|
||||
// ✅ Good - reasonable limits
|
||||
'free' => ['requests_per_hour' => 60],
|
||||
'pro' => ['requests_per_hour' => 600],
|
||||
|
||||
// ❌ Bad - too restrictive
|
||||
'free' => ['requests_per_hour' => 5], // Unusable
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Analytics →](/packages/mcp/analytics)
|
||||
- [Security →](/packages/mcp/security)
|
||||
- [Multi-Tenancy →](/packages/core/tenancy)
|
||||
363
docs/security.md
Normal file
363
docs/security.md
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
# MCP Security
|
||||
|
||||
Security features for protecting database access and preventing SQL injection in MCP tools.
|
||||
|
||||
## SQL Query Validation
|
||||
|
||||
### Validation Rules
|
||||
|
||||
The `SqlQueryValidator` enforces strict rules on all queries:
|
||||
|
||||
**Allowed:**
|
||||
- `SELECT` statements only
|
||||
- Table/column qualifiers
|
||||
- WHERE clauses
|
||||
- JOINs
|
||||
- ORDER BY, GROUP BY
|
||||
- LIMIT clauses
|
||||
- Subqueries (SELECT only)
|
||||
|
||||
**Forbidden:**
|
||||
- `INSERT`, `UPDATE`, `DELETE`, `DROP`, `CREATE`, `ALTER`
|
||||
- `TRUNCATE`, `GRANT`, `REVOKE`
|
||||
- Database modification operations
|
||||
- System table access
|
||||
- Multiple statements (`;` separated)
|
||||
|
||||
### Usage
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\SqlQueryValidator;
|
||||
|
||||
$validator = app(SqlQueryValidator::class);
|
||||
|
||||
// Valid query
|
||||
$result = $validator->validate('SELECT * FROM posts WHERE id = ?');
|
||||
// Returns: ['valid' => true]
|
||||
|
||||
// Invalid query
|
||||
$result = $validator->validate('DROP TABLE users');
|
||||
// Returns: ['valid' => false, 'error' => 'Only SELECT queries are allowed']
|
||||
```
|
||||
|
||||
### Forbidden Patterns
|
||||
|
||||
```php
|
||||
// ❌ Data modification
|
||||
DELETE FROM users WHERE id = 1
|
||||
UPDATE posts SET status = 'published'
|
||||
INSERT INTO logs VALUES (...)
|
||||
|
||||
// ❌ Schema changes
|
||||
DROP TABLE posts
|
||||
ALTER TABLE users ADD COLUMN...
|
||||
CREATE INDEX...
|
||||
|
||||
// ❌ Permission changes
|
||||
GRANT ALL ON *.* TO user
|
||||
REVOKE SELECT ON posts FROM user
|
||||
|
||||
// ❌ Multiple statements
|
||||
SELECT * FROM posts; DROP TABLE users;
|
||||
|
||||
// ❌ System tables
|
||||
SELECT * FROM information_schema.tables
|
||||
SELECT * FROM mysql.user
|
||||
```
|
||||
|
||||
### Parameterized Queries
|
||||
|
||||
Always use bindings to prevent SQL injection:
|
||||
|
||||
```php
|
||||
// ✅ Good - parameterized
|
||||
$tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
|
||||
'bindings' => [$userId, 'published'],
|
||||
]);
|
||||
|
||||
// ❌ Bad - SQL injection risk
|
||||
$tool->execute([
|
||||
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
|
||||
]);
|
||||
```
|
||||
|
||||
## Workspace Context Security
|
||||
|
||||
### Automatic Scoping
|
||||
|
||||
Queries are automatically scoped to the current workspace:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Context\WorkspaceContext;
|
||||
|
||||
// Get workspace context from request
|
||||
$context = WorkspaceContext::fromRequest($request);
|
||||
|
||||
// Queries automatically filtered by workspace_id
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE status = ?',
|
||||
'bindings' => ['published'],
|
||||
], $context);
|
||||
|
||||
// Internally becomes:
|
||||
// SELECT * FROM posts WHERE status = ? AND workspace_id = ?
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
Tools validate workspace context before execution:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||
|
||||
class MyTool
|
||||
{
|
||||
use RequiresWorkspaceContext;
|
||||
|
||||
public function execute(array $params)
|
||||
{
|
||||
// Throws MissingWorkspaceContextException if context missing
|
||||
$this->validateWorkspaceContext();
|
||||
|
||||
// Safe to proceed
|
||||
$workspace = $this->workspaceContext->workspace;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Bypassing (Admin Only)
|
||||
|
||||
```php
|
||||
// Requires admin permission
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts',
|
||||
'bypass_workspace_scope' => true, // Admin only
|
||||
]);
|
||||
```
|
||||
|
||||
## Connection Security
|
||||
|
||||
### Allowed Connections
|
||||
|
||||
Only specific connections can be queried:
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
return [
|
||||
'database' => [
|
||||
'allowed_connections' => [
|
||||
'mysql', // Primary database
|
||||
'analytics', // Read-only analytics
|
||||
'logs', // Application logs
|
||||
],
|
||||
'default_connection' => 'mysql',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### Read-Only Connections
|
||||
|
||||
Use read-only database users for MCP:
|
||||
|
||||
```php
|
||||
// config/database.php
|
||||
'connections' => [
|
||||
'mcp_readonly' => [
|
||||
'driver' => 'mysql',
|
||||
'host' => env('DB_HOST'),
|
||||
'database' => env('DB_DATABASE'),
|
||||
'username' => env('MCP_DB_USER'), // Read-only user
|
||||
'password' => env('MCP_DB_PASSWORD'),
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
**Database Setup:**
|
||||
|
||||
```sql
|
||||
-- Create read-only user
|
||||
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
|
||||
|
||||
-- Grant SELECT only
|
||||
GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
|
||||
|
||||
-- Explicitly deny modifications
|
||||
REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER ON app_database.* FROM 'mcp_readonly'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### Connection Validation
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\ConnectionValidator;
|
||||
|
||||
$validator = app(ConnectionValidator::class);
|
||||
|
||||
// Check if connection is allowed
|
||||
if (!$validator->isAllowed('mysql')) {
|
||||
throw new ForbiddenConnectionException();
|
||||
}
|
||||
|
||||
// Check if connection exists
|
||||
if (!$validator->exists('mysql')) {
|
||||
throw new InvalidConnectionException();
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Prevent abuse with rate limits:
|
||||
|
||||
```php
|
||||
use Core\Mcp\Middleware\CheckMcpQuota;
|
||||
|
||||
Route::middleware([CheckMcpQuota::class])
|
||||
->post('/mcp/query', [McpApiController::class, 'query']);
|
||||
```
|
||||
|
||||
**Limits:**
|
||||
|
||||
| Tier | Requests/Hour | Queries/Day |
|
||||
|------|--------------|-------------|
|
||||
| Free | 60 | 500 |
|
||||
| Pro | 600 | 10,000 |
|
||||
| Enterprise | Unlimited | Unlimited |
|
||||
|
||||
### Quota Enforcement
|
||||
|
||||
```php
|
||||
use Core\Mcp\Services\McpQuotaService;
|
||||
|
||||
$quota = app(McpQuotaService::class);
|
||||
|
||||
// Check if within quota
|
||||
if (!$quota->withinLimit($workspace)) {
|
||||
throw new QuotaExceededException();
|
||||
}
|
||||
|
||||
// Record usage
|
||||
$quota->recordUsage($workspace, 'query_database');
|
||||
```
|
||||
|
||||
## Query Logging
|
||||
|
||||
All queries are logged for audit:
|
||||
|
||||
```php
|
||||
// storage/logs/mcp-queries.log
|
||||
[2026-01-26 12:00:00] Query executed
|
||||
Workspace: acme-corp
|
||||
User: john@example.com
|
||||
Query: SELECT * FROM posts WHERE status = ?
|
||||
Bindings: ["published"]
|
||||
Rows: 42
|
||||
Duration: 5.23ms
|
||||
```
|
||||
|
||||
### Log Configuration
|
||||
|
||||
```php
|
||||
// config/logging.php
|
||||
'channels' => [
|
||||
'mcp' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/mcp-queries.log'),
|
||||
'level' => 'info',
|
||||
'days' => 90, // Retain for 90 days
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Bindings
|
||||
|
||||
```php
|
||||
// ✅ Good - parameterized
|
||||
'query' => 'SELECT * FROM posts WHERE id = ?',
|
||||
'bindings' => [$id],
|
||||
|
||||
// ❌ Bad - SQL injection risk
|
||||
'query' => "SELECT * FROM posts WHERE id = {$id}",
|
||||
```
|
||||
|
||||
### 2. Limit Result Sets
|
||||
|
||||
```php
|
||||
// ✅ Good - limited results
|
||||
'query' => 'SELECT * FROM posts LIMIT 100',
|
||||
|
||||
// ❌ Bad - unbounded query
|
||||
'query' => 'SELECT * FROM posts',
|
||||
```
|
||||
|
||||
### 3. Use Read-Only Connections
|
||||
|
||||
```php
|
||||
// ✅ Good - read-only user
|
||||
'connection' => 'mcp_readonly',
|
||||
|
||||
// ❌ Bad - admin connection
|
||||
'connection' => 'mysql_admin',
|
||||
```
|
||||
|
||||
### 4. Validate Workspace Context
|
||||
|
||||
```php
|
||||
// ✅ Good - validate context
|
||||
$this->validateWorkspaceContext();
|
||||
|
||||
// ❌ Bad - no validation
|
||||
// (workspace boundary bypass risk)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```php
|
||||
use Tests\TestCase;
|
||||
use Core\Mcp\Services\SqlQueryValidator;
|
||||
|
||||
class SecurityTest extends TestCase
|
||||
{
|
||||
public function test_blocks_destructive_queries(): void
|
||||
{
|
||||
$validator = app(SqlQueryValidator::class);
|
||||
|
||||
$result = $validator->validate('DROP TABLE users');
|
||||
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertStringContainsString('Only SELECT', $result['error']);
|
||||
}
|
||||
|
||||
public function test_allows_select_queries(): void
|
||||
{
|
||||
$validator = app(SqlQueryValidator::class);
|
||||
|
||||
$result = $validator->validate('SELECT * FROM posts WHERE id = ?');
|
||||
|
||||
$this->assertTrue($result['valid']);
|
||||
}
|
||||
|
||||
public function test_enforces_workspace_scope(): void
|
||||
{
|
||||
$workspace = Workspace::factory()->create();
|
||||
$context = new WorkspaceContext($workspace);
|
||||
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts',
|
||||
], $context);
|
||||
|
||||
// Should only return workspace's posts
|
||||
$this->assertEquals($workspace->id, $result['rows'][0]['workspace_id']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Query Database →](/packages/mcp/query-database)
|
||||
- [Workspace Context →](/packages/mcp/workspace)
|
||||
- [Quotas →](/packages/mcp/quotas)
|
||||
605
docs/sql-security.md
Normal file
605
docs/sql-security.md
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
# Guide: SQL Security
|
||||
|
||||
This guide documents the security controls for the Query Database MCP tool, including allowed SQL patterns, forbidden operations, and parameterized query requirements.
|
||||
|
||||
## Overview
|
||||
|
||||
The MCP Query Database tool provides AI agents with read-only SQL access. Multiple security layers protect against:
|
||||
|
||||
- SQL injection attacks
|
||||
- Data modification/destruction
|
||||
- Cross-tenant data access
|
||||
- Resource exhaustion
|
||||
- Information leakage
|
||||
|
||||
## Allowed SQL Patterns
|
||||
|
||||
### SELECT-Only Queries
|
||||
|
||||
Only `SELECT` statements are permitted. All queries must begin with `SELECT`:
|
||||
|
||||
```sql
|
||||
-- Allowed: Basic SELECT
|
||||
SELECT * FROM posts WHERE status = 'published';
|
||||
|
||||
-- Allowed: Specific columns
|
||||
SELECT id, title, created_at FROM posts;
|
||||
|
||||
-- Allowed: COUNT queries
|
||||
SELECT COUNT(*) FROM users WHERE active = 1;
|
||||
|
||||
-- Allowed: Aggregation
|
||||
SELECT status, COUNT(*) as count FROM posts GROUP BY status;
|
||||
|
||||
-- Allowed: JOIN queries
|
||||
SELECT posts.title, users.name
|
||||
FROM posts
|
||||
JOIN users ON posts.user_id = users.id;
|
||||
|
||||
-- Allowed: ORDER BY and LIMIT
|
||||
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
|
||||
|
||||
-- Allowed: WHERE with multiple conditions
|
||||
SELECT * FROM posts
|
||||
WHERE status = 'published'
|
||||
AND user_id = 42
|
||||
AND created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
### Supported Operators
|
||||
|
||||
WHERE clauses support these operators:
|
||||
|
||||
| Operator | Example |
|
||||
|----------|---------|
|
||||
| `=` | `WHERE status = 'active'` |
|
||||
| `!=`, `<>` | `WHERE status != 'deleted'` |
|
||||
| `>`, `>=` | `WHERE created_at > '2024-01-01'` |
|
||||
| `<`, `<=` | `WHERE views < 1000` |
|
||||
| `LIKE` | `WHERE title LIKE '%search%'` |
|
||||
| `IN` | `WHERE status IN ('draft', 'published')` |
|
||||
| `BETWEEN` | `WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'` |
|
||||
| `IS NULL` | `WHERE deleted_at IS NULL` |
|
||||
| `IS NOT NULL` | `WHERE email IS NOT NULL` |
|
||||
| `AND` | `WHERE a = 1 AND b = 2` |
|
||||
| `OR` | `WHERE status = 'draft' OR status = 'review'` |
|
||||
|
||||
## Forbidden Operations
|
||||
|
||||
### Data Modification (Blocked)
|
||||
|
||||
```sql
|
||||
-- BLOCKED: INSERT
|
||||
INSERT INTO users (name) VALUES ('attacker');
|
||||
|
||||
-- BLOCKED: UPDATE
|
||||
UPDATE users SET role = 'admin' WHERE id = 1;
|
||||
|
||||
-- BLOCKED: DELETE
|
||||
DELETE FROM users WHERE id = 1;
|
||||
|
||||
-- BLOCKED: REPLACE
|
||||
REPLACE INTO users (id, name) VALUES (1, 'changed');
|
||||
```
|
||||
|
||||
### Schema Modification (Blocked)
|
||||
|
||||
```sql
|
||||
-- BLOCKED: DROP
|
||||
DROP TABLE users;
|
||||
DROP DATABASE production;
|
||||
|
||||
-- BLOCKED: TRUNCATE
|
||||
TRUNCATE TABLE logs;
|
||||
|
||||
-- BLOCKED: ALTER
|
||||
ALTER TABLE users ADD COLUMN backdoor TEXT;
|
||||
|
||||
-- BLOCKED: CREATE
|
||||
CREATE TABLE malicious_table (...);
|
||||
|
||||
-- BLOCKED: RENAME
|
||||
RENAME TABLE users TO users_backup;
|
||||
```
|
||||
|
||||
### Permission Operations (Blocked)
|
||||
|
||||
```sql
|
||||
-- BLOCKED: GRANT
|
||||
GRANT ALL ON *.* TO 'attacker'@'%';
|
||||
|
||||
-- BLOCKED: REVOKE
|
||||
REVOKE SELECT ON database.* FROM 'user'@'%';
|
||||
|
||||
-- BLOCKED: FLUSH
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### System Operations (Blocked)
|
||||
|
||||
```sql
|
||||
-- BLOCKED: File operations
|
||||
SELECT * FROM posts INTO OUTFILE '/tmp/data.csv';
|
||||
SELECT LOAD_FILE('/etc/passwd');
|
||||
LOAD DATA INFILE '/etc/passwd' INTO TABLE users;
|
||||
|
||||
-- BLOCKED: Execution
|
||||
EXECUTE prepared_statement;
|
||||
CALL stored_procedure();
|
||||
PREPARE stmt FROM 'SELECT ...';
|
||||
|
||||
-- BLOCKED: Variables
|
||||
SET @var = (SELECT password FROM users);
|
||||
SET GLOBAL max_connections = 1;
|
||||
```
|
||||
|
||||
### Complete Blocked Keywords List
|
||||
|
||||
```php
|
||||
// Data modification
|
||||
'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'TRUNCATE'
|
||||
|
||||
// Schema changes
|
||||
'DROP', 'ALTER', 'CREATE', 'RENAME'
|
||||
|
||||
// Permissions
|
||||
'GRANT', 'REVOKE', 'FLUSH'
|
||||
|
||||
// System
|
||||
'KILL', 'RESET', 'PURGE'
|
||||
|
||||
// File operations
|
||||
'INTO OUTFILE', 'INTO DUMPFILE', 'LOAD_FILE', 'LOAD DATA'
|
||||
|
||||
// Execution
|
||||
'EXECUTE', 'EXEC', 'PREPARE', 'DEALLOCATE', 'CALL'
|
||||
|
||||
// Variables
|
||||
'SET '
|
||||
```
|
||||
|
||||
## SQL Injection Prevention
|
||||
|
||||
### Dangerous Patterns (Detected and Blocked)
|
||||
|
||||
The validator detects and blocks common injection patterns:
|
||||
|
||||
#### Stacked Queries
|
||||
|
||||
```sql
|
||||
-- BLOCKED: Multiple statements
|
||||
SELECT * FROM posts; DROP TABLE users;
|
||||
SELECT * FROM posts; DELETE FROM logs;
|
||||
```
|
||||
|
||||
#### UNION Injection
|
||||
|
||||
```sql
|
||||
-- BLOCKED: UNION attacks
|
||||
SELECT * FROM posts WHERE id = 1 UNION SELECT password FROM users;
|
||||
SELECT * FROM posts UNION ALL SELECT * FROM secrets;
|
||||
```
|
||||
|
||||
#### Comment Obfuscation
|
||||
|
||||
```sql
|
||||
-- BLOCKED: Comments hiding keywords
|
||||
SELECT * FROM posts WHERE id = 1 /**/UNION/**/SELECT password FROM users;
|
||||
SELECT * FROM posts; -- DROP TABLE users
|
||||
SELECT * FROM posts # DELETE FROM logs
|
||||
```
|
||||
|
||||
#### Hex Encoding
|
||||
|
||||
```sql
|
||||
-- BLOCKED: Hex-encoded strings
|
||||
SELECT * FROM posts WHERE id = 0x313B44524F50205441424C4520757365727320;
|
||||
```
|
||||
|
||||
#### Time-Based Attacks
|
||||
|
||||
```sql
|
||||
-- BLOCKED: Timing attacks
|
||||
SELECT * FROM posts WHERE id = 1 AND SLEEP(10);
|
||||
SELECT * FROM posts WHERE BENCHMARK(10000000, SHA1('test'));
|
||||
```
|
||||
|
||||
#### System Table Access
|
||||
|
||||
```sql
|
||||
-- BLOCKED: Information schema
|
||||
SELECT * FROM information_schema.tables;
|
||||
SELECT * FROM information_schema.columns WHERE table_name = 'users';
|
||||
|
||||
-- BLOCKED: MySQL system tables
|
||||
SELECT * FROM mysql.user;
|
||||
SELECT * FROM performance_schema.threads;
|
||||
SELECT * FROM sys.session;
|
||||
```
|
||||
|
||||
#### Subquery in WHERE
|
||||
|
||||
```sql
|
||||
-- BLOCKED: Potential data exfiltration
|
||||
SELECT * FROM posts WHERE id = (SELECT user_id FROM admins LIMIT 1);
|
||||
```
|
||||
|
||||
### Detection Patterns
|
||||
|
||||
The validator uses these regex patterns to detect attacks:
|
||||
|
||||
```php
|
||||
// Stacked queries
|
||||
'/;\s*\S/i'
|
||||
|
||||
// UNION injection
|
||||
'/\bUNION\b/i'
|
||||
|
||||
// Hex encoding
|
||||
'/0x[0-9a-f]+/i'
|
||||
|
||||
// Dangerous functions
|
||||
'/\bCHAR\s*\(/i'
|
||||
'/\bBENCHMARK\s*\(/i'
|
||||
'/\bSLEEP\s*\(/i'
|
||||
|
||||
// System tables
|
||||
'/\bINFORMATION_SCHEMA\b/i'
|
||||
'/\bmysql\./i'
|
||||
'/\bperformance_schema\./i'
|
||||
'/\bsys\./i'
|
||||
|
||||
// Subquery in WHERE
|
||||
'/WHERE\s+.*\(\s*SELECT/i'
|
||||
|
||||
// Comment obfuscation
|
||||
'/\/\*[^*]*\*\/\s*(?:UNION|SELECT|INSERT|UPDATE|DELETE|DROP)/i'
|
||||
```
|
||||
|
||||
## Parameterized Queries
|
||||
|
||||
**Always use parameter bindings** instead of string interpolation:
|
||||
|
||||
### Correct Usage
|
||||
|
||||
```php
|
||||
// SAFE: Parameterized query
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
|
||||
'bindings' => [$userId, 'published'],
|
||||
]);
|
||||
|
||||
// SAFE: Multiple parameters
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM orders WHERE created_at BETWEEN ? AND ? AND total > ?',
|
||||
'bindings' => ['2024-01-01', '2024-12-31', 100.00],
|
||||
]);
|
||||
```
|
||||
|
||||
### Incorrect Usage (Vulnerable)
|
||||
|
||||
```php
|
||||
// VULNERABLE: String interpolation
|
||||
$result = $tool->execute([
|
||||
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
|
||||
]);
|
||||
|
||||
// VULNERABLE: Concatenation
|
||||
$query = "SELECT * FROM posts WHERE status = '" . $status . "'";
|
||||
$result = $tool->execute(['query' => $query]);
|
||||
|
||||
// VULNERABLE: sprintf
|
||||
$query = sprintf("SELECT * FROM posts WHERE id = %d", $id);
|
||||
$result = $tool->execute(['query' => $query]);
|
||||
```
|
||||
|
||||
### Why Bindings Matter
|
||||
|
||||
With bindings, malicious input is escaped automatically:
|
||||
|
||||
```php
|
||||
// User input
|
||||
$userInput = "'; DROP TABLE users; --";
|
||||
|
||||
// With bindings: SAFE (input is escaped)
|
||||
$tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE title = ?',
|
||||
'bindings' => [$userInput],
|
||||
]);
|
||||
// Executed as: SELECT * FROM posts WHERE title = '\'; DROP TABLE users; --'
|
||||
|
||||
// Without bindings: VULNERABLE
|
||||
$tool->execute([
|
||||
'query' => "SELECT * FROM posts WHERE title = '$userInput'",
|
||||
]);
|
||||
// Executed as: SELECT * FROM posts WHERE title = ''; DROP TABLE users; --'
|
||||
```
|
||||
|
||||
## Whitelist-Based Validation
|
||||
|
||||
The validator uses a whitelist approach, only allowing queries matching known-safe patterns:
|
||||
|
||||
### Default Whitelist Patterns
|
||||
|
||||
```php
|
||||
// Simple SELECT with optional WHERE
|
||||
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+`?\w+`?
|
||||
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
|
||||
(\s+ORDER\s+BY\s+[\w\s,`]+)?
|
||||
(\s+LIMIT\s+\d+)?;?\s*$/i'
|
||||
|
||||
// COUNT queries
|
||||
'/^\s*SELECT\s+COUNT\s*\(\s*\*?\s*\)
|
||||
\s+FROM\s+`?\w+`?
|
||||
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*;?\s*$/i'
|
||||
|
||||
// Explicit column list
|
||||
'/^\s*SELECT\s+`?\w+`?(\s*,\s*`?\w+`?)*
|
||||
\s+FROM\s+`?\w+`?
|
||||
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
|
||||
(\s+ORDER\s+BY\s+[\w\s,`]+)?
|
||||
(\s+LIMIT\s+\d+)?;?\s*$/i'
|
||||
```
|
||||
|
||||
### Adding Custom Patterns
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
'database' => [
|
||||
'use_whitelist' => true,
|
||||
'whitelist_patterns' => [
|
||||
// Allow specific JOIN pattern
|
||||
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+posts\s+JOIN\s+users\s+ON\s+posts\.user_id\s*=\s*users\.id/i',
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## Connection Security
|
||||
|
||||
### Allowed Connections
|
||||
|
||||
Only whitelisted database connections can be queried:
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
'database' => [
|
||||
'allowed_connections' => [
|
||||
'mysql', // Primary database
|
||||
'analytics', // Read-only analytics
|
||||
'logs', // Application logs
|
||||
],
|
||||
'connection' => 'mcp_readonly', // Default MCP connection
|
||||
],
|
||||
```
|
||||
|
||||
### Read-Only Database User
|
||||
|
||||
Create a dedicated read-only user for MCP:
|
||||
|
||||
```sql
|
||||
-- Create read-only user
|
||||
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
|
||||
|
||||
-- Grant SELECT only
|
||||
GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
|
||||
|
||||
-- Explicitly deny write operations
|
||||
REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER
|
||||
ON app_database.* FROM 'mcp_readonly'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Configure in Laravel:
|
||||
|
||||
```php
|
||||
// config/database.php
|
||||
'connections' => [
|
||||
'mcp_readonly' => [
|
||||
'driver' => 'mysql',
|
||||
'host' => env('DB_HOST'),
|
||||
'database' => env('DB_DATABASE'),
|
||||
'username' => env('MCP_DB_USER', 'mcp_readonly'),
|
||||
'password' => env('MCP_DB_PASSWORD'),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'strict' => true,
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## Blocked Tables
|
||||
|
||||
Configure tables that cannot be queried:
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
'database' => [
|
||||
'blocked_tables' => [
|
||||
'users', // User credentials
|
||||
'password_resets', // Password tokens
|
||||
'sessions', // Session data
|
||||
'api_keys', // API credentials
|
||||
'oauth_access_tokens', // OAuth tokens
|
||||
'personal_access_tokens', // Sanctum tokens
|
||||
'failed_jobs', // Job queue data
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
The validator checks for table references in multiple formats:
|
||||
|
||||
```php
|
||||
// All these are blocked for 'users' table:
|
||||
'SELECT * FROM users'
|
||||
'SELECT * FROM `users`'
|
||||
'SELECT posts.*, users.name FROM posts JOIN users...'
|
||||
'SELECT users.email FROM ...'
|
||||
```
|
||||
|
||||
## Row Limits
|
||||
|
||||
Automatic row limits prevent data exfiltration:
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
'database' => [
|
||||
'max_rows' => 1000, // Maximum rows per query
|
||||
],
|
||||
```
|
||||
|
||||
If query doesn't include LIMIT, one is added automatically:
|
||||
|
||||
```php
|
||||
// Query without LIMIT
|
||||
$tool->execute(['query' => 'SELECT * FROM posts']);
|
||||
// Becomes: SELECT * FROM posts LIMIT 1000
|
||||
|
||||
// Query with smaller LIMIT (preserved)
|
||||
$tool->execute(['query' => 'SELECT * FROM posts LIMIT 10']);
|
||||
// Stays: SELECT * FROM posts LIMIT 10
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Forbidden Query Response
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid Structure Response
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Query rejected: Query must begin with SELECT"
|
||||
}
|
||||
```
|
||||
|
||||
### Not Whitelisted Response
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Query rejected: Query does not match any allowed pattern"
|
||||
}
|
||||
```
|
||||
|
||||
### Sanitized SQL Errors
|
||||
|
||||
Database errors are sanitized to prevent information leakage:
|
||||
|
||||
```php
|
||||
// Original error (logged for debugging)
|
||||
"SQLSTATE[42S02]: Table 'production.secret_table' doesn't exist at 192.168.1.100"
|
||||
|
||||
// Sanitized response (returned to client)
|
||||
"Query execution failed: Table '[path]' doesn't exist at [ip]"
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
```php
|
||||
// config/mcp.php
|
||||
return [
|
||||
'database' => [
|
||||
// Database connection for MCP queries
|
||||
'connection' => env('MCP_DB_CONNECTION', 'mcp_readonly'),
|
||||
|
||||
// Use whitelist validation (recommended: true)
|
||||
'use_whitelist' => true,
|
||||
|
||||
// Custom whitelist patterns (regex)
|
||||
'whitelist_patterns' => [],
|
||||
|
||||
// Tables that cannot be queried
|
||||
'blocked_tables' => [
|
||||
'users',
|
||||
'password_resets',
|
||||
'sessions',
|
||||
'api_keys',
|
||||
],
|
||||
|
||||
// Maximum rows per query
|
||||
'max_rows' => 1000,
|
||||
|
||||
// Query execution timeout (milliseconds)
|
||||
'timeout' => 5000,
|
||||
|
||||
// Enable EXPLAIN analysis
|
||||
'enable_explain' => true,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Testing Security
|
||||
|
||||
```php
|
||||
use Tests\TestCase;
|
||||
use Core\Mod\Mcp\Services\SqlQueryValidator;
|
||||
use Core\Mod\Mcp\Exceptions\ForbiddenQueryException;
|
||||
|
||||
class SqlSecurityTest extends TestCase
|
||||
{
|
||||
private SqlQueryValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->validator = new SqlQueryValidator();
|
||||
}
|
||||
|
||||
public function test_blocks_delete(): void
|
||||
{
|
||||
$this->expectException(ForbiddenQueryException::class);
|
||||
$this->validator->validate('DELETE FROM users');
|
||||
}
|
||||
|
||||
public function test_blocks_union_injection(): void
|
||||
{
|
||||
$this->expectException(ForbiddenQueryException::class);
|
||||
$this->validator->validate("SELECT * FROM posts UNION SELECT password FROM users");
|
||||
}
|
||||
|
||||
public function test_blocks_stacked_queries(): void
|
||||
{
|
||||
$this->expectException(ForbiddenQueryException::class);
|
||||
$this->validator->validate("SELECT * FROM posts; DROP TABLE users");
|
||||
}
|
||||
|
||||
public function test_blocks_system_tables(): void
|
||||
{
|
||||
$this->expectException(ForbiddenQueryException::class);
|
||||
$this->validator->validate("SELECT * FROM information_schema.tables");
|
||||
}
|
||||
|
||||
public function test_allows_safe_select(): void
|
||||
{
|
||||
$this->validator->validate("SELECT id, title FROM posts WHERE status = 'published'");
|
||||
$this->assertTrue(true); // No exception = pass
|
||||
}
|
||||
|
||||
public function test_allows_count(): void
|
||||
{
|
||||
$this->validator->validate("SELECT COUNT(*) FROM posts");
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. **Always use parameterized queries** - Never interpolate values into SQL strings
|
||||
2. **Use a read-only database user** - Database-level protection against modifications
|
||||
3. **Configure blocked tables** - Prevent access to sensitive data
|
||||
4. **Enable whitelist validation** - Only allow known-safe query patterns
|
||||
5. **Set appropriate row limits** - Prevent large data exports
|
||||
6. **Review logs regularly** - Monitor for suspicious query patterns
|
||||
7. **Test security controls** - Include injection tests in your test suite
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Query Database Tool](/packages/mcp/query-database) - Tool usage
|
||||
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
|
||||
- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Tool development
|
||||
739
docs/tools-reference.md
Normal file
739
docs/tools-reference.md
Normal file
|
|
@ -0,0 +1,739 @@
|
|||
# API Reference: MCP Tools
|
||||
|
||||
Complete reference for all MCP tools including parameters, response formats, and error handling.
|
||||
|
||||
## Database Tools
|
||||
|
||||
### query_database
|
||||
|
||||
Execute read-only SQL queries against the database.
|
||||
|
||||
**Description:** Execute a read-only SQL SELECT query against the database
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `query` | string | Yes | SQL SELECT query to execute. Only read-only SELECT queries are permitted. |
|
||||
| `explain` | boolean | No | If true, runs EXPLAIN on the query instead of executing it. Useful for query optimization. Default: `false` |
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "query_database",
|
||||
"arguments": {
|
||||
"query": "SELECT id, title, status FROM posts WHERE status = 'published' LIMIT 10"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": 1, "title": "First Post", "status": "published"},
|
||||
{"id": 2, "title": "Second Post", "status": "published"}
|
||||
]
|
||||
```
|
||||
|
||||
**With EXPLAIN:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "query_database",
|
||||
"arguments": {
|
||||
"query": "SELECT * FROM posts WHERE status = 'published'",
|
||||
"explain": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**EXPLAIN Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"explain": [
|
||||
{
|
||||
"id": 1,
|
||||
"select_type": "SIMPLE",
|
||||
"table": "posts",
|
||||
"type": "ref",
|
||||
"key": "idx_status",
|
||||
"rows": 150,
|
||||
"Extra": "Using index"
|
||||
}
|
||||
],
|
||||
"query": "SELECT * FROM posts WHERE status = 'published' LIMIT 1000",
|
||||
"interpretation": [
|
||||
{
|
||||
"table": "posts",
|
||||
"analysis": [
|
||||
"GOOD: Using index: idx_status"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response - Forbidden Query:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response - Invalid Structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Query rejected: Query must begin with SELECT"
|
||||
}
|
||||
```
|
||||
|
||||
**Security Notes:**
|
||||
- Only SELECT queries are allowed
|
||||
- Blocked keywords: INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE
|
||||
- UNION queries are blocked
|
||||
- System tables (information_schema, mysql.*) are blocked
|
||||
- Automatic LIMIT applied if not specified
|
||||
- Use read-only database connection
|
||||
|
||||
---
|
||||
|
||||
### list_tables
|
||||
|
||||
List all database tables in the application.
|
||||
|
||||
**Description:** List all database tables
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "list_tables",
|
||||
"arguments": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
"users",
|
||||
"posts",
|
||||
"comments",
|
||||
"tags",
|
||||
"categories",
|
||||
"media",
|
||||
"migrations",
|
||||
"jobs"
|
||||
]
|
||||
```
|
||||
|
||||
**Security Notes:**
|
||||
- Returns table names only, not structure
|
||||
- Some tables may be filtered based on configuration
|
||||
|
||||
---
|
||||
|
||||
## Commerce Tools
|
||||
|
||||
### get_billing_status
|
||||
|
||||
Get billing status for the authenticated workspace.
|
||||
|
||||
**Description:** Get billing status for your workspace including subscription, current plan, and billing period
|
||||
|
||||
**Parameters:** None (workspace from authentication context)
|
||||
|
||||
**Requires:** Workspace Context
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "get_billing_status",
|
||||
"arguments": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": {
|
||||
"id": 42,
|
||||
"name": "Acme Corp"
|
||||
},
|
||||
"subscription": {
|
||||
"id": 123,
|
||||
"status": "active",
|
||||
"gateway": "stripe",
|
||||
"billing_cycle": "monthly",
|
||||
"current_period_start": "2024-01-01T00:00:00+00:00",
|
||||
"current_period_end": "2024-02-01T00:00:00+00:00",
|
||||
"days_until_renewal": 15,
|
||||
"cancel_at_period_end": false,
|
||||
"on_trial": false,
|
||||
"trial_ends_at": null
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"code": "professional",
|
||||
"name": "Professional Plan",
|
||||
"status": "active",
|
||||
"expires_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `workspace.id` | integer | Workspace ID |
|
||||
| `workspace.name` | string | Workspace name |
|
||||
| `subscription.status` | string | active, trialing, past_due, canceled |
|
||||
| `subscription.billing_cycle` | string | monthly, yearly |
|
||||
| `subscription.days_until_renewal` | integer | Days until next billing |
|
||||
| `subscription.on_trial` | boolean | Currently in trial period |
|
||||
| `packages` | array | Active feature packages |
|
||||
|
||||
**Error Response - No Workspace Context:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "MCP tool 'get_billing_status' requires workspace context. Authenticate with an API key or user session."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### list_invoices
|
||||
|
||||
List invoices for the authenticated workspace.
|
||||
|
||||
**Description:** List invoices for your workspace with optional status filter
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `status` | string | No | Filter by status: paid, pending, overdue, void |
|
||||
| `limit` | integer | No | Maximum invoices to return. Default: 10, Max: 50 |
|
||||
|
||||
**Requires:** Workspace Context
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "list_invoices",
|
||||
"arguments": {
|
||||
"status": "paid",
|
||||
"limit": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace_id": 42,
|
||||
"count": 5,
|
||||
"invoices": [
|
||||
{
|
||||
"id": 1001,
|
||||
"invoice_number": "INV-2024-001",
|
||||
"status": "paid",
|
||||
"subtotal": 99.00,
|
||||
"discount_amount": 0.00,
|
||||
"tax_amount": 19.80,
|
||||
"total": 118.80,
|
||||
"amount_paid": 118.80,
|
||||
"amount_due": 0.00,
|
||||
"currency": "GBP",
|
||||
"issue_date": "2024-01-01",
|
||||
"due_date": "2024-01-15",
|
||||
"paid_at": "2024-01-10T14:30:00+00:00",
|
||||
"is_overdue": false,
|
||||
"order_number": "ORD-2024-001"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `invoice_number` | string | Unique invoice identifier |
|
||||
| `status` | string | paid, pending, overdue, void |
|
||||
| `total` | number | Total amount including tax |
|
||||
| `amount_due` | number | Remaining amount to pay |
|
||||
| `is_overdue` | boolean | Past due date with unpaid balance |
|
||||
|
||||
---
|
||||
|
||||
### upgrade_plan
|
||||
|
||||
Preview or execute a plan upgrade/downgrade.
|
||||
|
||||
**Description:** Preview or execute a plan upgrade/downgrade for your workspace subscription
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `package_code` | string | Yes | Code of the new package (e.g., agency, enterprise) |
|
||||
| `preview` | boolean | No | If true, only preview without executing. Default: `true` |
|
||||
| `immediate` | boolean | No | If true, apply immediately; false schedules for period end. Default: `true` |
|
||||
|
||||
**Requires:** Workspace Context
|
||||
|
||||
**Example Request - Preview:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "upgrade_plan",
|
||||
"arguments": {
|
||||
"package_code": "enterprise",
|
||||
"preview": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Preview Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"preview": true,
|
||||
"current_package": "professional",
|
||||
"new_package": "enterprise",
|
||||
"proration": {
|
||||
"is_upgrade": true,
|
||||
"is_downgrade": false,
|
||||
"current_plan_price": 99.00,
|
||||
"new_plan_price": 299.00,
|
||||
"credit_amount": 49.50,
|
||||
"prorated_new_cost": 149.50,
|
||||
"net_amount": 100.00,
|
||||
"requires_payment": true,
|
||||
"days_remaining": 15,
|
||||
"currency": "GBP"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execute Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"immediate": true,
|
||||
"current_package": "professional",
|
||||
"new_package": "enterprise",
|
||||
"proration": {
|
||||
"is_upgrade": true,
|
||||
"net_amount": 100.00
|
||||
},
|
||||
"subscription_status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response - Package Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Package not found",
|
||||
"available_packages": ["starter", "professional", "agency", "enterprise"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### create_coupon
|
||||
|
||||
Create a new discount coupon code.
|
||||
|
||||
**Description:** Create a new discount coupon code
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `code` | string | Yes | Unique coupon code (uppercase letters, numbers, hyphens, underscores) |
|
||||
| `name` | string | Yes | Display name for the coupon |
|
||||
| `type` | string | No | Discount type: percentage or fixed_amount. Default: percentage |
|
||||
| `value` | number | Yes | Discount value (1-100 for percentage, or fixed amount) |
|
||||
| `duration` | string | No | How long discount applies: once, repeating, forever. Default: once |
|
||||
| `max_uses` | integer | No | Maximum total uses (null for unlimited) |
|
||||
| `valid_until` | string | No | Expiry date in YYYY-MM-DD format |
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "create_coupon",
|
||||
"arguments": {
|
||||
"code": "SUMMER25",
|
||||
"name": "Summer Sale 2024",
|
||||
"type": "percentage",
|
||||
"value": 25,
|
||||
"duration": "once",
|
||||
"max_uses": 100,
|
||||
"valid_until": "2024-08-31"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"coupon": {
|
||||
"id": 42,
|
||||
"code": "SUMMER25",
|
||||
"name": "Summer Sale 2024",
|
||||
"type": "percentage",
|
||||
"value": 25.0,
|
||||
"duration": "once",
|
||||
"max_uses": 100,
|
||||
"valid_until": "2024-08-31",
|
||||
"is_active": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response - Invalid Code:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response - Duplicate Code:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "A coupon with this code already exists."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response - Invalid Percentage:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Percentage value must be between 1 and 100."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Tools
|
||||
|
||||
### list_sites
|
||||
|
||||
List all sites managed by the platform.
|
||||
|
||||
**Description:** List all sites managed by Host Hub
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "list_sites",
|
||||
"arguments": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "BioHost",
|
||||
"domain": "link.host.uk.com",
|
||||
"type": "WordPress"
|
||||
},
|
||||
{
|
||||
"name": "SocialHost",
|
||||
"domain": "social.host.uk.com",
|
||||
"type": "Laravel"
|
||||
},
|
||||
{
|
||||
"name": "AnalyticsHost",
|
||||
"domain": "analytics.host.uk.com",
|
||||
"type": "Node.js"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### list_routes
|
||||
|
||||
List all web routes in the application.
|
||||
|
||||
**Description:** List all web routes in the application
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "list_routes",
|
||||
"arguments": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"uri": "/",
|
||||
"methods": ["GET", "HEAD"],
|
||||
"name": "home"
|
||||
},
|
||||
{
|
||||
"uri": "/login",
|
||||
"methods": ["GET", "HEAD"],
|
||||
"name": "login"
|
||||
},
|
||||
{
|
||||
"uri": "/api/posts",
|
||||
"methods": ["GET", "HEAD"],
|
||||
"name": "api.posts.index"
|
||||
},
|
||||
{
|
||||
"uri": "/api/posts/{post}",
|
||||
"methods": ["GET", "HEAD"],
|
||||
"name": "api.posts.show"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### get_stats
|
||||
|
||||
Get current system statistics.
|
||||
|
||||
**Description:** Get current system statistics for Host Hub
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "get_stats",
|
||||
"arguments": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"total_sites": 6,
|
||||
"active_users": 128,
|
||||
"page_views_30d": 12500,
|
||||
"server_load": "23%"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Error Responses
|
||||
|
||||
### Missing Workspace Context
|
||||
|
||||
Tools requiring workspace context return this when no API key or session is provided:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "MCP tool 'tool_name' requires workspace context. Authenticate with an API key or user session."
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status:** 403
|
||||
|
||||
### Missing Dependency
|
||||
|
||||
When a tool's dependencies aren't satisfied:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "dependency_not_met",
|
||||
"message": "Dependencies not satisfied for tool 'update_task'",
|
||||
"missing": [
|
||||
{
|
||||
"type": "tool_called",
|
||||
"key": "create_plan",
|
||||
"description": "A plan must be created before updating tasks"
|
||||
}
|
||||
],
|
||||
"suggested_order": ["create_plan", "update_task"]
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status:** 422
|
||||
|
||||
### Quota Exceeded
|
||||
|
||||
When workspace has exceeded their tool usage quota:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "quota_exceeded",
|
||||
"message": "Daily tool quota exceeded for this workspace",
|
||||
"current_usage": 1000,
|
||||
"limit": 1000,
|
||||
"resets_at": "2024-01-16T00:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status:** 429
|
||||
|
||||
### Validation Error
|
||||
|
||||
When parameters fail validation:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Validation failed",
|
||||
"code": "VALIDATION_ERROR",
|
||||
"details": {
|
||||
"query": ["The query field is required"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status:** 422
|
||||
|
||||
### Internal Error
|
||||
|
||||
When an unexpected error occurs:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "An unexpected error occurred. Please try again.",
|
||||
"code": "INTERNAL_ERROR"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status:** 500
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### API Key Authentication
|
||||
|
||||
Include your API key in the Authorization header:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.example.com/mcp/tools/call \
|
||||
-H "Authorization: Bearer sk_live_xxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool": "get_billing_status", "arguments": {}}'
|
||||
```
|
||||
|
||||
### Session Authentication
|
||||
|
||||
For browser-based access, use session cookies:
|
||||
|
||||
```javascript
|
||||
fetch('/mcp/tools/call', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tool: 'list_invoices',
|
||||
arguments: { limit: 10 }
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### MCP Session ID
|
||||
|
||||
For tracking dependencies across tool calls, include a session ID:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.example.com/mcp/tools/call \
|
||||
-H "Authorization: Bearer sk_live_xxxxx" \
|
||||
-H "X-MCP-Session-ID: session_abc123" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool": "update_task", "arguments": {...}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### Query Tools
|
||||
- `query_database` - Execute SQL queries
|
||||
- `list_tables` - List database tables
|
||||
|
||||
### Commerce Tools
|
||||
- `get_billing_status` - Get subscription status
|
||||
- `list_invoices` - List workspace invoices
|
||||
- `upgrade_plan` - Change subscription plan
|
||||
- `create_coupon` - Create discount codes
|
||||
|
||||
### System Tools
|
||||
- `list_sites` - List managed sites
|
||||
- `list_routes` - List application routes
|
||||
- `get_stats` - Get system statistics
|
||||
|
||||
---
|
||||
|
||||
## Response Format
|
||||
|
||||
All tools return JSON responses. Success responses vary by tool, but error responses follow a consistent format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Human-readable error message",
|
||||
"code": "ERROR_CODE",
|
||||
"details": {} // Optional additional information
|
||||
}
|
||||
```
|
||||
|
||||
**Common Error Codes:**
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `VALIDATION_ERROR` | Invalid parameters |
|
||||
| `FORBIDDEN_QUERY` | SQL query blocked by security |
|
||||
| `MISSING_WORKSPACE_CONTEXT` | Workspace authentication required |
|
||||
| `QUOTA_EXCEEDED` | Usage limit reached |
|
||||
| `NOT_FOUND` | Resource not found |
|
||||
| `DEPENDENCY_NOT_MET` | Tool prerequisites not satisfied |
|
||||
| `INTERNAL_ERROR` | Unexpected server error |
|
||||
|
||||
---
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Build custom tools
|
||||
- [SQL Security](/packages/mcp/sql-security) - Query security rules
|
||||
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
|
||||
- [Quotas](/packages/mcp/quotas) - Usage limits
|
||||
- [Analytics](/packages/mcp/analytics) - Usage tracking
|
||||
569
docs/tools.md
Normal file
569
docs/tools.md
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
# Creating MCP Tools
|
||||
|
||||
Learn how to create custom MCP tools for AI agents with parameter validation, dependency management, and workspace context.
|
||||
|
||||
## Tool Structure
|
||||
|
||||
Every MCP tool extends `BaseTool`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Core\Mcp\Tools\BaseTool;
|
||||
|
||||
class ListPostsTool extends BaseTool
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'blog:list-posts';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'List all blog posts with optional filters';
|
||||
}
|
||||
|
||||
public function getParameters(): array
|
||||
{
|
||||
return [
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Filter by status',
|
||||
'enum' => ['published', 'draft', 'archived'],
|
||||
'required' => false,
|
||||
],
|
||||
'limit' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Number of posts to return',
|
||||
'default' => 10,
|
||||
'min' => 1,
|
||||
'max' => 100,
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $params): array
|
||||
{
|
||||
$query = Post::query();
|
||||
|
||||
if (isset($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
$posts = $query->limit($params['limit'] ?? 10)->get();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'posts' => $posts->map(fn ($post) => [
|
||||
'id' => $post->id,
|
||||
'title' => $post->title,
|
||||
'slug' => $post->slug,
|
||||
'status' => $post->status,
|
||||
'created_at' => $post->created_at->toIso8601String(),
|
||||
])->toArray(),
|
||||
'count' => $posts->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Registering Tools
|
||||
|
||||
Register tools in your module's `Boot.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog;
|
||||
|
||||
use Core\Events\McpToolsRegistering;
|
||||
use Mod\Blog\Tools\ListPostsTool;
|
||||
use Mod\Blog\Tools\CreatePostTool;
|
||||
|
||||
class Boot
|
||||
{
|
||||
public static array $listens = [
|
||||
McpToolsRegistering::class => 'onMcpTools',
|
||||
];
|
||||
|
||||
public function onMcpTools(McpToolsRegistering $event): void
|
||||
{
|
||||
$event->tool('blog:list-posts', ListPostsTool::class);
|
||||
$event->tool('blog:create-post', CreatePostTool::class);
|
||||
$event->tool('blog:get-post', GetPostTool::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Parameter Validation
|
||||
|
||||
### Parameter Types
|
||||
|
||||
```php
|
||||
public function getParameters(): array
|
||||
{
|
||||
return [
|
||||
// String
|
||||
'title' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Post title',
|
||||
'minLength' => 1,
|
||||
'maxLength' => 255,
|
||||
'required' => true,
|
||||
],
|
||||
|
||||
// Integer
|
||||
'views' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Number of views',
|
||||
'min' => 0,
|
||||
'max' => 1000000,
|
||||
'required' => false,
|
||||
],
|
||||
|
||||
// Boolean
|
||||
'published' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Is published',
|
||||
'required' => false,
|
||||
],
|
||||
|
||||
// Enum
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'enum' => ['draft', 'published', 'archived'],
|
||||
'description' => 'Post status',
|
||||
'required' => true,
|
||||
],
|
||||
|
||||
// Array
|
||||
'tags' => [
|
||||
'type' => 'array',
|
||||
'description' => 'Post tags',
|
||||
'items' => ['type' => 'string'],
|
||||
'required' => false,
|
||||
],
|
||||
|
||||
// Object
|
||||
'metadata' => [
|
||||
'type' => 'object',
|
||||
'description' => 'Additional metadata',
|
||||
'properties' => [
|
||||
'featured' => ['type' => 'boolean'],
|
||||
'views' => ['type' => 'integer'],
|
||||
],
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Default Values
|
||||
|
||||
```php
|
||||
'limit' => [
|
||||
'type' => 'integer',
|
||||
'default' => 10, // Used if not provided
|
||||
'required' => false,
|
||||
]
|
||||
```
|
||||
|
||||
### Custom Validation
|
||||
|
||||
```php
|
||||
public function execute(array $params): array
|
||||
{
|
||||
// Additional validation
|
||||
if (isset($params['email']) && !filter_var($params['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid email address',
|
||||
'code' => 'INVALID_EMAIL',
|
||||
];
|
||||
}
|
||||
|
||||
// Tool logic...
|
||||
}
|
||||
```
|
||||
|
||||
## Workspace Context
|
||||
|
||||
### Requiring Workspace
|
||||
|
||||
Use the `RequiresWorkspaceContext` trait:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Core\Mcp\Tools\BaseTool;
|
||||
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||
|
||||
class CreatePostTool extends BaseTool
|
||||
{
|
||||
use RequiresWorkspaceContext;
|
||||
|
||||
public function execute(array $params): array
|
||||
{
|
||||
// Workspace automatically validated and available
|
||||
$workspace = $this->getWorkspaceContext();
|
||||
|
||||
$post = Post::create([
|
||||
'title' => $params['title'],
|
||||
'content' => $params['content'],
|
||||
'workspace_id' => $workspace->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'post_id' => $post->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optional Workspace
|
||||
|
||||
```php
|
||||
public function execute(array $params): array
|
||||
{
|
||||
$workspace = $this->getWorkspaceContext(); // May be null
|
||||
|
||||
$query = Post::query();
|
||||
|
||||
if ($workspace) {
|
||||
$query->where('workspace_id', $workspace->id);
|
||||
}
|
||||
|
||||
return ['posts' => $query->get()];
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Dependencies
|
||||
|
||||
### Declaring Dependencies
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Tools;
|
||||
|
||||
use Core\Mcp\Tools\BaseTool;
|
||||
use Core\Mcp\Dependencies\HasDependencies;
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
use Core\Mcp\Dependencies\DependencyType;
|
||||
|
||||
class ImportPostsTool extends BaseTool
|
||||
{
|
||||
use HasDependencies;
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [
|
||||
// Required dependency
|
||||
new ToolDependency(
|
||||
'blog:list-posts',
|
||||
DependencyType::REQUIRED
|
||||
),
|
||||
|
||||
// Optional dependency
|
||||
new ToolDependency(
|
||||
'media:upload',
|
||||
DependencyType::OPTIONAL
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $params): array
|
||||
{
|
||||
// Dependencies automatically validated before execution
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Types
|
||||
|
||||
- `DependencyType::REQUIRED` - Tool cannot execute without this
|
||||
- `DependencyType::OPTIONAL` - Tool works better with this but not required
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Standard Error Format
|
||||
|
||||
```php
|
||||
public function execute(array $params): array
|
||||
{
|
||||
try {
|
||||
// Tool logic...
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'code' => 'TOOL_EXECUTION_FAILED',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Specific Error Codes
|
||||
|
||||
```php
|
||||
// Validation error
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Title is required',
|
||||
'code' => 'VALIDATION_ERROR',
|
||||
'field' => 'title',
|
||||
];
|
||||
|
||||
// Not found
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Post not found',
|
||||
'code' => 'NOT_FOUND',
|
||||
'resource_id' => $params['id'],
|
||||
];
|
||||
|
||||
// Forbidden
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Insufficient permissions',
|
||||
'code' => 'FORBIDDEN',
|
||||
'required_permission' => 'posts.create',
|
||||
];
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Tool with File Processing
|
||||
|
||||
```php
|
||||
public function execute(array $params): array
|
||||
{
|
||||
$csvPath = $params['csv_path'];
|
||||
|
||||
if (!file_exists($csvPath)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'CSV file not found',
|
||||
'code' => 'FILE_NOT_FOUND',
|
||||
];
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
$errors = [];
|
||||
|
||||
if (($handle = fopen($csvPath, 'r')) !== false) {
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
try {
|
||||
Post::create([
|
||||
'title' => $data[0],
|
||||
'content' => $data[1],
|
||||
]);
|
||||
$imported++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Row {$imported}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'imported' => $imported,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Tool with Pagination
|
||||
|
||||
```php
|
||||
public function execute(array $params): array
|
||||
{
|
||||
$page = $params['page'] ?? 1;
|
||||
$perPage = $params['per_page'] ?? 15;
|
||||
|
||||
$posts = Post::paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'posts' => $posts->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $posts->currentPage(),
|
||||
'last_page' => $posts->lastPage(),
|
||||
'per_page' => $posts->perPage(),
|
||||
'total' => $posts->total(),
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Tool with Progress Tracking
|
||||
|
||||
```php
|
||||
public function execute(array $params): array
|
||||
{
|
||||
$postIds = $params['post_ids'];
|
||||
$total = count($postIds);
|
||||
$processed = 0;
|
||||
|
||||
foreach ($postIds as $postId) {
|
||||
$post = Post::find($postId);
|
||||
|
||||
if ($post) {
|
||||
$post->publish();
|
||||
$processed++;
|
||||
|
||||
// Emit progress event
|
||||
event(new ToolProgress(
|
||||
tool: $this->getName(),
|
||||
progress: ($processed / $total) * 100,
|
||||
message: "Published post {$postId}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'processed' => $processed,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Tools
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Mod\Blog\Tools\ListPostsTool;
|
||||
use Mod\Blog\Models\Post;
|
||||
|
||||
class ListPostsToolTest extends TestCase
|
||||
{
|
||||
public function test_lists_all_posts(): void
|
||||
{
|
||||
Post::factory()->count(5)->create();
|
||||
|
||||
$tool = new ListPostsTool();
|
||||
|
||||
$result = $tool->execute([]);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertCount(5, $result['posts']);
|
||||
}
|
||||
|
||||
public function test_filters_by_status(): void
|
||||
{
|
||||
Post::factory()->count(3)->create(['status' => 'published']);
|
||||
Post::factory()->count(2)->create(['status' => 'draft']);
|
||||
|
||||
$tool = new ListPostsTool();
|
||||
|
||||
$result = $tool->execute([
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
$this->assertCount(3, $result['posts']);
|
||||
}
|
||||
|
||||
public function test_respects_limit(): void
|
||||
{
|
||||
Post::factory()->count(20)->create();
|
||||
|
||||
$tool = new ListPostsTool();
|
||||
|
||||
$result = $tool->execute([
|
||||
'limit' => 5,
|
||||
]);
|
||||
|
||||
$this->assertCount(5, $result['posts']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Clear Naming
|
||||
|
||||
```php
|
||||
// ✅ Good - descriptive name
|
||||
'blog:create-post'
|
||||
'blog:list-published-posts'
|
||||
'blog:delete-post'
|
||||
|
||||
// ❌ Bad - vague name
|
||||
'blog:action'
|
||||
'do-thing'
|
||||
```
|
||||
|
||||
### 2. Detailed Descriptions
|
||||
|
||||
```php
|
||||
// ✅ Good - explains what and why
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create a new blog post with title, content, and optional metadata. '
|
||||
. 'Requires workspace context. Validates entitlements before creation.';
|
||||
}
|
||||
|
||||
// ❌ Bad - too brief
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Creates post';
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Validate Parameters
|
||||
|
||||
```php
|
||||
// ✅ Good - strict validation
|
||||
public function getParameters(): array
|
||||
{
|
||||
return [
|
||||
'title' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'minLength' => 1,
|
||||
'maxLength' => 255,
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Return Consistent Format
|
||||
|
||||
```php
|
||||
// ✅ Good - always includes success
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
];
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
'code' => $code,
|
||||
];
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Query Database →](/packages/mcp/query-database)
|
||||
- [Workspace Context →](/packages/mcp/workspace)
|
||||
- [Tool Analytics →](/packages/mcp/analytics)
|
||||
368
docs/workspace.md
Normal file
368
docs/workspace.md
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
# Workspace Context
|
||||
|
||||
Workspace isolation and context resolution for MCP tools.
|
||||
|
||||
## Overview
|
||||
|
||||
Workspace context ensures that MCP tools operate within the correct workspace boundary, preventing data leaks and unauthorized access.
|
||||
|
||||
## Context Resolution
|
||||
|
||||
### From Request Headers
|
||||
|
||||
```php
|
||||
use Core\Mcp\Context\WorkspaceContext;
|
||||
|
||||
// Resolve from X-Workspace-ID header
|
||||
$context = WorkspaceContext::fromRequest($request);
|
||||
|
||||
// Returns WorkspaceContext with:
|
||||
// - workspace: Workspace model
|
||||
// - user: Current user
|
||||
// - namespace: Current namespace (if applicable)
|
||||
```
|
||||
|
||||
**Request Example:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer sk_live_..." \
|
||||
-H "X-Workspace-ID: ws_abc123" \
|
||||
https://api.example.com/mcp/query
|
||||
```
|
||||
|
||||
### From API Key
|
||||
|
||||
```php
|
||||
use Mod\Api\Models\ApiKey;
|
||||
|
||||
$apiKey = ApiKey::findByKey($providedKey);
|
||||
|
||||
// API key is scoped to workspace
|
||||
$context = WorkspaceContext::fromApiKey($apiKey);
|
||||
```
|
||||
|
||||
### Manual Creation
|
||||
|
||||
```php
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
$workspace = Workspace::find($id);
|
||||
|
||||
$context = new WorkspaceContext(
|
||||
workspace: $workspace,
|
||||
user: $user,
|
||||
namespace: $namespace
|
||||
);
|
||||
```
|
||||
|
||||
## Requiring Context
|
||||
|
||||
### Tool Implementation
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Mcp\Tools;
|
||||
|
||||
use Core\Mcp\Tools\BaseTool;
|
||||
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||
|
||||
class ListPosts extends BaseTool
|
||||
{
|
||||
use RequiresWorkspaceContext;
|
||||
|
||||
public function execute(array $params): array
|
||||
{
|
||||
// Validates workspace context exists
|
||||
$this->validateWorkspaceContext();
|
||||
|
||||
// Access workspace
|
||||
$workspace = $this->workspaceContext->workspace;
|
||||
|
||||
// Query scoped to workspace
|
||||
return Post::where('workspace_id', $workspace->id)
|
||||
->where('status', $params['status'] ?? 'published')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
```php
|
||||
use Core\Mcp\Middleware\ValidateWorkspaceContext;
|
||||
|
||||
Route::middleware([ValidateWorkspaceContext::class])
|
||||
->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- Header `X-Workspace-ID` is present
|
||||
- Workspace exists
|
||||
- User has access to workspace
|
||||
- API key is scoped to workspace
|
||||
|
||||
## Automatic Query Scoping
|
||||
|
||||
### SELECT Queries
|
||||
|
||||
```php
|
||||
// Query without workspace filter
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts WHERE status = ?',
|
||||
'bindings' => ['published'],
|
||||
]);
|
||||
|
||||
// Automatically becomes:
|
||||
// SELECT * FROM posts
|
||||
// WHERE status = ?
|
||||
// AND workspace_id = ?
|
||||
// With bindings: ['published', $workspaceId]
|
||||
```
|
||||
|
||||
### BelongsToWorkspace Models
|
||||
|
||||
```php
|
||||
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
|
||||
|
||||
class Post extends Model
|
||||
{
|
||||
use BelongsToWorkspace;
|
||||
|
||||
// Automatically scoped to workspace
|
||||
}
|
||||
|
||||
// All queries automatically filtered:
|
||||
Post::all(); // Only current workspace's posts
|
||||
Post::where('status', 'published')->get(); // Scoped
|
||||
Post::find($id); // Returns null if wrong workspace
|
||||
```
|
||||
|
||||
## Context Properties
|
||||
|
||||
### Workspace
|
||||
|
||||
```php
|
||||
$workspace = $context->workspace;
|
||||
|
||||
$workspace->id; // Workspace ID
|
||||
$workspace->name; // Workspace name
|
||||
$workspace->slug; // URL slug
|
||||
$workspace->settings; // Workspace settings
|
||||
$workspace->subscription; // Subscription plan
|
||||
```
|
||||
|
||||
### User
|
||||
|
||||
```php
|
||||
$user = $context->user;
|
||||
|
||||
$user->id; // User ID
|
||||
$user->name; // User name
|
||||
$user->email; // User email
|
||||
$user->workspace_id; // Primary workspace
|
||||
$user->permissions; // User permissions
|
||||
```
|
||||
|
||||
### Namespace
|
||||
|
||||
```php
|
||||
$namespace = $context->namespace;
|
||||
|
||||
if ($namespace) {
|
||||
$namespace->id; // Namespace ID
|
||||
$namespace->name; // Namespace name
|
||||
$namespace->entitlements; // Feature access
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Workspace Access
|
||||
|
||||
### Switching Context
|
||||
|
||||
```php
|
||||
// User with access to multiple workspaces
|
||||
$workspaces = $user->workspaces;
|
||||
|
||||
foreach ($workspaces as $workspace) {
|
||||
$context = new WorkspaceContext($workspace, $user);
|
||||
|
||||
// Execute in workspace context
|
||||
$result = $tool->execute($params, $context);
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-Workspace Queries (Admin)
|
||||
|
||||
```php
|
||||
// Requires admin permission
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts',
|
||||
'bypass_workspace_scope' => true,
|
||||
], $context);
|
||||
|
||||
// Returns posts from all workspaces
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Missing Context
|
||||
|
||||
```php
|
||||
use Core\Mcp\Exceptions\MissingWorkspaceContextException;
|
||||
|
||||
try {
|
||||
$tool->execute($params); // No context provided
|
||||
} catch (MissingWorkspaceContextException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Workspace context required',
|
||||
'message' => 'Please provide X-Workspace-ID header',
|
||||
], 400);
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid Workspace
|
||||
|
||||
```php
|
||||
use Core\Mod\Tenant\Exceptions\WorkspaceNotFoundException;
|
||||
|
||||
try {
|
||||
$context = WorkspaceContext::fromRequest($request);
|
||||
} catch (WorkspaceNotFoundException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Invalid workspace',
|
||||
'message' => 'Workspace not found',
|
||||
], 404);
|
||||
}
|
||||
```
|
||||
|
||||
### Unauthorized Access
|
||||
|
||||
```php
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
try {
|
||||
$context = WorkspaceContext::fromRequest($request);
|
||||
} catch (AuthorizationException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized',
|
||||
'message' => 'You do not have access to this workspace',
|
||||
], 403);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```php
|
||||
use Tests\TestCase;
|
||||
use Core\Mcp\Context\WorkspaceContext;
|
||||
|
||||
class WorkspaceContextTest extends TestCase
|
||||
{
|
||||
public function test_resolves_from_header(): void
|
||||
{
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Workspace-ID' => $workspace->id,
|
||||
])->postJson('/mcp/query', [...]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_scopes_queries_to_workspace(): void
|
||||
{
|
||||
$workspace1 = Workspace::factory()->create();
|
||||
$workspace2 = Workspace::factory()->create();
|
||||
|
||||
Post::factory()->create(['workspace_id' => $workspace1->id]);
|
||||
Post::factory()->create(['workspace_id' => $workspace2->id]);
|
||||
|
||||
$context = new WorkspaceContext($workspace1);
|
||||
|
||||
$result = $tool->execute([
|
||||
'query' => 'SELECT * FROM posts',
|
||||
], $context);
|
||||
|
||||
$this->assertCount(1, $result['rows']);
|
||||
$this->assertEquals($workspace1->id, $result['rows'][0]['workspace_id']);
|
||||
}
|
||||
|
||||
public function test_throws_when_context_missing(): void
|
||||
{
|
||||
$this->expectException(MissingWorkspaceContextException::class);
|
||||
|
||||
$tool->execute(['query' => 'SELECT * FROM posts']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Validate Context
|
||||
|
||||
```php
|
||||
// ✅ Good - validate context
|
||||
public function execute(array $params)
|
||||
{
|
||||
$this->validateWorkspaceContext();
|
||||
// ...
|
||||
}
|
||||
|
||||
// ❌ Bad - no validation
|
||||
public function execute(array $params)
|
||||
{
|
||||
// Potential workspace bypass
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use BelongsToWorkspace Trait
|
||||
|
||||
```php
|
||||
// ✅ Good - automatic scoping
|
||||
class Post extends Model
|
||||
{
|
||||
use BelongsToWorkspace;
|
||||
}
|
||||
|
||||
// ❌ Bad - manual filtering
|
||||
Post::where('workspace_id', $workspace->id)->get();
|
||||
```
|
||||
|
||||
### 3. Provide Clear Errors
|
||||
|
||||
```php
|
||||
// ✅ Good - helpful error
|
||||
throw new MissingWorkspaceContextException(
|
||||
'Please provide X-Workspace-ID header'
|
||||
);
|
||||
|
||||
// ❌ Bad - generic error
|
||||
throw new Exception('Error');
|
||||
```
|
||||
|
||||
### 4. Test Context Isolation
|
||||
|
||||
```php
|
||||
// ✅ Good - test workspace boundaries
|
||||
public function test_cannot_access_other_workspace(): void
|
||||
{
|
||||
$workspace1 = Workspace::factory()->create();
|
||||
$workspace2 = Workspace::factory()->create();
|
||||
|
||||
$context = new WorkspaceContext($workspace1);
|
||||
|
||||
$post = Post::factory()->create(['workspace_id' => $workspace2->id]);
|
||||
|
||||
$result = Post::find($post->id); // Should be null
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Multi-Tenancy →](/packages/core/tenancy)
|
||||
- [Security →](/packages/mcp/security)
|
||||
- [Creating Tools →](/packages/mcp/tools)
|
||||
Loading…
Add table
Reference in a new issue