monorepo sepration

This commit is contained in:
Snider 2026-01-26 20:57:41 +00:00
parent 3ac43d834b
commit afb3dacd98
126 changed files with 26487 additions and 170 deletions

285
README.md
View file

@ -1,138 +1,203 @@
# Core PHP Framework Project
# Core MCP Package
[![CI](https://github.com/host-uk/core-template/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-template/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/host-uk/core-template/graph/badge.svg)](https://codecov.io/gh/host-uk/core-template)
[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-template)](https://packagist.org/packages/host-uk/core-template)
[![Laravel](https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel)](https://laravel.com)
[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE)
A modular monolith Laravel application built with Core PHP Framework.
## Features
- **Core Framework** - Event-driven module system with lazy loading
- **Admin Panel** - Livewire-powered admin interface with Flux UI
- **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs
- **MCP Tools** - Model Context Protocol for AI agent integration
## Requirements
- PHP 8.2+
- Composer 2.x
- SQLite (default) or MySQL/PostgreSQL
- Node.js 18+ (for frontend assets)
Model Context Protocol (MCP) tools and analytics for AI-powered automation and integrations.
## Installation
```bash
# Clone or create from template
git clone https://github.com/host-uk/core-template.git my-project
cd my-project
# Install dependencies
composer install
npm install
# Configure environment
cp .env.example .env
php artisan key:generate
# Set up database
touch database/database.sqlite
php artisan migrate
# Start development server
php artisan serve
composer require host-uk/core-mcp
```
Visit: http://localhost:8000
## Features
## Project Structure
```
app/
├── Console/ # Artisan commands
├── Http/ # Controllers & Middleware
├── Models/ # Eloquent models
├── Mod/ # Your custom modules
└── Providers/ # Service providers
config/
└── core.php # Core framework configuration
routes/
├── web.php # Public web routes
├── api.php # REST API routes
└── console.php # Artisan commands
```
## Creating Modules
```bash
# Create a new module with all features
php artisan make:mod Blog --all
# Create module with specific features
php artisan make:mod Shop --web --api --admin
```
Modules follow the event-driven pattern:
### MCP Tool Registry
Extensible tool system for AI integrations:
```php
<?php
use Core\Mcp\Tools\BaseTool;
namespace App\Mod\Blog;
use Core\Events\WebRoutesRegistering;
use Core\Events\ApiRoutesRegistering;
use Core\Events\AdminPanelBooting;
class Boot
class GetProductsTool extends BaseTool
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
ApiRoutesRegistering::class => 'onApiRoutes',
AdminPanelBooting::class => 'onAdminPanel',
public function name(): string
{
return 'get_products';
}
public function description(): string
{
return 'Retrieve a list of products from the workspace';
}
public function schema(JsonSchema $schema): array
{
return [
'limit' => $schema->integer('Maximum number of products to return'),
];
}
public function onWebRoutes(WebRoutesRegistering $event): void
public function handle(Request $request): Response
{
$event->routes(fn() => require __DIR__.'/Routes/web.php');
$event->views('blog', __DIR__.'/Views');
$products = Product::take($request->input('limit', 10))->get();
return Response::text(json_encode($products));
}
}
```
## Core Packages
### Workspace Context Security
Prevents cross-tenant data leakage:
| Package | Description |
|---------|-------------|
| `host-uk/core` | Core framework components |
| `host-uk/core-admin` | Admin panel & Livewire modals |
| `host-uk/core-api` | REST API with scopes & webhooks |
| `host-uk/core-mcp` | Model Context Protocol tools |
```php
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
## Flux Pro (Optional)
class MyTool extends BaseTool
{
use RequiresWorkspaceContext;
This template uses the free Flux UI components. If you have a Flux Pro license:
```bash
# Configure authentication
composer config http-basic.composer.fluxui.dev your-email your-license-key
# Add the repository
composer config repositories.flux-pro composer https://composer.fluxui.dev
# Install Flux Pro
composer require livewire/flux-pro
// Automatically validates workspace context
// Throws exception if context is missing
}
```
## Documentation
### SQL Query Validation
Multi-layer protection for database queries:
- [Core PHP Framework](https://github.com/host-uk/core-php)
- [Getting Started Guide](https://host-uk.github.io/core-php/guide/)
- [Architecture](https://host-uk.github.io/core-php/architecture/)
```php
use Core\Mcp\Services\SqlQueryValidator;
$validator = new SqlQueryValidator();
$validator->validate($query); // Throws if unsafe
// Features:
// - Blocked keywords (INSERT, UPDATE, DELETE, DROP)
// - Pattern detection (stacked queries, hex encoding)
// - Whitelist matching
// - Comment stripping
```
### Tool Analytics
Track tool usage and performance:
```php
use Core\Mcp\Services\ToolAnalyticsService;
$analytics = app(ToolAnalyticsService::class);
$stats = $analytics->getToolStats('get_products');
// Returns: calls, avg_duration, error_rate, etc.
```
**Admin dashboard:** `/admin/mcp/analytics`
### Tool Dependencies
Declare tool dependencies and validate at runtime:
```php
use Core\Mcp\Dependencies\{HasDependencies, ToolDependency};
class AdvancedTool extends BaseTool implements HasDependencies
{
public function dependencies(): array
{
return [
new ToolDependency('get_products', DependencyType::REQUIRED),
new ToolDependency('send_email', DependencyType::OPTIONAL),
];
}
}
```
### MCP Playground
Interactive UI for testing tools:
**Route:** `/admin/mcp/playground`
**Features:**
- Tool browser with search
- Dynamic form generation
- JSON response viewer
- Conversation history
- Example pre-fill
### Query EXPLAIN Analysis
Performance insights for database queries:
```json
{
"query": "SELECT * FROM users WHERE email = ?",
"explain": true
}
```
**Returns:**
- Raw EXPLAIN output
- Performance warnings
- Index usage analysis
- Optimization recommendations
### Usage Quotas
Workspace-level rate limiting:
```php
use Core\Mcp\Services\McpQuotaService;
$quota = app(McpQuotaService::class);
// Check if workspace can execute tool
if (!$quota->canExecute($workspace, 'expensive_tool')) {
throw new QuotaExceededException();
}
// Record execution
$quota->recordExecution($workspace, 'expensive_tool');
```
## Configuration
```php
// config/mcp.php
return [
'database' => [
'connection' => 'readonly', // Dedicated read-only connection
'use_whitelist' => true,
'blocked_tables' => ['users', 'api_keys'],
],
'analytics' => [
'enabled' => true,
'retention_days' => 90,
],
'quota' => [
'enabled' => true,
'default_limit' => 1000, // Per workspace per day
],
];
```
## Security
### Query Security (Defense in Depth)
1. **Read-only database user** (infrastructure)
2. **Blocked keywords** (application)
3. **Pattern validation** (application)
4. **Whitelist matching** (application)
5. **Table access controls** (application)
### Workspace Isolation
- Context MUST come from authentication
- Cross-tenant access prevented by design
- Tools throw exceptions without context
See [changelog/2026/jan/security.md](changelog/2026/jan/security.md) for security updates.
## Requirements
- PHP 8.2+
- Laravel 11+ or 12+
## Changelog
See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes.
## License
EUPL-1.2 (European Union Public Licence)
EUPL-1.2 - See [LICENSE](../../LICENSE) for details.

305
TODO.md Normal file
View file

@ -0,0 +1,305 @@
# Core-MCP TODO
## Testing & Quality Assurance
### High Priority
- [ ] **Test Coverage: SQL Query Validator** - Test injection prevention
- [ ] Test all forbidden SQL keywords (DROP, INSERT, UPDATE, DELETE, etc.)
- [ ] Test SQL injection attempts (UNION, boolean blinds, etc.)
- [ ] Test parameterized query validation
- [ ] Test subquery restrictions
- [ ] Test multi-statement detection
- **Estimated effort:** 4-5 hours
- [ ] **Test Coverage: Workspace Context** - Test isolation and validation
- [ ] Test WorkspaceContext resolution from headers
- [ ] Test automatic workspace scoping in queries
- [ ] Test MissingWorkspaceContextException
- [ ] Test workspace boundary enforcement
- [ ] Test cross-workspace query prevention
- **Estimated effort:** 3-4 hours
- [ ] **Test Coverage: Tool Analytics** - Test metrics tracking
- [ ] Test ToolAnalyticsService recording
- [ ] Test ToolStats DTO calculations
- [ ] Test performance percentiles (P95, P99)
- [ ] Test error rate calculations
- [ ] Test daily trend aggregation
- **Estimated effort:** 3-4 hours
- [ ] **Test Coverage: Quota System** - Test limits and enforcement
- [ ] Test McpQuotaService tier limits
- [ ] Test quota exceeded detection
- [ ] Test quota reset timing
- [ ] Test workspace-scoped quotas
- [ ] Test custom quota overrides
- **Estimated effort:** 3-4 hours
### Medium Priority
- [ ] **Test Coverage: Tool Dependencies** - Test dependency validation
- [ ] Test ToolDependencyService resolution
- [ ] Test MissingDependencyException
- [ ] Test circular dependency detection
- [ ] Test version compatibility checking
- **Estimated effort:** 2-3 hours
- [ ] **Test Coverage: Query Database Tool** - Test complete workflow
- [ ] Test SELECT query execution
- [ ] Test EXPLAIN plan analysis
- [ ] Test connection validation
- [ ] Test result formatting
- [ ] Test error handling
- **Estimated effort:** 3-4 hours
### Low Priority
- [ ] **Test Coverage: Tool Registry** - Test tool registration
- [ ] Test AgentToolRegistry with multiple tools
- [ ] Test tool discovery
- [ ] Test tool metadata
- **Estimated effort:** 2-3 hours
## Security (Critical)
### High Priority - Security Fixes Needed
- [x] **COMPLETED: Database Connection Fallback** - Throw exception instead of fallback
- [x] Fixed to throw ForbiddenConnectionException
- [x] No silent fallback to default connection
- [x] Prevents accidental production data exposure
- **Completed:** January 2026
- [x] **COMPLETED: SQL Validator Regex Strengthening** - Stricter WHERE clause validation
- [x] Replaced permissive `.+` with restrictive character classes
- [x] Added explicit structure validation
- [x] Better detection of injection attempts
- **Completed:** January 2026
### Medium Priority - Additional Security
- [ ] **Security: Query Result Size Limits** - Prevent data exfiltration
- [ ] Add max_rows configuration per tier
- [ ] Enforce result set limits
- [ ] Return truncation warnings
- [ ] Test with large result sets
- **Estimated effort:** 2-3 hours
- [ ] **Security: Query Timeout Enforcement** - Prevent resource exhaustion
- [ ] Add per-query timeout configuration
- [ ] Kill long-running queries
- [ ] Log slow query attempts
- [ ] Test with expensive queries
- **Estimated effort:** 2-3 hours
- [ ] **Security: Audit Logging** - Complete query audit trail
- [ ] Log all query attempts (success and failure)
- [ ] Include user, workspace, query, and bindings
- [ ] Add tamper-proof logging
- [ ] Implement log retention policy
- **Estimated effort:** 3-4 hours
## Features & Enhancements
### High Priority
- [x] **COMPLETED: EXPLAIN Plan Analysis** - Query optimization insights
- [x] Added `explain` parameter to QueryDatabase tool
- [x] Returns human-readable performance analysis
- [x] Shows index usage and optimization opportunities
- **Completed:** January 2026
- [ ] **Feature: Query Templates** - Reusable parameterized queries
- [ ] Create query template system
- [ ] Support named parameters
- [ ] Add template validation
- [ ] Store templates per workspace
- [ ] Test with complex queries
- **Estimated effort:** 5-6 hours
- **Files:** `src/Mod/Mcp/Templates/`
- [ ] **Feature: Schema Exploration Tools** - Database metadata access
- [ ] Add ListTables tool
- [ ] Add DescribeTable tool
- [ ] Add ListIndexes tool
- [ ] Respect information_schema restrictions
- [ ] Test with multiple database types
- **Estimated effort:** 4-5 hours
- **Files:** `src/Mod/Mcp/Tools/Schema/`
### Medium Priority
- [ ] **Enhancement: Query Result Caching** - Cache frequent queries
- [ ] Implement result caching with TTL
- [ ] Add cache key generation
- [ ] Support cache invalidation
- [ ] Test cache hit rates
- **Estimated effort:** 3-4 hours
- [ ] **Enhancement: Query History** - Track agent queries
- [ ] Store query history per workspace
- [ ] Add query rerun capability
- [ ] Create history browser UI
- [ ] Add favorite queries
- **Estimated effort:** 4-5 hours
- **Files:** `src/Mod/Mcp/History/`
- [ ] **Enhancement: Advanced Analytics** - Deeper insights
- [ ] Add query complexity scoring
- [ ] Track table access patterns
- [ ] Identify slow query patterns
- [ ] Create optimization recommendations
- **Estimated effort:** 5-6 hours
- **Files:** `src/Mod/Mcp/Analytics/`
### Low Priority
- [ ] **Enhancement: Multi-Database Support** - Query multiple databases
- [ ] Support cross-database queries
- [ ] Add database selection parameter
- [ ] Test with MySQL, PostgreSQL, SQLite
- **Estimated effort:** 4-5 hours
- [ ] **Enhancement: Query Builder UI** - Visual query construction
- [ ] Create Livewire query builder component
- [ ] Add table/column selection
- [ ] Support WHERE clause builder
- [ ] Generate safe SQL
- **Estimated effort:** 8-10 hours
- **Files:** `src/Mod/Mcp/QueryBuilder/`
## Tool Development
### High Priority
- [ ] **Tool: Create/Update Records** - Controlled data modification
- [ ] Create InsertRecord tool with strict validation
- [ ] Create UpdateRecord tool with WHERE requirements
- [ ] Implement record-level permissions
- [ ] Require explicit confirmation for modifications
- [ ] Test with workspace scoping
- **Estimated effort:** 6-8 hours
- **Files:** `src/Mod/Mcp/Tools/Modify/`
- **Note:** Requires careful security review
- [ ] **Tool: Export Data** - Export query results
- [ ] Add ExportResults tool
- [ ] Support CSV, JSON, Excel formats
- [ ] Add row limits per tier
- [ ] Implement streaming for large exports
- **Estimated effort:** 4-5 hours
- **Files:** `src/Mod/Mcp/Tools/Export/`
### Medium Priority
- [ ] **Tool: Analyze Performance** - Database health insights
- [ ] Add TableStats tool (row count, size, etc.)
- [ ] Add SlowQueries tool
- [ ] Add IndexUsage tool
- [ ] Create performance dashboard
- **Estimated effort:** 5-6 hours
- **Files:** `src/Mod/Mcp/Tools/Performance/`
- [ ] **Tool: Data Validation** - Validate data quality
- [ ] Add ValidateData tool
- [ ] Check for NULL values, duplicates
- [ ] Validate foreign key integrity
- [ ] Generate data quality report
- **Estimated effort:** 4-5 hours
- **Files:** `src/Mod/Mcp/Tools/Validation/`
## Documentation
- [x] **Guide: Creating MCP Tools** - Comprehensive tutorial
- [x] Document tool interface
- [x] Show parameter validation
- [x] Explain workspace context
- [x] Add dependency examples
- [x] Include security best practices
- **Completed:** January 2026
- **File:** `docs/packages/mcp/creating-mcp-tools.md`
- [x] **Guide: SQL Security** - Safe query patterns
- [x] Document allowed SQL patterns
- [x] Show parameterized query examples
- [x] Explain validation rules
- [x] List forbidden operations
- **Completed:** January 2026
- **File:** `docs/packages/mcp/sql-security.md`
- [x] **API Reference: All MCP Tools** - Complete tool catalog
- [x] Document each tool's parameters
- [x] Add usage examples
- [x] Show response formats
- [x] Include error cases
- **Completed:** January 2026
- **File:** `docs/packages/mcp/tools-reference.md`
## Code Quality
- [ ] **Refactor: Extract SQL Parser** - Better query validation
- [ ] Create proper SQL parser
- [ ] Replace regex with AST parsing
- [ ] Support dialect-specific syntax
- [ ] Add comprehensive tests
- **Estimated effort:** 8-10 hours
- [ ] **Refactor: Standardize Tool Responses** - Consistent API
- [ ] Create ToolResult DTO
- [ ] Standardize error responses
- [ ] Add response metadata
- [ ] Update all tools
- **Estimated effort:** 3-4 hours
- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety
- [ ] Fix property type declarations
- [ ] Add missing return types
- [ ] Fix array shape types
- **Estimated effort:** 2-3 hours
## Performance
- [ ] **Optimization: Query Result Streaming** - Handle large results
- [ ] Implement cursor-based result streaming
- [ ] Add chunked response delivery
- [ ] Test with millions of rows
- **Estimated effort:** 3-4 hours
- [ ] **Optimization: Connection Pooling** - Reuse database connections
- [ ] Implement connection pool
- [ ] Add connection health checks
- [ ] Test connection lifecycle
- **Estimated effort:** 3-4 hours
## Infrastructure
- [ ] **Monitoring: Alert on Suspicious Queries** - Security monitoring
- [ ] Detect unusual query patterns
- [ ] Alert on potential injection attempts
- [ ] Track query anomalies
- [ ] Create security dashboard
- **Estimated effort:** 4-5 hours
- [ ] **CI/CD: Add Security Regression Tests** - Prevent vulnerabilities
- [ ] Test SQL injection prevention
- [ ] Test workspace isolation
- [ ] Test quota enforcement
- [ ] Fail CI on security issues
- **Estimated effort:** 3-4 hours
---
## Completed (January 2026)
- [x] **Security: Database Connection Validation** - Throws exception for invalid connections
- [x] **Security: SQL Validator Strengthening** - Stricter WHERE clause patterns
- [x] **Feature: EXPLAIN Plan Analysis** - Query optimization insights
- [x] **Tool Analytics System** - Complete usage tracking and metrics
- [x] **Quota System** - Tier-based limits with enforcement
- [x] **Workspace Context** - Automatic query scoping and validation
- [x] **Documentation: Creating MCP Tools Guide** - Complete tutorial with workspace context, dependencies, security
- [x] **Documentation: SQL Security Guide** - Allowed patterns, forbidden operations, injection prevention
- [x] **Documentation: MCP Tools API Reference** - All tools with parameters, examples, error handling
*See `changelog/2026/jan/` for completed features and security fixes.*

View file

@ -0,0 +1,121 @@
# Core-MCP - January 2026
## Features Implemented
### Workspace Context Security
Prevents cross-tenant data leakage by requiring authenticated workspace context.
**Files:**
- `Exceptions/MissingWorkspaceContextException.php`
- `Context/WorkspaceContext.php` - Value object
- `Tools/Concerns/RequiresWorkspaceContext.php` - Tool trait
- `Middleware/ValidateWorkspaceContext.php`
**Security Guarantees:**
- Workspace context MUST come from authentication
- Cross-tenant access prevented by design
- Tools throw exceptions when called without context
---
### Query Security
Defence in depth for SQL injection prevention.
**Files:**
- `Exceptions/ForbiddenQueryException.php`
- `Services/SqlQueryValidator.php` - Multi-layer validation
**Features:**
- Blocked keywords: INSERT, UPDATE, DELETE, DROP, UNION
- Pattern detection: stacked queries, hex encoding, SLEEP/BENCHMARK
- Comment stripping to prevent obfuscation
- Query whitelist matching
- Read-only database connection support
**Config:** `mcp.database.connection`, `mcp.database.use_whitelist`, `mcp.database.blocked_tables`
---
### MCP Playground UI
Interactive interface for testing MCP tools.
**Files:**
- `Services/ToolRegistry.php` - Tool discovery and schemas
- `View/Modal/Admin/McpPlayground.php` - Livewire component
- `View/Blade/admin/mcp-playground.blade.php`
**Features:**
- Tool browser with search and category filtering
- Dynamic form builder from JSON schemas
- JSON response viewer with syntax highlighting
- Conversation history (last 50 executions)
- Example input pre-fill
- API key validation
**Route:** `GET /admin/mcp/playground`
---
### Tool Usage Analytics
Usage tracking and dashboard for MCP tools.
**Files:**
- `Migrations/2026_01_26_*` - mcp_tool_metrics, mcp_tool_combinations
- `Models/ToolMetric.php`
- `DTO/ToolStats.php`
- `Services/ToolAnalyticsService.php`
- `Events/ToolExecuted.php`
- `Listeners/RecordToolExecution.php`
- `View/Modal/Admin/ToolAnalyticsDashboard.php`
- `View/Modal/Admin/ToolAnalyticsDetail.php`
- `Console/Commands/PruneMetricsCommand.php`
**Features:**
- Per-tool call counts with daily granularity
- Average, min, max response times
- Error rates with threshold highlighting
- Tool combination tracking
- Admin dashboard with sortable tables
- Date range filtering
**Routes:**
- `GET /admin/mcp/analytics` - Dashboard
- `GET /admin/mcp/analytics/tool/{name}` - Tool detail
**Config:** `mcp.analytics.enabled`, `mcp.analytics.retention_days`
---
### EXPLAIN Query Analysis
Query optimization insights with automated performance analysis.
**Files:**
- `Tools/QueryDatabase.php` - Added `explain` parameter
- Enhanced with human-readable performance interpretation
**Features:**
- Optional EXPLAIN execution before query runs
- Detects full table scans
- Identifies missing indexes
- Warns about filesort and temporary tables
- Shows row count estimates
- Includes MySQL warnings when available
**Usage:**
```json
{
"query": "SELECT * FROM users WHERE email = 'test@example.com'",
"explain": true
}
```
**Response includes:**
- Raw EXPLAIN output
- Performance warnings (full scans, high row counts)
- Index usage analysis
- Optimization recommendations

View file

@ -0,0 +1,52 @@
# Core-MCP - January 2026 - Security Fixes
## Critical
### Database Connection Validation
Fixed fallback behavior that could bypass read-only connection configuration.
**Issue:** QueryDatabase tool would silently fall back to default database connection if configured MCP connection was invalid.
**Fix:** Now throws `RuntimeException` with clear error message when configured connection doesn't exist.
**Files:**
- `Tools/QueryDatabase.php` - Added connection validation
**Impact:** Prevents accidental queries against production read-write connections.
---
## High Priority
### SQL Query Validator Strengthening
Restricted WHERE clause patterns to prevent SQL injection vectors.
**Issue:** Whitelist regex patterns used `.+` which was too permissive for WHERE clause validation.
**Fix:** Replaced with strict character class restrictions:
- Only allows: alphanumeric, spaces, backticks, operators, quotes, parentheses
- Explicitly supports AND/OR logical operators
- Blocks function calls and subqueries
- Prevents nested SELECT statements
**Files:**
- `Services/SqlQueryValidator.php` - Updated DEFAULT_WHITELIST patterns
**Before:**
```php
'/^\s*SELECT\s+.*\s+FROM\s+`?\w+`?(\s+WHERE\s+.+)?/i'
```
**After:**
```php
'/^\s*SELECT\s+.*\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?/i'
```
**Defense in depth:**
- Read-only database user (infrastructure)
- Blocked keywords (application)
- Pattern validation (application)
- Whitelist matching (application)
- Table access controls (application)

View file

@ -1,76 +1,26 @@
{
"name": "host-uk/core-template",
"type": "project",
"description": "Core PHP Framework - Project Template",
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
"name": "host-uk/core-mcp",
"description": "MCP (Model Context Protocol) tools module for Core PHP framework",
"keywords": ["laravel", "mcp", "ai", "tools", "claude"],
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10",
"livewire/flux": "^2.0",
"livewire/livewire": "^3.0",
"host-uk/core": "dev-main",
"host-uk/core-admin": "dev-main",
"host-uk/core-api": "dev-main",
"host-uk/core-mcp": "dev-main"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0"
"host-uk/core": "@dev"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
"Core\\Mod\\Mcp\\": "src/Mod/Mcp/",
"Core\\Website\\Mcp\\": "src/Website/Mcp/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
"Core\\Mod\\Mcp\\Tests\\": "tests/"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/host-uk/core-php.git"
}
],
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
"providers": []
}
},
"minimum-stability": "stable",

98
src/Mod/Mcp/Boot.php Normal file
View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp;
use Core\Events\AdminPanelBooting;
use Core\Events\ConsoleBooting;
use Core\Events\McpToolsRegistering;
use Core\Mod\Mcp\Events\ToolExecuted;
use Core\Mod\Mcp\Listeners\RecordToolExecution;
use Core\Mod\Mcp\Services\AuditLogService;
use Core\Mod\Mcp\Services\McpQuotaService;
use Core\Mod\Mcp\Services\ToolAnalyticsService;
use Core\Mod\Mcp\Services\ToolDependencyService;
use Core\Mod\Mcp\Services\ToolRegistry;
use Core\Mod\Mcp\Services\ToolVersionService;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class Boot extends ServiceProvider
{
/**
* The module name.
*/
protected string $moduleName = 'mcp';
/**
* Events this module listens to for lazy loading.
*
* @var array<class-string, string>
*/
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
ConsoleBooting::class => 'onConsole',
McpToolsRegistering::class => 'onMcpTools',
];
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(ToolRegistry::class);
$this->app->singleton(ToolAnalyticsService::class);
$this->app->singleton(McpQuotaService::class);
$this->app->singleton(ToolDependencyService::class);
$this->app->singleton(AuditLogService::class);
$this->app->singleton(ToolVersionService::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/Migrations');
// Register event listener for tool execution analytics
Event::listen(ToolExecuted::class, RecordToolExecution::class);
}
// -------------------------------------------------------------------------
// Event-driven handlers
// -------------------------------------------------------------------------
public function onAdminPanel(AdminPanelBooting $event): void
{
$event->views($this->moduleName, __DIR__.'/View/Blade');
if (file_exists(__DIR__.'/Routes/admin.php')) {
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
}
$event->livewire('mcp.admin.api-key-manager', View\Modal\Admin\ApiKeyManager::class);
$event->livewire('mcp.admin.playground', View\Modal\Admin\Playground::class);
$event->livewire('mcp.admin.mcp-playground', View\Modal\Admin\McpPlayground::class);
$event->livewire('mcp.admin.request-log', View\Modal\Admin\RequestLog::class);
$event->livewire('mcp.admin.tool-analytics-dashboard', View\Modal\Admin\ToolAnalyticsDashboard::class);
$event->livewire('mcp.admin.tool-analytics-detail', View\Modal\Admin\ToolAnalyticsDetail::class);
$event->livewire('mcp.admin.quota-usage', View\Modal\Admin\QuotaUsage::class);
$event->livewire('mcp.admin.audit-log-viewer', View\Modal\Admin\AuditLogViewer::class);
$event->livewire('mcp.admin.tool-version-manager', View\Modal\Admin\ToolVersionManager::class);
}
public function onConsole(ConsoleBooting $event): void
{
$event->command(Console\Commands\McpAgentServerCommand::class);
$event->command(Console\Commands\PruneMetricsCommand::class);
$event->command(Console\Commands\VerifyAuditLogCommand::class);
}
public function onMcpTools(McpToolsRegistering $event): void
{
// MCP tool handlers will be registered here once extracted
// from the monolithic McpAgentServerCommand
}
}

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Console\Commands;
use Illuminate\Console\Command;
use Mod\Mcp\Models\McpApiRequest;
use Mod\Mcp\Models\McpToolCall;
use Mod\Mcp\Models\McpToolCallStat;
/**
* Cleanup old MCP tool call logs and API request logs.
*
* Prunes records older than the configured retention period to prevent
* unbounded table growth. Aggregated statistics are retained longer
* than detailed logs.
*/
class CleanupToolCallLogsCommand extends Command
{
protected $signature = 'mcp:cleanup-logs
{--days= : Override the default retention period for detailed logs}
{--stats-days= : Override the default retention period for statistics}
{--dry-run : Show what would be deleted without actually deleting}';
protected $description = 'Clean up old MCP tool call logs and API request logs';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$logRetentionDays = (int) ($this->option('days') ?? config('mcp.log_retention.days', 90));
$statsRetentionDays = (int) ($this->option('stats-days') ?? config('mcp.log_retention.stats_days', 365));
$this->info('MCP Log Cleanup'.($dryRun ? ' (DRY RUN)' : ''));
$this->line('');
$this->line("Detailed logs retention: {$logRetentionDays} days");
$this->line("Statistics retention: {$statsRetentionDays} days");
$this->line('');
$logsCutoff = now()->subDays($logRetentionDays);
$statsCutoff = now()->subDays($statsRetentionDays);
// Clean up tool call logs
$toolCallsCount = McpToolCall::where('created_at', '<', $logsCutoff)->count();
if ($toolCallsCount > 0) {
if ($dryRun) {
$this->line("Would delete {$toolCallsCount} tool call log(s) older than {$logsCutoff->toDateString()}");
} else {
// Delete in chunks to avoid memory issues and lock contention
$deleted = $this->deleteInChunks(McpToolCall::class, 'created_at', $logsCutoff);
$this->info("Deleted {$deleted} tool call log(s)");
}
} else {
$this->line('No tool call logs to clean up');
}
// Clean up API request logs
$apiRequestsCount = McpApiRequest::where('created_at', '<', $logsCutoff)->count();
if ($apiRequestsCount > 0) {
if ($dryRun) {
$this->line("Would delete {$apiRequestsCount} API request log(s) older than {$logsCutoff->toDateString()}");
} else {
$deleted = $this->deleteInChunks(McpApiRequest::class, 'created_at', $logsCutoff);
$this->info("Deleted {$deleted} API request log(s)");
}
} else {
$this->line('No API request logs to clean up');
}
// Clean up aggregated statistics (longer retention)
$statsCount = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->count();
if ($statsCount > 0) {
if ($dryRun) {
$this->line("Would delete {$statsCount} tool call stat(s) older than {$statsCutoff->toDateString()}");
} else {
$deleted = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->delete();
$this->info("Deleted {$deleted} tool call stat(s)");
}
} else {
$this->line('No tool call stats to clean up');
}
$this->line('');
$this->info('Cleanup complete.');
return self::SUCCESS;
}
/**
* Delete records in chunks to avoid memory issues.
*/
protected function deleteInChunks(string $model, string $column, \DateTimeInterface $cutoff, int $chunkSize = 1000): int
{
$totalDeleted = 0;
do {
$deleted = $model::where($column, '<', $cutoff)
->limit($chunkSize)
->delete();
$totalDeleted += $deleted;
// Small pause to reduce database pressure
if ($deleted > 0) {
usleep(10000); // 10ms
}
} while ($deleted > 0);
return $totalDeleted;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Console\Commands;
use Illuminate\Console\Command;
use Mod\Mcp\Services\McpMetricsService;
use Mod\Mcp\Services\McpMonitoringService;
/**
* MCP Monitor Command.
*
* Provides CLI access to MCP monitoring features including
* health checks, metrics export, and alert checking.
*/
class McpMonitorCommand extends Command
{
protected $signature = 'mcp:monitor
{action=status : Action to perform (status, alerts, export, report, prometheus)}
{--days=7 : Number of days for report period}
{--json : Output as JSON}';
protected $description = 'Monitor MCP tool performance and health';
public function handle(McpMetricsService $metrics, McpMonitoringService $monitoring): int
{
$action = $this->argument('action');
return match ($action) {
'status' => $this->showStatus($monitoring),
'alerts' => $this->checkAlerts($monitoring),
'export' => $this->exportMetrics($monitoring),
'report' => $this->showReport($monitoring),
'prometheus' => $this->showPrometheus($monitoring),
default => $this->showHelp(),
};
}
protected function showStatus(McpMonitoringService $monitoring): int
{
$health = $monitoring->getHealthStatus();
if ($this->option('json')) {
$this->line(json_encode($health, JSON_PRETTY_PRINT));
return 0;
}
$statusColor = match ($health['status']) {
'healthy' => 'green',
'degraded' => 'yellow',
'critical' => 'red',
default => 'white',
};
$this->newLine();
$this->line("<fg={$statusColor};options=bold>MCP Health Status: ".strtoupper($health['status']).'</>');
$this->newLine();
$this->table(
['Metric', 'Value'],
[
['Total Calls (24h)', number_format($health['metrics']['total_calls'])],
['Success Rate', $health['metrics']['success_rate'].'%'],
['Error Rate', $health['metrics']['error_rate'].'%'],
['Avg Duration', $health['metrics']['avg_duration_ms'].'ms'],
]
);
if (count($health['issues']) > 0) {
$this->newLine();
$this->warn('Issues Detected:');
foreach ($health['issues'] as $issue) {
$icon = $issue['severity'] === 'critical' ? '!!' : '!';
$this->line(" [{$icon}] {$issue['message']}");
}
}
$this->newLine();
$this->line('<fg=gray>Checked at: '.$health['checked_at'].'</>');
return $health['status'] === 'critical' ? 1 : 0;
}
protected function checkAlerts(McpMonitoringService $monitoring): int
{
$alerts = $monitoring->checkAlerts();
if ($this->option('json')) {
$this->line(json_encode($alerts, JSON_PRETTY_PRINT));
return count($alerts) > 0 ? 1 : 0;
}
if (count($alerts) === 0) {
$this->info('No alerts detected.');
return 0;
}
$this->warn(count($alerts).' alert(s) detected:');
$this->newLine();
foreach ($alerts as $alert) {
$severityColor = $alert['severity'] === 'critical' ? 'red' : 'yellow';
$this->line("<fg={$severityColor}>[{$alert['severity']}]</> {$alert['message']}");
}
return 1;
}
protected function exportMetrics(McpMonitoringService $monitoring): int
{
$monitoring->exportMetrics();
$this->info('Metrics exported to monitoring channel.');
return 0;
}
protected function showReport(McpMonitoringService $monitoring): int
{
$days = (int) $this->option('days');
$report = $monitoring->getSummaryReport($days);
if ($this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT));
return 0;
}
$this->newLine();
$this->line("<options=bold>MCP Summary Report ({$days} days)</>");
$this->line("Period: {$report['period']['from']} to {$report['period']['to']}");
$this->newLine();
// Overview
$this->line('<fg=cyan>Overview:</>');
$this->table(
['Metric', 'Value'],
[
['Total Calls', number_format($report['overview']['total_calls'])],
['Success Rate', $report['overview']['success_rate'].'%'],
['Avg Duration', $report['overview']['avg_duration_ms'].'ms'],
['Unique Tools', $report['overview']['unique_tools']],
['Unique Servers', $report['overview']['unique_servers']],
]
);
// Top tools
if (count($report['top_tools']) > 0) {
$this->newLine();
$this->line('<fg=cyan>Top Tools:</>');
$toolRows = [];
foreach ($report['top_tools'] as $tool) {
$toolRows[] = [
$tool->tool_name,
number_format($tool->total_calls),
$tool->success_rate.'%',
round($tool->avg_duration ?? 0).'ms',
];
}
$this->table(['Tool', 'Calls', 'Success Rate', 'Avg Duration'], $toolRows);
}
// Anomalies
if (count($report['anomalies']) > 0) {
$this->newLine();
$this->warn('Anomalies Detected:');
foreach ($report['anomalies'] as $anomaly) {
$this->line(" - [{$anomaly['tool']}] {$anomaly['message']}");
}
}
$this->newLine();
$this->line('<fg=gray>Generated: '.$report['generated_at'].'</>');
return 0;
}
protected function showPrometheus(McpMonitoringService $monitoring): int
{
$metrics = $monitoring->getPrometheusMetrics();
$this->line($metrics);
return 0;
}
protected function showHelp(): int
{
$this->error('Unknown action. Available actions: status, alerts, export, report, prometheus');
return 1;
}
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Console\Commands;
use Core\Mod\Mcp\Models\ToolMetric;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Prune old MCP tool metrics data.
*
* Deletes metrics records older than the configured retention period
* to prevent unbounded database growth.
*/
class PruneMetricsCommand extends Command
{
protected $signature = 'mcp:prune-metrics
{--days= : Override the default retention period (in days)}
{--dry-run : Show what would be deleted without actually deleting}';
protected $description = 'Delete MCP tool metrics older than the retention period';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$retentionDays = (int) ($this->option('days') ?? config('mcp.analytics.retention_days', 90));
$this->info('MCP Metrics Pruning'.($dryRun ? ' (DRY RUN)' : ''));
$this->line('');
$this->line("Retention period: {$retentionDays} days");
$this->line('');
$cutoffDate = now()->subDays($retentionDays)->toDateString();
// Prune tool metrics
$metricsCount = ToolMetric::where('date', '<', $cutoffDate)->count();
if ($metricsCount > 0) {
if ($dryRun) {
$this->line("Would delete {$metricsCount} tool metric record(s) older than {$cutoffDate}");
} else {
$deleted = $this->deleteInChunks(ToolMetric::class, 'date', $cutoffDate);
$this->info("Deleted {$deleted} tool metric record(s)");
}
} else {
$this->line('No tool metrics to prune');
}
// Prune tool combinations
$combinationsCount = DB::table('mcp_tool_combinations')
->where('date', '<', $cutoffDate)
->count();
if ($combinationsCount > 0) {
if ($dryRun) {
$this->line("Would delete {$combinationsCount} tool combination record(s) older than {$cutoffDate}");
} else {
$deleted = DB::table('mcp_tool_combinations')
->where('date', '<', $cutoffDate)
->delete();
$this->info("Deleted {$deleted} tool combination record(s)");
}
} else {
$this->line('No tool combinations to prune');
}
$this->line('');
$this->info('Pruning complete.');
return self::SUCCESS;
}
/**
* Delete records in chunks to avoid memory issues.
*/
protected function deleteInChunks(string $model, string $column, string $cutoff, int $chunkSize = 1000): int
{
$totalDeleted = 0;
do {
$deleted = $model::where($column, '<', $cutoff)
->limit($chunkSize)
->delete();
$totalDeleted += $deleted;
// Small pause to reduce database pressure
if ($deleted > 0) {
usleep(10000); // 10ms
}
} while ($deleted > 0);
return $totalDeleted;
}
}

View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Console\Commands;
use Core\Mod\Mcp\Services\AuditLogService;
use Illuminate\Console\Command;
/**
* Verify MCP Audit Log Integrity.
*
* Checks the hash chain for tamper detection and reports any issues.
*/
class VerifyAuditLogCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'mcp:verify-audit-log
{--from= : Start verification from this ID}
{--to= : End verification at this ID}
{--json : Output results as JSON}';
/**
* The console command description.
*/
protected $description = 'Verify the integrity of the MCP audit log hash chain';
/**
* Execute the console command.
*/
public function handle(AuditLogService $auditLogService): int
{
$fromId = $this->option('from') ? (int) $this->option('from') : null;
$toId = $this->option('to') ? (int) $this->option('to') : null;
$jsonOutput = $this->option('json');
if (! $jsonOutput) {
$this->info('Verifying MCP audit log integrity...');
$this->newLine();
}
$result = $auditLogService->verifyChain($fromId, $toId);
if ($jsonOutput) {
$this->line(json_encode($result, JSON_PRETTY_PRINT));
return $result['valid'] ? self::SUCCESS : self::FAILURE;
}
// Display results
$this->displayResults($result);
return $result['valid'] ? self::SUCCESS : self::FAILURE;
}
/**
* Display verification results.
*/
protected function displayResults(array $result): void
{
// Summary table
$this->table(
['Metric', 'Value'],
[
['Total Entries', number_format($result['total'])],
['Verified', number_format($result['verified'])],
['Status', $result['valid'] ? '<fg=green>VALID</>' : '<fg=red>INVALID</>'],
['Issues Found', count($result['issues'])],
]
);
if ($result['valid']) {
$this->newLine();
$this->info('Audit log integrity verified successfully.');
$this->info('The hash chain is intact and no tampering has been detected.');
return;
}
// Display issues
$this->newLine();
$this->error('Integrity issues detected!');
$this->newLine();
foreach ($result['issues'] as $issue) {
$this->warn("Entry #{$issue['id']}: {$issue['type']}");
$this->line(" {$issue['message']}");
if (isset($issue['expected'])) {
$this->line(" Expected: {$issue['expected']}");
}
if (isset($issue['actual'])) {
$this->line(" Actual: {$issue['actual']}");
}
$this->newLine();
}
$this->error('The audit log may have been tampered with. Please investigate immediately.');
}
}

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Mod\Mcp\Context;
use Core\Mod\Tenant\Models\Workspace;
use Mod\Mcp\Exceptions\MissingWorkspaceContextException;
/**
* Workspace context for MCP tool execution.
*
* Holds authenticated workspace information and provides validation.
* This ensures workspace-scoped tools always have proper context
* from authentication, not from user-supplied parameters.
*/
final class WorkspaceContext
{
public function __construct(
public readonly int $workspaceId,
public readonly ?Workspace $workspace = null,
) {}
/**
* Create context from a workspace model.
*/
public static function fromWorkspace(Workspace $workspace): self
{
return new self(
workspaceId: $workspace->id,
workspace: $workspace,
);
}
/**
* Create context from a workspace ID (lazy loads workspace when needed).
*/
public static function fromId(int $workspaceId): self
{
return new self(workspaceId: $workspaceId);
}
/**
* Create context from request attributes.
*
* @throws MissingWorkspaceContextException If no workspace context is available
*/
public static function fromRequest(mixed $request, string $toolName = 'unknown'): self
{
// Try to get workspace from request attributes (set by middleware)
$workspace = $request->attributes->get('mcp_workspace')
?? $request->attributes->get('workspace');
if ($workspace instanceof Workspace) {
return self::fromWorkspace($workspace);
}
// Try to get API key's workspace
$apiKey = $request->attributes->get('api_key');
if ($apiKey?->workspace_id) {
return new self(
workspaceId: $apiKey->workspace_id,
workspace: $apiKey->workspace,
);
}
// Try authenticated user's default workspace
$user = $request->user();
if ($user && method_exists($user, 'defaultHostWorkspace')) {
$workspace = $user->defaultHostWorkspace();
if ($workspace) {
return self::fromWorkspace($workspace);
}
}
throw new MissingWorkspaceContextException($toolName);
}
/**
* Get the workspace model, loading it if necessary.
*/
public function getWorkspace(): Workspace
{
if ($this->workspace) {
return $this->workspace;
}
return Workspace::findOrFail($this->workspaceId);
}
/**
* Check if this context has a specific workspace ID.
*/
public function hasWorkspaceId(int $workspaceId): bool
{
return $this->workspaceId === $workspaceId;
}
/**
* Validate that a resource belongs to this workspace.
*
* @throws \RuntimeException If the resource doesn't belong to this workspace
*/
public function validateOwnership(int $resourceWorkspaceId, string $resourceType = 'resource'): void
{
if ($resourceWorkspaceId !== $this->workspaceId) {
throw new \RuntimeException(
"Access denied: {$resourceType} does not belong to the authenticated workspace."
);
}
}
}

View file

@ -0,0 +1,492 @@
<?php
declare(strict_types=1);
namespace Mod\Api\Controllers;
use Core\Front\Controller;
use Core\Mod\Mcp\Services\McpQuotaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Mod\Api\Models\ApiKey;
use Mod\Mcp\Models\McpApiRequest;
use Mod\Mcp\Models\McpToolCall;
use Mod\Mcp\Services\McpWebhookDispatcher;
use Symfony\Component\Yaml\Yaml;
/**
* MCP HTTP API Controller.
*
* Provides HTTP bridge to MCP servers for external integrations.
*/
class McpApiController extends Controller
{
/**
* List all available MCP servers.
*
* GET /api/v1/mcp/servers
*/
public function servers(Request $request): JsonResponse
{
$registry = $this->loadRegistry();
$servers = collect($registry['servers'] ?? [])
->map(fn ($ref) => $this->loadServerSummary($ref['id']))
->filter()
->values();
return response()->json([
'servers' => $servers,
'count' => $servers->count(),
]);
}
/**
* Get server details with tools and resources.
*
* GET /api/v1/mcp/servers/{id}
*/
public function server(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
return response()->json($server);
}
/**
* List tools for a specific server.
*
* GET /api/v1/mcp/servers/{id}/tools
*/
public function tools(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
return response()->json([
'server' => $id,
'tools' => $server['tools'] ?? [],
'count' => count($server['tools'] ?? []),
]);
}
/**
* Execute a tool on an MCP server.
*
* POST /api/v1/mcp/tools/call
*/
public function callTool(Request $request): JsonResponse
{
$validated = $request->validate([
'server' => 'required|string|max:64',
'tool' => 'required|string|max:128',
'arguments' => 'nullable|array',
]);
$server = $this->loadServerFull($validated['server']);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
// Verify tool exists
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
if (! $toolDef) {
return response()->json(['error' => 'Tool not found'], 404);
}
// Validate arguments against tool's input schema
$validationErrors = $this->validateToolArguments($toolDef, $validated['arguments'] ?? []);
if (! empty($validationErrors)) {
return response()->json([
'error' => 'validation_failed',
'message' => 'Tool arguments do not match input schema',
'validation_errors' => $validationErrors,
], 422);
}
// Get API key for logging
$apiKey = $request->attributes->get('api_key');
$workspace = $apiKey?->workspace;
$startTime = microtime(true);
try {
// Execute the tool via artisan command
$result = $this->executeToolViaArtisan(
$validated['server'],
$validated['tool'],
$validated['arguments'] ?? []
);
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
// Log the call
$this->logToolCall($apiKey, $validated, $result, $durationMs, true);
// Record quota usage
$this->recordQuotaUsage($workspace);
// Dispatch webhooks
$this->dispatchWebhook($apiKey, $validated, true, $durationMs);
$response = [
'success' => true,
'server' => $validated['server'],
'tool' => $validated['tool'],
'result' => $result,
'duration_ms' => $durationMs,
];
// Log full request for debugging/replay
$this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey);
return response()->json($response);
} catch (\Throwable $e) {
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
$this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage());
// Dispatch webhooks (even on failure)
$this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage());
$response = [
'success' => false,
'error' => $e->getMessage(),
'server' => $validated['server'],
'tool' => $validated['tool'],
];
// Log full request for debugging/replay
$this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage());
return response()->json($response, 500);
}
}
/**
* Read a resource from an MCP server.
*
* GET /api/v1/mcp/resources/{uri}
*
* NOTE: Resource reading is not yet implemented. Returns 501 Not Implemented.
*/
public function resource(Request $request, string $uri): JsonResponse
{
// Parse URI format: server://resource/path
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
return response()->json(['error' => 'Invalid resource URI format'], 400);
}
$serverId = $matches[1];
$server = $this->loadServerFull($serverId);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
// Resource reading not yet implemented
return response()->json([
'error' => 'not_implemented',
'message' => 'MCP resource reading is not yet implemented. Use tool calls instead.',
'uri' => $uri,
], 501);
}
/**
* Execute tool via artisan MCP server command.
*/
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
{
$commandMap = config('api.mcp.server_commands', []);
$command = $commandMap[$server] ?? null;
if (! $command) {
throw new \RuntimeException("Unknown server: {$server}");
}
// Build MCP request
$mcpRequest = [
'jsonrpc' => '2.0',
'id' => uniqid(),
'method' => 'tools/call',
'params' => [
'name' => $tool,
'arguments' => $arguments,
],
];
// Execute via process
$process = proc_open(
['php', 'artisan', $command],
[
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
base_path()
);
if (! is_resource($process)) {
throw new \RuntimeException('Failed to start MCP server process');
}
fwrite($pipes[0], json_encode($mcpRequest)."\n");
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$response = json_decode($output, true);
if (isset($response['error'])) {
throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed');
}
return $response['result'] ?? null;
}
/**
* Log full API request for debugging and replay.
*/
protected function logApiRequest(
Request $request,
array $validated,
int $status,
array $response,
int $durationMs,
?ApiKey $apiKey,
?string $error = null
): void {
try {
McpApiRequest::log(
method: $request->method(),
path: '/tools/call',
requestBody: $validated,
responseStatus: $status,
responseBody: $response,
durationMs: $durationMs,
workspaceId: $apiKey?->workspace_id,
apiKeyId: $apiKey?->id,
serverId: $validated['server'],
toolName: $validated['tool'],
errorMessage: $error,
ipAddress: $request->ip(),
headers: $request->headers->all()
);
} catch (\Throwable $e) {
// Don't let logging failures affect API response
report($e);
}
}
/**
* Dispatch webhook for tool execution.
*/
protected function dispatchWebhook(
?ApiKey $apiKey,
array $request,
bool $success,
int $durationMs,
?string $error = null
): void {
if (! $apiKey?->workspace_id) {
return;
}
try {
$dispatcher = new McpWebhookDispatcher;
$dispatcher->dispatchToolExecuted(
workspaceId: $apiKey->workspace_id,
serverId: $request['server'],
toolName: $request['tool'],
arguments: $request['arguments'] ?? [],
success: $success,
durationMs: $durationMs,
errorMessage: $error
);
} catch (\Throwable $e) {
// Don't let webhook failures affect API response
report($e);
}
}
/**
* Log tool call for analytics.
*/
protected function logToolCall(
?ApiKey $apiKey,
array $request,
mixed $result,
int $durationMs,
bool $success,
?string $error = null
): void {
McpToolCall::log(
serverId: $request['server'],
toolName: $request['tool'],
params: $request['arguments'] ?? [],
success: $success,
durationMs: $durationMs,
errorMessage: $error,
workspaceId: $apiKey?->workspace_id
);
}
/**
* Validate tool arguments against the tool's input schema.
*
* @return array<string> Validation errors (empty if valid)
*/
protected function validateToolArguments(array $toolDef, array $arguments): array
{
$inputSchema = $toolDef['inputSchema'] ?? null;
// No schema = no validation
if (! $inputSchema || ! is_array($inputSchema)) {
return [];
}
$errors = [];
$properties = $inputSchema['properties'] ?? [];
$required = $inputSchema['required'] ?? [];
// Check required properties
foreach ($required as $requiredProp) {
if (! array_key_exists($requiredProp, $arguments)) {
$errors[] = "Missing required argument: {$requiredProp}";
}
}
// Type validation for provided arguments
foreach ($arguments as $key => $value) {
// Check if argument is defined in schema
if (! isset($properties[$key])) {
// Allow extra properties unless additionalProperties is false
if (isset($inputSchema['additionalProperties']) && $inputSchema['additionalProperties'] === false) {
$errors[] = "Unknown argument: {$key}";
}
continue;
}
$propSchema = $properties[$key];
$expectedType = $propSchema['type'] ?? null;
if ($expectedType && ! $this->validateType($value, $expectedType)) {
$errors[] = "Argument '{$key}' must be of type {$expectedType}";
}
// Validate enum values
if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) {
$allowedValues = implode(', ', $propSchema['enum']);
$errors[] = "Argument '{$key}' must be one of: {$allowedValues}";
}
// Validate string constraints
if ($expectedType === 'string' && is_string($value)) {
if (isset($propSchema['minLength']) && strlen($value) < $propSchema['minLength']) {
$errors[] = "Argument '{$key}' must be at least {$propSchema['minLength']} characters";
}
if (isset($propSchema['maxLength']) && strlen($value) > $propSchema['maxLength']) {
$errors[] = "Argument '{$key}' must be at most {$propSchema['maxLength']} characters";
}
}
// Validate numeric constraints
if (in_array($expectedType, ['integer', 'number']) && is_numeric($value)) {
if (isset($propSchema['minimum']) && $value < $propSchema['minimum']) {
$errors[] = "Argument '{$key}' must be at least {$propSchema['minimum']}";
}
if (isset($propSchema['maximum']) && $value > $propSchema['maximum']) {
$errors[] = "Argument '{$key}' must be at most {$propSchema['maximum']}";
}
}
}
return $errors;
}
/**
* Validate a value against a JSON Schema type.
*/
protected function validateType(mixed $value, string $type): bool
{
return match ($type) {
'string' => is_string($value),
'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value),
'number' => is_numeric($value),
'boolean' => is_bool($value),
'array' => is_array($value) && array_is_list($value),
'object' => is_array($value) && ! array_is_list($value),
'null' => is_null($value),
default => true, // Unknown types pass validation
};
}
// Registry loading methods (shared with McpRegistryController)
protected function loadRegistry(): array
{
return Cache::remember('mcp:registry', 600, function () {
$path = resource_path('mcp/registry.yaml');
return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []];
});
}
protected function loadServerFull(string $id): ?array
{
return Cache::remember("mcp:server:{$id}", 600, function () use ($id) {
$path = resource_path("mcp/servers/{$id}.yaml");
return file_exists($path) ? Yaml::parseFile($path) : null;
});
}
protected function loadServerSummary(string $id): ?array
{
$server = $this->loadServerFull($id);
if (! $server) {
return null;
}
return [
'id' => $server['id'],
'name' => $server['name'],
'tagline' => $server['tagline'] ?? '',
'status' => $server['status'] ?? 'available',
'tool_count' => count($server['tools'] ?? []),
'resource_count' => count($server['resources'] ?? []),
];
}
/**
* Record quota usage for successful tool calls.
*/
protected function recordQuotaUsage($workspace): void
{
if (! $workspace) {
return;
}
try {
$quotaService = app(McpQuotaService::class);
$quotaService->recordUsage($workspace, toolCalls: 1);
} catch (\Throwable $e) {
// Don't let quota recording failures affect API response
report($e);
}
}
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\DTO;
/**
* Tool Statistics Data Transfer Object.
*
* Represents aggregated statistics for a single MCP tool.
*/
readonly class ToolStats
{
public function __construct(
public string $toolName,
public int $totalCalls,
public int $errorCount,
public float $errorRate,
public float $avgDurationMs,
public int $minDurationMs,
public int $maxDurationMs,
) {}
/**
* Create from an array of data.
*/
public static function fromArray(array $data): self
{
return new self(
toolName: $data['tool_name'] ?? $data['toolName'] ?? '',
totalCalls: (int) ($data['total_calls'] ?? $data['totalCalls'] ?? 0),
errorCount: (int) ($data['error_count'] ?? $data['errorCount'] ?? 0),
errorRate: (float) ($data['error_rate'] ?? $data['errorRate'] ?? 0.0),
avgDurationMs: (float) ($data['avg_duration_ms'] ?? $data['avgDurationMs'] ?? 0.0),
minDurationMs: (int) ($data['min_duration_ms'] ?? $data['minDurationMs'] ?? 0),
maxDurationMs: (int) ($data['max_duration_ms'] ?? $data['maxDurationMs'] ?? 0),
);
}
/**
* Convert to array.
*/
public function toArray(): array
{
return [
'tool_name' => $this->toolName,
'total_calls' => $this->totalCalls,
'error_count' => $this->errorCount,
'error_rate' => $this->errorRate,
'avg_duration_ms' => $this->avgDurationMs,
'min_duration_ms' => $this->minDurationMs,
'max_duration_ms' => $this->maxDurationMs,
];
}
/**
* Get success rate as percentage.
*/
public function getSuccessRate(): float
{
return 100.0 - $this->errorRate;
}
/**
* Get average duration formatted for display.
*/
public function getAvgDurationForHumans(): string
{
if ($this->avgDurationMs === 0.0) {
return '-';
}
if ($this->avgDurationMs < 1000) {
return round($this->avgDurationMs).'ms';
}
return round($this->avgDurationMs / 1000, 2).'s';
}
/**
* Check if the tool has a high error rate (above threshold).
*/
public function hasHighErrorRate(float $threshold = 10.0): bool
{
return $this->errorRate > $threshold;
}
/**
* Check if the tool has slow response times (above threshold in ms).
*/
public function isSlowResponding(int $thresholdMs = 5000): bool
{
return $this->avgDurationMs > $thresholdMs;
}
}

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Database\Seeders;
use Core\Mod\Mcp\Models\McpSensitiveTool;
use Illuminate\Database\Seeder;
/**
* Seeds default sensitive tool definitions.
*
* These tools require stricter auditing due to their potential
* impact on security, privacy, or critical operations.
*/
class SensitiveToolSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$sensitiveTools = [
// Database operations
[
'tool_name' => 'query_database',
'reason' => 'Direct database access - may expose sensitive data',
'redact_fields' => ['password', 'email', 'phone', 'address', 'ssn'],
'require_explicit_consent' => false,
],
// User management
[
'tool_name' => 'create_user',
'reason' => 'User account creation - security sensitive',
'redact_fields' => ['password', 'secret'],
'require_explicit_consent' => true,
],
[
'tool_name' => 'update_user',
'reason' => 'User account modification - security sensitive',
'redact_fields' => ['password', 'secret', 'email'],
'require_explicit_consent' => true,
],
[
'tool_name' => 'delete_user',
'reason' => 'User account deletion - irreversible operation',
'redact_fields' => [],
'require_explicit_consent' => true,
],
// API key management
[
'tool_name' => 'create_api_key',
'reason' => 'API key creation - security credential',
'redact_fields' => ['key', 'secret', 'token'],
'require_explicit_consent' => true,
],
[
'tool_name' => 'revoke_api_key',
'reason' => 'API key revocation - access control',
'redact_fields' => [],
'require_explicit_consent' => true,
],
// Billing and financial
[
'tool_name' => 'upgrade_plan',
'reason' => 'Plan upgrade - financial impact',
'redact_fields' => ['card_number', 'cvv', 'payment_method'],
'require_explicit_consent' => true,
],
[
'tool_name' => 'create_coupon',
'reason' => 'Coupon creation - financial impact',
'redact_fields' => [],
'require_explicit_consent' => false,
],
[
'tool_name' => 'process_refund',
'reason' => 'Refund processing - financial transaction',
'redact_fields' => ['card_number', 'bank_account'],
'require_explicit_consent' => true,
],
// Content operations
[
'tool_name' => 'delete_content',
'reason' => 'Content deletion - irreversible data loss',
'redact_fields' => [],
'require_explicit_consent' => true,
],
[
'tool_name' => 'publish_content',
'reason' => 'Public content publishing - visibility impact',
'redact_fields' => [],
'require_explicit_consent' => false,
],
// System configuration
[
'tool_name' => 'update_config',
'reason' => 'System configuration change - affects application behaviour',
'redact_fields' => ['api_key', 'secret', 'password'],
'require_explicit_consent' => true,
],
// Webhook management
[
'tool_name' => 'create_webhook',
'reason' => 'External webhook creation - data exfiltration risk',
'redact_fields' => ['secret', 'token'],
'require_explicit_consent' => true,
],
];
foreach ($sensitiveTools as $tool) {
McpSensitiveTool::updateOrCreate(
['tool_name' => $tool['tool_name']],
[
'reason' => $tool['reason'],
'redact_fields' => $tool['redact_fields'],
'require_explicit_consent' => $tool['require_explicit_consent'],
]
);
}
$this->command->info('Registered '.count($sensitiveTools).' sensitive tool definitions.');
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Dependencies;
/**
* Types of tool dependencies.
*
* Defines how a prerequisite must be satisfied before a tool can execute.
*/
enum DependencyType: string
{
/**
* Another tool must have been called in the current session.
* Example: task_update requires plan_create to have been called.
*/
case TOOL_CALLED = 'tool_called';
/**
* A specific state key must exist in the session context.
* Example: session_log requires session_id to be set.
*/
case SESSION_STATE = 'session_state';
/**
* A specific context value must be present.
* Example: workspace_id must exist for workspace-scoped tools.
*/
case CONTEXT_EXISTS = 'context_exists';
/**
* A database entity must exist (checked by ID or slug).
* Example: task_update requires the plan_slug to reference an existing plan.
*/
case ENTITY_EXISTS = 'entity_exists';
/**
* A custom condition evaluated at runtime.
* Example: Complex business rules that don't fit other types.
*/
case CUSTOM = 'custom';
/**
* Get a human-readable label for this dependency type.
*/
public function label(): string
{
return match ($this) {
self::TOOL_CALLED => 'Tool must be called first',
self::SESSION_STATE => 'Session state required',
self::CONTEXT_EXISTS => 'Context value required',
self::ENTITY_EXISTS => 'Entity must exist',
self::CUSTOM => 'Custom condition',
};
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Dependencies;
/**
* Interface for tools that declare dependencies.
*
* Tools implementing this interface can specify prerequisites
* that must be satisfied before execution.
*/
interface HasDependencies
{
/**
* Get the dependencies for this tool.
*
* @return array<ToolDependency>
*/
public function dependencies(): array;
}

View file

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Dependencies;
/**
* Represents a single tool dependency.
*
* Defines what must be satisfied before a tool can execute.
*/
class ToolDependency
{
/**
* Create a new tool dependency.
*
* @param DependencyType $type The type of dependency
* @param string $key The identifier (tool name, state key, context key, etc.)
* @param string|null $description Human-readable description for error messages
* @param bool $optional If true, this is a soft dependency (warning, not error)
* @param array $metadata Additional metadata for custom validation
*/
public function __construct(
public readonly DependencyType $type,
public readonly string $key,
public readonly ?string $description = null,
public readonly bool $optional = false,
public readonly array $metadata = [],
) {}
/**
* Create a tool_called dependency.
*/
public static function toolCalled(string $toolName, ?string $description = null): self
{
return new self(
type: DependencyType::TOOL_CALLED,
key: $toolName,
description: $description ?? "Tool '{$toolName}' must be called first",
);
}
/**
* Create a session_state dependency.
*/
public static function sessionState(string $stateKey, ?string $description = null): self
{
return new self(
type: DependencyType::SESSION_STATE,
key: $stateKey,
description: $description ?? "Session state '{$stateKey}' is required",
);
}
/**
* Create a context_exists dependency.
*/
public static function contextExists(string $contextKey, ?string $description = null): self
{
return new self(
type: DependencyType::CONTEXT_EXISTS,
key: $contextKey,
description: $description ?? "Context '{$contextKey}' is required",
);
}
/**
* Create an entity_exists dependency.
*/
public static function entityExists(string $entityType, ?string $description = null, array $metadata = []): self
{
return new self(
type: DependencyType::ENTITY_EXISTS,
key: $entityType,
description: $description ?? "Entity '{$entityType}' must exist",
metadata: $metadata,
);
}
/**
* Create a custom dependency with callback metadata.
*/
public static function custom(string $name, ?string $description = null, array $metadata = []): self
{
return new self(
type: DependencyType::CUSTOM,
key: $name,
description: $description,
metadata: $metadata,
);
}
/**
* Mark this dependency as optional (soft dependency).
*/
public function asOptional(): self
{
return new self(
type: $this->type,
key: $this->key,
description: $this->description,
optional: true,
metadata: $this->metadata,
);
}
/**
* Convert to array representation.
*/
public function toArray(): array
{
return [
'type' => $this->type->value,
'key' => $this->key,
'description' => $this->description,
'optional' => $this->optional,
'metadata' => $this->metadata,
];
}
/**
* Create from array representation.
*/
public static function fromArray(array $data): self
{
return new self(
type: DependencyType::from($data['type']),
key: $data['key'],
description: $data['description'] ?? null,
optional: $data['optional'] ?? false,
metadata: $data['metadata'] ?? [],
);
}
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Event fired when an MCP tool execution completes.
*
* This event can be dispatched after tool execution to trigger
* analytics recording and other side effects.
*/
class ToolExecuted
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly string $toolName,
public readonly int $durationMs,
public readonly bool $success,
public readonly ?string $workspaceId = null,
public readonly ?string $sessionId = null,
public readonly ?string $errorMessage = null,
public readonly ?string $errorCode = null,
public readonly ?array $metadata = null,
) {}
/**
* Create event for a successful execution.
*/
public static function success(
string $toolName,
int $durationMs,
?string $workspaceId = null,
?string $sessionId = null,
?array $metadata = null
): self {
return new self(
toolName: $toolName,
durationMs: $durationMs,
success: true,
workspaceId: $workspaceId,
sessionId: $sessionId,
metadata: $metadata,
);
}
/**
* Create event for a failed execution.
*/
public static function failure(
string $toolName,
int $durationMs,
?string $errorMessage = null,
?string $errorCode = null,
?string $workspaceId = null,
?string $sessionId = null,
?array $metadata = null
): self {
return new self(
toolName: $toolName,
durationMs: $durationMs,
success: false,
workspaceId: $workspaceId,
sessionId: $sessionId,
errorMessage: $errorMessage,
errorCode: $errorCode,
metadata: $metadata,
);
}
/**
* Get the tool name.
*/
public function getToolName(): string
{
return $this->toolName;
}
/**
* Get the duration in milliseconds.
*/
public function getDurationMs(): int
{
return $this->durationMs;
}
/**
* Check if the execution was successful.
*/
public function wasSuccessful(): bool
{
return $this->success;
}
/**
* Get the workspace ID.
*/
public function getWorkspaceId(): ?string
{
return $this->workspaceId;
}
/**
* Get the session ID.
*/
public function getSessionId(): ?string
{
return $this->sessionId;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mod\Mcp\Exceptions;
use RuntimeException;
/**
* Exception thrown when the circuit breaker is open and no fallback is provided.
*
* This indicates the target service is temporarily unavailable due to repeated failures.
*/
class CircuitOpenException extends RuntimeException
{
public function __construct(
public readonly string $service,
string $message = '',
) {
$message = $message ?: sprintf(
"Service '%s' is temporarily unavailable. Please try again later.",
$service
);
parent::__construct($message);
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Exceptions;
use RuntimeException;
/**
* Exception thrown when a SQL query is forbidden by security policies.
*
* This indicates the query failed validation due to:
* - Containing disallowed SQL keywords (UNION, INSERT, UPDATE, DELETE, etc.)
* - Not matching any whitelisted query pattern
* - Containing potentially malicious constructs (stacked queries, comments)
*/
class ForbiddenQueryException extends RuntimeException
{
public function __construct(
public readonly string $query,
public readonly string $reason,
string $message = '',
) {
$message = $message ?: sprintf(
'Query rejected: %s',
$reason
);
parent::__construct($message);
}
/**
* Create exception for disallowed keyword.
*/
public static function disallowedKeyword(string $query, string $keyword): self
{
return new self(
$query,
sprintf("Disallowed SQL keyword '%s' detected", strtoupper($keyword))
);
}
/**
* Create exception for query not matching whitelist.
*/
public static function notWhitelisted(string $query): self
{
return new self(
$query,
'Query does not match any allowed pattern'
);
}
/**
* Create exception for invalid query structure.
*/
public static function invalidStructure(string $query, string $detail): self
{
return new self(
$query,
sprintf('Invalid query structure: %s', $detail)
);
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Exceptions;
use Core\Mod\Mcp\Dependencies\ToolDependency;
use RuntimeException;
/**
* Exception thrown when tool dependencies are not met.
*
* Provides detailed information about what's missing and how to resolve it.
*/
class MissingDependencyException extends RuntimeException
{
/**
* @param string $toolName The tool that has unmet dependencies
* @param array<ToolDependency> $missingDependencies List of unmet dependencies
* @param array<string> $suggestedOrder Suggested tools to call first
*/
public function __construct(
public readonly string $toolName,
public readonly array $missingDependencies,
public readonly array $suggestedOrder = [],
) {
$message = $this->buildMessage();
parent::__construct($message);
}
/**
* Build a user-friendly error message.
*/
protected function buildMessage(): string
{
$missing = array_map(
fn (ToolDependency $dep) => "- {$dep->description}",
$this->missingDependencies
);
$message = "Cannot execute '{$this->toolName}': prerequisites not met.\n\n";
$message .= "Missing:\n".implode("\n", $missing);
if (! empty($this->suggestedOrder)) {
$message .= "\n\nSuggested order:\n";
foreach ($this->suggestedOrder as $i => $tool) {
$message .= sprintf(" %d. %s\n", $i + 1, $tool);
}
}
return $message;
}
/**
* Get a structured error response for API output.
*/
public function toApiResponse(): array
{
return [
'error' => 'dependency_not_met',
'message' => "Cannot execute '{$this->toolName}': prerequisites not met",
'tool' => $this->toolName,
'missing_dependencies' => array_map(
fn (ToolDependency $dep) => $dep->toArray(),
$this->missingDependencies
),
'suggested_order' => $this->suggestedOrder,
'help' => $this->getHelpText(),
];
}
/**
* Get help text explaining how to resolve the issue.
*/
protected function getHelpText(): string
{
if (empty($this->suggestedOrder)) {
return 'Ensure all required dependencies are satisfied before calling this tool.';
}
return sprintf(
'Call these tools in order before attempting %s: %s',
$this->toolName,
implode(' -> ', $this->suggestedOrder)
);
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Mod\Mcp\Exceptions;
use RuntimeException;
/**
* Exception thrown when an MCP tool requires workspace context but none is provided.
*
* This is a security measure to prevent cross-tenant data leakage.
* Workspace-scoped tools must have explicit workspace context from authentication,
* not from user-supplied parameters.
*/
class MissingWorkspaceContextException extends RuntimeException
{
public function __construct(
public readonly string $tool,
string $message = '',
) {
$message = $message ?: sprintf(
"MCP tool '%s' requires workspace context. Authenticate with an API key or user session.",
$tool
);
parent::__construct($message);
}
/**
* Get the HTTP status code for this exception.
*/
public function getStatusCode(): int
{
return 403;
}
/**
* Get the error type for JSON responses.
*/
public function getErrorType(): string
{
return 'missing_workspace_context';
}
}

View file

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/**
* MCP module translations (en_GB).
*
* Key structure: section.subsection.key
*/
return [
// API Key Manager
'keys' => [
'title' => 'API Keys',
'description' => 'Create API keys to authenticate HTTP requests to MCP servers.',
'empty' => [
'title' => 'No API Keys Yet',
'description' => 'Create an API key to start making authenticated requests to MCP servers over HTTP.',
],
'actions' => [
'create' => 'Create Key',
'create_first' => 'Create Your First Key',
'revoke' => 'Revoke',
],
'table' => [
'name' => 'Name',
'key' => 'Key',
'scopes' => 'Scopes',
'last_used' => 'Last Used',
'expires' => 'Expires',
'actions' => 'Actions',
],
'status' => [
'expired' => 'Expired',
'never' => 'Never',
],
'confirm_revoke' => 'Are you sure you want to revoke this API key? This cannot be undone.',
// Authentication section
'auth' => [
'title' => 'Authentication',
'description' => 'Include your API key in HTTP requests using one of these methods:',
'header_recommended' => 'Authorization Header (recommended)',
'header_api_key' => 'X-API-Key Header',
],
// Example section
'example' => [
'title' => 'Example Request',
'description' => 'Call an MCP tool via HTTP POST:',
],
// Create modal
'create_modal' => [
'title' => 'Create API Key',
'name_label' => 'Key Name',
'name_placeholder' => 'e.g., Production Server, Claude Agent',
'permissions_label' => 'Permissions',
'permission_read' => 'Read - Query tools and resources',
'permission_write' => 'Write - Create and update data',
'permission_delete' => 'Delete - Remove data',
'expiry_label' => 'Expiration',
'expiry_never' => 'Never expires',
'expiry_30' => '30 days',
'expiry_90' => '90 days',
'expiry_1year' => '1 year',
'cancel' => 'Cancel',
'create' => 'Create Key',
],
// New key modal
'new_key_modal' => [
'title' => 'API Key Created',
'warning' => 'Copy this key now.',
'warning_detail' => "You won't be able to see it again.",
'done' => 'Done',
],
],
// Request Log
'logs' => [
'title' => 'Request Log',
'description' => 'View API requests and generate curl commands to replay them.',
'filters' => [
'server' => 'Server',
'status' => 'Status',
'all_servers' => 'All servers',
'all' => 'All',
'success' => 'Success',
'failed' => 'Failed',
],
'empty' => 'No requests found.',
'detail' => [
'title' => 'Request Detail',
'status' => 'Status',
'request' => 'Request',
'response' => 'Response',
'error' => 'Error',
'replay_command' => 'Replay Command',
'copy' => 'Copy',
'copied' => 'Copied',
'metadata' => [
'request_id' => 'Request ID',
'duration' => 'Duration',
'ip' => 'IP',
'time' => 'Time',
],
],
'empty_detail' => 'Select a request to view details and generate replay commands.',
'status_ok' => 'OK',
'status_error' => 'Error',
],
// Playground
'playground' => [
'title' => 'Playground',
'description' => 'Test MCP tools interactively and execute requests live.',
// Authentication section
'auth' => [
'title' => 'Authentication',
'api_key_label' => 'API Key',
'api_key_placeholder' => 'hk_xxxxxxxx_xxxxxxxxxxxx...',
'api_key_description' => 'Paste your API key to execute requests live',
'validate' => 'Validate Key',
'status' => [
'valid' => 'Valid',
'invalid' => 'Invalid key',
'expired' => 'Expired',
'empty' => 'Enter a key to validate',
],
'key_info' => [
'name' => 'Name',
'workspace' => 'Workspace',
'scopes' => 'Scopes',
'last_used' => 'Last used',
],
'sign_in_prompt' => 'Sign in',
'sign_in_description' => 'to create API keys, or paste an existing key above.',
],
// Tool selection section
'tools' => [
'title' => 'Select Tool',
'server_label' => 'Server',
'server_placeholder' => 'Choose a server...',
'tool_label' => 'Tool',
'tool_placeholder' => 'Choose a tool...',
'arguments' => 'Arguments',
'no_arguments' => 'This tool has no arguments.',
'execute' => 'Execute Request',
'generate' => 'Generate Request',
'executing' => 'Executing...',
],
// Response section
'response' => [
'title' => 'Response',
'copy' => 'Copy',
'copied' => 'Copied',
'empty' => 'Select a server and tool to get started.',
],
// API Reference section
'reference' => [
'title' => 'API Reference',
'endpoint' => 'Endpoint',
'method' => 'Method',
'auth' => 'Auth',
'content_type' => 'Content-Type',
'manage_keys' => 'Manage API Keys',
],
],
// Common
'common' => [
'na' => 'N/A',
],
];

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Listeners;
use Core\Mod\Mcp\Services\ToolAnalyticsService;
/**
* Listener to record MCP tool executions for analytics.
*
* Hooks into the MCP tool execution pipeline to track timing,
* success/failure, and other metrics.
*/
class RecordToolExecution
{
public function __construct(
protected ToolAnalyticsService $analyticsService
) {}
/**
* Handle the tool execution event.
*
* @param object $event The tool execution event
*/
public function handle(object $event): void
{
if (! config('mcp.analytics.enabled', true)) {
return;
}
// Extract data from the event
$toolName = $this->getToolName($event);
$durationMs = $this->getDuration($event);
$success = $this->wasSuccessful($event);
$workspaceId = $this->getWorkspaceId($event);
$sessionId = $this->getSessionId($event);
if ($toolName === null || $durationMs === null) {
return;
}
$this->analyticsService->recordExecution(
tool: $toolName,
durationMs: $durationMs,
success: $success,
workspaceId: $workspaceId,
sessionId: $sessionId
);
}
/**
* Extract tool name from the event.
*/
protected function getToolName(object $event): ?string
{
// Support multiple event structures
if (property_exists($event, 'toolName')) {
return $event->toolName;
}
if (property_exists($event, 'tool_name')) {
return $event->tool_name;
}
if (property_exists($event, 'tool')) {
return is_string($event->tool) ? $event->tool : $event->tool->getName();
}
if (method_exists($event, 'getToolName')) {
return $event->getToolName();
}
return null;
}
/**
* Extract duration from the event.
*/
protected function getDuration(object $event): ?int
{
if (property_exists($event, 'durationMs')) {
return (int) $event->durationMs;
}
if (property_exists($event, 'duration_ms')) {
return (int) $event->duration_ms;
}
if (property_exists($event, 'duration')) {
return (int) $event->duration;
}
if (method_exists($event, 'getDurationMs')) {
return $event->getDurationMs();
}
return null;
}
/**
* Determine if the execution was successful.
*/
protected function wasSuccessful(object $event): bool
{
if (property_exists($event, 'success')) {
return (bool) $event->success;
}
if (property_exists($event, 'error')) {
return $event->error === null;
}
if (property_exists($event, 'exception')) {
return $event->exception === null;
}
if (method_exists($event, 'wasSuccessful')) {
return $event->wasSuccessful();
}
return true; // Assume success if no indicator
}
/**
* Extract workspace ID from the event.
*/
protected function getWorkspaceId(object $event): ?string
{
if (property_exists($event, 'workspaceId')) {
return $event->workspaceId;
}
if (property_exists($event, 'workspace_id')) {
return $event->workspace_id;
}
if (method_exists($event, 'getWorkspaceId')) {
return $event->getWorkspaceId();
}
return null;
}
/**
* Extract session ID from the event.
*/
protected function getSessionId(object $event): ?string
{
if (property_exists($event, 'sessionId')) {
return $event->sessionId;
}
if (property_exists($event, 'session_id')) {
return $event->session_id;
}
if (method_exists($event, 'getSessionId')) {
return $event->getSessionId();
}
return null;
}
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Middleware;
use Closure;
use Core\Mod\Mcp\Services\McpQuotaService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware to check MCP workspace quota before processing requests.
*
* Enforces monthly tool call and token limits based on workspace entitlements.
* Adds quota information to response headers.
*/
class CheckMcpQuota
{
public function __construct(
protected McpQuotaService $quotaService
) {}
public function handle(Request $request, Closure $next): Response
{
$workspace = $request->attributes->get('workspace');
// No workspace context = skip quota check (other middleware handles auth)
if (! $workspace) {
return $next($request);
}
// Check quota
$quotaCheck = $this->quotaService->checkQuotaDetailed($workspace);
if (! $quotaCheck['allowed']) {
return $this->quotaExceededResponse($quotaCheck, $workspace);
}
// Process request
$response = $next($request);
// Add quota headers to response
$this->addQuotaHeaders($response, $workspace);
return $response;
}
/**
* Build quota exceeded error response.
*/
protected function quotaExceededResponse(array $quotaCheck, $workspace): Response
{
$headers = $this->quotaService->getQuotaHeaders($workspace);
$errorData = [
'error' => 'quota_exceeded',
'message' => $quotaCheck['reason'] ?? 'Monthly quota exceeded',
'quota' => [
'tool_calls' => [
'used' => $quotaCheck['tool_calls']['used'] ?? 0,
'limit' => $quotaCheck['tool_calls']['limit'],
'unlimited' => $quotaCheck['tool_calls']['unlimited'] ?? false,
],
'tokens' => [
'used' => $quotaCheck['tokens']['used'] ?? 0,
'limit' => $quotaCheck['tokens']['limit'],
'unlimited' => $quotaCheck['tokens']['unlimited'] ?? false,
],
'resets_at' => now()->endOfMonth()->toIso8601String(),
],
'upgrade_hint' => 'Upgrade your plan to increase MCP quota limits.',
];
return response()->json($errorData, 429, $headers);
}
/**
* Add quota headers to response.
*/
protected function addQuotaHeaders(Response $response, $workspace): void
{
$headers = $this->quotaService->getQuotaHeaders($workspace);
foreach ($headers as $name => $value) {
$response->headers->set($name, $value);
}
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Middleware;
use Core\Mod\Api\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API Key authentication for MCP HTTP API.
*
* Supports:
* - Authorization: Bearer hk_xxx_yyy
* - X-API-Key: hk_xxx_yyy
*
* Also enforces per-server access scopes on tool execution.
*/
class McpApiKeyAuth
{
public function handle(Request $request, Closure $next): Response
{
$key = $this->extractKey($request);
if (! $key) {
return response()->json([
'error' => 'Missing API key',
'hint' => 'Provide via Authorization: Bearer <key> or X-API-Key header',
], 401);
}
$apiKey = ApiKey::findByPlainKey($key);
if (! $apiKey) {
return response()->json([
'error' => 'Invalid API key',
], 401);
}
if ($apiKey->isExpired()) {
return response()->json([
'error' => 'API key has expired',
], 401);
}
// Check server-level access for tool calls
if ($request->is('*/tools/call') && $request->isMethod('POST')) {
$serverId = $request->input('server');
if ($serverId && ! $apiKey->hasServerAccess($serverId)) {
return response()->json([
'error' => 'Access denied to server: '.$serverId,
'allowed_servers' => $apiKey->getAllowedServers(),
], 403);
}
}
// Record usage
$apiKey->recordUsage();
// Attach to request for controller access
$request->attributes->set('api_key', $apiKey);
$request->attributes->set('workspace', $apiKey->workspace);
return $next($request);
}
protected function extractKey(Request $request): ?string
{
// Try Authorization: Bearer
$authHeader = $request->header('Authorization');
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
return substr($authHeader, 7);
}
// Try X-API-Key
$apiKeyHeader = $request->header('X-API-Key');
if ($apiKeyHeader) {
return $apiKeyHeader;
}
return null;
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Middleware;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* MCP Portal Authentication Middleware.
*
* Handles authentication for the MCP portal:
* - Public routes (landing, server list) pass through
* - Protected routes require auth or API key
* - Checks mcp.access entitlement for workspace
*/
class McpAuthenticate
{
public function __construct(
protected EntitlementService $entitlementService
) {}
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, string $level = 'optional'): Response
{
// Try API key auth first (for programmatic access)
$workspace = $this->authenticateByApiKey($request);
// Fall back to session auth
if (! $workspace && $request->user()) {
$user = $request->user();
if (method_exists($user, 'defaultHostWorkspace')) {
$workspace = $user->defaultHostWorkspace();
}
}
// Store workspace for downstream use
if ($workspace) {
$request->attributes->set('mcp_workspace', $workspace);
// Check MCP access entitlement
$result = $this->entitlementService->can($workspace, 'mcp.access');
$request->attributes->set('mcp_entitlement', $result);
}
// For 'required' level, must have workspace
if ($level === 'required' && ! $workspace) {
return $this->unauthenticatedResponse($request);
}
return $next($request);
}
/**
* Authenticate using API key from header or query.
*/
protected function authenticateByApiKey(Request $request): ?Workspace
{
$apiKey = $request->header('X-API-Key')
?? $request->header('Authorization')
?? $request->query('api_key');
if (! $apiKey) {
return null;
}
// Strip 'Bearer ' prefix if present
if (str_starts_with($apiKey, 'Bearer ')) {
$apiKey = substr($apiKey, 7);
}
// Look up workspace by API key
return Workspace::whereHas('apiKeys', function ($query) use ($apiKey) {
$query->where('key', hash('sha256', $apiKey))
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
})->first();
}
/**
* Return unauthenticated response.
*/
protected function unauthenticatedResponse(Request $request): Response
{
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json([
'error' => 'unauthenticated',
'message' => 'Authentication required. Provide an API key or sign in.',
], 401);
}
return redirect()->guest(route('login'));
}
}

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Middleware;
use Closure;
use Core\Mod\Mcp\Exceptions\MissingDependencyException;
use Core\Mod\Mcp\Services\ToolDependencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Middleware to validate tool dependencies before execution.
*
* Checks that all prerequisites are met for an MCP tool call
* and returns a helpful error response if not.
*/
class ValidateToolDependencies
{
public function __construct(
protected ToolDependencyService $dependencyService,
) {}
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): mixed
{
// Only validate tool call endpoints
if (! $this->isToolCallRequest($request)) {
return $next($request);
}
$toolName = $this->extractToolName($request);
$sessionId = $this->extractSessionId($request);
$context = $this->extractContext($request);
$args = $this->extractArguments($request);
if (! $toolName || ! $sessionId) {
return $next($request);
}
try {
$this->dependencyService->validateDependencies($sessionId, $toolName, $context, $args);
} catch (MissingDependencyException $e) {
return $this->buildErrorResponse($e);
}
// Record the tool call after successful execution
$response = $next($request);
// Only record on success
if ($response instanceof JsonResponse && $this->isSuccessResponse($response)) {
$this->dependencyService->recordToolCall($sessionId, $toolName, $args);
}
return $response;
}
/**
* Check if this is a tool call request.
*/
protected function isToolCallRequest(Request $request): bool
{
return $request->is('*/tools/call') || $request->is('api/*/mcp/tools/call');
}
/**
* Extract the tool name from the request.
*/
protected function extractToolName(Request $request): ?string
{
return $request->input('tool') ?? $request->input('name');
}
/**
* Extract the session ID from the request.
*/
protected function extractSessionId(Request $request): ?string
{
// Try various locations where session ID might be
return $request->input('session_id')
?? $request->input('arguments.session_id')
?? $request->header('X-MCP-Session-ID')
?? $request->attributes->get('session_id');
}
/**
* Extract context from the request.
*/
protected function extractContext(Request $request): array
{
$context = [];
// Get API key context
$apiKey = $request->attributes->get('api_key');
if ($apiKey) {
$context['workspace_id'] = $apiKey->workspace_id;
}
// Get explicit context from request
$requestContext = $request->input('context', []);
if (is_array($requestContext)) {
$context = array_merge($context, $requestContext);
}
// Get session ID
$sessionId = $this->extractSessionId($request);
if ($sessionId) {
$context['session_id'] = $sessionId;
}
return $context;
}
/**
* Extract tool arguments from the request.
*/
protected function extractArguments(Request $request): array
{
return $request->input('arguments', []) ?? [];
}
/**
* Check if response indicates success.
*/
protected function isSuccessResponse(JsonResponse $response): bool
{
if ($response->getStatusCode() >= 400) {
return false;
}
$data = $response->getData(true);
return ($data['success'] ?? true) !== false;
}
/**
* Build error response for missing dependencies.
*/
protected function buildErrorResponse(MissingDependencyException $e): JsonResponse
{
return response()->json($e->toApiResponse(), 422);
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Mod\Mcp\Middleware;
use Closure;
use Illuminate\Http\Request;
use Mod\Mcp\Context\WorkspaceContext;
use Mod\Mcp\Exceptions\MissingWorkspaceContextException;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware that validates workspace context for MCP API requests.
*
* This middleware ensures that workspace-scoped MCP tools have proper
* authentication context. It creates a WorkspaceContext object from
* the authenticated workspace and stores it for downstream use.
*
* SECURITY: This prevents cross-tenant data leakage by ensuring
* workspace context comes from authentication, not user-supplied parameters.
*/
class ValidateWorkspaceContext
{
/**
* Handle an incoming request.
*
* @param string $mode 'required' or 'optional'
*/
public function handle(Request $request, Closure $next, string $mode = 'required'): Response
{
$workspace = $request->attributes->get('mcp_workspace');
if ($workspace) {
// Create workspace context and store it
$context = WorkspaceContext::fromWorkspace($workspace);
$request->attributes->set('mcp_workspace_context', $context);
return $next($request);
}
// Try to get workspace from API key
$apiKey = $request->attributes->get('api_key');
if ($apiKey?->workspace_id) {
$context = new WorkspaceContext(
workspaceId: $apiKey->workspace_id,
workspace: $apiKey->workspace,
);
$request->attributes->set('mcp_workspace_context', $context);
return $next($request);
}
// Try authenticated user's default workspace
$user = $request->user();
if ($user && method_exists($user, 'defaultHostWorkspace')) {
$workspace = $user->defaultHostWorkspace();
if ($workspace) {
$context = WorkspaceContext::fromWorkspace($workspace);
$request->attributes->set('mcp_workspace_context', $context);
return $next($request);
}
}
// If mode is 'required', reject the request
if ($mode === 'required') {
return $this->missingContextResponse($request);
}
// Mode is 'optional', continue without context
return $next($request);
}
/**
* Return response for missing workspace context.
*/
protected function missingContextResponse(Request $request): Response
{
$exception = new MissingWorkspaceContextException('MCP API');
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json([
'error' => $exception->getErrorType(),
'message' => $exception->getMessage(),
], $exception->getStatusCode());
}
return response($exception->getMessage(), $exception->getStatusCode());
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mcp_api_requests', function (Blueprint $table) {
$table->id();
$table->string('request_id', 32)->unique();
$table->foreignId('workspace_id')->nullable()->constrained('workspaces')->nullOnDelete();
$table->foreignId('api_key_id')->nullable()->constrained('api_keys')->nullOnDelete();
$table->string('method', 10);
$table->string('path', 255);
$table->json('headers')->nullable();
$table->json('request_body')->nullable();
$table->unsignedSmallInteger('response_status');
$table->json('response_body')->nullable();
$table->unsignedInteger('duration_ms')->default(0);
$table->string('server_id', 64)->nullable();
$table->string('tool_name', 128)->nullable();
$table->text('error_message')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamps();
$table->index(['workspace_id', 'created_at']);
$table->index(['server_id', 'tool_name']);
$table->index('created_at');
$table->index('response_status');
});
}
public function down(): void
{
Schema::dropIfExists('mcp_api_requests');
}
};

View file

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mcp_tool_metrics', function (Blueprint $table) {
$table->id();
$table->string('tool_name');
$table->string('workspace_id')->nullable();
$table->unsignedInteger('call_count')->default(0);
$table->unsignedInteger('error_count')->default(0);
$table->unsignedInteger('total_duration_ms')->default(0);
$table->unsignedInteger('min_duration_ms')->nullable();
$table->unsignedInteger('max_duration_ms')->nullable();
$table->date('date');
$table->timestamps();
$table->unique(['tool_name', 'workspace_id', 'date']);
$table->index(['date', 'tool_name']);
$table->index('workspace_id');
});
// Table for tracking tool combinations (tools used together in sessions)
Schema::create('mcp_tool_combinations', function (Blueprint $table) {
$table->id();
$table->string('tool_a');
$table->string('tool_b');
$table->string('workspace_id')->nullable();
$table->unsignedInteger('occurrence_count')->default(0);
$table->date('date');
$table->timestamps();
$table->unique(['tool_a', 'tool_b', 'workspace_id', 'date']);
$table->index(['date', 'occurrence_count']);
});
}
public function down(): void
{
Schema::dropIfExists('mcp_tool_combinations');
Schema::dropIfExists('mcp_tool_metrics');
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mcp_usage_quotas', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->string('month', 7); // YYYY-MM format
$table->unsignedBigInteger('tool_calls_count')->default(0);
$table->unsignedBigInteger('input_tokens')->default(0);
$table->unsignedBigInteger('output_tokens')->default(0);
$table->timestamps();
$table->unique(['workspace_id', 'month']);
$table->index('month');
});
}
public function down(): void
{
Schema::dropIfExists('mcp_usage_quotas');
}
};

View file

@ -0,0 +1,78 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mcp_audit_logs', function (Blueprint $table) {
$table->id();
// Tool execution details
$table->string('server_id')->index();
$table->string('tool_name')->index();
$table->unsignedBigInteger('workspace_id')->nullable()->index();
$table->string('session_id')->nullable()->index();
// Input/output (stored as JSON, may be redacted)
$table->json('input_params')->nullable();
$table->json('output_summary')->nullable();
$table->boolean('success')->default(true);
$table->unsignedInteger('duration_ms')->nullable();
$table->string('error_code')->nullable();
$table->text('error_message')->nullable();
// Actor information
$table->string('actor_type')->nullable(); // user, api_key, system
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_ip', 45)->nullable(); // IPv4 or IPv6
// Sensitive tool flagging
$table->boolean('is_sensitive')->default(false)->index();
$table->string('sensitivity_reason')->nullable();
// Hash chain for tamper detection
$table->string('previous_hash', 64)->nullable(); // SHA-256 of previous entry
$table->string('entry_hash', 64)->index(); // SHA-256 of this entry
// Agent context
$table->string('agent_type')->nullable();
$table->string('plan_slug')->nullable();
// Timestamps (immutable - no updated_at updates after creation)
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable();
// Foreign key constraint
$table->foreign('workspace_id')
->references('id')
->on('workspaces')
->nullOnDelete();
// Composite indexes for common queries
$table->index(['workspace_id', 'created_at']);
$table->index(['tool_name', 'created_at']);
$table->index(['is_sensitive', 'created_at']);
$table->index(['actor_type', 'actor_id']);
});
// Table for tracking sensitive tool definitions
Schema::create('mcp_sensitive_tools', function (Blueprint $table) {
$table->id();
$table->string('tool_name')->unique();
$table->string('reason');
$table->json('redact_fields')->nullable(); // Fields to redact in audit logs
$table->boolean('require_explicit_consent')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('mcp_sensitive_tools');
Schema::dropIfExists('mcp_audit_logs');
}
};

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mcp_tool_versions', function (Blueprint $table) {
$table->id();
$table->string('server_id', 64)->index();
$table->string('tool_name', 128);
$table->string('version', 32); // semver: 1.0.0, 2.1.0-beta, etc.
$table->json('input_schema')->nullable();
$table->json('output_schema')->nullable();
$table->text('description')->nullable();
$table->text('changelog')->nullable();
$table->text('migration_notes')->nullable(); // guidance for upgrading from previous version
$table->boolean('is_latest')->default(false);
$table->timestamp('deprecated_at')->nullable();
$table->timestamp('sunset_at')->nullable(); // after this date, version is blocked
$table->timestamps();
// Unique constraint: one version per tool per server
$table->unique(['server_id', 'tool_name', 'version'], 'mcp_tool_versions_unique');
// Index for finding latest versions
$table->index(['server_id', 'tool_name', 'is_latest'], 'mcp_tool_versions_latest');
// Index for finding deprecated/sunset versions
$table->index(['deprecated_at', 'sunset_at'], 'mcp_tool_versions_lifecycle');
});
}
public function down(): void
{
Schema::dropIfExists('mcp_tool_versions');
}
};

View file

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* MCP API Request - logs full request/response for debugging and replay.
*
* @property int $id
* @property string $request_id
* @property int|null $workspace_id
* @property int|null $api_key_id
* @property string $method
* @property string $path
* @property array $headers
* @property array $request_body
* @property int $response_status
* @property array|null $response_body
* @property int $duration_ms
* @property string|null $server_id
* @property string|null $tool_name
* @property string|null $error_message
* @property string|null $ip_address
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class McpApiRequest extends Model
{
protected $fillable = [
'request_id',
'workspace_id',
'api_key_id',
'method',
'path',
'headers',
'request_body',
'response_status',
'response_body',
'duration_ms',
'server_id',
'tool_name',
'error_message',
'ip_address',
];
protected $casts = [
'headers' => 'array',
'request_body' => 'array',
'response_body' => 'array',
'duration_ms' => 'integer',
'response_status' => 'integer',
];
/**
* Log an API request.
*/
public static function log(
string $method,
string $path,
array $requestBody,
int $responseStatus,
?array $responseBody = null,
int $durationMs = 0,
?int $workspaceId = null,
?int $apiKeyId = null,
?string $serverId = null,
?string $toolName = null,
?string $errorMessage = null,
?string $ipAddress = null,
array $headers = []
): self {
// Sanitise headers - remove sensitive info
$sanitisedHeaders = collect($headers)
->except(['authorization', 'x-api-key', 'cookie'])
->toArray();
return static::create([
'request_id' => 'req_'.Str::random(20),
'workspace_id' => $workspaceId,
'api_key_id' => $apiKeyId,
'method' => $method,
'path' => $path,
'headers' => $sanitisedHeaders,
'request_body' => $requestBody,
'response_status' => $responseStatus,
'response_body' => $responseBody,
'duration_ms' => $durationMs,
'server_id' => $serverId,
'tool_name' => $toolName,
'error_message' => $errorMessage,
'ip_address' => $ipAddress,
]);
}
/**
* Generate curl command to replay this request.
*/
public function toCurl(string $apiKey = 'YOUR_API_KEY'): string
{
$url = config('app.url').'/api/v1/mcp'.$this->path;
$curl = "curl -X {$this->method} \"{$url}\"";
$curl .= " \\\n -H \"Authorization: Bearer {$apiKey}\"";
$curl .= " \\\n -H \"Content-Type: application/json\"";
if (! empty($this->request_body)) {
$curl .= " \\\n -d '".json_encode($this->request_body)."'";
}
return $curl;
}
/**
* Get duration formatted for humans.
*/
public function getDurationForHumansAttribute(): string
{
if ($this->duration_ms < 1000) {
return $this->duration_ms.'ms';
}
return round($this->duration_ms / 1000, 2).'s';
}
/**
* Check if request was successful.
*/
public function isSuccessful(): bool
{
return $this->response_status >= 200 && $this->response_status < 300;
}
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
// Scopes
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
public function scopeForServer(Builder $query, string $serverId): Builder
{
return $query->where('server_id', $serverId);
}
public function scopeForTool(Builder $query, string $toolName): Builder
{
return $query->where('tool_name', $toolName);
}
public function scopeFailed(Builder $query): Builder
{
return $query->where('response_status', '>=', 400);
}
public function scopeSuccessful(Builder $query): Builder
{
return $query->whereBetween('response_status', [200, 299]);
}
public function scopeRecent(Builder $query, int $hours = 24): Builder
{
return $query->where('created_at', '>=', now()->subHours($hours));
}
}

View file

@ -0,0 +1,383 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Models;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* MCP Audit Log - immutable audit trail for MCP tool executions.
*
* Implements a hash chain for tamper detection. Each entry contains
* a hash of the previous entry, creating a verifiable chain of custody.
*
* @property int $id
* @property string $server_id
* @property string $tool_name
* @property int|null $workspace_id
* @property string|null $session_id
* @property array|null $input_params
* @property array|null $output_summary
* @property bool $success
* @property int|null $duration_ms
* @property string|null $error_code
* @property string|null $error_message
* @property string|null $actor_type
* @property int|null $actor_id
* @property string|null $actor_ip
* @property bool $is_sensitive
* @property string|null $sensitivity_reason
* @property string|null $previous_hash
* @property string $entry_hash
* @property string|null $agent_type
* @property string|null $plan_slug
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class McpAuditLog extends Model
{
use BelongsToWorkspace;
/**
* Actor types.
*/
public const ACTOR_USER = 'user';
public const ACTOR_API_KEY = 'api_key';
public const ACTOR_SYSTEM = 'system';
/**
* The table associated with the model.
*/
protected $table = 'mcp_audit_logs';
/**
* Indicates if the model should be timestamped.
* We handle timestamps manually for immutability.
*/
public $timestamps = true;
/**
* The attributes that are mass assignable.
*/
protected $fillable = [
'server_id',
'tool_name',
'workspace_id',
'session_id',
'input_params',
'output_summary',
'success',
'duration_ms',
'error_code',
'error_message',
'actor_type',
'actor_id',
'actor_ip',
'is_sensitive',
'sensitivity_reason',
'previous_hash',
'entry_hash',
'agent_type',
'plan_slug',
];
/**
* The attributes that should be cast.
*/
protected $casts = [
'input_params' => 'array',
'output_summary' => 'array',
'success' => 'boolean',
'duration_ms' => 'integer',
'actor_id' => 'integer',
'is_sensitive' => 'boolean',
'created_at' => 'datetime',
];
/**
* Boot the model.
*/
protected static function boot(): void
{
parent::boot();
// Prevent updates to maintain immutability
static::updating(function (self $model) {
// Allow only specific fields to be updated (for soft operations)
$allowedChanges = ['updated_at'];
$changes = array_keys($model->getDirty());
foreach ($changes as $change) {
if (! in_array($change, $allowedChanges)) {
throw new \RuntimeException(
'Audit log entries are immutable. Cannot modify: '.$change
);
}
}
});
// Prevent deletion
static::deleting(function () {
throw new \RuntimeException(
'Audit log entries cannot be deleted. They are immutable for compliance purposes.'
);
});
}
// -------------------------------------------------------------------------
// Relationships
// -------------------------------------------------------------------------
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
/**
* Filter by server.
*/
public function scopeForServer(Builder $query, string $serverId): Builder
{
return $query->where('server_id', $serverId);
}
/**
* Filter by tool name.
*/
public function scopeForTool(Builder $query, string $toolName): Builder
{
return $query->where('tool_name', $toolName);
}
/**
* Filter by session.
*/
public function scopeForSession(Builder $query, string $sessionId): Builder
{
return $query->where('session_id', $sessionId);
}
/**
* Filter successful calls.
*/
public function scopeSuccessful(Builder $query): Builder
{
return $query->where('success', true);
}
/**
* Filter failed calls.
*/
public function scopeFailed(Builder $query): Builder
{
return $query->where('success', false);
}
/**
* Filter sensitive tool calls.
*/
public function scopeSensitive(Builder $query): Builder
{
return $query->where('is_sensitive', true);
}
/**
* Filter by actor type.
*/
public function scopeByActorType(Builder $query, string $actorType): Builder
{
return $query->where('actor_type', $actorType);
}
/**
* Filter by actor.
*/
public function scopeByActor(Builder $query, string $actorType, int $actorId): Builder
{
return $query->where('actor_type', $actorType)
->where('actor_id', $actorId);
}
/**
* Filter by date range.
*/
public function scopeInDateRange(Builder $query, string|\DateTimeInterface $start, string|\DateTimeInterface $end): Builder
{
return $query->whereBetween('created_at', [$start, $end]);
}
/**
* Filter for today.
*/
public function scopeToday(Builder $query): Builder
{
return $query->whereDate('created_at', today());
}
/**
* Filter for last N days.
*/
public function scopeLastDays(Builder $query, int $days): Builder
{
return $query->where('created_at', '>=', now()->subDays($days));
}
// -------------------------------------------------------------------------
// Hash Chain Methods
// -------------------------------------------------------------------------
/**
* Compute the hash for this entry.
* Uses SHA-256 to create a deterministic hash of the entry data.
*/
public function computeHash(): string
{
$data = [
'id' => $this->id,
'server_id' => $this->server_id,
'tool_name' => $this->tool_name,
'workspace_id' => $this->workspace_id,
'session_id' => $this->session_id,
'input_params' => $this->input_params,
'output_summary' => $this->output_summary,
'success' => $this->success,
'duration_ms' => $this->duration_ms,
'error_code' => $this->error_code,
'actor_type' => $this->actor_type,
'actor_id' => $this->actor_id,
'actor_ip' => $this->actor_ip,
'is_sensitive' => $this->is_sensitive,
'previous_hash' => $this->previous_hash,
'created_at' => $this->created_at?->toIso8601String(),
];
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
}
/**
* Verify this entry's hash is valid.
*/
public function verifyHash(): bool
{
return $this->entry_hash === $this->computeHash();
}
/**
* Verify the chain link to the previous entry.
*/
public function verifyChainLink(): bool
{
if ($this->previous_hash === null) {
// First entry in chain - check there's no earlier entry
return ! static::where('id', '<', $this->id)->exists();
}
$previous = static::where('id', '<', $this->id)
->orderByDesc('id')
->first();
if (! $previous) {
return false; // Previous entry missing
}
return $this->previous_hash === $previous->entry_hash;
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Get duration formatted for humans.
*/
public function getDurationForHumans(): string
{
if (! $this->duration_ms) {
return '-';
}
if ($this->duration_ms < 1000) {
return $this->duration_ms.'ms';
}
return round($this->duration_ms / 1000, 2).'s';
}
/**
* Get actor display name.
*/
public function getActorDisplay(): string
{
return match ($this->actor_type) {
self::ACTOR_USER => "User #{$this->actor_id}",
self::ACTOR_API_KEY => "API Key #{$this->actor_id}",
self::ACTOR_SYSTEM => 'System',
default => 'Unknown',
};
}
/**
* Check if this entry has integrity issues.
*/
public function hasIntegrityIssues(): bool
{
return ! $this->verifyHash() || ! $this->verifyChainLink();
}
/**
* Get integrity status.
*/
public function getIntegrityStatus(): array
{
$hashValid = $this->verifyHash();
$chainValid = $this->verifyChainLink();
return [
'valid' => $hashValid && $chainValid,
'hash_valid' => $hashValid,
'chain_valid' => $chainValid,
'issues' => array_filter([
! $hashValid ? 'Entry hash mismatch - data may have been tampered' : null,
! $chainValid ? 'Chain link broken - previous entry missing or modified' : null,
]),
];
}
/**
* Convert to array for export.
*/
public function toExportArray(): array
{
return [
'id' => $this->id,
'timestamp' => $this->created_at->toIso8601String(),
'server_id' => $this->server_id,
'tool_name' => $this->tool_name,
'workspace_id' => $this->workspace_id,
'session_id' => $this->session_id,
'success' => $this->success,
'duration_ms' => $this->duration_ms,
'error_code' => $this->error_code,
'actor_type' => $this->actor_type,
'actor_id' => $this->actor_id,
'actor_ip' => $this->actor_ip,
'is_sensitive' => $this->is_sensitive,
'sensitivity_reason' => $this->sensitivity_reason,
'entry_hash' => $this->entry_hash,
'previous_hash' => $this->previous_hash,
'agent_type' => $this->agent_type,
'plan_slug' => $this->plan_slug,
];
}
}

View file

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* MCP Sensitive Tool - defines tools requiring stricter auditing.
*
* Used by AuditLogService to determine which tools need:
* - Enhanced logging with is_sensitive flag
* - Field redaction for privacy
* - Explicit consent requirements
*
* @property int $id
* @property string $tool_name
* @property string $reason
* @property array|null $redact_fields
* @property bool $require_explicit_consent
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class McpSensitiveTool extends Model
{
protected $table = 'mcp_sensitive_tools';
protected $fillable = [
'tool_name',
'reason',
'redact_fields',
'require_explicit_consent',
];
protected $casts = [
'redact_fields' => 'array',
'require_explicit_consent' => 'boolean',
];
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
/**
* Find by tool name.
*/
public function scopeForTool(Builder $query, string $toolName): Builder
{
return $query->where('tool_name', $toolName);
}
/**
* Filter tools requiring explicit consent.
*/
public function scopeRequiringConsent(Builder $query): Builder
{
return $query->where('require_explicit_consent', true);
}
// -------------------------------------------------------------------------
// Static Methods
// -------------------------------------------------------------------------
/**
* Check if a tool is marked as sensitive.
*/
public static function isSensitive(string $toolName): bool
{
return static::where('tool_name', $toolName)->exists();
}
/**
* Get sensitivity info for a tool.
*/
public static function getSensitivityInfo(string $toolName): ?array
{
$tool = static::where('tool_name', $toolName)->first();
if (! $tool) {
return null;
}
return [
'is_sensitive' => true,
'reason' => $tool->reason,
'redact_fields' => $tool->redact_fields ?? [],
'require_explicit_consent' => $tool->require_explicit_consent,
];
}
/**
* Register a sensitive tool.
*/
public static function register(
string $toolName,
string $reason,
array $redactFields = [],
bool $requireConsent = false
): self {
return static::updateOrCreate(
['tool_name' => $toolName],
[
'reason' => $reason,
'redact_fields' => $redactFields,
'require_explicit_consent' => $requireConsent,
]
);
}
/**
* Unregister a sensitive tool.
*/
public static function unregister(string $toolName): bool
{
return static::where('tool_name', $toolName)->delete() > 0;
}
/**
* Get all sensitive tool names.
*/
public static function getAllToolNames(): array
{
return static::pluck('tool_name')->toArray();
}
}

View file

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Models;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* MCP Tool Call - logs individual MCP tool invocations.
*
* Tracks tool usage for analytics and debugging.
* Updates daily aggregates automatically.
*
* @property int $id
* @property int|null $workspace_id
* @property string $server_id
* @property string $tool_name
* @property string|null $session_id
* @property array|null $input_params
* @property bool $success
* @property int|null $duration_ms
* @property string|null $error_message
* @property string|null $error_code
* @property array|null $result_summary
* @property string|null $agent_type
* @property string|null $plan_slug
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class McpToolCall extends Model
{
use BelongsToWorkspace;
protected $fillable = [
'workspace_id',
'server_id',
'tool_name',
'session_id',
'input_params',
'success',
'duration_ms',
'error_message',
'error_code',
'result_summary',
'agent_type',
'plan_slug',
];
protected $casts = [
'input_params' => 'array',
'result_summary' => 'array',
'success' => 'boolean',
'duration_ms' => 'integer',
];
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
// Scopes
public function scopeForServer(Builder $query, string $serverId): Builder
{
return $query->where('server_id', $serverId);
}
public function scopeForTool(Builder $query, string $toolName): Builder
{
return $query->where('tool_name', $toolName);
}
public function scopeSuccessful(Builder $query): Builder
{
return $query->where('success', true);
}
public function scopeFailed(Builder $query): Builder
{
return $query->where('success', false);
}
public function scopeRecent(Builder $query, int $hours = 24): Builder
{
return $query->where('created_at', '>=', now()->subHours($hours));
}
public function scopeToday(Builder $query): Builder
{
return $query->whereDate('created_at', today());
}
public function scopeThisWeek(Builder $query): Builder
{
return $query->where('created_at', '>=', now()->startOfWeek());
}
/**
* Log a tool call and update daily stats.
*/
public static function log(
string $serverId,
string $toolName,
array $params = [],
bool $success = true,
?int $durationMs = null,
?string $errorMessage = null,
?string $errorCode = null,
?array $resultSummary = null,
?string $sessionId = null,
?string $agentType = null,
?string $planSlug = null,
?int $workspaceId = null
): self {
$call = static::create([
'workspace_id' => $workspaceId,
'server_id' => $serverId,
'tool_name' => $toolName,
'input_params' => $params,
'success' => $success,
'duration_ms' => $durationMs,
'error_message' => $errorMessage,
'error_code' => $errorCode,
'result_summary' => $resultSummary,
'session_id' => $sessionId,
'agent_type' => $agentType,
'plan_slug' => $planSlug,
]);
// Update daily stats
McpToolCallStat::incrementForCall($call);
return $call;
}
// Helpers
public function getDurationForHumans(): string
{
if (! $this->duration_ms) {
return '-';
}
if ($this->duration_ms < 1000) {
return $this->duration_ms.'ms';
}
return round($this->duration_ms / 1000, 2).'s';
}
public function getStatusBadge(): string
{
return $this->success
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">Success</span>'
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">Failed</span>';
}
}

View file

@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Models;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* MCP Tool Call Stats - daily aggregates for MCP tool calls.
*
* Provides efficient querying for dashboards and reports.
* Updated automatically when McpToolCall::log() is called.
*
* @property int $id
* @property int|null $workspace_id
* @property \Carbon\Carbon $date
* @property string $server_id
* @property string $tool_name
* @property int $call_count
* @property int $success_count
* @property int $error_count
* @property int $total_duration_ms
* @property int|null $min_duration_ms
* @property int|null $max_duration_ms
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class McpToolCallStat extends Model
{
use BelongsToWorkspace;
protected $fillable = [
'workspace_id',
'date',
'server_id',
'tool_name',
'call_count',
'success_count',
'error_count',
'total_duration_ms',
'min_duration_ms',
'max_duration_ms',
];
protected $casts = [
'date' => 'date',
'call_count' => 'integer',
'success_count' => 'integer',
'error_count' => 'integer',
'total_duration_ms' => 'integer',
'min_duration_ms' => 'integer',
'max_duration_ms' => 'integer',
];
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
// Scopes
public function scopeForServer(Builder $query, string $serverId): Builder
{
return $query->where('server_id', $serverId);
}
public function scopeForTool(Builder $query, string $toolName): Builder
{
return $query->where('tool_name', $toolName);
}
public function scopeForDate(Builder $query, Carbon|string $date): Builder
{
$date = $date instanceof Carbon ? $date->toDateString() : $date;
return $query->where('date', $date);
}
public function scopeForDateRange(Builder $query, Carbon|string $start, Carbon|string $end): Builder
{
$start = $start instanceof Carbon ? $start->toDateString() : $start;
$end = $end instanceof Carbon ? $end->toDateString() : $end;
return $query->whereBetween('date', [$start, $end]);
}
public function scopeLast7Days(Builder $query): Builder
{
return $query->forDateRange(now()->subDays(6), now());
}
public function scopeLast30Days(Builder $query): Builder
{
return $query->forDateRange(now()->subDays(29), now());
}
/**
* Increment stats for a tool call.
*/
public static function incrementForCall(McpToolCall $call): void
{
$stat = static::firstOrCreate([
'date' => $call->created_at->toDateString(),
'server_id' => $call->server_id,
'tool_name' => $call->tool_name,
'workspace_id' => $call->workspace_id,
], [
'call_count' => 0,
'success_count' => 0,
'error_count' => 0,
'total_duration_ms' => 0,
]);
$stat->call_count++;
if ($call->success) {
$stat->success_count++;
} else {
$stat->error_count++;
}
if ($call->duration_ms) {
$stat->total_duration_ms += $call->duration_ms;
if ($stat->min_duration_ms === null || $call->duration_ms < $stat->min_duration_ms) {
$stat->min_duration_ms = $call->duration_ms;
}
if ($stat->max_duration_ms === null || $call->duration_ms > $stat->max_duration_ms) {
$stat->max_duration_ms = $call->duration_ms;
}
}
$stat->save();
}
// Computed attributes
public function getSuccessRateAttribute(): float
{
if ($this->call_count === 0) {
return 0;
}
return round(($this->success_count / $this->call_count) * 100, 1);
}
public function getAvgDurationMsAttribute(): ?float
{
if ($this->call_count === 0 || $this->total_duration_ms === 0) {
return null;
}
return round($this->total_duration_ms / $this->call_count, 1);
}
public function getAvgDurationForHumansAttribute(): string
{
$avg = $this->avg_duration_ms;
if ($avg === null) {
return '-';
}
if ($avg < 1000) {
return round($avg).'ms';
}
return round($avg / 1000, 2).'s';
}
/**
* Get top tools by call count.
*/
public static function getTopTools(int $days = 7, int $limit = 10, ?int $workspaceId = null): Collection
{
$query = static::query()
->select('server_id', 'tool_name')
->selectRaw('SUM(call_count) as total_calls')
->selectRaw('SUM(success_count) as total_success')
->selectRaw('SUM(error_count) as total_errors')
->selectRaw('AVG(total_duration_ms / NULLIF(call_count, 0)) as avg_duration')
->forDateRange(now()->subDays($days - 1), now())
->groupBy('server_id', 'tool_name')
->orderByDesc('total_calls')
->limit($limit);
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
return $query->get()
->map(function ($item) {
$item->success_rate = $item->total_calls > 0
? round(($item->total_success / $item->total_calls) * 100, 1)
: 0;
return $item;
});
}
/**
* Get daily trend data.
*/
public static function getDailyTrend(int $days = 7, ?int $workspaceId = null): Collection
{
$query = static::query()
->select('date')
->selectRaw('SUM(call_count) as total_calls')
->selectRaw('SUM(success_count) as total_success')
->selectRaw('SUM(error_count) as total_errors')
->forDateRange(now()->subDays($days - 1), now())
->groupBy('date')
->orderBy('date');
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
return $query->get()
->map(function ($item) {
$item->success_rate = $item->total_calls > 0
? round(($item->total_success / $item->total_calls) * 100, 1)
: 0;
return $item;
});
}
/**
* Get server-level statistics.
*/
public static function getServerStats(int $days = 7, ?int $workspaceId = null): Collection
{
$query = static::query()
->select('server_id')
->selectRaw('SUM(call_count) as total_calls')
->selectRaw('SUM(success_count) as total_success')
->selectRaw('SUM(error_count) as total_errors')
->selectRaw('COUNT(DISTINCT tool_name) as unique_tools')
->forDateRange(now()->subDays($days - 1), now())
->groupBy('server_id')
->orderByDesc('total_calls');
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
return $query->get()
->map(function ($item) {
$item->success_rate = $item->total_calls > 0
? round(($item->total_success / $item->total_calls) * 100, 1)
: 0;
return $item;
});
}
}

View file

@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* MCP Tool Version - tracks versioned tool schemas for backwards compatibility.
*
* Enables running agents to continue using older tool versions while
* newer versions are deployed. Supports deprecation lifecycle with
* warnings and eventual sunset blocking.
*
* @property int $id
* @property string $server_id
* @property string $tool_name
* @property string $version
* @property array|null $input_schema
* @property array|null $output_schema
* @property string|null $description
* @property string|null $changelog
* @property string|null $migration_notes
* @property bool $is_latest
* @property \Carbon\Carbon|null $deprecated_at
* @property \Carbon\Carbon|null $sunset_at
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property-read bool $is_deprecated
* @property-read bool $is_sunset
* @property-read string $status
* @property-read string $full_name
*/
class McpToolVersion extends Model
{
protected $table = 'mcp_tool_versions';
protected $fillable = [
'server_id',
'tool_name',
'version',
'input_schema',
'output_schema',
'description',
'changelog',
'migration_notes',
'is_latest',
'deprecated_at',
'sunset_at',
];
protected $casts = [
'input_schema' => 'array',
'output_schema' => 'array',
'is_latest' => 'boolean',
'deprecated_at' => 'datetime',
'sunset_at' => 'datetime',
];
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
/**
* Filter by server.
*/
public function scopeForServer(Builder $query, string $serverId): Builder
{
return $query->where('server_id', $serverId);
}
/**
* Filter by tool name.
*/
public function scopeForTool(Builder $query, string $toolName): Builder
{
return $query->where('tool_name', $toolName);
}
/**
* Filter by specific version.
*/
public function scopeForVersion(Builder $query, string $version): Builder
{
return $query->where('version', $version);
}
/**
* Get only latest versions.
*/
public function scopeLatest(Builder $query): Builder
{
return $query->where('is_latest', true);
}
/**
* Get deprecated versions.
*/
public function scopeDeprecated(Builder $query): Builder
{
return $query->whereNotNull('deprecated_at')
->where('deprecated_at', '<=', now());
}
/**
* Get sunset versions (blocked).
*/
public function scopeSunset(Builder $query): Builder
{
return $query->whereNotNull('sunset_at')
->where('sunset_at', '<=', now());
}
/**
* Get active versions (not sunset).
*/
public function scopeActive(Builder $query): Builder
{
return $query->where(function ($q) {
$q->whereNull('sunset_at')
->orWhere('sunset_at', '>', now());
});
}
/**
* Order by version (newest first using semver sort).
*/
public function scopeOrderByVersion(Builder $query, string $direction = 'desc'): Builder
{
// Basic version ordering - splits on dots and orders numerically
// For production use, consider a more robust semver sorting approach
return $query->orderByRaw(
"CAST(SUBSTRING_INDEX(version, '.', 1) AS UNSIGNED) {$direction}, ".
"CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 2), '.', -1) AS UNSIGNED) {$direction}, ".
"CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 3), '.', -1) AS UNSIGNED) {$direction}"
);
}
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
/**
* Check if this version is deprecated.
*/
public function getIsDeprecatedAttribute(): bool
{
return $this->deprecated_at !== null && $this->deprecated_at->isPast();
}
/**
* Check if this version is sunset (blocked).
*/
public function getIsSunsetAttribute(): bool
{
return $this->sunset_at !== null && $this->sunset_at->isPast();
}
/**
* Get the lifecycle status of this version.
*/
public function getStatusAttribute(): string
{
if ($this->is_sunset) {
return 'sunset';
}
if ($this->is_deprecated) {
return 'deprecated';
}
if ($this->is_latest) {
return 'latest';
}
return 'active';
}
/**
* Get full tool identifier (server:tool).
*/
public function getFullNameAttribute(): string
{
return "{$this->server_id}:{$this->tool_name}";
}
/**
* Get full versioned identifier (server:tool@version).
*/
public function getVersionedNameAttribute(): string
{
return "{$this->server_id}:{$this->tool_name}@{$this->version}";
}
// -------------------------------------------------------------------------
// Methods
// -------------------------------------------------------------------------
/**
* Get deprecation warning message if deprecated but not sunset.
*/
public function getDeprecationWarning(): ?array
{
if (! $this->is_deprecated || $this->is_sunset) {
return null;
}
$warning = [
'code' => 'TOOL_VERSION_DEPRECATED',
'message' => "Tool version {$this->version} is deprecated.",
'current_version' => $this->version,
];
// Find the latest version to suggest
$latest = static::forServer($this->server_id)
->forTool($this->tool_name)
->latest()
->first();
if ($latest && $latest->version !== $this->version) {
$warning['latest_version'] = $latest->version;
$warning['message'] .= " Please upgrade to version {$latest->version}.";
}
if ($this->sunset_at) {
$warning['sunset_at'] = $this->sunset_at->toIso8601String();
$warning['message'] .= " This version will be blocked after {$this->sunset_at->format('Y-m-d')}.";
}
if ($this->migration_notes) {
$warning['migration_notes'] = $this->migration_notes;
}
return $warning;
}
/**
* Get sunset error if this version is blocked.
*/
public function getSunsetError(): ?array
{
if (! $this->is_sunset) {
return null;
}
$error = [
'code' => 'TOOL_VERSION_SUNSET',
'message' => "Tool version {$this->version} is no longer available as of {$this->sunset_at->format('Y-m-d')}.",
'sunset_version' => $this->version,
'sunset_at' => $this->sunset_at->toIso8601String(),
];
// Find the latest version to suggest
$latest = static::forServer($this->server_id)
->forTool($this->tool_name)
->latest()
->first();
if ($latest && $latest->version !== $this->version) {
$error['latest_version'] = $latest->version;
$error['message'] .= " Please use version {$latest->version} instead.";
}
if ($this->migration_notes) {
$error['migration_notes'] = $this->migration_notes;
}
return $error;
}
/**
* Compare schemas between this version and another.
*
* @return array{added: array, removed: array, changed: array}
*/
public function compareSchemaWith(self $other): array
{
$thisProps = $this->input_schema['properties'] ?? [];
$otherProps = $other->input_schema['properties'] ?? [];
$added = array_diff_key($otherProps, $thisProps);
$removed = array_diff_key($thisProps, $otherProps);
$changed = [];
foreach (array_intersect_key($thisProps, $otherProps) as $key => $thisProp) {
$otherProp = $otherProps[$key];
if (json_encode($thisProp) !== json_encode($otherProp)) {
$changed[$key] = [
'from' => $thisProp,
'to' => $otherProp,
];
}
}
return [
'added' => array_keys($added),
'removed' => array_keys($removed),
'changed' => $changed,
];
}
/**
* Mark this version as deprecated.
*/
public function deprecate(?Carbon $sunsetAt = null): self
{
$this->deprecated_at = now();
if ($sunsetAt) {
$this->sunset_at = $sunsetAt;
}
$this->save();
return $this;
}
/**
* Mark this version as the latest (and unmark others).
*/
public function markAsLatest(): self
{
// Unmark all other versions for this tool
static::forServer($this->server_id)
->forTool($this->tool_name)
->where('id', '!=', $this->id)
->update(['is_latest' => false]);
$this->is_latest = true;
$this->save();
return $this;
}
/**
* Export version info for API responses.
*/
public function toApiArray(): array
{
return [
'server_id' => $this->server_id,
'tool_name' => $this->tool_name,
'version' => $this->version,
'is_latest' => $this->is_latest,
'status' => $this->status,
'description' => $this->description,
'input_schema' => $this->input_schema,
'output_schema' => $this->output_schema,
'deprecated_at' => $this->deprecated_at?->toIso8601String(),
'sunset_at' => $this->sunset_at?->toIso8601String(),
'migration_notes' => $this->migration_notes,
'changelog' => $this->changelog,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View file

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Models;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* MCP Usage Quota - tracks monthly workspace MCP usage.
*
* Stores monthly aggregated usage for tool calls and token consumption
* to enforce workspace-level quotas.
*
* @property int $id
* @property int $workspace_id
* @property string $month YYYY-MM format
* @property int $tool_calls_count
* @property int $input_tokens
* @property int $output_tokens
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class McpUsageQuota extends Model
{
use BelongsToWorkspace;
protected $table = 'mcp_usage_quotas';
protected $fillable = [
'workspace_id',
'month',
'tool_calls_count',
'input_tokens',
'output_tokens',
];
protected $casts = [
'tool_calls_count' => 'integer',
'input_tokens' => 'integer',
'output_tokens' => 'integer',
];
// ─────────────────────────────────────────────────────────────────────────
// Relationships
// ─────────────────────────────────────────────────────────────────────────
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
// ─────────────────────────────────────────────────────────────────────────
// Scopes
// ─────────────────────────────────────────────────────────────────────────
public function scopeForMonth(Builder $query, string $month): Builder
{
return $query->where('month', $month);
}
public function scopeCurrentMonth(Builder $query): Builder
{
return $query->where('month', now()->format('Y-m'));
}
// ─────────────────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Get or create usage quota record for a workspace and month.
*/
public static function getOrCreate(int $workspaceId, ?string $month = null): self
{
$month = $month ?? now()->format('Y-m');
return static::firstOrCreate(
[
'workspace_id' => $workspaceId,
'month' => $month,
],
[
'tool_calls_count' => 0,
'input_tokens' => 0,
'output_tokens' => 0,
]
);
}
/**
* Get current month's quota for a workspace.
*/
public static function getCurrentForWorkspace(int $workspaceId): self
{
return static::getOrCreate($workspaceId);
}
// ─────────────────────────────────────────────────────────────────────────
// Usage Recording
// ─────────────────────────────────────────────────────────────────────────
/**
* Record usage (increments counters atomically).
*/
public function recordUsage(int $toolCalls = 1, int $inputTokens = 0, int $outputTokens = 0): self
{
$this->increment('tool_calls_count', $toolCalls);
if ($inputTokens > 0) {
$this->increment('input_tokens', $inputTokens);
}
if ($outputTokens > 0) {
$this->increment('output_tokens', $outputTokens);
}
return $this->fresh();
}
/**
* Record usage for a workspace (static convenience method).
*/
public static function record(
int $workspaceId,
int $toolCalls = 1,
int $inputTokens = 0,
int $outputTokens = 0
): self {
$quota = static::getCurrentForWorkspace($workspaceId);
return $quota->recordUsage($toolCalls, $inputTokens, $outputTokens);
}
// ─────────────────────────────────────────────────────────────────────────
// Computed Attributes
// ─────────────────────────────────────────────────────────────────────────
/**
* Get total tokens (input + output).
*/
public function getTotalTokensAttribute(): int
{
return $this->input_tokens + $this->output_tokens;
}
/**
* Get formatted month (e.g., "January 2026").
*/
public function getMonthLabelAttribute(): string
{
return \Carbon\Carbon::createFromFormat('Y-m', $this->month)->format('F Y');
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Reset usage counters (for billing cycle reset).
*/
public function reset(): self
{
$this->update([
'tool_calls_count' => 0,
'input_tokens' => 0,
'output_tokens' => 0,
]);
return $this;
}
/**
* Convert to array for API responses.
*/
public function toArray(): array
{
return [
'workspace_id' => $this->workspace_id,
'month' => $this->month,
'month_label' => $this->month_label,
'tool_calls_count' => $this->tool_calls_count,
'input_tokens' => $this->input_tokens,
'output_tokens' => $this->output_tokens,
'total_tokens' => $this->total_tokens,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View file

@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Tool Metric - daily aggregates for MCP tool usage analytics.
*
* Tracks per-tool call counts, error rates, and response times.
* Updated automatically via ToolAnalyticsService.
*
* @property int $id
* @property string $tool_name
* @property string|null $workspace_id
* @property int $call_count
* @property int $error_count
* @property int $total_duration_ms
* @property int|null $min_duration_ms
* @property int|null $max_duration_ms
* @property \Carbon\Carbon $date
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property-read float $average_duration
* @property-read float $error_rate
*/
class ToolMetric extends Model
{
protected $table = 'mcp_tool_metrics';
protected $fillable = [
'tool_name',
'workspace_id',
'call_count',
'error_count',
'total_duration_ms',
'min_duration_ms',
'max_duration_ms',
'date',
];
protected $casts = [
'date' => 'date',
'call_count' => 'integer',
'error_count' => 'integer',
'total_duration_ms' => 'integer',
'min_duration_ms' => 'integer',
'max_duration_ms' => 'integer',
];
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
/**
* Filter metrics for a specific tool.
*/
public function scopeForTool(Builder $query, string $toolName): Builder
{
return $query->where('tool_name', $toolName);
}
/**
* Filter metrics for a specific workspace.
*/
public function scopeForWorkspace(Builder $query, ?string $workspaceId): Builder
{
if ($workspaceId === null) {
return $query->whereNull('workspace_id');
}
return $query->where('workspace_id', $workspaceId);
}
/**
* Filter metrics within a date range.
*/
public function scopeForDateRange(Builder $query, Carbon|string $start, Carbon|string $end): Builder
{
$start = $start instanceof Carbon ? $start->toDateString() : $start;
$end = $end instanceof Carbon ? $end->toDateString() : $end;
return $query->whereBetween('date', [$start, $end]);
}
/**
* Filter metrics for today.
*/
public function scopeToday(Builder $query): Builder
{
return $query->where('date', today()->toDateString());
}
/**
* Filter metrics for the last N days.
*/
public function scopeLastDays(Builder $query, int $days): Builder
{
return $query->forDateRange(now()->subDays($days - 1), now());
}
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
/**
* Get the average duration in milliseconds.
*/
public function getAverageDurationAttribute(): float
{
if ($this->call_count === 0 || $this->total_duration_ms === 0) {
return 0.0;
}
return round($this->total_duration_ms / $this->call_count, 2);
}
/**
* Get the error rate as a percentage (0-100).
*/
public function getErrorRateAttribute(): float
{
if ($this->call_count === 0) {
return 0.0;
}
return round(($this->error_count / $this->call_count) * 100, 2);
}
/**
* Get average duration formatted for display.
*/
public function getAverageDurationForHumansAttribute(): string
{
$avg = $this->average_duration;
if ($avg === 0.0) {
return '-';
}
if ($avg < 1000) {
return round($avg).'ms';
}
return round($avg / 1000, 2).'s';
}
// -------------------------------------------------------------------------
// Methods
// -------------------------------------------------------------------------
/**
* Record a successful tool call.
*/
public static function recordCall(
string $toolName,
int $durationMs,
?string $workspaceId = null,
?Carbon $date = null
): self {
$date = $date ?? now();
$metric = static::firstOrCreate([
'tool_name' => $toolName,
'workspace_id' => $workspaceId,
'date' => $date->toDateString(),
], [
'call_count' => 0,
'error_count' => 0,
'total_duration_ms' => 0,
]);
$metric->call_count++;
$metric->total_duration_ms += $durationMs;
if ($metric->min_duration_ms === null || $durationMs < $metric->min_duration_ms) {
$metric->min_duration_ms = $durationMs;
}
if ($metric->max_duration_ms === null || $durationMs > $metric->max_duration_ms) {
$metric->max_duration_ms = $durationMs;
}
$metric->save();
return $metric;
}
/**
* Record a failed tool call.
*/
public static function recordError(
string $toolName,
int $durationMs,
?string $workspaceId = null,
?Carbon $date = null
): self {
$date = $date ?? now();
$metric = static::firstOrCreate([
'tool_name' => $toolName,
'workspace_id' => $workspaceId,
'date' => $date->toDateString(),
], [
'call_count' => 0,
'error_count' => 0,
'total_duration_ms' => 0,
]);
$metric->call_count++;
$metric->error_count++;
$metric->total_duration_ms += $durationMs;
if ($metric->min_duration_ms === null || $durationMs < $metric->min_duration_ms) {
$metric->min_duration_ms = $durationMs;
}
if ($metric->max_duration_ms === null || $durationMs > $metric->max_duration_ms) {
$metric->max_duration_ms = $durationMs;
}
$metric->save();
return $metric;
}
/**
* Get aggregated stats for a tool across all dates.
*/
public static function getAggregatedStats(
string $toolName,
?Carbon $from = null,
?Carbon $to = null,
?string $workspaceId = null
): array {
$query = static::forTool($toolName);
if ($from && $to) {
$query->forDateRange($from, $to);
}
if ($workspaceId !== null) {
$query->forWorkspace($workspaceId);
}
$metrics = $query->get();
if ($metrics->isEmpty()) {
return [
'tool_name' => $toolName,
'total_calls' => 0,
'error_count' => 0,
'error_rate' => 0.0,
'avg_duration_ms' => 0.0,
'min_duration_ms' => 0,
'max_duration_ms' => 0,
];
}
$totalCalls = $metrics->sum('call_count');
$errorCount = $metrics->sum('error_count');
$totalDuration = $metrics->sum('total_duration_ms');
return [
'tool_name' => $toolName,
'total_calls' => $totalCalls,
'error_count' => $errorCount,
'error_rate' => $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0,
'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0,
'min_duration_ms' => $metrics->min('min_duration_ms') ?? 0,
'max_duration_ms' => $metrics->max('max_duration_ms') ?? 0,
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace Core\Mod\Mcp\Resources;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
class AppConfig extends Resource
{
protected string $description = 'Application configuration for Host Hub';
public function handle(Request $request): Response
{
$config = [
'name' => config('app.name'),
'env' => config('app.env'),
'debug' => config('app.debug'),
'url' => config('app.url'),
];
return Response::text(json_encode($config, JSON_PRETTY_PRINT));
}
}

View file

@ -0,0 +1,170 @@
<?php
namespace Core\Mod\Mcp\Resources;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Tenant\Models\Workspace;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
/**
* MCP Resource for content items.
*
* Part of TASK-004 Phase 3: MCP Integration.
*
* URI format: content://{workspace}/{slug}
* Returns content as markdown for AI context.
*/
class ContentResource extends Resource
{
protected string $description = 'Content items from the CMS - returns markdown for AI context';
public function handle(Request $request): Response
{
$uri = $request->get('uri', '');
// Parse URI: content://{workspace}/{slug}
if (! str_starts_with($uri, 'content://')) {
return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}');
}
$path = substr($uri, 10); // Remove 'content://'
$parts = explode('/', $path, 2);
if (count($parts) < 2) {
return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}');
}
[$workspaceSlug, $contentSlug] = $parts;
// Resolve workspace
$workspace = Workspace::where('slug', $workspaceSlug)
->orWhere('id', $workspaceSlug)
->first();
if (! $workspace) {
return Response::text("Workspace not found: {$workspaceSlug}");
}
// Find content item
$item = ContentItem::forWorkspace($workspace->id)
->native()
->where('slug', $contentSlug)
->first();
if (! $item) {
// Try by ID
if (is_numeric($contentSlug)) {
$item = ContentItem::forWorkspace($workspace->id)
->native()
->find($contentSlug);
}
}
if (! $item) {
return Response::text("Content not found: {$contentSlug}");
}
// Load relationships
$item->load(['author', 'taxonomies']);
// Return as markdown with frontmatter
$markdown = $this->contentToMarkdown($item, $workspace);
return Response::text($markdown);
}
/**
* Convert content item to markdown with frontmatter.
*/
protected function contentToMarkdown(ContentItem $item, Workspace $workspace): string
{
$md = "---\n";
$md .= "title: \"{$item->title}\"\n";
$md .= "slug: {$item->slug}\n";
$md .= "workspace: {$workspace->slug}\n";
$md .= "type: {$item->type}\n";
$md .= "status: {$item->status}\n";
if ($item->author) {
$md .= "author: {$item->author->name}\n";
}
$categories = $item->categories->pluck('name')->all();
if (! empty($categories)) {
$md .= 'categories: ['.implode(', ', $categories)."]\n";
}
$tags = $item->tags->pluck('name')->all();
if (! empty($tags)) {
$md .= 'tags: ['.implode(', ', $tags)."]\n";
}
if ($item->publish_at) {
$md .= 'publish_at: '.$item->publish_at->toIso8601String()."\n";
}
$md .= 'created_at: '.$item->created_at->toIso8601String()."\n";
$md .= 'updated_at: '.$item->updated_at->toIso8601String()."\n";
if ($item->seo_meta) {
if (isset($item->seo_meta['title'])) {
$md .= "seo_title: \"{$item->seo_meta['title']}\"\n";
}
if (isset($item->seo_meta['description'])) {
$md .= "seo_description: \"{$item->seo_meta['description']}\"\n";
}
}
$md .= "---\n\n";
// Add excerpt if available
if ($item->excerpt) {
$md .= "> {$item->excerpt}\n\n";
}
// Prefer markdown content, fall back to stripping HTML (clean > original)
$content = $item->content_markdown
?? strip_tags($item->content_html_clean ?? $item->content_html_original ?? '');
$md .= $content;
return $md;
}
/**
* Get list of available content resources.
*
* This is called when MCP lists available resources.
*/
public static function list(): array
{
$resources = [];
// Get all workspaces with content
$workspaces = Workspace::whereHas('contentItems', function ($q) {
$q->native()->where('status', 'publish');
})->get();
foreach ($workspaces as $workspace) {
// Get published content for this workspace
$items = ContentItem::forWorkspace($workspace->id)
->native()
->published()
->orderByDesc('updated_at')
->limit(50)
->get(['id', 'slug', 'title', 'type']);
foreach ($items as $item) {
$resources[] = [
'uri' => "content://{$workspace->slug}/{$item->slug}",
'name' => $item->title,
'description' => ucfirst($item->type).": {$item->title}",
'mimeType' => 'text/markdown',
];
}
}
return $resources;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Core\Mod\Mcp\Resources;
use Illuminate\Support\Facades\DB;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
class DatabaseSchema extends Resource
{
protected string $description = 'Database schema information for Host Hub';
public function handle(Request $request): Response
{
$schema = collect(DB::select('SHOW TABLES'))
->mapWithKeys(function ($table) {
$tableName = array_values((array) $table)[0];
$columns = DB::select("DESCRIBE {$tableName}");
return [$tableName => $columns];
})
->toArray();
return Response::text(json_encode($schema, JSON_PRETTY_PRINT));
}
}

View file

@ -0,0 +1,70 @@
<?php
use Core\Mod\Mcp\View\Modal\Admin\ApiKeyManager;
use Core\Mod\Mcp\View\Modal\Admin\AuditLogViewer;
use Core\Mod\Mcp\View\Modal\Admin\McpPlayground;
use Core\Mod\Mcp\View\Modal\Admin\Playground;
use Core\Mod\Mcp\View\Modal\Admin\QuotaUsage;
use Core\Mod\Mcp\View\Modal\Admin\RequestLog;
use Core\Mod\Mcp\View\Modal\Admin\ToolAnalyticsDashboard;
use Core\Mod\Mcp\View\Modal\Admin\ToolAnalyticsDetail;
use Core\Mod\Mcp\View\Modal\Admin\ToolVersionManager;
use Core\Website\Mcp\Controllers\McpRegistryController;
use Core\Website\Mcp\View\Modal\Dashboard;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| MCP Admin Routes
|--------------------------------------------------------------------------
|
| Protected routes for MCP portal management.
| Requires authentication via admin middleware.
|
*/
Route::prefix('mcp')->name('mcp.')->group(function () {
// Dashboard (workspace MCP usage overview)
Route::get('dashboard', Dashboard::class)
->name('dashboard');
// API key management
Route::get('keys', ApiKeyManager::class)
->name('keys');
// Enhanced MCP Playground with tool browser, history, and examples
Route::get('playground', McpPlayground::class)
->name('playground');
// Legacy simple playground (API-key focused)
Route::get('playground/simple', Playground::class)
->name('playground.simple');
// Request log for debugging
Route::get('logs', RequestLog::class)
->name('logs');
// Analytics endpoints
Route::get('servers/{id}/analytics', [McpRegistryController::class, 'analytics'])
->name('servers.analytics');
// Tool Usage Analytics Dashboard
Route::get('analytics', ToolAnalyticsDashboard::class)
->name('analytics');
// Single tool analytics detail
Route::get('analytics/tool/{name}', ToolAnalyticsDetail::class)
->name('analytics.tool');
// Audit log viewer (compliance and security)
Route::get('audit-log', AuditLogViewer::class)
->name('audit-log');
// Tool version management (Hades only)
Route::get('versions', ToolVersionManager::class)
->name('versions');
// Quota usage overview
Route::get('quotas', QuotaUsage::class)
->name('quotas');
});

View file

@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace Mod\Mcp\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Mod\Agentic\Models\AgentPlan;
use Mod\Agentic\Models\AgentSession;
/**
* Agent Session Service - manages session persistence for agent continuity.
*
* Provides session creation, retrieval, and resumption capabilities
* for multi-agent handoff and long-running tasks.
*/
class AgentSessionService
{
/**
* Cache prefix for session state.
*/
protected const CACHE_PREFIX = 'mcp_session:';
/**
* Get the cache TTL from config.
*/
protected function getCacheTtl(): int
{
return (int) config('mcp.session.cache_ttl', 86400);
}
/**
* Start a new session.
*/
public function start(
string $agentType,
?AgentPlan $plan = null,
?int $workspaceId = null,
array $initialContext = []
): AgentSession {
$session = AgentSession::start($plan, $agentType);
if ($workspaceId !== null) {
$session->update(['workspace_id' => $workspaceId]);
}
if (! empty($initialContext)) {
$session->updateContextSummary($initialContext);
}
// Cache the active session ID for quick lookup
$this->cacheActiveSession($session);
return $session;
}
/**
* Get an active session by ID.
*/
public function get(string $sessionId): ?AgentSession
{
return AgentSession::where('session_id', $sessionId)->first();
}
/**
* Resume an existing session.
*/
public function resume(string $sessionId): ?AgentSession
{
$session = $this->get($sessionId);
if (! $session) {
return null;
}
// Only resume if paused or was handed off
if ($session->status === AgentSession::STATUS_PAUSED) {
$session->resume();
}
// Update activity timestamp
$session->touchActivity();
// Cache as active
$this->cacheActiveSession($session);
return $session;
}
/**
* Get active sessions for a workspace.
*/
public function getActiveSessions(?int $workspaceId = null): Collection
{
$query = AgentSession::active();
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
return $query->orderBy('last_active_at', 'desc')->get();
}
/**
* Get sessions for a specific plan.
*/
public function getSessionsForPlan(AgentPlan $plan): Collection
{
return AgentSession::forPlan($plan)
->orderBy('created_at', 'desc')
->get();
}
/**
* Get the most recent session for a plan.
*/
public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession
{
return AgentSession::forPlan($plan)
->orderBy('created_at', 'desc')
->first();
}
/**
* End a session.
*/
public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession
{
$session = $this->get($sessionId);
if (! $session) {
return null;
}
$session->end($status, $summary);
// Remove from active cache
$this->clearCachedSession($session);
return $session;
}
/**
* Pause a session for later resumption.
*/
public function pause(string $sessionId): ?AgentSession
{
$session = $this->get($sessionId);
if (! $session) {
return null;
}
$session->pause();
return $session;
}
/**
* Prepare a session for handoff to another agent.
*/
public function prepareHandoff(
string $sessionId,
string $summary,
array $nextSteps = [],
array $blockers = [],
array $contextForNext = []
): ?AgentSession {
$session = $this->get($sessionId);
if (! $session) {
return null;
}
$session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext);
return $session;
}
/**
* Get handoff context from a session.
*/
public function getHandoffContext(string $sessionId): ?array
{
$session = $this->get($sessionId);
if (! $session) {
return null;
}
return $session->getHandoffContext();
}
/**
* Create a follow-up session continuing from a previous one.
*/
public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession
{
$previousSession = $this->get($previousSessionId);
if (! $previousSession) {
return null;
}
// Get the handoff context
$handoffContext = $previousSession->getHandoffContext();
// Create new session with context from previous
$newSession = $this->start(
$newAgentType,
$previousSession->plan,
$previousSession->workspace_id,
[
'continued_from' => $previousSessionId,
'previous_agent' => $previousSession->agent_type,
'handoff_notes' => $handoffContext['handoff_notes'] ?? null,
'inherited_context' => $handoffContext['context_summary'] ?? null,
]
);
// Mark previous session as handed off
$previousSession->end('handed_off', 'Handed off to '.$newAgentType);
return $newSession;
}
/**
* Store custom state in session cache for fast access.
*/
public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void
{
$cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key;
Cache::put($cacheKey, $value, $ttl ?? $this->getCacheTtl());
}
/**
* Get custom state from session cache.
*/
public function getState(string $sessionId, string $key, mixed $default = null): mixed
{
$cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key;
return Cache::get($cacheKey, $default);
}
/**
* Check if a session exists and is valid.
*/
public function exists(string $sessionId): bool
{
return AgentSession::where('session_id', $sessionId)->exists();
}
/**
* Check if a session is active.
*/
public function isActive(string $sessionId): bool
{
$session = $this->get($sessionId);
return $session !== null && $session->isActive();
}
/**
* Get session statistics.
*/
public function getSessionStats(?int $workspaceId = null, int $days = 7): array
{
$query = AgentSession::where('created_at', '>=', now()->subDays($days));
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
$sessions = $query->get();
$byStatus = $sessions->groupBy('status')->map->count();
$byAgent = $sessions->groupBy('agent_type')->map->count();
$completedSessions = $sessions->where('status', AgentSession::STATUS_COMPLETED);
$avgDuration = $completedSessions->avg(fn ($s) => $s->getDuration() ?? 0);
return [
'total' => $sessions->count(),
'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(),
'by_status' => $byStatus->toArray(),
'by_agent_type' => $byAgent->toArray(),
'avg_duration_minutes' => round($avgDuration, 1),
'period_days' => $days,
];
}
/**
* Clean up stale sessions (active but not touched in X hours).
*/
public function cleanupStaleSessions(int $hoursInactive = 24): int
{
$cutoff = now()->subHours($hoursInactive);
$staleSessions = AgentSession::active()
->where('last_active_at', '<', $cutoff)
->get();
foreach ($staleSessions as $session) {
$session->fail('Session timed out due to inactivity');
$this->clearCachedSession($session);
}
return $staleSessions->count();
}
/**
* Cache the active session for quick lookup.
*/
protected function cacheActiveSession(AgentSession $session): void
{
$cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id;
Cache::put($cacheKey, [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan_id' => $session->agent_plan_id,
'workspace_id' => $session->workspace_id,
'started_at' => $session->started_at?->toIso8601String(),
], $this->getCacheTtl());
}
/**
* Clear cached session data.
*/
protected function clearCachedSession(AgentSession $session): void
{
$cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id;
Cache::forget($cacheKey);
}
}

View file

@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Mod\Mcp\Services;
use Core\Mod\Mcp\Dependencies\HasDependencies;
use Core\Mod\Mcp\Services\ToolDependencyService;
use Illuminate\Support\Collection;
use Mod\Api\Models\ApiKey;
use Mod\Mcp\Tools\Agent\Contracts\AgentToolInterface;
/**
* Registry for MCP Agent Server tools.
*
* Provides discovery, permission checking, and execution
* of registered agent tools.
*/
class AgentToolRegistry
{
/**
* Registered tools indexed by name.
*
* @var array<string, AgentToolInterface>
*/
protected array $tools = [];
/**
* Register a tool.
*
* If the tool implements HasDependencies, its dependencies
* are automatically registered with the ToolDependencyService.
*/
public function register(AgentToolInterface $tool): self
{
$this->tools[$tool->name()] = $tool;
// Auto-register dependencies if tool declares them
if ($tool instanceof HasDependencies && method_exists($tool, 'dependencies')) {
$dependencies = $tool->dependencies();
if (! empty($dependencies)) {
app(ToolDependencyService::class)->register($tool->name(), $dependencies);
}
}
return $this;
}
/**
* Register multiple tools at once.
*
* @param array<AgentToolInterface> $tools
*/
public function registerMany(array $tools): self
{
foreach ($tools as $tool) {
$this->register($tool);
}
return $this;
}
/**
* Check if a tool is registered.
*/
public function has(string $name): bool
{
return isset($this->tools[$name]);
}
/**
* Get a tool by name.
*/
public function get(string $name): ?AgentToolInterface
{
return $this->tools[$name] ?? null;
}
/**
* Get all registered tools.
*
* @return Collection<string, AgentToolInterface>
*/
public function all(): Collection
{
return collect($this->tools);
}
/**
* Get tools filtered by category.
*
* @return Collection<string, AgentToolInterface>
*/
public function byCategory(string $category): Collection
{
return $this->all()->filter(
fn (AgentToolInterface $tool) => $tool->category() === $category
);
}
/**
* Get tools accessible by an API key.
*
* @return Collection<string, AgentToolInterface>
*/
public function forApiKey(ApiKey $apiKey): Collection
{
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
// Check if API key has required scopes
foreach ($tool->requiredScopes() as $scope) {
if (! $apiKey->hasScope($scope)) {
return false;
}
}
// Check if API key has tool-level permission
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
});
}
/**
* Check if an API key can access a specific tool.
*/
public function apiKeyCanAccessTool(ApiKey $apiKey, string $toolName): bool
{
$allowedTools = $apiKey->tool_scopes ?? null;
// Null means all tools allowed
if ($allowedTools === null) {
return true;
}
return in_array($toolName, $allowedTools, true);
}
/**
* Execute a tool with permission and dependency checking.
*
* @param string $name Tool name
* @param array $args Tool arguments
* @param array $context Execution context
* @param ApiKey|null $apiKey Optional API key for permission checking
* @param bool $validateDependencies Whether to validate dependencies
* @return array Tool result
*
* @throws \InvalidArgumentException If tool not found
* @throws \RuntimeException If permission denied
* @throws \Core\Mod\Mcp\Exceptions\MissingDependencyException If dependencies not met
*/
public function execute(
string $name,
array $args,
array $context = [],
?ApiKey $apiKey = null,
bool $validateDependencies = true
): array {
$tool = $this->get($name);
if (! $tool) {
throw new \InvalidArgumentException("Unknown tool: {$name}");
}
// Permission check if API key provided
if ($apiKey !== null) {
// Check scopes
foreach ($tool->requiredScopes() as $scope) {
if (! $apiKey->hasScope($scope)) {
throw new \RuntimeException(
"Permission denied: API key missing scope '{$scope}' for tool '{$name}'"
);
}
}
// Check tool-level permission
if (! $this->apiKeyCanAccessTool($apiKey, $name)) {
throw new \RuntimeException(
"Permission denied: API key does not have access to tool '{$name}'"
);
}
}
// Dependency check
if ($validateDependencies) {
$sessionId = $context['session_id'] ?? 'anonymous';
$dependencyService = app(ToolDependencyService::class);
$dependencyService->validateDependencies($sessionId, $name, $context, $args);
}
$result = $tool->handle($args, $context);
// Record successful tool call for dependency tracking
if ($validateDependencies && ($result['success'] ?? true) !== false) {
$sessionId = $context['session_id'] ?? 'anonymous';
app(ToolDependencyService::class)->recordToolCall($sessionId, $name, $args);
}
return $result;
}
/**
* Get all tools as MCP tool definitions.
*
* @param ApiKey|null $apiKey Filter by API key permissions
*/
public function toMcpDefinitions(?ApiKey $apiKey = null): array
{
$tools = $apiKey !== null
? $this->forApiKey($apiKey)
: $this->all();
return $tools->map(fn (AgentToolInterface $tool) => $tool->toMcpDefinition())
->values()
->all();
}
/**
* Get tool categories with counts.
*/
public function categories(): Collection
{
return $this->all()
->groupBy(fn (AgentToolInterface $tool) => $tool->category())
->map(fn ($tools) => $tools->count());
}
/**
* Get all tool names.
*
* @return array<string>
*/
public function names(): array
{
return array_keys($this->tools);
}
/**
* Get tool count.
*/
public function count(): int
{
return count($this->tools);
}
}

View file

@ -0,0 +1,480 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\Models\McpAuditLog;
use Core\Mod\Mcp\Models\McpSensitiveTool;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Audit Log Service - records immutable tool execution logs with hash chain.
*
* Provides tamper-evident logging for MCP tool calls, supporting
* compliance requirements and forensic analysis.
*/
class AuditLogService
{
/**
* Cache key for sensitive tool list.
*/
protected const SENSITIVE_TOOLS_CACHE_KEY = 'mcp:audit:sensitive_tools';
/**
* Cache TTL for sensitive tools (5 minutes).
*/
protected const SENSITIVE_TOOLS_CACHE_TTL = 300;
/**
* Default fields to always redact.
*/
protected array $defaultRedactFields = [
'password',
'secret',
'token',
'api_key',
'apiKey',
'access_token',
'refresh_token',
'private_key',
'credit_card',
'card_number',
'cvv',
'ssn',
];
/**
* Record a tool execution in the audit log.
*/
public function record(
string $serverId,
string $toolName,
array $inputParams = [],
?array $outputSummary = null,
bool $success = true,
?int $durationMs = null,
?string $errorCode = null,
?string $errorMessage = null,
?string $sessionId = null,
?int $workspaceId = null,
?string $actorType = null,
?int $actorId = null,
?string $actorIp = null,
?string $agentType = null,
?string $planSlug = null
): McpAuditLog {
return DB::transaction(function () use (
$serverId,
$toolName,
$inputParams,
$outputSummary,
$success,
$durationMs,
$errorCode,
$errorMessage,
$sessionId,
$workspaceId,
$actorType,
$actorId,
$actorIp,
$agentType,
$planSlug
) {
// Get sensitivity info for this tool
$sensitivityInfo = $this->getSensitivityInfo($toolName);
$isSensitive = $sensitivityInfo !== null;
$sensitivityReason = $sensitivityInfo['reason'] ?? null;
$redactFields = $sensitivityInfo['redact_fields'] ?? [];
// Redact sensitive fields from input
$redactedInput = $this->redactFields($inputParams, $redactFields);
// Redact output if it contains sensitive data
$redactedOutput = $outputSummary ? $this->redactFields($outputSummary, $redactFields) : null;
// Get the previous entry's hash for chain linking
$previousEntry = McpAuditLog::orderByDesc('id')->first();
$previousHash = $previousEntry?->entry_hash;
// Create the audit log entry
$auditLog = new McpAuditLog([
'server_id' => $serverId,
'tool_name' => $toolName,
'workspace_id' => $workspaceId,
'session_id' => $sessionId,
'input_params' => $redactedInput,
'output_summary' => $redactedOutput,
'success' => $success,
'duration_ms' => $durationMs,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'actor_type' => $actorType,
'actor_id' => $actorId,
'actor_ip' => $actorIp,
'is_sensitive' => $isSensitive,
'sensitivity_reason' => $sensitivityReason,
'previous_hash' => $previousHash,
'agent_type' => $agentType,
'plan_slug' => $planSlug,
]);
$auditLog->save();
// Compute and store the entry hash
$auditLog->entry_hash = $auditLog->computeHash();
$auditLog->saveQuietly(); // Bypass updating event to allow hash update
return $auditLog;
});
}
/**
* Verify the integrity of the entire audit log chain.
*
* @return array{valid: bool, total: int, verified: int, issues: array}
*/
public function verifyChain(?int $fromId = null, ?int $toId = null): array
{
$query = McpAuditLog::orderBy('id');
if ($fromId !== null) {
$query->where('id', '>=', $fromId);
}
if ($toId !== null) {
$query->where('id', '<=', $toId);
}
$issues = [];
$verified = 0;
$previousHash = null;
$isFirst = true;
// If starting from a specific ID, get the previous entry's hash
if ($fromId !== null && $fromId > 1) {
$previousEntry = McpAuditLog::where('id', '<', $fromId)
->orderByDesc('id')
->first();
$previousHash = $previousEntry?->entry_hash;
$isFirst = false;
}
$total = $query->count();
// Process in chunks to avoid memory issues
$query->chunk(1000, function ($entries) use (&$issues, &$verified, &$previousHash, &$isFirst) {
foreach ($entries as $entry) {
// Verify hash
if (! $entry->verifyHash()) {
$issues[] = [
'id' => $entry->id,
'type' => 'hash_mismatch',
'message' => "Entry #{$entry->id}: Hash mismatch - data may have been tampered",
'expected' => $entry->computeHash(),
'actual' => $entry->entry_hash,
];
}
// Verify chain link
if ($isFirst) {
if ($entry->previous_hash !== null) {
$issues[] = [
'id' => $entry->id,
'type' => 'chain_break',
'message' => "Entry #{$entry->id}: First entry should have null previous_hash",
];
}
$isFirst = false;
} else {
if ($entry->previous_hash !== $previousHash) {
$issues[] = [
'id' => $entry->id,
'type' => 'chain_break',
'message' => "Entry #{$entry->id}: Chain link broken",
'expected' => $previousHash,
'actual' => $entry->previous_hash,
];
}
}
$previousHash = $entry->entry_hash;
$verified++;
}
});
return [
'valid' => empty($issues),
'total' => $total,
'verified' => $verified,
'issues' => $issues,
];
}
/**
* Get audit logs for export.
*/
public function export(
?int $workspaceId = null,
?Carbon $from = null,
?Carbon $to = null,
?string $toolName = null,
bool $sensitiveOnly = false
): Collection {
$query = McpAuditLog::orderBy('id');
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
if ($from !== null) {
$query->where('created_at', '>=', $from);
}
if ($to !== null) {
$query->where('created_at', '<=', $to);
}
if ($toolName !== null) {
$query->where('tool_name', $toolName);
}
if ($sensitiveOnly) {
$query->where('is_sensitive', true);
}
return $query->get()->map(fn ($entry) => $entry->toExportArray());
}
/**
* Export to CSV format.
*/
public function exportToCsv(
?int $workspaceId = null,
?Carbon $from = null,
?Carbon $to = null,
?string $toolName = null,
bool $sensitiveOnly = false
): string {
$data = $this->export($workspaceId, $from, $to, $toolName, $sensitiveOnly);
if ($data->isEmpty()) {
return '';
}
$headers = array_keys($data->first());
$output = fopen('php://temp', 'r+');
fputcsv($output, $headers);
foreach ($data as $row) {
fputcsv($output, array_values($row));
}
rewind($output);
$csv = stream_get_contents($output);
fclose($output);
return $csv;
}
/**
* Export to JSON format.
*/
public function exportToJson(
?int $workspaceId = null,
?Carbon $from = null,
?Carbon $to = null,
?string $toolName = null,
bool $sensitiveOnly = false
): string {
$data = $this->export($workspaceId, $from, $to, $toolName, $sensitiveOnly);
// Include integrity verification in export
$verification = $this->verifyChain();
return json_encode([
'exported_at' => now()->toIso8601String(),
'integrity' => [
'valid' => $verification['valid'],
'total_entries' => $verification['total'],
'verified' => $verification['verified'],
'issues_count' => count($verification['issues']),
],
'filters' => [
'workspace_id' => $workspaceId,
'from' => $from?->toIso8601String(),
'to' => $to?->toIso8601String(),
'tool_name' => $toolName,
'sensitive_only' => $sensitiveOnly,
],
'entries' => $data->toArray(),
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
}
/**
* Get statistics for the audit log.
*/
public function getStats(?int $workspaceId = null, ?int $days = 30): array
{
$query = McpAuditLog::query();
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
if ($days !== null) {
$query->where('created_at', '>=', now()->subDays($days));
}
$total = $query->count();
$successful = (clone $query)->where('success', true)->count();
$failed = (clone $query)->where('success', false)->count();
$sensitive = (clone $query)->where('is_sensitive', true)->count();
$topTools = (clone $query)
->select('tool_name', DB::raw('COUNT(*) as count'))
->groupBy('tool_name')
->orderByDesc('count')
->limit(10)
->pluck('count', 'tool_name')
->toArray();
$dailyCounts = (clone $query)
->select(DB::raw('DATE(created_at) as date'), DB::raw('COUNT(*) as count'))
->groupBy('date')
->orderBy('date')
->limit($days ?? 30)
->pluck('count', 'date')
->toArray();
return [
'total' => $total,
'successful' => $successful,
'failed' => $failed,
'success_rate' => $total > 0 ? round(($successful / $total) * 100, 2) : 0,
'sensitive_calls' => $sensitive,
'top_tools' => $topTools,
'daily_counts' => $dailyCounts,
];
}
/**
* Register a sensitive tool.
*/
public function registerSensitiveTool(
string $toolName,
string $reason,
array $redactFields = [],
bool $requireConsent = false
): void {
McpSensitiveTool::register($toolName, $reason, $redactFields, $requireConsent);
$this->clearSensitiveToolsCache();
}
/**
* Unregister a sensitive tool.
*/
public function unregisterSensitiveTool(string $toolName): bool
{
$result = McpSensitiveTool::unregister($toolName);
$this->clearSensitiveToolsCache();
return $result;
}
/**
* Get all registered sensitive tools.
*/
public function getSensitiveTools(): Collection
{
return McpSensitiveTool::all();
}
/**
* Check if a tool requires explicit consent.
*/
public function requiresConsent(string $toolName): bool
{
$info = $this->getSensitivityInfo($toolName);
return $info !== null && ($info['require_explicit_consent'] ?? false);
}
// -------------------------------------------------------------------------
// Protected Methods
// -------------------------------------------------------------------------
/**
* Get sensitivity info for a tool (cached).
*/
protected function getSensitivityInfo(string $toolName): ?array
{
$sensitiveTools = Cache::remember(
self::SENSITIVE_TOOLS_CACHE_KEY,
self::SENSITIVE_TOOLS_CACHE_TTL,
fn () => McpSensitiveTool::all()->keyBy('tool_name')->toArray()
);
if (! isset($sensitiveTools[$toolName])) {
return null;
}
$tool = $sensitiveTools[$toolName];
return [
'is_sensitive' => true,
'reason' => $tool['reason'],
'redact_fields' => $tool['redact_fields'] ?? [],
'require_explicit_consent' => $tool['require_explicit_consent'] ?? false,
];
}
/**
* Redact sensitive fields from data.
*/
protected function redactFields(array $data, array $additionalFields = []): array
{
$fieldsToRedact = array_merge($this->defaultRedactFields, $additionalFields);
return $this->redactRecursive($data, $fieldsToRedact);
}
/**
* Recursively redact fields in nested arrays.
*/
protected function redactRecursive(array $data, array $fieldsToRedact): array
{
foreach ($data as $key => $value) {
$keyLower = strtolower((string) $key);
// Check if this key should be redacted
foreach ($fieldsToRedact as $field) {
if (str_contains($keyLower, strtolower($field))) {
$data[$key] = '[REDACTED]';
continue 2;
}
}
// Recurse into nested arrays
if (is_array($value)) {
$data[$key] = $this->redactRecursive($value, $fieldsToRedact);
}
}
return $data;
}
/**
* Clear the sensitive tools cache.
*/
protected function clearSensitiveToolsCache(): void
{
Cache::forget(self::SENSITIVE_TOOLS_CACHE_KEY);
}
}

View file

@ -0,0 +1,442 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Closure;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Mod\Mcp\Exceptions\CircuitOpenException;
use Throwable;
/**
* Circuit Breaker for external module dependencies.
*
* Provides fault tolerance when dependent services (like the Agentic module)
* are unavailable. Implements the circuit breaker pattern with three states:
* - Closed: Normal operation, requests pass through
* - Open: Service is down, requests fail fast
* - Half-Open: Testing if service has recovered
*
* @see https://martinfowler.com/bliki/CircuitBreaker.html
*/
class CircuitBreaker
{
/**
* Cache key prefix for circuit state.
*/
protected const CACHE_PREFIX = 'circuit_breaker:';
/**
* Circuit states.
*/
public const STATE_CLOSED = 'closed';
public const STATE_OPEN = 'open';
public const STATE_HALF_OPEN = 'half_open';
/**
* Default TTL for success/failure counters (seconds).
*/
protected const COUNTER_TTL = 300;
/**
* Execute a callable with circuit breaker protection.
*
* @param string $service Service identifier (e.g., 'agentic', 'content')
* @param Closure $operation The operation to execute
* @param Closure|null $fallback Optional fallback when circuit is open
* @return mixed The operation result or fallback value
*
* @throws CircuitOpenException When circuit is open and no fallback provided
* @throws Throwable When operation fails and circuit records the failure
*/
public function call(string $service, Closure $operation, ?Closure $fallback = null): mixed
{
$state = $this->getState($service);
// Fast fail when circuit is open
if ($state === self::STATE_OPEN) {
Log::debug("Circuit breaker open for {$service}, failing fast");
if ($fallback !== null) {
return $fallback();
}
throw new CircuitOpenException($service);
}
// Handle half-open state with trial lock to prevent concurrent trial requests
$hasTrialLock = false;
if ($state === self::STATE_HALF_OPEN) {
$hasTrialLock = $this->acquireTrialLock($service);
if (! $hasTrialLock) {
// Another request is already testing the service, fail fast
Log::debug("Circuit breaker half-open for {$service}, trial in progress, failing fast");
if ($fallback !== null) {
return $fallback();
}
throw new CircuitOpenException($service, "Service '{$service}' is being tested. Please try again shortly.");
}
}
// Try the operation
try {
$result = $operation();
// Record success and release trial lock if held
$this->recordSuccess($service);
if ($hasTrialLock) {
$this->releaseTrialLock($service);
}
return $result;
} catch (Throwable $e) {
// Release trial lock if held
if ($hasTrialLock) {
$this->releaseTrialLock($service);
}
// Record failure
$this->recordFailure($service, $e);
// Check if we should trip the circuit
if ($this->shouldTrip($service)) {
$this->tripCircuit($service);
}
// If fallback provided and this is a recoverable error, use it
if ($fallback !== null && $this->isRecoverableError($e)) {
Log::warning("Circuit breaker using fallback for {$service}", [
'error' => $e->getMessage(),
]);
return $fallback();
}
throw $e;
}
}
/**
* Get the current state of a circuit.
*/
public function getState(string $service): string
{
$cacheKey = $this->getStateKey($service);
$state = Cache::get($cacheKey);
if ($state === null) {
return self::STATE_CLOSED;
}
// Check if open circuit should transition to half-open
if ($state === self::STATE_OPEN) {
$openedAt = Cache::get($this->getOpenedAtKey($service));
$resetTimeout = $this->getResetTimeout($service);
if ($openedAt && (time() - $openedAt) >= $resetTimeout) {
$this->setState($service, self::STATE_HALF_OPEN);
return self::STATE_HALF_OPEN;
}
}
return $state;
}
/**
* Get circuit statistics for monitoring.
*/
public function getStats(string $service): array
{
return [
'service' => $service,
'state' => $this->getState($service),
'failures' => (int) Cache::get($this->getFailureCountKey($service), 0),
'successes' => (int) Cache::get($this->getSuccessCountKey($service), 0),
'last_failure' => Cache::get($this->getLastFailureKey($service)),
'opened_at' => Cache::get($this->getOpenedAtKey($service)),
'threshold' => $this->getFailureThreshold($service),
'reset_timeout' => $this->getResetTimeout($service),
];
}
/**
* Manually reset a circuit to closed state.
*/
public function reset(string $service): void
{
$this->setState($service, self::STATE_CLOSED);
Cache::forget($this->getFailureCountKey($service));
Cache::forget($this->getSuccessCountKey($service));
Cache::forget($this->getLastFailureKey($service));
Cache::forget($this->getOpenedAtKey($service));
Log::info("Circuit breaker manually reset for {$service}");
}
/**
* Check if a service is available (circuit not open).
*/
public function isAvailable(string $service): bool
{
return $this->getState($service) !== self::STATE_OPEN;
}
/**
* Record a successful operation.
*/
protected function recordSuccess(string $service): void
{
$state = $this->getState($service);
// Increment success counter with TTL
$this->atomicIncrement($this->getSuccessCountKey($service), self::COUNTER_TTL);
// If half-open and we got a success, close the circuit
if ($state === self::STATE_HALF_OPEN) {
$this->closeCircuit($service);
}
// Decay failures over time (successful calls reduce failure count)
$this->atomicDecrement($this->getFailureCountKey($service));
}
/**
* Record a failed operation.
*/
protected function recordFailure(string $service, Throwable $e): void
{
$failureKey = $this->getFailureCountKey($service);
$lastFailureKey = $this->getLastFailureKey($service);
$window = $this->getFailureWindow($service);
// Atomic increment with TTL refresh using lock
$newCount = $this->atomicIncrement($failureKey, $window);
// Record last failure details
Cache::put($lastFailureKey, [
'message' => $e->getMessage(),
'class' => get_class($e),
'time' => now()->toIso8601String(),
], $window);
Log::warning("Circuit breaker recorded failure for {$service}", [
'error' => $e->getMessage(),
'failures' => $newCount,
]);
}
/**
* Check if the circuit should trip (open).
*/
protected function shouldTrip(string $service): bool
{
$failures = (int) Cache::get($this->getFailureCountKey($service), 0);
$threshold = $this->getFailureThreshold($service);
return $failures >= $threshold;
}
/**
* Trip the circuit to open state.
*/
protected function tripCircuit(string $service): void
{
$this->setState($service, self::STATE_OPEN);
Cache::put($this->getOpenedAtKey($service), time(), 86400); // 24h max
Log::error("Circuit breaker tripped for {$service}", [
'failures' => Cache::get($this->getFailureCountKey($service)),
]);
}
/**
* Close the circuit after successful recovery.
*/
protected function closeCircuit(string $service): void
{
$this->setState($service, self::STATE_CLOSED);
Cache::forget($this->getFailureCountKey($service));
Cache::forget($this->getOpenedAtKey($service));
Log::info("Circuit breaker closed for {$service} after successful recovery");
}
/**
* Set circuit state.
*/
protected function setState(string $service, string $state): void
{
Cache::put($this->getStateKey($service), $state, 86400); // 24h max
}
/**
* Check if an exception is recoverable (should use fallback).
*/
protected function isRecoverableError(Throwable $e): bool
{
// Database connection errors, table not found, etc.
$recoverablePatterns = [
'SQLSTATE',
'Connection refused',
'Table .* doesn\'t exist',
'Base table or view not found',
'Connection timed out',
'Too many connections',
];
$message = $e->getMessage();
foreach ($recoverablePatterns as $pattern) {
if (preg_match('/'.$pattern.'/i', $message)) {
return true;
}
}
return false;
}
/**
* Get the failure threshold from config.
*/
protected function getFailureThreshold(string $service): int
{
return (int) config("mcp.circuit_breaker.{$service}.threshold",
config('mcp.circuit_breaker.default_threshold', 5)
);
}
/**
* Get the reset timeout (how long to wait before trying again).
*/
protected function getResetTimeout(string $service): int
{
return (int) config("mcp.circuit_breaker.{$service}.reset_timeout",
config('mcp.circuit_breaker.default_reset_timeout', 60)
);
}
/**
* Get the failure window (how long failures are counted).
*/
protected function getFailureWindow(string $service): int
{
return (int) config("mcp.circuit_breaker.{$service}.failure_window",
config('mcp.circuit_breaker.default_failure_window', 120)
);
}
/**
* Atomically increment a counter with TTL refresh.
*
* Uses a lock to ensure the increment and TTL refresh are atomic.
*/
protected function atomicIncrement(string $key, int $ttl): int
{
$lock = Cache::lock($key.':lock', 5);
try {
$lock->block(3);
$current = (int) Cache::get($key, 0);
$newValue = $current + 1;
Cache::put($key, $newValue, $ttl);
return $newValue;
} finally {
$lock->release();
}
}
/**
* Atomically decrement a counter (only if positive).
*
* Note: We use COUNTER_TTL as a fallback since Laravel's Cache facade
* doesn't expose remaining TTL. The counter will refresh on activity.
*/
protected function atomicDecrement(string $key): int
{
$lock = Cache::lock($key.':lock', 5);
try {
$lock->block(3);
$current = (int) Cache::get($key, 0);
if ($current > 0) {
$newValue = $current - 1;
Cache::put($key, $newValue, self::COUNTER_TTL);
return $newValue;
}
return 0;
} finally {
$lock->release();
}
}
/**
* Acquire a trial lock for half-open state.
*
* Only one request can hold the trial lock at a time, preventing
* concurrent trial requests during half-open state.
*/
protected function acquireTrialLock(string $service): bool
{
$lockKey = $this->getTrialLockKey($service);
// Try to acquire lock with a short TTL (auto-release if request hangs)
return Cache::add($lockKey, true, 30);
}
/**
* Release the trial lock.
*/
protected function releaseTrialLock(string $service): void
{
Cache::forget($this->getTrialLockKey($service));
}
/**
* Get the trial lock cache key.
*/
protected function getTrialLockKey(string $service): string
{
return self::CACHE_PREFIX.$service.':trial_lock';
}
// Cache key helpers
protected function getStateKey(string $service): string
{
return self::CACHE_PREFIX.$service.':state';
}
protected function getFailureCountKey(string $service): string
{
return self::CACHE_PREFIX.$service.':failures';
}
protected function getSuccessCountKey(string $service): string
{
return self::CACHE_PREFIX.$service.':successes';
}
protected function getLastFailureKey(string $service): string
{
return self::CACHE_PREFIX.$service.':last_failure';
}
protected function getOpenedAtKey(string $service): string
{
return self::CACHE_PREFIX.$service.':opened_at';
}
}

View file

@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
/**
* Data Redactor - redacts sensitive information from tool call logs.
*
* Prevents PII, credentials, and secrets from being stored in tool call
* logs while maintaining enough context for debugging.
*/
class DataRedactor
{
/**
* Keys that should always be fully redacted.
*/
protected const SENSITIVE_KEYS = [
'password',
'passwd',
'secret',
'token',
'api_key',
'apikey',
'api-key',
'auth',
'authorization',
'bearer',
'credential',
'credentials',
'private_key',
'privatekey',
'access_token',
'refresh_token',
'session_token',
'jwt',
'ssn',
'social_security',
'credit_card',
'creditcard',
'card_number',
'cvv',
'cvc',
'pin',
'routing_number',
'account_number',
'bank_account',
];
/**
* Keys containing PII that should be partially redacted.
*/
protected const PII_KEYS = [
'email',
'phone',
'telephone',
'mobile',
'address',
'street',
'postcode',
'zip',
'zipcode',
'date_of_birth',
'dob',
'birthdate',
'national_insurance',
'ni_number',
'passport',
'license',
'licence',
];
/**
* Replacement string for fully redacted values.
*/
protected const REDACTED = '[REDACTED]';
/**
* Redact sensitive data from an array recursively.
*/
public function redact(mixed $data, int $maxDepth = 10): mixed
{
if ($maxDepth <= 0) {
return '[MAX_DEPTH_EXCEEDED]';
}
if (is_array($data)) {
return $this->redactArray($data, $maxDepth - 1);
}
if (is_string($data)) {
return $this->redactString($data);
}
return $data;
}
/**
* Redact sensitive values from an array.
*/
protected function redactArray(array $data, int $maxDepth): array
{
$result = [];
foreach ($data as $key => $value) {
$lowerKey = strtolower((string) $key);
// Check for fully sensitive keys
if ($this->isSensitiveKey($lowerKey)) {
$result[$key] = self::REDACTED;
continue;
}
// Check for PII keys - partially redact
if ($this->isPiiKey($lowerKey) && is_string($value)) {
$result[$key] = $this->partialRedact($value);
continue;
}
// Recurse into nested arrays (with depth guard)
if (is_array($value)) {
if ($maxDepth <= 0) {
$result[$key] = '[MAX_DEPTH_EXCEEDED]';
} else {
$result[$key] = $this->redactArray($value, $maxDepth - 1);
}
continue;
}
// Check string values for embedded sensitive patterns
if (is_string($value)) {
$result[$key] = $this->redactString($value);
continue;
}
$result[$key] = $value;
}
return $result;
}
/**
* Check if a key name indicates sensitive data.
*/
protected function isSensitiveKey(string $key): bool
{
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
if (str_contains($key, $sensitiveKey)) {
return true;
}
}
return false;
}
/**
* Check if a key name indicates PII.
*/
protected function isPiiKey(string $key): bool
{
foreach (self::PII_KEYS as $piiKey) {
if (str_contains($key, $piiKey)) {
return true;
}
}
return false;
}
/**
* Redact sensitive patterns from a string value.
*/
protected function redactString(string $value): string
{
// Redact bearer tokens
$value = preg_replace(
'/Bearer\s+[A-Za-z0-9\-_\.]+/i',
'Bearer '.self::REDACTED,
$value
) ?? $value;
// Redact Basic auth
$value = preg_replace(
'/Basic\s+[A-Za-z0-9\+\/=]+/i',
'Basic '.self::REDACTED,
$value
) ?? $value;
// Redact common API key patterns (key_xxx, sk_xxx, pk_xxx)
$value = preg_replace(
'/\b(sk|pk|key|api|token)_[a-zA-Z0-9]{16,}/i',
'$1_'.self::REDACTED,
$value
) ?? $value;
// Redact JWT tokens (xxx.xxx.xxx format with base64)
$value = preg_replace(
'/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/i',
self::REDACTED,
$value
) ?? $value;
// Redact UK National Insurance numbers
$value = preg_replace(
'/[A-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Z]/i',
self::REDACTED,
$value
) ?? $value;
// Redact credit card numbers (basic pattern)
$value = preg_replace(
'/\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/',
self::REDACTED,
$value
) ?? $value;
return $value;
}
/**
* Partially redact a value, showing first and last characters.
*/
protected function partialRedact(string $value): string
{
$length = strlen($value);
if ($length <= 4) {
return self::REDACTED;
}
if ($length <= 8) {
return substr($value, 0, 2).'***'.substr($value, -1);
}
// For longer values, show more context
$showChars = min(3, (int) floor($length / 4));
return substr($value, 0, $showChars).'***'.substr($value, -$showChars);
}
/**
* Create a summary of array data without sensitive information.
*
* Useful for result_summary where we want structure info without details.
*/
public function summarize(mixed $data, int $maxDepth = 3): mixed
{
if ($maxDepth <= 0) {
return '[...]';
}
if (is_array($data)) {
$result = [];
$count = count($data);
// Limit array size in summary
$limit = 10;
$truncated = $count > $limit;
$items = array_slice($data, 0, $limit, true);
foreach ($items as $key => $value) {
$lowerKey = strtolower((string) $key);
// Fully redact sensitive keys
if ($this->isSensitiveKey($lowerKey)) {
$result[$key] = self::REDACTED;
continue;
}
// Partially redact PII keys
if ($this->isPiiKey($lowerKey) && is_string($value)) {
$result[$key] = $this->partialRedact($value);
continue;
}
// Recurse with reduced depth
$result[$key] = $this->summarize($value, $maxDepth - 1);
}
if ($truncated) {
$result['_truncated'] = '... and '.($count - $limit).' more items';
}
return $result;
}
if (is_string($data)) {
// Redact first, then truncate (prevents leaking sensitive patterns)
$redacted = $this->redactString($data);
if (strlen($redacted) > 100) {
return substr($redacted, 0, 97).'...';
}
return $redacted;
}
return $data;
}
}

View file

@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
use Symfony\Component\Yaml\Yaml;
/**
* MCP Server Health Check Service
*
* Pings MCP servers via stdio to check their health status.
* Results are cached for 60 seconds to avoid excessive subprocess spawning.
*/
class McpHealthService
{
public const STATUS_ONLINE = 'online';
public const STATUS_OFFLINE = 'offline';
public const STATUS_DEGRADED = 'degraded';
public const STATUS_UNKNOWN = 'unknown';
/**
* Cache TTL in seconds for health check results.
*/
protected int $cacheTtl = 60;
/**
* Timeout in seconds for health check ping.
*/
protected int $timeout = 5;
/**
* Check health of a specific MCP server.
*/
public function check(string $serverId, bool $forceRefresh = false): array
{
$cacheKey = "mcp:health:{$serverId}";
if (! $forceRefresh && Cache::has($cacheKey)) {
return Cache::get($cacheKey);
}
$server = $this->loadServerConfig($serverId);
if (! $server) {
$result = $this->buildResult(self::STATUS_UNKNOWN, 'Server not found');
Cache::put($cacheKey, $result, $this->cacheTtl);
return $result;
}
$result = $this->pingServer($server);
Cache::put($cacheKey, $result, $this->cacheTtl);
return $result;
}
/**
* Check health of all registered MCP servers.
*/
public function checkAll(bool $forceRefresh = false): array
{
$servers = $this->getRegisteredServers();
$results = [];
foreach ($servers as $serverId) {
$results[$serverId] = $this->check($serverId, $forceRefresh);
}
return $results;
}
/**
* Get cached health status without triggering a check.
*/
public function getCachedStatus(string $serverId): ?array
{
return Cache::get("mcp:health:{$serverId}");
}
/**
* Clear cached health status for a server.
*/
public function clearCache(string $serverId): void
{
Cache::forget("mcp:health:{$serverId}");
}
/**
* Clear all cached health statuses.
*/
public function clearAllCache(): void
{
foreach ($this->getRegisteredServers() as $serverId) {
Cache::forget("mcp:health:{$serverId}");
}
}
/**
* Ping a server by sending a minimal MCP request.
*/
protected function pingServer(array $server): array
{
$connection = $server['connection'] ?? [];
$type = $connection['type'] ?? 'stdio';
// Only support stdio for now
if ($type !== 'stdio') {
return $this->buildResult(
self::STATUS_UNKNOWN,
"Connection type '{$type}' health check not supported"
);
}
$command = $connection['command'] ?? null;
$args = $connection['args'] ?? [];
$cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd());
if (! $command) {
return $this->buildResult(self::STATUS_OFFLINE, 'No command configured');
}
// Build the MCP initialize request
$initRequest = json_encode([
'jsonrpc' => '2.0',
'method' => 'initialize',
'params' => [
'protocolVersion' => '2024-11-05',
'capabilities' => [],
'clientInfo' => [
'name' => 'mcp-health-check',
'version' => '1.0.0',
],
],
'id' => 1,
]);
try {
$startTime = microtime(true);
// Build full command
$fullCommand = array_merge([$command], $args);
$process = new Process($fullCommand, $cwd);
$process->setInput($initRequest);
$process->setTimeout($this->timeout);
$process->run();
$duration = round((microtime(true) - $startTime) * 1000);
$output = $process->getOutput();
// Check for valid JSON-RPC response
if ($process->isSuccessful() && ! empty($output)) {
// Try to parse the response
$lines = explode("\n", trim($output));
foreach ($lines as $line) {
$response = json_decode($line, true);
if ($response && isset($response['result'])) {
return $this->buildResult(
self::STATUS_ONLINE,
'Server responding',
[
'response_time_ms' => $duration,
'server_info' => $response['result']['serverInfo'] ?? null,
'protocol_version' => $response['result']['protocolVersion'] ?? null,
]
);
}
}
}
// Process ran but didn't return expected response
if ($process->isSuccessful()) {
return $this->buildResult(
self::STATUS_DEGRADED,
'Server started but returned unexpected response',
[
'response_time_ms' => $duration,
'output' => substr($output, 0, 500),
]
);
}
// Process failed
return $this->buildResult(
self::STATUS_OFFLINE,
'Server failed to start',
[
'exit_code' => $process->getExitCode(),
'error' => substr($process->getErrorOutput(), 0, 500),
]
);
} catch (\Exception $e) {
Log::warning("MCP health check failed for {$server['id']}", [
'error' => $e->getMessage(),
]);
return $this->buildResult(
self::STATUS_OFFLINE,
'Health check failed: '.$e->getMessage()
);
}
}
/**
* Build a health check result array.
*/
protected function buildResult(string $status, string $message, array $extra = []): array
{
return array_merge([
'status' => $status,
'message' => $message,
'checked_at' => now()->toIso8601String(),
], $extra);
}
/**
* Get list of registered server IDs.
*/
protected function getRegisteredServers(): array
{
$registry = $this->loadRegistry();
return collect($registry['servers'] ?? [])
->pluck('id')
->all();
}
/**
* Load the main registry file.
*/
protected function loadRegistry(): array
{
$path = resource_path('mcp/registry.yaml');
if (! file_exists($path)) {
return ['servers' => []];
}
return Yaml::parseFile($path);
}
/**
* Load a server's YAML config.
*/
protected function loadServerConfig(string $id): ?array
{
$path = resource_path("mcp/servers/{$id}.yaml");
if (! file_exists($path)) {
return null;
}
return Yaml::parseFile($path);
}
/**
* Resolve environment variables in a string.
*/
protected function resolveEnvVars(string $value): string
{
return preg_replace_callback('/\$\{([^}]+)\}/', function ($matches) {
$parts = explode(':-', $matches[1], 2);
$var = $parts[0];
$default = $parts[1] ?? '';
return env($var, $default);
}, $value);
}
/**
* Get status badge HTML.
*/
public function getStatusBadge(string $status): string
{
return match ($status) {
self::STATUS_ONLINE => '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Online</span>',
self::STATUS_OFFLINE => '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Offline</span>',
self::STATUS_DEGRADED => '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Degraded</span>',
default => '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Unknown</span>',
};
}
/**
* Get status colour class for Tailwind.
*/
public function getStatusColour(string $status): string
{
return match ($status) {
self::STATUS_ONLINE => 'green',
self::STATUS_OFFLINE => 'red',
self::STATUS_DEGRADED => 'yellow',
default => 'gray',
};
}
}

View file

@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\Models\McpToolCall;
use Core\Mod\Mcp\Models\McpToolCallStat;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* MCP Metrics Service - dashboard metrics for MCP tool usage.
*
* Provides overview stats, trends, performance percentiles, and activity feeds.
*/
class McpMetricsService
{
/**
* Get overview metrics for the dashboard.
*/
public function getOverview(int $days = 7): array
{
$startDate = now()->subDays($days - 1)->startOfDay();
$stats = McpToolCallStat::forDateRange($startDate, now())->get();
$totalCalls = $stats->sum('call_count');
$successCalls = $stats->sum('success_count');
$errorCalls = $stats->sum('error_count');
$successRate = $totalCalls > 0
? round(($successCalls / $totalCalls) * 100, 1)
: 0;
$avgDuration = $totalCalls > 0
? round($stats->sum('total_duration_ms') / $totalCalls, 1)
: 0;
// Compare to previous period
$previousStart = $startDate->copy()->subDays($days);
$previousStats = McpToolCallStat::forDateRange($previousStart, $startDate->copy()->subDay())->get();
$previousCalls = $previousStats->sum('call_count');
$callsTrend = $previousCalls > 0
? round((($totalCalls - $previousCalls) / $previousCalls) * 100, 1)
: 0;
return [
'total_calls' => $totalCalls,
'success_calls' => $successCalls,
'error_calls' => $errorCalls,
'success_rate' => $successRate,
'avg_duration_ms' => $avgDuration,
'calls_trend_percent' => $callsTrend,
'unique_tools' => $stats->pluck('tool_name')->unique()->count(),
'unique_servers' => $stats->pluck('server_id')->unique()->count(),
'period_days' => $days,
];
}
/**
* Get daily call trend data for charting.
*/
public function getDailyTrend(int $days = 7): Collection
{
$trend = McpToolCallStat::getDailyTrend($days);
// Fill in missing dates with zeros
$dates = collect();
for ($i = $days - 1; $i >= 0; $i--) {
$date = now()->subDays($i)->toDateString();
$existing = $trend->firstWhere('date', $date);
$dates->push([
'date' => $date,
'date_formatted' => Carbon::parse($date)->format('M j'),
'total_calls' => $existing->total_calls ?? 0,
'total_success' => $existing->total_success ?? 0,
'total_errors' => $existing->total_errors ?? 0,
'success_rate' => $existing->success_rate ?? 0,
]);
}
return $dates;
}
/**
* Get top tools by call count.
*/
public function getTopTools(int $days = 7, int $limit = 10): Collection
{
return McpToolCallStat::getTopTools($days, $limit);
}
/**
* Get server breakdown.
*/
public function getServerStats(int $days = 7): Collection
{
return McpToolCallStat::getServerStats($days);
}
/**
* Get recent tool calls for activity feed.
*/
public function getRecentCalls(int $limit = 20): Collection
{
return McpToolCall::query()
->orderByDesc('created_at')
->limit($limit)
->get()
->map(function ($call) {
return [
'id' => $call->id,
'server_id' => $call->server_id,
'tool_name' => $call->tool_name,
'success' => $call->success,
'duration' => $call->getDurationForHumans(),
'duration_ms' => $call->duration_ms,
'error_message' => $call->error_message,
'session_id' => $call->session_id,
'plan_slug' => $call->plan_slug,
'created_at' => $call->created_at->diffForHumans(),
'created_at_full' => $call->created_at->toIso8601String(),
];
});
}
/**
* Get error breakdown.
*/
public function getErrorBreakdown(int $days = 7): Collection
{
return McpToolCall::query()
->select('tool_name', 'error_code')
->selectRaw('COUNT(*) as error_count')
->where('success', false)
->where('created_at', '>=', now()->subDays($days))
->groupBy('tool_name', 'error_code')
->orderByDesc('error_count')
->limit(20)
->get();
}
/**
* Get tool performance metrics (p50, p95, p99).
*/
public function getToolPerformance(int $days = 7, int $limit = 10): Collection
{
// Get raw call data for percentile calculations
$calls = McpToolCall::query()
->select('tool_name', 'duration_ms')
->whereNotNull('duration_ms')
->where('success', true)
->where('created_at', '>=', now()->subDays($days))
->get()
->groupBy('tool_name');
$performance = collect();
foreach ($calls as $toolName => $toolCalls) {
$durations = $toolCalls->pluck('duration_ms')->sort()->values();
$count = $durations->count();
if ($count === 0) {
continue;
}
$performance->push([
'tool_name' => $toolName,
'call_count' => $count,
'min_ms' => $durations->first(),
'max_ms' => $durations->last(),
'avg_ms' => round($durations->avg(), 1),
'p50_ms' => $this->percentile($durations, 50),
'p95_ms' => $this->percentile($durations, 95),
'p99_ms' => $this->percentile($durations, 99),
]);
}
return $performance
->sortByDesc('call_count')
->take($limit)
->values();
}
/**
* Get hourly distribution for the last 24 hours.
*/
public function getHourlyDistribution(): Collection
{
$hourly = McpToolCall::query()
->selectRaw('HOUR(created_at) as hour')
->selectRaw('COUNT(*) as call_count')
->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count')
->where('created_at', '>=', now()->subHours(24))
->groupBy('hour')
->orderBy('hour')
->get()
->keyBy('hour');
// Fill in missing hours
$result = collect();
for ($i = 0; $i < 24; $i++) {
$hour = str_pad((string) $i, 2, '0', STR_PAD_LEFT);
$existing = $hourly->get($i);
$result->push([
'hour' => $hour,
'hour_formatted' => Carbon::createFromTime($i)->format('ga'),
'call_count' => $existing->call_count ?? 0,
'success_count' => $existing->success_count ?? 0,
]);
}
return $result;
}
/**
* Get plan activity - which plans are using MCP tools.
*/
public function getPlanActivity(int $days = 7, int $limit = 10): Collection
{
return McpToolCall::query()
->select('plan_slug')
->selectRaw('COUNT(*) as call_count')
->selectRaw('COUNT(DISTINCT tool_name) as unique_tools')
->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count')
->whereNotNull('plan_slug')
->where('created_at', '>=', now()->subDays($days))
->groupBy('plan_slug')
->orderByDesc('call_count')
->limit($limit)
->get()
->map(function ($item) {
$item->success_rate = $item->call_count > 0
? round(($item->success_count / $item->call_count) * 100, 1)
: 0;
return $item;
});
}
/**
* Calculate percentile from a sorted collection.
*/
protected function percentile(Collection $sortedValues, int $percentile): float
{
$count = $sortedValues->count();
if ($count === 0) {
return 0;
}
$index = ($percentile / 100) * ($count - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) {
return $sortedValues[$lower];
}
$fraction = $index - $lower;
return round($sortedValues[$lower] + ($sortedValues[$upper] - $sortedValues[$lower]) * $fraction, 1);
}
}

View file

@ -0,0 +1,395 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\Models\McpUsageQuota;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Support\Facades\Cache;
/**
* MCP Quota Service - manages workspace-level usage quotas for MCP.
*
* Provides quota checking, usage recording, and limit enforcement
* for tool calls and token consumption.
*/
class McpQuotaService
{
/**
* Feature codes for MCP quota limits in the entitlement system.
*/
public const FEATURE_MONTHLY_TOOL_CALLS = 'mcp.monthly_tool_calls';
public const FEATURE_MONTHLY_TOKENS = 'mcp.monthly_tokens';
/**
* Cache TTL for quota limits (5 minutes).
*/
protected const CACHE_TTL = 300;
public function __construct(
protected EntitlementService $entitlements
) {}
// ─────────────────────────────────────────────────────────────────────────
// Usage Recording
// ─────────────────────────────────────────────────────────────────────────
/**
* Record MCP usage for a workspace.
*/
public function recordUsage(
Workspace|int $workspace,
int $toolCalls = 1,
int $inputTokens = 0,
int $outputTokens = 0
): McpUsageQuota {
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
$quota = McpUsageQuota::record($workspaceId, $toolCalls, $inputTokens, $outputTokens);
// Invalidate cached usage
$this->invalidateUsageCache($workspaceId);
return $quota;
}
// ─────────────────────────────────────────────────────────────────────────
// Quota Checking
// ─────────────────────────────────────────────────────────────────────────
/**
* Check if workspace is within quota limits.
*
* Returns true if within limits (or unlimited), false if quota exceeded.
*/
public function checkQuota(Workspace|int $workspace): bool
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
$workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId);
if (! $workspace) {
return false;
}
// Check tool calls quota
$toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS);
if ($toolCallsResult->isDenied()) {
// Feature not in plan - deny access
return false;
}
if (! $toolCallsResult->isUnlimited()) {
$usage = $this->getCurrentUsage($workspace);
$limit = $toolCallsResult->limit;
if ($limit !== null && $usage['tool_calls_count'] >= $limit) {
return false;
}
}
// Check tokens quota
$tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS);
if (! $tokensResult->isUnlimited() && $tokensResult->isAllowed()) {
$usage = $this->getCurrentUsage($workspace);
$limit = $tokensResult->limit;
if ($limit !== null && $usage['total_tokens'] >= $limit) {
return false;
}
}
return true;
}
/**
* Get detailed quota check result with reasons.
*
* @return array{allowed: bool, reason: ?string, tool_calls: array, tokens: array}
*/
public function checkQuotaDetailed(Workspace|int $workspace): array
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
$workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId);
if (! $workspace) {
return [
'allowed' => false,
'reason' => 'Workspace not found',
'tool_calls' => ['allowed' => false],
'tokens' => ['allowed' => false],
];
}
$usage = $this->getCurrentUsage($workspace);
// Check tool calls
$toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS);
$toolCallsAllowed = true;
$toolCallsReason = null;
if ($toolCallsResult->isDenied()) {
$toolCallsAllowed = false;
$toolCallsReason = 'MCP tool calls not included in your plan';
} elseif (! $toolCallsResult->isUnlimited()) {
$limit = $toolCallsResult->limit;
if ($limit !== null && $usage['tool_calls_count'] >= $limit) {
$toolCallsAllowed = false;
$toolCallsReason = "Monthly tool calls limit reached ({$usage['tool_calls_count']}/{$limit})";
}
}
// Check tokens
$tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS);
$tokensAllowed = true;
$tokensReason = null;
if ($tokensResult->isDenied()) {
// Tokens might not be tracked separately - this is OK
$tokensAllowed = true;
} elseif (! $tokensResult->isUnlimited() && $tokensResult->isAllowed()) {
$limit = $tokensResult->limit;
if ($limit !== null && $usage['total_tokens'] >= $limit) {
$tokensAllowed = false;
$tokensReason = "Monthly token limit reached ({$usage['total_tokens']}/{$limit})";
}
}
$allowed = $toolCallsAllowed && $tokensAllowed;
$reason = $toolCallsReason ?? $tokensReason;
return [
'allowed' => $allowed,
'reason' => $reason,
'tool_calls' => [
'allowed' => $toolCallsAllowed,
'reason' => $toolCallsReason,
'used' => $usage['tool_calls_count'],
'limit' => $toolCallsResult->isUnlimited() ? null : $toolCallsResult->limit,
'unlimited' => $toolCallsResult->isUnlimited(),
],
'tokens' => [
'allowed' => $tokensAllowed,
'reason' => $tokensReason,
'used' => $usage['total_tokens'],
'input_tokens' => $usage['input_tokens'],
'output_tokens' => $usage['output_tokens'],
'limit' => $tokensResult->isUnlimited() ? null : $tokensResult->limit,
'unlimited' => $tokensResult->isUnlimited(),
],
];
}
// ─────────────────────────────────────────────────────────────────────────
// Usage Retrieval
// ─────────────────────────────────────────────────────────────────────────
/**
* Get current month's usage for a workspace.
*
* @return array{tool_calls_count: int, input_tokens: int, output_tokens: int, total_tokens: int, month: string}
*/
public function getCurrentUsage(Workspace|int $workspace): array
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
return Cache::remember(
$this->getUsageCacheKey($workspaceId),
60, // 1 minute cache for current usage
function () use ($workspaceId) {
$quota = McpUsageQuota::getCurrentForWorkspace($workspaceId);
return [
'tool_calls_count' => $quota->tool_calls_count,
'input_tokens' => $quota->input_tokens,
'output_tokens' => $quota->output_tokens,
'total_tokens' => $quota->total_tokens,
'month' => $quota->month,
];
}
);
}
/**
* Get remaining quota for a workspace.
*
* @return array{tool_calls: int|null, tokens: int|null, tool_calls_unlimited: bool, tokens_unlimited: bool}
*/
public function getRemainingQuota(Workspace|int $workspace): array
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
$workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId);
if (! $workspace) {
return [
'tool_calls' => 0,
'tokens' => 0,
'tool_calls_unlimited' => false,
'tokens_unlimited' => false,
];
}
$usage = $this->getCurrentUsage($workspace);
// Tool calls remaining
$toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS);
$toolCallsRemaining = null;
$toolCallsUnlimited = $toolCallsResult->isUnlimited();
if ($toolCallsResult->isAllowed() && ! $toolCallsUnlimited && $toolCallsResult->limit !== null) {
$toolCallsRemaining = max(0, $toolCallsResult->limit - $usage['tool_calls_count']);
}
// Tokens remaining
$tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS);
$tokensRemaining = null;
$tokensUnlimited = $tokensResult->isUnlimited();
if ($tokensResult->isAllowed() && ! $tokensUnlimited && $tokensResult->limit !== null) {
$tokensRemaining = max(0, $tokensResult->limit - $usage['total_tokens']);
}
return [
'tool_calls' => $toolCallsRemaining,
'tokens' => $tokensRemaining,
'tool_calls_unlimited' => $toolCallsUnlimited,
'tokens_unlimited' => $tokensUnlimited,
];
}
// ─────────────────────────────────────────────────────────────────────────
// Quota Management
// ─────────────────────────────────────────────────────────────────────────
/**
* Reset monthly quota for a workspace (for billing cycle reset).
*/
public function resetMonthlyQuota(Workspace|int $workspace): McpUsageQuota
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
$quota = McpUsageQuota::getCurrentForWorkspace($workspaceId);
$quota->reset();
$this->invalidateUsageCache($workspaceId);
return $quota;
}
/**
* Get usage history for a workspace (last N months).
*
* @return \Illuminate\Support\Collection<McpUsageQuota>
*/
public function getUsageHistory(Workspace|int $workspace, int $months = 12): \Illuminate\Support\Collection
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
return McpUsageQuota::where('workspace_id', $workspaceId)
->orderByDesc('month')
->limit($months)
->get();
}
/**
* Get quota limits from entitlements.
*
* @return array{tool_calls_limit: int|null, tokens_limit: int|null, tool_calls_unlimited: bool, tokens_unlimited: bool}
*/
public function getQuotaLimits(Workspace|int $workspace): array
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
$workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId);
if (! $workspace) {
return [
'tool_calls_limit' => 0,
'tokens_limit' => 0,
'tool_calls_unlimited' => false,
'tokens_unlimited' => false,
];
}
$cacheKey = "mcp_quota_limits:{$workspaceId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($workspace) {
$toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS);
$tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS);
return [
'tool_calls_limit' => $toolCallsResult->isUnlimited() ? null : $toolCallsResult->limit,
'tokens_limit' => $tokensResult->isUnlimited() ? null : $tokensResult->limit,
'tool_calls_unlimited' => $toolCallsResult->isUnlimited(),
'tokens_unlimited' => $tokensResult->isUnlimited(),
];
});
}
// ─────────────────────────────────────────────────────────────────────────
// Response Headers
// ─────────────────────────────────────────────────────────────────────────
/**
* Get quota info formatted for HTTP response headers.
*
* @return array<string, string>
*/
public function getQuotaHeaders(Workspace|int $workspace): array
{
$usage = $this->getCurrentUsage($workspace);
$remaining = $this->getRemainingQuota($workspace);
$limits = $this->getQuotaLimits($workspace);
$headers = [
'X-MCP-Quota-Tool-Calls-Used' => (string) $usage['tool_calls_count'],
'X-MCP-Quota-Tokens-Used' => (string) $usage['total_tokens'],
];
if ($limits['tool_calls_unlimited']) {
$headers['X-MCP-Quota-Tool-Calls-Limit'] = 'unlimited';
$headers['X-MCP-Quota-Tool-Calls-Remaining'] = 'unlimited';
} else {
$headers['X-MCP-Quota-Tool-Calls-Limit'] = (string) ($limits['tool_calls_limit'] ?? 0);
$headers['X-MCP-Quota-Tool-Calls-Remaining'] = (string) ($remaining['tool_calls'] ?? 0);
}
if ($limits['tokens_unlimited']) {
$headers['X-MCP-Quota-Tokens-Limit'] = 'unlimited';
$headers['X-MCP-Quota-Tokens-Remaining'] = 'unlimited';
} else {
$headers['X-MCP-Quota-Tokens-Limit'] = (string) ($limits['tokens_limit'] ?? 0);
$headers['X-MCP-Quota-Tokens-Remaining'] = (string) ($remaining['tokens'] ?? 0);
}
$headers['X-MCP-Quota-Reset'] = now()->endOfMonth()->toIso8601String();
return $headers;
}
// ─────────────────────────────────────────────────────────────────────────
// Cache Management
// ─────────────────────────────────────────────────────────────────────────
/**
* Invalidate usage cache for a workspace.
*/
public function invalidateUsageCache(int $workspaceId): void
{
Cache::forget($this->getUsageCacheKey($workspaceId));
Cache::forget("mcp_quota_limits:{$workspaceId}");
}
/**
* Get cache key for workspace usage.
*/
protected function getUsageCacheKey(int $workspaceId): string
{
$month = now()->format('Y-m');
return "mcp_usage:{$workspaceId}:{$month}";
}
}

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Api\Models\WebhookDelivery;
use Core\Mod\Api\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Dispatches webhooks for MCP tool execution events.
*/
class McpWebhookDispatcher
{
/**
* Dispatch tool.executed event to all subscribed endpoints.
*/
public function dispatchToolExecuted(
int $workspaceId,
string $serverId,
string $toolName,
array $arguments,
bool $success,
int $durationMs,
?string $errorMessage = null
): void {
$eventType = 'mcp.tool.executed';
$endpoints = WebhookEndpoint::query()
->forWorkspace($workspaceId)
->active()
->forEvent($eventType)
->get();
if ($endpoints->isEmpty()) {
return;
}
$payload = [
'event' => $eventType,
'timestamp' => now()->toIso8601String(),
'data' => [
'server_id' => $serverId,
'tool_name' => $toolName,
'arguments' => $arguments,
'success' => $success,
'duration_ms' => $durationMs,
'error' => $errorMessage,
],
];
foreach ($endpoints as $endpoint) {
$this->deliverWebhook($endpoint, $payload);
}
}
/**
* Deliver a webhook to an endpoint.
*/
protected function deliverWebhook(WebhookEndpoint $endpoint, array $payload): void
{
$payloadJson = json_encode($payload);
$signature = $endpoint->generateSignature($payloadJson);
$startTime = microtime(true);
try {
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Signature' => $signature,
'X-Webhook-Event' => $payload['event'],
'X-Webhook-Timestamp' => $payload['timestamp'],
])
->withBody($payloadJson, 'application/json')
->post($endpoint->url);
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
// Record delivery
WebhookDelivery::create([
'webhook_endpoint_id' => $endpoint->id,
'event_id' => 'evt_'.uniqid(),
'event_type' => $payload['event'],
'payload' => $payload,
'response_code' => $response->status(),
'response_body' => substr($response->body(), 0, 1000),
'status' => $response->successful() ? 'success' : 'failed',
'attempt' => 1,
'delivered_at' => $response->successful() ? now() : null,
]);
if ($response->successful()) {
$endpoint->recordSuccess();
} else {
$endpoint->recordFailure();
Log::warning('MCP Webhook delivery failed', [
'endpoint_id' => $endpoint->id,
'url' => $endpoint->url,
'status' => $response->status(),
]);
}
} catch (\Throwable $e) {
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
WebhookDelivery::create([
'webhook_endpoint_id' => $endpoint->id,
'event_id' => 'evt_'.uniqid(),
'event_type' => $payload['event'],
'payload' => $payload,
'response_code' => 0,
'response_body' => $e->getMessage(),
'status' => 'failed',
'attempt' => 1,
]);
$endpoint->recordFailure();
Log::error('MCP Webhook delivery error', [
'endpoint_id' => $endpoint->id,
'url' => $endpoint->url,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Symfony\Component\Yaml\Yaml;
/**
* Generates OpenAPI 3.0 spec from MCP YAML definitions.
*/
class OpenApiGenerator
{
protected array $registry;
protected array $servers = [];
public function generate(): array
{
$this->loadRegistry();
$this->loadServers();
return [
'openapi' => '3.0.3',
'info' => $this->buildInfo(),
'servers' => $this->buildServers(),
'tags' => $this->buildTags(),
'paths' => $this->buildPaths(),
'components' => $this->buildComponents(),
];
}
public function toJson(): string
{
return json_encode($this->generate(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
public function toYaml(): string
{
return Yaml::dump($this->generate(), 10, 2);
}
protected function loadRegistry(): void
{
$path = resource_path('mcp/registry.yaml');
$this->registry = file_exists($path) ? Yaml::parseFile($path) : ['servers' => []];
}
protected function loadServers(): void
{
foreach ($this->registry['servers'] ?? [] as $ref) {
$path = resource_path("mcp/servers/{$ref['id']}.yaml");
if (file_exists($path)) {
$this->servers[$ref['id']] = Yaml::parseFile($path);
}
}
}
protected function buildInfo(): array
{
return [
'title' => 'Host UK MCP API',
'description' => 'HTTP API for interacting with Host UK MCP servers. Execute tools, read resources, and discover available capabilities.',
'version' => '1.0.0',
'contact' => [
'name' => 'Host UK Support',
'url' => 'https://host.uk.com/contact',
'email' => 'support@host.uk.com',
],
'license' => [
'name' => 'Proprietary',
'url' => 'https://host.uk.com/terms',
],
];
}
protected function buildServers(): array
{
return [
[
'url' => 'https://mcp.host.uk.com/api/v1/mcp',
'description' => 'Production',
],
[
'url' => 'https://mcp.test/api/v1/mcp',
'description' => 'Local development',
],
];
}
protected function buildTags(): array
{
$tags = [
[
'name' => 'Discovery',
'description' => 'Server and tool discovery endpoints',
],
[
'name' => 'Execution',
'description' => 'Tool execution endpoints',
],
];
foreach ($this->servers as $id => $server) {
$tags[] = [
'name' => $server['name'] ?? $id,
'description' => $server['tagline'] ?? $server['description'] ?? '',
];
}
return $tags;
}
protected function buildPaths(): array
{
$paths = [];
// Discovery endpoints
$paths['/servers'] = [
'get' => [
'tags' => ['Discovery'],
'summary' => 'List all MCP servers',
'operationId' => 'listServers',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'responses' => [
'200' => [
'description' => 'List of available servers',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ServerList',
],
],
],
],
],
],
];
$paths['/servers/{serverId}'] = [
'get' => [
'tags' => ['Discovery'],
'summary' => 'Get server details',
'operationId' => 'getServer',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'parameters' => [
[
'name' => 'serverId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
'description' => 'Server identifier',
],
],
'responses' => [
'200' => [
'description' => 'Server details with tools and resources',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/Server',
],
],
],
],
'404' => ['description' => 'Server not found'],
],
],
];
$paths['/servers/{serverId}/tools'] = [
'get' => [
'tags' => ['Discovery'],
'summary' => 'List tools for a server',
'operationId' => 'listServerTools',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'parameters' => [
[
'name' => 'serverId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
],
],
'responses' => [
'200' => [
'description' => 'List of tools',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ToolList',
],
],
],
],
],
],
];
// Execution endpoint
$paths['/tools/call'] = [
'post' => [
'tags' => ['Execution'],
'summary' => 'Execute an MCP tool',
'operationId' => 'callTool',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'requestBody' => [
'required' => true,
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ToolCallRequest',
],
],
],
],
'responses' => [
'200' => [
'description' => 'Tool executed successfully',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ToolCallResponse',
],
],
],
],
'400' => ['description' => 'Invalid request'],
'401' => ['description' => 'Unauthorized'],
'404' => ['description' => 'Server or tool not found'],
'500' => ['description' => 'Tool execution error'],
],
],
];
// Resource endpoint
$paths['/resources/{uri}'] = [
'get' => [
'tags' => ['Execution'],
'summary' => 'Read a resource',
'operationId' => 'readResource',
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
'parameters' => [
[
'name' => 'uri',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
'description' => 'Resource URI (server://path)',
],
],
'responses' => [
'200' => [
'description' => 'Resource content',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ResourceResponse',
],
],
],
],
],
],
];
return $paths;
}
protected function buildComponents(): array
{
return [
'securitySchemes' => [
'bearerAuth' => [
'type' => 'http',
'scheme' => 'bearer',
'description' => 'API key in Bearer format: hk_xxx_yyy',
],
'apiKeyAuth' => [
'type' => 'apiKey',
'in' => 'header',
'name' => 'X-API-Key',
'description' => 'API key header',
],
],
'schemas' => $this->buildSchemas(),
];
}
protected function buildSchemas(): array
{
$schemas = [
'ServerList' => [
'type' => 'object',
'properties' => [
'servers' => [
'type' => 'array',
'items' => ['$ref' => '#/components/schemas/ServerSummary'],
],
'count' => ['type' => 'integer'],
],
],
'ServerSummary' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'name' => ['type' => 'string'],
'tagline' => ['type' => 'string'],
'status' => ['type' => 'string', 'enum' => ['available', 'beta', 'deprecated']],
'tool_count' => ['type' => 'integer'],
'resource_count' => ['type' => 'integer'],
],
],
'Server' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'name' => ['type' => 'string'],
'tagline' => ['type' => 'string'],
'description' => ['type' => 'string'],
'tools' => [
'type' => 'array',
'items' => ['$ref' => '#/components/schemas/Tool'],
],
'resources' => [
'type' => 'array',
'items' => ['$ref' => '#/components/schemas/Resource'],
],
],
],
'Tool' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'description' => ['type' => 'string'],
'inputSchema' => [
'type' => 'object',
'additionalProperties' => true,
],
],
],
'Resource' => [
'type' => 'object',
'properties' => [
'uri' => ['type' => 'string'],
'name' => ['type' => 'string'],
'description' => ['type' => 'string'],
'mimeType' => ['type' => 'string'],
],
],
'ToolList' => [
'type' => 'object',
'properties' => [
'server' => ['type' => 'string'],
'tools' => [
'type' => 'array',
'items' => ['$ref' => '#/components/schemas/Tool'],
],
'count' => ['type' => 'integer'],
],
],
'ToolCallRequest' => [
'type' => 'object',
'required' => ['server', 'tool'],
'properties' => [
'server' => [
'type' => 'string',
'description' => 'Server ID',
],
'tool' => [
'type' => 'string',
'description' => 'Tool name',
],
'arguments' => [
'type' => 'object',
'description' => 'Tool arguments',
'additionalProperties' => true,
],
],
],
'ToolCallResponse' => [
'type' => 'object',
'properties' => [
'success' => ['type' => 'boolean'],
'server' => ['type' => 'string'],
'tool' => ['type' => 'string'],
'result' => [
'type' => 'object',
'additionalProperties' => true,
],
'duration_ms' => ['type' => 'integer'],
'error' => ['type' => 'string'],
],
],
'ResourceResponse' => [
'type' => 'object',
'properties' => [
'uri' => ['type' => 'string'],
'content' => [
'type' => 'object',
'additionalProperties' => true,
],
],
],
];
return $schemas;
}
}

View file

@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\Exceptions\ForbiddenQueryException;
/**
* Validates SQL queries for security before execution.
*
* Implements multiple layers of defence:
* 1. Keyword blocking - Prevents dangerous SQL operations
* 2. Structure validation - Detects injection patterns
* 3. Whitelist matching - Only allows known-safe query patterns
*/
class SqlQueryValidator
{
/**
* SQL keywords that are never allowed in queries.
* These represent write operations or dangerous constructs.
*/
private const BLOCKED_KEYWORDS = [
// Data modification
'INSERT',
'UPDATE',
'DELETE',
'REPLACE',
'TRUNCATE',
'DROP',
'ALTER',
'CREATE',
'RENAME',
// Permission/admin
'GRANT',
'REVOKE',
'FLUSH',
'KILL',
'RESET',
'PURGE',
// Data export
'INTO OUTFILE',
'INTO DUMPFILE',
'LOAD_FILE',
'LOAD DATA',
// Execution
'EXECUTE',
'EXEC',
'PREPARE',
'DEALLOCATE',
'CALL',
// Variables/settings
'SET ',
];
/**
* Patterns that indicate injection attempts.
* These are checked BEFORE comment stripping to catch obfuscation attempts.
*/
private const DANGEROUS_PATTERNS = [
// Stacked queries (semicolon followed by anything)
'/;\s*\S/i',
// UNION-based injection (with optional comment obfuscation)
'/\bUNION\b/i',
'/UNION/i', // Also catch UNION without word boundaries (comment-obfuscated)
// Hex encoding to bypass filters
'/0x[0-9a-f]+/i',
// CHAR() function often used in injection
'/\bCHAR\s*\(/i',
// BENCHMARK for time-based attacks
'/\bBENCHMARK\s*\(/i',
// SLEEP for time-based attacks
'/\bSLEEP\s*\(/i',
// Information schema access (could be allowed with whitelist)
'/\bINFORMATION_SCHEMA\b/i',
// System tables
'/\bmysql\./i',
'/\bperformance_schema\./i',
'/\bsys\./i',
// Subquery in WHERE that could leak data
'/WHERE\s+.*\(\s*SELECT/i',
// Comment obfuscation attempts (inline comments between keywords)
'/\/\*[^*]*\*\/\s*(?:UNION|SELECT|INSERT|UPDATE|DELETE|DROP)/i',
];
/**
* Default whitelist patterns for safe queries.
* These are regex patterns that match allowed query structures.
*
* WHERE clause restrictions:
* - Only allows column = value, column != value, column > value, etc.
* - Supports AND/OR logical operators
* - Allows LIKE, IN, BETWEEN, IS NULL/NOT NULL operators
* - No subqueries (no nested SELECT)
* - No function calls except common safe ones
*/
private const DEFAULT_WHITELIST = [
// Simple SELECT from single table with optional WHERE
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?(\s+ORDER\s+BY\s+[\w\s,`]+(\s+(ASC|DESC))?)?(\s+LIMIT\s+\d+(\s*,\s*\d+)?)?;?\s*$/i',
// COUNT queries
'/^\s*SELECT\s+COUNT\s*\(\s*\*?\s*\)\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?;?\s*$/i',
// SELECT with explicit column list
'/^\s*SELECT\s+`?\w+`?(\s*,\s*`?\w+`?)*\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?(\s+ORDER\s+BY\s+[\w\s,`]+)?(\s+LIMIT\s+\d+)?;?\s*$/i',
];
private array $whitelist;
private bool $useWhitelist;
public function __construct(
?array $whitelist = null,
bool $useWhitelist = true
) {
$this->whitelist = $whitelist ?? self::DEFAULT_WHITELIST;
$this->useWhitelist = $useWhitelist;
}
/**
* Validate a SQL query for safety.
*
* @throws ForbiddenQueryException If the query fails validation
*/
public function validate(string $query): void
{
// Check for dangerous patterns on the ORIGINAL query first
// This catches attempts to obfuscate keywords with comments
$this->checkDangerousPatterns($query);
// Now normalise and continue validation
$query = $this->normaliseQuery($query);
$this->checkBlockedKeywords($query);
$this->checkQueryStructure($query);
if ($this->useWhitelist) {
$this->checkWhitelist($query);
}
}
/**
* Check if a query is valid without throwing.
*/
public function isValid(string $query): bool
{
try {
$this->validate($query);
return true;
} catch (ForbiddenQueryException) {
return false;
}
}
/**
* Add a pattern to the whitelist.
*/
public function addWhitelistPattern(string $pattern): self
{
$this->whitelist[] = $pattern;
return $this;
}
/**
* Replace the entire whitelist.
*/
public function setWhitelist(array $patterns): self
{
$this->whitelist = $patterns;
return $this;
}
/**
* Enable or disable whitelist checking.
*/
public function setUseWhitelist(bool $use): self
{
$this->useWhitelist = $use;
return $this;
}
/**
* Normalise the query for consistent validation.
*/
private function normaliseQuery(string $query): string
{
// Remove SQL comments
$query = $this->stripComments($query);
// Normalise whitespace
$query = preg_replace('/\s+/', ' ', $query);
return trim($query);
}
/**
* Strip SQL comments which could be used to bypass filters.
*/
private function stripComments(string $query): string
{
// Remove -- style comments
$query = preg_replace('/--.*$/m', '', $query);
// Remove # style comments
$query = preg_replace('/#.*$/m', '', $query);
// Remove /* */ style comments (including multi-line)
$query = preg_replace('/\/\*.*?\*\//s', '', $query);
// Remove /*! MySQL-specific comments that execute code
$query = preg_replace('/\/\*!.*?\*\//s', '', $query);
return $query;
}
/**
* Check for blocked SQL keywords.
*
* @throws ForbiddenQueryException
*/
private function checkBlockedKeywords(string $query): void
{
$upperQuery = strtoupper($query);
foreach (self::BLOCKED_KEYWORDS as $keyword) {
// Use word boundary check for most keywords
$pattern = '/\b'.preg_quote($keyword, '/').'\b/i';
if (preg_match($pattern, $query)) {
throw ForbiddenQueryException::disallowedKeyword($query, $keyword);
}
}
}
/**
* Check for dangerous patterns that indicate injection.
*
* @throws ForbiddenQueryException
*/
private function checkDangerousPatterns(string $query): void
{
foreach (self::DANGEROUS_PATTERNS as $pattern) {
if (preg_match($pattern, $query)) {
throw ForbiddenQueryException::invalidStructure(
$query,
'Query contains potentially malicious pattern'
);
}
}
}
/**
* Check basic query structure.
*
* @throws ForbiddenQueryException
*/
private function checkQueryStructure(string $query): void
{
// Must start with SELECT
if (! preg_match('/^\s*SELECT\b/i', $query)) {
throw ForbiddenQueryException::invalidStructure(
$query,
'Query must begin with SELECT'
);
}
// Check for multiple statements (stacked queries)
// After stripping comments, there should be at most one semicolon at the end
$semicolonCount = substr_count($query, ';');
if ($semicolonCount > 1) {
throw ForbiddenQueryException::invalidStructure(
$query,
'Multiple statements detected'
);
}
if ($semicolonCount === 1 && ! preg_match('/;\s*$/', $query)) {
throw ForbiddenQueryException::invalidStructure(
$query,
'Semicolon only allowed at end of query'
);
}
}
/**
* Check if query matches at least one whitelist pattern.
*
* @throws ForbiddenQueryException
*/
private function checkWhitelist(string $query): void
{
foreach ($this->whitelist as $pattern) {
if (preg_match($pattern, $query)) {
return; // Query matches a whitelisted pattern
}
}
throw ForbiddenQueryException::notWhitelisted($query);
}
}

View file

@ -0,0 +1,386 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\DTO\ToolStats;
use Core\Mod\Mcp\Models\ToolMetric;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Tool Analytics Service - analytics and reporting for MCP tool usage.
*
* Provides methods for recording tool executions and querying analytics data
* including usage statistics, trends, and tool combinations.
*/
class ToolAnalyticsService
{
/**
* Batch of pending metrics to be flushed.
*
* @var array<string, array{calls: int, errors: int, duration: int, min: int|null, max: int|null}>
*/
protected array $pendingMetrics = [];
/**
* Track tools used in current session for combination tracking.
*
* @var array<string, array<string>>
*/
protected array $sessionTools = [];
/**
* Record a tool execution.
*/
public function recordExecution(
string $tool,
int $durationMs,
bool $success,
?string $workspaceId = null,
?string $sessionId = null
): void {
if (! config('mcp.analytics.enabled', true)) {
return;
}
$key = $this->getMetricKey($tool, $workspaceId);
if (! isset($this->pendingMetrics[$key])) {
$this->pendingMetrics[$key] = [
'tool_name' => $tool,
'workspace_id' => $workspaceId,
'calls' => 0,
'errors' => 0,
'duration' => 0,
'min' => null,
'max' => null,
];
}
$this->pendingMetrics[$key]['calls']++;
$this->pendingMetrics[$key]['duration'] += $durationMs;
if (! $success) {
$this->pendingMetrics[$key]['errors']++;
}
if ($this->pendingMetrics[$key]['min'] === null || $durationMs < $this->pendingMetrics[$key]['min']) {
$this->pendingMetrics[$key]['min'] = $durationMs;
}
if ($this->pendingMetrics[$key]['max'] === null || $durationMs > $this->pendingMetrics[$key]['max']) {
$this->pendingMetrics[$key]['max'] = $durationMs;
}
// Track tool combinations if session ID provided
if ($sessionId !== null) {
$this->trackToolInSession($sessionId, $tool, $workspaceId);
}
// Flush if batch size reached
$batchSize = config('mcp.analytics.batch_size', 100);
if ($this->getTotalPendingCalls() >= $batchSize) {
$this->flush();
}
}
/**
* Get statistics for a specific tool.
*/
public function getToolStats(string $tool, ?Carbon $from = null, ?Carbon $to = null): ToolStats
{
$from = $from ?? now()->subDays(30);
$to = $to ?? now();
$stats = ToolMetric::getAggregatedStats($tool, $from, $to);
return ToolStats::fromArray($stats);
}
/**
* Get statistics for all tools.
*/
public function getAllToolStats(?Carbon $from = null, ?Carbon $to = null): Collection
{
$from = $from ?? now()->subDays(30);
$to = $to ?? now();
$results = ToolMetric::query()
->select('tool_name')
->selectRaw('SUM(call_count) as total_calls')
->selectRaw('SUM(error_count) as error_count')
->selectRaw('SUM(total_duration_ms) as total_duration')
->selectRaw('MIN(min_duration_ms) as min_duration_ms')
->selectRaw('MAX(max_duration_ms) as max_duration_ms')
->forDateRange($from, $to)
->groupBy('tool_name')
->orderByDesc('total_calls')
->get();
return $results->map(function ($row) {
$totalCalls = (int) $row->total_calls;
$errorCount = (int) $row->error_count;
$totalDuration = (int) $row->total_duration;
return new ToolStats(
toolName: $row->tool_name,
totalCalls: $totalCalls,
errorCount: $errorCount,
errorRate: $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0,
avgDurationMs: $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0,
minDurationMs: (int) ($row->min_duration_ms ?? 0),
maxDurationMs: (int) ($row->max_duration_ms ?? 0),
);
});
}
/**
* Get the most popular tools by call count.
*/
public function getPopularTools(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection
{
return $this->getAllToolStats($from, $to)
->sortByDesc(fn (ToolStats $stats) => $stats->totalCalls)
->take($limit)
->values();
}
/**
* Get tools with the highest error rates.
*/
public function getErrorProneTools(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection
{
$minCalls = 10; // Require minimum calls to be considered
return $this->getAllToolStats($from, $to)
->filter(fn (ToolStats $stats) => $stats->totalCalls >= $minCalls)
->sortByDesc(fn (ToolStats $stats) => $stats->errorRate)
->take($limit)
->values();
}
/**
* Get tool combinations - tools frequently used together.
*/
public function getToolCombinations(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection
{
$from = $from ?? now()->subDays(30);
$to = $to ?? now();
return DB::table('mcp_tool_combinations')
->select('tool_a', 'tool_b')
->selectRaw('SUM(occurrence_count) as total_occurrences')
->whereBetween('date', [$from->toDateString(), $to->toDateString()])
->groupBy('tool_a', 'tool_b')
->orderByDesc('total_occurrences')
->limit($limit)
->get()
->map(fn ($row) => [
'tool_a' => $row->tool_a,
'tool_b' => $row->tool_b,
'occurrences' => (int) $row->total_occurrences,
]);
}
/**
* Get usage trends for a specific tool.
*/
public function getUsageTrends(string $tool, int $days = 30): array
{
$startDate = now()->subDays($days - 1)->startOfDay();
$endDate = now()->endOfDay();
$metrics = ToolMetric::forTool($tool)
->forDateRange($startDate, $endDate)
->orderBy('date')
->get()
->keyBy(fn ($m) => $m->date->toDateString());
$trends = [];
for ($i = $days - 1; $i >= 0; $i--) {
$date = now()->subDays($i)->toDateString();
$metric = $metrics->get($date);
$trends[] = [
'date' => $date,
'date_formatted' => Carbon::parse($date)->format('M j'),
'calls' => $metric?->call_count ?? 0,
'errors' => $metric?->error_count ?? 0,
'avg_duration_ms' => $metric?->average_duration ?? 0,
'error_rate' => $metric?->error_rate ?? 0,
];
}
return $trends;
}
/**
* Get workspace-specific statistics.
*/
public function getWorkspaceStats(string $workspaceId, ?Carbon $from = null, ?Carbon $to = null): array
{
$from = $from ?? now()->subDays(30);
$to = $to ?? now();
$results = ToolMetric::query()
->forWorkspace($workspaceId)
->forDateRange($from, $to)
->get();
$totalCalls = $results->sum('call_count');
$errorCount = $results->sum('error_count');
$totalDuration = $results->sum('total_duration_ms');
$uniqueTools = $results->pluck('tool_name')->unique()->count();
return [
'workspace_id' => $workspaceId,
'total_calls' => $totalCalls,
'error_count' => $errorCount,
'error_rate' => $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0,
'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0,
'unique_tools' => $uniqueTools,
];
}
/**
* Flush pending metrics to the database.
*/
public function flush(): void
{
if (empty($this->pendingMetrics)) {
return;
}
$date = now()->toDateString();
foreach ($this->pendingMetrics as $data) {
$metric = ToolMetric::firstOrCreate([
'tool_name' => $data['tool_name'],
'workspace_id' => $data['workspace_id'],
'date' => $date,
], [
'call_count' => 0,
'error_count' => 0,
'total_duration_ms' => 0,
]);
$metric->call_count += $data['calls'];
$metric->error_count += $data['errors'];
$metric->total_duration_ms += $data['duration'];
if ($data['min'] !== null) {
if ($metric->min_duration_ms === null || $data['min'] < $metric->min_duration_ms) {
$metric->min_duration_ms = $data['min'];
}
}
if ($data['max'] !== null) {
if ($metric->max_duration_ms === null || $data['max'] > $metric->max_duration_ms) {
$metric->max_duration_ms = $data['max'];
}
}
$metric->save();
}
// Flush session tool combinations
$this->flushToolCombinations();
$this->pendingMetrics = [];
}
/**
* Track a tool being used in a session.
*/
protected function trackToolInSession(string $sessionId, string $tool, ?string $workspaceId): void
{
$key = $sessionId.':'.($workspaceId ?? 'global');
if (! isset($this->sessionTools[$key])) {
$this->sessionTools[$key] = [
'workspace_id' => $workspaceId,
'tools' => [],
];
}
if (! in_array($tool, $this->sessionTools[$key]['tools'], true)) {
$this->sessionTools[$key]['tools'][] = $tool;
}
}
/**
* Flush tool combinations to the database.
*/
protected function flushToolCombinations(): void
{
$date = now()->toDateString();
foreach ($this->sessionTools as $sessionData) {
$tools = $sessionData['tools'];
$workspaceId = $sessionData['workspace_id'];
// Generate all unique pairs
$count = count($tools);
for ($i = 0; $i < $count; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
// Ensure consistent ordering (alphabetical)
$pair = [$tools[$i], $tools[$j]];
sort($pair);
DB::table('mcp_tool_combinations')
->updateOrInsert(
[
'tool_a' => $pair[0],
'tool_b' => $pair[1],
'workspace_id' => $workspaceId,
'date' => $date,
],
[
'occurrence_count' => DB::raw('occurrence_count + 1'),
'updated_at' => now(),
]
);
// Handle insert case where occurrence_count wasn't set
DB::table('mcp_tool_combinations')
->where('tool_a', $pair[0])
->where('tool_b', $pair[1])
->where('workspace_id', $workspaceId)
->where('date', $date)
->whereNull('created_at')
->update([
'created_at' => now(),
'occurrence_count' => 1,
]);
}
}
}
$this->sessionTools = [];
}
/**
* Get the metric key for batching.
*/
protected function getMetricKey(string $tool, ?string $workspaceId): string
{
return $tool.':'.($workspaceId ?? 'global');
}
/**
* Get total pending calls across all batches.
*/
protected function getTotalPendingCalls(): int
{
$total = 0;
foreach ($this->pendingMetrics as $data) {
$total += $data['calls'];
}
return $total;
}
}

View file

@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\Dependencies\DependencyType;
use Core\Mod\Mcp\Dependencies\ToolDependency;
use Core\Mod\Mcp\Exceptions\MissingDependencyException;
use Illuminate\Support\Facades\Cache;
/**
* Service for validating tool dependency graphs.
*
* Ensures tools have their prerequisites met before execution.
* Tracks which tools have been called in a session and validates
* against defined dependency rules.
*/
class ToolDependencyService
{
/**
* Cache key prefix for session tool history.
*/
protected const SESSION_CACHE_PREFIX = 'mcp:session_tools:';
/**
* Cache TTL for session data (24 hours).
*/
protected const SESSION_CACHE_TTL = 86400;
/**
* Registered tool dependencies.
*
* @var array<string, array<ToolDependency>>
*/
protected array $dependencies = [];
/**
* Custom dependency validators.
*
* @var array<string, callable>
*/
protected array $customValidators = [];
public function __construct()
{
$this->registerDefaultDependencies();
}
/**
* Register dependencies for a tool.
*
* @param string $toolName The tool name
* @param array<ToolDependency> $dependencies List of dependencies
*/
public function register(string $toolName, array $dependencies): self
{
$this->dependencies[$toolName] = $dependencies;
return $this;
}
/**
* Register a custom validator for CUSTOM dependency types.
*
* @param string $name The custom dependency name
* @param callable $validator Function(array $context, array $args): bool
*/
public function registerCustomValidator(string $name, callable $validator): self
{
$this->customValidators[$name] = $validator;
return $this;
}
/**
* Get dependencies for a tool.
*
* @return array<ToolDependency>
*/
public function getDependencies(string $toolName): array
{
return $this->dependencies[$toolName] ?? [];
}
/**
* Check if all dependencies are met for a tool.
*
* @param string $sessionId The session identifier
* @param string $toolName The tool to check
* @param array $context The execution context
* @param array $args The tool arguments
* @return bool True if all dependencies are met
*/
public function checkDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): bool
{
$missing = $this->getMissingDependencies($sessionId, $toolName, $context, $args);
return empty($missing);
}
/**
* Get list of missing dependencies for a tool.
*
* @param string $sessionId The session identifier
* @param string $toolName The tool to check
* @param array $context The execution context
* @param array $args The tool arguments
* @return array<ToolDependency> List of unmet dependencies
*/
public function getMissingDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): array
{
$dependencies = $this->getDependencies($toolName);
if (empty($dependencies)) {
return [];
}
$calledTools = $this->getCalledTools($sessionId);
$missing = [];
foreach ($dependencies as $dependency) {
if ($dependency->optional) {
continue; // Skip optional dependencies
}
$isMet = $this->isDependencyMet($dependency, $calledTools, $context, $args);
if (! $isMet) {
$missing[] = $dependency;
}
}
return $missing;
}
/**
* Validate dependencies and throw exception if not met.
*
* @param string $sessionId The session identifier
* @param string $toolName The tool to validate
* @param array $context The execution context
* @param array $args The tool arguments
*
* @throws MissingDependencyException If dependencies are not met
*/
public function validateDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): void
{
$missing = $this->getMissingDependencies($sessionId, $toolName, $context, $args);
if (! empty($missing)) {
$suggestedOrder = $this->getSuggestedToolOrder($toolName, $missing);
throw new MissingDependencyException($toolName, $missing, $suggestedOrder);
}
}
/**
* Record that a tool was called in a session.
*
* @param string $sessionId The session identifier
* @param string $toolName The tool that was called
* @param array $args The arguments used (for entity tracking)
*/
public function recordToolCall(string $sessionId, string $toolName, array $args = []): void
{
$key = self::SESSION_CACHE_PREFIX.$sessionId;
$history = Cache::get($key, []);
$history[] = [
'tool' => $toolName,
'args' => $args,
'timestamp' => now()->toIso8601String(),
];
Cache::put($key, $history, self::SESSION_CACHE_TTL);
}
/**
* Get list of tools called in a session.
*
* @return array<string> Tool names that have been called
*/
public function getCalledTools(string $sessionId): array
{
$key = self::SESSION_CACHE_PREFIX.$sessionId;
$history = Cache::get($key, []);
return array_unique(array_column($history, 'tool'));
}
/**
* Get full tool call history for a session.
*
* @return array<array{tool: string, args: array, timestamp: string}>
*/
public function getToolHistory(string $sessionId): array
{
$key = self::SESSION_CACHE_PREFIX.$sessionId;
return Cache::get($key, []);
}
/**
* Clear session tool history.
*/
public function clearSession(string $sessionId): void
{
Cache::forget(self::SESSION_CACHE_PREFIX.$sessionId);
}
/**
* Get the full dependency graph for visualization.
*
* @return array<string, array{dependencies: array, dependents: array}>
*/
public function getDependencyGraph(): array
{
$graph = [];
// Build forward dependencies
foreach ($this->dependencies as $tool => $deps) {
$graph[$tool] = [
'dependencies' => array_map(fn (ToolDependency $d) => $d->toArray(), $deps),
'dependents' => [],
];
}
// Build reverse dependencies (who depends on whom)
foreach ($this->dependencies as $tool => $deps) {
foreach ($deps as $dep) {
if ($dep->type === DependencyType::TOOL_CALLED) {
if (! isset($graph[$dep->key])) {
$graph[$dep->key] = [
'dependencies' => [],
'dependents' => [],
];
}
$graph[$dep->key]['dependents'][] = $tool;
}
}
}
return $graph;
}
/**
* Get all tools that depend on a specific tool.
*
* @return array<string> Tool names that depend on the given tool
*/
public function getDependentTools(string $toolName): array
{
$dependents = [];
foreach ($this->dependencies as $tool => $deps) {
foreach ($deps as $dep) {
if ($dep->type === DependencyType::TOOL_CALLED && $dep->key === $toolName) {
$dependents[] = $tool;
}
}
}
return $dependents;
}
/**
* Get all tools in dependency order (topological sort).
*
* @return array<string> Tools sorted by dependency order
*/
public function getTopologicalOrder(): array
{
$visited = [];
$order = [];
$tools = array_keys($this->dependencies);
foreach ($tools as $tool) {
$this->topologicalVisit($tool, $visited, $order);
}
return $order;
}
/**
* Check if a specific dependency is met.
*/
protected function isDependencyMet(
ToolDependency $dependency,
array $calledTools,
array $context,
array $args
): bool {
return match ($dependency->type) {
DependencyType::TOOL_CALLED => in_array($dependency->key, $calledTools, true),
DependencyType::SESSION_STATE => isset($context[$dependency->key]) && $context[$dependency->key] !== null,
DependencyType::CONTEXT_EXISTS => array_key_exists($dependency->key, $context),
DependencyType::ENTITY_EXISTS => $this->checkEntityExists($dependency, $args, $context),
DependencyType::CUSTOM => $this->checkCustomDependency($dependency, $context, $args),
};
}
/**
* Check if an entity exists based on the dependency configuration.
*/
protected function checkEntityExists(ToolDependency $dependency, array $args, array $context): bool
{
$entityType = $dependency->key;
$argKey = $dependency->metadata['arg_key'] ?? null;
if (! $argKey || ! isset($args[$argKey])) {
return false;
}
// Check based on entity type
return match ($entityType) {
'plan' => $this->planExists($args[$argKey]),
'session' => $this->sessionExists($args[$argKey] ?? $context['session_id'] ?? null),
'phase' => $this->phaseExists($args['plan_slug'] ?? null, $args[$argKey] ?? null),
default => true, // Unknown entity types pass by default
};
}
/**
* Check if a plan exists.
*/
protected function planExists(?string $slug): bool
{
if (! $slug) {
return false;
}
// Use a simple database check - the model namespace may vary
return \DB::table('agent_plans')->where('slug', $slug)->exists();
}
/**
* Check if a session exists.
*/
protected function sessionExists(?string $sessionId): bool
{
if (! $sessionId) {
return false;
}
return \DB::table('agent_sessions')->where('session_id', $sessionId)->exists();
}
/**
* Check if a phase exists.
*/
protected function phaseExists(?string $planSlug, ?string $phaseIdentifier): bool
{
if (! $planSlug || ! $phaseIdentifier) {
return false;
}
$plan = \DB::table('agent_plans')->where('slug', $planSlug)->first();
if (! $plan) {
return false;
}
$query = \DB::table('agent_phases')->where('agent_plan_id', $plan->id);
if (is_numeric($phaseIdentifier)) {
return $query->where('order', (int) $phaseIdentifier)->exists();
}
return $query->where('name', $phaseIdentifier)->exists();
}
/**
* Check a custom dependency using registered validator.
*/
protected function checkCustomDependency(ToolDependency $dependency, array $context, array $args): bool
{
$validator = $this->customValidators[$dependency->key] ?? null;
if (! $validator) {
// No validator registered - pass by default with warning
return true;
}
return call_user_func($validator, $context, $args);
}
/**
* Get suggested tool order to satisfy dependencies.
*
* @param array<ToolDependency> $missing
* @return array<string>
*/
protected function getSuggestedToolOrder(string $targetTool, array $missing): array
{
$order = [];
foreach ($missing as $dep) {
if ($dep->type === DependencyType::TOOL_CALLED) {
// Recursively get dependencies of the required tool
$preDeps = $this->getDependencies($dep->key);
foreach ($preDeps as $preDep) {
if ($preDep->type === DependencyType::TOOL_CALLED && ! in_array($preDep->key, $order, true)) {
$order[] = $preDep->key;
}
}
if (! in_array($dep->key, $order, true)) {
$order[] = $dep->key;
}
}
}
$order[] = $targetTool;
return $order;
}
/**
* Helper for topological sort.
*/
protected function topologicalVisit(string $tool, array &$visited, array &$order): void
{
if (isset($visited[$tool])) {
return;
}
$visited[$tool] = true;
foreach ($this->getDependencies($tool) as $dep) {
if ($dep->type === DependencyType::TOOL_CALLED) {
$this->topologicalVisit($dep->key, $visited, $order);
}
}
$order[] = $tool;
}
/**
* Register default dependencies for known tools.
*/
protected function registerDefaultDependencies(): void
{
// Session tools - session_log/artifact/handoff require active session
$this->register('session_log', [
ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'),
]);
$this->register('session_artifact', [
ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'),
]);
$this->register('session_handoff', [
ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'),
]);
$this->register('session_end', [
ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'),
]);
// Plan tools - require workspace context
$this->register('plan_create', [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
]);
// Task tools - require plan to exist
$this->register('task_update', [
ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']),
]);
$this->register('task_toggle', [
ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']),
]);
// Phase tools - require plan to exist
$this->register('phase_get', [
ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']),
]);
$this->register('phase_update_status', [
ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']),
]);
$this->register('phase_add_checkpoint', [
ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']),
]);
// Content tools - require brief to exist for generation
$this->register('content_generate', [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
]);
$this->register('content_batch_generate', [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
]);
}
}

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Illuminate\Support\Facades\Cache;
/**
* Rate limiter for MCP tool calls.
*
* Provides rate limiting for both HTTP and STDIO server tool invocations.
* Uses cache-based rate limiting that works with any cache driver.
*/
class ToolRateLimiter
{
/**
* Cache key prefix for rate limit tracking.
*/
protected const CACHE_PREFIX = 'mcp_rate_limit:';
/**
* Check if a tool call should be rate limited.
*
* @param string $identifier Session ID, API key, or other unique identifier
* @param string $toolName The tool being called
* @return array{limited: bool, remaining: int, retry_after: int|null}
*/
public function check(string $identifier, string $toolName): array
{
if (! config('mcp.rate_limiting.enabled', true)) {
return ['limited' => false, 'remaining' => PHP_INT_MAX, 'retry_after' => null];
}
$limit = $this->getLimitForTool($toolName);
$decaySeconds = config('mcp.rate_limiting.decay_seconds', 60);
$cacheKey = $this->getCacheKey($identifier, $toolName);
$current = (int) Cache::get($cacheKey, 0);
if ($current >= $limit) {
$ttl = Cache::ttl($cacheKey);
return [
'limited' => true,
'remaining' => 0,
'retry_after' => $ttl > 0 ? $ttl : $decaySeconds,
];
}
return [
'limited' => false,
'remaining' => $limit - $current - 1,
'retry_after' => null,
];
}
/**
* Record a tool call against the rate limit.
*
* @param string $identifier Session ID, API key, or other unique identifier
* @param string $toolName The tool being called
*/
public function hit(string $identifier, string $toolName): void
{
if (! config('mcp.rate_limiting.enabled', true)) {
return;
}
$decaySeconds = config('mcp.rate_limiting.decay_seconds', 60);
$cacheKey = $this->getCacheKey($identifier, $toolName);
$current = (int) Cache::get($cacheKey, 0);
if ($current === 0) {
// First call - set with expiration
Cache::put($cacheKey, 1, $decaySeconds);
} else {
// Increment without resetting TTL
Cache::increment($cacheKey);
}
}
/**
* Clear rate limit for an identifier.
*
* @param string $identifier Session ID, API key, or other unique identifier
* @param string|null $toolName Specific tool, or null to clear all
*/
public function clear(string $identifier, ?string $toolName = null): void
{
if ($toolName !== null) {
Cache::forget($this->getCacheKey($identifier, $toolName));
} else {
// Clear all tool rate limits for this identifier (requires knowing tools)
// For now, just clear the specific key pattern
Cache::forget($this->getCacheKey($identifier, '*'));
}
}
/**
* Get the rate limit for a specific tool.
*/
protected function getLimitForTool(string $toolName): int
{
// Check for tool-specific limit
$perToolLimits = config('mcp.rate_limiting.per_tool', []);
if (isset($perToolLimits[$toolName])) {
return (int) $perToolLimits[$toolName];
}
// Use default limit
return (int) config('mcp.rate_limiting.calls_per_minute', 60);
}
/**
* Generate cache key for rate limiting.
*/
protected function getCacheKey(string $identifier, string $toolName): string
{
// Use general key for overall rate limiting
return self::CACHE_PREFIX.$identifier.':'.$toolName;
}
/**
* Get rate limit status for reporting.
*
* @return array{limit: int, remaining: int, reset_at: string|null}
*/
public function getStatus(string $identifier, string $toolName): array
{
$limit = $this->getLimitForTool($toolName);
$cacheKey = $this->getCacheKey($identifier, $toolName);
$current = (int) Cache::get($cacheKey, 0);
$ttl = Cache::ttl($cacheKey);
return [
'limit' => $limit,
'remaining' => max(0, $limit - $current),
'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null,
];
}
}

View file

@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\Models\McpToolVersion;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Yaml\Yaml;
/**
* Registry for MCP Tools with schema and example management.
*
* Provides tool discovery, schema extraction, example inputs,
* and category-based organisation for the MCP Playground UI.
*/
class ToolRegistry
{
/**
* Cache TTL for registry data (5 minutes).
*/
protected const CACHE_TTL = 300;
/**
* Example inputs for specific tools.
* These provide sensible defaults for testing tools.
*
* @var array<string, array<string, mixed>>
*/
protected array $examples = [
'query_database' => [
'query' => 'SELECT id, name FROM users LIMIT 10',
],
'list_tables' => [],
'list_routes' => [],
'list_sites' => [],
'get_stats' => [],
'create_coupon' => [
'code' => 'SUMMER25',
'discount_type' => 'percentage',
'discount_value' => 25,
'expires_at' => '2025-12-31',
],
'list_invoices' => [
'status' => 'paid',
'limit' => 10,
],
'get_billing_status' => [],
'upgrade_plan' => [
'plan_slug' => 'professional',
],
];
/**
* Get all available MCP servers.
*
* @return Collection<int, array{id: string, name: string, tagline: string, tool_count: int}>
*/
public function getServers(): Collection
{
return Cache::remember('mcp:playground:servers', self::CACHE_TTL, function () {
$registry = $this->loadRegistry();
return collect($registry['servers'] ?? [])
->map(fn ($ref) => $this->loadServerSummary($ref['id']))
->filter()
->values();
});
}
/**
* Get all tools for a specific server.
*
* @return Collection<int, array{name: string, description: string, category: string, inputSchema: array, examples: array, version: string|null}>
*/
public function getToolsForServer(string $serverId, bool $includeVersionInfo = false): Collection
{
$cacheKey = $includeVersionInfo
? "mcp:playground:tools:{$serverId}:versioned"
: "mcp:playground:tools:{$serverId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $includeVersionInfo) {
$server = $this->loadServerFull($serverId);
if (! $server) {
return collect();
}
return collect($server['tools'] ?? [])
->map(function ($tool) use ($serverId, $includeVersionInfo) {
$name = $tool['name'];
$baseVersion = $tool['version'] ?? ToolVersionService::DEFAULT_VERSION;
$result = [
'name' => $name,
'description' => $tool['description'] ?? $tool['purpose'] ?? '',
'category' => $this->extractCategory($tool),
'inputSchema' => $tool['inputSchema'] ?? ['type' => 'object', 'properties' => $tool['parameters'] ?? []],
'examples' => $this->examples[$name] ?? $this->generateExampleFromSchema($tool['inputSchema'] ?? []),
'version' => $baseVersion,
];
// Optionally enrich with database version info
if ($includeVersionInfo) {
$latestVersion = McpToolVersion::forServer($serverId)
->forTool($name)
->latest()
->first();
if ($latestVersion) {
$result['version'] = $latestVersion->version;
$result['version_status'] = $latestVersion->status;
$result['is_deprecated'] = $latestVersion->is_deprecated;
$result['sunset_at'] = $latestVersion->sunset_at?->toIso8601String();
// Use versioned schema if available
if ($latestVersion->input_schema) {
$result['inputSchema'] = $latestVersion->input_schema;
}
}
}
return $result;
})
->values();
});
}
/**
* Get all tools grouped by category.
*
* @return Collection<string, Collection<int, array>>
*/
public function getToolsByCategory(string $serverId): Collection
{
return $this->getToolsForServer($serverId)
->groupBy('category')
->sortKeys();
}
/**
* Search tools by name or description.
*
* @return Collection<int, array>
*/
public function searchTools(string $serverId, string $query): Collection
{
$query = strtolower(trim($query));
if (empty($query)) {
return $this->getToolsForServer($serverId);
}
return $this->getToolsForServer($serverId)
->filter(function ($tool) use ($query) {
return str_contains(strtolower($tool['name']), $query)
|| str_contains(strtolower($tool['description']), $query)
|| str_contains(strtolower($tool['category']), $query);
})
->values();
}
/**
* Get a specific tool by name.
*/
public function getTool(string $serverId, string $toolName): ?array
{
return $this->getToolsForServer($serverId)
->firstWhere('name', $toolName);
}
/**
* Get example inputs for a tool.
*/
public function getExampleInputs(string $toolName): array
{
return $this->examples[$toolName] ?? [];
}
/**
* Set custom example inputs for a tool.
*/
public function setExampleInputs(string $toolName, array $examples): void
{
$this->examples[$toolName] = $examples;
}
/**
* Get all categories across all servers.
*
* @return Collection<string, int>
*/
public function getAllCategories(): Collection
{
return $this->getServers()
->flatMap(fn ($server) => $this->getToolsForServer($server['id']))
->groupBy('category')
->map(fn ($tools) => $tools->count())
->sortKeys();
}
/**
* Get full server configuration.
*/
public function getServerFull(string $serverId): ?array
{
return $this->loadServerFull($serverId);
}
/**
* Clear cached registry data.
*/
public function clearCache(): void
{
Cache::forget('mcp:playground:servers');
foreach ($this->getServers() as $server) {
Cache::forget("mcp:playground:tools:{$server['id']}");
}
}
/**
* Extract category from tool definition.
*/
protected function extractCategory(array $tool): string
{
// Check for explicit category
if (isset($tool['category'])) {
return ucfirst($tool['category']);
}
// Infer from tool name
$name = $tool['name'] ?? '';
$categoryPatterns = [
'query' => ['query', 'search', 'find', 'get', 'list'],
'commerce' => ['coupon', 'invoice', 'billing', 'plan', 'payment', 'subscription'],
'content' => ['content', 'article', 'page', 'post', 'media'],
'system' => ['table', 'route', 'stat', 'config', 'setting'],
'user' => ['user', 'auth', 'session', 'permission'],
];
foreach ($categoryPatterns as $category => $patterns) {
foreach ($patterns as $pattern) {
if (str_contains(strtolower($name), $pattern)) {
return ucfirst($category);
}
}
}
return 'General';
}
/**
* Generate example inputs from JSON schema.
*/
protected function generateExampleFromSchema(array $schema): array
{
$properties = $schema['properties'] ?? [];
$examples = [];
foreach ($properties as $name => $prop) {
$type = is_array($prop['type'] ?? 'string') ? ($prop['type'][0] ?? 'string') : ($prop['type'] ?? 'string');
// Use default if available
if (isset($prop['default'])) {
$examples[$name] = $prop['default'];
continue;
}
// Use example if available
if (isset($prop['example'])) {
$examples[$name] = $prop['example'];
continue;
}
// Use first enum value if available
if (isset($prop['enum']) && ! empty($prop['enum'])) {
$examples[$name] = $prop['enum'][0];
continue;
}
// Generate based on type
$examples[$name] = match ($type) {
'integer', 'number' => $prop['minimum'] ?? 0,
'boolean' => false,
'array' => [],
'object' => new \stdClass,
default => '', // string
};
}
return $examples;
}
/**
* Load the MCP registry file.
*/
protected function loadRegistry(): array
{
$path = resource_path('mcp/registry.yaml');
if (! file_exists($path)) {
return ['servers' => []];
}
return Yaml::parseFile($path);
}
/**
* Load full server configuration.
*/
protected function loadServerFull(string $id): ?array
{
// Sanitise server ID to prevent path traversal
$id = basename($id, '.yaml');
if (! preg_match('/^[a-z0-9-]+$/', $id)) {
return null;
}
$path = resource_path("mcp/servers/{$id}.yaml");
if (! file_exists($path)) {
return null;
}
return Yaml::parseFile($path);
}
/**
* Load server summary (id, name, tagline, tool count).
*/
protected function loadServerSummary(string $id): ?array
{
$server = $this->loadServerFull($id);
if (! $server) {
return null;
}
return [
'id' => $server['id'],
'name' => $server['name'],
'tagline' => $server['tagline'] ?? '',
'tool_count' => count($server['tools'] ?? []),
];
}
}

View file

@ -0,0 +1,478 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\Models\McpToolVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Tool Version Service - manages MCP tool versioning for backwards compatibility.
*
* Provides version registration, lookup, comparison, and migration support
* for maintaining compatibility with running agents during tool schema changes.
*/
class ToolVersionService
{
/**
* Cache key prefix for version lookups.
*/
protected const CACHE_PREFIX = 'mcp:tool_version:';
/**
* Cache TTL for version data (5 minutes).
*/
protected const CACHE_TTL = 300;
/**
* Default version for unversioned tools.
*/
public const DEFAULT_VERSION = '1.0.0';
/**
* Register a new tool version.
*
* @param array $options Additional options (changelog, migration_notes, mark_latest)
*/
public function registerVersion(
string $serverId,
string $toolName,
string $version,
?array $inputSchema = null,
?array $outputSchema = null,
?string $description = null,
array $options = []
): McpToolVersion {
// Validate semver format
if (! $this->isValidSemver($version)) {
throw new \InvalidArgumentException("Invalid semver version: {$version}");
}
// Check if version already exists
$existing = McpToolVersion::forServer($serverId)
->forTool($toolName)
->forVersion($version)
->first();
if ($existing) {
// Update existing version
$existing->update([
'input_schema' => $inputSchema ?? $existing->input_schema,
'output_schema' => $outputSchema ?? $existing->output_schema,
'description' => $description ?? $existing->description,
'changelog' => $options['changelog'] ?? $existing->changelog,
'migration_notes' => $options['migration_notes'] ?? $existing->migration_notes,
]);
if ($options['mark_latest'] ?? false) {
$existing->markAsLatest();
}
$this->clearCache($serverId, $toolName);
return $existing->fresh();
}
// Create new version
$toolVersion = McpToolVersion::create([
'server_id' => $serverId,
'tool_name' => $toolName,
'version' => $version,
'input_schema' => $inputSchema,
'output_schema' => $outputSchema,
'description' => $description,
'changelog' => $options['changelog'] ?? null,
'migration_notes' => $options['migration_notes'] ?? null,
'is_latest' => false,
]);
// Mark as latest if requested or if it's the first version
$isFirst = McpToolVersion::forServer($serverId)->forTool($toolName)->count() === 1;
if (($options['mark_latest'] ?? false) || $isFirst) {
$toolVersion->markAsLatest();
}
$this->clearCache($serverId, $toolName);
Log::info('MCP tool version registered', [
'server_id' => $serverId,
'tool_name' => $toolName,
'version' => $version,
'is_latest' => $toolVersion->is_latest,
]);
return $toolVersion;
}
/**
* Get a tool at a specific version.
*
* Returns null if version doesn't exist. Use getLatestVersion() for fallback.
*/
public function getToolAtVersion(string $serverId, string $toolName, string $version): ?McpToolVersion
{
$cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:{$version}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName, $version) {
return McpToolVersion::forServer($serverId)
->forTool($toolName)
->forVersion($version)
->first();
});
}
/**
* Get the latest version of a tool.
*/
public function getLatestVersion(string $serverId, string $toolName): ?McpToolVersion
{
$cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:latest";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName) {
// First try to find explicitly marked latest
$latest = McpToolVersion::forServer($serverId)
->forTool($toolName)
->latest()
->first();
if ($latest) {
return $latest;
}
// Fallback to newest version by semver
return McpToolVersion::forServer($serverId)
->forTool($toolName)
->active()
->orderByVersion('desc')
->first();
});
}
/**
* Resolve a tool version, falling back to latest if not specified.
*
* @return array{version: McpToolVersion|null, warning: array|null, error: array|null}
*/
public function resolveVersion(string $serverId, string $toolName, ?string $requestedVersion = null): array
{
// If no version requested, use latest
if ($requestedVersion === null) {
$version = $this->getLatestVersion($serverId, $toolName);
return [
'version' => $version,
'warning' => null,
'error' => $version === null ? [
'code' => 'TOOL_NOT_FOUND',
'message' => "No versions found for tool {$serverId}:{$toolName}",
] : null,
];
}
// Look up specific version
$version = $this->getToolAtVersion($serverId, $toolName, $requestedVersion);
if (! $version) {
return [
'version' => null,
'warning' => null,
'error' => [
'code' => 'VERSION_NOT_FOUND',
'message' => "Version {$requestedVersion} not found for tool {$serverId}:{$toolName}",
],
];
}
// Check if sunset
if ($version->is_sunset) {
return [
'version' => null,
'warning' => null,
'error' => $version->getSunsetError(),
];
}
// Check if deprecated (warning, not error)
$warning = $version->getDeprecationWarning();
return [
'version' => $version,
'warning' => $warning,
'error' => null,
];
}
/**
* Check if a version is deprecated.
*/
public function isDeprecated(string $serverId, string $toolName, string $version): bool
{
$toolVersion = $this->getToolAtVersion($serverId, $toolName, $version);
return $toolVersion?->is_deprecated ?? false;
}
/**
* Check if a version is sunset (blocked).
*/
public function isSunset(string $serverId, string $toolName, string $version): bool
{
$toolVersion = $this->getToolAtVersion($serverId, $toolName, $version);
return $toolVersion?->is_sunset ?? false;
}
/**
* Compare two semver versions.
*
* @return int -1 if $a < $b, 0 if equal, 1 if $a > $b
*/
public function compareVersions(string $a, string $b): int
{
return version_compare(
$this->normalizeSemver($a),
$this->normalizeSemver($b)
);
}
/**
* Get version history for a tool.
*
* @return Collection<int, McpToolVersion>
*/
public function getVersionHistory(string $serverId, string $toolName): Collection
{
return McpToolVersion::forServer($serverId)
->forTool($toolName)
->orderByVersion('desc')
->get();
}
/**
* Attempt to migrate a tool call from an old version schema to a new one.
*
* This is a best-effort migration that:
* - Preserves arguments that exist in both schemas
* - Applies defaults for new required arguments where possible
* - Returns warnings for arguments that couldn't be migrated
*
* @return array{arguments: array, warnings: array, success: bool}
*/
public function migrateToolCall(
string $serverId,
string $toolName,
string $fromVersion,
string $toVersion,
array $arguments
): array {
$fromTool = $this->getToolAtVersion($serverId, $toolName, $fromVersion);
$toTool = $this->getToolAtVersion($serverId, $toolName, $toVersion);
if (! $fromTool || ! $toTool) {
return [
'arguments' => $arguments,
'warnings' => ['Could not load version schemas for migration'],
'success' => false,
];
}
$toSchema = $toTool->input_schema ?? [];
$toProperties = $toSchema['properties'] ?? [];
$toRequired = $toSchema['required'] ?? [];
$migratedArgs = [];
$warnings = [];
// Copy over arguments that exist in the new schema
foreach ($arguments as $key => $value) {
if (isset($toProperties[$key])) {
$migratedArgs[$key] = $value;
} else {
$warnings[] = "Argument '{$key}' removed in version {$toVersion}";
}
}
// Check for new required arguments without defaults
foreach ($toRequired as $requiredKey) {
if (! isset($migratedArgs[$requiredKey])) {
// Try to apply default from schema
if (isset($toProperties[$requiredKey]['default'])) {
$migratedArgs[$requiredKey] = $toProperties[$requiredKey]['default'];
$warnings[] = "Applied default value for new required argument '{$requiredKey}'";
} else {
$warnings[] = "Missing required argument '{$requiredKey}' added in version {$toVersion}";
}
}
}
return [
'arguments' => $migratedArgs,
'warnings' => $warnings,
'success' => empty(array_filter($warnings, fn ($w) => str_starts_with($w, 'Missing required'))),
];
}
/**
* Deprecate a tool version with optional sunset date.
*/
public function deprecateVersion(
string $serverId,
string $toolName,
string $version,
?Carbon $sunsetAt = null
): ?McpToolVersion {
$toolVersion = McpToolVersion::forServer($serverId)
->forTool($toolName)
->forVersion($version)
->first();
if (! $toolVersion) {
return null;
}
$toolVersion->deprecate($sunsetAt);
$this->clearCache($serverId, $toolName);
Log::info('MCP tool version deprecated', [
'server_id' => $serverId,
'tool_name' => $toolName,
'version' => $version,
'sunset_at' => $sunsetAt?->toIso8601String(),
]);
return $toolVersion;
}
/**
* Get all tools with version info for a server.
*
* @return Collection<string, array{latest: McpToolVersion|null, versions: Collection}>
*/
public function getToolsWithVersions(string $serverId): Collection
{
$versions = McpToolVersion::forServer($serverId)
->orderByVersion('desc')
->get();
return $versions->groupBy('tool_name')
->map(function ($toolVersions, $toolName) {
return [
'tool_name' => $toolName,
'latest' => $toolVersions->firstWhere('is_latest', true) ?? $toolVersions->first(),
'versions' => $toolVersions,
'version_count' => $toolVersions->count(),
'has_deprecated' => $toolVersions->contains(fn ($v) => $v->is_deprecated),
'has_sunset' => $toolVersions->contains(fn ($v) => $v->is_sunset),
];
});
}
/**
* Get all unique servers that have versioned tools.
*/
public function getServersWithVersions(): Collection
{
return McpToolVersion::select('server_id')
->distinct()
->orderBy('server_id')
->pluck('server_id');
}
/**
* Sync tool versions from YAML server definitions.
*
* Call this during deployment to register/update versions from server configs.
*
* @param array $serverConfig Parsed YAML server configuration
* @param string $version Version to register (e.g., from deployment tag)
*/
public function syncFromServerConfig(array $serverConfig, string $version, bool $markLatest = true): int
{
$serverId = $serverConfig['id'] ?? null;
$tools = $serverConfig['tools'] ?? [];
if (! $serverId || empty($tools)) {
return 0;
}
$registered = 0;
foreach ($tools as $tool) {
$toolName = $tool['name'] ?? null;
if (! $toolName) {
continue;
}
$this->registerVersion(
serverId: $serverId,
toolName: $toolName,
version: $version,
inputSchema: $tool['inputSchema'] ?? null,
outputSchema: $tool['outputSchema'] ?? null,
description: $tool['description'] ?? $tool['purpose'] ?? null,
options: [
'mark_latest' => $markLatest,
]
);
$registered++;
}
return $registered;
}
/**
* Get statistics about tool versions.
*/
public function getStats(): array
{
return [
'total_versions' => McpToolVersion::count(),
'total_tools' => McpToolVersion::select('server_id', 'tool_name')
->distinct()
->count(),
'deprecated_count' => McpToolVersion::deprecated()->count(),
'sunset_count' => McpToolVersion::sunset()->count(),
'servers' => $this->getServersWithVersions()->count(),
];
}
// -------------------------------------------------------------------------
// Protected Methods
// -------------------------------------------------------------------------
/**
* Validate semver format.
*/
protected function isValidSemver(string $version): bool
{
// Basic semver pattern: major.minor.patch with optional prerelease/build
$pattern = '/^(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/';
return (bool) preg_match($pattern, $version);
}
/**
* Normalize semver for comparison (removes prerelease/build metadata).
*/
protected function normalizeSemver(string $version): string
{
// Remove prerelease and build metadata for basic comparison
return preg_replace('/[-+].*$/', '', $version) ?? $version;
}
/**
* Clear cache for a tool's versions.
*/
protected function clearCache(string $serverId, string $toolName): void
{
// Clear specific version caches would require tracking all versions
// For simplicity, we use a short TTL and let cache naturally expire
Cache::forget(self::CACHE_PREFIX."{$serverId}:{$toolName}:latest");
}
}

View file

@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tests\Unit;
use Core\Mod\Mcp\Models\McpUsageQuota;
use Core\Mod\Mcp\Services\McpQuotaService;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementResult;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class McpQuotaServiceTest extends TestCase
{
use RefreshDatabase;
protected McpQuotaService $quotaService;
protected EntitlementService $entitlementsMock;
protected Workspace $workspace;
protected function setUp(): void
{
parent::setUp();
$this->entitlementsMock = Mockery::mock(EntitlementService::class);
$this->quotaService = new McpQuotaService($this->entitlementsMock);
$this->workspace = Workspace::factory()->create();
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_records_usage_for_workspace(): void
{
$quota = $this->quotaService->recordUsage($this->workspace, toolCalls: 5, inputTokens: 100, outputTokens: 50);
$this->assertInstanceOf(McpUsageQuota::class, $quota);
$this->assertEquals(5, $quota->tool_calls_count);
$this->assertEquals(100, $quota->input_tokens);
$this->assertEquals(50, $quota->output_tokens);
$this->assertEquals(now()->format('Y-m'), $quota->month);
}
public function test_increments_existing_usage(): void
{
// First call
$this->quotaService->recordUsage($this->workspace, toolCalls: 5, inputTokens: 100, outputTokens: 50);
// Second call
$quota = $this->quotaService->recordUsage($this->workspace, toolCalls: 3, inputTokens: 200, outputTokens: 100);
$this->assertEquals(8, $quota->tool_calls_count);
$this->assertEquals(300, $quota->input_tokens);
$this->assertEquals(150, $quota->output_tokens);
}
public function test_check_quota_returns_true_when_unlimited(): void
{
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)
->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS));
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS)
->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS));
$result = $this->quotaService->checkQuota($this->workspace);
$this->assertTrue($result);
}
public function test_check_quota_returns_false_when_denied(): void
{
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)
->andReturn(EntitlementResult::denied('Not included in plan', featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS));
$result = $this->quotaService->checkQuota($this->workspace);
$this->assertFalse($result);
}
public function test_check_quota_returns_false_when_limit_exceeded(): void
{
// Set up existing usage that exceeds limit
McpUsageQuota::create([
'workspace_id' => $this->workspace->id,
'month' => now()->format('Y-m'),
'tool_calls_count' => 100,
'input_tokens' => 0,
'output_tokens' => 0,
]);
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)
->andReturn(EntitlementResult::allowed(limit: 100, used: 100, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS));
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS)
->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS));
$result = $this->quotaService->checkQuota($this->workspace);
$this->assertFalse($result);
}
public function test_check_quota_returns_true_when_within_limit(): void
{
McpUsageQuota::create([
'workspace_id' => $this->workspace->id,
'month' => now()->format('Y-m'),
'tool_calls_count' => 50,
'input_tokens' => 0,
'output_tokens' => 0,
]);
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)
->andReturn(EntitlementResult::allowed(limit: 100, used: 50, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS));
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS)
->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS));
$result = $this->quotaService->checkQuota($this->workspace);
$this->assertTrue($result);
}
public function test_get_remaining_quota_calculates_correctly(): void
{
McpUsageQuota::create([
'workspace_id' => $this->workspace->id,
'month' => now()->format('Y-m'),
'tool_calls_count' => 30,
'input_tokens' => 500,
'output_tokens' => 500,
]);
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)
->andReturn(EntitlementResult::allowed(limit: 100, used: 30, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS));
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS)
->andReturn(EntitlementResult::allowed(limit: 5000, used: 1000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS));
$remaining = $this->quotaService->getRemainingQuota($this->workspace);
$this->assertEquals(70, $remaining['tool_calls']);
$this->assertEquals(4000, $remaining['tokens']);
$this->assertFalse($remaining['tool_calls_unlimited']);
$this->assertFalse($remaining['tokens_unlimited']);
}
public function test_get_quota_headers_returns_correct_format(): void
{
McpUsageQuota::create([
'workspace_id' => $this->workspace->id,
'month' => now()->format('Y-m'),
'tool_calls_count' => 25,
'input_tokens' => 300,
'output_tokens' => 200,
]);
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)
->andReturn(EntitlementResult::allowed(limit: 100, used: 25, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS));
$this->entitlementsMock
->shouldReceive('can')
->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS)
->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS));
$headers = $this->quotaService->getQuotaHeaders($this->workspace);
$this->assertArrayHasKey('X-MCP-Quota-Tool-Calls-Used', $headers);
$this->assertArrayHasKey('X-MCP-Quota-Tool-Calls-Limit', $headers);
$this->assertArrayHasKey('X-MCP-Quota-Tool-Calls-Remaining', $headers);
$this->assertArrayHasKey('X-MCP-Quota-Tokens-Used', $headers);
$this->assertArrayHasKey('X-MCP-Quota-Tokens-Limit', $headers);
$this->assertArrayHasKey('X-MCP-Quota-Reset', $headers);
$this->assertEquals('25', $headers['X-MCP-Quota-Tool-Calls-Used']);
$this->assertEquals('100', $headers['X-MCP-Quota-Tool-Calls-Limit']);
$this->assertEquals('unlimited', $headers['X-MCP-Quota-Tokens-Limit']);
}
public function test_reset_monthly_quota_clears_usage(): void
{
McpUsageQuota::create([
'workspace_id' => $this->workspace->id,
'month' => now()->format('Y-m'),
'tool_calls_count' => 50,
'input_tokens' => 1000,
'output_tokens' => 500,
]);
$quota = $this->quotaService->resetMonthlyQuota($this->workspace);
$this->assertEquals(0, $quota->tool_calls_count);
$this->assertEquals(0, $quota->input_tokens);
$this->assertEquals(0, $quota->output_tokens);
}
public function test_get_usage_history_returns_ordered_records(): void
{
// Create usage for multiple months
foreach (['2026-01', '2025-12', '2025-11'] as $month) {
McpUsageQuota::create([
'workspace_id' => $this->workspace->id,
'month' => $month,
'tool_calls_count' => rand(10, 100),
'input_tokens' => rand(100, 1000),
'output_tokens' => rand(100, 1000),
]);
}
$history = $this->quotaService->getUsageHistory($this->workspace, 3);
$this->assertCount(3, $history);
// Should be ordered by month descending
$this->assertEquals('2026-01', $history->first()->month);
$this->assertEquals('2025-11', $history->last()->month);
}
}

View file

@ -0,0 +1,480 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tests\Unit;
use Core\Mod\Mcp\Dependencies\DependencyType;
use Core\Mod\Mcp\Dependencies\ToolDependency;
use Core\Mod\Mcp\Exceptions\MissingDependencyException;
use Core\Mod\Mcp\Services\ToolDependencyService;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class ToolDependencyServiceTest extends TestCase
{
protected ToolDependencyService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new ToolDependencyService;
Cache::flush();
}
public function test_can_register_dependencies(): void
{
$deps = [
ToolDependency::toolCalled('plan_create'),
ToolDependency::contextExists('workspace_id'),
];
$this->service->register('custom_tool', $deps);
$registered = $this->service->getDependencies('custom_tool');
$this->assertCount(2, $registered);
$this->assertSame('plan_create', $registered[0]->key);
$this->assertSame(DependencyType::TOOL_CALLED, $registered[0]->type);
}
public function test_returns_empty_for_unregistered_tool(): void
{
$deps = $this->service->getDependencies('nonexistent_tool');
$this->assertEmpty($deps);
}
public function test_check_dependencies_passes_when_no_deps(): void
{
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'tool_without_deps',
context: [],
args: [],
);
$this->assertTrue($result);
}
public function test_check_dependencies_fails_when_tool_not_called(): void
{
$this->service->register('dependent_tool', [
ToolDependency::toolCalled('required_tool'),
]);
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'dependent_tool',
context: [],
args: [],
);
$this->assertFalse($result);
}
public function test_check_dependencies_passes_after_tool_called(): void
{
$this->service->register('dependent_tool', [
ToolDependency::toolCalled('required_tool'),
]);
// Record the required tool call
$this->service->recordToolCall('test-session', 'required_tool');
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'dependent_tool',
context: [],
args: [],
);
$this->assertTrue($result);
}
public function test_check_context_exists_dependency(): void
{
$this->service->register('workspace_tool', [
ToolDependency::contextExists('workspace_id'),
]);
// Without workspace_id
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'workspace_tool',
context: [],
args: [],
);
$this->assertFalse($result);
// With workspace_id
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'workspace_tool',
context: ['workspace_id' => 123],
args: [],
);
$this->assertTrue($result);
}
public function test_check_session_state_dependency(): void
{
$this->service->register('session_tool', [
ToolDependency::sessionState('session_id'),
]);
// Without session_id
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'session_tool',
context: [],
args: [],
);
$this->assertFalse($result);
// With null session_id (should still fail)
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'session_tool',
context: ['session_id' => null],
args: [],
);
$this->assertFalse($result);
// With valid session_id
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'session_tool',
context: ['session_id' => 'ses_123'],
args: [],
);
$this->assertTrue($result);
}
public function test_get_missing_dependencies(): void
{
$this->service->register('multi_dep_tool', [
ToolDependency::toolCalled('tool_a'),
ToolDependency::toolCalled('tool_b'),
ToolDependency::contextExists('workspace_id'),
]);
// Record one tool call
$this->service->recordToolCall('test-session', 'tool_a');
$missing = $this->service->getMissingDependencies(
sessionId: 'test-session',
toolName: 'multi_dep_tool',
context: [],
args: [],
);
$this->assertCount(2, $missing);
$this->assertSame('tool_b', $missing[0]->key);
$this->assertSame('workspace_id', $missing[1]->key);
}
public function test_validate_dependencies_throws_exception(): void
{
$this->service->register('validated_tool', [
ToolDependency::toolCalled('required_tool', 'You must call required_tool first'),
]);
$this->expectException(MissingDependencyException::class);
$this->expectExceptionMessage('Cannot execute \'validated_tool\'');
$this->service->validateDependencies(
sessionId: 'test-session',
toolName: 'validated_tool',
context: [],
args: [],
);
}
public function test_validate_dependencies_passes_when_met(): void
{
$this->service->register('validated_tool', [
ToolDependency::toolCalled('required_tool'),
]);
$this->service->recordToolCall('test-session', 'required_tool');
// Should not throw
$this->service->validateDependencies(
sessionId: 'test-session',
toolName: 'validated_tool',
context: [],
args: [],
);
$this->assertTrue(true); // No exception means pass
}
public function test_optional_dependencies_are_skipped(): void
{
$this->service->register('soft_dep_tool', [
ToolDependency::toolCalled('hard_req'),
ToolDependency::toolCalled('soft_req')->asOptional(),
]);
$this->service->recordToolCall('test-session', 'hard_req');
// Should pass even though soft_req not called
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'soft_dep_tool',
context: [],
args: [],
);
$this->assertTrue($result);
}
public function test_record_and_get_tool_call_history(): void
{
$this->service->recordToolCall('test-session', 'tool_a', ['arg1' => 'value1']);
$this->service->recordToolCall('test-session', 'tool_b');
$this->service->recordToolCall('test-session', 'tool_a', ['arg1' => 'value2']);
$calledTools = $this->service->getCalledTools('test-session');
$this->assertCount(2, $calledTools);
$this->assertContains('tool_a', $calledTools);
$this->assertContains('tool_b', $calledTools);
$history = $this->service->getToolHistory('test-session');
$this->assertCount(3, $history);
$this->assertSame('tool_a', $history[0]['tool']);
$this->assertSame(['arg1' => 'value1'], $history[0]['args']);
}
public function test_clear_session(): void
{
$this->service->recordToolCall('test-session', 'tool_a');
$this->assertNotEmpty($this->service->getCalledTools('test-session'));
$this->service->clearSession('test-session');
$this->assertEmpty($this->service->getCalledTools('test-session'));
}
public function test_get_dependency_graph(): void
{
$this->service->register('tool_a', []);
$this->service->register('tool_b', [
ToolDependency::toolCalled('tool_a'),
]);
$this->service->register('tool_c', [
ToolDependency::toolCalled('tool_b'),
]);
$graph = $this->service->getDependencyGraph();
$this->assertArrayHasKey('tool_a', $graph);
$this->assertArrayHasKey('tool_b', $graph);
$this->assertArrayHasKey('tool_c', $graph);
// tool_b depends on tool_a
$this->assertContains('tool_b', $graph['tool_a']['dependents']);
// tool_c depends on tool_b
$this->assertContains('tool_c', $graph['tool_b']['dependents']);
}
public function test_get_dependent_tools(): void
{
$this->service->register('base_tool', []);
$this->service->register('dep_tool_1', [
ToolDependency::toolCalled('base_tool'),
]);
$this->service->register('dep_tool_2', [
ToolDependency::toolCalled('base_tool'),
]);
$dependents = $this->service->getDependentTools('base_tool');
$this->assertCount(2, $dependents);
$this->assertContains('dep_tool_1', $dependents);
$this->assertContains('dep_tool_2', $dependents);
}
public function test_get_topological_order(): void
{
$this->service->register('tool_a', []);
$this->service->register('tool_b', [
ToolDependency::toolCalled('tool_a'),
]);
$this->service->register('tool_c', [
ToolDependency::toolCalled('tool_b'),
]);
$order = $this->service->getTopologicalOrder();
$indexA = array_search('tool_a', $order);
$indexB = array_search('tool_b', $order);
$indexC = array_search('tool_c', $order);
$this->assertLessThan($indexB, $indexA);
$this->assertLessThan($indexC, $indexB);
}
public function test_custom_validator(): void
{
$this->service->register('custom_validated_tool', [
ToolDependency::custom('has_permission', 'User must have admin permission'),
]);
// Register custom validator that checks for admin role
$this->service->registerCustomValidator('has_permission', function ($context, $args) {
return ($context['role'] ?? null) === 'admin';
});
// Without admin role
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'custom_validated_tool',
context: ['role' => 'user'],
args: [],
);
$this->assertFalse($result);
// With admin role
$result = $this->service->checkDependencies(
sessionId: 'test-session',
toolName: 'custom_validated_tool',
context: ['role' => 'admin'],
args: [],
);
$this->assertTrue($result);
}
public function test_suggested_tool_order(): void
{
$this->service->register('tool_a', []);
$this->service->register('tool_b', [
ToolDependency::toolCalled('tool_a'),
]);
$this->service->register('tool_c', [
ToolDependency::toolCalled('tool_b'),
]);
try {
$this->service->validateDependencies(
sessionId: 'test-session',
toolName: 'tool_c',
context: [],
args: [],
);
$this->fail('Should have thrown MissingDependencyException');
} catch (MissingDependencyException $e) {
$this->assertContains('tool_a', $e->suggestedOrder);
$this->assertContains('tool_b', $e->suggestedOrder);
$this->assertContains('tool_c', $e->suggestedOrder);
// Verify order
$indexA = array_search('tool_a', $e->suggestedOrder);
$indexB = array_search('tool_b', $e->suggestedOrder);
$this->assertLessThan($indexB, $indexA);
}
}
public function test_session_isolation(): void
{
$this->service->register('isolated_tool', [
ToolDependency::toolCalled('prereq'),
]);
// Record in session 1
$this->service->recordToolCall('session-1', 'prereq');
// Session 1 should pass
$result1 = $this->service->checkDependencies(
sessionId: 'session-1',
toolName: 'isolated_tool',
context: [],
args: [],
);
$this->assertTrue($result1);
// Session 2 should fail (different session)
$result2 = $this->service->checkDependencies(
sessionId: 'session-2',
toolName: 'isolated_tool',
context: [],
args: [],
);
$this->assertFalse($result2);
}
public function test_missing_dependency_exception_api_response(): void
{
$missing = [
ToolDependency::toolCalled('tool_a', 'Tool A must be called first'),
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
];
$exception = new MissingDependencyException(
toolName: 'target_tool',
missingDependencies: $missing,
suggestedOrder: ['tool_a', 'target_tool'],
);
$response = $exception->toApiResponse();
$this->assertSame('dependency_not_met', $response['error']);
$this->assertSame('target_tool', $response['tool']);
$this->assertCount(2, $response['missing_dependencies']);
$this->assertSame(['tool_a', 'target_tool'], $response['suggested_order']);
$this->assertArrayHasKey('help', $response);
}
public function test_default_dependencies_registered(): void
{
// The service should have default dependencies registered
$sessionLogDeps = $this->service->getDependencies('session_log');
$this->assertNotEmpty($sessionLogDeps);
$this->assertSame(DependencyType::SESSION_STATE, $sessionLogDeps[0]->type);
$this->assertSame('session_id', $sessionLogDeps[0]->key);
}
public function test_tool_dependency_factory_methods(): void
{
$toolCalled = ToolDependency::toolCalled('some_tool');
$this->assertSame(DependencyType::TOOL_CALLED, $toolCalled->type);
$this->assertSame('some_tool', $toolCalled->key);
$sessionState = ToolDependency::sessionState('session_key');
$this->assertSame(DependencyType::SESSION_STATE, $sessionState->type);
$contextExists = ToolDependency::contextExists('context_key');
$this->assertSame(DependencyType::CONTEXT_EXISTS, $contextExists->type);
$entityExists = ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']);
$this->assertSame(DependencyType::ENTITY_EXISTS, $entityExists->type);
$this->assertSame('plan_slug', $entityExists->metadata['arg_key']);
$custom = ToolDependency::custom('custom_check', 'Custom validation');
$this->assertSame(DependencyType::CUSTOM, $custom->type);
}
public function test_tool_dependency_to_and_from_array(): void
{
$original = ToolDependency::toolCalled('some_tool', 'Must call first')
->asOptional();
$array = $original->toArray();
$this->assertSame('tool_called', $array['type']);
$this->assertSame('some_tool', $array['key']);
$this->assertTrue($array['optional']);
$restored = ToolDependency::fromArray($array);
$this->assertSame($original->type, $restored->type);
$this->assertSame($original->key, $restored->key);
$this->assertSame($original->optional, $restored->optional);
}
}

View file

@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tests\Unit;
use Core\Mod\Mcp\Services\ToolVersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ToolVersionServiceTest extends TestCase
{
use RefreshDatabase;
protected ToolVersionService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new ToolVersionService;
}
public function test_can_register_new_version(): void
{
$version = $this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '1.0.0',
inputSchema: ['type' => 'object', 'properties' => ['query' => ['type' => 'string']]],
description: 'A test tool',
options: ['mark_latest' => true]
);
$this->assertSame('test-server', $version->server_id);
$this->assertSame('test_tool', $version->tool_name);
$this->assertSame('1.0.0', $version->version);
$this->assertTrue($version->is_latest);
}
public function test_first_version_is_automatically_latest(): void
{
$version = $this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '1.0.0',
);
$this->assertTrue($version->is_latest);
}
public function test_can_get_tool_at_specific_version(): void
{
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$this->service->registerVersion('test-server', 'test_tool', '2.0.0');
$v1 = $this->service->getToolAtVersion('test-server', 'test_tool', '1.0.0');
$v2 = $this->service->getToolAtVersion('test-server', 'test_tool', '2.0.0');
$this->assertSame('1.0.0', $v1->version);
$this->assertSame('2.0.0', $v2->version);
}
public function test_get_latest_version(): void
{
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
$latest = $this->service->getLatestVersion('test-server', 'test_tool');
$this->assertSame('2.0.0', $latest->version);
$this->assertTrue($latest->is_latest);
}
public function test_resolve_version_returns_latest_when_no_version_specified(): void
{
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
$result = $this->service->resolveVersion('test-server', 'test_tool', null);
$this->assertNotNull($result['version']);
$this->assertSame('2.0.0', $result['version']->version);
$this->assertNull($result['warning']);
$this->assertNull($result['error']);
}
public function test_resolve_version_returns_specific_version(): void
{
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
$result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0');
$this->assertNotNull($result['version']);
$this->assertSame('1.0.0', $result['version']->version);
}
public function test_resolve_version_returns_error_for_nonexistent_version(): void
{
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$result = $this->service->resolveVersion('test-server', 'test_tool', '9.9.9');
$this->assertNull($result['version']);
$this->assertNotNull($result['error']);
$this->assertSame('VERSION_NOT_FOUND', $result['error']['code']);
}
public function test_resolve_deprecated_version_returns_warning(): void
{
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$version->deprecate(Carbon::now()->addDays(30));
$this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
$result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0');
$this->assertNotNull($result['version']);
$this->assertNotNull($result['warning']);
$this->assertSame('TOOL_VERSION_DEPRECATED', $result['warning']['code']);
}
public function test_resolve_sunset_version_returns_error(): void
{
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$version->deprecated_at = Carbon::now()->subDays(60);
$version->sunset_at = Carbon::now()->subDays(30);
$version->save();
$this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
$result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0');
$this->assertNull($result['version']);
$this->assertNotNull($result['error']);
$this->assertSame('TOOL_VERSION_SUNSET', $result['error']['code']);
}
public function test_is_deprecated(): void
{
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$version->deprecate();
$this->assertTrue($this->service->isDeprecated('test-server', 'test_tool', '1.0.0'));
}
public function test_is_sunset(): void
{
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$version->deprecated_at = Carbon::now()->subDays(60);
$version->sunset_at = Carbon::now()->subDays(30);
$version->save();
$this->assertTrue($this->service->isSunset('test-server', 'test_tool', '1.0.0'));
}
public function test_compare_versions(): void
{
$this->assertSame(-1, $this->service->compareVersions('1.0.0', '2.0.0'));
$this->assertSame(0, $this->service->compareVersions('1.0.0', '1.0.0'));
$this->assertSame(1, $this->service->compareVersions('2.0.0', '1.0.0'));
$this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.0.1'));
$this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.1.0'));
}
public function test_get_version_history(): void
{
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$this->service->registerVersion('test-server', 'test_tool', '1.1.0');
$this->service->registerVersion('test-server', 'test_tool', '2.0.0');
$history = $this->service->getVersionHistory('test-server', 'test_tool');
$this->assertCount(3, $history);
// Should be ordered by version desc
$this->assertSame('2.0.0', $history[0]->version);
$this->assertSame('1.1.0', $history[1]->version);
$this->assertSame('1.0.0', $history[2]->version);
}
public function test_migrate_tool_call(): void
{
$this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '1.0.0',
inputSchema: [
'type' => 'object',
'properties' => ['query' => ['type' => 'string']],
'required' => ['query'],
]
);
$this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '2.0.0',
inputSchema: [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string'],
'limit' => ['type' => 'integer', 'default' => 10],
],
'required' => ['query', 'limit'],
]
);
$result = $this->service->migrateToolCall(
serverId: 'test-server',
toolName: 'test_tool',
fromVersion: '1.0.0',
toVersion: '2.0.0',
arguments: ['query' => 'SELECT * FROM users']
);
$this->assertTrue($result['success']);
$this->assertSame('SELECT * FROM users', $result['arguments']['query']);
$this->assertSame(10, $result['arguments']['limit']); // Default applied
}
public function test_deprecate_version(): void
{
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$sunsetDate = Carbon::now()->addDays(30);
$deprecatedVersion = $this->service->deprecateVersion(
'test-server',
'test_tool',
'1.0.0',
$sunsetDate
);
$this->assertNotNull($deprecatedVersion->deprecated_at);
$this->assertSame($sunsetDate->toDateString(), $deprecatedVersion->sunset_at->toDateString());
}
public function test_get_tools_with_versions(): void
{
$this->service->registerVersion('test-server', 'tool_a', '1.0.0');
$this->service->registerVersion('test-server', 'tool_a', '2.0.0', options: ['mark_latest' => true]);
$this->service->registerVersion('test-server', 'tool_b', '1.0.0');
$tools = $this->service->getToolsWithVersions('test-server');
$this->assertCount(2, $tools);
$this->assertArrayHasKey('tool_a', $tools);
$this->assertArrayHasKey('tool_b', $tools);
$this->assertSame(2, $tools['tool_a']['version_count']);
$this->assertSame(1, $tools['tool_b']['version_count']);
}
public function test_get_servers_with_versions(): void
{
$this->service->registerVersion('server-a', 'tool', '1.0.0');
$this->service->registerVersion('server-b', 'tool', '1.0.0');
$servers = $this->service->getServersWithVersions();
$this->assertCount(2, $servers);
$this->assertContains('server-a', $servers);
$this->assertContains('server-b', $servers);
}
public function test_sync_from_server_config(): void
{
$config = [
'id' => 'test-server',
'tools' => [
[
'name' => 'tool_a',
'description' => 'Tool A',
'inputSchema' => ['type' => 'object'],
],
[
'name' => 'tool_b',
'purpose' => 'Tool B purpose',
],
],
];
$registered = $this->service->syncFromServerConfig($config, '1.0.0');
$this->assertSame(2, $registered);
$toolA = $this->service->getToolAtVersion('test-server', 'tool_a', '1.0.0');
$toolB = $this->service->getToolAtVersion('test-server', 'tool_b', '1.0.0');
$this->assertNotNull($toolA);
$this->assertNotNull($toolB);
$this->assertSame('Tool A', $toolA->description);
$this->assertSame('Tool B purpose', $toolB->description);
}
public function test_get_stats(): void
{
$this->service->registerVersion('server-a', 'tool_a', '1.0.0');
$this->service->registerVersion('server-a', 'tool_a', '2.0.0');
$this->service->registerVersion('server-b', 'tool_b', '1.0.0');
$stats = $this->service->getStats();
$this->assertSame(3, $stats['total_versions']);
$this->assertSame(2, $stats['total_tools']);
$this->assertSame(2, $stats['servers']);
}
public function test_invalid_semver_throws_exception(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid semver version');
$this->service->registerVersion('test-server', 'test_tool', 'invalid');
}
public function test_valid_semver_formats(): void
{
// Basic versions
$v1 = $this->service->registerVersion('test-server', 'tool', '1.0.0');
$this->assertSame('1.0.0', $v1->version);
// Prerelease
$v2 = $this->service->registerVersion('test-server', 'tool', '2.0.0-beta');
$this->assertSame('2.0.0-beta', $v2->version);
// Prerelease with dots
$v3 = $this->service->registerVersion('test-server', 'tool', '2.0.0-alpha.1');
$this->assertSame('2.0.0-alpha.1', $v3->version);
// Build metadata
$v4 = $this->service->registerVersion('test-server', 'tool', '2.0.0+build.123');
$this->assertSame('2.0.0+build.123', $v4->version);
}
public function test_updating_existing_version(): void
{
$original = $this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '1.0.0',
description: 'Original description'
);
$updated = $this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '1.0.0',
description: 'Updated description'
);
$this->assertSame($original->id, $updated->id);
$this->assertSame('Updated description', $updated->description);
}
public function test_model_compare_schema_with(): void
{
$v1 = $this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '1.0.0',
inputSchema: [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string'],
'format' => ['type' => 'string'],
],
]
);
$v2 = $this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '2.0.0',
inputSchema: [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string', 'maxLength' => 1000], // Changed
'limit' => ['type' => 'integer'], // Added
],
]
);
$diff = $v1->compareSchemaWith($v2);
$this->assertContains('limit', $diff['added']);
$this->assertContains('format', $diff['removed']);
$this->assertArrayHasKey('query', $diff['changed']);
}
public function test_model_mark_as_latest(): void
{
$v1 = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0');
$v2->markAsLatest();
$this->assertFalse($v1->fresh()->is_latest);
$this->assertTrue($v2->fresh()->is_latest);
}
public function test_model_status_attribute(): void
{
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
$this->assertSame('latest', $version->status);
$version->is_latest = false;
$version->save();
$this->assertSame('active', $version->fresh()->status);
$version->deprecated_at = Carbon::now()->subDay();
$version->save();
$this->assertSame('deprecated', $version->fresh()->status);
$version->sunset_at = Carbon::now()->subDay();
$version->save();
$this->assertSame('sunset', $version->fresh()->status);
}
public function test_model_to_api_array(): void
{
$version = $this->service->registerVersion(
serverId: 'test-server',
toolName: 'test_tool',
version: '1.0.0',
inputSchema: ['type' => 'object'],
description: 'Test tool',
options: ['changelog' => 'Initial release']
);
$array = $version->toApiArray();
$this->assertSame('test-server', $array['server_id']);
$this->assertSame('test_tool', $array['tool_name']);
$this->assertSame('1.0.0', $array['version']);
$this->assertTrue($array['is_latest']);
$this->assertSame('latest', $array['status']);
$this->assertSame('Test tool', $array['description']);
$this->assertSame('Initial release', $array['changelog']);
}
}

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
/**
* Unit: ValidateWorkspaceContext Middleware
*
* Tests for the MCP workspace context validation middleware.
*/
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Http\Request;
use Mod\Mcp\Context\WorkspaceContext;
use Mod\Mcp\Middleware\ValidateWorkspaceContext;
describe('ValidateWorkspaceContext Middleware', function () {
beforeEach(function () {
$this->middleware = new ValidateWorkspaceContext;
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
});
it('sets workspace context when mcp_workspace attribute exists', function () {
$request = Request::create('/api/mcp/tools/call', 'POST');
$request->attributes->set('mcp_workspace', $this->workspace);
$contextSet = null;
$response = $this->middleware->handle($request, function ($request) use (&$contextSet) {
$contextSet = $request->attributes->get('mcp_workspace_context');
return response()->json(['success' => true]);
});
expect($contextSet)->toBeInstanceOf(WorkspaceContext::class);
expect($contextSet->workspaceId)->toBe($this->workspace->id);
expect($response->getStatusCode())->toBe(200);
});
it('rejects requests without workspace when mode is required', function () {
$request = Request::create('/api/mcp/tools/call', 'POST');
$request->headers->set('Accept', 'application/json');
$response = $this->middleware->handle($request, function () {
return response()->json(['success' => true]);
}, 'required');
expect($response->getStatusCode())->toBe(403);
$data = json_decode($response->getContent(), true);
expect($data['error'])->toBe('missing_workspace_context');
});
it('allows requests without workspace when mode is optional', function () {
$request = Request::create('/api/mcp/tools/call', 'POST');
$response = $this->middleware->handle($request, function ($request) {
$context = $request->attributes->get('mcp_workspace_context');
return response()->json(['has_context' => $context !== null]);
}, 'optional');
expect($response->getStatusCode())->toBe(200);
$data = json_decode($response->getContent(), true);
expect($data['has_context'])->toBeFalse();
});
it('extracts workspace from authenticated user', function () {
$request = Request::create('/api/mcp/tools/call', 'POST');
$request->setUserResolver(fn () => $this->user);
$contextSet = null;
$response = $this->middleware->handle($request, function ($request) use (&$contextSet) {
$contextSet = $request->attributes->get('mcp_workspace_context');
return response()->json(['success' => true]);
});
expect($contextSet)->toBeInstanceOf(WorkspaceContext::class);
expect($contextSet->workspaceId)->toBe($this->workspace->id);
});
it('defaults to required mode', function () {
$request = Request::create('/api/mcp/tools/call', 'POST');
$request->headers->set('Accept', 'application/json');
$response = $this->middleware->handle($request, function () {
return response()->json(['success' => true]);
});
expect($response->getStatusCode())->toBe(403);
});
it('returns HTML response for non-API requests', function () {
$request = Request::create('/mcp/tools', 'GET');
// Not setting Accept: application/json
$response = $this->middleware->handle($request, function () {
return response()->json(['success' => true]);
}, 'required');
expect($response->getStatusCode())->toBe(403);
expect($response->headers->get('Content-Type'))->not->toContain('application/json');
});
});

View file

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
/**
* Unit: Workspace Context Security
*
* Tests for MCP workspace context security to prevent cross-tenant data leakage.
*/
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Mod\Mcp\Context\WorkspaceContext;
use Mod\Mcp\Exceptions\MissingWorkspaceContextException;
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
// Test class using the trait
class TestToolWithWorkspaceContext
{
use RequiresWorkspaceContext;
protected string $name = 'test_tool';
}
describe('MissingWorkspaceContextException', function () {
it('creates exception with tool name', function () {
$exception = new MissingWorkspaceContextException('ListInvoices');
expect($exception->tool)->toBe('ListInvoices');
expect($exception->getMessage())->toContain('ListInvoices');
expect($exception->getMessage())->toContain('workspace context');
});
it('creates exception with custom message', function () {
$exception = new MissingWorkspaceContextException('TestTool', 'Custom error message');
expect($exception->getMessage())->toBe('Custom error message');
expect($exception->tool)->toBe('TestTool');
});
it('returns correct status code', function () {
$exception = new MissingWorkspaceContextException('TestTool');
expect($exception->getStatusCode())->toBe(403);
});
it('returns correct error type', function () {
$exception = new MissingWorkspaceContextException('TestTool');
expect($exception->getErrorType())->toBe('missing_workspace_context');
});
});
describe('WorkspaceContext', function () {
beforeEach(function () {
$this->workspace = Workspace::factory()->create([
'name' => 'Test Workspace',
'slug' => 'test-workspace',
]);
});
it('creates context from workspace model', function () {
$context = WorkspaceContext::fromWorkspace($this->workspace);
expect($context->workspaceId)->toBe($this->workspace->id);
expect($context->workspace)->toBe($this->workspace);
});
it('creates context from workspace ID', function () {
$context = WorkspaceContext::fromId($this->workspace->id);
expect($context->workspaceId)->toBe($this->workspace->id);
expect($context->workspace)->toBeNull();
});
it('loads workspace when accessing from ID-only context', function () {
$context = WorkspaceContext::fromId($this->workspace->id);
$loadedWorkspace = $context->getWorkspace();
expect($loadedWorkspace->id)->toBe($this->workspace->id);
expect($loadedWorkspace->name)->toBe('Test Workspace');
});
it('validates ownership correctly', function () {
$context = WorkspaceContext::fromWorkspace($this->workspace);
// Should not throw for matching workspace
$context->validateOwnership($this->workspace->id, 'invoice');
expect(true)->toBeTrue(); // If we get here, no exception was thrown
});
it('throws on ownership validation failure', function () {
$context = WorkspaceContext::fromWorkspace($this->workspace);
$differentWorkspaceId = $this->workspace->id + 999;
expect(fn () => $context->validateOwnership($differentWorkspaceId, 'invoice'))
->toThrow(\RuntimeException::class, 'invoice does not belong to the authenticated workspace');
});
it('checks workspace ID correctly', function () {
$context = WorkspaceContext::fromWorkspace($this->workspace);
expect($context->hasWorkspaceId($this->workspace->id))->toBeTrue();
expect($context->hasWorkspaceId($this->workspace->id + 1))->toBeFalse();
});
});
describe('RequiresWorkspaceContext trait', function () {
beforeEach(function () {
$this->workspace = Workspace::factory()->create();
$this->tool = new TestToolWithWorkspaceContext;
});
it('throws MissingWorkspaceContextException when no context set', function () {
expect(fn () => $this->tool->getWorkspaceId())
->toThrow(MissingWorkspaceContextException::class);
});
it('returns workspace ID when context is set', function () {
$this->tool->setWorkspace($this->workspace);
expect($this->tool->getWorkspaceId())->toBe($this->workspace->id);
});
it('returns workspace when context is set', function () {
$this->tool->setWorkspace($this->workspace);
$workspace = $this->tool->getWorkspace();
expect($workspace->id)->toBe($this->workspace->id);
});
it('allows setting context from workspace ID', function () {
$this->tool->setWorkspaceId($this->workspace->id);
expect($this->tool->getWorkspaceId())->toBe($this->workspace->id);
});
it('allows setting context object directly', function () {
$context = WorkspaceContext::fromWorkspace($this->workspace);
$this->tool->setWorkspaceContext($context);
expect($this->tool->getWorkspaceId())->toBe($this->workspace->id);
});
it('correctly reports whether context is available', function () {
expect($this->tool->hasWorkspaceContext())->toBeFalse();
$this->tool->setWorkspace($this->workspace);
expect($this->tool->hasWorkspaceContext())->toBeTrue();
});
it('validates resource ownership through context', function () {
$this->tool->setWorkspace($this->workspace);
$differentWorkspaceId = $this->workspace->id + 999;
expect(fn () => $this->tool->validateResourceOwnership($differentWorkspaceId, 'subscription'))
->toThrow(\RuntimeException::class, 'subscription does not belong');
});
it('requires context with custom error message', function () {
expect(fn () => $this->tool->requireWorkspaceContext('listing invoices'))
->toThrow(MissingWorkspaceContextException::class, 'listing invoices');
});
});
describe('Workspace-scoped tool security', function () {
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Create another workspace to test isolation
$this->otherWorkspace = Workspace::factory()->create();
});
it('prevents accessing another workspace data by setting context correctly', function () {
$context = WorkspaceContext::fromWorkspace($this->workspace);
// Trying to validate ownership of data from another workspace should fail
expect(fn () => $context->validateOwnership($this->otherWorkspace->id, 'data'))
->toThrow(\RuntimeException::class);
});
});

View file

@ -0,0 +1,71 @@
<?php
/**
* UseCase: MCP API Key Manager (Basic Flow)
*
* Acceptance test for the MCP admin panel.
* Tests the primary admin flow through the API key manager.
*/
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
describe('MCP API Key Manager', function () {
beforeEach(function () {
// Create user with workspace
$this->user = User::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
});
it('can view the API key manager page with all sections', function () {
// Login and navigate to MCP keys page
$this->actingAs($this->user);
$response = $this->get(route('mcp.keys'));
$response->assertOk();
// Verify page title and description
$response->assertSee(__('mcp::mcp.keys.title'));
$response->assertSee(__('mcp::mcp.keys.description'));
// Verify empty state when no keys exist
$response->assertSee(__('mcp::mcp.keys.empty.title'));
$response->assertSee(__('mcp::mcp.keys.empty.description'));
// Verify action buttons
$response->assertSee(__('mcp::mcp.keys.actions.create'));
});
it('can view the playground page', function () {
$this->actingAs($this->user);
$response = $this->get(route('mcp.playground'));
$response->assertOk();
// Verify page title and description
$response->assertSee(__('mcp::mcp.playground.title'));
$response->assertSee(__('mcp::mcp.playground.description'));
});
it('can view the request log page', function () {
$this->actingAs($this->user);
$response = $this->get(route('mcp.logs'));
$response->assertOk();
// Verify page title and description
$response->assertSee(__('mcp::mcp.logs.title'));
$response->assertSee(__('mcp::mcp.logs.description'));
});
});

View file

@ -0,0 +1,100 @@
<?php
namespace Core\Mod\Mcp\Tools\Commerce;
use Core\Mod\Commerce\Models\Coupon;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class CreateCoupon extends Tool
{
protected string $description = 'Create a new discount coupon code';
public function handle(Request $request): Response
{
$code = strtoupper($request->input('code'));
$name = $request->input('name');
$type = $request->input('type', 'percentage');
$value = $request->input('value');
$duration = $request->input('duration', 'once');
$maxUses = $request->input('max_uses');
$validUntil = $request->input('valid_until');
// Validate code format
if (! preg_match('/^[A-Z0-9_-]+$/', $code)) {
return Response::text(json_encode([
'error' => 'Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores.',
]));
}
// Check for existing code
if (Coupon::where('code', $code)->exists()) {
return Response::text(json_encode([
'error' => 'A coupon with this code already exists.',
]));
}
// Validate type
if (! in_array($type, ['percentage', 'fixed_amount'])) {
return Response::text(json_encode([
'error' => 'Invalid type. Use percentage or fixed_amount.',
]));
}
// Validate value
if ($type === 'percentage' && ($value < 1 || $value > 100)) {
return Response::text(json_encode([
'error' => 'Percentage value must be between 1 and 100.',
]));
}
try {
$coupon = Coupon::create([
'code' => $code,
'name' => $name,
'type' => $type,
'value' => $value,
'duration' => $duration,
'max_uses' => $maxUses,
'max_uses_per_workspace' => 1,
'valid_until' => $validUntil ? \Carbon\Carbon::parse($validUntil) : null,
'is_active' => true,
'applies_to' => 'all',
]);
return Response::text(json_encode([
'success' => true,
'coupon' => [
'id' => $coupon->id,
'code' => $coupon->code,
'name' => $coupon->name,
'type' => $coupon->type,
'value' => (float) $coupon->value,
'duration' => $coupon->duration,
'max_uses' => $coupon->max_uses,
'valid_until' => $coupon->valid_until?->toDateString(),
'is_active' => $coupon->is_active,
],
], JSON_PRETTY_PRINT));
} catch (\Exception $e) {
return Response::text(json_encode([
'error' => 'Failed to create coupon: '.$e->getMessage(),
]));
}
}
public function schema(JsonSchema $schema): array
{
return [
'code' => $schema->string('Unique coupon code (uppercase letters, numbers, hyphens, underscores)')->required(),
'name' => $schema->string('Display name for the coupon')->required(),
'type' => $schema->string('Discount type: percentage or fixed_amount (default: percentage)'),
'value' => $schema->number('Discount value (percentage 1-100 or fixed amount)')->required(),
'duration' => $schema->string('How long discount applies: once, repeating, or forever (default: once)'),
'max_uses' => $schema->integer('Maximum total uses (null for unlimited)'),
'valid_until' => $schema->string('Expiry date in YYYY-MM-DD format'),
];
}
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tools\Commerce;
use Core\Mod\Commerce\Models\Subscription;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
/**
* Get billing status for the authenticated workspace.
*
* SECURITY: This tool uses authenticated workspace context, not user-supplied
* workspace_id parameters, to prevent cross-tenant data access.
*/
class GetBillingStatus extends Tool
{
use RequiresWorkspaceContext;
protected string $description = 'Get billing status for your workspace including subscription, current plan, and billing period';
public function handle(Request $request): Response
{
// Get workspace from authenticated context (not from request parameters)
$workspace = $this->getWorkspace();
$workspaceId = $workspace->id;
// Get active subscription
$subscription = Subscription::with('workspacePackage.package')
->where('workspace_id', $workspaceId)
->whereIn('status', ['active', 'trialing', 'past_due'])
->first();
// Get workspace packages
$packages = $workspace->workspacePackages()
->with('package')
->whereIn('status', ['active', 'trial'])
->get();
$status = [
'workspace' => [
'id' => $workspace->id,
'name' => $workspace->name,
],
'subscription' => $subscription ? [
'id' => $subscription->id,
'status' => $subscription->status,
'gateway' => $subscription->gateway,
'billing_cycle' => $subscription->billing_cycle,
'current_period_start' => $subscription->current_period_start?->toIso8601String(),
'current_period_end' => $subscription->current_period_end?->toIso8601String(),
'days_until_renewal' => $subscription->daysUntilRenewal(),
'cancel_at_period_end' => $subscription->cancel_at_period_end,
'on_trial' => $subscription->onTrial(),
'trial_ends_at' => $subscription->trial_ends_at?->toIso8601String(),
] : null,
'packages' => $packages->map(fn ($wp) => [
'code' => $wp->package?->code,
'name' => $wp->package?->name,
'status' => $wp->status,
'expires_at' => $wp->expires_at?->toIso8601String(),
])->values()->all(),
];
return Response::text(json_encode($status, JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
// No parameters needed - workspace comes from authentication context
return [];
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tools\Commerce;
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: This tool uses authenticated workspace context, not user-supplied
* workspace_id parameters, to prevent cross-tenant data access.
*/
class ListInvoices 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 authenticated context (not from request parameters)
$workspaceId = $this->getWorkspaceId();
$status = $request->input('status'); // paid, pending, overdue, void
$limit = min($request->input('limit', 10), 50);
$query = Invoice::with('order')
->where('workspace_id', $workspaceId)
->latest();
if ($status) {
$query->where('status', $status);
}
$invoices = $query->limit($limit)->get();
$result = [
'workspace_id' => $workspaceId,
'count' => $invoices->count(),
'invoices' => $invoices->map(fn ($invoice) => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'status' => $invoice->status,
'subtotal' => (float) $invoice->subtotal,
'discount_amount' => (float) $invoice->discount_amount,
'tax_amount' => (float) $invoice->tax_amount,
'total' => (float) $invoice->total,
'amount_paid' => (float) $invoice->amount_paid,
'amount_due' => (float) $invoice->amount_due,
'currency' => $invoice->currency,
'issue_date' => $invoice->issue_date?->toDateString(),
'due_date' => $invoice->due_date?->toDateString(),
'paid_at' => $invoice->paid_at?->toIso8601String(),
'is_overdue' => $invoice->isOverdue(),
'order_number' => $invoice->order?->order_number,
])->all(),
];
return Response::text(json_encode($result, JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'status' => $schema->string('Filter by status: paid, pending, overdue, void'),
'limit' => $schema->integer('Maximum number of invoices to return (default 10, max 50)'),
];
}
}

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tools\Commerce;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Services\SubscriptionService;
use Core\Mod\Tenant\Models\Package;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
/**
* Preview or execute a plan upgrade/downgrade for the authenticated workspace.
*
* SECURITY: This tool uses authenticated workspace context, not user-supplied
* workspace_id parameters, to prevent cross-tenant data access.
*/
class UpgradePlan extends Tool
{
use RequiresWorkspaceContext;
protected string $description = 'Preview or execute a plan upgrade/downgrade for your workspace subscription';
public function handle(Request $request): Response
{
// Get workspace from authenticated context (not from request parameters)
$workspace = $this->getWorkspace();
$workspaceId = $workspace->id;
$newPackageCode = $request->input('package_code');
$preview = $request->input('preview', true);
$immediate = $request->input('immediate', true);
$newPackage = Package::where('code', $newPackageCode)->first();
if (! $newPackage) {
return Response::text(json_encode([
'error' => 'Package not found',
'available_packages' => Package::where('is_active', true)
->where('is_public', true)
->pluck('code')
->all(),
]));
}
// Get active subscription
$subscription = Subscription::with('workspacePackage.package')
->where('workspace_id', $workspaceId)
->whereIn('status', ['active', 'trialing'])
->first();
if (! $subscription) {
return Response::text(json_encode([
'error' => 'No active subscription found for this workspace',
]));
}
$subscriptionService = app(SubscriptionService::class);
try {
if ($preview) {
// Preview the proration
$proration = $subscriptionService->previewPlanChange($subscription, $newPackage);
return Response::text(json_encode([
'preview' => true,
'current_package' => $subscription->workspacePackage?->package?->code,
'new_package' => $newPackage->code,
'proration' => [
'is_upgrade' => $proration->isUpgrade(),
'is_downgrade' => $proration->isDowngrade(),
'current_plan_price' => $proration->currentPlanPrice,
'new_plan_price' => $proration->newPlanPrice,
'credit_amount' => $proration->creditAmount,
'prorated_new_cost' => $proration->proratedNewPlanCost,
'net_amount' => $proration->netAmount,
'requires_payment' => $proration->requiresPayment(),
'days_remaining' => $proration->daysRemaining,
'currency' => $proration->currency,
],
], JSON_PRETTY_PRINT));
}
// Execute the plan change
$result = $subscriptionService->changePlan(
$subscription,
$newPackage,
prorate: true,
immediate: $immediate
);
return Response::text(json_encode([
'success' => true,
'immediate' => $result['immediate'],
'current_package' => $subscription->workspacePackage?->package?->code,
'new_package' => $newPackage->code,
'proration' => $result['proration']?->toArray(),
'subscription_status' => $result['subscription']->status,
], JSON_PRETTY_PRINT));
} catch (\Exception $e) {
return Response::text(json_encode([
'error' => $e->getMessage(),
]));
}
}
public function schema(JsonSchema $schema): array
{
return [
'package_code' => $schema->string('The code of the new package (e.g., agency, enterprise)')->required(),
'preview' => $schema->boolean('If true, only preview the change without executing (default: true)'),
'immediate' => $schema->boolean('If true, apply change immediately; if false, schedule for period end (default: true)'),
];
}
}

View file

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Mod\Mcp\Tools\Concerns;
use Core\Mod\Tenant\Models\Workspace;
use Mod\Mcp\Context\WorkspaceContext;
use Mod\Mcp\Exceptions\MissingWorkspaceContextException;
/**
* Trait for MCP tools that require workspace context.
*
* This trait provides methods for validating and retrieving workspace context
* from the MCP request. Tools using this trait will throw
* MissingWorkspaceContextException if called without proper context.
*
* SECURITY: Workspace context must come from authentication (API key or session),
* never from user-supplied request parameters.
*/
trait RequiresWorkspaceContext
{
/**
* The current workspace context.
*/
protected ?WorkspaceContext $workspaceContext = null;
/**
* Get the tool name for error messages.
*/
protected function getToolName(): string
{
return property_exists($this, 'name') && $this->name
? $this->name
: class_basename(static::class);
}
/**
* Get the workspace context, throwing if not available.
*
* @throws MissingWorkspaceContextException
*/
protected function getWorkspaceContext(): WorkspaceContext
{
if ($this->workspaceContext) {
return $this->workspaceContext;
}
throw new MissingWorkspaceContextException($this->getToolName());
}
/**
* Get the workspace ID from context.
*
* @throws MissingWorkspaceContextException
*/
protected function getWorkspaceId(): int
{
return $this->getWorkspaceContext()->workspaceId;
}
/**
* Get the workspace model from context.
*
* @throws MissingWorkspaceContextException
*/
protected function getWorkspace(): Workspace
{
return $this->getWorkspaceContext()->getWorkspace();
}
/**
* Set the workspace context for this tool execution.
*/
public function setWorkspaceContext(WorkspaceContext $context): void
{
$this->workspaceContext = $context;
}
/**
* Set workspace context from a workspace model.
*/
public function setWorkspace(Workspace $workspace): void
{
$this->workspaceContext = WorkspaceContext::fromWorkspace($workspace);
}
/**
* Set workspace context from a workspace ID.
*/
public function setWorkspaceId(int $workspaceId): void
{
$this->workspaceContext = WorkspaceContext::fromId($workspaceId);
}
/**
* Check if workspace context is available.
*/
protected function hasWorkspaceContext(): bool
{
return $this->workspaceContext !== null;
}
/**
* Validate that a resource belongs to the current workspace.
*
* @throws \RuntimeException If the resource doesn't belong to this workspace
* @throws MissingWorkspaceContextException If no workspace context
*/
protected function validateResourceOwnership(int $resourceWorkspaceId, string $resourceType = 'resource'): void
{
$this->getWorkspaceContext()->validateOwnership($resourceWorkspaceId, $resourceType);
}
/**
* Require workspace context, throwing with a custom message if not available.
*
* @throws MissingWorkspaceContextException
*/
protected function requireWorkspaceContext(string $operation = 'this operation'): WorkspaceContext
{
if (! $this->workspaceContext) {
throw new MissingWorkspaceContextException(
$this->getToolName(),
sprintf(
"Workspace context is required for %s in tool '%s'. Authenticate with an API key or user session.",
$operation,
$this->getToolName()
)
);
}
return $this->workspaceContext;
}
}

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tools\Concerns;
use Core\Mod\Mcp\Dependencies\ToolDependency;
use Core\Mod\Mcp\Exceptions\MissingDependencyException;
use Core\Mod\Mcp\Services\ToolDependencyService;
/**
* Trait for tools that validate dependencies before execution.
*
* Provides methods to declare and check dependencies inline.
*/
trait ValidatesDependencies
{
/**
* Get the dependencies for this tool.
*
* Override this method in your tool to declare dependencies.
*
* @return array<ToolDependency>
*/
public function dependencies(): array
{
return [];
}
/**
* Validate that all dependencies are met.
*
* @param array $context The execution context
* @param array $args The tool arguments
*
* @throws MissingDependencyException If dependencies are not met
*/
protected function validateDependencies(array $context = [], array $args = []): void
{
$sessionId = $context['session_id'] ?? 'anonymous';
app(ToolDependencyService::class)->validateDependencies(
sessionId: $sessionId,
toolName: $this->name(),
context: $context,
args: $args,
);
}
/**
* Check if all dependencies are met without throwing.
*
* @param array $context The execution context
* @param array $args The tool arguments
*/
protected function dependenciesMet(array $context = [], array $args = []): bool
{
$sessionId = $context['session_id'] ?? 'anonymous';
return app(ToolDependencyService::class)->checkDependencies(
sessionId: $sessionId,
toolName: $this->name(),
context: $context,
args: $args,
);
}
/**
* Get list of unmet dependencies.
*
* @param array $context The execution context
* @param array $args The tool arguments
* @return array<ToolDependency>
*/
protected function getMissingDependencies(array $context = [], array $args = []): array
{
$sessionId = $context['session_id'] ?? 'anonymous';
return app(ToolDependencyService::class)->getMissingDependencies(
sessionId: $sessionId,
toolName: $this->name(),
context: $context,
args: $args,
);
}
/**
* Record this tool call for dependency tracking.
*
* @param array $context The execution context
* @param array $args The tool arguments
*/
protected function recordToolCall(array $context = [], array $args = []): void
{
$sessionId = $context['session_id'] ?? 'anonymous';
app(ToolDependencyService::class)->recordToolCall(
sessionId: $sessionId,
toolName: $this->name(),
args: $args,
);
}
/**
* Create a dependency error response.
*/
protected function dependencyError(MissingDependencyException $e): array
{
return [
'error' => 'dependency_not_met',
'message' => $e->getMessage(),
'missing' => array_map(
fn (ToolDependency $dep) => [
'type' => $dep->type->value,
'key' => $dep->key,
'description' => $dep->description,
],
$e->missingDependencies
),
'suggested_order' => $e->suggestedOrder,
];
}
}

View file

@ -0,0 +1,633 @@
<?php
namespace Core\Mod\Mcp\Tools;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentRevision;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
/**
* MCP tools for managing content items.
*
* Part of TASK-004 Phase 3: MCP Integration.
*
* Provides functionality for listing, reading, creating, updating,
* and deleting content items for MCP agents.
*/
class ContentTools extends Tool
{
protected string $description = 'Manage content items - list, read, create, update, and delete blog posts and pages';
public function handle(Request $request): Response
{
$action = $request->get('action');
$workspaceSlug = $request->get('workspace');
// Resolve workspace
$workspace = $this->resolveWorkspace($workspaceSlug);
if (! $workspace && in_array($action, ['list', 'read', 'create', 'update', 'delete'])) {
return Response::text(json_encode([
'error' => 'Workspace is required. Provide a workspace slug.',
]));
}
return match ($action) {
'list' => $this->listContent($workspace, $request),
'read' => $this->readContent($workspace, $request),
'create' => $this->createContent($workspace, $request),
'update' => $this->updateContent($workspace, $request),
'delete' => $this->deleteContent($workspace, $request),
'taxonomies' => $this->listTaxonomies($workspace, $request),
default => Response::text(json_encode([
'error' => 'Invalid action. Available: list, read, create, update, delete, taxonomies',
])),
};
}
/**
* Resolve workspace from slug.
*/
protected function resolveWorkspace(?string $slug): ?Workspace
{
if (! $slug) {
return null;
}
return Workspace::where('slug', $slug)
->orWhere('id', $slug)
->first();
}
/**
* Check entitlements for content operations.
*/
protected function checkEntitlement(Workspace $workspace, string $action): ?array
{
$entitlements = app(EntitlementService::class);
// Check if workspace has content MCP access
$result = $entitlements->can($workspace, 'content.mcp_access');
if ($result->isDenied()) {
return ['error' => $result->reason ?? 'Content MCP access not available in your plan.'];
}
// For create operations, check content limits
if ($action === 'create') {
$limitResult = $entitlements->can($workspace, 'content.items');
if ($limitResult->isDenied()) {
return ['error' => $limitResult->reason ?? 'Content item limit reached.'];
}
}
return null;
}
/**
* List content items for a workspace.
*/
protected function listContent(Workspace $workspace, Request $request): Response
{
$query = ContentItem::forWorkspace($workspace->id)
->native()
->with(['author', 'taxonomies']);
// Filter by type (post/page)
if ($type = $request->get('type')) {
$query->where('type', $type);
}
// Filter by status
if ($status = $request->get('status')) {
if ($status === 'published') {
$query->published();
} elseif ($status === 'scheduled') {
$query->scheduled();
} else {
$query->where('status', $status);
}
}
// Search
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('content_html', 'like', "%{$search}%")
->orWhere('excerpt', 'like', "%{$search}%");
});
}
// Pagination
$limit = min($request->get('limit', 20), 100);
$offset = $request->get('offset', 0);
$total = $query->count();
$items = $query->orderByDesc('updated_at')
->skip($offset)
->take($limit)
->get();
$result = [
'items' => $items->map(fn (ContentItem $item) => [
'id' => $item->id,
'slug' => $item->slug,
'title' => $item->title,
'type' => $item->type,
'status' => $item->status,
'excerpt' => Str::limit($item->excerpt, 200),
'author' => $item->author?->name,
'categories' => $item->categories->pluck('name')->all(),
'tags' => $item->tags->pluck('name')->all(),
'word_count' => str_word_count(strip_tags($item->content_html ?? '')),
'publish_at' => $item->publish_at?->toIso8601String(),
'created_at' => $item->created_at->toIso8601String(),
'updated_at' => $item->updated_at->toIso8601String(),
]),
'total' => $total,
'limit' => $limit,
'offset' => $offset,
];
return Response::text(json_encode($result, JSON_PRETTY_PRINT));
}
/**
* Read full content of an item.
*/
protected function readContent(Workspace $workspace, Request $request): Response
{
$identifier = $request->get('identifier');
if (! $identifier) {
return Response::text(json_encode(['error' => 'identifier (slug or ID) is required']));
}
$query = ContentItem::forWorkspace($workspace->id)->native();
// Find by ID, slug, or wp_id
if (is_numeric($identifier)) {
$item = $query->where('id', $identifier)
->orWhere('wp_id', $identifier)
->first();
} else {
$item = $query->where('slug', $identifier)->first();
}
if (! $item) {
return Response::text(json_encode(['error' => 'Content not found']));
}
// Load relationships
$item->load(['author', 'taxonomies', 'revisions' => fn ($q) => $q->latest()->limit(5)]);
// Return as markdown with frontmatter for AI context
$format = $request->get('format', 'json');
if ($format === 'markdown') {
$markdown = $this->contentToMarkdown($item);
return Response::text($markdown);
}
$result = [
'id' => $item->id,
'slug' => $item->slug,
'title' => $item->title,
'type' => $item->type,
'status' => $item->status,
'excerpt' => $item->excerpt,
'content_html' => $item->content_html,
'content_markdown' => $item->content_markdown,
'author' => [
'id' => $item->author?->id,
'name' => $item->author?->name,
],
'categories' => $item->categories->map(fn ($t) => [
'id' => $t->id,
'slug' => $t->slug,
'name' => $t->name,
])->all(),
'tags' => $item->tags->map(fn ($t) => [
'id' => $t->id,
'slug' => $t->slug,
'name' => $t->name,
])->all(),
'seo_meta' => $item->seo_meta,
'publish_at' => $item->publish_at?->toIso8601String(),
'revision_count' => $item->revision_count,
'recent_revisions' => $item->revisions->map(fn ($r) => [
'id' => $r->id,
'revision_number' => $r->revision_number,
'change_type' => $r->change_type,
'created_at' => $r->created_at->toIso8601String(),
])->all(),
'created_at' => $item->created_at->toIso8601String(),
'updated_at' => $item->updated_at->toIso8601String(),
];
return Response::text(json_encode($result, JSON_PRETTY_PRINT));
}
/**
* Create new content.
*/
protected function createContent(Workspace $workspace, Request $request): Response
{
// Check entitlements
$entitlementError = $this->checkEntitlement($workspace, 'create');
if ($entitlementError) {
return Response::text(json_encode($entitlementError));
}
// Validate required fields
$title = $request->get('title');
if (! $title) {
return Response::text(json_encode(['error' => 'title is required']));
}
$type = $request->get('type', 'post');
if (! in_array($type, ['post', 'page'])) {
return Response::text(json_encode(['error' => 'type must be post or page']));
}
$status = $request->get('status', 'draft');
if (! in_array($status, ['draft', 'publish', 'future', 'private'])) {
return Response::text(json_encode(['error' => 'status must be draft, publish, future, or private']));
}
// Generate slug
$slug = $request->get('slug') ?: Str::slug($title);
$baseSlug = $slug;
$counter = 1;
// Ensure unique slug within workspace
while (ContentItem::forWorkspace($workspace->id)->where('slug', $slug)->exists()) {
$slug = $baseSlug.'-'.$counter++;
}
// Parse markdown content if provided
$content = $request->get('content', '');
$contentHtml = $request->get('content_html');
$contentMarkdown = $request->get('content_markdown', $content);
// Convert markdown to HTML if only markdown provided
if ($contentMarkdown && ! $contentHtml) {
$contentHtml = Str::markdown($contentMarkdown);
}
// Handle scheduling
$publishAt = null;
if ($status === 'future') {
$publishAt = $request->get('publish_at');
if (! $publishAt) {
return Response::text(json_encode(['error' => 'publish_at is required for scheduled content']));
}
$publishAt = \Carbon\Carbon::parse($publishAt);
}
// Create content item
$item = ContentItem::create([
'workspace_id' => $workspace->id,
'content_type' => ContentType::NATIVE,
'type' => $type,
'status' => $status,
'slug' => $slug,
'title' => $title,
'excerpt' => $request->get('excerpt'),
'content_html' => $contentHtml,
'content_markdown' => $contentMarkdown,
'seo_meta' => $request->get('seo_meta'),
'publish_at' => $publishAt,
'last_edited_by' => Auth::id(),
]);
// Handle categories
if ($categories = $request->get('categories')) {
$categoryIds = $this->resolveOrCreateTaxonomies($workspace, $categories, 'category');
$item->taxonomies()->attach($categoryIds);
}
// Handle tags
if ($tags = $request->get('tags')) {
$tagIds = $this->resolveOrCreateTaxonomies($workspace, $tags, 'tag');
$item->taxonomies()->attach($tagIds);
}
// Create initial revision
$item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, 'Created via MCP');
// Record usage
$entitlements = app(EntitlementService::class);
$entitlements->recordUsage($workspace, 'content.items', 1, Auth::user(), [
'source' => 'mcp',
'content_id' => $item->id,
]);
return Response::text(json_encode([
'ok' => true,
'item' => [
'id' => $item->id,
'slug' => $item->slug,
'title' => $item->title,
'type' => $item->type,
'status' => $item->status,
'url' => $this->getContentUrl($workspace, $item),
],
], JSON_PRETTY_PRINT));
}
/**
* Update existing content.
*/
protected function updateContent(Workspace $workspace, Request $request): Response
{
$identifier = $request->get('identifier');
if (! $identifier) {
return Response::text(json_encode(['error' => 'identifier (slug or ID) is required']));
}
$query = ContentItem::forWorkspace($workspace->id)->native();
if (is_numeric($identifier)) {
$item = $query->find($identifier);
} else {
$item = $query->where('slug', $identifier)->first();
}
if (! $item) {
return Response::text(json_encode(['error' => 'Content not found']));
}
// Build update data
$updateData = [];
if ($request->has('title')) {
$updateData['title'] = $request->get('title');
}
if ($request->has('excerpt')) {
$updateData['excerpt'] = $request->get('excerpt');
}
if ($request->has('content') || $request->has('content_markdown')) {
$contentMarkdown = $request->get('content_markdown') ?? $request->get('content');
$updateData['content_markdown'] = $contentMarkdown;
$updateData['content_html'] = $request->get('content_html') ?? Str::markdown($contentMarkdown);
}
if ($request->has('content_html') && ! $request->has('content_markdown')) {
$updateData['content_html'] = $request->get('content_html');
}
if ($request->has('status')) {
$status = $request->get('status');
if (! in_array($status, ['draft', 'publish', 'future', 'private'])) {
return Response::text(json_encode(['error' => 'status must be draft, publish, future, or private']));
}
$updateData['status'] = $status;
if ($status === 'future' && $request->has('publish_at')) {
$updateData['publish_at'] = \Carbon\Carbon::parse($request->get('publish_at'));
}
}
if ($request->has('seo_meta')) {
$updateData['seo_meta'] = $request->get('seo_meta');
}
if ($request->has('slug')) {
$newSlug = $request->get('slug');
if ($newSlug !== $item->slug) {
// Check uniqueness
if (ContentItem::forWorkspace($workspace->id)->where('slug', $newSlug)->where('id', '!=', $item->id)->exists()) {
return Response::text(json_encode(['error' => 'Slug already exists']));
}
$updateData['slug'] = $newSlug;
}
}
$updateData['last_edited_by'] = Auth::id();
// Update item
$item->update($updateData);
// Handle categories
if ($request->has('categories')) {
$categoryIds = $this->resolveOrCreateTaxonomies($workspace, $request->get('categories'), 'category');
$item->categories()->sync($categoryIds);
}
// Handle tags
if ($request->has('tags')) {
$tagIds = $this->resolveOrCreateTaxonomies($workspace, $request->get('tags'), 'tag');
$item->tags()->sync($tagIds);
}
// Create revision
$changeSummary = $request->get('change_summary', 'Updated via MCP');
$item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, $changeSummary);
$item->refresh()->load(['author', 'taxonomies']);
return Response::text(json_encode([
'ok' => true,
'item' => [
'id' => $item->id,
'slug' => $item->slug,
'title' => $item->title,
'type' => $item->type,
'status' => $item->status,
'revision_count' => $item->revision_count,
'url' => $this->getContentUrl($workspace, $item),
],
], JSON_PRETTY_PRINT));
}
/**
* Delete content (soft delete).
*/
protected function deleteContent(Workspace $workspace, Request $request): Response
{
$identifier = $request->get('identifier');
if (! $identifier) {
return Response::text(json_encode(['error' => 'identifier (slug or ID) is required']));
}
$query = ContentItem::forWorkspace($workspace->id)->native();
if (is_numeric($identifier)) {
$item = $query->find($identifier);
} else {
$item = $query->where('slug', $identifier)->first();
}
if (! $item) {
return Response::text(json_encode(['error' => 'Content not found']));
}
// Create final revision before delete
$item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, 'Deleted via MCP');
// Soft delete
$item->delete();
return Response::text(json_encode([
'ok' => true,
'deleted' => [
'id' => $item->id,
'slug' => $item->slug,
'title' => $item->title,
],
], JSON_PRETTY_PRINT));
}
/**
* List taxonomies (categories and tags).
*/
protected function listTaxonomies(Workspace $workspace, Request $request): Response
{
$type = $request->get('type'); // category or tag
$query = ContentTaxonomy::where('workspace_id', $workspace->id);
if ($type) {
$query->where('type', $type);
}
$taxonomies = $query->orderBy('name')->get();
$result = [
'taxonomies' => $taxonomies->map(fn ($t) => [
'id' => $t->id,
'type' => $t->type,
'slug' => $t->slug,
'name' => $t->name,
'description' => $t->description,
])->all(),
'total' => $taxonomies->count(),
];
return Response::text(json_encode($result, JSON_PRETTY_PRINT));
}
/**
* Resolve or create taxonomies from slugs/names.
*/
protected function resolveOrCreateTaxonomies(Workspace $workspace, array $items, string $type): array
{
$ids = [];
foreach ($items as $item) {
$taxonomy = ContentTaxonomy::where('workspace_id', $workspace->id)
->where('type', $type)
->where(function ($q) use ($item) {
$q->where('slug', $item)
->orWhere('name', $item);
})
->first();
if (! $taxonomy) {
// Create new taxonomy
$taxonomy = ContentTaxonomy::create([
'workspace_id' => $workspace->id,
'type' => $type,
'slug' => Str::slug($item),
'name' => $item,
]);
}
$ids[] = $taxonomy->id;
}
return $ids;
}
/**
* Convert content item to markdown with frontmatter.
*/
protected function contentToMarkdown(ContentItem $item): string
{
$frontmatter = [
'title' => $item->title,
'slug' => $item->slug,
'type' => $item->type,
'status' => $item->status,
'author' => $item->author?->name,
'categories' => $item->categories->pluck('name')->all(),
'tags' => $item->tags->pluck('name')->all(),
'created_at' => $item->created_at->toIso8601String(),
'updated_at' => $item->updated_at->toIso8601String(),
];
if ($item->publish_at) {
$frontmatter['publish_at'] = $item->publish_at->toIso8601String();
}
if ($item->seo_meta) {
$frontmatter['seo'] = $item->seo_meta;
}
$yaml = "---\n";
foreach ($frontmatter as $key => $value) {
if (is_array($value)) {
$yaml .= "{$key}: ".json_encode($value)."\n";
} else {
$yaml .= "{$key}: {$value}\n";
}
}
$yaml .= "---\n\n";
// Prefer markdown content, fall back to stripping HTML
$content = $item->content_markdown ?? strip_tags($item->content_html ?? '');
return $yaml.$content;
}
/**
* Get the public URL for content.
*/
protected function getContentUrl(Workspace $workspace, ContentItem $item): string
{
$domain = $workspace->domain ?? config('app.url');
$path = $item->type === 'post' ? "/blog/{$item->slug}" : "/{$item->slug}";
return "https://{$domain}{$path}";
}
public function schema(JsonSchema $schema): array
{
return [
'action' => $schema->string('Action: list, read, create, update, delete, taxonomies'),
'workspace' => $schema->string('Workspace slug (required for most actions)')->nullable(),
'identifier' => $schema->string('Content slug or ID (for read, update, delete)')->nullable(),
'type' => $schema->string('Content type: post or page (for list filter or create)')->nullable(),
'status' => $schema->string('Content status: draft, publish, future, private')->nullable(),
'search' => $schema->string('Search term for list action')->nullable(),
'limit' => $schema->integer('Max items to return (default 20, max 100)')->nullable(),
'offset' => $schema->integer('Offset for pagination')->nullable(),
'format' => $schema->string('Output format: json or markdown (for read action)')->nullable(),
'title' => $schema->string('Content title (for create/update)')->nullable(),
'slug' => $schema->string('URL slug (for create/update)')->nullable(),
'excerpt' => $schema->string('Content excerpt/summary')->nullable(),
'content' => $schema->string('Content body as markdown (for create/update)')->nullable(),
'content_html' => $schema->string('Content body as HTML (optional, auto-generated from markdown)')->nullable(),
'content_markdown' => $schema->string('Content body as markdown (alias for content)')->nullable(),
'categories' => $schema->array('Array of category slugs or names')->nullable(),
'tags' => $schema->array('Array of tag strings')->nullable(),
'seo_meta' => $schema->array('SEO metadata: {title, description, keywords}')->nullable(),
'publish_at' => $schema->string('ISO datetime for scheduled publishing (status=future)')->nullable(),
'change_summary' => $schema->string('Summary of changes for revision history (update action)')->nullable(),
];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Core\Mod\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetStats extends Tool
{
protected string $description = 'Get current system statistics for Host Hub';
public function handle(Request $request): Response
{
$stats = [
'total_sites' => 6,
'active_users' => 128,
'page_views_30d' => 12500,
'server_load' => '23%',
];
return Response::text(json_encode($stats, JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [];
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Core\Mod\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListRoutes extends Tool
{
protected string $description = 'List all web routes in the application';
public function handle(Request $request): Response
{
$routes = collect(app('router')->getRoutes())
->map(fn ($route) => [
'uri' => $route->uri(),
'methods' => $route->methods(),
'name' => $route->getName(),
])
->values()
->toArray();
return Response::text(json_encode($routes, JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [];
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Core\Mod\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListSites extends Tool
{
protected string $description = 'List all sites managed by Host Hub';
public function handle(Request $request): Response
{
$sites = [
['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'],
['name' => 'TrustHost', 'domain' => 'trust.host.uk.com', 'type' => 'WordPress'],
['name' => 'NotifyHost', 'domain' => 'notify.host.uk.com', 'type' => 'Go'],
['name' => 'MailHost', 'domain' => 'hostmail.cc', 'type' => 'MailCow'],
];
return Response::text(json_encode($sites, JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Core\Mod\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\DB;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListTables extends Tool
{
protected string $description = 'List all database tables';
public function handle(Request $request): Response
{
$tables = collect(DB::select('SHOW TABLES'))
->map(fn ($table) => array_values((array) $table)[0])
->toArray();
return Response::text(json_encode($tables, JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [];
}
}

View file

@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tools;
use Core\Mod\Mcp\Exceptions\ForbiddenQueryException;
use Core\Mod\Mcp\Services\SqlQueryValidator;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
/**
* MCP Tool for executing read-only SQL queries.
*
* Security measures:
* 1. Uses configurable read-only database connection
* 2. Validates queries against blocked keywords and patterns
* 3. Optional whitelist-based query validation
* 4. Blocks access to sensitive tables
* 5. Enforces row limits
*/
class QueryDatabase extends Tool
{
protected string $description = 'Execute a read-only SQL SELECT query against the database';
private SqlQueryValidator $validator;
public function __construct()
{
$this->validator = $this->createValidator();
}
public function handle(Request $request): Response
{
$query = $request->input('query');
$explain = $request->input('explain', false);
if (empty($query)) {
return $this->errorResponse('Query is required');
}
// Validate the query
try {
$this->validator->validate($query);
} catch (ForbiddenQueryException $e) {
return $this->errorResponse($e->getMessage());
}
// Check for blocked tables
$blockedTable = $this->checkBlockedTables($query);
if ($blockedTable !== null) {
return $this->errorResponse(
sprintf("Access to table '%s' is not permitted", $blockedTable)
);
}
// Apply row limit if not present
$query = $this->applyRowLimit($query);
try {
$connection = $this->getConnection();
// If explain is requested, run EXPLAIN first
if ($explain) {
return $this->handleExplain($connection, $query);
}
$results = DB::connection($connection)->select($query);
return Response::text(json_encode($results, JSON_PRETTY_PRINT));
} catch (\Exception $e) {
// Log the actual error for debugging but return sanitised message
report($e);
return $this->errorResponse('Query execution failed: '.$this->sanitiseErrorMessage($e->getMessage()));
}
}
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string('SQL SELECT query to execute. Only read-only SELECT queries are permitted.'),
'explain' => $schema->boolean('If true, runs EXPLAIN on the query instead of executing it. Useful for query optimization and debugging.')->default(false),
];
}
/**
* Create the SQL validator with configuration.
*/
private function createValidator(): SqlQueryValidator
{
$useWhitelist = Config::get('mcp.database.use_whitelist', true);
$customPatterns = Config::get('mcp.database.whitelist_patterns', []);
$validator = new SqlQueryValidator(null, $useWhitelist);
foreach ($customPatterns as $pattern) {
$validator->addWhitelistPattern($pattern);
}
return $validator;
}
/**
* Get the database connection to use.
*
* @throws \RuntimeException If the configured connection is invalid
*/
private function getConnection(): ?string
{
$connection = Config::get('mcp.database.connection');
// If configured connection doesn't exist, throw exception
if ($connection && ! Config::has("database.connections.{$connection}")) {
throw new \RuntimeException(
"Invalid MCP database connection '{$connection}' configured. ".
"Please ensure 'database.connections.{$connection}' exists in your database configuration."
);
}
return $connection;
}
/**
* Check if the query references any blocked tables.
*/
private function checkBlockedTables(string $query): ?string
{
$blockedTables = Config::get('mcp.database.blocked_tables', []);
foreach ($blockedTables as $table) {
// Check for table references in various formats
$patterns = [
'/\bFROM\s+`?'.preg_quote($table, '/').'`?\b/i',
'/\bJOIN\s+`?'.preg_quote($table, '/').'`?\b/i',
'/\b'.preg_quote($table, '/').'\./i', // table.column format
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $query)) {
return $table;
}
}
}
return null;
}
/**
* Apply row limit to query if not already present.
*/
private function applyRowLimit(string $query): string
{
$maxRows = Config::get('mcp.database.max_rows', 1000);
// Check if LIMIT is already present
if (preg_match('/\bLIMIT\s+\d+/i', $query)) {
return $query;
}
// Remove trailing semicolon if present
$query = rtrim(trim($query), ';');
return $query.' LIMIT '.$maxRows;
}
/**
* Sanitise database error messages to avoid leaking sensitive information.
*/
private function sanitiseErrorMessage(string $message): string
{
// Remove specific database paths, credentials, etc.
$message = preg_replace('/\/[^\s]+/', '[path]', $message);
$message = preg_replace('/at \d+\.\d+\.\d+\.\d+/', 'at [ip]', $message);
// Truncate long messages
if (strlen($message) > 200) {
$message = substr($message, 0, 200).'...';
}
return $message;
}
/**
* Handle EXPLAIN query execution.
*/
private function handleExplain(?string $connection, string $query): Response
{
try {
// Run EXPLAIN on the query
$explainResults = DB::connection($connection)->select("EXPLAIN {$query}");
// Also try to get extended information if MySQL/MariaDB
$warnings = [];
try {
$warnings = DB::connection($connection)->select('SHOW WARNINGS');
} catch (\Exception $e) {
// SHOW WARNINGS may not be available on all databases
}
$response = [
'explain' => $explainResults,
'query' => $query,
];
if (! empty($warnings)) {
$response['warnings'] = $warnings;
}
// Add helpful interpretation
$response['interpretation'] = $this->interpretExplain($explainResults);
return Response::text(json_encode($response, JSON_PRETTY_PRINT));
} catch (\Exception $e) {
report($e);
return $this->errorResponse('EXPLAIN failed: '.$this->sanitiseErrorMessage($e->getMessage()));
}
}
/**
* Provide human-readable interpretation of EXPLAIN results.
*/
private function interpretExplain(array $explainResults): array
{
$interpretation = [];
foreach ($explainResults as $row) {
$rowAnalysis = [];
// Convert stdClass to array for easier access
$rowArray = (array) $row;
// Check for full table scan
if (isset($rowArray['type']) && $rowArray['type'] === 'ALL') {
$rowAnalysis[] = 'WARNING: Full table scan detected. Consider adding an index.';
}
// Check for filesort
if (isset($rowArray['Extra']) && str_contains($rowArray['Extra'], 'Using filesort')) {
$rowAnalysis[] = 'INFO: Using filesort. Query may benefit from an index on ORDER BY columns.';
}
// Check for temporary table
if (isset($rowArray['Extra']) && str_contains($rowArray['Extra'], 'Using temporary')) {
$rowAnalysis[] = 'INFO: Using temporary table. Consider optimizing the query.';
}
// Check rows examined
if (isset($rowArray['rows']) && $rowArray['rows'] > 10000) {
$rowAnalysis[] = sprintf('WARNING: High row count (%d rows). Query may be slow.', $rowArray['rows']);
}
// Check if index is used
if (isset($rowArray['key']) && $rowArray['key'] !== null) {
$rowAnalysis[] = sprintf('GOOD: Using index: %s', $rowArray['key']);
}
if (! empty($rowAnalysis)) {
$interpretation[] = [
'table' => $rowArray['table'] ?? 'unknown',
'analysis' => $rowAnalysis,
];
}
}
return $interpretation;
}
/**
* Create an error response.
*/
private function errorResponse(string $message): Response
{
return Response::text(json_encode(['error' => $message]));
}
}

View file

@ -0,0 +1,233 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<flux:heading size="xl">Tool Usage Analytics</flux:heading>
<flux:subheading>Monitor MCP tool usage patterns, performance, and errors</flux:subheading>
</div>
<div class="flex gap-2">
<flux:button.group>
<flux:button size="sm" wire:click="setDays(7)" variant="{{ $days === 7 ? 'primary' : 'ghost' }}">7 Days</flux:button>
<flux:button size="sm" wire:click="setDays(14)" variant="{{ $days === 14 ? 'primary' : 'ghost' }}">14 Days</flux:button>
<flux:button size="sm" wire:click="setDays(30)" variant="{{ $days === 30 ? 'primary' : 'ghost' }}">30 Days</flux:button>
</flux:button.group>
<flux:button icon="arrow-path" wire:click="$refresh">Refresh</flux:button>
</div>
</div>
<!-- Overview Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
@include('mcp::admin.analytics.partials.stats-card', [
'label' => 'Total Calls',
'value' => number_format($this->overview['total_calls']),
'color' => 'default',
])
@include('mcp::admin.analytics.partials.stats-card', [
'label' => 'Error Rate',
'value' => $this->overview['error_rate'] . '%',
'color' => $this->overview['error_rate'] > 10 ? 'red' : ($this->overview['error_rate'] > 5 ? 'yellow' : 'green'),
])
@include('mcp::admin.analytics.partials.stats-card', [
'label' => 'Avg Response',
'value' => $this->formatDuration($this->overview['avg_duration_ms']),
'color' => $this->overview['avg_duration_ms'] > 5000 ? 'yellow' : 'default',
])
@include('mcp::admin.analytics.partials.stats-card', [
'label' => 'Total Errors',
'value' => number_format($this->overview['total_errors']),
'color' => $this->overview['total_errors'] > 0 ? 'red' : 'default',
])
@include('mcp::admin.analytics.partials.stats-card', [
'label' => 'Unique Tools',
'value' => $this->overview['unique_tools'],
'color' => 'default',
])
</div>
<!-- Tabs -->
<div class="border-b border-zinc-200 dark:border-zinc-700">
<nav class="-mb-px flex gap-4">
<button wire:click="setTab('overview')" class="px-4 py-2 text-sm font-medium {{ $tab === 'overview' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-zinc-500 hover:text-zinc-700' }}">
Overview
</button>
<button wire:click="setTab('tools')" class="px-4 py-2 text-sm font-medium {{ $tab === 'tools' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-zinc-500 hover:text-zinc-700' }}">
All Tools
</button>
<button wire:click="setTab('errors')" class="px-4 py-2 text-sm font-medium {{ $tab === 'errors' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-zinc-500 hover:text-zinc-700' }}">
Errors
</button>
<button wire:click="setTab('combinations')" class="px-4 py-2 text-sm font-medium {{ $tab === 'combinations' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-zinc-500 hover:text-zinc-700' }}">
Combinations
</button>
</nav>
</div>
@if($tab === 'overview')
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Top Tools Chart -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<flux:heading>Top 10 Most Used Tools</flux:heading>
</div>
<div class="p-6">
@if($this->popularTools->isEmpty())
<div class="text-zinc-500 text-center py-8">No tool usage data available</div>
@else
<div class="space-y-3">
@php $maxCalls = $this->popularTools->first()->totalCalls ?: 1; @endphp
@foreach($this->popularTools as $tool)
<div class="flex items-center gap-4">
<div class="w-32 truncate text-sm font-mono" title="{{ $tool->toolName }}">
{{ $tool->toolName }}
</div>
<div class="flex-1">
<div class="h-6 bg-zinc-100 dark:bg-zinc-700 rounded-full overflow-hidden">
<div class="h-full {{ $tool->errorRate > 10 ? 'bg-red-500' : 'bg-blue-500' }} rounded-full transition-all"
style="width: {{ ($tool->totalCalls / $maxCalls) * 100 }}%">
</div>
</div>
</div>
<div class="w-20 text-right text-sm">
{{ number_format($tool->totalCalls) }}
</div>
<div class="w-16 text-right text-sm {{ $tool->errorRate > 10 ? 'text-red-600' : ($tool->errorRate > 5 ? 'text-yellow-600' : 'text-green-600') }}">
{{ $tool->errorRate }}%
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
<!-- Error-Prone Tools -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<flux:heading>Tools with Highest Error Rates</flux:heading>
</div>
<div class="p-6">
@if($this->errorProneTools->isEmpty())
<div class="text-green-600 text-center py-8">All tools are healthy - no significant errors</div>
@else
<div class="space-y-3">
@foreach($this->errorProneTools as $tool)
<div class="flex items-center justify-between p-3 rounded-lg {{ $tool->errorRate > 20 ? 'bg-red-50 dark:bg-red-900/20' : 'bg-yellow-50 dark:bg-yellow-900/20' }}">
<div>
<a href="{{ route('admin.mcp.analytics.tool', ['name' => $tool->toolName]) }}"
class="font-mono text-sm hover:underline">
{{ $tool->toolName }}
</a>
<div class="text-xs text-zinc-500">
{{ number_format($tool->errorCount) }} errors / {{ number_format($tool->totalCalls) }} calls
</div>
</div>
<flux:badge :color="$tool->errorRate > 20 ? 'red' : 'yellow'">
{{ $tool->errorRate }}% errors
</flux:badge>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
@endif
@if($tab === 'tools')
<!-- All Tools Table -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex justify-between items-center">
<flux:heading>All Tools</flux:heading>
<flux:subheading>{{ $this->sortedTools->count() }} tools</flux:subheading>
</div>
<div class="overflow-x-auto">
@include('mcp::admin.analytics.partials.tool-table', ['tools' => $this->sortedTools])
</div>
</div>
@endif
@if($tab === 'errors')
<!-- Error-Prone Tools List -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<flux:heading>Error Analysis</flux:heading>
</div>
<div class="p-6">
@if($this->errorProneTools->isEmpty())
<div class="text-green-600 text-center py-8">
<div class="text-4xl mb-2">&#10003;</div>
All tools are healthy - no significant errors detected
</div>
@else
<div class="space-y-4">
@foreach($this->errorProneTools as $tool)
<div class="p-4 rounded-lg border {{ $tool->errorRate > 20 ? 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/10' : 'border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/10' }}">
<div class="flex items-center justify-between mb-2">
<a href="{{ route('admin.mcp.analytics.tool', ['name' => $tool->toolName]) }}"
class="font-mono font-medium hover:underline">
{{ $tool->toolName }}
</a>
<flux:badge :color="$tool->errorRate > 20 ? 'red' : 'yellow'" size="lg">
{{ $tool->errorRate }}% Error Rate
</flux:badge>
</div>
<div class="grid grid-cols-4 gap-4 text-sm">
<div>
<span class="text-zinc-500">Total Calls:</span>
<span class="font-medium ml-1">{{ number_format($tool->totalCalls) }}</span>
</div>
<div>
<span class="text-zinc-500">Errors:</span>
<span class="font-medium text-red-600 ml-1">{{ number_format($tool->errorCount) }}</span>
</div>
<div>
<span class="text-zinc-500">Avg Duration:</span>
<span class="font-medium ml-1">{{ $this->formatDuration($tool->avgDurationMs) }}</span>
</div>
<div>
<span class="text-zinc-500">Max Duration:</span>
<span class="font-medium ml-1">{{ $this->formatDuration($tool->maxDurationMs) }}</span>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
@endif
@if($tab === 'combinations')
<!-- Tool Combinations -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<flux:heading>Popular Tool Combinations</flux:heading>
<flux:subheading>Tools frequently used together in the same session</flux:subheading>
</div>
<div class="p-6">
@if($this->toolCombinations->isEmpty())
<div class="text-zinc-500 text-center py-8">No tool combination data available yet</div>
@else
<div class="space-y-3">
@foreach($this->toolCombinations as $combo)
<div class="flex items-center justify-between p-3 rounded-lg bg-zinc-50 dark:bg-zinc-700/50">
<div class="flex items-center gap-2">
<span class="font-mono text-sm">{{ $combo['tool_a'] }}</span>
<span class="text-zinc-400">+</span>
<span class="font-mono text-sm">{{ $combo['tool_b'] }}</span>
</div>
<flux:badge>
{{ number_format($combo['occurrences']) }} times
</flux:badge>
</div>
@endforeach
</div>
@endif
</div>
</div>
@endif
</div>

View file

@ -0,0 +1,32 @@
@props([
'label',
'value',
'color' => 'default',
'subtext' => null,
])
@php
$colorClasses = match($color) {
'red' => 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
'yellow' => 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
'green' => 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
'blue' => 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
default => 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700',
};
$valueClasses = match($color) {
'red' => 'text-red-600 dark:text-red-400',
'yellow' => 'text-yellow-600 dark:text-yellow-400',
'green' => 'text-green-600 dark:text-green-400',
'blue' => 'text-blue-600 dark:text-blue-400',
default => '',
};
@endphp
<div class="p-4 rounded-lg border {{ $colorClasses }}">
<flux:subheading>{{ $label }}</flux:subheading>
<flux:heading size="xl" class="{{ $valueClasses }}">{{ $value }}</flux:heading>
@if($subtext)
<span class="text-sm text-zinc-500">{{ $subtext }}</span>
@endif
</div>

View file

@ -0,0 +1,100 @@
@props(['tools'])
<table class="w-full">
<thead class="bg-zinc-50 dark:bg-zinc-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider cursor-pointer hover:text-zinc-700"
wire:click="sort('toolName')">
<div class="flex items-center gap-1">
Tool Name
@if($sortColumn === 'toolName')
<span class="text-blue-500">{{ $sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}</span>
@endif
</div>
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider cursor-pointer hover:text-zinc-700"
wire:click="sort('totalCalls')">
<div class="flex items-center justify-end gap-1">
Total Calls
@if($sortColumn === 'totalCalls')
<span class="text-blue-500">{{ $sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}</span>
@endif
</div>
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider cursor-pointer hover:text-zinc-700"
wire:click="sort('errorCount')">
<div class="flex items-center justify-end gap-1">
Errors
@if($sortColumn === 'errorCount')
<span class="text-blue-500">{{ $sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}</span>
@endif
</div>
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider cursor-pointer hover:text-zinc-700"
wire:click="sort('errorRate')">
<div class="flex items-center justify-end gap-1">
Error Rate
@if($sortColumn === 'errorRate')
<span class="text-blue-500">{{ $sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}</span>
@endif
</div>
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider cursor-pointer hover:text-zinc-700"
wire:click="sort('avgDurationMs')">
<div class="flex items-center justify-end gap-1">
Avg Duration
@if($sortColumn === 'avgDurationMs')
<span class="text-blue-500">{{ $sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}</span>
@endif
</div>
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider">
Min / Max
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-700">
@forelse($tools as $tool)
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-700/50">
<td class="px-6 py-4 whitespace-nowrap">
<a href="{{ route('admin.mcp.analytics.tool', ['name' => $tool->toolName]) }}"
class="font-mono text-sm text-blue-600 hover:underline">
{{ $tool->toolName }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{{ number_format($tool->totalCalls) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm {{ $tool->errorCount > 0 ? 'text-red-600' : 'text-zinc-500' }}">
{{ number_format($tool->errorCount) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="px-2 py-1 text-xs font-medium rounded {{ $tool->errorRate > 10 ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' : ($tool->errorRate > 5 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' : 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200') }}">
{{ $tool->errorRate }}%
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm {{ $tool->avgDurationMs > 5000 ? 'text-yellow-600' : '' }}">
{{ $this->formatDuration($tool->avgDurationMs) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-zinc-500">
{{ $this->formatDuration($tool->minDurationMs) }} / {{ $this->formatDuration($tool->maxDurationMs) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<a href="{{ route('admin.mcp.analytics.tool', ['name' => $tool->toolName]) }}"
class="text-blue-600 hover:text-blue-800 text-sm">
View Details
</a>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-8 text-center text-zinc-500">
No tool usage data available
</td>
</tr>
@endforelse
</tbody>
</table>

View file

@ -0,0 +1,183 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2 mb-1">
<a href="{{ route('admin.mcp.analytics') }}" class="text-zinc-500 hover:text-zinc-700">
Analytics
</a>
<span class="text-zinc-400">/</span>
</div>
<flux:heading size="xl" class="font-mono">{{ $toolName }}</flux:heading>
<flux:subheading>Detailed usage analytics for this tool</flux:subheading>
</div>
<div class="flex gap-2">
<flux:button.group>
<flux:button size="sm" wire:click="setDays(7)" variant="{{ $days === 7 ? 'primary' : 'ghost' }}">7 Days</flux:button>
<flux:button size="sm" wire:click="setDays(14)" variant="{{ $days === 14 ? 'primary' : 'ghost' }}">14 Days</flux:button>
<flux:button size="sm" wire:click="setDays(30)" variant="{{ $days === 30 ? 'primary' : 'ghost' }}">30 Days</flux:button>
</flux:button.group>
<flux:button icon="arrow-path" wire:click="$refresh">Refresh</flux:button>
</div>
</div>
<!-- Overview Stats -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="p-4 bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<flux:subheading>Total Calls</flux:subheading>
<flux:heading size="xl">{{ number_format($this->stats->totalCalls) }}</flux:heading>
</div>
<div class="p-4 {{ $this->stats->errorRate > 10 ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800' : ($this->stats->errorRate > 5 ? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800' : 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800') }} rounded-lg border">
<flux:subheading>Error Rate</flux:subheading>
<flux:heading size="xl" class="{{ $this->stats->errorRate > 10 ? 'text-red-600' : ($this->stats->errorRate > 5 ? 'text-yellow-600' : 'text-green-600') }}">
{{ $this->stats->errorRate }}%
</flux:heading>
</div>
<div class="p-4 {{ $this->stats->errorCount > 0 ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800' : 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700' }} rounded-lg border">
<flux:subheading>Total Errors</flux:subheading>
<flux:heading size="xl" class="{{ $this->stats->errorCount > 0 ? 'text-red-600' : '' }}">
{{ number_format($this->stats->errorCount) }}
</flux:heading>
</div>
<div class="p-4 bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<flux:subheading>Avg Duration</flux:subheading>
<flux:heading size="xl">{{ $this->formatDuration($this->stats->avgDurationMs) }}</flux:heading>
</div>
<div class="p-4 bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<flux:subheading>Min Duration</flux:subheading>
<flux:heading size="xl">{{ $this->formatDuration($this->stats->minDurationMs) }}</flux:heading>
</div>
<div class="p-4 bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<flux:subheading>Max Duration</flux:subheading>
<flux:heading size="xl">{{ $this->formatDuration($this->stats->maxDurationMs) }}</flux:heading>
</div>
</div>
<!-- Usage Trend Chart -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<flux:heading>Usage Trend</flux:heading>
</div>
<div class="p-6">
@if(empty($this->trends) || array_sum(array_column($this->trends, 'calls')) === 0)
<div class="text-zinc-500 text-center py-8">No usage data available for this period</div>
@else
<div class="space-y-2">
@php
$maxCalls = max(array_column($this->trends, 'calls')) ?: 1;
@endphp
@foreach($this->trends as $day)
<div class="flex items-center gap-4">
<span class="w-16 text-sm text-zinc-500">{{ $day['date_formatted'] }}</span>
<div class="flex-1 flex items-center gap-2">
<div class="flex-1 bg-zinc-100 dark:bg-zinc-700 rounded-full h-5 overflow-hidden">
@php
$callsWidth = ($day['calls'] / $maxCalls) * 100;
$errorsWidth = $day['calls'] > 0 ? ($day['errors'] / $day['calls']) * $callsWidth : 0;
$successWidth = $callsWidth - $errorsWidth;
@endphp
<div class="h-full flex">
<div class="bg-green-500 h-full" style="width: {{ $successWidth }}%"></div>
<div class="bg-red-500 h-full" style="width: {{ $errorsWidth }}%"></div>
</div>
</div>
<span class="w-12 text-sm text-right">{{ $day['calls'] }}</span>
</div>
<div class="w-20 text-right">
@if($day['calls'] > 0)
<span class="text-sm {{ $day['error_rate'] > 10 ? 'text-red-600' : ($day['error_rate'] > 5 ? 'text-yellow-600' : 'text-green-600') }}">
{{ round($day['error_rate'], 1) }}%
</span>
@else
<span class="text-sm text-zinc-400">-</span>
@endif
</div>
</div>
@endforeach
</div>
<div class="mt-6 flex items-center justify-center gap-6 text-sm">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-green-500"></div>
<span class="text-zinc-600 dark:text-zinc-400">Successful</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-red-500"></div>
<span class="text-zinc-600 dark:text-zinc-400">Errors</span>
</div>
</div>
@endif
</div>
</div>
<!-- Response Time Distribution -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<flux:heading>Response Time Distribution</flux:heading>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<div class="text-sm text-zinc-600 dark:text-zinc-400 mb-1">Fastest</div>
<div class="text-2xl font-bold text-green-600">{{ $this->formatDuration($this->stats->minDurationMs) }}</div>
</div>
<div class="text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div class="text-sm text-zinc-600 dark:text-zinc-400 mb-1">Average</div>
<div class="text-2xl font-bold text-blue-600">{{ $this->formatDuration($this->stats->avgDurationMs) }}</div>
</div>
<div class="text-center p-4 rounded-lg bg-yellow-50 dark:bg-yellow-900/20">
<div class="text-sm text-zinc-600 dark:text-zinc-400 mb-1">Slowest</div>
<div class="text-2xl font-bold text-yellow-600">{{ $this->formatDuration($this->stats->maxDurationMs) }}</div>
</div>
</div>
</div>
</div>
<!-- Daily Breakdown Table -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<flux:heading>Daily Breakdown</flux:heading>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-zinc-50 dark:bg-zinc-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider">Calls</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider">Errors</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider">Error Rate</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 uppercase tracking-wider">Avg Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-700">
@forelse($this->trends as $day)
@if($day['calls'] > 0)
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-700/50">
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ $day['date'] }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">{{ number_format($day['calls']) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm {{ $day['errors'] > 0 ? 'text-red-600' : 'text-zinc-500' }}">{{ number_format($day['errors']) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="px-2 py-1 text-xs font-medium rounded {{ $day['error_rate'] > 10 ? 'bg-red-100 text-red-800' : ($day['error_rate'] > 5 ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800') }}">
{{ round($day['error_rate'], 1) }}%
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">{{ $this->formatDuration($day['avg_duration_ms']) }}</td>
</tr>
@endif
@empty
<tr>
<td colspan="5" class="px-6 py-8 text-center text-zinc-500">
No data available for this period
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>

View file

@ -0,0 +1,268 @@
<div>
<!-- Flash Messages -->
@if(session('message'))
<div class="mb-6 p-4 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg">
<p class="text-emerald-800 dark:text-emerald-200">{{ session('message') }}</p>
</div>
@endif
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white mb-2">
{{ __('mcp::mcp.keys.title') }}
</h1>
<p class="text-zinc-600 dark:text-zinc-400">
{{ __('mcp::mcp.keys.description') }}
</p>
</div>
<core:button icon="plus" variant="primary" wire:click="openCreateModal">
{{ __('mcp::mcp.keys.actions.create') }}
</core:button>
</div>
<!-- Keys List -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 overflow-hidden">
@if($keys->isEmpty())
<div class="p-12 text-center">
<div class="p-4 bg-cyan-50 dark:bg-cyan-900/20 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<core:icon.key class="w-8 h-8 text-cyan-500" />
</div>
<h3 class="text-lg font-semibold text-zinc-900 dark:text-white mb-2">{{ __('mcp::mcp.keys.empty.title') }}</h3>
<p class="text-zinc-600 dark:text-zinc-400 mb-6 max-w-md mx-auto">
{{ __('mcp::mcp.keys.empty.description') }}
</p>
<core:button icon="plus" variant="primary" wire:click="openCreateModal">
{{ __('mcp::mcp.keys.actions.create_first') }}
</core:button>
</div>
@else
<table class="w-full">
<thead class="bg-zinc-50 dark:bg-zinc-900/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{{ __('mcp::mcp.keys.table.name') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{{ __('mcp::mcp.keys.table.key') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{{ __('mcp::mcp.keys.table.scopes') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{{ __('mcp::mcp.keys.table.last_used') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{{ __('mcp::mcp.keys.table.expires') }}
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{{ __('mcp::mcp.keys.table.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach($keys as $key)
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-zinc-900 dark:text-white font-medium">{{ $key->name }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded font-mono">
{{ $key->prefix }}_****
</code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex gap-1">
@foreach($key->scopes ?? [] as $scope)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{{ $scope === 'read' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : '' }}
{{ $scope === 'write' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : '' }}
{{ $scope === 'delete' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : '' }}
">
{{ $scope }}
</span>
@endforeach
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-zinc-600 dark:text-zinc-400">
{{ $key->last_used_at?->diffForHumans() ?? __('mcp::mcp.keys.status.never') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
@if($key->expires_at)
@if($key->expires_at->isPast())
<span class="text-red-600 dark:text-red-400">{{ __('mcp::mcp.keys.status.expired') }}</span>
@else
<span class="text-zinc-600 dark:text-zinc-400">{{ $key->expires_at->diffForHumans() }}</span>
@endif
@else
<span class="text-zinc-500 dark:text-zinc-500">{{ __('mcp::mcp.keys.status.never') }}</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<core:button
variant="ghost"
size="sm"
icon="trash"
class="text-red-600 hover:text-red-700"
wire:click="revokeKey({{ $key->id }})"
wire:confirm="{{ __('mcp::mcp.keys.confirm_revoke') }}"
>
{{ __('mcp::mcp.keys.actions.revoke') }}
</core:button>
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
<!-- HTTP Usage Instructions -->
<div class="mt-8 grid md:grid-cols-2 gap-6">
<!-- Authentication -->
<div class="p-6 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl border border-zinc-200 dark:border-zinc-700">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4 flex items-center gap-2">
<core:icon.lock-closed class="w-5 h-5 text-cyan-500" />
{{ __('mcp::mcp.keys.auth.title') }}
</h2>
<p class="text-zinc-600 dark:text-zinc-400 mb-4 text-sm">
{{ __('mcp::mcp.keys.auth.description') }}
</p>
<div class="space-y-3">
<div>
<p class="text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">{{ __('mcp::mcp.keys.auth.header_recommended') }}</p>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">Authorization: Bearer hk_abc123_****</code></pre>
</div>
<div>
<p class="text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">{{ __('mcp::mcp.keys.auth.header_api_key') }}</p>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">X-API-Key: hk_abc123_****</code></pre>
</div>
</div>
</div>
<!-- Example Request -->
<div class="p-6 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl border border-zinc-200 dark:border-zinc-700">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4 flex items-center gap-2">
<core:icon.code-bracket class="w-5 h-5 text-cyan-500" />
{{ __('mcp::mcp.keys.example.title') }}
</h2>
<p class="text-zinc-600 dark:text-zinc-400 mb-4 text-sm">
{{ __('mcp::mcp.keys.example.description') }}
</p>
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-3 overflow-x-auto text-xs"><code class="text-emerald-400">curl -X POST https://mcp.host.uk.com/api/v1/tools/call \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server": "commerce",
"tool": "product_list",
"arguments": {}
}'</code></pre>
</div>
</div>
<!-- Create Key Modal -->
<core:modal wire:model="showCreateModal" class="max-w-md">
<div class="p-6">
<h3 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4">{{ __('mcp::mcp.keys.create_modal.title') }}</h3>
<div class="space-y-4">
<!-- Key Name -->
<div>
<core:label for="keyName">{{ __('mcp::mcp.keys.create_modal.name_label') }}</core:label>
<core:input
id="keyName"
wire:model="newKeyName"
placeholder="{{ __('mcp::mcp.keys.create_modal.name_placeholder') }}"
/>
@error('newKeyName')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Scopes -->
<div>
<core:label>{{ __('mcp::mcp.keys.create_modal.permissions_label') }}</core:label>
<div class="mt-2 space-y-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
wire:click="toggleScope('read')"
{{ in_array('read', $newKeyScopes) ? 'checked' : '' }}
class="rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500"
>
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_read') }}</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
wire:click="toggleScope('write')"
{{ in_array('write', $newKeyScopes) ? 'checked' : '' }}
class="rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500"
>
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_write') }}</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
wire:click="toggleScope('delete')"
{{ in_array('delete', $newKeyScopes) ? 'checked' : '' }}
class="rounded border-zinc-300 text-zinc-600 focus:ring-cyan-500"
>
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_delete') }}</span>
</label>
</div>
</div>
<!-- Expiry -->
<div>
<core:label for="keyExpiry">{{ __('mcp::mcp.keys.create_modal.expiry_label') }}</core:label>
<core:select id="keyExpiry" wire:model="newKeyExpiry">
<option value="never">{{ __('mcp::mcp.keys.create_modal.expiry_never') }}</option>
<option value="30days">{{ __('mcp::mcp.keys.create_modal.expiry_30') }}</option>
<option value="90days">{{ __('mcp::mcp.keys.create_modal.expiry_90') }}</option>
<option value="1year">{{ __('mcp::mcp.keys.create_modal.expiry_1year') }}</option>
</core:select>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<core:button variant="ghost" wire:click="closeCreateModal">{{ __('mcp::mcp.keys.create_modal.cancel') }}</core:button>
<core:button variant="primary" wire:click="createKey">{{ __('mcp::mcp.keys.create_modal.create') }}</core:button>
</div>
</div>
</core:modal>
<!-- New Key Display Modal -->
<core:modal wire:model="showNewKeyModal" class="max-w-lg">
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">
<core:icon.check class="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 class="text-lg font-semibold text-zinc-900 dark:text-white">{{ __('mcp::mcp.keys.new_key_modal.title') }}</h3>
</div>
<div class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-4">
<p class="text-sm text-amber-800 dark:text-amber-200">
<strong>{{ __('mcp::mcp.keys.new_key_modal.warning') }}</strong> {{ __('mcp::mcp.keys.new_key_modal.warning_detail') }}
</p>
</div>
<div class="relative" x-data="{ copied: false }">
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-4 overflow-x-auto text-sm font-mono break-all pr-12"><code class="text-zinc-800 dark:text-zinc-200">{{ $newPlainKey }}</code></pre>
<button
type="button"
x-on:click="navigator.clipboard.writeText('{{ $newPlainKey }}'); copied = true; setTimeout(() => copied = false, 2000)"
class="absolute top-3 right-3 p-2 rounded-lg bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
>
<core:icon.clipboard x-show="!copied" class="w-5 h-5 text-zinc-500" />
<core:icon.check x-show="copied" x-cloak class="w-5 h-5 text-emerald-500" />
</button>
</div>
<div class="mt-6 flex justify-end">
<core:button variant="primary" wire:click="closeNewKeyModal">{{ __('mcp::mcp.keys.new_key_modal.done') }}</core:button>
</div>
</div>
</core:modal>
</div>

View file

@ -0,0 +1,400 @@
{{--
MCP Audit Log Viewer.
Displays immutable audit trail for MCP tool executions.
Includes integrity verification and compliance export features.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<core:heading size="xl">{{ __('MCP Audit Log') }}</core:heading>
<core:subheading>Immutable audit trail for tool executions with hash chain integrity</core:subheading>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="verifyIntegrity" variant="ghost" icon="shield-check">
Verify Integrity
</flux:button>
<flux:button wire:click="openExportModal" icon="arrow-down-tray">
Export
</flux:button>
</div>
</div>
{{-- Stats Cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Total Entries</div>
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
{{ number_format($this->stats['total']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Success Rate</div>
<div class="mt-1 text-2xl font-semibold text-green-600 dark:text-green-400">
{{ $this->stats['success_rate'] }}%
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Failed Calls</div>
<div class="mt-1 text-2xl font-semibold text-red-600 dark:text-red-400">
{{ number_format($this->stats['failed']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sensitive Calls</div>
<div class="mt-1 text-2xl font-semibold text-amber-600 dark:text-amber-400">
{{ number_format($this->stats['sensitive_calls']) }}
</div>
</div>
</div>
{{-- Filters --}}
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search..." icon="magnifying-glass" />
</div>
<flux:select wire:model.live="tool" placeholder="All tools">
<flux:select.option value="">All tools</flux:select.option>
@foreach ($this->tools as $toolName)
<flux:select.option value="{{ $toolName }}">{{ $toolName }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model.live="workspace" placeholder="All workspaces">
<flux:select.option value="">All workspaces</flux:select.option>
@foreach ($this->workspaces as $ws)
<flux:select.option value="{{ $ws->id }}">{{ $ws->name }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model.live="status" placeholder="All statuses">
<flux:select.option value="">All statuses</flux:select.option>
<flux:select.option value="success">Success</flux:select.option>
<flux:select.option value="failed">Failed</flux:select.option>
</flux:select>
<flux:select wire:model.live="sensitivity" placeholder="All sensitivity">
<flux:select.option value="">All sensitivity</flux:select.option>
<flux:select.option value="sensitive">Sensitive only</flux:select.option>
<flux:select.option value="normal">Normal only</flux:select.option>
</flux:select>
<flux:input type="date" wire:model.live="dateFrom" placeholder="From" />
<flux:input type="date" wire:model.live="dateTo" placeholder="To" />
@if($search || $tool || $workspace || $status || $sensitivity || $dateFrom || $dateTo)
<flux:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">Clear</flux:button>
@endif
</div>
{{-- Audit Log Table --}}
<flux:table>
<flux:table.columns>
<flux:table.column>ID</flux:table.column>
<flux:table.column>Time</flux:table.column>
<flux:table.column>Tool</flux:table.column>
<flux:table.column>Workspace</flux:table.column>
<flux:table.column>Status</flux:table.column>
<flux:table.column>Sensitivity</flux:table.column>
<flux:table.column>Actor</flux:table.column>
<flux:table.column>Duration</flux:table.column>
<flux:table.column></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($this->entries as $entry)
<flux:table.row wire:key="entry-{{ $entry->id }}">
<flux:table.cell class="font-mono text-xs text-zinc-500">
#{{ $entry->id }}
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500 whitespace-nowrap">
{{ $entry->created_at->format('M j, Y H:i:s') }}
</flux:table.cell>
<flux:table.cell>
<div class="font-medium text-zinc-900 dark:text-white">{{ $entry->tool_name }}</div>
<div class="text-xs text-zinc-500">{{ $entry->server_id }}</div>
</flux:table.cell>
<flux:table.cell class="text-sm">
@if($entry->workspace)
{{ $entry->workspace->name }}
@else
<span class="text-zinc-400">-</span>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="$entry->success ? 'green' : 'red'">
{{ $entry->success ? 'Success' : 'Failed' }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
@if($entry->is_sensitive)
<flux:badge size="sm" color="amber" icon="exclamation-triangle">
Sensitive
</flux:badge>
@else
<span class="text-zinc-400 text-xs">-</span>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm">
{{ $entry->getActorDisplay() }}
@if($entry->actor_ip)
<div class="text-xs text-zinc-400">{{ $entry->actor_ip }}</div>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500">
{{ $entry->getDurationForHumans() }}
</flux:table.cell>
<flux:table.cell>
<flux:button wire:click="viewEntry({{ $entry->id }})" variant="ghost" size="xs" icon="eye">
View
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="9">
<div class="flex flex-col items-center py-12">
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="document-magnifying-glass" class="size-8 text-zinc-400" />
</div>
<flux:heading size="lg">No audit entries found</flux:heading>
<flux:subheading class="mt-1">Audit logs will appear here as tools are executed.</flux:subheading>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if($this->entries->hasPages())
<div class="border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
{{ $this->entries->links() }}
</div>
@endif
{{-- Entry Detail Modal --}}
@if($this->selectedEntry)
<flux:modal wire:model="selectedEntryId" name="entry-detail" class="max-w-3xl">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Audit Entry #{{ $this->selectedEntry->id }}</flux:heading>
<flux:button wire:click="closeEntryDetail" variant="ghost" icon="x-mark" />
</div>
{{-- Integrity Status --}}
@php
$integrity = $this->selectedEntry->getIntegrityStatus();
@endphp
<div class="rounded-lg p-4 {{ $integrity['valid'] ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20' }}">
<div class="flex items-center gap-2">
<flux:icon name="{{ $integrity['valid'] ? 'shield-check' : 'shield-exclamation' }}"
class="size-5 {{ $integrity['valid'] ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }}" />
<span class="font-medium {{ $integrity['valid'] ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300' }}">
{{ $integrity['valid'] ? 'Integrity Verified' : 'Integrity Issues Detected' }}
</span>
</div>
@if(!$integrity['valid'])
<ul class="mt-2 text-sm text-red-600 dark:text-red-400 list-disc list-inside">
@foreach($integrity['issues'] as $issue)
<li>{{ $issue }}</li>
@endforeach
</ul>
@endif
</div>
{{-- Entry Details --}}
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Tool</div>
<div class="mt-1 font-medium">{{ $this->selectedEntry->tool_name }}</div>
</div>
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Server</div>
<div class="mt-1">{{ $this->selectedEntry->server_id }}</div>
</div>
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Timestamp</div>
<div class="mt-1">{{ $this->selectedEntry->created_at->format('Y-m-d H:i:s.u') }}</div>
</div>
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Duration</div>
<div class="mt-1">{{ $this->selectedEntry->getDurationForHumans() }}</div>
</div>
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Status</div>
<div class="mt-1">
<flux:badge :color="$this->selectedEntry->success ? 'green' : 'red'">
{{ $this->selectedEntry->success ? 'Success' : 'Failed' }}
</flux:badge>
</div>
</div>
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Actor</div>
<div class="mt-1">{{ $this->selectedEntry->getActorDisplay() }}</div>
</div>
</div>
@if($this->selectedEntry->is_sensitive)
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
<flux:icon name="exclamation-triangle" class="size-5" />
<span class="font-medium">Sensitive Tool</span>
</div>
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400">
{{ $this->selectedEntry->sensitivity_reason ?? 'This tool is flagged as sensitive.' }}
</p>
</div>
@endif
@if($this->selectedEntry->error_message)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Error</div>
<div class="mt-1 rounded-lg bg-red-50 p-3 dark:bg-red-900/20">
@if($this->selectedEntry->error_code)
<div class="font-mono text-sm text-red-600 dark:text-red-400">
{{ $this->selectedEntry->error_code }}
</div>
@endif
<div class="text-sm text-red-700 dark:text-red-300">
{{ $this->selectedEntry->error_message }}
</div>
</div>
</div>
@endif
@if($this->selectedEntry->input_params)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Input Parameters</div>
<pre class="mt-1 overflow-auto rounded-lg bg-zinc-100 p-3 text-xs dark:bg-zinc-800">{{ json_encode($this->selectedEntry->input_params, JSON_PRETTY_PRINT) }}</pre>
</div>
@endif
@if($this->selectedEntry->output_summary)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Output Summary</div>
<pre class="mt-1 overflow-auto rounded-lg bg-zinc-100 p-3 text-xs dark:bg-zinc-800">{{ json_encode($this->selectedEntry->output_summary, JSON_PRETTY_PRINT) }}</pre>
</div>
@endif
{{-- Hash Chain Info --}}
<div class="border-t border-zinc-200 pt-4 dark:border-zinc-700">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Hash Chain</div>
<div class="space-y-2 font-mono text-xs">
<div>
<span class="text-zinc-500">Entry Hash:</span>
<span class="text-zinc-700 dark:text-zinc-300 break-all">{{ $this->selectedEntry->entry_hash }}</span>
</div>
<div>
<span class="text-zinc-500">Previous Hash:</span>
<span class="text-zinc-700 dark:text-zinc-300 break-all">{{ $this->selectedEntry->previous_hash ?? '(first entry)' }}</span>
</div>
</div>
</div>
</div>
</flux:modal>
@endif
{{-- Integrity Verification Modal --}}
@if($showIntegrityModal && $integrityStatus)
<flux:modal wire:model="showIntegrityModal" name="integrity-modal" class="max-w-lg">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Integrity Verification</flux:heading>
<flux:button wire:click="closeIntegrityModal" variant="ghost" icon="x-mark" />
</div>
<div class="rounded-lg p-6 {{ $integrityStatus['valid'] ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20' }}">
<div class="flex items-center gap-3">
<flux:icon name="{{ $integrityStatus['valid'] ? 'shield-check' : 'shield-exclamation' }}"
class="size-10 {{ $integrityStatus['valid'] ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }}" />
<div>
<div class="text-lg font-semibold {{ $integrityStatus['valid'] ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300' }}">
{{ $integrityStatus['valid'] ? 'Audit Log Verified' : 'Integrity Issues Detected' }}
</div>
<div class="text-sm {{ $integrityStatus['valid'] ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }}">
{{ number_format($integrityStatus['verified']) }} of {{ number_format($integrityStatus['total']) }} entries verified
</div>
</div>
</div>
</div>
@if(!$integrityStatus['valid'] && !empty($integrityStatus['issues']))
<div class="space-y-2">
<div class="text-sm font-medium text-zinc-700 dark:text-zinc-300">Issues Found:</div>
<div class="max-h-60 overflow-auto rounded-lg border border-red-200 dark:border-red-800">
@foreach($integrityStatus['issues'] as $issue)
<div class="border-b border-red-100 p-3 last:border-0 dark:border-red-900">
<div class="font-medium text-red-700 dark:text-red-300">
Entry #{{ $issue['id'] }}: {{ $issue['type'] }}
</div>
<div class="text-sm text-red-600 dark:text-red-400">
{{ $issue['message'] }}
</div>
</div>
@endforeach
</div>
</div>
@endif
<div class="flex justify-end">
<flux:button wire:click="closeIntegrityModal" variant="primary">
Close
</flux:button>
</div>
</div>
</flux:modal>
@endif
{{-- Export Modal --}}
@if($showExportModal)
<flux:modal wire:model="showExportModal" name="export-modal" class="max-w-md">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Export Audit Log</flux:heading>
<flux:button wire:click="closeExportModal" variant="ghost" icon="x-mark" />
</div>
<div class="space-y-4">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Export the audit log with current filters applied. The export includes integrity verification metadata.
</p>
<div>
<flux:label>Export Format</flux:label>
<flux:select wire:model="exportFormat">
<flux:select.option value="json">JSON (with integrity metadata)</flux:select.option>
<flux:select.option value="csv">CSV (data only)</flux:select.option>
</flux:select>
</div>
<div class="rounded-lg bg-zinc-100 p-3 text-sm dark:bg-zinc-800">
<div class="font-medium text-zinc-700 dark:text-zinc-300">Current Filters:</div>
<ul class="mt-1 text-zinc-600 dark:text-zinc-400">
@if($tool)
<li>Tool: {{ $tool }}</li>
@endif
@if($workspace)
<li>Workspace: {{ $this->workspaces->firstWhere('id', $workspace)?->name }}</li>
@endif
@if($dateFrom || $dateTo)
<li>Date: {{ $dateFrom ?: 'start' }} to {{ $dateTo ?: 'now' }}</li>
@endif
@if($sensitivity === 'sensitive')
<li>Sensitive only</li>
@endif
@if(!$tool && !$workspace && !$dateFrom && !$dateTo && !$sensitivity)
<li>All entries</li>
@endif
</ul>
</div>
</div>
<div class="flex justify-end gap-2">
<flux:button wire:click="closeExportModal" variant="ghost">
Cancel
</flux:button>
<flux:button wire:click="export" variant="primary" icon="arrow-down-tray">
Download
</flux:button>
</div>
</div>
</flux:modal>
@endif
</div>

View file

@ -0,0 +1,502 @@
<div class="min-h-screen" x-data="{ showHistory: false }">
{{-- Header --}}
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">MCP Playground</h1>
<p class="mt-2 text-zinc-600 dark:text-zinc-400">
Interactive tool testing with documentation and examples
</p>
</div>
<div class="flex items-center gap-3">
<button
x-on:click="showHistory = !showHistory"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border transition-colors"
:class="showHistory ? 'bg-cyan-50 dark:bg-cyan-900/20 border-cyan-200 dark:border-cyan-800 text-cyan-700 dark:text-cyan-300' : 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
History
@if(count($conversationHistory) > 0)
<span class="inline-flex items-center justify-center w-5 h-5 text-xs font-medium rounded-full bg-cyan-100 dark:bg-cyan-900 text-cyan-700 dark:text-cyan-300">
{{ count($conversationHistory) }}
</span>
@endif
</button>
</div>
</div>
</div>
{{-- Error Display --}}
@if($error)
<div class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<p class="text-sm text-red-700 dark:text-red-300">{{ $error }}</p>
</div>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
{{-- Left Sidebar: Tool Browser --}}
<div class="lg:col-span-3">
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 overflow-hidden sticky top-6">
{{-- Server Selection --}}
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700">
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Server</label>
<select
wire:model.live="selectedServer"
class="w-full rounded-lg border-zinc-300 dark:border-zinc-600 dark:bg-zinc-700 text-sm focus:border-cyan-500 focus:ring-cyan-500"
>
<option value="">Select a server...</option>
@foreach($servers as $server)
<option value="{{ $server['id'] }}">{{ $server['name'] }} ({{ $server['tool_count'] }})</option>
@endforeach
</select>
</div>
@if($selectedServer)
{{-- Search --}}
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
wire:model.live.debounce.300ms="searchQuery"
placeholder="Search tools..."
class="w-full pl-10 pr-4 py-2 text-sm rounded-lg border-zinc-300 dark:border-zinc-600 dark:bg-zinc-700 focus:border-cyan-500 focus:ring-cyan-500"
>
</div>
</div>
{{-- Category Filter --}}
@if($categories->isNotEmpty())
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700">
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-2">Category</label>
<div class="flex flex-wrap gap-1">
<button
wire:click="$set('selectedCategory', '')"
class="px-2 py-1 text-xs font-medium rounded-md transition-colors {{ empty($selectedCategory) ? 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300' : 'bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-600' }}"
>
All
</button>
@foreach($categories as $category)
<button
wire:click="$set('selectedCategory', '{{ $category }}')"
class="px-2 py-1 text-xs font-medium rounded-md transition-colors {{ $selectedCategory === $category ? 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300' : 'bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-600' }}"
>
{{ $category }}
</button>
@endforeach
</div>
</div>
@endif
{{-- Tools List --}}
<div class="max-h-[400px] overflow-y-auto">
@forelse($toolsByCategory as $category => $categoryTools)
<div class="px-4 py-2 bg-zinc-50 dark:bg-zinc-900/50 border-b border-zinc-200 dark:border-zinc-700">
<h4 class="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">{{ $category }}</h4>
</div>
@foreach($categoryTools as $tool)
<button
wire:click="selectTool('{{ $tool['name'] }}')"
class="w-full text-left px-4 py-3 border-b border-zinc-100 dark:border-zinc-700/50 transition-colors {{ $selectedTool === $tool['name'] ? 'bg-cyan-50 dark:bg-cyan-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-700/50' }}"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-zinc-900 dark:text-white">{{ $tool['name'] }}</span>
@if($selectedTool === $tool['name'])
<svg class="w-4 h-4 text-cyan-500" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
@endif
</div>
@if(!empty($tool['description']))
<p class="text-xs text-zinc-500 dark:text-zinc-400 mt-1 line-clamp-2">{{ Str::limit($tool['description'], 80) }}</p>
@endif
</button>
@endforeach
@empty
<div class="p-8 text-center">
<p class="text-sm text-zinc-500 dark:text-zinc-400">No tools found</p>
</div>
@endforelse
</div>
@else
<div class="p-8 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-zinc-300 dark:text-zinc-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Select a server to browse tools</p>
</div>
@endif
</div>
</div>
{{-- Center: Tool Details & Input Form --}}
<div class="lg:col-span-5">
{{-- API Key Authentication --}}
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6 mb-6">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-cyan-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
Authentication
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">API Key</label>
<input
type="password"
wire:model="apiKey"
placeholder="hk_xxxxxxxx_xxxxxxxxxxxx..."
class="w-full rounded-lg border-zinc-300 dark:border-zinc-600 dark:bg-zinc-700 text-sm focus:border-cyan-500 focus:ring-cyan-500"
>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">Paste your API key to execute requests live</p>
</div>
<div class="flex items-center gap-3">
<button
wire:click="validateKey"
class="px-3 py-1.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-600 rounded-lg transition-colors"
>
Validate Key
</button>
@if($keyStatus === 'valid')
<span class="inline-flex items-center gap-1 text-sm text-emerald-600 dark:text-emerald-400">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Valid
</span>
@elseif($keyStatus === 'invalid')
<span class="inline-flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Invalid key
</span>
@elseif($keyStatus === 'expired')
<span class="inline-flex items-center gap-1 text-sm text-amber-600 dark:text-amber-400">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Expired
</span>
@endif
</div>
@if($keyInfo)
<div class="p-3 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg">
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-emerald-600 dark:text-emerald-400">Name:</span>
<span class="text-emerald-800 dark:text-emerald-200 ml-1">{{ $keyInfo['name'] }}</span>
</div>
<div>
<span class="text-emerald-600 dark:text-emerald-400">Workspace:</span>
<span class="text-emerald-800 dark:text-emerald-200 ml-1">{{ $keyInfo['workspace'] }}</span>
</div>
</div>
</div>
@endif
</div>
</div>
{{-- Tool Form --}}
@if($currentTool)
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6">
<div class="mb-6">
<div class="flex items-start justify-between">
<div>
<h3 class="text-lg font-semibold text-zinc-900 dark:text-white">{{ $currentTool['name'] }}</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">{{ $currentTool['description'] }}</p>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200">
{{ $currentTool['category'] }}
</span>
</div>
</div>
@php
$properties = $currentTool['inputSchema']['properties'] ?? [];
$required = $currentTool['inputSchema']['required'] ?? [];
@endphp
@if(count($properties) > 0)
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300">Parameters</h4>
<button
wire:click="loadExampleInputs"
class="text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300"
>
Load examples
</button>
</div>
@foreach($properties as $name => $schema)
@php
$isRequired = in_array($name, $required) || ($schema['required'] ?? false);
$type = is_array($schema['type'] ?? 'string') ? ($schema['type'][0] ?? 'string') : ($schema['type'] ?? 'string');
$description = $schema['description'] ?? '';
@endphp
<div>
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
{{ $name }}
@if($isRequired)
<span class="text-red-500">*</span>
@endif
</label>
@if(isset($schema['enum']))
<select
wire:model="toolInput.{{ $name }}"
class="w-full rounded-lg border-zinc-300 dark:border-zinc-600 dark:bg-zinc-700 text-sm focus:border-cyan-500 focus:ring-cyan-500"
>
<option value="">Select...</option>
@foreach($schema['enum'] as $option)
<option value="{{ $option }}">{{ $option }}</option>
@endforeach
</select>
@elseif($type === 'boolean')
<select
wire:model="toolInput.{{ $name }}"
class="w-full rounded-lg border-zinc-300 dark:border-zinc-600 dark:bg-zinc-700 text-sm focus:border-cyan-500 focus:ring-cyan-500"
>
<option value="">Default</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
@elseif($type === 'integer' || $type === 'number')
<input
type="number"
wire:model="toolInput.{{ $name }}"
placeholder="{{ $schema['default'] ?? '' }}"
class="w-full rounded-lg border-zinc-300 dark:border-zinc-600 dark:bg-zinc-700 text-sm focus:border-cyan-500 focus:ring-cyan-500"
@if(isset($schema['minimum'])) min="{{ $schema['minimum'] }}" @endif
@if(isset($schema['maximum'])) max="{{ $schema['maximum'] }}" @endif
>
@elseif($type === 'array' || $type === 'object')
<textarea
wire:model="toolInput.{{ $name }}"
rows="3"
placeholder="Enter JSON..."
class="w-full rounded-lg border-zinc-300 dark:border-zinc-600 dark:bg-zinc-700 text-sm font-mono focus:border-cyan-500 focus:ring-cyan-500"
></textarea>
@else
<input
type="text"
wire:model="toolInput.{{ $name }}"
placeholder="{{ $schema['default'] ?? '' }}"
class="w-full rounded-lg border-zinc-300 dark:border-zinc-600 dark:bg-zinc-700 text-sm focus:border-cyan-500 focus:ring-cyan-500"
>
@endif
@if($description)
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">{{ $description }}</p>
@endif
</div>
@endforeach
</div>
@else
<p class="text-sm text-zinc-500 dark:text-zinc-400">This tool has no parameters.</p>
@endif
<div class="mt-6">
<button
wire:click="execute"
wire:loading.attr="disabled"
class="w-full inline-flex justify-center items-center px-4 py-2.5 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span wire:loading.remove wire:target="execute">
@if($keyStatus === 'valid')
Execute Request
@else
Generate Request Preview
@endif
</span>
<span wire:loading wire:target="execute" class="flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Executing...
</span>
</button>
</div>
</div>
@else
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-zinc-300 dark:text-zinc-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
<h3 class="text-lg font-medium text-zinc-900 dark:text-white mb-2">Select a tool</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Choose a tool from the sidebar to view its documentation and test it
</p>
</div>
@endif
</div>
{{-- Right: Response Viewer --}}
<div class="lg:col-span-4">
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 overflow-hidden sticky top-6">
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Response</h2>
@if($executionTime > 0)
<span class="text-sm text-zinc-500 dark:text-zinc-400">{{ $executionTime }}ms</span>
@endif
</div>
<div class="p-4" x-data="{ copied: false }">
@if($lastResponse)
<div class="flex justify-end mb-2">
<button
x-on:click="navigator.clipboard.writeText($refs.response.textContent); copied = true; setTimeout(() => copied = false, 2000)"
class="inline-flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
<span x-show="!copied">Copy</span>
<span x-show="copied" x-cloak>Copied!</span>
</button>
</div>
@if(isset($lastResponse['error']))
<div class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-700 dark:text-red-300">{{ $lastResponse['error'] }}</p>
</div>
@endif
<div class="bg-zinc-900 dark:bg-zinc-950 rounded-lg overflow-hidden">
<pre x-ref="response" class="p-4 text-sm text-emerald-400 overflow-x-auto max-h-[500px] whitespace-pre-wrap">{{ json_encode($lastResponse, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}</pre>
</div>
@if(isset($lastResponse['executed']) && !$lastResponse['executed'])
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm text-amber-700 dark:text-amber-300">
This is a preview. Add a valid API key to execute requests live.
</p>
</div>
@endif
@else
<div class="py-12 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-zinc-300 dark:text-zinc-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Response will appear here</p>
</div>
@endif
</div>
{{-- API Reference --}}
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900/50">
<h4 class="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-3">API Reference</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-zinc-500 dark:text-zinc-400">Endpoint</span>
<code class="text-zinc-800 dark:text-zinc-200 text-xs">/api/v1/mcp/tools/call</code>
</div>
<div class="flex justify-between">
<span class="text-zinc-500 dark:text-zinc-400">Method</span>
<code class="text-zinc-800 dark:text-zinc-200 text-xs">POST</code>
</div>
<div class="flex justify-between">
<span class="text-zinc-500 dark:text-zinc-400">Auth</span>
<code class="text-zinc-800 dark:text-zinc-200 text-xs">Bearer token</code>
</div>
</div>
</div>
</div>
</div>
</div>
{{-- History Panel (Collapsible Bottom) --}}
<div
x-show="showHistory"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
class="mt-6"
>
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 overflow-hidden">
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-cyan-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Conversation History
</h2>
@if(count($conversationHistory) > 0)
<button
wire:click="clearHistory"
wire:confirm="Are you sure you want to clear your history?"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
Clear All
</button>
@endif
</div>
@if(count($conversationHistory) > 0)
<div class="divide-y divide-zinc-200 dark:divide-zinc-700 max-h-[300px] overflow-y-auto">
@foreach($conversationHistory as $index => $entry)
<div class="p-4 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
@if($entry['success'] ?? true)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300">
Success
</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300">
Failed
</span>
@endif
<span class="font-medium text-zinc-900 dark:text-white">{{ $entry['tool'] }}</span>
<span class="text-zinc-400">on</span>
<span class="text-zinc-600 dark:text-zinc-400">{{ $entry['server'] }}</span>
</div>
<div class="mt-1 flex items-center gap-4 text-xs text-zinc-500 dark:text-zinc-400">
<span>{{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans() }}</span>
@if(isset($entry['duration_ms']))
<span>{{ $entry['duration_ms'] }}ms</span>
@endif
</div>
</div>
<div class="flex items-center gap-2 ml-4">
<button
wire:click="viewFromHistory({{ $index }})"
class="px-2 py-1 text-xs font-medium text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-600 rounded transition-colors"
>
View
</button>
<button
wire:click="rerunFromHistory({{ $index }})"
class="px-2 py-1 text-xs font-medium text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 bg-cyan-50 dark:bg-cyan-900/20 hover:bg-cyan-100 dark:hover:bg-cyan-900/30 rounded transition-colors"
>
Re-run
</button>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="p-8 text-center">
<p class="text-sm text-zinc-500 dark:text-zinc-400">No history yet. Execute a tool to see it here.</p>
</div>
@endif
</div>
</div>
</div>

View file

@ -0,0 +1,281 @@
<div>
<div class="mb-8">
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">{{ __('mcp::mcp.playground.title') }}</h1>
<p class="mt-2 text-lg text-zinc-600 dark:text-zinc-400">
{{ __('mcp::mcp.playground.description') }}
</p>
</div>
{{-- Error Display --}}
@if($error)
<div class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<div class="flex items-start gap-3">
<core:icon.x-circle class="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p class="text-sm text-red-700 dark:text-red-300">{{ $error }}</p>
</div>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Request Builder -->
<div class="space-y-6">
<!-- API Key Input -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4">{{ __('mcp::mcp.playground.auth.title') }}</h2>
<div class="space-y-4">
<div>
<core:input
wire:model="apiKey"
type="password"
label="{{ __('mcp::mcp.playground.auth.api_key_label') }}"
placeholder="{{ __('mcp::mcp.playground.auth.api_key_placeholder') }}"
description="{{ __('mcp::mcp.playground.auth.api_key_description') }}"
/>
</div>
<div class="flex items-center gap-3">
<core:button wire:click="validateKey" size="sm" variant="ghost">
{{ __('mcp::mcp.playground.auth.validate') }}
</core:button>
@if($keyStatus === 'valid')
<span class="inline-flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
<core:icon.check-circle class="w-4 h-4" />
{{ __('mcp::mcp.playground.auth.status.valid') }}
</span>
@elseif($keyStatus === 'invalid')
<span class="inline-flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
<core:icon.x-circle class="w-4 h-4" />
{{ __('mcp::mcp.playground.auth.status.invalid') }}
</span>
@elseif($keyStatus === 'expired')
<span class="inline-flex items-center gap-1 text-sm text-amber-600 dark:text-amber-400">
<core:icon.clock class="w-4 h-4" />
{{ __('mcp::mcp.playground.auth.status.expired') }}
</span>
@elseif($keyStatus === 'empty')
<span class="text-sm text-zinc-500 dark:text-zinc-400">
{{ __('mcp::mcp.playground.auth.status.empty') }}
</span>
@endif
</div>
@if($keyInfo)
<div class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-green-600 dark:text-green-400">{{ __('mcp::mcp.playground.auth.key_info.name') }}:</span>
<span class="text-green-800 dark:text-green-200 ml-1">{{ $keyInfo['name'] }}</span>
</div>
<div>
<span class="text-green-600 dark:text-green-400">{{ __('mcp::mcp.playground.auth.key_info.workspace') }}:</span>
<span class="text-green-800 dark:text-green-200 ml-1">{{ $keyInfo['workspace'] }}</span>
</div>
<div>
<span class="text-green-600 dark:text-green-400">{{ __('mcp::mcp.playground.auth.key_info.scopes') }}:</span>
<span class="text-green-800 dark:text-green-200 ml-1">{{ implode(', ', $keyInfo['scopes'] ?? []) }}</span>
</div>
<div>
<span class="text-green-600 dark:text-green-400">{{ __('mcp::mcp.playground.auth.key_info.last_used') }}:</span>
<span class="text-green-800 dark:text-green-200 ml-1">{{ $keyInfo['last_used'] }}</span>
</div>
</div>
</div>
@elseif(!$isAuthenticated && !$apiKey)
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm text-amber-700 dark:text-amber-300">
<a href="{{ route('login') }}" class="underline hover:no-underline">{{ __('mcp::mcp.playground.auth.sign_in_prompt') }}</a>
{{ __('mcp::mcp.playground.auth.sign_in_description') }}
</p>
</div>
@endif
</div>
</div>
<!-- Server & Tool Selection -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4">{{ __('mcp::mcp.playground.tools.title') }}</h2>
<div class="space-y-4">
<core:select wire:model.live="selectedServer" label="{{ __('mcp::mcp.playground.tools.server_label') }}" placeholder="{{ __('mcp::mcp.playground.tools.server_placeholder') }}">
@foreach($servers as $server)
<core:select.option value="{{ $server['id'] }}">{{ $server['name'] }}</core:select.option>
@endforeach
</core:select>
@if($selectedServer && count($tools) > 0)
<core:select wire:model.live="selectedTool" label="{{ __('mcp::mcp.playground.tools.tool_label') }}" placeholder="{{ __('mcp::mcp.playground.tools.tool_placeholder') }}">
@foreach($tools as $tool)
<core:select.option value="{{ $tool['name'] }}">{{ $tool['name'] }}</core:select.option>
@endforeach
</core:select>
@endif
</div>
</div>
<!-- Tool Info & Arguments -->
@if($toolSchema)
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-zinc-900 dark:text-white">{{ $toolSchema['name'] }}</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400">{{ $toolSchema['description'] ?? $toolSchema['purpose'] ?? '' }}</p>
</div>
@php
$params = $toolSchema['inputSchema']['properties'] ?? $toolSchema['parameters'] ?? [];
$required = $toolSchema['inputSchema']['required'] ?? [];
@endphp
@if(count($params) > 0)
<div class="space-y-4">
<h4 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.playground.tools.arguments') }}</h4>
@foreach($params as $name => $schema)
<div>
@php
$paramRequired = in_array($name, $required) || ($schema['required'] ?? false);
$paramType = is_array($schema['type'] ?? 'string') ? ($schema['type'][0] ?? 'string') : ($schema['type'] ?? 'string');
@endphp
@if(isset($schema['enum']))
<core:select
wire:model="arguments.{{ $name }}"
label="{{ $name }}{{ $paramRequired ? ' *' : '' }}"
placeholder="Select..."
description="{{ $schema['description'] ?? '' }}"
>
@foreach($schema['enum'] as $option)
<core:select.option value="{{ $option }}">{{ $option }}</core:select.option>
@endforeach
</core:select>
@elseif($paramType === 'boolean')
<core:select
wire:model="arguments.{{ $name }}"
label="{{ $name }}{{ $paramRequired ? ' *' : '' }}"
placeholder="Default"
description="{{ $schema['description'] ?? '' }}"
>
<core:select.option value="true">true</core:select.option>
<core:select.option value="false">false</core:select.option>
</core:select>
@elseif($paramType === 'integer' || $paramType === 'number')
<core:input
type="number"
wire:model="arguments.{{ $name }}"
label="{{ $name }}{{ $paramRequired ? ' *' : '' }}"
placeholder="{{ $schema['default'] ?? '' }}"
description="{{ $schema['description'] ?? '' }}"
/>
@else
<core:input
type="text"
wire:model="arguments.{{ $name }}"
label="{{ $name }}{{ $paramRequired ? ' *' : '' }}"
placeholder="{{ $schema['default'] ?? '' }}"
description="{{ $schema['description'] ?? '' }}"
/>
@endif
</div>
@endforeach
</div>
@else
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('mcp::mcp.playground.tools.no_arguments') }}</p>
@endif
<div class="mt-6">
<core:button
wire:click="execute"
wire:loading.attr="disabled"
variant="primary"
class="w-full"
>
<span wire:loading.remove wire:target="execute">
@if($keyStatus === 'valid')
{{ __('mcp::mcp.playground.tools.execute') }}
@else
{{ __('mcp::mcp.playground.tools.generate') }}
@endif
</span>
<span wire:loading wire:target="execute">{{ __('mcp::mcp.playground.tools.executing') }}</span>
</core:button>
</div>
</div>
@endif
</div>
<!-- Response -->
<div class="space-y-6">
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white mb-4">{{ __('mcp::mcp.playground.response.title') }}</h2>
@if($response)
<div x-data="{ copied: false }">
<div class="flex justify-end mb-2">
<button
x-on:click="navigator.clipboard.writeText($refs.response.textContent); copied = true; setTimeout(() => copied = false, 2000)"
class="text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
<span x-show="!copied">{{ __('mcp::mcp.playground.response.copy') }}</span>
<span x-show="copied" x-cloak>{{ __('mcp::mcp.playground.response.copied') }}</span>
</button>
</div>
<pre x-ref="response" class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm text-emerald-400 whitespace-pre-wrap">{{ $response }}</pre>
</div>
@else
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<core:icon.code-bracket-square class="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{{ __('mcp::mcp.playground.response.empty') }}</p>
</div>
@endif
</div>
<!-- Quick Reference -->
<div class="bg-zinc-50 dark:bg-zinc-900/50 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6">
<h3 class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-3">{{ __('mcp::mcp.playground.reference.title') }}</h3>
<div class="space-y-3 text-sm">
<div>
<span class="text-zinc-500 dark:text-zinc-400">{{ __('mcp::mcp.playground.reference.endpoint') }}:</span>
<code class="ml-2 text-zinc-800 dark:text-zinc-200 break-all">{{ config('app.url') }}/api/v1/mcp/tools/call</code>
</div>
<div>
<span class="text-zinc-500 dark:text-zinc-400">{{ __('mcp::mcp.playground.reference.method') }}:</span>
<code class="ml-2 text-zinc-800 dark:text-zinc-200">POST</code>
</div>
<div>
<span class="text-zinc-500 dark:text-zinc-400">{{ __('mcp::mcp.playground.reference.auth') }}:</span>
@if($keyStatus === 'valid')
<code class="ml-2 text-green-600 dark:text-green-400">Bearer {{ Str::limit($apiKey, 20, '...') }}</code>
@else
<code class="ml-2 text-zinc-800 dark:text-zinc-200">Bearer &lt;your-api-key&gt;</code>
@endif
</div>
<div>
<span class="text-zinc-500 dark:text-zinc-400">{{ __('mcp::mcp.playground.reference.content_type') }}:</span>
<code class="ml-2 text-zinc-800 dark:text-zinc-200">application/json</code>
</div>
</div>
@if($isAuthenticated)
<div class="mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-700">
<core:button href="{{ route('mcp.keys') }}" size="sm" variant="ghost" icon="key">
{{ __('mcp::mcp.playground.reference.manage_keys') }}
</core:button>
</div>
@endif
</div>
</div>
</div>
</div>
@script
<script>
// Suppress Livewire request errors to prevent modal popups
// Errors are handled gracefully in the component
document.addEventListener('admin:request-error', (event) => {
// Prevent the default Livewire error modal
event.preventDefault();
console.warn('MCP Playground: Request failed', event.detail);
});
</script>
@endscript

View file

@ -0,0 +1,186 @@
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">MCP Usage Quota</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Current billing period resets {{ $this->resetDate }}
</p>
</div>
<button wire:click="loadQuotaData" class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-zinc-700 bg-white border border-zinc-300 rounded-lg hover:bg-zinc-50 dark:bg-zinc-800 dark:text-zinc-300 dark:border-zinc-600 dark:hover:bg-zinc-700">
<x-heroicon-o-arrow-path class="w-4 h-4" />
Refresh
</button>
</div>
{{-- Current Usage Cards --}}
<div class="grid gap-6 md:grid-cols-2">
{{-- Tool Calls Card --}}
<div class="p-6 bg-white border border-zinc-200 rounded-xl dark:bg-zinc-800 dark:border-zinc-700">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-indigo-100 rounded-lg dark:bg-indigo-900/30">
<x-heroicon-o-wrench-screwdriver class="w-5 h-5 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="font-medium text-zinc-900 dark:text-white">Tool Calls</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Monthly usage</p>
</div>
</div>
</div>
@if($quotaLimits['tool_calls_unlimited'] ?? false)
<div class="flex items-baseline gap-2">
<span class="text-3xl font-bold text-zinc-900 dark:text-white">
{{ number_format($currentUsage['tool_calls_count'] ?? 0) }}
</span>
<span class="text-sm font-medium text-emerald-600 dark:text-emerald-400">Unlimited</span>
</div>
@else
<div class="space-y-3">
<div class="flex items-baseline justify-between">
<span class="text-3xl font-bold text-zinc-900 dark:text-white">
{{ number_format($currentUsage['tool_calls_count'] ?? 0) }}
</span>
<span class="text-sm text-zinc-500 dark:text-zinc-400">
of {{ number_format($quotaLimits['tool_calls_limit'] ?? 0) }}
</span>
</div>
<div class="w-full h-2 bg-zinc-200 rounded-full dark:bg-zinc-700">
<div
class="h-2 rounded-full transition-all duration-300 {{ $this->toolCallsPercentage >= 90 ? 'bg-red-500' : ($this->toolCallsPercentage >= 75 ? 'bg-amber-500' : 'bg-indigo-500') }}"
style="width: {{ $this->toolCallsPercentage }}%"
></div>
</div>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
{{ number_format($remaining['tool_calls'] ?? 0) }} remaining
</p>
</div>
@endif
</div>
{{-- Tokens Card --}}
<div class="p-6 bg-white border border-zinc-200 rounded-xl dark:bg-zinc-800 dark:border-zinc-700">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-purple-100 rounded-lg dark:bg-purple-900/30">
<x-heroicon-o-cube class="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 class="font-medium text-zinc-900 dark:text-white">Tokens</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Monthly consumption</p>
</div>
</div>
</div>
@if($quotaLimits['tokens_unlimited'] ?? false)
<div class="flex items-baseline gap-2">
<span class="text-3xl font-bold text-zinc-900 dark:text-white">
{{ number_format($currentUsage['total_tokens'] ?? 0) }}
</span>
<span class="text-sm font-medium text-emerald-600 dark:text-emerald-400">Unlimited</span>
</div>
<div class="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-zinc-500 dark:text-zinc-400">Input:</span>
<span class="ml-1 font-medium text-zinc-700 dark:text-zinc-300">
{{ number_format($currentUsage['input_tokens'] ?? 0) }}
</span>
</div>
<div>
<span class="text-zinc-500 dark:text-zinc-400">Output:</span>
<span class="ml-1 font-medium text-zinc-700 dark:text-zinc-300">
{{ number_format($currentUsage['output_tokens'] ?? 0) }}
</span>
</div>
</div>
@else
<div class="space-y-3">
<div class="flex items-baseline justify-between">
<span class="text-3xl font-bold text-zinc-900 dark:text-white">
{{ number_format($currentUsage['total_tokens'] ?? 0) }}
</span>
<span class="text-sm text-zinc-500 dark:text-zinc-400">
of {{ number_format($quotaLimits['tokens_limit'] ?? 0) }}
</span>
</div>
<div class="w-full h-2 bg-zinc-200 rounded-full dark:bg-zinc-700">
<div
class="h-2 rounded-full transition-all duration-300 {{ $this->tokensPercentage >= 90 ? 'bg-red-500' : ($this->tokensPercentage >= 75 ? 'bg-amber-500' : 'bg-purple-500') }}"
style="width: {{ $this->tokensPercentage }}%"
></div>
</div>
<div class="flex justify-between text-sm">
<p class="text-zinc-500 dark:text-zinc-400">
{{ number_format($remaining['tokens'] ?? 0) }} remaining
</p>
<div class="flex gap-3">
<span class="text-zinc-400 dark:text-zinc-500">
In: {{ number_format($currentUsage['input_tokens'] ?? 0) }}
</span>
<span class="text-zinc-400 dark:text-zinc-500">
Out: {{ number_format($currentUsage['output_tokens'] ?? 0) }}
</span>
</div>
</div>
</div>
@endif
</div>
</div>
{{-- Usage History --}}
@if($usageHistory->count() > 0)
<div class="p-6 bg-white border border-zinc-200 rounded-xl dark:bg-zinc-800 dark:border-zinc-700">
<h3 class="mb-4 font-medium text-zinc-900 dark:text-white">Usage History</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-200 dark:border-zinc-700">
<th class="px-4 py-3 text-left font-medium text-zinc-500 dark:text-zinc-400">Month</th>
<th class="px-4 py-3 text-right font-medium text-zinc-500 dark:text-zinc-400">Tool Calls</th>
<th class="px-4 py-3 text-right font-medium text-zinc-500 dark:text-zinc-400">Input Tokens</th>
<th class="px-4 py-3 text-right font-medium text-zinc-500 dark:text-zinc-400">Output Tokens</th>
<th class="px-4 py-3 text-right font-medium text-zinc-500 dark:text-zinc-400">Total Tokens</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach($usageHistory as $record)
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-white">
{{ $record->month_label }}
</td>
<td class="px-4 py-3 text-right text-zinc-600 dark:text-zinc-300">
{{ number_format($record->tool_calls_count) }}
</td>
<td class="px-4 py-3 text-right text-zinc-600 dark:text-zinc-300">
{{ number_format($record->input_tokens) }}
</td>
<td class="px-4 py-3 text-right text-zinc-600 dark:text-zinc-300">
{{ number_format($record->output_tokens) }}
</td>
<td class="px-4 py-3 text-right font-medium text-zinc-900 dark:text-white">
{{ number_format($record->total_tokens) }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
{{-- Upgrade Prompt (shown when near limit) --}}
@if(($this->toolCallsPercentage >= 80 || $this->tokensPercentage >= 80) && !($quotaLimits['tool_calls_unlimited'] ?? false))
<div class="p-4 bg-amber-50 border border-amber-200 rounded-xl dark:bg-amber-900/20 dark:border-amber-800">
<div class="flex items-start gap-3">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 mt-0.5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div>
<h4 class="font-medium text-amber-800 dark:text-amber-200">Approaching usage limit</h4>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-300">
You're nearing your monthly MCP quota. Consider upgrading your plan for higher limits.
</p>
</div>
</div>
</div>
@endif
</div>

View file

@ -0,0 +1,153 @@
<div>
<div class="mb-8">
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">{{ __('mcp::mcp.logs.title') }}</h1>
<p class="mt-2 text-lg text-zinc-600 dark:text-zinc-400">
{{ __('mcp::mcp.logs.description') }}
</p>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-4 mb-6">
<div class="flex flex-wrap gap-4">
<div>
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">{{ __('mcp::mcp.logs.filters.server') }}</label>
<select
wire:model.live="serverFilter"
class="px-3 py-1.5 bg-zinc-50 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-600 rounded-lg text-sm"
>
<option value="">{{ __('mcp::mcp.logs.filters.all_servers') }}</option>
@foreach($servers as $server)
<option value="{{ $server }}">{{ $server }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">{{ __('mcp::mcp.logs.filters.status') }}</label>
<select
wire:model.live="statusFilter"
class="px-3 py-1.5 bg-zinc-50 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-600 rounded-lg text-sm"
>
<option value="">{{ __('mcp::mcp.logs.filters.all') }}</option>
<option value="success">{{ __('mcp::mcp.logs.filters.success') }}</option>
<option value="failed">{{ __('mcp::mcp.logs.filters.failed') }}</option>
</select>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Request List -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 overflow-hidden">
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse($requests as $request)
<button
wire:click="selectRequest({{ $request->id }})"
class="w-full text-left p-4 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors {{ $selectedRequestId === $request->id ? 'bg-cyan-50 dark:bg-cyan-900/20' : '' }}"
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center space-x-2">
<span class="text-xs font-mono px-1.5 py-0.5 rounded {{ $request->isSuccessful() ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }}">
{{ $request->response_status }}
</span>
<span class="text-sm font-medium text-zinc-900 dark:text-white">
{{ $request->server_id }}/{{ $request->tool_name }}
</span>
</div>
<span class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $request->duration_for_humans }}
</span>
</div>
<div class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $request->created_at->diffForHumans() }}
<span class="mx-1">&middot;</span>
{{ $request->request_id }}
</div>
</button>
@empty
<div class="p-8 text-center text-zinc-500 dark:text-zinc-400">
{{ __('mcp::mcp.logs.empty') }}
</div>
@endforelse
</div>
@if($requests->hasPages())
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
{{ $requests->links() }}
</div>
@endif
</div>
<!-- Request Detail -->
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-6">
@if($selectedRequest)
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">{{ __('mcp::mcp.logs.detail.title') }}</h2>
<button
wire:click="closeDetail"
class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
>
<core:icon.x-mark class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<!-- Status -->
<div>
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">{{ __('mcp::mcp.logs.detail.status') }}</label>
<span class="inline-flex items-center px-2 py-1 rounded text-sm font-medium {{ $selectedRequest->isSuccessful() ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }}">
{{ $selectedRequest->response_status }}
{{ $selectedRequest->isSuccessful() ? __('mcp::mcp.logs.status_ok') : __('mcp::mcp.logs.status_error') }}
</span>
</div>
<!-- Request Body -->
<div>
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">{{ __('mcp::mcp.logs.detail.request') }}</label>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-3 text-xs overflow-x-auto">{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}</pre>
</div>
<!-- Response Body -->
<div>
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">{{ __('mcp::mcp.logs.detail.response') }}</label>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-3 text-xs overflow-x-auto max-h-48">{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}</pre>
</div>
@if($selectedRequest->error_message)
<div>
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">{{ __('mcp::mcp.logs.detail.error') }}</label>
<pre class="bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg p-3 text-xs">{{ $selectedRequest->error_message }}</pre>
</div>
@endif
<!-- Curl Command -->
<div x-data="{ copied: false }">
<label class="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">
{{ __('mcp::mcp.logs.detail.replay_command') }}
<button
x-on:click="navigator.clipboard.writeText($refs.curl.textContent); copied = true; setTimeout(() => copied = false, 2000)"
class="ml-2 text-cyan-600 hover:text-cyan-700"
>
<span x-show="!copied">{{ __('mcp::mcp.logs.detail.copy') }}</span>
<span x-show="copied" x-cloak>{{ __('mcp::mcp.logs.detail.copied') }}</span>
</button>
</label>
<pre x-ref="curl" class="bg-zinc-900 dark:bg-zinc-950 text-emerald-400 rounded-lg p-3 text-xs overflow-x-auto">{{ $selectedRequest->toCurl() }}</pre>
</div>
<!-- Metadata -->
<div class="pt-4 border-t border-zinc-200 dark:border-zinc-700 text-xs text-zinc-500 dark:text-zinc-400 space-y-1">
<div>{{ __('mcp::mcp.logs.detail.metadata.request_id') }}: {{ $selectedRequest->request_id }}</div>
<div>{{ __('mcp::mcp.logs.detail.metadata.duration') }}: {{ $selectedRequest->duration_for_humans }}</div>
<div>{{ __('mcp::mcp.logs.detail.metadata.ip') }}: {{ $selectedRequest->ip_address ?? __('mcp::mcp.common.na') }}</div>
<div>{{ __('mcp::mcp.logs.detail.metadata.time') }}: {{ $selectedRequest->created_at->format('Y-m-d H:i:s') }}</div>
</div>
</div>
@else
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<core:icon.document-text class="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{{ __('mcp::mcp.logs.empty_detail') }}</p>
</div>
@endif
</div>
</div>
</div>

View file

@ -0,0 +1,537 @@
{{--
MCP Tool Version Manager.
Admin interface for managing tool version lifecycles,
viewing schema changes between versions, and setting deprecation schedules.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<core:heading size="xl">{{ __('Tool Versions') }}</core:heading>
<core:subheading>Manage MCP tool version lifecycles and backwards compatibility</core:subheading>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="openRegisterModal" icon="plus">
Register Version
</flux:button>
</div>
</div>
{{-- Stats Cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Total Versions</div>
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
{{ number_format($this->stats['total_versions']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Unique Tools</div>
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
{{ number_format($this->stats['total_tools']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Servers</div>
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
{{ number_format($this->stats['servers']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Deprecated</div>
<div class="mt-1 text-2xl font-semibold text-amber-600 dark:text-amber-400">
{{ number_format($this->stats['deprecated_count']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sunset</div>
<div class="mt-1 text-2xl font-semibold text-red-600 dark:text-red-400">
{{ number_format($this->stats['sunset_count']) }}
</div>
</div>
</div>
{{-- Filters --}}
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search tools, servers, versions..." icon="magnifying-glass" />
</div>
<flux:select wire:model.live="server" placeholder="All servers">
<flux:select.option value="">All servers</flux:select.option>
@foreach ($this->servers as $serverId)
<flux:select.option value="{{ $serverId }}">{{ $serverId }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model.live="status" placeholder="All statuses">
<flux:select.option value="">All statuses</flux:select.option>
<flux:select.option value="latest">Latest</flux:select.option>
<flux:select.option value="active">Active (non-latest)</flux:select.option>
<flux:select.option value="deprecated">Deprecated</flux:select.option>
<flux:select.option value="sunset">Sunset</flux:select.option>
</flux:select>
@if($search || $server || $status)
<flux:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">Clear</flux:button>
@endif
</div>
{{-- Versions Table --}}
<flux:table>
<flux:table.columns>
<flux:table.column>Tool</flux:table.column>
<flux:table.column>Server</flux:table.column>
<flux:table.column>Version</flux:table.column>
<flux:table.column>Status</flux:table.column>
<flux:table.column>Deprecated</flux:table.column>
<flux:table.column>Sunset</flux:table.column>
<flux:table.column>Created</flux:table.column>
<flux:table.column></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($this->versions as $version)
<flux:table.row wire:key="version-{{ $version->id }}">
<flux:table.cell>
<div class="font-medium text-zinc-900 dark:text-white">{{ $version->tool_name }}</div>
@if($version->description)
<div class="text-xs text-zinc-500 truncate max-w-xs">{{ $version->description }}</div>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-600 dark:text-zinc-400">
{{ $version->server_id }}
</flux:table.cell>
<flux:table.cell>
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $version->version }}
</code>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="$this->getStatusBadgeColor($version->status)">
{{ ucfirst($version->status) }}
</flux:badge>
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500">
@if($version->deprecated_at)
{{ $version->deprecated_at->format('M j, Y') }}
@else
<span class="text-zinc-400">-</span>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500">
@if($version->sunset_at)
<span class="{{ $version->is_sunset ? 'text-red-600 dark:text-red-400' : '' }}">
{{ $version->sunset_at->format('M j, Y') }}
</span>
@else
<span class="text-zinc-400">-</span>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500">
{{ $version->created_at->format('M j, Y') }}
</flux:table.cell>
<flux:table.cell>
<flux:dropdown>
<flux:button variant="ghost" size="xs" icon="ellipsis-horizontal" />
<flux:menu>
<flux:menu.item wire:click="viewVersion({{ $version->id }})" icon="eye">
View Details
</flux:menu.item>
@if(!$version->is_latest && !$version->is_sunset)
<flux:menu.item wire:click="markAsLatest({{ $version->id }})" icon="star">
Mark as Latest
</flux:menu.item>
@endif
@if(!$version->is_deprecated && !$version->is_sunset)
<flux:menu.item wire:click="openDeprecateModal({{ $version->id }})" icon="archive-box">
Deprecate
</flux:menu.item>
@endif
</flux:menu>
</flux:dropdown>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="flex flex-col items-center py-12">
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="cube" class="size-8 text-zinc-400" />
</div>
<flux:heading size="lg">No tool versions found</flux:heading>
<flux:subheading class="mt-1">Register tool versions to enable backwards compatibility.</flux:subheading>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if($this->versions->hasPages())
<div class="border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
{{ $this->versions->links() }}
</div>
@endif
{{-- Version Detail Modal --}}
@if($showVersionDetail && $this->selectedVersion)
<flux:modal wire:model="showVersionDetail" name="version-detail" class="max-w-4xl">
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<flux:heading size="lg">{{ $this->selectedVersion->tool_name }}</flux:heading>
<div class="flex items-center gap-2 mt-1">
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $this->selectedVersion->version }}
</code>
<flux:badge size="sm" :color="$this->getStatusBadgeColor($this->selectedVersion->status)">
{{ ucfirst($this->selectedVersion->status) }}
</flux:badge>
</div>
</div>
<flux:button wire:click="closeVersionDetail" variant="ghost" icon="x-mark" />
</div>
{{-- Metadata --}}
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Server</div>
<div class="mt-1">{{ $this->selectedVersion->server_id }}</div>
</div>
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Created</div>
<div class="mt-1">{{ $this->selectedVersion->created_at->format('Y-m-d H:i:s') }}</div>
</div>
@if($this->selectedVersion->deprecated_at)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Deprecated</div>
<div class="mt-1 text-amber-600 dark:text-amber-400">
{{ $this->selectedVersion->deprecated_at->format('Y-m-d') }}
</div>
</div>
@endif
@if($this->selectedVersion->sunset_at)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sunset</div>
<div class="mt-1 {{ $this->selectedVersion->is_sunset ? 'text-red-600 dark:text-red-400' : 'text-zinc-600' }}">
{{ $this->selectedVersion->sunset_at->format('Y-m-d') }}
</div>
</div>
@endif
</div>
@if($this->selectedVersion->description)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Description</div>
<div class="mt-1">{{ $this->selectedVersion->description }}</div>
</div>
@endif
@if($this->selectedVersion->changelog)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Changelog</div>
<div class="mt-1 prose prose-sm dark:prose-invert">
{!! nl2br(e($this->selectedVersion->changelog)) !!}
</div>
</div>
@endif
@if($this->selectedVersion->migration_notes)
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<flux:icon name="arrow-path" class="size-5" />
<span class="font-medium">Migration Notes</span>
</div>
<div class="mt-2 text-sm text-blue-600 dark:text-blue-400">
{!! nl2br(e($this->selectedVersion->migration_notes)) !!}
</div>
</div>
@endif
{{-- Input Schema --}}
@if($this->selectedVersion->input_schema)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Input Schema</div>
<pre class="overflow-auto rounded-lg bg-zinc-100 p-4 text-xs dark:bg-zinc-800 max-h-60">{{ $this->formatSchema($this->selectedVersion->input_schema) }}</pre>
</div>
@endif
{{-- Output Schema --}}
@if($this->selectedVersion->output_schema)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Output Schema</div>
<pre class="overflow-auto rounded-lg bg-zinc-100 p-4 text-xs dark:bg-zinc-800 max-h-60">{{ $this->formatSchema($this->selectedVersion->output_schema) }}</pre>
</div>
@endif
{{-- Version History --}}
@if($this->versionHistory->count() > 1)
<div class="border-t border-zinc-200 pt-4 dark:border-zinc-700">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-3">Version History</div>
<div class="space-y-2">
@foreach($this->versionHistory as $index => $historyVersion)
<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-3 dark:border-zinc-700 {{ $historyVersion->id === $this->selectedVersion->id ? 'bg-zinc-50 dark:bg-zinc-800/50' : '' }}">
<div class="flex items-center gap-3">
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $historyVersion->version }}
</code>
<flux:badge size="sm" :color="$this->getStatusBadgeColor($historyVersion->status)">
{{ ucfirst($historyVersion->status) }}
</flux:badge>
<span class="text-xs text-zinc-500">
{{ $historyVersion->created_at->format('M j, Y') }}
</span>
</div>
@if($historyVersion->id !== $this->selectedVersion->id && $index < $this->versionHistory->count() - 1)
@php $nextVersion = $this->versionHistory[$index + 1] @endphp
<flux:button
wire:click="openCompareModal({{ $nextVersion->id }}, {{ $historyVersion->id }})"
variant="ghost"
size="xs"
icon="arrows-right-left"
>
Compare
</flux:button>
@endif
</div>
@endforeach
</div>
</div>
@endif
</div>
</flux:modal>
@endif
{{-- Compare Schemas Modal --}}
@if($showCompareModal && $this->schemaComparison)
<flux:modal wire:model="showCompareModal" name="compare-modal" class="max-w-4xl">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Schema Comparison</flux:heading>
<flux:button wire:click="closeCompareModal" variant="ghost" icon="x-mark" />
</div>
<div class="flex items-center justify-center gap-4">
<div class="text-center">
<code class="rounded bg-zinc-100 px-3 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $this->schemaComparison['from']->version }}
</code>
</div>
<flux:icon name="arrow-right" class="size-5 text-zinc-400" />
<div class="text-center">
<code class="rounded bg-zinc-100 px-3 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $this->schemaComparison['to']->version }}
</code>
</div>
</div>
@php $changes = $this->schemaComparison['changes'] @endphp
@if(empty($changes['added']) && empty($changes['removed']) && empty($changes['changed']))
<div class="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
<div class="flex items-center gap-2 text-green-700 dark:text-green-300">
<flux:icon name="check-circle" class="size-5" />
<span>No schema changes between versions</span>
</div>
</div>
@else
<div class="space-y-4">
@if(!empty($changes['added']))
<div class="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
<div class="font-medium text-green-700 dark:text-green-300 mb-2">
Added Properties ({{ count($changes['added']) }})
</div>
<ul class="list-disc list-inside text-sm text-green-600 dark:text-green-400">
@foreach($changes['added'] as $prop)
<li><code>{{ $prop }}</code></li>
@endforeach
</ul>
</div>
@endif
@if(!empty($changes['removed']))
<div class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<div class="font-medium text-red-700 dark:text-red-300 mb-2">
Removed Properties ({{ count($changes['removed']) }})
</div>
<ul class="list-disc list-inside text-sm text-red-600 dark:text-red-400">
@foreach($changes['removed'] as $prop)
<li><code>{{ $prop }}</code></li>
@endforeach
</ul>
</div>
@endif
@if(!empty($changes['changed']))
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<div class="font-medium text-amber-700 dark:text-amber-300 mb-2">
Changed Properties ({{ count($changes['changed']) }})
</div>
<div class="space-y-3">
@foreach($changes['changed'] as $prop => $change)
<div class="text-sm">
<code class="font-medium text-amber-700 dark:text-amber-300">{{ $prop }}</code>
<div class="mt-1 grid grid-cols-2 gap-2 text-xs">
<div class="rounded bg-red-100 p-2 dark:bg-red-900/30">
<div class="text-red-600 dark:text-red-400 mb-1">Before:</div>
<pre class="text-red-700 dark:text-red-300 overflow-auto">{{ json_encode($change['from'], JSON_PRETTY_PRINT) }}</pre>
</div>
<div class="rounded bg-green-100 p-2 dark:bg-green-900/30">
<div class="text-green-600 dark:text-green-400 mb-1">After:</div>
<pre class="text-green-700 dark:text-green-300 overflow-auto">{{ json_encode($change['to'], JSON_PRETTY_PRINT) }}</pre>
</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>
@endif
<div class="flex justify-end">
<flux:button wire:click="closeCompareModal" variant="primary">Close</flux:button>
</div>
</div>
</flux:modal>
@endif
{{-- Deprecate Modal --}}
@if($showDeprecateModal)
@php $deprecateVersion = \Core\Mod\Mcp\Models\McpToolVersion::find($deprecateVersionId) @endphp
@if($deprecateVersion)
<flux:modal wire:model="showDeprecateModal" name="deprecate-modal" class="max-w-md">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Deprecate Version</flux:heading>
<flux:button wire:click="closeDeprecateModal" variant="ghost" icon="x-mark" />
</div>
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
<flux:icon name="exclamation-triangle" class="size-5" />
<span class="font-medium">{{ $deprecateVersion->tool_name }} v{{ $deprecateVersion->version }}</span>
</div>
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
Deprecated versions will show warnings to agents but remain usable until sunset.
</p>
</div>
<div>
<flux:label>Sunset Date (optional)</flux:label>
<flux:input
type="date"
wire:model="deprecateSunsetDate"
:min="now()->addDay()->format('Y-m-d')"
/>
<flux:description class="mt-1">
After this date, the version will be blocked and return errors.
</flux:description>
</div>
<div class="flex justify-end gap-2">
<flux:button wire:click="closeDeprecateModal" variant="ghost">Cancel</flux:button>
<flux:button wire:click="deprecateVersion" variant="primary" color="amber">
Deprecate Version
</flux:button>
</div>
</div>
</flux:modal>
@endif
@endif
{{-- Register Version Modal --}}
@if($showRegisterModal)
<flux:modal wire:model="showRegisterModal" name="register-modal" class="max-w-2xl">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Register Tool Version</flux:heading>
<flux:button wire:click="closeRegisterModal" variant="ghost" icon="x-mark" />
</div>
<form wire:submit="registerVersion" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<flux:label>Server ID</flux:label>
<flux:input
wire:model="registerServer"
placeholder="e.g., hub-agent"
required
/>
@error('registerServer') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div>
<flux:label>Tool Name</flux:label>
<flux:input
wire:model="registerTool"
placeholder="e.g., query_database"
required
/>
@error('registerTool') <flux:error>{{ $message }}</flux:error> @enderror
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<flux:label>Version (semver)</flux:label>
<flux:input
wire:model="registerVersion"
placeholder="1.0.0"
required
/>
@error('registerVersion') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div class="flex items-end">
<flux:checkbox wire:model="registerMarkLatest" label="Mark as latest version" />
</div>
</div>
<div>
<flux:label>Description</flux:label>
<flux:input
wire:model="registerDescription"
placeholder="Brief description of the tool"
/>
@error('registerDescription') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div>
<flux:label>Changelog</flux:label>
<flux:textarea
wire:model="registerChangelog"
placeholder="What changed in this version..."
rows="3"
/>
@error('registerChangelog') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div>
<flux:label>Migration Notes</flux:label>
<flux:textarea
wire:model="registerMigrationNotes"
placeholder="Guidance for upgrading from previous version..."
rows="3"
/>
@error('registerMigrationNotes') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div>
<flux:label>Input Schema (JSON)</flux:label>
<flux:textarea
wire:model="registerInputSchema"
placeholder='{"type": "object", "properties": {...}}'
rows="6"
class="font-mono text-sm"
/>
@error('registerInputSchema') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
<flux:button type="button" wire:click="closeRegisterModal" variant="ghost">Cancel</flux:button>
<flux:button type="submit" variant="primary">Register Version</flux:button>
</div>
</form>
</div>
</flux:modal>
@endif
</div>

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Api\Models\ApiKey;
use Core\Mod\Tenant\Models\Workspace;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* MCP API Key Manager.
*
* Allows workspace owners to create and manage API keys
* for accessing MCP servers via HTTP API.
*/
#[Layout('hub::admin.layouts.app')]
class ApiKeyManager extends Component
{
public Workspace $workspace;
// Create form state
public bool $showCreateModal = false;
public string $newKeyName = '';
public array $newKeyScopes = ['read', 'write'];
public string $newKeyExpiry = 'never';
// Show new key (only visible once after creation)
public ?string $newPlainKey = null;
public bool $showNewKeyModal = false;
public function mount(Workspace $workspace): void
{
$this->workspace = $workspace;
}
public function openCreateModal(): void
{
$this->showCreateModal = true;
$this->newKeyName = '';
$this->newKeyScopes = ['read', 'write'];
$this->newKeyExpiry = 'never';
}
public function closeCreateModal(): void
{
$this->showCreateModal = false;
}
public function createKey(): void
{
$this->validate([
'newKeyName' => 'required|string|max:100',
]);
$expiresAt = match ($this->newKeyExpiry) {
'30days' => now()->addDays(30),
'90days' => now()->addDays(90),
'1year' => now()->addYear(),
default => null,
};
$result = ApiKey::generate(
workspaceId: $this->workspace->id,
userId: auth()->id(),
name: $this->newKeyName,
scopes: $this->newKeyScopes,
expiresAt: $expiresAt,
);
$this->newPlainKey = $result['plain_key'];
$this->showCreateModal = false;
$this->showNewKeyModal = true;
session()->flash('message', 'API key created successfully.');
}
public function closeNewKeyModal(): void
{
$this->newPlainKey = null;
$this->showNewKeyModal = false;
}
public function revokeKey(int $keyId): void
{
$key = $this->workspace->apiKeys()->findOrFail($keyId);
$key->revoke();
session()->flash('message', 'API key revoked.');
}
public function toggleScope(string $scope): void
{
if (in_array($scope, $this->newKeyScopes)) {
$this->newKeyScopes = array_values(array_diff($this->newKeyScopes, [$scope]));
} else {
$this->newKeyScopes[] = $scope;
}
}
public function render()
{
return view('mcp::admin.api-key-manager', [
'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(),
]);
}
}

View file

@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Mcp\Models\McpAuditLog;
use Core\Mod\Mcp\Services\AuditLogService;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* MCP Audit Log Viewer.
*
* Admin interface for viewing and exporting immutable tool execution logs.
* Includes integrity verification and compliance export features.
*/
#[Title('MCP Audit Log')]
#[Layout('hub::admin.layouts.app')]
class AuditLogViewer extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $tool = '';
#[Url]
public string $workspace = '';
#[Url]
public string $status = '';
#[Url]
public string $sensitivity = '';
#[Url]
public string $dateFrom = '';
#[Url]
public string $dateTo = '';
public int $perPage = 25;
public ?int $selectedEntryId = null;
public ?array $integrityStatus = null;
public bool $showIntegrityModal = false;
public bool $showExportModal = false;
public string $exportFormat = 'json';
public function mount(): void
{
$this->checkHadesAccess();
}
#[Computed]
public function entries(): LengthAwarePaginator
{
$query = McpAuditLog::query()
->with('workspace')
->orderByDesc('id');
if ($this->search) {
$query->where(function ($q) {
$q->where('tool_name', 'like', "%{$this->search}%")
->orWhere('server_id', 'like', "%{$this->search}%")
->orWhere('session_id', 'like', "%{$this->search}%")
->orWhere('error_message', 'like', "%{$this->search}%");
});
}
if ($this->tool) {
$query->where('tool_name', $this->tool);
}
if ($this->workspace) {
$query->where('workspace_id', $this->workspace);
}
if ($this->status === 'success') {
$query->where('success', true);
} elseif ($this->status === 'failed') {
$query->where('success', false);
}
if ($this->sensitivity === 'sensitive') {
$query->where('is_sensitive', true);
} elseif ($this->sensitivity === 'normal') {
$query->where('is_sensitive', false);
}
if ($this->dateFrom) {
$query->where('created_at', '>=', Carbon::parse($this->dateFrom)->startOfDay());
}
if ($this->dateTo) {
$query->where('created_at', '<=', Carbon::parse($this->dateTo)->endOfDay());
}
return $query->paginate($this->perPage);
}
#[Computed]
public function workspaces(): Collection
{
return Workspace::orderBy('name')->get(['id', 'name']);
}
#[Computed]
public function tools(): Collection
{
return McpAuditLog::query()
->select('tool_name')
->distinct()
->orderBy('tool_name')
->pluck('tool_name');
}
#[Computed]
public function selectedEntry(): ?McpAuditLog
{
if (! $this->selectedEntryId) {
return null;
}
return McpAuditLog::with('workspace')->find($this->selectedEntryId);
}
#[Computed]
public function stats(): array
{
return app(AuditLogService::class)->getStats(
workspaceId: $this->workspace ? (int) $this->workspace : null,
days: 30
);
}
public function viewEntry(int $id): void
{
$this->selectedEntryId = $id;
}
public function closeEntryDetail(): void
{
$this->selectedEntryId = null;
}
public function verifyIntegrity(): void
{
$this->integrityStatus = app(AuditLogService::class)->verifyChain();
$this->showIntegrityModal = true;
}
public function closeIntegrityModal(): void
{
$this->showIntegrityModal = false;
$this->integrityStatus = null;
}
public function openExportModal(): void
{
$this->showExportModal = true;
}
public function closeExportModal(): void
{
$this->showExportModal = false;
}
public function export(): StreamedResponse
{
$auditLogService = app(AuditLogService::class);
$workspaceId = $this->workspace ? (int) $this->workspace : null;
$from = $this->dateFrom ? Carbon::parse($this->dateFrom) : null;
$to = $this->dateTo ? Carbon::parse($this->dateTo) : null;
$tool = $this->tool ?: null;
$sensitiveOnly = $this->sensitivity === 'sensitive';
if ($this->exportFormat === 'csv') {
$content = $auditLogService->exportToCsv($workspaceId, $from, $to, $tool, $sensitiveOnly);
$filename = 'mcp-audit-log-'.now()->format('Y-m-d-His').'.csv';
$contentType = 'text/csv';
} else {
$content = $auditLogService->exportToJson($workspaceId, $from, $to, $tool, $sensitiveOnly);
$filename = 'mcp-audit-log-'.now()->format('Y-m-d-His').'.json';
$contentType = 'application/json';
}
return response()->streamDownload(function () use ($content) {
echo $content;
}, $filename, [
'Content-Type' => $contentType,
]);
}
public function clearFilters(): void
{
$this->search = '';
$this->tool = '';
$this->workspace = '';
$this->status = '';
$this->sensitivity = '';
$this->dateFrom = '';
$this->dateTo = '';
$this->resetPage();
}
public function getStatusBadgeClass(bool $success): string
{
return $success
? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300';
}
public function getSensitivityBadgeClass(bool $isSensitive): string
{
return $isSensitive
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300';
}
private function checkHadesAccess(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades access required');
}
}
public function render()
{
return view('mcp::admin.audit-log-viewer');
}
}

View file

@ -0,0 +1,539 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Api\Models\ApiKey;
use Core\Mod\Mcp\Services\ToolRegistry;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* MCP Playground - Interactive tool testing interface.
*
* Provides a comprehensive UI for testing MCP tools with:
* - Tool browser with search and category filtering
* - Dynamic input form generation from JSON schemas
* - Response viewer with syntax highlighting
* - Session-based conversation history (last 50 executions)
* - Example inputs per tool
*/
#[Layout('hub::admin.layouts.app')]
class McpPlayground extends Component
{
/**
* Currently selected MCP server ID.
*/
public string $selectedServer = '';
/**
* Currently selected tool name.
*/
public ?string $selectedTool = null;
/**
* Tool input parameters (key-value pairs).
*/
public array $toolInput = [];
/**
* Last response from tool execution.
*/
public ?array $lastResponse = null;
/**
* Conversation/execution history from session.
*/
public array $conversationHistory = [];
/**
* Search query for filtering tools.
*/
public string $searchQuery = '';
/**
* Selected category for filtering tools.
*/
public string $selectedCategory = '';
/**
* API key for authentication.
*/
public string $apiKey = '';
/**
* API key validation status.
*/
public ?string $keyStatus = null;
/**
* Validated API key info.
*/
public ?array $keyInfo = null;
/**
* Error message for display.
*/
public ?string $error = null;
/**
* Whether a request is currently executing.
*/
public bool $isExecuting = false;
/**
* Last execution duration in milliseconds.
*/
public int $executionTime = 0;
/**
* Session key for conversation history.
*/
protected const HISTORY_SESSION_KEY = 'mcp_playground_history';
/**
* Maximum history entries to keep.
*/
protected const MAX_HISTORY_ENTRIES = 50;
public function mount(): void
{
$this->loadConversationHistory();
// Auto-select first server if available
$servers = $this->getServers();
if ($servers->isNotEmpty()) {
$this->selectedServer = $servers->first()['id'];
}
}
/**
* Handle server selection change.
*/
public function updatedSelectedServer(): void
{
$this->selectedTool = null;
$this->toolInput = [];
$this->lastResponse = null;
$this->error = null;
$this->searchQuery = '';
$this->selectedCategory = '';
}
/**
* Handle tool selection change.
*/
public function updatedSelectedTool(): void
{
$this->toolInput = [];
$this->lastResponse = null;
$this->error = null;
if ($this->selectedTool) {
$this->loadExampleInputs();
}
}
/**
* Handle API key change.
*/
public function updatedApiKey(): void
{
$this->keyStatus = null;
$this->keyInfo = null;
}
/**
* Validate the API key.
*/
public function validateKey(): void
{
$this->keyStatus = null;
$this->keyInfo = null;
if (empty($this->apiKey)) {
$this->keyStatus = 'empty';
return;
}
$key = ApiKey::findByPlainKey($this->apiKey);
if (! $key) {
$this->keyStatus = 'invalid';
return;
}
if ($key->isExpired()) {
$this->keyStatus = 'expired';
return;
}
$this->keyStatus = 'valid';
$this->keyInfo = [
'name' => $key->name,
'scopes' => $key->scopes ?? [],
'workspace' => $key->workspace?->name ?? 'Unknown',
'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never',
];
}
/**
* Select a tool by name.
*/
public function selectTool(string $toolName): void
{
$this->selectedTool = $toolName;
$this->updatedSelectedTool();
}
/**
* Load example inputs for the selected tool.
*/
public function loadExampleInputs(): void
{
if (! $this->selectedTool) {
return;
}
$tool = $this->getRegistry()->getTool($this->selectedServer, $this->selectedTool);
if (! $tool) {
return;
}
// Load example inputs
$examples = $tool['examples'] ?? [];
// Also populate from schema defaults if no examples
if (empty($examples) && isset($tool['inputSchema']['properties'])) {
foreach ($tool['inputSchema']['properties'] as $name => $schema) {
if (isset($schema['default'])) {
$examples[$name] = $schema['default'];
}
}
}
$this->toolInput = $examples;
}
/**
* Execute the selected tool.
*/
public function execute(): void
{
if (! $this->selectedServer || ! $this->selectedTool) {
$this->error = 'Please select a server and tool.';
return;
}
// Rate limiting: 10 executions per minute
$rateLimitKey = 'mcp-playground:'.$this->getRateLimitKey();
if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) {
$this->error = 'Too many requests. Please wait before trying again.';
return;
}
RateLimiter::hit($rateLimitKey, 60);
$this->isExecuting = true;
$this->lastResponse = null;
$this->error = null;
try {
$startTime = microtime(true);
// Filter empty values from input
$args = array_filter($this->toolInput, fn ($v) => $v !== '' && $v !== null);
// Type conversion for arguments
$args = $this->convertArgumentTypes($args);
// Execute the tool
if ($this->keyStatus === 'valid') {
$result = $this->executeViaApi($args);
} else {
$result = $this->generateRequestPreview($args);
}
$this->executionTime = (int) round((microtime(true) - $startTime) * 1000);
$this->lastResponse = $result;
// Add to conversation history
$this->addToHistory([
'server' => $this->selectedServer,
'tool' => $this->selectedTool,
'input' => $args,
'output' => $result,
'success' => ! isset($result['error']),
'duration_ms' => $this->executionTime,
'timestamp' => now()->toIso8601String(),
]);
} catch (\Throwable $e) {
$this->error = $e->getMessage();
$this->lastResponse = ['error' => $e->getMessage()];
} finally {
$this->isExecuting = false;
}
}
/**
* Re-run a historical execution.
*/
public function rerunFromHistory(int $index): void
{
if (! isset($this->conversationHistory[$index])) {
return;
}
$entry = $this->conversationHistory[$index];
$this->selectedServer = $entry['server'];
$this->selectedTool = $entry['tool'];
$this->toolInput = $entry['input'] ?? [];
$this->execute();
}
/**
* View a historical execution result.
*/
public function viewFromHistory(int $index): void
{
if (! isset($this->conversationHistory[$index])) {
return;
}
$entry = $this->conversationHistory[$index];
$this->selectedServer = $entry['server'];
$this->selectedTool = $entry['tool'];
$this->toolInput = $entry['input'] ?? [];
$this->lastResponse = $entry['output'] ?? null;
$this->executionTime = $entry['duration_ms'] ?? 0;
}
/**
* Clear conversation history.
*/
public function clearHistory(): void
{
$this->conversationHistory = [];
Session::forget(self::HISTORY_SESSION_KEY);
}
/**
* Get available servers.
*/
#[Computed]
public function getServers(): \Illuminate\Support\Collection
{
return $this->getRegistry()->getServers();
}
/**
* Get tools for the selected server.
*/
#[Computed]
public function getTools(): \Illuminate\Support\Collection
{
if (empty($this->selectedServer)) {
return collect();
}
$tools = $this->getRegistry()->getToolsForServer($this->selectedServer);
// Apply search filter
if (! empty($this->searchQuery)) {
$query = strtolower($this->searchQuery);
$tools = $tools->filter(function ($tool) use ($query) {
return str_contains(strtolower($tool['name']), $query)
|| str_contains(strtolower($tool['description']), $query);
});
}
// Apply category filter
if (! empty($this->selectedCategory)) {
$tools = $tools->filter(fn ($tool) => $tool['category'] === $this->selectedCategory);
}
return $tools->values();
}
/**
* Get tools grouped by category.
*/
#[Computed]
public function getToolsByCategory(): \Illuminate\Support\Collection
{
return $this->getTools()->groupBy('category')->sortKeys();
}
/**
* Get available categories.
*/
#[Computed]
public function getCategories(): \Illuminate\Support\Collection
{
if (empty($this->selectedServer)) {
return collect();
}
return $this->getRegistry()
->getToolsForServer($this->selectedServer)
->pluck('category')
->unique()
->sort()
->values();
}
/**
* Get the current tool schema.
*/
#[Computed]
public function getCurrentTool(): ?array
{
if (! $this->selectedTool) {
return null;
}
return $this->getRegistry()->getTool($this->selectedServer, $this->selectedTool);
}
/**
* Check if user is authenticated.
*/
public function isAuthenticated(): bool
{
return auth()->check();
}
public function render()
{
return view('mcp::admin.mcp-playground', [
'servers' => $this->getServers(),
'tools' => $this->getTools(),
'toolsByCategory' => $this->getToolsByCategory(),
'categories' => $this->getCategories(),
'currentTool' => $this->getCurrentTool(),
'isAuthenticated' => $this->isAuthenticated(),
]);
}
/**
* Get the tool registry service.
*/
protected function getRegistry(): ToolRegistry
{
return app(ToolRegistry::class);
}
/**
* Get rate limit key based on user or IP.
*/
protected function getRateLimitKey(): string
{
if (auth()->check()) {
return 'user:'.auth()->id();
}
return 'ip:'.request()->ip();
}
/**
* Convert argument types based on their values.
*/
protected function convertArgumentTypes(array $args): array
{
foreach ($args as $key => $value) {
if (is_numeric($value)) {
$args[$key] = str_contains((string) $value, '.') ? (float) $value : (int) $value;
}
if ($value === 'true') {
$args[$key] = true;
}
if ($value === 'false') {
$args[$key] = false;
}
}
return $args;
}
/**
* Execute tool via HTTP API.
*/
protected function executeViaApi(array $args): array
{
$payload = [
'server' => $this->selectedServer,
'tool' => $this->selectedTool,
'arguments' => $args,
];
$response = Http::withToken($this->apiKey)
->timeout(30)
->post(config('app.url').'/api/v1/mcp/tools/call', $payload);
return [
'status' => $response->status(),
'response' => $response->json(),
'executed' => true,
];
}
/**
* Generate a request preview without executing.
*/
protected function generateRequestPreview(array $args): array
{
$payload = [
'server' => $this->selectedServer,
'tool' => $this->selectedTool,
'arguments' => $args,
];
return [
'request' => $payload,
'note' => 'Add a valid API key to execute this request live.',
'curl' => sprintf(
"curl -X POST %s/api/v1/mcp/tools/call \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '%s'",
config('app.url'),
json_encode($payload, JSON_UNESCAPED_SLASHES)
),
'executed' => false,
];
}
/**
* Load conversation history from session.
*/
protected function loadConversationHistory(): void
{
$this->conversationHistory = Session::get(self::HISTORY_SESSION_KEY, []);
}
/**
* Add an entry to conversation history.
*/
protected function addToHistory(array $entry): void
{
// Prepend new entry
array_unshift($this->conversationHistory, $entry);
// Keep only last N entries
$this->conversationHistory = array_slice($this->conversationHistory, 0, self::MAX_HISTORY_ENTRIES);
// Save to session
Session::put(self::HISTORY_SESSION_KEY, $this->conversationHistory);
}
}

View file

@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Api\Models\ApiKey;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
/**
* MCP Playground - interactive tool testing in the browser.
*/
#[Layout('hub::admin.layouts.app')]
class Playground extends Component
{
public string $selectedServer = '';
public string $selectedTool = '';
public array $arguments = [];
public string $response = '';
public bool $loading = false;
public string $apiKey = '';
public ?string $error = null;
public ?string $keyStatus = null;
public ?array $keyInfo = null;
public array $servers = [];
public array $tools = [];
public ?array $toolSchema = null;
public function mount(): void
{
$this->loadServers();
}
public function loadServers(): void
{
try {
$registry = $this->loadRegistry();
$this->servers = collect($registry['servers'] ?? [])
->map(fn ($ref) => $this->loadServerSummary($ref['id']))
->filter()
->values()
->toArray();
} catch (\Throwable $e) {
$this->error = 'Failed to load servers';
$this->servers = [];
}
}
public function updatedSelectedServer(): void
{
$this->error = null;
$this->selectedTool = '';
$this->toolSchema = null;
$this->arguments = [];
$this->response = '';
if (! $this->selectedServer) {
$this->tools = [];
return;
}
try {
$server = $this->loadServerFull($this->selectedServer);
$this->tools = $server['tools'] ?? [];
} catch (\Throwable $e) {
$this->error = 'Failed to load server tools';
$this->tools = [];
}
}
public function updatedSelectedTool(): void
{
$this->error = null;
$this->arguments = [];
$this->response = '';
if (! $this->selectedTool) {
$this->toolSchema = null;
return;
}
try {
$this->toolSchema = collect($this->tools)->firstWhere('name', $this->selectedTool);
// Pre-fill arguments with defaults
$params = $this->toolSchema['inputSchema']['properties'] ?? [];
foreach ($params as $name => $schema) {
$this->arguments[$name] = $schema['default'] ?? '';
}
} catch (\Throwable $e) {
$this->error = 'Failed to load tool schema';
$this->toolSchema = null;
}
}
public function updatedApiKey(): void
{
// Clear key status when key changes
$this->keyStatus = null;
$this->keyInfo = null;
}
public function validateKey(): void
{
$this->keyStatus = null;
$this->keyInfo = null;
if (empty($this->apiKey)) {
$this->keyStatus = 'empty';
return;
}
$key = ApiKey::findByPlainKey($this->apiKey);
if (! $key) {
$this->keyStatus = 'invalid';
return;
}
if ($key->isExpired()) {
$this->keyStatus = 'expired';
return;
}
$this->keyStatus = 'valid';
$this->keyInfo = [
'name' => $key->name,
'scopes' => $key->scopes,
'server_scopes' => $key->getAllowedServers(),
'workspace' => $key->workspace?->name ?? 'Unknown',
'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never',
];
}
public function isAuthenticated(): bool
{
return auth()->check();
}
public function execute(): void
{
if (! $this->selectedServer || ! $this->selectedTool) {
return;
}
$this->loading = true;
$this->response = '';
$this->error = null;
try {
// Filter out empty arguments
$args = array_filter($this->arguments, fn ($v) => $v !== '' && $v !== null);
// Convert numeric strings to numbers where appropriate
foreach ($args as $key => $value) {
if (is_numeric($value)) {
$args[$key] = str_contains($value, '.') ? (float) $value : (int) $value;
}
if ($value === 'true') {
$args[$key] = true;
}
if ($value === 'false') {
$args[$key] = false;
}
}
$payload = [
'server' => $this->selectedServer,
'tool' => $this->selectedTool,
'arguments' => $args,
];
// If we have an API key, make a real request
if (! empty($this->apiKey) && $this->keyStatus === 'valid') {
$response = Http::withToken($this->apiKey)
->timeout(30)
->post(config('app.url').'/api/v1/mcp/tools/call', $payload);
$this->response = json_encode([
'status' => $response->status(),
'response' => $response->json(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return;
}
// Otherwise, just show request format
$this->response = json_encode([
'request' => $payload,
'note' => 'Add an API key above to execute this request live.',
'curl' => sprintf(
"curl -X POST %s/api/v1/mcp/tools/call \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '%s'",
config('app.url'),
json_encode($payload, JSON_UNESCAPED_SLASHES)
),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $e) {
$this->response = json_encode([
'error' => $e->getMessage(),
], JSON_PRETTY_PRINT);
} finally {
$this->loading = false;
}
}
public function render()
{
$isAuthenticated = $this->isAuthenticated();
$workspace = $isAuthenticated ? auth()->user()?->defaultHostWorkspace() : null;
return view('mcp::admin.playground', [
'isAuthenticated' => $isAuthenticated,
'workspace' => $workspace,
]);
}
protected function loadRegistry(): array
{
$path = resource_path('mcp/registry.yaml');
return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []];
}
protected function loadServerFull(string $id): ?array
{
$path = resource_path("mcp/servers/{$id}.yaml");
return file_exists($path) ? Yaml::parseFile($path) : null;
}
protected function loadServerSummary(string $id): ?array
{
$server = $this->loadServerFull($id);
if (! $server) {
return null;
}
return [
'id' => $server['id'],
'name' => $server['name'],
'tagline' => $server['tagline'] ?? '',
];
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Mcp\Services\McpQuotaService;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Collection;
use Livewire\Component;
/**
* MCP Quota Usage Dashboard.
*
* Displays current workspace MCP usage against quota limits
* and historical usage trends.
*/
class QuotaUsage extends Component
{
public ?int $workspaceId = null;
public array $currentUsage = [];
public array $quotaLimits = [];
public array $remaining = [];
public Collection $usageHistory;
public function mount(?int $workspaceId = null): void
{
$this->workspaceId = $workspaceId ?? auth()->user()?->defaultHostWorkspace()?->id;
$this->usageHistory = collect();
$this->loadQuotaData();
}
public function loadQuotaData(): void
{
if (! $this->workspaceId) {
return;
}
$quotaService = app(McpQuotaService::class);
$workspace = Workspace::find($this->workspaceId);
if (! $workspace) {
return;
}
$this->currentUsage = $quotaService->getCurrentUsage($workspace);
$this->quotaLimits = $quotaService->getQuotaLimits($workspace);
$this->remaining = $quotaService->getRemainingQuota($workspace);
$this->usageHistory = $quotaService->getUsageHistory($workspace, 6);
}
public function getToolCallsPercentageProperty(): float
{
if ($this->quotaLimits['tool_calls_unlimited'] ?? false) {
return 0;
}
$limit = $this->quotaLimits['tool_calls_limit'] ?? 0;
if ($limit === 0) {
return 0;
}
return min(100, round(($this->currentUsage['tool_calls_count'] ?? 0) / $limit * 100, 1));
}
public function getTokensPercentageProperty(): float
{
if ($this->quotaLimits['tokens_unlimited'] ?? false) {
return 0;
}
$limit = $this->quotaLimits['tokens_limit'] ?? 0;
if ($limit === 0) {
return 0;
}
return min(100, round(($this->currentUsage['total_tokens'] ?? 0) / $limit * 100, 1));
}
public function getResetDateProperty(): string
{
return now()->endOfMonth()->format('j F Y');
}
public function render()
{
return view('mcp::admin.quota-usage');
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Mcp\Models\McpApiRequest;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
/**
* MCP Request Log - view and replay API requests.
*/
#[Layout('hub::admin.layouts.app')]
class RequestLog extends Component
{
use WithPagination;
public string $serverFilter = '';
public string $statusFilter = '';
public ?int $selectedRequestId = null;
public ?McpApiRequest $selectedRequest = null;
public function updatedServerFilter(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function selectRequest(int $id): void
{
$this->selectedRequestId = $id;
$this->selectedRequest = McpApiRequest::find($id);
}
public function closeDetail(): void
{
$this->selectedRequestId = null;
$this->selectedRequest = null;
}
public function render()
{
$workspace = auth()->user()?->defaultHostWorkspace();
$query = McpApiRequest::query()
->orderByDesc('created_at');
if ($workspace) {
$query->forWorkspace($workspace->id);
}
if ($this->serverFilter) {
$query->forServer($this->serverFilter);
}
if ($this->statusFilter === 'success') {
$query->successful();
} elseif ($this->statusFilter === 'failed') {
$query->failed();
}
$requests = $query->paginate(20);
// Get unique servers for filter dropdown
$servers = McpApiRequest::query()
->when($workspace, fn ($q) => $q->forWorkspace($workspace->id))
->distinct()
->pluck('server_id')
->filter()
->values();
return view('mcp::admin.request-log', [
'requests' => $requests,
'servers' => $servers,
]);
}
}

View file

@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Mcp\DTO\ToolStats;
use Core\Mod\Mcp\Services\ToolAnalyticsService;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* Tool Analytics Dashboard - admin dashboard for MCP tool usage analytics.
*
* Displays overview cards, charts, and tables for monitoring tool usage patterns.
*/
#[Layout('hub::admin.layouts.app')]
class ToolAnalyticsDashboard extends Component
{
/**
* Number of days to show in analytics.
*/
#[Url]
public int $days = 30;
/**
* Currently selected tab.
*/
#[Url]
public string $tab = 'overview';
/**
* Workspace filter (null = all workspaces).
*/
#[Url]
public ?string $workspaceId = null;
/**
* Sort column for the tools table.
*/
public string $sortColumn = 'totalCalls';
/**
* Sort direction for the tools table.
*/
public string $sortDirection = 'desc';
/**
* The analytics service.
*/
protected ToolAnalyticsService $analyticsService;
public function boot(ToolAnalyticsService $analyticsService): void
{
$this->analyticsService = $analyticsService;
}
/**
* Set the number of days to display.
*/
public function setDays(int $days): void
{
$this->days = max(1, min(90, $days));
}
/**
* Set the active tab.
*/
public function setTab(string $tab): void
{
$this->tab = $tab;
}
/**
* Set the sort column and direction.
*/
public function sort(string $column): void
{
if ($this->sortColumn === $column) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortColumn = $column;
$this->sortDirection = 'desc';
}
}
/**
* Set the workspace filter.
*/
public function setWorkspace(?string $workspaceId): void
{
$this->workspaceId = $workspaceId;
}
/**
* Get the date range.
*/
protected function getDateRange(): array
{
return [
'from' => now()->subDays($this->days - 1)->startOfDay(),
'to' => now()->endOfDay(),
];
}
/**
* Get overview statistics.
*/
public function getOverviewProperty(): array
{
$range = $this->getDateRange();
$stats = $this->getAllToolsProperty();
$totalCalls = $stats->sum(fn (ToolStats $s) => $s->totalCalls);
$totalErrors = $stats->sum(fn (ToolStats $s) => $s->errorCount);
$avgDuration = $totalCalls > 0
? $stats->sum(fn (ToolStats $s) => $s->avgDurationMs * $s->totalCalls) / $totalCalls
: 0;
return [
'total_calls' => $totalCalls,
'total_errors' => $totalErrors,
'error_rate' => $totalCalls > 0 ? round(($totalErrors / $totalCalls) * 100, 2) : 0,
'avg_duration_ms' => round($avgDuration, 2),
'unique_tools' => $stats->count(),
];
}
/**
* Get all tool statistics.
*/
public function getAllToolsProperty(): Collection
{
$range = $this->getDateRange();
return app(ToolAnalyticsService::class)->getAllToolStats($range['from'], $range['to']);
}
/**
* Get sorted tool statistics for the table.
*/
public function getSortedToolsProperty(): Collection
{
$tools = $this->getAllToolsProperty();
return $tools->sortBy(
fn (ToolStats $s) => match ($this->sortColumn) {
'toolName' => $s->toolName,
'totalCalls' => $s->totalCalls,
'errorCount' => $s->errorCount,
'errorRate' => $s->errorRate,
'avgDurationMs' => $s->avgDurationMs,
default => $s->totalCalls,
},
SORT_REGULAR,
$this->sortDirection === 'desc'
)->values();
}
/**
* Get the most popular tools.
*/
public function getPopularToolsProperty(): Collection
{
$range = $this->getDateRange();
return app(ToolAnalyticsService::class)->getPopularTools(10, $range['from'], $range['to']);
}
/**
* Get tools with high error rates.
*/
public function getErrorProneToolsProperty(): Collection
{
$range = $this->getDateRange();
return app(ToolAnalyticsService::class)->getErrorProneTools(10, $range['from'], $range['to']);
}
/**
* Get tool combinations.
*/
public function getToolCombinationsProperty(): Collection
{
$range = $this->getDateRange();
return app(ToolAnalyticsService::class)->getToolCombinations(10, $range['from'], $range['to']);
}
/**
* Get daily trends for charting.
*/
public function getDailyTrendsProperty(): array
{
$range = $this->getDateRange();
$allStats = $this->getAllToolsProperty();
// Aggregate daily data
$dailyData = [];
for ($i = $this->days - 1; $i >= 0; $i--) {
$date = now()->subDays($i);
$dailyData[] = [
'date' => $date->toDateString(),
'date_formatted' => $date->format('M j'),
'calls' => 0, // Would need per-day aggregation
'errors' => 0,
];
}
return $dailyData;
}
/**
* Get chart data for the top tools bar chart.
*/
public function getTopToolsChartDataProperty(): array
{
$tools = $this->getPopularToolsProperty()->take(10);
return [
'labels' => $tools->pluck('toolName')->toArray(),
'data' => $tools->pluck('totalCalls')->toArray(),
'colors' => $tools->map(fn (ToolStats $t) => $t->errorRate > 10 ? '#ef4444' : '#3b82f6')->toArray(),
];
}
/**
* Format duration for display.
*/
public function formatDuration(float $ms): string
{
if ($ms === 0.0) {
return '-';
}
if ($ms < 1000) {
return round($ms).'ms';
}
return round($ms / 1000, 2).'s';
}
public function render()
{
return view('mcp::admin.analytics.dashboard');
}
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Mcp\DTO\ToolStats;
use Core\Mod\Mcp\Services\ToolAnalyticsService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* Tool Analytics Detail - detailed view for a single MCP tool.
*
* Shows usage trends, performance metrics, and error details for a specific tool.
*/
#[Layout('hub::admin.layouts.app')]
class ToolAnalyticsDetail extends Component
{
/**
* The tool name to display.
*/
public string $toolName;
/**
* Number of days to show in analytics.
*/
#[Url]
public int $days = 30;
/**
* The analytics service.
*/
protected ToolAnalyticsService $analyticsService;
public function mount(string $name): void
{
$this->toolName = $name;
}
public function boot(ToolAnalyticsService $analyticsService): void
{
$this->analyticsService = $analyticsService;
}
/**
* Set the number of days to display.
*/
public function setDays(int $days): void
{
$this->days = max(1, min(90, $days));
}
/**
* Get the tool statistics.
*/
public function getStatsProperty(): ToolStats
{
$from = now()->subDays($this->days - 1)->startOfDay();
$to = now()->endOfDay();
return app(ToolAnalyticsService::class)->getToolStats($this->toolName, $from, $to);
}
/**
* Get usage trends for the tool.
*/
public function getTrendsProperty(): array
{
return app(ToolAnalyticsService::class)->getUsageTrends($this->toolName, $this->days);
}
/**
* Get chart data for the usage trend line chart.
*/
public function getTrendChartDataProperty(): array
{
$trends = $this->getTrendsProperty();
return [
'labels' => array_column($trends, 'date_formatted'),
'calls' => array_column($trends, 'calls'),
'errors' => array_column($trends, 'errors'),
'avgDuration' => array_column($trends, 'avg_duration_ms'),
];
}
/**
* Format duration for display.
*/
public function formatDuration(float $ms): string
{
if ($ms === 0.0) {
return '-';
}
if ($ms < 1000) {
return round($ms).'ms';
}
return round($ms / 1000, 2).'s';
}
public function render()
{
return view('mcp::admin.analytics.tool-detail');
}
}

Some files were not shown because too many files have changed in this diff Show more