14 KiB
MCP Package
The MCP (Model Context Protocol) package provides AI-powered tools for integrating with Large Language Models. Build custom tools with workspace context security, SQL query validation, usage quotas, and analytics.
Installation
composer require host-uk/core-mcp
Features
Tool Registry
Automatically discover and register MCP tools:
<?php
namespace Mod\Blog\Mcp\Tools;
use Core\Mcp\Tool;
use Core\Mcp\Request;
use Core\Mcp\Response;
use Mod\Blog\Models\Post;
class GetPostTool extends Tool
{
public function name(): string
{
return 'blog_get_post';
}
public function description(): string
{
return 'Retrieve a blog post by ID or slug';
}
public function parameters(): array
{
return [
'post_id' => [
'type' => 'number',
'description' => 'The post ID',
'required' => false,
],
'slug' => [
'type' => 'string',
'description' => 'The post slug',
'required' => false,
],
];
}
public function handle(Request $request): Response
{
$post = $request->input('post_id')
? Post::findOrFail($request->input('post_id'))
: Post::where('slug', $request->input('slug'))->firstOrFail();
return Response::success([
'id' => $post->id,
'title' => $post->title,
'content' => $post->content,
'published_at' => $post->published_at,
]);
}
}
Register tools in your module:
public function onMcpTools(McpToolsRegistering $event): void
{
$event->tools([
GetPostTool::class,
CreatePostTool::class,
UpdatePostTool::class,
]);
}
Workspace Context Security
Enforce workspace context for multi-tenant safety:
<?php
namespace Mod\Blog\Mcp\Tools;
use Core\Mcp\Tool;
use Core\Mcp\Concerns\RequiresWorkspaceContext;
class ListPostsTool extends Tool
{
use RequiresWorkspaceContext;
public function handle(Request $request): Response
{
// Workspace context automatically validated
$workspace = $this->workspace();
// Queries automatically scoped to workspace
$posts = Post::latest()->limit(10)->get();
return Response::success([
'posts' => $posts->map(fn ($post) => [
'id' => $post->id,
'title' => $post->title,
'published_at' => $post->published_at,
]),
]);
}
}
If workspace context is missing or invalid, the tool automatically throws MissingWorkspaceContextException.
SQL Query Validation
Secure database querying with multi-layer validation:
<?php
namespace Core\Mcp\Tools;
use Core\Mcp\Tool;
use Core\Mcp\Request;
use Core\Mcp\Response;
use Core\Mcp\Services\SqlQueryValidator;
class QueryDatabaseTool extends Tool
{
use RequiresWorkspaceContext;
public function __construct(
private SqlQueryValidator $validator,
) {}
public function handle(Request $request): Response
{
$query = $request->input('query');
// Validate query against:
// - Blocked keywords (INSERT, UPDATE, DELETE, etc.)
// - Blocked tables (users, api_keys, etc.)
// - SQL injection patterns
// - Whitelist (if enabled)
$this->validator->validate($query);
$results = DB::connection('mcp_readonly')
->select($query);
return Response::success([
'rows' => $results,
'count' => count($results),
]);
}
}
Configuration:
// config/core-mcp.php
'database' => [
'validation' => [
'enabled' => true,
'blocked_keywords' => ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE'],
'blocked_tables' => ['users', 'api_keys', 'password_resets'],
'whitelist_enabled' => false,
],
],
EXPLAIN Query Analysis
Analyze query performance:
$tool = new QueryDatabaseTool();
$response = $tool->handle(new Request([
'query' => 'SELECT * FROM posts WHERE category_id = 1',
'explain' => true,
]));
// Returns:
[
'explain' => [
'type' => 'ref',
'possible_keys' => 'category_id_index',
'key' => 'category_id_index',
'rows' => 42,
],
'analysis' => [
'efficient' => true,
'warnings' => [],
'recommendations' => [
'Consider adding LIMIT clause for large result sets',
],
],
]
Tool Dependencies
Declare tool dependencies:
<?php
namespace Mod\Blog\Mcp\Tools;
use Core\Mcp\Tool;
use Core\Mcp\Dependencies\HasDependencies;
use Core\Mcp\Dependencies\ToolDependency;
class PublishPostTool extends Tool
{
use HasDependencies;
public function dependencies(): array
{
return [
ToolDependency::make('blog_get_post')
->description('Required to fetch post before publishing'),
ToolDependency::make('notifications_send')
->optional()
->description('Send notifications when post is published'),
];
}
public function handle(Request $request): Response
{
// Dependencies validated before execution
$post = $this->callTool('blog_get_post', [
'post_id' => $request->input('post_id'),
]);
$post->update(['published_at' => now()]);
// Optional dependency
if ($this->hasTool('notifications_send')) {
$this->callTool('notifications_send', [
'type' => 'post_published',
'post_id' => $post->id,
]);
}
return Response::success($post);
}
}
Usage Quotas
Per-workspace usage limits:
// config/core-mcp.php
'quotas' => [
'enabled' => true,
'tiers' => [
'free' => [
'daily_calls' => 100,
'monthly_calls' => 2000,
],
'pro' => [
'daily_calls' => 1000,
'monthly_calls' => 25000,
],
'enterprise' => [
'daily_calls' => null, // unlimited
'monthly_calls' => null,
],
],
],
Quota enforcement is automatic via middleware:
// Applied automatically to MCP routes
Route::middleware(CheckMcpQuota::class)->group(/*...*/);
Check quota status:
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
$usage = $quota->getUsage($workspace);
// ['daily' => 42, 'monthly' => 1250]
$remaining = $quota->getRemaining($workspace);
// ['daily' => 58, 'monthly' => 750]
$isExceeded = $quota->isExceeded($workspace);
// false
Tool Analytics
Track tool usage and performance:
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
// Get tool statistics
$stats = $analytics->getToolStats('blog_get_post', $workspace);
// ToolStats {
// total_calls: 1234,
// success_rate: 98.5,
// avg_duration_ms: 45.2,
// error_count: 19,
// }
// Get top tools
$topTools = $analytics->getTopTools($workspace, limit: 10);
// Get recent errors
$errors = $analytics->getRecentErrors($workspace, limit: 20);
View analytics in admin panel:
/admin/mcp/analytics
/admin/mcp/analytics/{tool}
MCP Playground
Interactive tool testing interface:
/admin/mcp/playground
Features:
- Tool browser with search
- Parameter editor with validation
- Real-time response preview
- Workspace context switcher
- Request history
Tool Patterns
Read-Only Tools
class GetPostsTool extends Tool
{
use RequiresWorkspaceContext;
public function handle(Request $request): Response
{
$posts = Post::query()
->when($request->input('category_id'), fn ($q, $id) =>
$q->where('category_id', $id)
)
->latest()
->limit($request->input('limit', 10))
->get();
return Response::success(['posts' => $posts]);
}
}
Mutation Tools
class CreatePostTool extends Tool
{
use RequiresWorkspaceContext;
public function parameters(): array
{
return [
'title' => ['type' => 'string', 'required' => true],
'content' => ['type' => 'string', 'required' => true],
'category_id' => ['type' => 'number', 'required' => false],
];
}
public function handle(Request $request): Response
{
$validated = $request->validate([
'title' => 'required|max:255',
'content' => 'required',
'category_id' => 'nullable|exists:categories,id',
]);
$post = Post::create($validated);
return Response::success($post);
}
}
Async Tools
class GeneratePostContentTool extends Tool
{
public function handle(Request $request): Response
{
// Queue long-running task
$job = GenerateContentJob::dispatch(
$request->input('topic'),
$request->input('style')
);
return Response::accepted([
'job_id' => $job->id,
'status_url' => route('api.jobs.status', $job->id),
]);
}
}
Error Handling
class GetPostTool extends Tool
{
public function handle(Request $request): Response
{
try {
$post = Post::findOrFail($request->input('post_id'));
return Response::success($post);
} catch (ModelNotFoundException $e) {
return Response::error(
'Post not found',
code: 'POST_NOT_FOUND',
status: 404
);
} catch (\Exception $e) {
return Response::error(
'Failed to fetch post',
code: 'INTERNAL_ERROR',
status: 500,
details: app()->environment('local') ? $e->getMessage() : null
);
}
}
}
Testing
Tool Tests
<?php
namespace Tests\Feature\Mcp;
use Tests\TestCase;
use Mod\Blog\Models\Post;
use Mod\Blog\Mcp\Tools\GetPostTool;
use Core\Mcp\Request;
class GetPostToolTest extends TestCase
{
public function test_retrieves_post_by_id(): void
{
$post = Post::factory()->create();
$tool = new GetPostTool();
$response = $tool->handle(new Request([
'post_id' => $post->id,
]));
$this->assertTrue($response->isSuccess());
$this->assertEquals($post->id, $response->data['id']);
}
public function test_requires_workspace_context(): void
{
$this->expectException(MissingWorkspaceContextException::class);
// No workspace context set
app()->forgetInstance('current.workspace');
$tool = new GetPostTool();
$tool->handle(new Request(['post_id' => 1]));
}
public function test_respects_workspace_isolation(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
$post = Post::factory()->for($workspace1)->create();
// Set context to workspace2
app()->instance('current.workspace', $workspace2);
$tool = new GetPostTool();
$response = $tool->handle(new Request([
'post_id' => $post->id,
]));
$this->assertTrue($response->isError());
$this->assertEquals(404, $response->status);
}
}
Configuration
// config/core-mcp.php
return [
'tools' => [
'auto_discover' => true,
'paths' => [
'Mod/*/Mcp/Tools',
'Core/Mcp/Tools',
],
],
'database' => [
'connection' => 'mcp_readonly',
'validation' => [
'enabled' => true,
'blocked_keywords' => ['INSERT', 'UPDATE', 'DELETE'],
'blocked_tables' => ['users', 'api_keys'],
],
],
'workspace_context' => [
'required' => true,
'validation' => [
'verify_existence' => true,
'check_suspension' => true,
],
],
'analytics' => [
'enabled' => true,
'retention_days' => 90,
],
'quotas' => [
'enabled' => true,
'tiers' => [/*...*/],
],
];
Artisan Commands
# List registered tools
php artisan mcp:tools
# Test tool execution
php artisan mcp:test blog_get_post --post_id=1
# Prune old metrics
php artisan mcp:prune-metrics --days=90
# Check quota usage
php artisan mcp:quota-status {workspace-id}
# Export tool definitions
php artisan mcp:export-tools --format=json
Best Practices
1. Use Workspace Context
// ✅ Good - workspace security
class ListPostsTool extends Tool
{
use RequiresWorkspaceContext;
}
// ❌ Bad - no workspace isolation
class ListPostsTool extends Tool { }
2. Validate SQL Queries
// ✅ Good - validated queries
$this->validator->validate($query);
DB::select($query);
// ❌ Bad - raw queries
DB::select($userInput); // SQL injection risk!
3. Use Read-Only Connections
// ✅ Good - read-only connection
DB::connection('mcp_readonly')->select($query);
// ❌ Bad - default connection with write access
DB::select($query);
4. Track Analytics
// ✅ Good - analytics tracked automatically
// Just implement the tool, framework handles tracking
// ❌ Bad - manual tracking (not needed)
5. Declare Dependencies
// ✅ Good - explicit dependencies
public function dependencies(): array
{
return [
ToolDependency::make('prerequisite_tool'),
];
}
Changelog
See CHANGELOG.md
License
EUPL-1.2