monorepo sepration
This commit is contained in:
parent
3ac43d834b
commit
afb3dacd98
126 changed files with 26487 additions and 170 deletions
289
README.md
289
README.md
|
|
@ -1,138 +1,203 @@
|
||||||
# Core PHP Framework Project
|
# Core MCP Package
|
||||||
|
|
||||||
[](https://github.com/host-uk/core-template/actions/workflows/ci.yml)
|
Model Context Protocol (MCP) tools and analytics for AI-powered automation and integrations.
|
||||||
[](https://codecov.io/gh/host-uk/core-template)
|
|
||||||
[](https://packagist.org/packages/host-uk/core-template)
|
|
||||||
[](https://laravel.com)
|
|
||||||
[](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)
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone or create from template
|
composer require host-uk/core-mcp
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit: http://localhost:8000
|
## Features
|
||||||
|
|
||||||
## Project Structure
|
### MCP Tool Registry
|
||||||
|
Extensible tool system for AI integrations:
|
||||||
```
|
|
||||||
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:
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
use Core\Mcp\Tools\BaseTool;
|
||||||
|
|
||||||
namespace App\Mod\Blog;
|
class GetProductsTool extends BaseTool
|
||||||
|
|
||||||
use Core\Events\WebRoutesRegistering;
|
|
||||||
use Core\Events\ApiRoutesRegistering;
|
|
||||||
use Core\Events\AdminPanelBooting;
|
|
||||||
|
|
||||||
class Boot
|
|
||||||
{
|
{
|
||||||
public static array $listens = [
|
public function name(): string
|
||||||
WebRoutesRegistering::class => 'onWebRoutes',
|
|
||||||
ApiRoutesRegistering::class => 'onApiRoutes',
|
|
||||||
AdminPanelBooting::class => 'onAdminPanel',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function onWebRoutes(WebRoutesRegistering $event): void
|
|
||||||
{
|
{
|
||||||
$event->routes(fn() => require __DIR__.'/Routes/web.php');
|
return 'get_products';
|
||||||
$event->views('blog', __DIR__.'/Views');
|
}
|
||||||
|
|
||||||
|
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 handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$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 |
|
```php
|
||||||
|---------|-------------|
|
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Flux Pro (Optional)
|
class MyTool extends BaseTool
|
||||||
|
{
|
||||||
|
use RequiresWorkspaceContext;
|
||||||
|
|
||||||
This template uses the free Flux UI components. If you have a Flux Pro license:
|
// Automatically validates workspace context
|
||||||
|
// Throws exception if context is missing
|
||||||
```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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
### SQL Query Validation
|
||||||
|
Multi-layer protection for database queries:
|
||||||
|
|
||||||
- [Core PHP Framework](https://github.com/host-uk/core-php)
|
```php
|
||||||
- [Getting Started Guide](https://host-uk.github.io/core-php/guide/)
|
use Core\Mcp\Services\SqlQueryValidator;
|
||||||
- [Architecture](https://host-uk.github.io/core-php/architecture/)
|
|
||||||
|
$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
|
## License
|
||||||
|
|
||||||
EUPL-1.2 (European Union Public Licence)
|
EUPL-1.2 - See [LICENSE](../../LICENSE) for details.
|
||||||
|
|
|
||||||
305
TODO.md
Normal file
305
TODO.md
Normal 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.*
|
||||||
121
changelog/2026/jan/features.md
Normal file
121
changelog/2026/jan/features.md
Normal 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
|
||||||
52
changelog/2026/jan/security.md
Normal file
52
changelog/2026/jan/security.md
Normal 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)
|
||||||
|
|
@ -1,76 +1,26 @@
|
||||||
{
|
{
|
||||||
"name": "host-uk/core-template",
|
"name": "host-uk/core-mcp",
|
||||||
"type": "project",
|
"description": "MCP (Model Context Protocol) tools module for Core PHP framework",
|
||||||
"description": "Core PHP Framework - Project Template",
|
"keywords": ["laravel", "mcp", "ai", "tools", "claude"],
|
||||||
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
|
|
||||||
"license": "EUPL-1.2",
|
"license": "EUPL-1.2",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/framework": "^12.0",
|
"host-uk/core": "@dev"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"Core\\Mod\\Mcp\\": "src/Mod/Mcp/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Core\\Website\\Mcp\\": "src/Website/Mcp/"
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"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": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"dont-discover": []
|
"providers": []
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"optimize-autoloader": true,
|
|
||||||
"preferred-install": "dist",
|
|
||||||
"sort-packages": true,
|
|
||||||
"allow-plugins": {
|
|
||||||
"php-http/discovery": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
|
|
|
||||||
98
src/Mod/Mcp/Boot.php
Normal file
98
src/Mod/Mcp/Boot.php
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php
Normal file
111
src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2064
src/Mod/Mcp/Console/Commands/McpAgentServerCommand.php
Normal file
2064
src/Mod/Mcp/Console/Commands/McpAgentServerCommand.php
Normal file
File diff suppressed because it is too large
Load diff
199
src/Mod/Mcp/Console/Commands/McpMonitorCommand.php
Normal file
199
src/Mod/Mcp/Console/Commands/McpMonitorCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/Mod/Mcp/Console/Commands/PruneMetricsCommand.php
Normal file
97
src/Mod/Mcp/Console/Commands/PruneMetricsCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/Mod/Mcp/Console/Commands/VerifyAuditLogCommand.php
Normal file
104
src/Mod/Mcp/Console/Commands/VerifyAuditLogCommand.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/Mod/Mcp/Context/WorkspaceContext.php
Normal file
112
src/Mod/Mcp/Context/WorkspaceContext.php
Normal 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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
492
src/Mod/Mcp/Controllers/McpApiController.php
Normal file
492
src/Mod/Mcp/Controllers/McpApiController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Mod/Mcp/DTO/ToolStats.php
Normal file
95
src/Mod/Mcp/DTO/ToolStats.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/Mod/Mcp/Database/Seeders/SensitiveToolSeeder.php
Normal file
130
src/Mod/Mcp/Database/Seeders/SensitiveToolSeeder.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/Mod/Mcp/Dependencies/DependencyType.php
Normal file
57
src/Mod/Mcp/Dependencies/DependencyType.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Mod/Mcp/Dependencies/HasDependencies.php
Normal file
21
src/Mod/Mcp/Dependencies/HasDependencies.php
Normal 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;
|
||||||
|
}
|
||||||
134
src/Mod/Mcp/Dependencies/ToolDependency.php
Normal file
134
src/Mod/Mcp/Dependencies/ToolDependency.php
Normal 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'] ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/Mod/Mcp/Events/ToolExecuted.php
Normal file
114
src/Mod/Mcp/Events/ToolExecuted.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Mod/Mcp/Exceptions/CircuitOpenException.php
Normal file
27
src/Mod/Mcp/Exceptions/CircuitOpenException.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Mod/Mcp/Exceptions/ForbiddenQueryException.php
Normal file
64
src/Mod/Mcp/Exceptions/ForbiddenQueryException.php
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/Mod/Mcp/Exceptions/MissingDependencyException.php
Normal file
87
src/Mod/Mcp/Exceptions/MissingDependencyException.php
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Mod/Mcp/Exceptions/MissingWorkspaceContextException.php
Normal file
45
src/Mod/Mcp/Exceptions/MissingWorkspaceContextException.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/Mod/Mcp/Lang/en_GB/mcp.php
Normal file
179
src/Mod/Mcp/Lang/en_GB/mcp.php
Normal 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',
|
||||||
|
],
|
||||||
|
];
|
||||||
164
src/Mod/Mcp/Listeners/RecordToolExecution.php
Normal file
164
src/Mod/Mcp/Listeners/RecordToolExecution.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/Mod/Mcp/Middleware/CheckMcpQuota.php
Normal file
89
src/Mod/Mcp/Middleware/CheckMcpQuota.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/Mod/Mcp/Middleware/McpApiKeyAuth.php
Normal file
85
src/Mod/Mcp/Middleware/McpApiKeyAuth.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/Mod/Mcp/Middleware/McpAuthenticate.php
Normal file
102
src/Mod/Mcp/Middleware/McpAuthenticate.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/Mod/Mcp/Middleware/ValidateToolDependencies.php
Normal file
146
src/Mod/Mcp/Middleware/ValidateToolDependencies.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/Mod/Mcp/Middleware/ValidateWorkspaceContext.php
Normal file
91
src/Mod/Mcp/Middleware/ValidateWorkspaceContext.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
176
src/Mod/Mcp/Models/McpApiRequest.php
Normal file
176
src/Mod/Mcp/Models/McpApiRequest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
383
src/Mod/Mcp/Models/McpAuditLog.php
Normal file
383
src/Mod/Mcp/Models/McpAuditLog.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/Mod/Mcp/Models/McpSensitiveTool.php
Normal file
127
src/Mod/Mcp/Models/McpSensitiveTool.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/Mod/Mcp/Models/McpToolCall.php
Normal file
161
src/Mod/Mcp/Models/McpToolCall.php
Normal 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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/Mod/Mcp/Models/McpToolCallStat.php
Normal file
263
src/Mod/Mcp/Models/McpToolCallStat.php
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
359
src/Mod/Mcp/Models/McpToolVersion.php
Normal file
359
src/Mod/Mcp/Models/McpToolVersion.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
193
src/Mod/Mcp/Models/McpUsageQuota.php
Normal file
193
src/Mod/Mcp/Models/McpUsageQuota.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
278
src/Mod/Mcp/Models/ToolMetric.php
Normal file
278
src/Mod/Mcp/Models/ToolMetric.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/Mod/Mcp/Resources/AppConfig.php
Normal file
24
src/Mod/Mcp/Resources/AppConfig.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/Mod/Mcp/Resources/ContentResource.php
Normal file
170
src/Mod/Mcp/Resources/ContentResource.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Mod/Mcp/Resources/DatabaseSchema.php
Normal file
27
src/Mod/Mcp/Resources/DatabaseSchema.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/Mod/Mcp/Routes/admin.php
Normal file
70
src/Mod/Mcp/Routes/admin.php
Normal 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');
|
||||||
|
});
|
||||||
336
src/Mod/Mcp/Services/AgentSessionService.php
Normal file
336
src/Mod/Mcp/Services/AgentSessionService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
src/Mod/Mcp/Services/AgentToolRegistry.php
Normal file
244
src/Mod/Mcp/Services/AgentToolRegistry.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
480
src/Mod/Mcp/Services/AuditLogService.php
Normal file
480
src/Mod/Mcp/Services/AuditLogService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
442
src/Mod/Mcp/Services/CircuitBreaker.php
Normal file
442
src/Mod/Mcp/Services/CircuitBreaker.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/Mod/Mcp/Services/DataRedactor.php
Normal file
305
src/Mod/Mcp/Services/DataRedactor.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
303
src/Mod/Mcp/Services/McpHealthService.php
Normal file
303
src/Mod/Mcp/Services/McpHealthService.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/Mod/Mcp/Services/McpMetricsService.php
Normal file
267
src/Mod/Mcp/Services/McpMetricsService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
395
src/Mod/Mcp/Services/McpQuotaService.php
Normal file
395
src/Mod/Mcp/Services/McpQuotaService.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/Mod/Mcp/Services/McpWebhookDispatcher.php
Normal file
128
src/Mod/Mcp/Services/McpWebhookDispatcher.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
409
src/Mod/Mcp/Services/OpenApiGenerator.php
Normal file
409
src/Mod/Mcp/Services/OpenApiGenerator.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
302
src/Mod/Mcp/Services/SqlQueryValidator.php
Normal file
302
src/Mod/Mcp/Services/SqlQueryValidator.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
386
src/Mod/Mcp/Services/ToolAnalyticsService.php
Normal file
386
src/Mod/Mcp/Services/ToolAnalyticsService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
496
src/Mod/Mcp/Services/ToolDependencyService.php
Normal file
496
src/Mod/Mcp/Services/ToolDependencyService.php
Normal 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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/Mod/Mcp/Services/ToolRateLimiter.php
Normal file
144
src/Mod/Mcp/Services/ToolRateLimiter.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
353
src/Mod/Mcp/Services/ToolRegistry.php
Normal file
353
src/Mod/Mcp/Services/ToolRegistry.php
Normal 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'] ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
478
src/Mod/Mcp/Services/ToolVersionService.php
Normal file
478
src/Mod/Mcp/Services/ToolVersionService.php
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/Mod/Mcp/Tests/Unit/McpQuotaServiceTest.php
Normal file
245
src/Mod/Mcp/Tests/Unit/McpQuotaServiceTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
480
src/Mod/Mcp/Tests/Unit/ToolDependencyServiceTest.php
Normal file
480
src/Mod/Mcp/Tests/Unit/ToolDependencyServiceTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
441
src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php
Normal file
441
src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
190
src/Mod/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php
Normal file
190
src/Mod/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/Mod/Mcp/Tests/UseCase/ApiKeyManagerBasic.php
Normal file
71
src/Mod/Mcp/Tests/UseCase/ApiKeyManagerBasic.php
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
100
src/Mod/Mcp/Tools/Commerce/CreateCoupon.php
Normal file
100
src/Mod/Mcp/Tools/Commerce/CreateCoupon.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/Mod/Mcp/Tools/Commerce/GetBillingStatus.php
Normal file
77
src/Mod/Mcp/Tools/Commerce/GetBillingStatus.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/Mod/Mcp/Tools/Commerce/ListInvoices.php
Normal file
76
src/Mod/Mcp/Tools/Commerce/ListInvoices.php
Normal 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)'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/Mod/Mcp/Tools/Commerce/UpgradePlan.php
Normal file
120
src/Mod/Mcp/Tools/Commerce/UpgradePlan.php
Normal 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)'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/Mod/Mcp/Tools/Concerns/RequiresWorkspaceContext.php
Normal file
135
src/Mod/Mcp/Tools/Concerns/RequiresWorkspaceContext.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/Mod/Mcp/Tools/Concerns/ValidatesDependencies.php
Normal file
123
src/Mod/Mcp/Tools/Concerns/ValidatesDependencies.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
633
src/Mod/Mcp/Tools/ContentTools.php
Normal file
633
src/Mod/Mcp/Tools/ContentTools.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Mod/Mcp/Tools/GetStats.php
Normal file
30
src/Mod/Mcp/Tools/GetStats.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Mod/Mcp/Tools/ListRoutes.php
Normal file
32
src/Mod/Mcp/Tools/ListRoutes.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Mod/Mcp/Tools/ListSites.php
Normal file
32
src/Mod/Mcp/Tools/ListSites.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Mod/Mcp/Tools/ListTables.php
Normal file
28
src/Mod/Mcp/Tools/ListTables.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
281
src/Mod/Mcp/Tools/QueryDatabase.php
Normal file
281
src/Mod/Mcp/Tools/QueryDatabase.php
Normal 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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/Mod/Mcp/View/Blade/admin/analytics/dashboard.blade.php
Normal file
233
src/Mod/Mcp/View/Blade/admin/analytics/dashboard.blade.php
Normal 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">✓</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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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' ? '▲' : '▼' }}</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' ? '▲' : '▼' }}</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' ? '▲' : '▼' }}</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' ? '▲' : '▼' }}</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' ? '▲' : '▼' }}</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>
|
||||||
183
src/Mod/Mcp/View/Blade/admin/analytics/tool-detail.blade.php
Normal file
183
src/Mod/Mcp/View/Blade/admin/analytics/tool-detail.blade.php
Normal 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>
|
||||||
268
src/Mod/Mcp/View/Blade/admin/api-key-manager.blade.php
Normal file
268
src/Mod/Mcp/View/Blade/admin/api-key-manager.blade.php
Normal 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>
|
||||||
400
src/Mod/Mcp/View/Blade/admin/audit-log-viewer.blade.php
Normal file
400
src/Mod/Mcp/View/Blade/admin/audit-log-viewer.blade.php
Normal 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>
|
||||||
502
src/Mod/Mcp/View/Blade/admin/mcp-playground.blade.php
Normal file
502
src/Mod/Mcp/View/Blade/admin/mcp-playground.blade.php
Normal 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>
|
||||||
281
src/Mod/Mcp/View/Blade/admin/playground.blade.php
Normal file
281
src/Mod/Mcp/View/Blade/admin/playground.blade.php
Normal 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 <your-api-key></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
|
||||||
186
src/Mod/Mcp/View/Blade/admin/quota-usage.blade.php
Normal file
186
src/Mod/Mcp/View/Blade/admin/quota-usage.blade.php
Normal 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>
|
||||||
153
src/Mod/Mcp/View/Blade/admin/request-log.blade.php
Normal file
153
src/Mod/Mcp/View/Blade/admin/request-log.blade.php
Normal 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">·</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>
|
||||||
537
src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php
Normal file
537
src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php
Normal 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>
|
||||||
112
src/Mod/Mcp/View/Modal/Admin/ApiKeyManager.php
Normal file
112
src/Mod/Mcp/View/Modal/Admin/ApiKeyManager.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
249
src/Mod/Mcp/View/Modal/Admin/AuditLogViewer.php
Normal file
249
src/Mod/Mcp/View/Modal/Admin/AuditLogViewer.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
539
src/Mod/Mcp/View/Modal/Admin/McpPlayground.php
Normal file
539
src/Mod/Mcp/View/Modal/Admin/McpPlayground.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/Mod/Mcp/View/Modal/Admin/Playground.php
Normal file
263
src/Mod/Mcp/View/Modal/Admin/Playground.php
Normal 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'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/Mod/Mcp/View/Modal/Admin/QuotaUsage.php
Normal file
93
src/Mod/Mcp/View/Modal/Admin/QuotaUsage.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Mod/Mcp/View/Modal/Admin/RequestLog.php
Normal file
86
src/Mod/Mcp/View/Modal/Admin/RequestLog.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
249
src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php
Normal file
249
src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php
Normal file
109
src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php
Normal 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
Loading…
Add table
Reference in a new issue