diff --git a/README.md b/README.md index 5db97d9..8234704 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,203 @@ -# Core PHP Framework Project +# Core MCP Package -[![CI](https://github.com/host-uk/core-template/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-template/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/host-uk/core-template/graph/badge.svg)](https://codecov.io/gh/host-uk/core-template) -[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-template)](https://packagist.org/packages/host-uk/core-template) -[![Laravel](https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel)](https://laravel.com) -[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) - -A modular monolith Laravel application built with Core PHP Framework. - -## Features - -- **Core Framework** - Event-driven module system with lazy loading -- **Admin Panel** - Livewire-powered admin interface with Flux UI -- **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs -- **MCP Tools** - Model Context Protocol for AI agent integration - -## Requirements - -- PHP 8.2+ -- Composer 2.x -- SQLite (default) or MySQL/PostgreSQL -- Node.js 18+ (for frontend assets) +Model Context Protocol (MCP) tools and analytics for AI-powered automation and integrations. ## Installation ```bash -# Clone or create from template -git clone https://github.com/host-uk/core-template.git my-project -cd my-project - -# Install dependencies -composer install -npm install - -# Configure environment -cp .env.example .env -php artisan key:generate - -# Set up database -touch database/database.sqlite -php artisan migrate - -# Start development server -php artisan serve +composer require host-uk/core-mcp ``` -Visit: http://localhost:8000 +## Features -## Project Structure - -``` -app/ -├── Console/ # Artisan commands -├── Http/ # Controllers & Middleware -├── Models/ # Eloquent models -├── Mod/ # Your custom modules -└── Providers/ # Service providers - -config/ -└── core.php # Core framework configuration - -routes/ -├── web.php # Public web routes -├── api.php # REST API routes -└── console.php # Artisan commands -``` - -## Creating Modules - -```bash -# Create a new module with all features -php artisan make:mod Blog --all - -# Create module with specific features -php artisan make:mod Shop --web --api --admin -``` - -Modules follow the event-driven pattern: +### MCP Tool Registry +Extensible tool system for AI integrations: ```php - 'onWebRoutes', - ApiRoutesRegistering::class => 'onApiRoutes', - AdminPanelBooting::class => 'onAdminPanel', - ]; - - public function onWebRoutes(WebRoutesRegistering $event): void + public function name(): string { - $event->routes(fn() => require __DIR__.'/Routes/web.php'); - $event->views('blog', __DIR__.'/Views'); + return 'get_products'; + } + + public function description(): string + { + return 'Retrieve a list of products from the workspace'; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'limit' => $schema->integer('Maximum number of products to return'), + ]; + } + + public function 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 | -|---------|-------------| -| `host-uk/core` | Core framework components | -| `host-uk/core-admin` | Admin panel & Livewire modals | -| `host-uk/core-api` | REST API with scopes & webhooks | -| `host-uk/core-mcp` | Model Context Protocol tools | +```php +use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext; -## Flux Pro (Optional) +class MyTool extends BaseTool +{ + use RequiresWorkspaceContext; -This template uses the free Flux UI components. If you have a Flux Pro license: - -```bash -# Configure authentication -composer config http-basic.composer.fluxui.dev your-email your-license-key - -# Add the repository -composer config repositories.flux-pro composer https://composer.fluxui.dev - -# Install Flux Pro -composer require livewire/flux-pro + // Automatically validates workspace context + // Throws exception if context is missing +} ``` -## Documentation +### SQL Query Validation +Multi-layer protection for database queries: -- [Core PHP Framework](https://github.com/host-uk/core-php) -- [Getting Started Guide](https://host-uk.github.io/core-php/guide/) -- [Architecture](https://host-uk.github.io/core-php/architecture/) +```php +use Core\Mcp\Services\SqlQueryValidator; + +$validator = new SqlQueryValidator(); +$validator->validate($query); // Throws if unsafe + +// Features: +// - Blocked keywords (INSERT, UPDATE, DELETE, DROP) +// - Pattern detection (stacked queries, hex encoding) +// - Whitelist matching +// - Comment stripping +``` + +### Tool Analytics +Track tool usage and performance: + +```php +use Core\Mcp\Services\ToolAnalyticsService; + +$analytics = app(ToolAnalyticsService::class); + +$stats = $analytics->getToolStats('get_products'); +// Returns: calls, avg_duration, error_rate, etc. +``` + +**Admin dashboard:** `/admin/mcp/analytics` + +### Tool Dependencies +Declare tool dependencies and validate at runtime: + +```php +use Core\Mcp\Dependencies\{HasDependencies, ToolDependency}; + +class AdvancedTool extends BaseTool implements HasDependencies +{ + public function dependencies(): array + { + return [ + new ToolDependency('get_products', DependencyType::REQUIRED), + new ToolDependency('send_email', DependencyType::OPTIONAL), + ]; + } +} +``` + +### MCP Playground +Interactive UI for testing tools: + +**Route:** `/admin/mcp/playground` + +**Features:** +- Tool browser with search +- Dynamic form generation +- JSON response viewer +- Conversation history +- Example pre-fill + +### Query EXPLAIN Analysis +Performance insights for database queries: + +```json +{ + "query": "SELECT * FROM users WHERE email = ?", + "explain": true +} +``` + +**Returns:** +- Raw EXPLAIN output +- Performance warnings +- Index usage analysis +- Optimization recommendations + +### Usage Quotas +Workspace-level rate limiting: + +```php +use Core\Mcp\Services\McpQuotaService; + +$quota = app(McpQuotaService::class); + +// Check if workspace can execute tool +if (!$quota->canExecute($workspace, 'expensive_tool')) { + throw new QuotaExceededException(); +} + +// Record execution +$quota->recordExecution($workspace, 'expensive_tool'); +``` + +## Configuration + +```php +// config/mcp.php + +return [ + 'database' => [ + 'connection' => 'readonly', // Dedicated read-only connection + 'use_whitelist' => true, + 'blocked_tables' => ['users', 'api_keys'], + ], + 'analytics' => [ + 'enabled' => true, + 'retention_days' => 90, + ], + 'quota' => [ + 'enabled' => true, + 'default_limit' => 1000, // Per workspace per day + ], +]; +``` + +## Security + +### Query Security (Defense in Depth) +1. **Read-only database user** (infrastructure) +2. **Blocked keywords** (application) +3. **Pattern validation** (application) +4. **Whitelist matching** (application) +5. **Table access controls** (application) + +### Workspace Isolation +- Context MUST come from authentication +- Cross-tenant access prevented by design +- Tools throw exceptions without context + +See [changelog/2026/jan/security.md](changelog/2026/jan/security.md) for security updates. + +## Requirements + +- PHP 8.2+ +- Laravel 11+ or 12+ + +## Changelog + +See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes. ## License -EUPL-1.2 (European Union Public Licence) +EUPL-1.2 - See [LICENSE](../../LICENSE) for details. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..62992bf --- /dev/null +++ b/TODO.md @@ -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.* diff --git a/changelog/2026/jan/features.md b/changelog/2026/jan/features.md new file mode 100644 index 0000000..d99e2bb --- /dev/null +++ b/changelog/2026/jan/features.md @@ -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 diff --git a/changelog/2026/jan/security.md b/changelog/2026/jan/security.md new file mode 100644 index 0000000..8399cdb --- /dev/null +++ b/changelog/2026/jan/security.md @@ -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) diff --git a/composer.json b/composer.json index 5cdb126..d085cc1 100644 --- a/composer.json +++ b/composer.json @@ -1,76 +1,26 @@ { - "name": "host-uk/core-template", - "type": "project", - "description": "Core PHP Framework - Project Template", - "keywords": ["laravel", "core-php", "modular", "framework", "template"], + "name": "host-uk/core-mcp", + "description": "MCP (Model Context Protocol) tools module for Core PHP framework", + "keywords": ["laravel", "mcp", "ai", "tools", "claude"], "license": "EUPL-1.2", "require": { "php": "^8.2", - "laravel/framework": "^12.0", - "laravel/tinker": "^2.10", - "livewire/flux": "^2.0", - "livewire/livewire": "^3.0", - "host-uk/core": "dev-main", - "host-uk/core-admin": "dev-main", - "host-uk/core-api": "dev-main", - "host-uk/core-mcp": "dev-main" - }, - "require-dev": { - "fakerphp/faker": "^1.23", - "laravel/pail": "^1.2", - "laravel/pint": "^1.18", - "laravel/sail": "^1.41", - "mockery/mockery": "^1.6", - "nunomaduro/collision": "^8.6", - "pestphp/pest": "^3.0", - "pestphp/pest-plugin-laravel": "^3.0" + "host-uk/core": "@dev" }, "autoload": { "psr-4": { - "App\\": "app/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "Core\\Mod\\Mcp\\": "src/Mod/Mcp/", + "Core\\Website\\Mcp\\": "src/Website/Mcp/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Core\\Mod\\Mcp\\Tests\\": "tests/" } }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/host-uk/core-php.git" - } - ], - "scripts": { - "post-autoload-dump": [ - "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", - "@php artisan package:discover --ansi" - ], - "post-update-cmd": [ - "@php artisan vendor:publish --tag=laravel-assets --ansi --force" - ], - "post-root-package-install": [ - "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" - ], - "post-create-project-cmd": [ - "@php artisan key:generate --ansi", - "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", - "@php artisan migrate --graceful --ansi" - ] - }, "extra": { "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "php-http/discovery": true + "providers": [] } }, "minimum-stability": "stable", diff --git a/src/Mod/Mcp/Boot.php b/src/Mod/Mcp/Boot.php new file mode 100644 index 0000000..afc6a89 --- /dev/null +++ b/src/Mod/Mcp/Boot.php @@ -0,0 +1,98 @@ + + */ + 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 + } +} diff --git a/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php b/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php new file mode 100644 index 0000000..42923eb --- /dev/null +++ b/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php @@ -0,0 +1,111 @@ +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; + } +} diff --git a/src/Mod/Mcp/Console/Commands/McpAgentServerCommand.php b/src/Mod/Mcp/Console/Commands/McpAgentServerCommand.php new file mode 100644 index 0000000..411752e --- /dev/null +++ b/src/Mod/Mcp/Console/Commands/McpAgentServerCommand.php @@ -0,0 +1,2064 @@ +registerTools(); + $this->registerResources(); + + // Run MCP server loop + while (($line = fgets(STDIN)) !== false) { + $line = trim($line); + if (empty($line)) { + continue; + } + + try { + $request = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + $response = $this->handleRequest($request); + + if ($response !== null) { + $this->sendResponse($response); + } + } catch (Throwable $e) { + Log::error('MCP Agent Server error', [ + 'error' => $e->getMessage(), + 'line' => $line, + ]); + + $this->sendResponse([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => [ + 'code' => -32700, + 'message' => 'Parse error: '.$e->getMessage(), + ], + ]); + } + } + + return 0; + } + + protected function registerTools(): void + { + // Plan management tools + $this->tools['plan_list'] = [ + 'description' => 'List all work plans with their current status and progress', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'type' => 'string', + 'description' => 'Filter by status (draft, active, paused, completed, archived)', + 'enum' => ['draft', 'active', 'paused', 'completed', 'archived'], + ], + 'include_archived' => [ + 'type' => 'boolean', + 'description' => 'Include archived plans (default: false)', + ], + ], + ], + 'handler' => 'toolPlanList', + ]; + + $this->tools['plan_create'] = [ + 'description' => 'Create a new work plan with phases and tasks', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'type' => 'string', + 'description' => 'Plan title', + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'URL-friendly identifier (auto-generated if not provided)', + ], + 'description' => [ + 'type' => 'string', + 'description' => 'Plan description', + ], + 'context' => [ + 'type' => 'object', + 'description' => 'Additional context (related files, dependencies, etc.)', + ], + 'phases' => [ + 'type' => 'array', + 'description' => 'Array of phase definitions with name, description, and tasks', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'tasks' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], + ], + ], + ], + ], + 'required' => ['title'], + ], + 'handler' => 'toolPlanCreate', + ]; + + $this->tools['plan_get'] = [ + 'description' => 'Get detailed information about a specific plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'format' => [ + 'type' => 'string', + 'description' => 'Output format: json or markdown', + 'enum' => ['json', 'markdown'], + ], + ], + 'required' => ['slug'], + ], + 'handler' => 'toolPlanGet', + ]; + + $this->tools['plan_update_status'] = [ + 'description' => 'Update the status of a plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'status' => [ + 'type' => 'string', + 'description' => 'New status', + 'enum' => ['draft', 'active', 'paused', 'completed'], + ], + ], + 'required' => ['slug', 'status'], + ], + 'handler' => 'toolPlanUpdateStatus', + ]; + + $this->tools['plan_archive'] = [ + 'description' => 'Archive a completed or abandoned plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'reason' => [ + 'type' => 'string', + 'description' => 'Reason for archiving', + ], + ], + 'required' => ['slug'], + ], + 'handler' => 'toolPlanArchive', + ]; + + // Phase tools + $this->tools['phase_get'] = [ + 'description' => 'Get details of a specific phase within a plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + ], + 'required' => ['plan_slug', 'phase'], + ], + 'handler' => 'toolPhaseGet', + ]; + + $this->tools['phase_update_status'] = [ + 'description' => 'Update the status of a phase', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + 'status' => [ + 'type' => 'string', + 'description' => 'New status', + 'enum' => ['pending', 'in_progress', 'completed', 'blocked', 'skipped'], + ], + 'notes' => [ + 'type' => 'string', + 'description' => 'Optional notes about the status change', + ], + ], + 'required' => ['plan_slug', 'phase', 'status'], + ], + 'handler' => 'toolPhaseUpdateStatus', + ]; + + $this->tools['phase_add_checkpoint'] = [ + 'description' => 'Add a checkpoint note to a phase', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + 'note' => [ + 'type' => 'string', + 'description' => 'Checkpoint note', + ], + 'context' => [ + 'type' => 'object', + 'description' => 'Additional context data', + ], + ], + 'required' => ['plan_slug', 'phase', 'note'], + ], + 'handler' => 'toolPhaseAddCheckpoint', + ]; + + // Task tools + $this->tools['task_toggle'] = [ + 'description' => 'Toggle a task completion status', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + 'task_index' => [ + 'type' => 'integer', + 'description' => 'Task index (0-based)', + ], + ], + 'required' => ['plan_slug', 'phase', 'task_index'], + ], + 'handler' => 'toolTaskToggle', + ]; + + $this->tools['task_update'] = [ + 'description' => 'Update task details (status, notes)', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'phase' => [ + 'type' => 'string', + 'description' => 'Phase identifier (number or name)', + ], + 'task_index' => [ + 'type' => 'integer', + 'description' => 'Task index (0-based)', + ], + 'status' => [ + 'type' => 'string', + 'description' => 'New status', + 'enum' => ['pending', 'in_progress', 'completed', 'blocked', 'skipped'], + ], + 'notes' => [ + 'type' => 'string', + 'description' => 'Task notes', + ], + ], + 'required' => ['plan_slug', 'phase', 'task_index'], + ], + 'handler' => 'toolTaskUpdate', + ]; + + // Session tools (for multi-agent handoff) + $this->tools['session_start'] = [ + 'description' => 'Start a new agent session for a plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'agent_type' => [ + 'type' => 'string', + 'description' => 'Type of agent (e.g., opus, sonnet, haiku)', + ], + 'context' => [ + 'type' => 'object', + 'description' => 'Initial session context', + ], + ], + 'required' => ['agent_type'], + ], + 'handler' => 'toolSessionStart', + ]; + + $this->tools['session_log'] = [ + 'description' => 'Log an entry in the current session', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'message' => [ + 'type' => 'string', + 'description' => 'Log message', + ], + 'type' => [ + 'type' => 'string', + 'description' => 'Log type', + 'enum' => ['info', 'progress', 'decision', 'error', 'checkpoint'], + ], + 'data' => [ + 'type' => 'object', + 'description' => 'Additional data to log', + ], + ], + 'required' => ['message'], + ], + 'handler' => 'toolSessionLog', + ]; + + $this->tools['session_artifact'] = [ + 'description' => 'Record an artifact created/modified during the session', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'path' => [ + 'type' => 'string', + 'description' => 'File or resource path', + ], + 'action' => [ + 'type' => 'string', + 'description' => 'Action performed', + 'enum' => ['created', 'modified', 'deleted', 'reviewed'], + ], + 'description' => [ + 'type' => 'string', + 'description' => 'Description of changes', + ], + ], + 'required' => ['path', 'action'], + ], + 'handler' => 'toolSessionArtifact', + ]; + + $this->tools['session_handoff'] = [ + 'description' => 'Prepare session for handoff to another agent', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'summary' => [ + 'type' => 'string', + 'description' => 'Summary of work done', + ], + 'next_steps' => [ + 'type' => 'array', + 'description' => 'Recommended next steps', + 'items' => ['type' => 'string'], + ], + 'blockers' => [ + 'type' => 'array', + 'description' => 'Any blockers encountered', + 'items' => ['type' => 'string'], + ], + 'context_for_next' => [ + 'type' => 'object', + 'description' => 'Context to pass to next agent', + ], + ], + 'required' => ['summary'], + ], + 'handler' => 'toolSessionHandoff', + ]; + + $this->tools['session_end'] = [ + 'description' => 'End the current session', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'type' => 'string', + 'description' => 'Final session status', + 'enum' => ['completed', 'handed_off', 'paused', 'failed'], + ], + 'summary' => [ + 'type' => 'string', + 'description' => 'Final summary', + ], + ], + 'required' => ['status'], + ], + 'handler' => 'toolSessionEnd', + ]; + + // State tools (persistent workspace state) + $this->tools['state_get'] = [ + 'description' => 'Get a workspace state value', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'key' => [ + 'type' => 'string', + 'description' => 'State key', + ], + ], + 'required' => ['plan_slug', 'key'], + ], + 'handler' => 'toolStateGet', + ]; + + $this->tools['state_set'] = [ + 'description' => 'Set a workspace state value', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'key' => [ + 'type' => 'string', + 'description' => 'State key', + ], + 'value' => [ + 'type' => ['string', 'number', 'boolean', 'object', 'array'], + 'description' => 'State value', + ], + 'category' => [ + 'type' => 'string', + 'description' => 'State category for organisation', + ], + ], + 'required' => ['plan_slug', 'key', 'value'], + ], + 'handler' => 'toolStateSet', + ]; + + $this->tools['state_list'] = [ + 'description' => 'List all state values for a plan', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug identifier', + ], + 'category' => [ + 'type' => 'string', + 'description' => 'Filter by category', + ], + ], + 'required' => ['plan_slug'], + ], + 'handler' => 'toolStateList', + ]; + + // Template tools + $this->tools['template_list'] = [ + 'description' => 'List available plan templates', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'category' => [ + 'type' => 'string', + 'description' => 'Filter by category', + ], + ], + ], + 'handler' => 'toolTemplateList', + ]; + + $this->tools['template_preview'] = [ + 'description' => 'Preview a template with variables', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'template' => [ + 'type' => 'string', + 'description' => 'Template name/slug', + ], + 'variables' => [ + 'type' => 'object', + 'description' => 'Variable values for the template', + ], + ], + 'required' => ['template'], + ], + 'handler' => 'toolTemplatePreview', + ]; + + $this->tools['template_create_plan'] = [ + 'description' => 'Create a new plan from a template', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'template' => [ + 'type' => 'string', + 'description' => 'Template name/slug', + ], + 'variables' => [ + 'type' => 'object', + 'description' => 'Variable values for the template', + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'Custom slug for the plan', + ], + ], + 'required' => ['template', 'variables'], + ], + 'handler' => 'toolTemplateCreatePlan', + ]; + + // Content generation tools + $this->tools['content_status'] = [ + 'description' => 'Get content generation pipeline status (AI provider availability, brief counts)', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [], + ], + 'handler' => 'toolContentStatus', + ]; + + $this->tools['content_brief_create'] = [ + 'description' => 'Create a content brief for AI generation', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'type' => 'string', + 'description' => 'Content title', + ], + 'content_type' => [ + 'type' => 'string', + 'description' => 'Type of content', + 'enum' => ['help_article', 'blog_post', 'landing_page', 'social_post'], + ], + 'service' => [ + 'type' => 'string', + 'description' => 'Service context (e.g., BioHost, QRHost)', + ], + 'keywords' => [ + 'type' => 'array', + 'description' => 'SEO keywords to include', + 'items' => ['type' => 'string'], + ], + 'target_word_count' => [ + 'type' => 'integer', + 'description' => 'Target word count (default: 800)', + ], + 'description' => [ + 'type' => 'string', + 'description' => 'Brief description of what to write about', + ], + 'difficulty' => [ + 'type' => 'string', + 'description' => 'Target audience level', + 'enum' => ['beginner', 'intermediate', 'advanced'], + ], + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Link to an existing plan', + ], + ], + 'required' => ['title', 'content_type'], + ], + 'handler' => 'toolContentBriefCreate', + ]; + + $this->tools['content_brief_list'] = [ + 'description' => 'List content briefs with optional status filter', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'type' => 'string', + 'description' => 'Filter by status', + 'enum' => ['pending', 'queued', 'generating', 'review', 'published', 'failed'], + ], + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum results (default: 20)', + ], + ], + ], + 'handler' => 'toolContentBriefList', + ]; + + $this->tools['content_brief_get'] = [ + 'description' => 'Get details of a specific content brief including generated content', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'integer', + 'description' => 'Brief ID', + ], + ], + 'required' => ['id'], + ], + 'handler' => 'toolContentBriefGet', + ]; + + $this->tools['content_generate'] = [ + 'description' => 'Generate content for a brief using AI pipeline (Gemini draft → Claude refine)', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'brief_id' => [ + 'type' => 'integer', + 'description' => 'Brief ID to generate content for', + ], + 'mode' => [ + 'type' => 'string', + 'description' => 'Generation mode', + 'enum' => ['draft', 'refine', 'full'], + ], + 'sync' => [ + 'type' => 'boolean', + 'description' => 'Run synchronously (wait for result) vs queue for async processing', + ], + ], + 'required' => ['brief_id'], + ], + 'handler' => 'toolContentGenerate', + ]; + + $this->tools['content_batch_generate'] = [ + 'description' => 'Queue multiple briefs for batch content generation', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum briefs to process (default: 5)', + ], + 'mode' => [ + 'type' => 'string', + 'description' => 'Generation mode', + 'enum' => ['draft', 'refine', 'full'], + ], + ], + ], + 'handler' => 'toolContentBatchGenerate', + ]; + + $this->tools['content_from_plan'] = [ + 'description' => 'Create content briefs from plan tasks and queue for generation', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'plan_slug' => [ + 'type' => 'string', + 'description' => 'Plan slug to generate content from', + ], + 'content_type' => [ + 'type' => 'string', + 'description' => 'Type of content to generate', + 'enum' => ['help_article', 'blog_post', 'landing_page', 'social_post'], + ], + 'service' => [ + 'type' => 'string', + 'description' => 'Service context', + ], + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum briefs to create (default: 5)', + ], + 'target_word_count' => [ + 'type' => 'integer', + 'description' => 'Target word count per article', + ], + ], + 'required' => ['plan_slug'], + ], + 'handler' => 'toolContentFromPlan', + ]; + + $this->tools['content_usage_stats'] = [ + 'description' => 'Get AI usage statistics (tokens, costs) for content generation', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'period' => [ + 'type' => 'string', + 'description' => 'Time period for stats', + 'enum' => ['day', 'week', 'month', 'year'], + ], + ], + ], + 'handler' => 'toolContentUsageStats', + ]; + } + + protected function registerResources(): void + { + $this->resources['plans://all'] = [ + 'name' => 'All Plans Overview', + 'description' => 'Overview of all work plans and their status', + 'mimeType' => 'text/markdown', + 'handler' => 'resourceAllPlans', + ]; + + // Dynamic plan resources are handled in getResourcesList + } + + protected function handleRequest(array $request): ?array + { + $method = $request['method'] ?? ''; + $id = $request['id'] ?? null; + $params = $request['params'] ?? []; + + return match ($method) { + 'initialize' => $this->handleInitialize($id, $params), + 'tools/list' => $this->handleToolsList($id), + 'tools/call' => $this->handleToolsCall($id, $params), + 'resources/list' => $this->handleResourcesList($id), + 'resources/read' => $this->handleResourcesRead($id, $params), + 'notifications/initialized' => null, + default => $this->errorResponse($id, -32601, "Method not found: {$method}"), + }; + } + + protected function handleInitialize(mixed $id, array $params): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => [ + 'tools' => ['listChanged' => true], + 'resources' => ['subscribe' => false, 'listChanged' => true], + ], + 'serverInfo' => [ + 'name' => 'hosthub-agent', + 'version' => '1.0.0', + ], + ], + ]; + } + + protected function handleToolsList(mixed $id): array + { + $tools = []; + + foreach ($this->tools as $name => $tool) { + $tools[] = [ + 'name' => $name, + 'description' => $tool['description'], + 'inputSchema' => $tool['inputSchema'], + ]; + } + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => ['tools' => $tools], + ]; + } + + protected function handleToolsCall(mixed $id, array $params): array + { + $toolName = $params['name'] ?? ''; + $args = $params['arguments'] ?? []; + $startTime = microtime(true); + + if (! isset($this->tools[$toolName])) { + return $this->errorResponse($id, -32602, "Unknown tool: {$toolName}"); + } + + try { + $handler = $this->tools[$toolName]['handler']; + $result = $this->$handler($args); + + // Log tool call + $this->logToolCall($toolName, $args, $result, $startTime, true); + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'content' => [ + [ + 'type' => 'text', + 'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + ], + ], + ], + ]; + } catch (Throwable $e) { + $this->logToolCall($toolName, $args, ['error' => $e->getMessage()], $startTime, false); + + Log::error('MCP tool error', [ + 'tool' => $toolName, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return $this->errorResponse($id, -32603, $e->getMessage()); + } + } + + protected function handleResourcesList(mixed $id): array + { + $resources = []; + + // Static resources + foreach ($this->resources as $uri => $resource) { + $resources[] = [ + 'uri' => $uri, + 'name' => $resource['name'], + 'description' => $resource['description'], + 'mimeType' => $resource['mimeType'], + ]; + } + + // Dynamic plan resources + $plans = AgentPlan::notArchived()->get(); + foreach ($plans as $plan) { + $resources[] = [ + 'uri' => "plans://{$plan->slug}", + 'name' => $plan->title, + 'description' => "Work plan: {$plan->title}", + 'mimeType' => 'text/markdown', + ]; + } + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => ['resources' => $resources], + ]; + } + + protected function handleResourcesRead(mixed $id, array $params): array + { + $uri = $params['uri'] ?? ''; + + // Handle static resources + if (isset($this->resources[$uri])) { + $handler = $this->resources[$uri]['handler']; + $content = $this->$handler(); + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'contents' => [ + [ + 'uri' => $uri, + 'mimeType' => $this->resources[$uri]['mimeType'], + 'text' => $content, + ], + ], + ], + ]; + } + + // Handle dynamic plan resources + if (str_starts_with($uri, 'plans://')) { + $path = substr($uri, 9); // Remove 'plans://' + $parts = explode('/', $path); + $slug = $parts[0]; + + // plans://{slug}/phases/{order} + if (count($parts) === 3 && $parts[1] === 'phases') { + $content = $this->resourcePhaseChecklist($slug, (int) $parts[2]); + } + // plans://{slug}/state/{key} + elseif (count($parts) === 3 && $parts[1] === 'state') { + $content = $this->resourceStateValue($slug, $parts[2]); + } + // plans://{slug} + else { + $content = $this->resourcePlanDocument($slug); + } + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'contents' => [ + [ + 'uri' => $uri, + 'mimeType' => 'text/markdown', + 'text' => $content, + ], + ], + ], + ]; + } + + // Handle session resources + if (str_starts_with($uri, 'sessions://')) { + $path = substr($uri, 11); + $parts = explode('/', $path); + + if (count($parts) === 2 && $parts[1] === 'context') { + $content = $this->resourceSessionContext($parts[0]); + + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'contents' => [ + [ + 'uri' => $uri, + 'mimeType' => 'text/markdown', + 'text' => $content, + ], + ], + ], + ]; + } + } + + return $this->errorResponse($id, -32602, "Resource not found: {$uri}"); + } + + protected function sendResponse(array $response): void + { + echo json_encode($response, JSON_UNESCAPED_SLASHES)."\n"; + flush(); + } + + protected function logToolCall(string $tool, array $args, array $result, float $startTime, bool $success): void + { + $duration = (int) ((microtime(true) - $startTime) * 1000); + + // Use the log() method which updates daily stats automatically + McpToolCall::log( + serverId: 'hosthub-agent', + toolName: $tool, + params: $args, + success: $success, + durationMs: $duration, + errorMessage: $success ? null : ($result['error'] ?? null), + errorCode: $success ? null : ($result['code'] ?? null), + resultSummary: $success ? $result : null, + sessionId: $this->currentSessionId, + ); + } + + // ===== TOOL IMPLEMENTATIONS ===== + + protected function toolPlanList(array $args): array + { + $query = AgentPlan::with('agentPhases') + ->orderBy('updated_at', 'desc'); + + if (! ($args['include_archived'] ?? false)) { + $query->notArchived(); + } + + if (! empty($args['status'])) { + $query->where('status', $args['status']); + } + + $plans = $query->get(); + + return [ + 'plans' => $plans->map(fn ($plan) => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'progress' => $plan->getProgress(), + 'updated_at' => $plan->updated_at->toIso8601String(), + ])->all(), + 'total' => $plans->count(), + ]; + } + + protected function toolPlanCreate(array $args): array + { + $slug = $args['slug'] ?? Str::slug($args['title']).'-'.Str::random(6); + + if (AgentPlan::where('slug', $slug)->exists()) { + return ['error' => "Plan with slug '{$slug}' already exists"]; + } + + $plan = AgentPlan::create([ + 'slug' => $slug, + 'title' => $args['title'], + 'description' => $args['description'] ?? null, + 'status' => 'draft', + 'context' => $args['context'] ?? [], + ]); + + // Create phases if provided + if (! empty($args['phases'])) { + foreach ($args['phases'] as $order => $phaseData) { + $tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [ + 'name' => $task, + 'status' => 'pending', + ])->all(); + + AgentPhase::create([ + 'agent_plan_id' => $plan->id, + 'name' => $phaseData['name'], + 'description' => $phaseData['description'] ?? null, + 'order' => $order + 1, + 'status' => 'pending', + 'tasks' => $tasks, + ]); + } + } + + $plan->load('agentPhases'); + + return [ + 'success' => true, + 'plan' => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'phases' => $plan->agentPhases->count(), + ], + ]; + } + + protected function toolPlanGet(array $args): array + { + $plan = AgentPlan::with('agentPhases') + ->where('slug', $args['slug']) + ->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['slug']}"]; + } + + $format = $args['format'] ?? 'json'; + + if ($format === 'markdown') { + return ['markdown' => $plan->toMarkdown()]; + } + + return [ + 'plan' => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'description' => $plan->description, + 'status' => $plan->status, + 'context' => $plan->context, + 'progress' => $plan->getProgress(), + 'phases' => $plan->agentPhases->map(fn ($phase) => [ + 'order' => $phase->order, + 'name' => $phase->name, + 'description' => $phase->description, + 'status' => $phase->status, + 'tasks' => $phase->tasks, + 'checkpoints' => $phase->checkpoints, + ])->all(), + 'created_at' => $plan->created_at->toIso8601String(), + 'updated_at' => $plan->updated_at->toIso8601String(), + ], + ]; + } + + protected function toolPlanUpdateStatus(array $args): array + { + $plan = AgentPlan::where('slug', $args['slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['slug']}"]; + } + + $plan->update(['status' => $args['status']]); + + return [ + 'success' => true, + 'plan' => [ + 'slug' => $plan->slug, + 'status' => $plan->fresh()->status, + ], + ]; + } + + protected function toolPlanArchive(array $args): array + { + $plan = AgentPlan::where('slug', $args['slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['slug']}"]; + } + + $plan->archive($args['reason'] ?? null); + + return [ + 'success' => true, + 'plan' => [ + 'slug' => $plan->slug, + 'status' => 'archived', + 'archived_at' => $plan->archived_at->toIso8601String(), + ], + ]; + } + + protected function toolPhaseGet(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + return [ + 'phase' => [ + 'order' => $phase->order, + 'name' => $phase->name, + 'description' => $phase->description, + 'status' => $phase->status, + 'tasks' => $phase->tasks, + 'checkpoints' => $phase->checkpoints, + 'dependencies' => $phase->dependencies, + ], + ]; + } + + protected function toolPhaseUpdateStatus(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + $updateData = ['status' => $args['status']]; + + if (! empty($args['notes'])) { + $phase->addCheckpoint($args['notes'], ['status_change' => $args['status']]); + } + + $phase->update($updateData); + + return [ + 'success' => true, + 'phase' => [ + 'order' => $phase->order, + 'name' => $phase->name, + 'status' => $phase->fresh()->status, + ], + ]; + } + + protected function toolPhaseAddCheckpoint(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + $phase->addCheckpoint($args['note'], $args['context'] ?? []); + + return [ + 'success' => true, + 'checkpoints' => $phase->fresh()->checkpoints, + ]; + } + + protected function toolTaskToggle(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + $tasks = $phase->tasks ?? []; + $index = $args['task_index']; + + if (! isset($tasks[$index])) { + return ['error' => "Task not found at index: {$index}"]; + } + + $currentStatus = is_string($tasks[$index]) + ? 'pending' + : ($tasks[$index]['status'] ?? 'pending'); + + $newStatus = $currentStatus === 'completed' ? 'pending' : 'completed'; + + if (is_string($tasks[$index])) { + $tasks[$index] = [ + 'name' => $tasks[$index], + 'status' => $newStatus, + ]; + } else { + $tasks[$index]['status'] = $newStatus; + } + + $phase->update(['tasks' => $tasks]); + + return [ + 'success' => true, + 'task' => $tasks[$index], + 'plan_progress' => $plan->fresh()->getProgress(), + ]; + } + + protected function toolTaskUpdate(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $phase = $this->findPhase($plan, $args['phase']); + + if (! $phase) { + return ['error' => "Phase not found: {$args['phase']}"]; + } + + $tasks = $phase->tasks ?? []; + $index = $args['task_index']; + + if (! isset($tasks[$index])) { + return ['error' => "Task not found at index: {$index}"]; + } + + if (is_string($tasks[$index])) { + $tasks[$index] = ['name' => $tasks[$index], 'status' => 'pending']; + } + + if (isset($args['status'])) { + $tasks[$index]['status'] = $args['status']; + } + + if (isset($args['notes'])) { + $tasks[$index]['notes'] = $args['notes']; + } + + $phase->update(['tasks' => $tasks]); + + return [ + 'success' => true, + 'task' => $tasks[$index], + ]; + } + + protected function toolSessionStart(array $args): array + { + $plan = null; + if (! empty($args['plan_slug'])) { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + } + + $sessionId = 'ses_'.Str::random(12); + $this->currentSessionId = $sessionId; + + $session = AgentSession::create([ + 'session_id' => $sessionId, + 'agent_plan_id' => $plan?->id, + 'agent_type' => $args['agent_type'], + 'status' => 'active', + 'started_at' => now(), + 'context_summary' => $args['context'] ?? [], + ]); + + return [ + 'success' => true, + 'session' => [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'plan' => $plan?->slug, + 'status' => $session->status, + ], + ]; + } + + protected function toolSessionLog(array $args): array + { + if (! $this->currentSessionId) { + return ['error' => 'No active session. Call session_start first.']; + } + + $session = AgentSession::where('session_id', $this->currentSessionId)->first(); + + if (! $session) { + return ['error' => 'Session not found']; + } + + $session->addWorkLogEntry( + $args['message'], + $args['type'] ?? 'info', + $args['data'] ?? [] + ); + + return ['success' => true, 'logged' => $args['message']]; + } + + protected function toolSessionArtifact(array $args): array + { + if (! $this->currentSessionId) { + return ['error' => 'No active session. Call session_start first.']; + } + + $session = AgentSession::where('session_id', $this->currentSessionId)->first(); + + if (! $session) { + return ['error' => 'Session not found']; + } + + $session->addArtifact( + $args['path'], + $args['action'], + $args['description'] ?? null + ); + + return ['success' => true, 'artifact' => $args['path']]; + } + + protected function toolSessionHandoff(array $args): array + { + if (! $this->currentSessionId) { + return ['error' => 'No active session. Call session_start first.']; + } + + $session = AgentSession::where('session_id', $this->currentSessionId)->first(); + + if (! $session) { + return ['error' => 'Session not found']; + } + + $session->prepareHandoff( + $args['summary'], + $args['next_steps'] ?? [], + $args['blockers'] ?? [], + $args['context_for_next'] ?? [] + ); + + return [ + 'success' => true, + 'handoff_context' => $session->getHandoffContext(), + ]; + } + + protected function toolSessionEnd(array $args): array + { + if (! $this->currentSessionId) { + return ['error' => 'No active session']; + } + + $session = AgentSession::where('session_id', $this->currentSessionId)->first(); + + if (! $session) { + return ['error' => 'Session not found']; + } + + $session->end($args['status'], $args['summary'] ?? null); + $this->currentSessionId = null; + + return [ + 'success' => true, + 'session' => [ + 'session_id' => $session->session_id, + 'status' => $session->status, + 'duration' => $session->getDurationFormatted(), + ], + ]; + } + + protected function toolStateGet(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $state = $plan->states()->where('key', $args['key'])->first(); + + if (! $state) { + return ['error' => "State not found: {$args['key']}"]; + } + + return [ + 'key' => $state->key, + 'value' => $state->value, + 'category' => $state->category, + 'updated_at' => $state->updated_at->toIso8601String(), + ]; + } + + protected function toolStateSet(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $state = AgentWorkspaceState::updateOrCreate( + [ + 'agent_plan_id' => $plan->id, + 'key' => $args['key'], + ], + [ + 'value' => $args['value'], + 'category' => $args['category'] ?? 'general', + ] + ); + + return [ + 'success' => true, + 'state' => [ + 'key' => $state->key, + 'value' => $state->value, + 'category' => $state->category, + ], + ]; + } + + protected function toolStateList(array $args): array + { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $query = $plan->states(); + + if (! empty($args['category'])) { + $query->where('category', $args['category']); + } + + $states = $query->get(); + + return [ + 'states' => $states->map(fn ($state) => [ + 'key' => $state->key, + 'value' => $state->value, + 'category' => $state->category, + ])->all(), + 'total' => $states->count(), + ]; + } + + protected function toolTemplateList(array $args): array + { + $templateService = app(PlanTemplateService::class); + $templates = $templateService->listTemplates(); + + if (! empty($args['category'])) { + $templates = array_filter($templates, fn ($t) => ($t['category'] ?? '') === $args['category']); + } + + return [ + 'templates' => array_values($templates), + 'total' => count($templates), + ]; + } + + protected function toolTemplatePreview(array $args): array + { + $templateService = app(PlanTemplateService::class); + $templateSlug = $args['template']; + $variables = $args['variables'] ?? []; + + $preview = $templateService->previewTemplate($templateSlug, $variables); + + if (! $preview) { + return ['error' => "Template not found: {$templateSlug}"]; + } + + return [ + 'template' => $templateSlug, + 'preview' => $preview, + ]; + } + + protected function toolTemplateCreatePlan(array $args): array + { + $templateService = app(PlanTemplateService::class); + $templateSlug = $args['template']; + $variables = $args['variables'] ?? []; + + $options = []; + + if (! empty($args['slug'])) { + $options['slug'] = $args['slug']; + } + + $plan = $templateService->createPlan($templateSlug, $variables, $options); + + if (! $plan) { + return ['error' => 'Failed to create plan from template']; + } + + return [ + 'success' => true, + 'plan' => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'phases' => $plan->agentPhases->count(), + 'total_tasks' => $plan->getProgress()['total'], + ], + 'commands' => [ + 'view' => "php artisan plan:show {$plan->slug}", + 'activate' => "php artisan plan:status {$plan->slug} --set=active", + ], + ]; + } + + // ===== CONTENT GENERATION TOOL IMPLEMENTATIONS ===== + + protected function toolContentStatus(array $args): array + { + $gateway = app(AIGatewayService::class); + + return [ + 'providers' => [ + 'gemini' => $gateway->isGeminiAvailable(), + 'claude' => $gateway->isClaudeAvailable(), + ], + 'pipeline_available' => $gateway->isAvailable(), + 'briefs' => [ + 'pending' => ContentBrief::pending()->count(), + 'queued' => ContentBrief::where('status', ContentBrief::STATUS_QUEUED)->count(), + 'generating' => ContentBrief::where('status', ContentBrief::STATUS_GENERATING)->count(), + 'review' => ContentBrief::needsReview()->count(), + 'published' => ContentBrief::where('status', ContentBrief::STATUS_PUBLISHED)->count(), + 'failed' => ContentBrief::where('status', ContentBrief::STATUS_FAILED)->count(), + ], + ]; + } + + protected function toolContentBriefCreate(array $args): array + { + $plan = null; + if (! empty($args['plan_slug'])) { + $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); + } + + $brief = ContentBrief::create([ + 'title' => $args['title'], + 'slug' => Str::slug($args['title']).'-'.Str::random(6), + 'content_type' => $args['content_type'], + 'service' => $args['service'] ?? null, + 'description' => $args['description'] ?? null, + 'keywords' => $args['keywords'] ?? null, + 'target_word_count' => $args['target_word_count'] ?? 800, + 'difficulty' => $args['difficulty'] ?? null, + 'status' => ContentBrief::STATUS_PENDING, + 'metadata' => $plan ? [ + 'plan_id' => $plan->id, + 'plan_slug' => $plan->slug, + ] : null, + ]); + + return [ + 'success' => true, + 'brief' => [ + 'id' => $brief->id, + 'title' => $brief->title, + 'slug' => $brief->slug, + 'status' => $brief->status, + 'content_type' => $brief->content_type, + ], + ]; + } + + protected function toolContentBriefList(array $args): array + { + $query = ContentBrief::query()->orderBy('created_at', 'desc'); + + if (! empty($args['status'])) { + $query->where('status', $args['status']); + } + + $limit = $args['limit'] ?? 20; + $briefs = $query->limit($limit)->get(); + + return [ + 'briefs' => $briefs->map(fn ($brief) => [ + 'id' => $brief->id, + 'title' => $brief->title, + 'status' => $brief->status, + 'content_type' => $brief->content_type, + 'service' => $brief->service, + 'created_at' => $brief->created_at->toIso8601String(), + ])->all(), + 'total' => $briefs->count(), + ]; + } + + protected function toolContentBriefGet(array $args): array + { + $brief = ContentBrief::find($args['id']); + + if (! $brief) { + return ['error' => "Brief not found: {$args['id']}"]; + } + + return [ + 'brief' => [ + 'id' => $brief->id, + 'title' => $brief->title, + 'slug' => $brief->slug, + 'status' => $brief->status, + 'content_type' => $brief->content_type, + 'service' => $brief->service, + 'description' => $brief->description, + 'keywords' => $brief->keywords, + 'target_word_count' => $brief->target_word_count, + 'difficulty' => $brief->difficulty, + 'draft_output' => $brief->draft_output, + 'refined_output' => $brief->refined_output, + 'final_content' => $brief->final_content, + 'best_content' => $brief->best_content, + 'error_message' => $brief->error_message, + 'generation_log' => $brief->generation_log, + 'total_cost' => $brief->total_cost, + 'created_at' => $brief->created_at->toIso8601String(), + 'generated_at' => $brief->generated_at?->toIso8601String(), + 'refined_at' => $brief->refined_at?->toIso8601String(), + ], + ]; + } + + protected function toolContentGenerate(array $args): array + { + $brief = ContentBrief::find($args['brief_id']); + + if (! $brief) { + return ['error' => "Brief not found: {$args['brief_id']}"]; + } + + $gateway = app(AIGatewayService::class); + + if (! $gateway->isAvailable()) { + return ['error' => 'AI providers not configured. Set GOOGLE_AI_API_KEY and ANTHROPIC_API_KEY.']; + } + + $mode = $args['mode'] ?? 'full'; + $sync = $args['sync'] ?? false; + + if ($sync) { + try { + if ($mode === 'full') { + $result = $gateway->generateAndRefine($brief); + + return [ + 'success' => true, + 'brief_id' => $brief->id, + 'status' => $brief->fresh()->status, + 'draft' => [ + 'model' => $result['draft']->model, + 'tokens' => $result['draft']->totalTokens(), + 'cost' => $result['draft']->estimateCost(), + ], + 'refined' => [ + 'model' => $result['refined']->model, + 'tokens' => $result['refined']->totalTokens(), + 'cost' => $result['refined']->estimateCost(), + ], + ]; + } elseif ($mode === 'draft') { + $response = $gateway->generateDraft($brief); + $brief->markDraftComplete($response->content); + + return [ + 'success' => true, + 'brief_id' => $brief->id, + 'status' => $brief->fresh()->status, + 'draft' => [ + 'model' => $response->model, + 'tokens' => $response->totalTokens(), + 'cost' => $response->estimateCost(), + ], + ]; + } elseif ($mode === 'refine') { + if (! $brief->isGenerated()) { + return ['error' => 'No draft to refine. Generate draft first.']; + } + $response = $gateway->refineDraft($brief, $brief->draft_output); + $brief->markRefined($response->content); + + return [ + 'success' => true, + 'brief_id' => $brief->id, + 'status' => $brief->fresh()->status, + 'refined' => [ + 'model' => $response->model, + 'tokens' => $response->totalTokens(), + 'cost' => $response->estimateCost(), + ], + ]; + } + } catch (\Exception $e) { + $brief->markFailed($e->getMessage()); + + return ['error' => $e->getMessage()]; + } + } + + // Async - queue for processing + $brief->markQueued(); + GenerateContentJob::dispatch($brief, $mode); + + return [ + 'success' => true, + 'queued' => true, + 'brief_id' => $brief->id, + 'mode' => $mode, + 'message' => 'Brief queued for generation', + ]; + } + + protected function toolContentBatchGenerate(array $args): array + { + $limit = $args['limit'] ?? 5; + $mode = $args['mode'] ?? 'full'; + + $briefs = ContentBrief::readyToProcess()->limit($limit)->get(); + + if ($briefs->isEmpty()) { + return ['message' => 'No briefs ready for processing', 'queued' => 0]; + } + + foreach ($briefs as $brief) { + GenerateContentJob::dispatch($brief, $mode); + } + + return [ + 'success' => true, + 'queued' => $briefs->count(), + 'mode' => $mode, + 'brief_ids' => $briefs->pluck('id')->all(), + ]; + } + + protected function toolContentFromPlan(array $args): array + { + $plan = AgentPlan::with('agentPhases') + ->where('slug', $args['plan_slug']) + ->first(); + + if (! $plan) { + return ['error' => "Plan not found: {$args['plan_slug']}"]; + } + + $limit = $args['limit'] ?? 5; + $contentType = $args['content_type'] ?? 'help_article'; + $service = $args['service'] ?? ($plan->metadata['service'] ?? null); + $wordCount = $args['target_word_count'] ?? 800; + + $phases = $plan->agentPhases() + ->whereIn('status', ['pending', 'in_progress']) + ->get(); + + if ($phases->isEmpty()) { + return ['message' => 'No pending phases in plan', 'created' => 0]; + } + + $briefsCreated = []; + + foreach ($phases as $phase) { + $tasks = $phase->getTasks(); + + foreach ($tasks as $index => $task) { + if (count($briefsCreated) >= $limit) { + break 2; + } + + $taskName = is_string($task) ? $task : ($task['name'] ?? ''); + $taskStatus = is_array($task) ? ($task['status'] ?? 'pending') : 'pending'; + + if ($taskStatus === 'completed') { + continue; + } + + $brief = ContentBrief::create([ + 'title' => $taskName, + 'slug' => Str::slug($taskName).'-'.time(), + 'content_type' => $contentType, + 'service' => $service, + 'target_word_count' => $wordCount, + 'status' => ContentBrief::STATUS_QUEUED, + 'metadata' => [ + 'plan_id' => $plan->id, + 'plan_slug' => $plan->slug, + 'phase_id' => $phase->id, + 'phase_order' => $phase->order, + 'task_index' => $index, + ], + ]); + + GenerateContentJob::dispatch($brief, 'full'); + $briefsCreated[] = [ + 'id' => $brief->id, + 'title' => $brief->title, + ]; + } + } + + return [ + 'success' => true, + 'plan' => $plan->slug, + 'created' => count($briefsCreated), + 'briefs' => $briefsCreated, + ]; + } + + protected function toolContentUsageStats(array $args): array + { + $period = $args['period'] ?? 'month'; + $stats = AIUsage::statsForWorkspace(null, $period); + + return [ + 'period' => $period, + 'total_requests' => $stats['total_requests'], + 'total_input_tokens' => $stats['total_input_tokens'], + 'total_output_tokens' => $stats['total_output_tokens'], + 'total_cost' => number_format($stats['total_cost'], 4), + 'by_provider' => $stats['by_provider'], + 'by_purpose' => $stats['by_purpose'], + ]; + } + + // ===== RESOURCE IMPLEMENTATIONS ===== + + protected function resourceAllPlans(): string + { + $plans = AgentPlan::with('agentPhases')->notArchived()->orderBy('updated_at', 'desc')->get(); + + $md = "# Work Plans\n\n"; + $md .= '**Total:** '.$plans->count()." plan(s)\n\n"; + + foreach ($plans->groupBy('status') as $status => $group) { + $md .= '## '.ucfirst($status).' ('.$group->count().")\n\n"; + + foreach ($group as $plan) { + $progress = $plan->getProgress(); + $md .= "- **[{$plan->slug}]** {$plan->title} - {$progress['percentage']}%\n"; + } + $md .= "\n"; + } + + return $md; + } + + protected function resourcePlanDocument(string $slug): string + { + $plan = AgentPlan::with('agentPhases')->where('slug', $slug)->first(); + + if (! $plan) { + return "Plan not found: {$slug}"; + } + + return $plan->toMarkdown(); + } + + protected function resourcePhaseChecklist(string $slug, int $phaseOrder): string + { + $plan = AgentPlan::where('slug', $slug)->first(); + + if (! $plan) { + return "Plan not found: {$slug}"; + } + + $phase = $plan->agentPhases()->where('order', $phaseOrder)->first(); + + if (! $phase) { + return "Phase not found: {$phaseOrder}"; + } + + $md = "# Phase {$phase->order}: {$phase->name}\n\n"; + $md .= "**Status:** {$phase->getStatusIcon()} {$phase->status}\n\n"; + + if ($phase->description) { + $md .= "{$phase->description}\n\n"; + } + + $md .= "## Tasks\n\n"; + + foreach ($phase->tasks ?? [] as $task) { + $status = is_string($task) ? 'pending' : ($task['status'] ?? 'pending'); + $name = is_string($task) ? $task : ($task['name'] ?? 'Unknown'); + $icon = $status === 'completed' ? '✅' : '⬜'; + $md .= "- {$icon} {$name}\n"; + } + + return $md; + } + + protected function resourceStateValue(string $slug, string $key): string + { + $plan = AgentPlan::where('slug', $slug)->first(); + + if (! $plan) { + return "Plan not found: {$slug}"; + } + + $state = $plan->states()->where('key', $key)->first(); + + if (! $state) { + return "State key not found: {$key}"; + } + + return $state->getFormattedValue(); + } + + protected function resourceSessionContext(string $sessionId): string + { + $session = AgentSession::where('session_id', $sessionId)->first(); + + if (! $session) { + return "Session not found: {$sessionId}"; + } + + $context = $session->getHandoffContext(); + + $md = "# Session: {$session->session_id}\n\n"; + $md .= "**Agent:** {$session->agent_type}\n"; + $md .= "**Status:** {$session->status}\n"; + $md .= "**Duration:** {$session->getDurationFormatted()}\n\n"; + + if ($session->plan) { + $md .= "## Plan\n\n"; + $md .= "**{$session->plan->title}** ({$session->plan->slug})\n\n"; + } + + if (! empty($context['context_summary'])) { + $md .= "## Context Summary\n\n"; + $md .= json_encode($context['context_summary'], JSON_PRETTY_PRINT)."\n\n"; + } + + if (! empty($context['handoff_notes'])) { + $md .= "## Handoff Notes\n\n"; + $md .= json_encode($context['handoff_notes'], JSON_PRETTY_PRINT)."\n\n"; + } + + if (! empty($context['artifacts'])) { + $md .= "## Artifacts\n\n"; + foreach ($context['artifacts'] as $artifact) { + $md .= "- {$artifact['action']}: {$artifact['path']}\n"; + } + $md .= "\n"; + } + + return $md; + } + + // ===== HELPERS ===== + + protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase + { + if (is_numeric($identifier)) { + return $plan->agentPhases()->where('order', (int) $identifier)->first(); + } + + return $plan->agentPhases() + ->where(function ($query) use ($identifier) { + $query->where('name', $identifier) + ->orWhere('order', $identifier); + }) + ->first(); + } + + protected function errorResponse(mixed $id, int $code, string $message): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => [ + 'code' => $code, + 'message' => $message, + ], + ]; + } +} diff --git a/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php b/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php new file mode 100644 index 0000000..d3869b8 --- /dev/null +++ b/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php @@ -0,0 +1,199 @@ +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("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('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("[{$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("MCP Summary Report ({$days} days)"); + $this->line("Period: {$report['period']['from']} to {$report['period']['to']}"); + $this->newLine(); + + // Overview + $this->line('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('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('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; + } +} diff --git a/src/Mod/Mcp/Console/Commands/PruneMetricsCommand.php b/src/Mod/Mcp/Console/Commands/PruneMetricsCommand.php new file mode 100644 index 0000000..0c088f7 --- /dev/null +++ b/src/Mod/Mcp/Console/Commands/PruneMetricsCommand.php @@ -0,0 +1,97 @@ +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; + } +} diff --git a/src/Mod/Mcp/Console/Commands/VerifyAuditLogCommand.php b/src/Mod/Mcp/Console/Commands/VerifyAuditLogCommand.php new file mode 100644 index 0000000..c6f67ef --- /dev/null +++ b/src/Mod/Mcp/Console/Commands/VerifyAuditLogCommand.php @@ -0,0 +1,104 @@ +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'] ? 'VALID' : '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.'); + } +} diff --git a/src/Mod/Mcp/Context/WorkspaceContext.php b/src/Mod/Mcp/Context/WorkspaceContext.php new file mode 100644 index 0000000..1ce6876 --- /dev/null +++ b/src/Mod/Mcp/Context/WorkspaceContext.php @@ -0,0 +1,112 @@ +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." + ); + } + } +} diff --git a/src/Mod/Mcp/Controllers/McpApiController.php b/src/Mod/Mcp/Controllers/McpApiController.php new file mode 100644 index 0000000..19ff660 --- /dev/null +++ b/src/Mod/Mcp/Controllers/McpApiController.php @@ -0,0 +1,492 @@ +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 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); + } + } +} diff --git a/src/Mod/Mcp/DTO/ToolStats.php b/src/Mod/Mcp/DTO/ToolStats.php new file mode 100644 index 0000000..01f8e50 --- /dev/null +++ b/src/Mod/Mcp/DTO/ToolStats.php @@ -0,0 +1,95 @@ + $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; + } +} diff --git a/src/Mod/Mcp/Database/Seeders/SensitiveToolSeeder.php b/src/Mod/Mcp/Database/Seeders/SensitiveToolSeeder.php new file mode 100644 index 0000000..4ce6f8e --- /dev/null +++ b/src/Mod/Mcp/Database/Seeders/SensitiveToolSeeder.php @@ -0,0 +1,130 @@ + '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.'); + } +} diff --git a/src/Mod/Mcp/Dependencies/DependencyType.php b/src/Mod/Mcp/Dependencies/DependencyType.php new file mode 100644 index 0000000..78cc407 --- /dev/null +++ b/src/Mod/Mcp/Dependencies/DependencyType.php @@ -0,0 +1,57 @@ + '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', + }; + } +} diff --git a/src/Mod/Mcp/Dependencies/HasDependencies.php b/src/Mod/Mcp/Dependencies/HasDependencies.php new file mode 100644 index 0000000..692fcfc --- /dev/null +++ b/src/Mod/Mcp/Dependencies/HasDependencies.php @@ -0,0 +1,21 @@ + + */ + public function dependencies(): array; +} diff --git a/src/Mod/Mcp/Dependencies/ToolDependency.php b/src/Mod/Mcp/Dependencies/ToolDependency.php new file mode 100644 index 0000000..69ff64d --- /dev/null +++ b/src/Mod/Mcp/Dependencies/ToolDependency.php @@ -0,0 +1,134 @@ +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'] ?? [], + ); + } +} diff --git a/src/Mod/Mcp/Events/ToolExecuted.php b/src/Mod/Mcp/Events/ToolExecuted.php new file mode 100644 index 0000000..5c3ce77 --- /dev/null +++ b/src/Mod/Mcp/Events/ToolExecuted.php @@ -0,0 +1,114 @@ +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; + } +} diff --git a/src/Mod/Mcp/Exceptions/CircuitOpenException.php b/src/Mod/Mcp/Exceptions/CircuitOpenException.php new file mode 100644 index 0000000..779b065 --- /dev/null +++ b/src/Mod/Mcp/Exceptions/CircuitOpenException.php @@ -0,0 +1,27 @@ + $missingDependencies List of unmet dependencies + * @param array $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) + ); + } +} diff --git a/src/Mod/Mcp/Exceptions/MissingWorkspaceContextException.php b/src/Mod/Mcp/Exceptions/MissingWorkspaceContextException.php new file mode 100644 index 0000000..0ff33ed --- /dev/null +++ b/src/Mod/Mcp/Exceptions/MissingWorkspaceContextException.php @@ -0,0 +1,45 @@ + [ + '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', + ], +]; diff --git a/src/Mod/Mcp/Listeners/RecordToolExecution.php b/src/Mod/Mcp/Listeners/RecordToolExecution.php new file mode 100644 index 0000000..25e09b9 --- /dev/null +++ b/src/Mod/Mcp/Listeners/RecordToolExecution.php @@ -0,0 +1,164 @@ +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; + } +} diff --git a/src/Mod/Mcp/Middleware/CheckMcpQuota.php b/src/Mod/Mcp/Middleware/CheckMcpQuota.php new file mode 100644 index 0000000..e370220 --- /dev/null +++ b/src/Mod/Mcp/Middleware/CheckMcpQuota.php @@ -0,0 +1,89 @@ +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); + } + } +} diff --git a/src/Mod/Mcp/Middleware/McpApiKeyAuth.php b/src/Mod/Mcp/Middleware/McpApiKeyAuth.php new file mode 100644 index 0000000..96150b3 --- /dev/null +++ b/src/Mod/Mcp/Middleware/McpApiKeyAuth.php @@ -0,0 +1,85 @@ +extractKey($request); + + if (! $key) { + return response()->json([ + 'error' => 'Missing API key', + 'hint' => 'Provide via Authorization: Bearer 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; + } +} diff --git a/src/Mod/Mcp/Middleware/McpAuthenticate.php b/src/Mod/Mcp/Middleware/McpAuthenticate.php new file mode 100644 index 0000000..a4264af --- /dev/null +++ b/src/Mod/Mcp/Middleware/McpAuthenticate.php @@ -0,0 +1,102 @@ +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')); + } +} diff --git a/src/Mod/Mcp/Middleware/ValidateToolDependencies.php b/src/Mod/Mcp/Middleware/ValidateToolDependencies.php new file mode 100644 index 0000000..8992a27 --- /dev/null +++ b/src/Mod/Mcp/Middleware/ValidateToolDependencies.php @@ -0,0 +1,146 @@ +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); + } +} diff --git a/src/Mod/Mcp/Middleware/ValidateWorkspaceContext.php b/src/Mod/Mcp/Middleware/ValidateWorkspaceContext.php new file mode 100644 index 0000000..40d71a8 --- /dev/null +++ b/src/Mod/Mcp/Middleware/ValidateWorkspaceContext.php @@ -0,0 +1,91 @@ +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()); + } +} diff --git a/src/Mod/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php b/src/Mod/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php new file mode 100644 index 0000000..76cc9fa --- /dev/null +++ b/src/Mod/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/src/Mod/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php b/src/Mod/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php new file mode 100644 index 0000000..d31a179 --- /dev/null +++ b/src/Mod/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/src/Mod/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php b/src/Mod/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php new file mode 100644 index 0000000..f3f2180 --- /dev/null +++ b/src/Mod/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/src/Mod/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php b/src/Mod/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php new file mode 100644 index 0000000..0520748 --- /dev/null +++ b/src/Mod/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php @@ -0,0 +1,78 @@ +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'); + } +}; diff --git a/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php b/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php new file mode 100644 index 0000000..9248f62 --- /dev/null +++ b/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php @@ -0,0 +1,41 @@ +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'); + } +}; diff --git a/src/Mod/Mcp/Models/McpApiRequest.php b/src/Mod/Mcp/Models/McpApiRequest.php new file mode 100644 index 0000000..bdacb4c --- /dev/null +++ b/src/Mod/Mcp/Models/McpApiRequest.php @@ -0,0 +1,176 @@ + '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)); + } +} diff --git a/src/Mod/Mcp/Models/McpAuditLog.php b/src/Mod/Mcp/Models/McpAuditLog.php new file mode 100644 index 0000000..ecf3e7a --- /dev/null +++ b/src/Mod/Mcp/Models/McpAuditLog.php @@ -0,0 +1,383 @@ + '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, + ]; + } +} diff --git a/src/Mod/Mcp/Models/McpSensitiveTool.php b/src/Mod/Mcp/Models/McpSensitiveTool.php new file mode 100644 index 0000000..3a03bf1 --- /dev/null +++ b/src/Mod/Mcp/Models/McpSensitiveTool.php @@ -0,0 +1,127 @@ + '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(); + } +} diff --git a/src/Mod/Mcp/Models/McpToolCall.php b/src/Mod/Mcp/Models/McpToolCall.php new file mode 100644 index 0000000..9281d99 --- /dev/null +++ b/src/Mod/Mcp/Models/McpToolCall.php @@ -0,0 +1,161 @@ + '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 + ? 'Success' + : 'Failed'; + } +} diff --git a/src/Mod/Mcp/Models/McpToolCallStat.php b/src/Mod/Mcp/Models/McpToolCallStat.php new file mode 100644 index 0000000..0ed58b2 --- /dev/null +++ b/src/Mod/Mcp/Models/McpToolCallStat.php @@ -0,0 +1,263 @@ + '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; + }); + } +} diff --git a/src/Mod/Mcp/Models/McpToolVersion.php b/src/Mod/Mcp/Models/McpToolVersion.php new file mode 100644 index 0000000..3bff53a --- /dev/null +++ b/src/Mod/Mcp/Models/McpToolVersion.php @@ -0,0 +1,359 @@ + '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(), + ]; + } +} diff --git a/src/Mod/Mcp/Models/McpUsageQuota.php b/src/Mod/Mcp/Models/McpUsageQuota.php new file mode 100644 index 0000000..e58d18e --- /dev/null +++ b/src/Mod/Mcp/Models/McpUsageQuota.php @@ -0,0 +1,193 @@ + '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(), + ]; + } +} diff --git a/src/Mod/Mcp/Models/ToolMetric.php b/src/Mod/Mcp/Models/ToolMetric.php new file mode 100644 index 0000000..92bb7ac --- /dev/null +++ b/src/Mod/Mcp/Models/ToolMetric.php @@ -0,0 +1,278 @@ + '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, + ]; + } +} diff --git a/src/Mod/Mcp/Resources/AppConfig.php b/src/Mod/Mcp/Resources/AppConfig.php new file mode 100644 index 0000000..ba42cf7 --- /dev/null +++ b/src/Mod/Mcp/Resources/AppConfig.php @@ -0,0 +1,24 @@ + config('app.name'), + 'env' => config('app.env'), + 'debug' => config('app.debug'), + 'url' => config('app.url'), + ]; + + return Response::text(json_encode($config, JSON_PRETTY_PRINT)); + } +} diff --git a/src/Mod/Mcp/Resources/ContentResource.php b/src/Mod/Mcp/Resources/ContentResource.php new file mode 100644 index 0000000..f807f9f --- /dev/null +++ b/src/Mod/Mcp/Resources/ContentResource.php @@ -0,0 +1,170 @@ +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; + } +} diff --git a/src/Mod/Mcp/Resources/DatabaseSchema.php b/src/Mod/Mcp/Resources/DatabaseSchema.php new file mode 100644 index 0000000..3055249 --- /dev/null +++ b/src/Mod/Mcp/Resources/DatabaseSchema.php @@ -0,0 +1,27 @@ +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)); + } +} diff --git a/src/Mod/Mcp/Routes/admin.php b/src/Mod/Mcp/Routes/admin.php new file mode 100644 index 0000000..251c438 --- /dev/null +++ b/src/Mod/Mcp/Routes/admin.php @@ -0,0 +1,70 @@ +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'); +}); diff --git a/src/Mod/Mcp/Services/AgentSessionService.php b/src/Mod/Mcp/Services/AgentSessionService.php new file mode 100644 index 0000000..dac3aad --- /dev/null +++ b/src/Mod/Mcp/Services/AgentSessionService.php @@ -0,0 +1,336 @@ +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); + } +} diff --git a/src/Mod/Mcp/Services/AgentToolRegistry.php b/src/Mod/Mcp/Services/AgentToolRegistry.php new file mode 100644 index 0000000..e3718c9 --- /dev/null +++ b/src/Mod/Mcp/Services/AgentToolRegistry.php @@ -0,0 +1,244 @@ + + */ + 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 $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 + */ + public function all(): Collection + { + return collect($this->tools); + } + + /** + * Get tools filtered by category. + * + * @return Collection + */ + 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 + */ + 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 + */ + public function names(): array + { + return array_keys($this->tools); + } + + /** + * Get tool count. + */ + public function count(): int + { + return count($this->tools); + } +} diff --git a/src/Mod/Mcp/Services/AuditLogService.php b/src/Mod/Mcp/Services/AuditLogService.php new file mode 100644 index 0000000..ee2f0c6 --- /dev/null +++ b/src/Mod/Mcp/Services/AuditLogService.php @@ -0,0 +1,480 @@ +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); + } +} diff --git a/src/Mod/Mcp/Services/CircuitBreaker.php b/src/Mod/Mcp/Services/CircuitBreaker.php new file mode 100644 index 0000000..4b130df --- /dev/null +++ b/src/Mod/Mcp/Services/CircuitBreaker.php @@ -0,0 +1,442 @@ +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'; + } +} diff --git a/src/Mod/Mcp/Services/DataRedactor.php b/src/Mod/Mcp/Services/DataRedactor.php new file mode 100644 index 0000000..54c1e71 --- /dev/null +++ b/src/Mod/Mcp/Services/DataRedactor.php @@ -0,0 +1,305 @@ +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; + } +} diff --git a/src/Mod/Mcp/Services/McpHealthService.php b/src/Mod/Mcp/Services/McpHealthService.php new file mode 100644 index 0000000..83fafe1 --- /dev/null +++ b/src/Mod/Mcp/Services/McpHealthService.php @@ -0,0 +1,303 @@ +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 => 'Online', + self::STATUS_OFFLINE => 'Offline', + self::STATUS_DEGRADED => 'Degraded', + default => 'Unknown', + }; + } + + /** + * 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', + }; + } +} diff --git a/src/Mod/Mcp/Services/McpMetricsService.php b/src/Mod/Mcp/Services/McpMetricsService.php new file mode 100644 index 0000000..7a0a23b --- /dev/null +++ b/src/Mod/Mcp/Services/McpMetricsService.php @@ -0,0 +1,267 @@ +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); + } +} diff --git a/src/Mod/Mcp/Services/McpQuotaService.php b/src/Mod/Mcp/Services/McpQuotaService.php new file mode 100644 index 0000000..d695983 --- /dev/null +++ b/src/Mod/Mcp/Services/McpQuotaService.php @@ -0,0 +1,395 @@ +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 + */ + 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 + */ + 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}"; + } +} diff --git a/src/Mod/Mcp/Services/McpWebhookDispatcher.php b/src/Mod/Mcp/Services/McpWebhookDispatcher.php new file mode 100644 index 0000000..91f0048 --- /dev/null +++ b/src/Mod/Mcp/Services/McpWebhookDispatcher.php @@ -0,0 +1,128 @@ +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(), + ]); + } + } +} diff --git a/src/Mod/Mcp/Services/OpenApiGenerator.php b/src/Mod/Mcp/Services/OpenApiGenerator.php new file mode 100644 index 0000000..6872eb3 --- /dev/null +++ b/src/Mod/Mcp/Services/OpenApiGenerator.php @@ -0,0 +1,409 @@ +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; + } +} diff --git a/src/Mod/Mcp/Services/SqlQueryValidator.php b/src/Mod/Mcp/Services/SqlQueryValidator.php new file mode 100644 index 0000000..134186a --- /dev/null +++ b/src/Mod/Mcp/Services/SqlQueryValidator.php @@ -0,0 +1,302 @@ + 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); + } +} diff --git a/src/Mod/Mcp/Services/ToolAnalyticsService.php b/src/Mod/Mcp/Services/ToolAnalyticsService.php new file mode 100644 index 0000000..daa67c1 --- /dev/null +++ b/src/Mod/Mcp/Services/ToolAnalyticsService.php @@ -0,0 +1,386 @@ + + */ + protected array $pendingMetrics = []; + + /** + * Track tools used in current session for combination tracking. + * + * @var array> + */ + 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; + } +} diff --git a/src/Mod/Mcp/Services/ToolDependencyService.php b/src/Mod/Mcp/Services/ToolDependencyService.php new file mode 100644 index 0000000..3376f77 --- /dev/null +++ b/src/Mod/Mcp/Services/ToolDependencyService.php @@ -0,0 +1,496 @@ +> + */ + protected array $dependencies = []; + + /** + * Custom dependency validators. + * + * @var array + */ + protected array $customValidators = []; + + public function __construct() + { + $this->registerDefaultDependencies(); + } + + /** + * Register dependencies for a tool. + * + * @param string $toolName The tool name + * @param array $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 + */ + 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 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 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 + */ + 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 + */ + 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 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 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 $missing + * @return array + */ + 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'), + ]); + } +} diff --git a/src/Mod/Mcp/Services/ToolRateLimiter.php b/src/Mod/Mcp/Services/ToolRateLimiter.php new file mode 100644 index 0000000..6178983 --- /dev/null +++ b/src/Mod/Mcp/Services/ToolRateLimiter.php @@ -0,0 +1,144 @@ + 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, + ]; + } +} diff --git a/src/Mod/Mcp/Services/ToolRegistry.php b/src/Mod/Mcp/Services/ToolRegistry.php new file mode 100644 index 0000000..5738007 --- /dev/null +++ b/src/Mod/Mcp/Services/ToolRegistry.php @@ -0,0 +1,353 @@ +> + */ + 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 + */ + 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 + */ + 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> + */ + public function getToolsByCategory(string $serverId): Collection + { + return $this->getToolsForServer($serverId) + ->groupBy('category') + ->sortKeys(); + } + + /** + * Search tools by name or description. + * + * @return Collection + */ + 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 + */ + 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'] ?? []), + ]; + } +} diff --git a/src/Mod/Mcp/Services/ToolVersionService.php b/src/Mod/Mcp/Services/ToolVersionService.php new file mode 100644 index 0000000..83ee630 --- /dev/null +++ b/src/Mod/Mcp/Services/ToolVersionService.php @@ -0,0 +1,478 @@ +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 + */ + 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 + */ + 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"); + } +} diff --git a/src/Mod/Mcp/Tests/Unit/McpQuotaServiceTest.php b/src/Mod/Mcp/Tests/Unit/McpQuotaServiceTest.php new file mode 100644 index 0000000..b8ef3fd --- /dev/null +++ b/src/Mod/Mcp/Tests/Unit/McpQuotaServiceTest.php @@ -0,0 +1,245 @@ +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); + } +} diff --git a/src/Mod/Mcp/Tests/Unit/ToolDependencyServiceTest.php b/src/Mod/Mcp/Tests/Unit/ToolDependencyServiceTest.php new file mode 100644 index 0000000..6eb0838 --- /dev/null +++ b/src/Mod/Mcp/Tests/Unit/ToolDependencyServiceTest.php @@ -0,0 +1,480 @@ +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); + } +} diff --git a/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php b/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php new file mode 100644 index 0000000..caaedd1 --- /dev/null +++ b/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php @@ -0,0 +1,441 @@ +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']); + } +} diff --git a/src/Mod/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php b/src/Mod/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php new file mode 100644 index 0000000..526c3ff --- /dev/null +++ b/src/Mod/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php @@ -0,0 +1,110 @@ +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'); + }); +}); diff --git a/src/Mod/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php b/src/Mod/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php new file mode 100644 index 0000000..b3c1e4f --- /dev/null +++ b/src/Mod/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php @@ -0,0 +1,190 @@ +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); + }); +}); diff --git a/src/Mod/Mcp/Tests/UseCase/ApiKeyManagerBasic.php b/src/Mod/Mcp/Tests/UseCase/ApiKeyManagerBasic.php new file mode 100644 index 0000000..270680c --- /dev/null +++ b/src/Mod/Mcp/Tests/UseCase/ApiKeyManagerBasic.php @@ -0,0 +1,71 @@ +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')); + }); +}); diff --git a/src/Mod/Mcp/Tools/Commerce/CreateCoupon.php b/src/Mod/Mcp/Tools/Commerce/CreateCoupon.php new file mode 100644 index 0000000..18b53eb --- /dev/null +++ b/src/Mod/Mcp/Tools/Commerce/CreateCoupon.php @@ -0,0 +1,100 @@ +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'), + ]; + } +} diff --git a/src/Mod/Mcp/Tools/Commerce/GetBillingStatus.php b/src/Mod/Mcp/Tools/Commerce/GetBillingStatus.php new file mode 100644 index 0000000..d30f037 --- /dev/null +++ b/src/Mod/Mcp/Tools/Commerce/GetBillingStatus.php @@ -0,0 +1,77 @@ +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 []; + } +} diff --git a/src/Mod/Mcp/Tools/Commerce/ListInvoices.php b/src/Mod/Mcp/Tools/Commerce/ListInvoices.php new file mode 100644 index 0000000..3f4282c --- /dev/null +++ b/src/Mod/Mcp/Tools/Commerce/ListInvoices.php @@ -0,0 +1,76 @@ +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)'), + ]; + } +} diff --git a/src/Mod/Mcp/Tools/Commerce/UpgradePlan.php b/src/Mod/Mcp/Tools/Commerce/UpgradePlan.php new file mode 100644 index 0000000..44e57a9 --- /dev/null +++ b/src/Mod/Mcp/Tools/Commerce/UpgradePlan.php @@ -0,0 +1,120 @@ +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)'), + ]; + } +} diff --git a/src/Mod/Mcp/Tools/Concerns/RequiresWorkspaceContext.php b/src/Mod/Mcp/Tools/Concerns/RequiresWorkspaceContext.php new file mode 100644 index 0000000..9b06991 --- /dev/null +++ b/src/Mod/Mcp/Tools/Concerns/RequiresWorkspaceContext.php @@ -0,0 +1,135 @@ +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; + } +} diff --git a/src/Mod/Mcp/Tools/Concerns/ValidatesDependencies.php b/src/Mod/Mcp/Tools/Concerns/ValidatesDependencies.php new file mode 100644 index 0000000..ad92c7e --- /dev/null +++ b/src/Mod/Mcp/Tools/Concerns/ValidatesDependencies.php @@ -0,0 +1,123 @@ + + */ + 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 + */ + 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, + ]; + } +} diff --git a/src/Mod/Mcp/Tools/ContentTools.php b/src/Mod/Mcp/Tools/ContentTools.php new file mode 100644 index 0000000..fd5b69d --- /dev/null +++ b/src/Mod/Mcp/Tools/ContentTools.php @@ -0,0 +1,633 @@ +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(), + ]; + } +} diff --git a/src/Mod/Mcp/Tools/GetStats.php b/src/Mod/Mcp/Tools/GetStats.php new file mode 100644 index 0000000..16ee6e3 --- /dev/null +++ b/src/Mod/Mcp/Tools/GetStats.php @@ -0,0 +1,30 @@ + 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 []; + } +} diff --git a/src/Mod/Mcp/Tools/ListRoutes.php b/src/Mod/Mcp/Tools/ListRoutes.php new file mode 100644 index 0000000..bdb9230 --- /dev/null +++ b/src/Mod/Mcp/Tools/ListRoutes.php @@ -0,0 +1,32 @@ +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 []; + } +} diff --git a/src/Mod/Mcp/Tools/ListSites.php b/src/Mod/Mcp/Tools/ListSites.php new file mode 100644 index 0000000..7ce9a9f --- /dev/null +++ b/src/Mod/Mcp/Tools/ListSites.php @@ -0,0 +1,32 @@ + '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 []; + } +} diff --git a/src/Mod/Mcp/Tools/ListTables.php b/src/Mod/Mcp/Tools/ListTables.php new file mode 100644 index 0000000..f6255ea --- /dev/null +++ b/src/Mod/Mcp/Tools/ListTables.php @@ -0,0 +1,28 @@ +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 []; + } +} diff --git a/src/Mod/Mcp/Tools/QueryDatabase.php b/src/Mod/Mcp/Tools/QueryDatabase.php new file mode 100644 index 0000000..164199e --- /dev/null +++ b/src/Mod/Mcp/Tools/QueryDatabase.php @@ -0,0 +1,281 @@ +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])); + } +} diff --git a/src/Mod/Mcp/View/Blade/admin/analytics/dashboard.blade.php b/src/Mod/Mcp/View/Blade/admin/analytics/dashboard.blade.php new file mode 100644 index 0000000..10a44b0 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/analytics/dashboard.blade.php @@ -0,0 +1,233 @@ +
+ +
+
+ Tool Usage Analytics + Monitor MCP tool usage patterns, performance, and errors +
+
+ + 7 Days + 14 Days + 30 Days + + Refresh +
+
+ + +
+ @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', + ]) +
+ + +
+ +
+ + @if($tab === 'overview') +
+ +
+
+ Top 10 Most Used Tools +
+
+ @if($this->popularTools->isEmpty()) +
No tool usage data available
+ @else +
+ @php $maxCalls = $this->popularTools->first()->totalCalls ?: 1; @endphp + @foreach($this->popularTools as $tool) +
+
+ {{ $tool->toolName }} +
+
+
+
+
+
+
+
+ {{ number_format($tool->totalCalls) }} +
+
+ {{ $tool->errorRate }}% +
+
+ @endforeach +
+ @endif +
+
+ + +
+
+ Tools with Highest Error Rates +
+
+ @if($this->errorProneTools->isEmpty()) +
All tools are healthy - no significant errors
+ @else +
+ @foreach($this->errorProneTools as $tool) +
+
+ + {{ $tool->toolName }} + +
+ {{ number_format($tool->errorCount) }} errors / {{ number_format($tool->totalCalls) }} calls +
+
+ + {{ $tool->errorRate }}% errors + +
+ @endforeach +
+ @endif +
+
+
+ @endif + + @if($tab === 'tools') + +
+
+ All Tools + {{ $this->sortedTools->count() }} tools +
+
+ @include('mcp::admin.analytics.partials.tool-table', ['tools' => $this->sortedTools]) +
+
+ @endif + + @if($tab === 'errors') + +
+
+ Error Analysis +
+
+ @if($this->errorProneTools->isEmpty()) +
+
+ All tools are healthy - no significant errors detected +
+ @else +
+ @foreach($this->errorProneTools as $tool) +
+
+ + {{ $tool->toolName }} + + + {{ $tool->errorRate }}% Error Rate + +
+
+
+ Total Calls: + {{ number_format($tool->totalCalls) }} +
+
+ Errors: + {{ number_format($tool->errorCount) }} +
+
+ Avg Duration: + {{ $this->formatDuration($tool->avgDurationMs) }} +
+
+ Max Duration: + {{ $this->formatDuration($tool->maxDurationMs) }} +
+
+
+ @endforeach +
+ @endif +
+
+ @endif + + @if($tab === 'combinations') + +
+
+ Popular Tool Combinations + Tools frequently used together in the same session +
+
+ @if($this->toolCombinations->isEmpty()) +
No tool combination data available yet
+ @else +
+ @foreach($this->toolCombinations as $combo) +
+
+ {{ $combo['tool_a'] }} + + + {{ $combo['tool_b'] }} +
+ + {{ number_format($combo['occurrences']) }} times + +
+ @endforeach +
+ @endif +
+
+ @endif +
diff --git a/src/Mod/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php b/src/Mod/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php new file mode 100644 index 0000000..c873cf3 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php @@ -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 + +
+ {{ $label }} + {{ $value }} + @if($subtext) + {{ $subtext }} + @endif +
diff --git a/src/Mod/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php b/src/Mod/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php new file mode 100644 index 0000000..a03c517 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php @@ -0,0 +1,100 @@ +@props(['tools']) + + + + + + + + + + + + + + + @forelse($tools as $tool) + + + + + + + + + + @empty + + + + @endforelse + +
+
+ Tool Name + @if($sortColumn === 'toolName') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+
+ Total Calls + @if($sortColumn === 'totalCalls') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+
+ Errors + @if($sortColumn === 'errorCount') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+
+ Error Rate + @if($sortColumn === 'errorRate') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+
+ Avg Duration + @if($sortColumn === 'avgDurationMs') + {{ $sortDirection === 'asc' ? '▲' : '▼' }} + @endif +
+
+ Min / Max + + Actions +
+ + {{ $tool->toolName }} + + + {{ number_format($tool->totalCalls) }} + + {{ number_format($tool->errorCount) }} + + + {{ $tool->errorRate }}% + + + {{ $this->formatDuration($tool->avgDurationMs) }} + + {{ $this->formatDuration($tool->minDurationMs) }} / {{ $this->formatDuration($tool->maxDurationMs) }} + + + View Details + +
+ No tool usage data available +
diff --git a/src/Mod/Mcp/View/Blade/admin/analytics/tool-detail.blade.php b/src/Mod/Mcp/View/Blade/admin/analytics/tool-detail.blade.php new file mode 100644 index 0000000..3166aaa --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/analytics/tool-detail.blade.php @@ -0,0 +1,183 @@ +
+ +
+
+ + {{ $toolName }} + Detailed usage analytics for this tool +
+
+ + 7 Days + 14 Days + 30 Days + + Refresh +
+
+ + +
+
+ Total Calls + {{ number_format($this->stats->totalCalls) }} +
+ +
+ Error Rate + + {{ $this->stats->errorRate }}% + +
+ +
+ Total Errors + + {{ number_format($this->stats->errorCount) }} + +
+ +
+ Avg Duration + {{ $this->formatDuration($this->stats->avgDurationMs) }} +
+ +
+ Min Duration + {{ $this->formatDuration($this->stats->minDurationMs) }} +
+ +
+ Max Duration + {{ $this->formatDuration($this->stats->maxDurationMs) }} +
+
+ + +
+
+ Usage Trend +
+
+ @if(empty($this->trends) || array_sum(array_column($this->trends, 'calls')) === 0) +
No usage data available for this period
+ @else +
+ @php + $maxCalls = max(array_column($this->trends, 'calls')) ?: 1; + @endphp + @foreach($this->trends as $day) +
+ {{ $day['date_formatted'] }} +
+
+ @php + $callsWidth = ($day['calls'] / $maxCalls) * 100; + $errorsWidth = $day['calls'] > 0 ? ($day['errors'] / $day['calls']) * $callsWidth : 0; + $successWidth = $callsWidth - $errorsWidth; + @endphp +
+
+
+
+
+ {{ $day['calls'] }} +
+
+ @if($day['calls'] > 0) + + {{ round($day['error_rate'], 1) }}% + + @else + - + @endif +
+
+ @endforeach +
+ +
+
+
+ Successful +
+
+
+ Errors +
+
+ @endif +
+
+ + +
+
+ Response Time Distribution +
+
+
+
+
Fastest
+
{{ $this->formatDuration($this->stats->minDurationMs) }}
+
+
+
Average
+
{{ $this->formatDuration($this->stats->avgDurationMs) }}
+
+
+
Slowest
+
{{ $this->formatDuration($this->stats->maxDurationMs) }}
+
+
+
+
+ + +
+
+ Daily Breakdown +
+
+ + + + + + + + + + + + @forelse($this->trends as $day) + @if($day['calls'] > 0) + + + + + + + + @endif + @empty + + + + @endforelse + +
DateCallsErrorsError RateAvg Duration
{{ $day['date'] }}{{ number_format($day['calls']) }}{{ number_format($day['errors']) }} + + {{ round($day['error_rate'], 1) }}% + + {{ $this->formatDuration($day['avg_duration_ms']) }}
+ No data available for this period +
+
+
+
diff --git a/src/Mod/Mcp/View/Blade/admin/api-key-manager.blade.php b/src/Mod/Mcp/View/Blade/admin/api-key-manager.blade.php new file mode 100644 index 0000000..7226a73 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/api-key-manager.blade.php @@ -0,0 +1,268 @@ +
+ + @if(session('message')) +
+

{{ session('message') }}

+
+ @endif + + +
+
+

+ {{ __('mcp::mcp.keys.title') }} +

+

+ {{ __('mcp::mcp.keys.description') }} +

+
+ + {{ __('mcp::mcp.keys.actions.create') }} + +
+ + +
+ @if($keys->isEmpty()) +
+
+ +
+

{{ __('mcp::mcp.keys.empty.title') }}

+

+ {{ __('mcp::mcp.keys.empty.description') }} +

+ + {{ __('mcp::mcp.keys.actions.create_first') }} + +
+ @else + + + + + + + + + + + + + @foreach($keys as $key) + + + + + + + + + @endforeach + +
+ {{ __('mcp::mcp.keys.table.name') }} + + {{ __('mcp::mcp.keys.table.key') }} + + {{ __('mcp::mcp.keys.table.scopes') }} + + {{ __('mcp::mcp.keys.table.last_used') }} + + {{ __('mcp::mcp.keys.table.expires') }} + + {{ __('mcp::mcp.keys.table.actions') }} +
+ {{ $key->name }} + + + {{ $key->prefix }}_**** + + +
+ @foreach($key->scopes ?? [] as $scope) + + {{ $scope }} + + @endforeach +
+
+ {{ $key->last_used_at?->diffForHumans() ?? __('mcp::mcp.keys.status.never') }} + + @if($key->expires_at) + @if($key->expires_at->isPast()) + {{ __('mcp::mcp.keys.status.expired') }} + @else + {{ $key->expires_at->diffForHumans() }} + @endif + @else + {{ __('mcp::mcp.keys.status.never') }} + @endif + + + {{ __('mcp::mcp.keys.actions.revoke') }} + +
+ @endif +
+ + +
+ +
+

+ + {{ __('mcp::mcp.keys.auth.title') }} +

+

+ {{ __('mcp::mcp.keys.auth.description') }} +

+
+
+

{{ __('mcp::mcp.keys.auth.header_recommended') }}

+
Authorization: Bearer hk_abc123_****
+
+
+

{{ __('mcp::mcp.keys.auth.header_api_key') }}

+
X-API-Key: hk_abc123_****
+
+
+
+ + +
+

+ + {{ __('mcp::mcp.keys.example.title') }} +

+

+ {{ __('mcp::mcp.keys.example.description') }} +

+
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": {}
+  }'
+
+
+ + + +
+

{{ __('mcp::mcp.keys.create_modal.title') }}

+ +
+ +
+ {{ __('mcp::mcp.keys.create_modal.name_label') }} + + @error('newKeyName') +

{{ $message }}

+ @enderror +
+ + +
+ {{ __('mcp::mcp.keys.create_modal.permissions_label') }} +
+ + + +
+
+ + +
+ {{ __('mcp::mcp.keys.create_modal.expiry_label') }} + + + + + + +
+
+ +
+ {{ __('mcp::mcp.keys.create_modal.cancel') }} + {{ __('mcp::mcp.keys.create_modal.create') }} +
+
+
+ + + +
+
+
+ +
+

{{ __('mcp::mcp.keys.new_key_modal.title') }}

+
+ +
+

+ {{ __('mcp::mcp.keys.new_key_modal.warning') }} {{ __('mcp::mcp.keys.new_key_modal.warning_detail') }} +

+
+ +
+
{{ $newPlainKey }}
+ +
+ +
+ {{ __('mcp::mcp.keys.new_key_modal.done') }} +
+
+
+
diff --git a/src/Mod/Mcp/View/Blade/admin/audit-log-viewer.blade.php b/src/Mod/Mcp/View/Blade/admin/audit-log-viewer.blade.php new file mode 100644 index 0000000..dbac118 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/audit-log-viewer.blade.php @@ -0,0 +1,400 @@ +{{-- +MCP Audit Log Viewer. + +Displays immutable audit trail for MCP tool executions. +Includes integrity verification and compliance export features. +--}} + +
+ {{-- Header --}} +
+
+ {{ __('MCP Audit Log') }} + Immutable audit trail for tool executions with hash chain integrity +
+
+ + Verify Integrity + + + Export + +
+
+ + {{-- Stats Cards --}} +
+
+
Total Entries
+
+ {{ number_format($this->stats['total']) }} +
+
+
+
Success Rate
+
+ {{ $this->stats['success_rate'] }}% +
+
+
+
Failed Calls
+
+ {{ number_format($this->stats['failed']) }} +
+
+
+
Sensitive Calls
+
+ {{ number_format($this->stats['sensitive_calls']) }} +
+
+
+ + {{-- Filters --}} +
+
+ +
+ + All tools + @foreach ($this->tools as $toolName) + {{ $toolName }} + @endforeach + + + All workspaces + @foreach ($this->workspaces as $ws) + {{ $ws->name }} + @endforeach + + + All statuses + Success + Failed + + + All sensitivity + Sensitive only + Normal only + + + + @if($search || $tool || $workspace || $status || $sensitivity || $dateFrom || $dateTo) + Clear + @endif +
+ + {{-- Audit Log Table --}} + + + ID + Time + Tool + Workspace + Status + Sensitivity + Actor + Duration + + + + + @forelse ($this->entries as $entry) + + + #{{ $entry->id }} + + + {{ $entry->created_at->format('M j, Y H:i:s') }} + + +
{{ $entry->tool_name }}
+
{{ $entry->server_id }}
+
+ + @if($entry->workspace) + {{ $entry->workspace->name }} + @else + - + @endif + + + + {{ $entry->success ? 'Success' : 'Failed' }} + + + + @if($entry->is_sensitive) + + Sensitive + + @else + - + @endif + + + {{ $entry->getActorDisplay() }} + @if($entry->actor_ip) +
{{ $entry->actor_ip }}
+ @endif +
+ + {{ $entry->getDurationForHumans() }} + + + + View + + +
+ @empty + + +
+
+ +
+ No audit entries found + Audit logs will appear here as tools are executed. +
+
+
+ @endforelse +
+
+ + @if($this->entries->hasPages()) +
+ {{ $this->entries->links() }} +
+ @endif + + {{-- Entry Detail Modal --}} + @if($this->selectedEntry) + +
+
+ Audit Entry #{{ $this->selectedEntry->id }} + +
+ + {{-- Integrity Status --}} + @php + $integrity = $this->selectedEntry->getIntegrityStatus(); + @endphp +
+
+ + + {{ $integrity['valid'] ? 'Integrity Verified' : 'Integrity Issues Detected' }} + +
+ @if(!$integrity['valid']) +
    + @foreach($integrity['issues'] as $issue) +
  • {{ $issue }}
  • + @endforeach +
+ @endif +
+ + {{-- Entry Details --}} +
+
+
Tool
+
{{ $this->selectedEntry->tool_name }}
+
+
+
Server
+
{{ $this->selectedEntry->server_id }}
+
+
+
Timestamp
+
{{ $this->selectedEntry->created_at->format('Y-m-d H:i:s.u') }}
+
+
+
Duration
+
{{ $this->selectedEntry->getDurationForHumans() }}
+
+
+
Status
+
+ + {{ $this->selectedEntry->success ? 'Success' : 'Failed' }} + +
+
+
+
Actor
+
{{ $this->selectedEntry->getActorDisplay() }}
+
+
+ + @if($this->selectedEntry->is_sensitive) +
+
+ + Sensitive Tool +
+

+ {{ $this->selectedEntry->sensitivity_reason ?? 'This tool is flagged as sensitive.' }} +

+
+ @endif + + @if($this->selectedEntry->error_message) +
+
Error
+
+ @if($this->selectedEntry->error_code) +
+ {{ $this->selectedEntry->error_code }} +
+ @endif +
+ {{ $this->selectedEntry->error_message }} +
+
+
+ @endif + + @if($this->selectedEntry->input_params) +
+
Input Parameters
+
{{ json_encode($this->selectedEntry->input_params, JSON_PRETTY_PRINT) }}
+
+ @endif + + @if($this->selectedEntry->output_summary) +
+
Output Summary
+
{{ json_encode($this->selectedEntry->output_summary, JSON_PRETTY_PRINT) }}
+
+ @endif + + {{-- Hash Chain Info --}} +
+
Hash Chain
+
+
+ Entry Hash: + {{ $this->selectedEntry->entry_hash }} +
+
+ Previous Hash: + {{ $this->selectedEntry->previous_hash ?? '(first entry)' }} +
+
+
+
+
+ @endif + + {{-- Integrity Verification Modal --}} + @if($showIntegrityModal && $integrityStatus) + +
+
+ Integrity Verification + +
+ +
+
+ +
+
+ {{ $integrityStatus['valid'] ? 'Audit Log Verified' : 'Integrity Issues Detected' }} +
+
+ {{ number_format($integrityStatus['verified']) }} of {{ number_format($integrityStatus['total']) }} entries verified +
+
+
+
+ + @if(!$integrityStatus['valid'] && !empty($integrityStatus['issues'])) +
+
Issues Found:
+
+ @foreach($integrityStatus['issues'] as $issue) +
+
+ Entry #{{ $issue['id'] }}: {{ $issue['type'] }} +
+
+ {{ $issue['message'] }} +
+
+ @endforeach +
+
+ @endif + +
+ + Close + +
+
+
+ @endif + + {{-- Export Modal --}} + @if($showExportModal) + +
+
+ Export Audit Log + +
+ +
+

+ Export the audit log with current filters applied. The export includes integrity verification metadata. +

+ +
+ Export Format + + JSON (with integrity metadata) + CSV (data only) + +
+ +
+
Current Filters:
+
    + @if($tool) +
  • Tool: {{ $tool }}
  • + @endif + @if($workspace) +
  • Workspace: {{ $this->workspaces->firstWhere('id', $workspace)?->name }}
  • + @endif + @if($dateFrom || $dateTo) +
  • Date: {{ $dateFrom ?: 'start' }} to {{ $dateTo ?: 'now' }}
  • + @endif + @if($sensitivity === 'sensitive') +
  • Sensitive only
  • + @endif + @if(!$tool && !$workspace && !$dateFrom && !$dateTo && !$sensitivity) +
  • All entries
  • + @endif +
+
+
+ +
+ + Cancel + + + Download + +
+
+
+ @endif +
diff --git a/src/Mod/Mcp/View/Blade/admin/mcp-playground.blade.php b/src/Mod/Mcp/View/Blade/admin/mcp-playground.blade.php new file mode 100644 index 0000000..d5f5191 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/mcp-playground.blade.php @@ -0,0 +1,502 @@ +
+ {{-- Header --}} +
+
+
+

MCP Playground

+

+ Interactive tool testing with documentation and examples +

+
+
+ +
+
+
+ + {{-- Error Display --}} + @if($error) +
+
+ + + +

{{ $error }}

+
+
+ @endif + +
+ {{-- Left Sidebar: Tool Browser --}} +
+
+ {{-- Server Selection --}} +
+ + +
+ + @if($selectedServer) + {{-- Search --}} +
+
+ + + + +
+
+ + {{-- Category Filter --}} + @if($categories->isNotEmpty()) +
+ +
+ + @foreach($categories as $category) + + @endforeach +
+
+ @endif + + {{-- Tools List --}} +
+ @forelse($toolsByCategory as $category => $categoryTools) +
+

{{ $category }}

+
+ @foreach($categoryTools as $tool) + + @endforeach + @empty +
+

No tools found

+
+ @endforelse +
+ @else +
+ + + +

Select a server to browse tools

+
+ @endif +
+
+ + {{-- Center: Tool Details & Input Form --}} +
+ {{-- API Key Authentication --}} +
+

+ + + + Authentication +

+
+
+ + +

Paste your API key to execute requests live

+
+
+ + @if($keyStatus === 'valid') + + + + + Valid + + @elseif($keyStatus === 'invalid') + + + + + Invalid key + + @elseif($keyStatus === 'expired') + + + + + Expired + + @endif +
+ @if($keyInfo) +
+
+
+ Name: + {{ $keyInfo['name'] }} +
+
+ Workspace: + {{ $keyInfo['workspace'] }} +
+
+
+ @endif +
+
+ + {{-- Tool Form --}} + @if($currentTool) +
+
+
+
+

{{ $currentTool['name'] }}

+

{{ $currentTool['description'] }}

+
+ + {{ $currentTool['category'] }} + +
+
+ + @php + $properties = $currentTool['inputSchema']['properties'] ?? []; + $required = $currentTool['inputSchema']['required'] ?? []; + @endphp + + @if(count($properties) > 0) +
+
+

Parameters

+ +
+ + @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 + +
+ + + @if(isset($schema['enum'])) + + @elseif($type === 'boolean') + + @elseif($type === 'integer' || $type === 'number') + + @elseif($type === 'array' || $type === 'object') + + @else + + @endif + + @if($description) +

{{ $description }}

+ @endif +
+ @endforeach +
+ @else +

This tool has no parameters.

+ @endif + +
+ +
+
+ @else +
+ + + +

Select a tool

+

+ Choose a tool from the sidebar to view its documentation and test it +

+
+ @endif +
+ + {{-- Right: Response Viewer --}} +
+
+
+

Response

+ @if($executionTime > 0) + {{ $executionTime }}ms + @endif +
+ +
+ @if($lastResponse) +
+ +
+ + @if(isset($lastResponse['error'])) +
+

{{ $lastResponse['error'] }}

+
+ @endif + +
+
{{ json_encode($lastResponse, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
+
+ + @if(isset($lastResponse['executed']) && !$lastResponse['executed']) +
+

+ This is a preview. Add a valid API key to execute requests live. +

+
+ @endif + @else +
+ + + +

Response will appear here

+
+ @endif +
+ + {{-- API Reference --}} +
+

API Reference

+
+
+ Endpoint + /api/v1/mcp/tools/call +
+
+ Method + POST +
+
+ Auth + Bearer token +
+
+
+
+
+
+ + {{-- History Panel (Collapsible Bottom) --}} +
+
+
+

+ + + + Conversation History +

+ @if(count($conversationHistory) > 0) + + @endif +
+ + @if(count($conversationHistory) > 0) +
+ @foreach($conversationHistory as $index => $entry) +
+
+
+
+ @if($entry['success'] ?? true) + + Success + + @else + + Failed + + @endif + {{ $entry['tool'] }} + on + {{ $entry['server'] }} +
+
+ {{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans() }} + @if(isset($entry['duration_ms'])) + {{ $entry['duration_ms'] }}ms + @endif +
+
+
+ + +
+
+
+ @endforeach +
+ @else +
+

No history yet. Execute a tool to see it here.

+
+ @endif +
+
+
diff --git a/src/Mod/Mcp/View/Blade/admin/playground.blade.php b/src/Mod/Mcp/View/Blade/admin/playground.blade.php new file mode 100644 index 0000000..1077ee5 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/playground.blade.php @@ -0,0 +1,281 @@ +
+
+

{{ __('mcp::mcp.playground.title') }}

+

+ {{ __('mcp::mcp.playground.description') }} +

+
+ + {{-- Error Display --}} + @if($error) +
+
+ +

{{ $error }}

+
+
+ @endif + +
+ +
+ +
+

{{ __('mcp::mcp.playground.auth.title') }}

+ +
+
+ +
+ +
+ + {{ __('mcp::mcp.playground.auth.validate') }} + + + @if($keyStatus === 'valid') + + + {{ __('mcp::mcp.playground.auth.status.valid') }} + + @elseif($keyStatus === 'invalid') + + + {{ __('mcp::mcp.playground.auth.status.invalid') }} + + @elseif($keyStatus === 'expired') + + + {{ __('mcp::mcp.playground.auth.status.expired') }} + + @elseif($keyStatus === 'empty') + + {{ __('mcp::mcp.playground.auth.status.empty') }} + + @endif +
+ + @if($keyInfo) +
+
+
+ {{ __('mcp::mcp.playground.auth.key_info.name') }}: + {{ $keyInfo['name'] }} +
+
+ {{ __('mcp::mcp.playground.auth.key_info.workspace') }}: + {{ $keyInfo['workspace'] }} +
+
+ {{ __('mcp::mcp.playground.auth.key_info.scopes') }}: + {{ implode(', ', $keyInfo['scopes'] ?? []) }} +
+
+ {{ __('mcp::mcp.playground.auth.key_info.last_used') }}: + {{ $keyInfo['last_used'] }} +
+
+
+ @elseif(!$isAuthenticated && !$apiKey) +
+

+ {{ __('mcp::mcp.playground.auth.sign_in_prompt') }} + {{ __('mcp::mcp.playground.auth.sign_in_description') }} +

+
+ @endif +
+
+ + +
+

{{ __('mcp::mcp.playground.tools.title') }}

+ +
+ + @foreach($servers as $server) + {{ $server['name'] }} + @endforeach + + + @if($selectedServer && count($tools) > 0) + + @foreach($tools as $tool) + {{ $tool['name'] }} + @endforeach + + @endif +
+
+ + + @if($toolSchema) +
+
+

{{ $toolSchema['name'] }}

+

{{ $toolSchema['description'] ?? $toolSchema['purpose'] ?? '' }}

+
+ + @php + $params = $toolSchema['inputSchema']['properties'] ?? $toolSchema['parameters'] ?? []; + $required = $toolSchema['inputSchema']['required'] ?? []; + @endphp + + @if(count($params) > 0) +
+

{{ __('mcp::mcp.playground.tools.arguments') }}

+ + @foreach($params as $name => $schema) +
+ @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'])) + + @foreach($schema['enum'] as $option) + {{ $option }} + @endforeach + + @elseif($paramType === 'boolean') + + true + false + + @elseif($paramType === 'integer' || $paramType === 'number') + + @else + + @endif +
+ @endforeach +
+ @else +

{{ __('mcp::mcp.playground.tools.no_arguments') }}

+ @endif + +
+ + + @if($keyStatus === 'valid') + {{ __('mcp::mcp.playground.tools.execute') }} + @else + {{ __('mcp::mcp.playground.tools.generate') }} + @endif + + {{ __('mcp::mcp.playground.tools.executing') }} + +
+
+ @endif +
+ + +
+
+

{{ __('mcp::mcp.playground.response.title') }}

+ + @if($response) +
+
+ +
+
{{ $response }}
+
+ @else +
+ +

{{ __('mcp::mcp.playground.response.empty') }}

+
+ @endif +
+ + +
+

{{ __('mcp::mcp.playground.reference.title') }}

+
+
+ {{ __('mcp::mcp.playground.reference.endpoint') }}: + {{ config('app.url') }}/api/v1/mcp/tools/call +
+
+ {{ __('mcp::mcp.playground.reference.method') }}: + POST +
+
+ {{ __('mcp::mcp.playground.reference.auth') }}: + @if($keyStatus === 'valid') + Bearer {{ Str::limit($apiKey, 20, '...') }} + @else + Bearer <your-api-key> + @endif +
+
+ {{ __('mcp::mcp.playground.reference.content_type') }}: + application/json +
+
+ + @if($isAuthenticated) +
+ + {{ __('mcp::mcp.playground.reference.manage_keys') }} + +
+ @endif +
+
+
+
+ +@script + +@endscript diff --git a/src/Mod/Mcp/View/Blade/admin/quota-usage.blade.php b/src/Mod/Mcp/View/Blade/admin/quota-usage.blade.php new file mode 100644 index 0000000..90f27fe --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/quota-usage.blade.php @@ -0,0 +1,186 @@ +
+ {{-- Header --}} +
+
+

MCP Usage Quota

+

+ Current billing period resets {{ $this->resetDate }} +

+
+ +
+ + {{-- Current Usage Cards --}} +
+ {{-- Tool Calls Card --}} +
+
+
+
+ +
+
+

Tool Calls

+

Monthly usage

+
+
+
+ + @if($quotaLimits['tool_calls_unlimited'] ?? false) +
+ + {{ number_format($currentUsage['tool_calls_count'] ?? 0) }} + + Unlimited +
+ @else +
+
+ + {{ number_format($currentUsage['tool_calls_count'] ?? 0) }} + + + of {{ number_format($quotaLimits['tool_calls_limit'] ?? 0) }} + +
+
+
+
+

+ {{ number_format($remaining['tool_calls'] ?? 0) }} remaining +

+
+ @endif +
+ + {{-- Tokens Card --}} +
+
+
+
+ +
+
+

Tokens

+

Monthly consumption

+
+
+
+ + @if($quotaLimits['tokens_unlimited'] ?? false) +
+ + {{ number_format($currentUsage['total_tokens'] ?? 0) }} + + Unlimited +
+
+
+ Input: + + {{ number_format($currentUsage['input_tokens'] ?? 0) }} + +
+
+ Output: + + {{ number_format($currentUsage['output_tokens'] ?? 0) }} + +
+
+ @else +
+
+ + {{ number_format($currentUsage['total_tokens'] ?? 0) }} + + + of {{ number_format($quotaLimits['tokens_limit'] ?? 0) }} + +
+
+
+
+
+

+ {{ number_format($remaining['tokens'] ?? 0) }} remaining +

+
+ + In: {{ number_format($currentUsage['input_tokens'] ?? 0) }} + + + Out: {{ number_format($currentUsage['output_tokens'] ?? 0) }} + +
+
+
+ @endif +
+
+ + {{-- Usage History --}} + @if($usageHistory->count() > 0) +
+

Usage History

+
+ + + + + + + + + + + + @foreach($usageHistory as $record) + + + + + + + + @endforeach + +
MonthTool CallsInput TokensOutput TokensTotal Tokens
+ {{ $record->month_label }} + + {{ number_format($record->tool_calls_count) }} + + {{ number_format($record->input_tokens) }} + + {{ number_format($record->output_tokens) }} + + {{ number_format($record->total_tokens) }} +
+
+
+ @endif + + {{-- Upgrade Prompt (shown when near limit) --}} + @if(($this->toolCallsPercentage >= 80 || $this->tokensPercentage >= 80) && !($quotaLimits['tool_calls_unlimited'] ?? false)) +
+
+ +
+

Approaching usage limit

+

+ You're nearing your monthly MCP quota. Consider upgrading your plan for higher limits. +

+
+
+
+ @endif +
diff --git a/src/Mod/Mcp/View/Blade/admin/request-log.blade.php b/src/Mod/Mcp/View/Blade/admin/request-log.blade.php new file mode 100644 index 0000000..9086b55 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/request-log.blade.php @@ -0,0 +1,153 @@ +
+
+

{{ __('mcp::mcp.logs.title') }}

+

+ {{ __('mcp::mcp.logs.description') }} +

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ @forelse($requests as $request) + + @empty +
+ {{ __('mcp::mcp.logs.empty') }} +
+ @endforelse +
+ + @if($requests->hasPages()) +
+ {{ $requests->links() }} +
+ @endif +
+ + +
+ @if($selectedRequest) +
+

{{ __('mcp::mcp.logs.detail.title') }}

+ +
+ +
+ +
+ + + {{ $selectedRequest->response_status }} + {{ $selectedRequest->isSuccessful() ? __('mcp::mcp.logs.status_ok') : __('mcp::mcp.logs.status_error') }} + +
+ + +
+ +
{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}
+
+ + +
+ +
{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}
+
+ + @if($selectedRequest->error_message) +
+ +
{{ $selectedRequest->error_message }}
+
+ @endif + + +
+ +
{{ $selectedRequest->toCurl() }}
+
+ + +
+
{{ __('mcp::mcp.logs.detail.metadata.request_id') }}: {{ $selectedRequest->request_id }}
+
{{ __('mcp::mcp.logs.detail.metadata.duration') }}: {{ $selectedRequest->duration_for_humans }}
+
{{ __('mcp::mcp.logs.detail.metadata.ip') }}: {{ $selectedRequest->ip_address ?? __('mcp::mcp.common.na') }}
+
{{ __('mcp::mcp.logs.detail.metadata.time') }}: {{ $selectedRequest->created_at->format('Y-m-d H:i:s') }}
+
+
+ @else +
+ +

{{ __('mcp::mcp.logs.empty_detail') }}

+
+ @endif +
+
+
diff --git a/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php b/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php new file mode 100644 index 0000000..5d6b424 --- /dev/null +++ b/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php @@ -0,0 +1,537 @@ +{{-- +MCP Tool Version Manager. + +Admin interface for managing tool version lifecycles, +viewing schema changes between versions, and setting deprecation schedules. +--}} + +
+ {{-- Header --}} +
+
+ {{ __('Tool Versions') }} + Manage MCP tool version lifecycles and backwards compatibility +
+
+ + Register Version + +
+
+ + {{-- Stats Cards --}} +
+
+
Total Versions
+
+ {{ number_format($this->stats['total_versions']) }} +
+
+
+
Unique Tools
+
+ {{ number_format($this->stats['total_tools']) }} +
+
+
+
Servers
+
+ {{ number_format($this->stats['servers']) }} +
+
+
+
Deprecated
+
+ {{ number_format($this->stats['deprecated_count']) }} +
+
+
+
Sunset
+
+ {{ number_format($this->stats['sunset_count']) }} +
+
+
+ + {{-- Filters --}} +
+
+ +
+ + All servers + @foreach ($this->servers as $serverId) + {{ $serverId }} + @endforeach + + + All statuses + Latest + Active (non-latest) + Deprecated + Sunset + + @if($search || $server || $status) + Clear + @endif +
+ + {{-- Versions Table --}} + + + Tool + Server + Version + Status + Deprecated + Sunset + Created + + + + + @forelse ($this->versions as $version) + + +
{{ $version->tool_name }}
+ @if($version->description) +
{{ $version->description }}
+ @endif +
+ + {{ $version->server_id }} + + + + {{ $version->version }} + + + + + {{ ucfirst($version->status) }} + + + + @if($version->deprecated_at) + {{ $version->deprecated_at->format('M j, Y') }} + @else + - + @endif + + + @if($version->sunset_at) + + {{ $version->sunset_at->format('M j, Y') }} + + @else + - + @endif + + + {{ $version->created_at->format('M j, Y') }} + + + + + + + View Details + + @if(!$version->is_latest && !$version->is_sunset) + + Mark as Latest + + @endif + @if(!$version->is_deprecated && !$version->is_sunset) + + Deprecate + + @endif + + + +
+ @empty + + +
+
+ +
+ No tool versions found + Register tool versions to enable backwards compatibility. +
+
+
+ @endforelse +
+
+ + @if($this->versions->hasPages()) +
+ {{ $this->versions->links() }} +
+ @endif + + {{-- Version Detail Modal --}} + @if($showVersionDetail && $this->selectedVersion) + +
+
+
+ {{ $this->selectedVersion->tool_name }} +
+ + {{ $this->selectedVersion->version }} + + + {{ ucfirst($this->selectedVersion->status) }} + +
+
+ +
+ + {{-- Metadata --}} +
+
+
Server
+
{{ $this->selectedVersion->server_id }}
+
+
+
Created
+
{{ $this->selectedVersion->created_at->format('Y-m-d H:i:s') }}
+
+ @if($this->selectedVersion->deprecated_at) +
+
Deprecated
+
+ {{ $this->selectedVersion->deprecated_at->format('Y-m-d') }} +
+
+ @endif + @if($this->selectedVersion->sunset_at) +
+
Sunset
+
+ {{ $this->selectedVersion->sunset_at->format('Y-m-d') }} +
+
+ @endif +
+ + @if($this->selectedVersion->description) +
+
Description
+
{{ $this->selectedVersion->description }}
+
+ @endif + + @if($this->selectedVersion->changelog) +
+
Changelog
+
+ {!! nl2br(e($this->selectedVersion->changelog)) !!} +
+
+ @endif + + @if($this->selectedVersion->migration_notes) +
+
+ + Migration Notes +
+
+ {!! nl2br(e($this->selectedVersion->migration_notes)) !!} +
+
+ @endif + + {{-- Input Schema --}} + @if($this->selectedVersion->input_schema) +
+
Input Schema
+
{{ $this->formatSchema($this->selectedVersion->input_schema) }}
+
+ @endif + + {{-- Output Schema --}} + @if($this->selectedVersion->output_schema) +
+
Output Schema
+
{{ $this->formatSchema($this->selectedVersion->output_schema) }}
+
+ @endif + + {{-- Version History --}} + @if($this->versionHistory->count() > 1) +
+
Version History
+
+ @foreach($this->versionHistory as $index => $historyVersion) +
+
+ + {{ $historyVersion->version }} + + + {{ ucfirst($historyVersion->status) }} + + + {{ $historyVersion->created_at->format('M j, Y') }} + +
+ @if($historyVersion->id !== $this->selectedVersion->id && $index < $this->versionHistory->count() - 1) + @php $nextVersion = $this->versionHistory[$index + 1] @endphp + + Compare + + @endif +
+ @endforeach +
+
+ @endif +
+
+ @endif + + {{-- Compare Schemas Modal --}} + @if($showCompareModal && $this->schemaComparison) + +
+
+ Schema Comparison + +
+ +
+
+ + {{ $this->schemaComparison['from']->version }} + +
+ +
+ + {{ $this->schemaComparison['to']->version }} + +
+
+ + @php $changes = $this->schemaComparison['changes'] @endphp + + @if(empty($changes['added']) && empty($changes['removed']) && empty($changes['changed'])) +
+
+ + No schema changes between versions +
+
+ @else +
+ @if(!empty($changes['added'])) +
+
+ Added Properties ({{ count($changes['added']) }}) +
+
    + @foreach($changes['added'] as $prop) +
  • {{ $prop }}
  • + @endforeach +
+
+ @endif + + @if(!empty($changes['removed'])) +
+
+ Removed Properties ({{ count($changes['removed']) }}) +
+
    + @foreach($changes['removed'] as $prop) +
  • {{ $prop }}
  • + @endforeach +
+
+ @endif + + @if(!empty($changes['changed'])) +
+
+ Changed Properties ({{ count($changes['changed']) }}) +
+
+ @foreach($changes['changed'] as $prop => $change) +
+ {{ $prop }} +
+
+
Before:
+
{{ json_encode($change['from'], JSON_PRETTY_PRINT) }}
+
+
+
After:
+
{{ json_encode($change['to'], JSON_PRETTY_PRINT) }}
+
+
+
+ @endforeach +
+
+ @endif +
+ @endif + +
+ Close +
+
+
+ @endif + + {{-- Deprecate Modal --}} + @if($showDeprecateModal) + @php $deprecateVersion = \Core\Mod\Mcp\Models\McpToolVersion::find($deprecateVersionId) @endphp + @if($deprecateVersion) + +
+
+ Deprecate Version + +
+ +
+
+ + {{ $deprecateVersion->tool_name }} v{{ $deprecateVersion->version }} +
+

+ Deprecated versions will show warnings to agents but remain usable until sunset. +

+
+ +
+ Sunset Date (optional) + + + After this date, the version will be blocked and return errors. + +
+ +
+ Cancel + + Deprecate Version + +
+
+
+ @endif + @endif + + {{-- Register Version Modal --}} + @if($showRegisterModal) + +
+
+ Register Tool Version + +
+ +
+
+
+ Server ID + + @error('registerServer') {{ $message }} @enderror +
+
+ Tool Name + + @error('registerTool') {{ $message }} @enderror +
+
+ +
+
+ Version (semver) + + @error('registerVersion') {{ $message }} @enderror +
+
+ +
+
+ +
+ Description + + @error('registerDescription') {{ $message }} @enderror +
+ +
+ Changelog + + @error('registerChangelog') {{ $message }} @enderror +
+ +
+ Migration Notes + + @error('registerMigrationNotes') {{ $message }} @enderror +
+ +
+ Input Schema (JSON) + + @error('registerInputSchema') {{ $message }} @enderror +
+ +
+ Cancel + Register Version +
+
+
+
+ @endif +
diff --git a/src/Mod/Mcp/View/Modal/Admin/ApiKeyManager.php b/src/Mod/Mcp/View/Modal/Admin/ApiKeyManager.php new file mode 100644 index 0000000..3f98cd2 --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/ApiKeyManager.php @@ -0,0 +1,112 @@ +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(), + ]); + } +} diff --git a/src/Mod/Mcp/View/Modal/Admin/AuditLogViewer.php b/src/Mod/Mcp/View/Modal/Admin/AuditLogViewer.php new file mode 100644 index 0000000..df98d14 --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/AuditLogViewer.php @@ -0,0 +1,249 @@ +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'); + } +} diff --git a/src/Mod/Mcp/View/Modal/Admin/McpPlayground.php b/src/Mod/Mcp/View/Modal/Admin/McpPlayground.php new file mode 100644 index 0000000..3b4c983 --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/McpPlayground.php @@ -0,0 +1,539 @@ +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); + } +} diff --git a/src/Mod/Mcp/View/Modal/Admin/Playground.php b/src/Mod/Mcp/View/Modal/Admin/Playground.php new file mode 100644 index 0000000..ccea82e --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/Playground.php @@ -0,0 +1,263 @@ +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'] ?? '', + ]; + } +} diff --git a/src/Mod/Mcp/View/Modal/Admin/QuotaUsage.php b/src/Mod/Mcp/View/Modal/Admin/QuotaUsage.php new file mode 100644 index 0000000..889afd1 --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/QuotaUsage.php @@ -0,0 +1,93 @@ +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'); + } +} diff --git a/src/Mod/Mcp/View/Modal/Admin/RequestLog.php b/src/Mod/Mcp/View/Modal/Admin/RequestLog.php new file mode 100644 index 0000000..1927c28 --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/RequestLog.php @@ -0,0 +1,86 @@ +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, + ]); + } +} diff --git a/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php b/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php new file mode 100644 index 0000000..4676eeb --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php @@ -0,0 +1,249 @@ +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'); + } +} diff --git a/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php b/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php new file mode 100644 index 0000000..e58f207 --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php @@ -0,0 +1,109 @@ +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'); + } +} diff --git a/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php b/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php new file mode 100644 index 0000000..bea8367 --- /dev/null +++ b/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php @@ -0,0 +1,349 @@ +checkHadesAccess(); + } + + #[Computed] + public function versions(): LengthAwarePaginator + { + $query = McpToolVersion::query() + ->orderByDesc('created_at'); + + if ($this->search) { + $query->where(function ($q) { + $q->where('tool_name', 'like', "%{$this->search}%") + ->orWhere('server_id', 'like', "%{$this->search}%") + ->orWhere('version', 'like', "%{$this->search}%") + ->orWhere('description', 'like', "%{$this->search}%"); + }); + } + + if ($this->server) { + $query->forServer($this->server); + } + + if ($this->status === 'latest') { + $query->latest(); + } elseif ($this->status === 'deprecated') { + $query->deprecated(); + } elseif ($this->status === 'sunset') { + $query->sunset(); + } elseif ($this->status === 'active') { + $query->active()->where('is_latest', false); + } + + return $query->paginate($this->perPage); + } + + #[Computed] + public function servers(): Collection + { + return app(ToolVersionService::class)->getServersWithVersions(); + } + + #[Computed] + public function stats(): array + { + return app(ToolVersionService::class)->getStats(); + } + + #[Computed] + public function selectedVersion(): ?McpToolVersion + { + if (! $this->selectedVersionId) { + return null; + } + + return McpToolVersion::find($this->selectedVersionId); + } + + #[Computed] + public function versionHistory(): Collection + { + if (! $this->selectedVersion) { + return collect(); + } + + return app(ToolVersionService::class)->getVersionHistory( + $this->selectedVersion->server_id, + $this->selectedVersion->tool_name + ); + } + + #[Computed] + public function schemaComparison(): ?array + { + if (! $this->compareFromId || ! $this->compareToId) { + return null; + } + + $from = McpToolVersion::find($this->compareFromId); + $to = McpToolVersion::find($this->compareToId); + + if (! $from || ! $to) { + return null; + } + + return [ + 'from' => $from, + 'to' => $to, + 'changes' => $from->compareSchemaWith($to), + ]; + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + public function viewVersion(int $id): void + { + $this->selectedVersionId = $id; + $this->showVersionDetail = true; + } + + public function closeVersionDetail(): void + { + $this->showVersionDetail = false; + $this->selectedVersionId = null; + } + + public function openCompareModal(int $fromId, int $toId): void + { + $this->compareFromId = $fromId; + $this->compareToId = $toId; + $this->showCompareModal = true; + } + + public function closeCompareModal(): void + { + $this->showCompareModal = false; + $this->compareFromId = null; + $this->compareToId = null; + } + + public function openDeprecateModal(int $versionId): void + { + $this->deprecateVersionId = $versionId; + $this->deprecateSunsetDate = ''; + $this->showDeprecateModal = true; + } + + public function closeDeprecateModal(): void + { + $this->showDeprecateModal = false; + $this->deprecateVersionId = null; + $this->deprecateSunsetDate = ''; + } + + public function deprecateVersion(): void + { + $version = McpToolVersion::find($this->deprecateVersionId); + if (! $version) { + return; + } + + $sunsetAt = $this->deprecateSunsetDate + ? Carbon::parse($this->deprecateSunsetDate) + : null; + + app(ToolVersionService::class)->deprecateVersion( + $version->server_id, + $version->tool_name, + $version->version, + $sunsetAt + ); + + $this->closeDeprecateModal(); + $this->dispatch('version-deprecated'); + } + + public function markAsLatest(int $versionId): void + { + $version = McpToolVersion::find($versionId); + if (! $version) { + return; + } + + $version->markAsLatest(); + $this->dispatch('version-marked-latest'); + } + + public function openRegisterModal(): void + { + $this->resetRegisterForm(); + $this->showRegisterModal = true; + } + + public function closeRegisterModal(): void + { + $this->showRegisterModal = false; + $this->resetRegisterForm(); + } + + public function registerVersion(): void + { + $this->validate([ + 'registerServer' => 'required|string|max:64', + 'registerTool' => 'required|string|max:128', + 'registerVersion' => 'required|string|max:32|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/', + 'registerDescription' => 'nullable|string|max:1000', + 'registerChangelog' => 'nullable|string|max:5000', + 'registerMigrationNotes' => 'nullable|string|max:5000', + 'registerInputSchema' => 'nullable|string', + ]); + + $inputSchema = null; + if ($this->registerInputSchema) { + $inputSchema = json_decode($this->registerInputSchema, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->addError('registerInputSchema', 'Invalid JSON'); + + return; + } + } + + app(ToolVersionService::class)->registerVersion( + serverId: $this->registerServer, + toolName: $this->registerTool, + version: $this->registerVersion, + inputSchema: $inputSchema, + description: $this->registerDescription ?: null, + options: [ + 'changelog' => $this->registerChangelog ?: null, + 'migration_notes' => $this->registerMigrationNotes ?: null, + 'mark_latest' => $this->registerMarkLatest, + ] + ); + + $this->closeRegisterModal(); + $this->dispatch('version-registered'); + } + + public function clearFilters(): void + { + $this->search = ''; + $this->server = ''; + $this->status = ''; + $this->resetPage(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + public function getStatusBadgeColor(string $status): string + { + return match ($status) { + 'latest' => 'green', + 'active' => 'zinc', + 'deprecated' => 'amber', + 'sunset' => 'red', + default => 'zinc', + }; + } + + public function formatSchema(array $schema): string + { + return json_encode($schema, JSON_PRETTY_PRINT); + } + + private function resetRegisterForm(): void + { + $this->registerServer = ''; + $this->registerTool = ''; + $this->registerVersion = ''; + $this->registerDescription = ''; + $this->registerChangelog = ''; + $this->registerMigrationNotes = ''; + $this->registerInputSchema = ''; + $this->registerMarkLatest = false; + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render() + { + return view('mcp::admin.tool-version-manager'); + } +} diff --git a/src/Website/Mcp/Boot.php b/src/Website/Mcp/Boot.php new file mode 100644 index 0000000..ea4035a --- /dev/null +++ b/src/Website/Mcp/Boot.php @@ -0,0 +1,48 @@ +loadViewsFrom(__DIR__.'/View/Blade', 'mcp'); + + $this->registerLivewireComponents(); + $this->registerRoutes(); + } + + protected function registerLivewireComponents(): void + { + Livewire::component('mcp.dashboard', View\Modal\Dashboard::class); + Livewire::component('mcp.api-key-manager', View\Modal\ApiKeyManager::class); + Livewire::component('mcp.api-explorer', View\Modal\ApiExplorer::class); + Livewire::component('mcp.mcp-metrics', View\Modal\McpMetrics::class); + Livewire::component('mcp.mcp-playground', View\Modal\McpPlayground::class); + Livewire::component('mcp.playground', View\Modal\Playground::class); + Livewire::component('mcp.request-log', View\Modal\RequestLog::class); + Livewire::component('mcp.unified-search', View\Modal\UnifiedSearch::class); + } + + protected function registerRoutes(): void + { + Route::middleware('web')->group(__DIR__.'/Routes/web.php'); + } +} diff --git a/src/Website/Mcp/Controllers/McpRegistryController.php b/src/Website/Mcp/Controllers/McpRegistryController.php new file mode 100644 index 0000000..f0ad09c --- /dev/null +++ b/src/Website/Mcp/Controllers/McpRegistryController.php @@ -0,0 +1,482 @@ +environment('production') ? 600 : 0; + } + + /** + * Discovery endpoint: /.well-known/mcp-servers.json + * + * Returns the registry of all available MCP servers. + * This is the entry point for agent discovery. + */ + public function registry(Request $request) + { + $registry = $this->loadRegistry(); + + // Build server summaries for discovery + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values() + ->all(); + + $data = [ + 'servers' => $servers, + 'registry_version' => $registry['registry_version'] ?? '1.0', + 'organization' => $registry['organization'] ?? 'Host UK', + ]; + + // Always return JSON for .well-known + return response()->json($data); + } + + /** + * Server list page: /servers + * + * Shows all available servers (HTML) or returns JSON array. + */ + public function index(Request $request) + { + $registry = $this->loadRegistry(); + + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerFull($ref['id'])) + ->filter() + ->values(); + + // Include planned servers for display + $plannedServers = collect($registry['planned_servers'] ?? []); + + if ($this->wantsJson($request)) { + return response()->json([ + 'servers' => $servers, + 'planned' => $plannedServers, + ]); + } + + return view('mcp::web.index', [ + 'servers' => $servers, + 'plannedServers' => $plannedServers, + ]); + } + + /** + * Server detail: /servers/{id} or /servers/{id}.json + * + * Returns full server definition with all tools, resources, workflows. + */ + public function show(Request $request, string $id) + { + // Remove .json extension if present + $id = preg_replace('/\.json$/', '', $id); + + $server = $this->loadServerFull($id); + + if (! $server) { + if ($this->wantsJson($request)) { + return response()->json(['error' => 'Server not found'], 404); + } + abort(404, 'Server not found'); + } + + if ($this->wantsJson($request)) { + return response()->json($server); + } + + return view('mcp::web.show', ['server' => $server]); + } + + /** + * Landing page: / + * + * MCP portal landing page for humans. + */ + public function landing(Request $request) + { + $registry = $this->loadRegistry(); + + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values(); + + $plannedServers = collect($registry['planned_servers'] ?? []); + + return view('mcp::web.landing', [ + 'servers' => $servers, + 'plannedServers' => $plannedServers, + 'organization' => $registry['organization'] ?? 'Host UK', + ]); + } + + /** + * Connection config generator: /connect + * + * Shows how to add MCP servers to Claude Code etc. + */ + public function connect(Request $request) + { + $registry = $this->loadRegistry(); + + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerFull($ref['id'])) + ->filter() + ->values(); + + return view('mcp::web.connect', [ + 'servers' => $servers, + 'templates' => $registry['connection_templates'] ?? [], + 'workspace' => $request->attributes->get('mcp_workspace'), + ]); + } + + /** + * Dashboard: /dashboard + * + * Shows MCP usage for the authenticated workspace. + */ + public function dashboard(Request $request) + { + $workspace = $request->attributes->get('mcp_workspace'); + $entitlement = $request->attributes->get('mcp_entitlement'); + + // Get tool call stats for this workspace + $stats = $this->getWorkspaceStats($workspace); + + return view('mcp::web.dashboard', [ + 'workspace' => $workspace, + 'entitlement' => $entitlement, + 'stats' => $stats, + ]); + } + + /** + * API Keys management: /keys + * + * Manage API keys for MCP access. + */ + public function keys(Request $request) + { + $workspace = $request->attributes->get('mcp_workspace'); + + return view('mcp::web.keys', [ + 'workspace' => $workspace, + 'keys' => $workspace->apiKeys ?? collect(), + ]); + } + + /** + * Get MCP usage stats for a workspace. + */ + protected function getWorkspaceStats($workspace): array + { + $since = now()->subDays(30); + + // Use aggregate queries instead of loading all records into memory + $baseQuery = McpToolCall::where('created_at', '>=', $since); + + if ($workspace) { + $baseQuery->where('workspace_id', $workspace->id); + } + + $totalCalls = (clone $baseQuery)->count(); + $successfulCalls = (clone $baseQuery)->where('success', true)->count(); + + $byServer = (clone $baseQuery) + ->selectRaw('server_id, COUNT(*) as count') + ->groupBy('server_id') + ->orderByDesc('count') + ->limit(5) + ->pluck('count', 'server_id') + ->all(); + + $byDay = (clone $baseQuery) + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->pluck('count', 'date') + ->all(); + + return [ + 'total_calls' => $totalCalls, + 'successful_calls' => $successfulCalls, + 'by_server' => $byServer, + 'by_day' => $byDay, + ]; + } + + /** + * Usage analytics endpoint: /servers/{id}/analytics + * + * Shows tool usage stats for a specific server. + */ + public function analytics(Request $request, string $id) + { + $server = $this->loadServerFull($id); + + if (! $server) { + if ($this->wantsJson($request)) { + return response()->json(['error' => 'Server not found'], 404); + } + abort(404, 'Server not found'); + } + + // Validate days parameter - bound to reasonable range + $days = min(max($request->integer('days', 7), 1), 90); + + // Get tool call stats for this server + $stats = $this->getServerAnalytics($id, $days); + + if ($this->wantsJson($request)) { + return response()->json([ + 'server_id' => $id, + 'period_days' => $days, + 'stats' => $stats, + ]); + } + + return view('mcp::web.analytics', [ + 'server' => $server, + 'stats' => $stats, + 'days' => $days, + ]); + } + + /** + * OpenAPI specification. + * + * GET /openapi.json or /openapi.yaml + */ + public function openapi(Request $request) + { + $generator = new OpenApiGenerator; + $format = $request->query('format', 'json'); + + if ($format === 'yaml' || str_ends_with($request->path(), '.yaml')) { + return response($generator->toYaml()) + ->header('Content-Type', 'application/x-yaml'); + } + + return response()->json($generator->generate()); + } + + /** + * Get analytics for a specific server. + */ + protected function getServerAnalytics(string $serverId, int $days = 7): array + { + $since = now()->subDays($days); + + $baseQuery = McpToolCall::forServer($serverId) + ->where('created_at', '>=', $since); + + // Get aggregate stats without loading all records into memory + $totalCalls = (clone $baseQuery)->count(); + $successfulCalls = (clone $baseQuery)->where('success', true)->count(); + $failedCalls = $totalCalls - $successfulCalls; + $avgDuration = (clone $baseQuery)->avg('duration_ms') ?? 0; + + // Tool breakdown with aggregates + $byTool = (clone $baseQuery) + ->selectRaw('tool_name, COUNT(*) as calls, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count, AVG(duration_ms) as avg_duration') + ->groupBy('tool_name') + ->orderByDesc('calls') + ->limit(10) + ->get() + ->mapWithKeys(fn ($row) => [ + $row->tool_name => [ + 'calls' => (int) $row->calls, + 'success_rate' => $row->calls > 0 + ? round($row->success_count / $row->calls * 100, 1) + : 0, + 'avg_duration_ms' => round($row->avg_duration ?? 0), + ], + ]) + ->all(); + + // Daily breakdown + $byDay = (clone $baseQuery) + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->pluck('count', 'date') + ->all(); + + // Error breakdown + $errors = (clone $baseQuery) + ->where('success', false) + ->whereNotNull('error_code') + ->selectRaw('error_code, COUNT(*) as count') + ->groupBy('error_code') + ->orderByDesc('count') + ->limit(5) + ->pluck('count', 'error_code') + ->all(); + + return [ + 'total_calls' => $totalCalls, + 'successful_calls' => $successfulCalls, + 'failed_calls' => $failedCalls, + 'success_rate' => $totalCalls > 0 ? round($successfulCalls / $totalCalls * 100, 1) : 0, + 'avg_duration_ms' => round($avgDuration), + 'by_tool' => $byTool, + 'by_day' => $byDay, + 'errors' => $errors, + ]; + } + + /** + * Load the main registry file. + */ + protected function loadRegistry(): array + { + return Cache::remember('mcp:registry', $this->getCacheTtl(), function () { + $path = resource_path('mcp/registry.yaml'); + + if (! file_exists($path)) { + return ['servers' => [], 'planned_servers' => []]; + } + + return Yaml::parseFile($path); + }); + } + + /** + * Load a server's YAML file. + */ + protected function loadServerYaml(string $id): ?array + { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + + return Cache::remember("mcp:server:{$id}", $this->getCacheTtl(), function () use ($id) { + $path = resource_path("mcp/servers/{$id}.yaml"); + + if (! file_exists($path)) { + return null; + } + + return Yaml::parseFile($path); + }); + } + + /** + * Load server summary for registry discovery. + * + * Returns minimal info: id, name, description, use_when, connection type. + */ + protected function loadServerSummary(string $id): ?array + { + $server = $this->loadServerYaml($id); + + if (! $server) { + return null; + } + + return [ + 'id' => $server['id'], + 'name' => $server['name'], + 'description' => $server['description'] ?? $server['tagline'] ?? '', + 'tagline' => $server['tagline'] ?? '', + 'icon' => $server['icon'] ?? 'server', + 'status' => $server['status'] ?? 'available', + 'use_when' => $server['use_when'] ?? [], + 'connection' => [ + 'type' => $server['connection']['type'] ?? 'stdio', + ], + 'capabilities' => $this->extractCapabilities($server), + 'related_servers' => $server['related_servers'] ?? [], + ]; + } + + /** + * Load full server definition for detail view. + */ + protected function loadServerFull(string $id): ?array + { + $server = $this->loadServerYaml($id); + + if (! $server) { + return null; + } + + // Add computed fields + $server['tool_count'] = count($server['tools'] ?? []); + $server['resource_count'] = count($server['resources'] ?? []); + $server['workflow_count'] = count($server['workflows'] ?? []); + $server['capabilities'] = $this->extractCapabilities($server); + + return $server; + } + + /** + * Extract capability summary from server definition. + */ + protected function extractCapabilities(array $server): array + { + $caps = []; + + if (! empty($server['tools'])) { + $caps[] = 'tools'; + } + + if (! empty($server['resources'])) { + $caps[] = 'resources'; + } + + return $caps; + } + + /** + * Check if request wants JSON response. + */ + protected function wantsJson(Request $request): bool + { + // Explicit .json extension + if (str_ends_with($request->path(), '.json')) { + return true; + } + + // Accept header + if ($request->wantsJson()) { + return true; + } + + // Query param override + if ($request->query('format') === 'json') { + return true; + } + + return false; + } +} diff --git a/src/Website/Mcp/Routes/web.php b/src/Website/Mcp/Routes/web.php new file mode 100644 index 0000000..c6c575d --- /dev/null +++ b/src/Website/Mcp/Routes/web.php @@ -0,0 +1,48 @@ +name('mcp.')->group(function () { + // Agent discovery endpoint (always JSON) + Route::get('.well-known/mcp-servers.json', [McpRegistryController::class, 'registry']) + ->name('registry'); + + // Landing page + Route::get('/', [McpRegistryController::class, 'landing']) + ->middleware(McpAuthenticate::class.':optional') + ->name('landing'); + + // Server list (HTML/JSON based on Accept header) + Route::get('servers', [McpRegistryController::class, 'index']) + ->middleware(McpAuthenticate::class.':optional') + ->name('servers.index'); + + // Server detail (supports .json extension) + Route::get('servers/{id}', [McpRegistryController::class, 'show']) + ->middleware(McpAuthenticate::class.':optional') + ->name('servers.show') + ->where('id', '[a-z0-9-]+(?:\.json)?'); + + // Connection config page + Route::get('connect', [McpRegistryController::class, 'connect']) + ->middleware(McpAuthenticate::class.':optional') + ->name('connect'); + + // OpenAPI spec + Route::get('openapi.json', [McpRegistryController::class, 'openapi'])->name('openapi.json'); + Route::get('openapi.yaml', [McpRegistryController::class, 'openapi'])->name('openapi.yaml'); +}); diff --git a/src/Website/Mcp/View/Blade/web/analytics.blade.php b/src/Website/Mcp/View/Blade/web/analytics.blade.php new file mode 100644 index 0000000..c6afc41 --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/analytics.blade.php @@ -0,0 +1,115 @@ + + {{ $server['name'] }} Analytics + +
+ + +

{{ $server['name'] }} Analytics

+

+ Tool usage statistics for the last {{ $days }} days. +

+
+ + +
+
+

Total Calls

+

{{ number_format($stats['total_calls']) }}

+
+
+

Success Rate

+

+ {{ $stats['success_rate'] }}% +

+
+
+

Successful

+

{{ number_format($stats['successful_calls']) }}

+
+
+

Failed

+

{{ number_format($stats['failed_calls']) }}

+
+
+ + + @if(!empty($stats['by_tool'])) +
+

Tool Usage

+
+ @foreach($stats['by_tool'] as $tool => $data) +
+
+ {{ $tool }} +
+
+ {{ $data['calls'] }} calls + + {{ $data['success_rate'] }}% success + + {{ $data['avg_duration_ms'] }}ms avg +
+
+ @endforeach +
+
+ @endif + + + @if(!empty($stats['by_day'])) +
+

Daily Activity

+
+ @foreach($stats['by_day'] as $date => $count) +
+ {{ $date }} +
+
+ @php + $maxCalls = max($stats['by_day']); + $width = $maxCalls > 0 ? ($count / $maxCalls) * 100 : 0; + @endphp +
+
+
+ {{ $count }} +
+ @endforeach +
+
+ @endif + + + @if(!empty($stats['errors'])) +
+

Error Breakdown

+
+ @foreach($stats['errors'] as $code => $count) +
+ {{ $code ?: 'Unknown' }} + {{ $count }} occurrences +
+ @endforeach +
+
+ @endif + + +
+ Time range: + + @foreach([7, 14, 30] as $range) + + {{ $range }} days + + @endforeach + +
+
diff --git a/src/Website/Mcp/View/Blade/web/api-explorer.blade.php b/src/Website/Mcp/View/Blade/web/api-explorer.blade.php new file mode 100644 index 0000000..17595d7 --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/api-explorer.blade.php @@ -0,0 +1,219 @@ +
+
+ +
+

API Explorer

+

Interactive documentation with code snippets in 11 languages

+
+ + +
+
+ + + +
+ +
+ + +
+

Enter your API key to enable live testing. Keys are not stored.

+
+
+
+ +
+ +
+
+
+

Endpoints

+
+
+ @foreach($endpoints as $index => $endpoint) + + @endforeach +
+
+
+ + +
+ +
+
+

Request

+
+
+
+ + +
+ + @if(in_array($method, ['POST', 'PUT', 'PATCH'])) +
+
+ + +
+ +
+ @endif + + +
+
+ + +
+
+
+

Code Snippet

+ +
+
+ + +
+
+ @foreach($languages as $lang) + + @endforeach +
+
+ + +
+
{{ $snippet }}
+
+
+ + + @if($error) +
+
+ + + +
+

Error

+

{{ $error }}

+
+
+
+ @endif + + @if($response) +
+
+
+

Response

+ + {{ $response['status'] }} + +
+ {{ $responseTime }}ms +
+
+
{{ json_encode($response['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
+
+
+ @endif +
+
+ + + +
+ + @script + + @endscript +
diff --git a/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php b/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php new file mode 100644 index 0000000..340a4eb --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php @@ -0,0 +1,268 @@ +
+ + @if(session('message')) +
+

{{ session('message') }}

+
+ @endif + + +
+
+

+ API Keys +

+

+ Create API keys to authenticate HTTP requests to MCP servers. +

+
+ + Create Key + +
+ + +
+ @if($keys->isEmpty()) +
+
+ +
+

No API Keys Yet

+

+ Create an API key to start making authenticated requests to MCP servers over HTTP. +

+ + Create Your First Key + +
+ @else + + + + + + + + + + + + + @foreach($keys as $key) + + + + + + + + + @endforeach + +
+ Name + + Key + + Scopes + + Last Used + + Expires + + Actions +
+ {{ $key->name }} + + + {{ $key->prefix }}_**** + + +
+ @foreach($key->scopes ?? [] as $scope) + + {{ $scope }} + + @endforeach +
+
+ {{ $key->last_used_at?->diffForHumans() ?? 'Never' }} + + @if($key->expires_at) + @if($key->expires_at->isPast()) + Expired + @else + {{ $key->expires_at->diffForHumans() }} + @endif + @else + Never + @endif + + + Revoke + +
+ @endif +
+ + +
+ +
+

+ + Authentication +

+

+ Include your API key in HTTP requests using one of these methods: +

+
+
+

Authorization Header (recommended)

+
Authorization: Bearer hk_abc123_****
+
+
+

X-API-Key Header

+
X-API-Key: hk_abc123_****
+
+
+
+ + +
+

+ + Example Request +

+

+ Call an MCP tool via HTTP POST: +

+
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": {}
+  }'
+
+
+ + + +
+

Create API Key

+ +
+ +
+ Key Name + + @error('newKeyName') +

{{ $message }}

+ @enderror +
+ + +
+ Permissions +
+ + + +
+
+ + +
+ Expiration + + + + + + +
+
+ +
+ Cancel + Create Key +
+
+
+ + + +
+
+
+ +
+

API Key Created

+
+ +
+

+ Copy this key now. You won't be able to see it again. +

+
+ +
+
{{ $newPlainKey }}
+ +
+ +
+ Done +
+
+
+
diff --git a/src/Website/Mcp/View/Blade/web/connect.blade.php b/src/Website/Mcp/View/Blade/web/connect.blade.php new file mode 100644 index 0000000..426972d --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/connect.blade.php @@ -0,0 +1,217 @@ + + Setup Guide + +
+
+

Setup Guide

+

+ Connect to Host UK MCP servers via HTTP API or stdio. +

+
+ + + + + +
+
+
+ +
+
+

HTTP API

+ + Recommended + +
+
+ +

+ Call MCP tools from any language or platform using standard HTTP requests. + Perfect for external integrations, webhooks, and remote agents. +

+ +

1. Get your API key

+

+ Sign in to your Host UK account to create an API key from the admin dashboard. +

+ +

2. Call a tool

+
curl -X POST https://mcp.host.uk.com/api/v1/mcp/tools/call \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "server": "commerce",
+    "tool": "product_list",
+    "arguments": { "category": "hosting" }
+  }'
+ +

3. List available tools

+
curl https://mcp.host.uk.com/api/v1/mcp/servers \
+  -H "Authorization: Bearer YOUR_API_KEY"
+ +
+
+

API Endpoints

+ + View OpenAPI Spec → + +
+
+
+ GET /api/v1/mcp/servers + List all servers +
+
+ GET /api/v1/mcp/servers/{id} + Server details +
+
+ GET /api/v1/mcp/servers/{id}/tools + List tools +
+
+ POST /api/v1/mcp/tools/call + Execute a tool +
+
+ GET /api/v1/mcp/resources/{uri} + Read a resource +
+
+
+
+ + +
+
+
+ +
+
+

Stdio (Local)

+ + For local development + +
+
+ +

+ Direct stdio connection for Claude Code and other local AI agents. + Ideal for OSS framework users running their own Host Hub instance. +

+ +
+ + Show stdio configuration + + +
+ +
+

Claude Code

+

+ Add to ~/.claude/claude_code_config.json: +

+
{
+  "mcpServers": {
+@foreach($servers as $server)
+    "{{ $server['id'] }}": {
+      "command": "{{ $server['connection']['command'] ?? 'php' }}",
+      "args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!},
+      "cwd": "{{ $server['connection']['cwd'] ?? '/path/to/host.uk.com' }}"
+    }{{ !$loop->last ? ',' : '' }}
+@endforeach
+  }
+}
+
+ + +
+

Cursor

+

+ Add to .cursor/mcp.json: +

+
{
+  "mcpServers": {
+@foreach($servers as $server)
+    "{{ $server['id'] }}": {
+      "command": "{{ $server['connection']['command'] ?? 'php' }}",
+      "args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!}
+    }{{ !$loop->last ? ',' : '' }}
+@endforeach
+  }
+}
+
+ + +
+

Docker

+
{
+  "mcpServers": {
+    "hosthub-agent": {
+      "command": "docker",
+      "args": ["exec", "-i", "hosthub-app", "php", "artisan", "mcp:agent-server"]
+    }
+  }
+}
+
+
+
+
+ + +
+

Authentication

+ +
+
+

Authorization Header (Recommended)

+
Authorization: Bearer hk_abc123_your_key_here
+
+ +
+

X-API-Key Header

+
X-API-Key: hk_abc123_your_key_here
+
+
+ +
+

Server-scoped keys

+

+ API keys can be restricted to specific MCP servers. If you get a 403 error, + check your key's server scopes in your admin dashboard. +

+
+
+ + +
+

Need help setting up?

+
+ + Browse Servers + + + Contact Support + +
+
+
+
diff --git a/src/Website/Mcp/View/Blade/web/dashboard.blade.php b/src/Website/Mcp/View/Blade/web/dashboard.blade.php new file mode 100644 index 0000000..f530059 --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/dashboard.blade.php @@ -0,0 +1,283 @@ +
+ +
+
+ Upstream Intelligence + Track vendor updates and manage porting tasks +
+
+ Refresh +
+
+ + +
+
+ Vendors + {{ $this->stats['total_vendors'] }} +
+
+ Pending + {{ $this->stats['pending_todos'] }} +
+
+ Quick Wins + {{ $this->stats['quick_wins'] }} +
+
+ Security + {{ $this->stats['security_updates'] }} +
+
+ In Progress + {{ $this->stats['in_progress'] }} +
+
+ This Week + {{ $this->stats['recent_releases'] }} +
+
+ + +
+
+ Tracked Vendors +
+
+
+ @foreach($this->vendors as $vendor) +
+
+ {{ $vendor->getSourceTypeIcon() }} + {{ $vendor->name }} +
+
+
{{ $vendor->vendor_name }} · {{ $vendor->getSourceTypeLabel() }}
+
Version: {{ $vendor->current_version ?? 'Not set' }}
+
+ {{ $vendor->todos_count }} todos + {{ $vendor->releases_count }} releases +
+
+
+ @endforeach +
+
+
+ + +
+
+ + All Vendors + @foreach($this->vendors as $vendor) + {{ $vendor->name }} + @endforeach + + + + All Status + Pending + In Progress + Ported + Skipped + + + + All Types + Feature + Bugfix + Security + UI + Block + API + + + + All Effort + Low (<1hr) + Medium (1-4hr) + High (4+hr) + + + +
+
+ + +
+
+ Porting Tasks + {{ $this->todos->total() }} total +
+
+ + + Type + Title + Vendor + Priority + Effort + Status + Actions + + + @forelse($this->todos as $todo) + + + {{ $todo->getTypeIcon() }} + + +
+
{{ $todo->title }}
+ @if($todo->description) +
{{ Str::limit($todo->description, 80) }}
+ @endif +
+
+ + {{ $todo->vendor->name }} + + + + {{ $todo->priority }}/10 + + + + + {{ $todo->getEffortLabel() }} + + + + + {{ str_replace('_', ' ', $todo->status) }} + + + + @if($todo->status === 'pending') + Start + @elseif($todo->status === 'in_progress') +
+ Done + Skip +
+ @endif +
+
+ @empty + + + No todos found matching filters + + + @endforelse +
+
+
+ @if($this->todos->hasPages()) +
+ {{ $this->todos->links() }} +
+ @endif +
+ + +
+ +
+
+ Asset Library +
+ @if($this->assetStats['updates_available'] > 0) + {{ $this->assetStats['updates_available'] }} updates + @endif + {{ $this->assetStats['total'] }} assets +
+
+
+
+ @foreach($this->assets as $asset) +
+
+ {{ $asset->getTypeIcon() }} +
+
{{ $asset->name }}
+
+ @if($asset->package_name) + {{ $asset->package_name }} + @endif +
+
+
+
+ {{ $asset->getLicenseIcon() }} + @if($asset->installed_version) + + {{ $asset->installed_version }} + @if($asset->hasUpdate()) + → {{ $asset->latest_version }} + @endif + + @else + Not installed + @endif +
+
+ @endforeach +
+
+
+ + +
+
+ Pattern Library + {{ $this->assetStats['patterns'] }} patterns +
+
+
+ @foreach($this->patterns as $pattern) +
+
+ {{ $pattern->getCategoryIcon() }} + {{ $pattern->name }} +
+
{{ $pattern->description }}
+
+ {{ $pattern->language }} + @if($pattern->is_vetted) + Vetted + @endif +
+
+ @endforeach +
+
+
+
+ + +
+
+ Recent Activity +
+
+
+ @forelse($this->recentLogs as $log) +
+ {{ $log->getActionIcon() }} + {{ $log->created_at->diffForHumans() }} + {{ $log->getActionLabel() }} + · + {{ $log->vendor->name }} + @if($log->error_message) + Error + @endif +
+ @empty +
No recent activity
+ @endforelse +
+
+
+
diff --git a/src/Website/Mcp/View/Blade/web/index.blade.php b/src/Website/Mcp/View/Blade/web/index.blade.php new file mode 100644 index 0000000..9f7a8bc --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/index.blade.php @@ -0,0 +1,126 @@ + + MCP Servers + +
+

MCP Servers

+

+ All available MCP servers for AI agent integration. +

+
+ + +
+ @forelse($servers as $server) +
+
+
+
+
+ @switch($server['id']) + @case('hosthub-agent') + + @break + @case('commerce') + + @break + @case('socialhost') + + @break + @case('biohost') + + @break + @case('supporthost') + + @break + @case('analyticshost') + + @break + @default + + @endswitch +
+
+

+ {{ $server['name'] }} +

+

{{ $server['id'] }}

+
+
+ + {{ ucfirst($server['status'] ?? 'available') }} + +
+ +

+ {{ $server['tagline'] ?? $server['description'] ?? '' }} +

+ + +
+ + + {{ $server['tool_count'] ?? 0 }} tools + + + + {{ $server['resource_count'] ?? 0 }} resources + + @if(($server['workflow_count'] ?? 0) > 0) + + + {{ $server['workflow_count'] }} workflows + + @endif +
+
+ + +
+ @empty +
+ +

No MCP servers available.

+
+ @endforelse +
+ + + @if($plannedServers->isNotEmpty()) +
+

Planned Servers

+
+ @foreach($plannedServers as $server) +
+
+
+ @switch($server['id']) + @case('upstream') + + @break + @case('analyticshost') + + @break + @default + + @endswitch +
+

{{ $server['name'] }}

+
+

{{ $server['tagline'] ?? '' }}

+
+ @endforeach +
+
+ @endif +
diff --git a/src/Website/Mcp/View/Blade/web/keys.blade.php b/src/Website/Mcp/View/Blade/web/keys.blade.php new file mode 100644 index 0000000..78463fb --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/keys.blade.php @@ -0,0 +1,6 @@ + + API Keys + Manage API keys for MCP server access. + + + diff --git a/src/Website/Mcp/View/Blade/web/landing.blade.php b/src/Website/Mcp/View/Blade/web/landing.blade.php new file mode 100644 index 0000000..18a79a5 --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/landing.blade.php @@ -0,0 +1,205 @@ + + MCP Portal + Connect AI agents to Host UK infrastructure. Machine-readable, agent-optimised, human-friendly. + + +
+

+ Host UK MCP Ecosystem +

+

+ Connect AI agents to Host UK infrastructure.
+ Machine-readable • + Agent-optimised • + Human-friendly +

+
+ + Browse Servers + + + Setup Guide + +
+
+ + +
+

Developer Tools

+ +
+ + +
+

Available Servers

+ +
+ + + @if($plannedServers->isNotEmpty()) +
+

Coming Soon

+
+ @foreach($plannedServers as $server) +
+
+
+ @switch($server['id']) + @case('analyticshost') + + @break + @case('upstream') + + @break + @default + + @endswitch +
+ + Planned + +
+

+ {{ $server['name'] }} +

+

+ {{ $server['tagline'] ?? '' }} +

+
+ @endforeach +
+
+ @endif + + +
+

Quick Start

+

+ Call MCP tools via HTTP API with your API key: +

+
curl -X POST https://mcp.host.uk.com/api/v1/mcp/tools/call \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "server": "commerce",
+    "tool": "product_list",
+    "arguments": {}
+  }'
+
+ + Full Setup Guide + + + OpenAPI Spec + +
+
+
diff --git a/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php b/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php new file mode 100644 index 0000000..fd550bc --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php @@ -0,0 +1,309 @@ +
+ +
+
+ MCP Agent Metrics + Monitor tool usage, performance, and errors +
+
+ + 7 Days + 14 Days + 30 Days + + Refresh +
+
+ + +
+
+ Total Calls + {{ number_format($this->overview['total_calls']) }} + @if($this->overview['calls_trend_percent'] != 0) + + {{ $this->overview['calls_trend_percent'] > 0 ? '+' : '' }}{{ $this->overview['calls_trend_percent'] }}% + + @endif +
+
+ Success Rate + {{ $this->overview['success_rate'] }}% +
+
+ Successful + {{ number_format($this->overview['success_calls']) }} +
+
+ Errors + {{ number_format($this->overview['error_calls']) }} +
+
+ Avg Duration + {{ $this->overview['avg_duration_ms'] < 1000 ? $this->overview['avg_duration_ms'] . 'ms' : round($this->overview['avg_duration_ms'] / 1000, 2) . 's' }} +
+
+ Unique Tools + {{ $this->overview['unique_tools'] }} +
+
+ + +
+ +
+ + @if($activeTab === 'overview') +
+ +
+
+ Daily Call Volume +
+
+
+ @foreach($this->dailyTrend as $day) +
+ {{ $day['date_formatted'] }} +
+
+ @php + $maxCalls = collect($this->dailyTrend)->max('total_calls') ?: 1; + $successWidth = ($day['total_success'] / $maxCalls) * 100; + $errorWidth = ($day['total_errors'] / $maxCalls) * 100; + @endphp +
+
+
+
+
+ {{ $day['total_calls'] }} +
+
+ @endforeach +
+
+
+ + +
+
+ Top Tools +
+
+
+ @forelse($this->topTools as $tool) +
+
+ {{ $tool->tool_name }} + {{ $tool->server_id }} +
+
+ + {{ $tool->success_rate }}% + + {{ number_format($tool->total_calls) }} +
+
+ @empty +
No tool calls recorded yet
+ @endforelse +
+
+
+ + +
+
+ Server Breakdown +
+
+ @forelse($this->serverStats as $server) +
+
+ {{ $server->server_id }} + {{ $server->unique_tools }} tools +
+
+ {{ number_format($server->total_success) }} + {{ number_format($server->total_errors) }} + {{ number_format($server->total_calls) }} +
+
+ @empty +
No servers active yet
+ @endforelse +
+
+ + +
+
+ Plan Activity +
+
+ @forelse($this->planActivity as $plan) +
+
+ {{ $plan->plan_slug }} + {{ $plan->unique_tools }} tools +
+
+ + {{ $plan->success_rate }}% + + {{ number_format($plan->call_count) }} +
+
+ @empty +
No plan activity recorded
+ @endforelse +
+
+
+ @endif + + @if($activeTab === 'performance') +
+ +
+
+ Tool Performance (p50 / p95 / p99) +
+
+ + + + + + + + + + + + + + + @forelse($this->toolPerformance as $tool) + + + + + + + + + + + @empty + + + + @endforelse + +
ToolCallsMinAvgp50p95p99Max
{{ $tool['tool_name'] }}{{ number_format($tool['call_count']) }}{{ $tool['min_ms'] }}ms{{ round($tool['avg_ms']) }}ms{{ round($tool['p50_ms']) }}ms{{ round($tool['p95_ms']) }}ms{{ round($tool['p99_ms']) }}ms{{ $tool['max_ms'] }}ms
No performance data recorded yet
+
+
+ + +
+
+ Hourly Distribution (Last 24 Hours) +
+
+
+ @php $maxHourly = collect($this->hourlyDistribution)->max('call_count') ?: 1; @endphp + @foreach($this->hourlyDistribution as $hour) +
+
+ {{ $hour['hour_formatted'] }} +
+ @endforeach +
+
+
+
+ @endif + + @if($activeTab === 'errors') +
+
+ Error Breakdown +
+
+ + + + + + + + + + @forelse($this->errorBreakdown as $error) + + + + + + @empty + + + + @endforelse + +
ToolError CodeCount
{{ $error->tool_name }} + + {{ $error->error_code ?? 'unknown' }} + + {{ number_format($error->error_count) }}
No errors recorded - all systems healthy
+
+
+ @endif + + @if($activeTab === 'activity') +
+
+ Recent Activity +
+
+ @forelse($this->recentCalls as $call) +
+
+ +
+ {{ $call['tool_name'] }} + @if($call['plan_slug']) + @ {{ $call['plan_slug'] }} + @endif + @if(!$call['success'] && $call['error_message']) +
{{ Str::limit($call['error_message'], 80) }}
+ @endif +
+
+
+ {{ $call['duration'] }} + {{ $call['created_at'] }} +
+
+ @empty +
No activity recorded yet
+ @endforelse +
+
+ @endif +
diff --git a/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php b/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php new file mode 100644 index 0000000..df3ea74 --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php @@ -0,0 +1,180 @@ +
+
+ +
+

MCP Tool Playground

+

Test MCP tool calls with custom parameters

+
+ +
+ +
+
+

Request

+ + +
+ + +
+ + +
+ + + @if($selectedTool) + @php $currentTool = collect($tools)->firstWhere('name', $selectedTool); @endphp + @if($currentTool && !empty($currentTool['purpose'])) +

{{ $currentTool['purpose'] }}

+ @endif + @endif +
+ + +
+
+ + +
+ + @error('inputJson') +

{{ $message }}

+ @enderror +
+ + + +
+
+ + +
+
+
+

Response

+ @if($executionTime > 0) + {{ $executionTime }}ms + @endif +
+ + @if($lastError) +
+
+
+ + + +
+
+

Error

+

{{ $lastError }}

+
+
+
+ @endif + +
+
@if($lastResult){{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}@else// Response will appear here...@endif
+
+
+
+
+ + + @if($selectedTool && !empty($tools)) + @php $currentTool = collect($tools)->firstWhere('name', $selectedTool); @endphp + @if($currentTool && !empty($currentTool['parameters'])) +
+
+

Parameter Reference

+
+ + + + + + + + + + + @foreach($currentTool['parameters'] as $paramName => $paramDef) + + + + + + + @endforeach + +
NameTypeRequiredDescription
{{ $paramName }}{{ is_array($paramDef) ? ($paramDef['type'] ?? 'string') : 'string' }} + @if(is_array($paramDef) && ($paramDef['required'] ?? false)) + Required + @else + Optional + @endif + {{ is_array($paramDef) ? ($paramDef['description'] ?? '-') : $paramDef }}
+
+
+
+ @endif + @endif + + + +
+
diff --git a/src/Website/Mcp/View/Blade/web/playground.blade.php b/src/Website/Mcp/View/Blade/web/playground.blade.php new file mode 100644 index 0000000..0554f9d --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/playground.blade.php @@ -0,0 +1,274 @@ +
+
+

Playground

+

+ Test MCP tools interactively and execute requests live. +

+
+ + {{-- Error Display --}} + @if($error) +
+
+ +

{{ $error }}

+
+
+ @endif + +
+ +
+ +
+

Authentication

+ +
+
+ +
+ +
+ + Validate Key + + + @if($keyStatus === 'valid') + + + Valid + + @elseif($keyStatus === 'invalid') + + + Invalid key + + @elseif($keyStatus === 'expired') + + + Expired + + @elseif($keyStatus === 'empty') + + Enter a key to validate + + @endif +
+ + @if($keyInfo) +
+
+
+ Name: + {{ $keyInfo['name'] }} +
+
+ Workspace: + {{ $keyInfo['workspace'] }} +
+
+ Scopes: + {{ implode(', ', $keyInfo['scopes'] ?? []) }} +
+
+ Last used: + {{ $keyInfo['last_used'] }} +
+
+
+ @elseif(!$isAuthenticated && !$apiKey) +
+

+ Sign in + to create API keys, or paste an existing key above. +

+
+ @endif +
+
+ + +
+

Select Tool

+ +
+ + @foreach($servers as $server) + {{ $server['name'] }} + @endforeach + + + @if($selectedServer && count($tools) > 0) + + @foreach($tools as $tool) + {{ $tool['name'] }} + @endforeach + + @endif +
+
+ + + @if($toolSchema) +
+
+

{{ $toolSchema['name'] }}

+

{{ $toolSchema['description'] ?? $toolSchema['purpose'] ?? '' }}

+
+ + @php + $params = $toolSchema['inputSchema']['properties'] ?? $toolSchema['parameters'] ?? []; + $required = $toolSchema['inputSchema']['required'] ?? []; + @endphp + + @if(count($params) > 0) +
+

Arguments

+ + @foreach($params as $name => $schema) +
+ @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'])) + + @foreach($schema['enum'] as $option) + {{ $option }} + @endforeach + + @elseif($paramType === 'boolean') + + true + false + + @elseif($paramType === 'integer' || $paramType === 'number') + + @else + + @endif +
+ @endforeach +
+ @else +

This tool has no arguments.

+ @endif + +
+ + + @if($keyStatus === 'valid') + Execute Request + @else + Generate Request + @endif + + Executing... + +
+
+ @endif +
+ + +
+
+

Response

+ + @if($response) +
+
+ +
+
{{ $response }}
+
+ @else +
+ +

Select a server and tool to get started.

+
+ @endif +
+ + +
+

API Reference

+
+
+ Endpoint: + {{ config('app.url') }}/api/v1/mcp/tools/call +
+
+ Method: + POST +
+
+ Auth: + @if($keyStatus === 'valid') + Bearer {{ Str::limit($apiKey, 20, '...') }} + @else + Bearer <your-api-key> + @endif +
+
+ Content-Type: + application/json +
+
+ +
+
+
+
+ +@script + +@endscript diff --git a/src/Website/Mcp/View/Blade/web/request-log.blade.php b/src/Website/Mcp/View/Blade/web/request-log.blade.php new file mode 100644 index 0000000..fc6a27b --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/request-log.blade.php @@ -0,0 +1,153 @@ +
+
+

Request Log

+

+ View API requests and generate curl commands to replay them. +

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ @forelse($requests as $request) + + @empty +
+ No requests found. +
+ @endforelse +
+ + @if($requests->hasPages()) +
+ {{ $requests->links() }} +
+ @endif +
+ + +
+ @if($selectedRequest) +
+

Request Detail

+ +
+ +
+ +
+ + + {{ $selectedRequest->response_status }} + {{ $selectedRequest->isSuccessful() ? 'OK' : 'Error' }} + +
+ + +
+ +
{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}
+
+ + +
+ +
{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}
+
+ + @if($selectedRequest->error_message) +
+ +
{{ $selectedRequest->error_message }}
+
+ @endif + + +
+ +
{{ $selectedRequest->toCurl() }}
+
+ + +
+
Request ID: {{ $selectedRequest->request_id }}
+
Duration: {{ $selectedRequest->duration_for_humans }}
+
IP: {{ $selectedRequest->ip_address ?? 'N/A' }}
+
Time: {{ $selectedRequest->created_at->format('Y-m-d H:i:s') }}
+
+
+ @else +
+ +

Select a request to view details and generate replay commands.

+
+ @endif +
+
+
diff --git a/src/Website/Mcp/View/Blade/web/show.blade.php b/src/Website/Mcp/View/Blade/web/show.blade.php new file mode 100644 index 0000000..9c2b9e7 --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/show.blade.php @@ -0,0 +1,227 @@ + + {{ $server['name'] }} + {{ $server['tagline'] ?? $server['description'] ?? '' }} + + +
+ + +
+
+
+ @switch($server['id']) + @case('hosthub-agent') + + @break + @case('commerce') + + @break + @case('socialhost') + + @break + @case('biohost') + + @break + @case('supporthost') + + @break + @case('analyticshost') + + @break + @case('upstream') + + @break + @default + + @endswitch +
+
+

{{ $server['name'] }}

+

{{ $server['id'] }}

+
+
+ + {{ ucfirst($server['status'] ?? 'available') }} + +
+ +

+ {{ $server['tagline'] ?? '' }} +

+
+ + + @if(!empty($server['description'])) +
+

About

+
+ {!! nl2br(e($server['description'])) !!} +
+
+ @endif + + +
+ @if(!empty($server['use_when'])) +
+

+ + Use when +

+
    + @foreach($server['use_when'] as $item) +
  • • {{ $item }}
  • + @endforeach +
+
+ @endif + + @if(!empty($server['dont_use_when'])) +
+

+ + Don't use when +

+
    + @foreach($server['dont_use_when'] as $item) +
  • • {{ $item }}
  • + @endforeach +
+
+ @endif +
+ + + @if(!empty($server['connection'])) +
+

Connection

+
{
+  "{{ $server['id'] }}": {
+    "command": "{{ $server['connection']['command'] ?? 'php' }}",
+    "args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!},
+    "cwd": "{{ $server['connection']['cwd'] ?? '/path/to/project' }}"
+  }
+}
+
+ @endif + + + @if(!empty($server['tools'])) +
+

+ Tools ({{ count($server['tools']) }}) +

+
+ @foreach($server['tools'] as $tool) +
+
+

+ {{ $tool['name'] }} +

+
+

+ {{ $tool['purpose'] ?? '' }} +

+ + @if(!empty($tool['example_prompts'])) +
+

Example prompts:

+
    + @foreach(array_slice($tool['example_prompts'], 0, 3) as $prompt) +
  • "{{ $prompt }}"
  • + @endforeach +
+
+ @endif + + @if(!empty($tool['parameters'])) +
+ + Parameters + +
+ @foreach($tool['parameters'] as $name => $param) +
+ {{ $name }} + @if(!empty($param['required'])) + * + @endif + + {{ is_array($param['type'] ?? '') ? implode('|', $param['type']) : ($param['type'] ?? 'string') }} + + @if(!empty($param['description'])) +

{{ $param['description'] }}

+ @endif +
+ @endforeach +
+
+ @endif +
+ @endforeach +
+
+ @endif + + + @if(!empty($server['resources'])) +
+

+ Resources ({{ count($server['resources']) }}) +

+
+ @foreach($server['resources'] as $resource) +
+ +
+

{{ $resource['uri'] }}

+

{{ $resource['purpose'] ?? $resource['name'] ?? '' }}

+
+
+ @endforeach +
+
+ @endif + + + @if(!empty($server['workflows'])) +
+

+ Workflows ({{ count($server['workflows']) }}) +

+
+ @foreach($server['workflows'] as $workflow) +
+

{{ $workflow['name'] }}

+

{{ $workflow['description'] ?? '' }}

+ @if(!empty($workflow['steps'])) +
    + @foreach($workflow['steps'] as $index => $step) +
  1. + {{ $step['action'] }} + @if(!empty($step['note'])) + — {{ $step['note'] }} + @endif +
  2. + @endforeach +
+ @endif +
+ @endforeach +
+
+ @endif + + + +
diff --git a/src/Website/Mcp/View/Blade/web/unified-search.blade.php b/src/Website/Mcp/View/Blade/web/unified-search.blade.php new file mode 100644 index 0000000..db7d1b3 --- /dev/null +++ b/src/Website/Mcp/View/Blade/web/unified-search.blade.php @@ -0,0 +1,202 @@ +
+
+ +
+

Search

+

Find tools, endpoints, patterns, and more across the system

+
+ + +
+
+
+
+ + + +
+ +
+
+ + +
+
+ Filter: + @foreach($this->types as $typeKey => $typeInfo) + + @endforeach + + @if(count($selectedTypes) > 0) + + @endif +
+
+
+ + + @if(strlen($query) >= 2) + + + + @if($this->results->count() > 0) +
+ Showing {{ $this->results->count() }} result{{ $this->results->count() !== 1 ? 's' : '' }} +
+ @endif + @else + +
+ + + +

Start searching

+

Type at least 2 characters to search across all system components.

+
+ @foreach($this->types as $typeKey => $typeInfo) + + {{ $typeInfo['name'] }} + + @endforeach +
+
+ @endif + + + +
+
diff --git a/src/Website/Mcp/View/Modal/ApiExplorer.php b/src/Website/Mcp/View/Modal/ApiExplorer.php new file mode 100644 index 0000000..1c7bd24 --- /dev/null +++ b/src/Website/Mcp/View/Modal/ApiExplorer.php @@ -0,0 +1,271 @@ + 'List Workspaces', + 'method' => 'GET', + 'path' => '/api/v1/workspaces', + 'description' => 'Get all workspaces for the authenticated user', + 'body' => null, + ], + [ + 'name' => 'Create Workspace', + 'method' => 'POST', + 'path' => '/api/v1/workspaces', + 'description' => 'Create a new workspace', + 'body' => ['name' => 'My Workspace', 'description' => 'A new workspace'], + ], + [ + 'name' => 'Get Workspace', + 'method' => 'GET', + 'path' => '/api/v1/workspaces/{id}', + 'description' => 'Get a specific workspace by ID', + 'body' => null, + ], + [ + 'name' => 'Update Workspace', + 'method' => 'PATCH', + 'path' => '/api/v1/workspaces/{id}', + 'description' => 'Update workspace details', + 'body' => ['name' => 'Updated Workspace', 'settings' => ['timezone' => 'UTC']], + ], + [ + 'name' => 'List Namespaces', + 'method' => 'GET', + 'path' => '/api/v1/namespaces', + 'description' => 'Get all namespaces accessible to the user', + 'body' => null, + ], + [ + 'name' => 'Check Entitlement', + 'method' => 'POST', + 'path' => '/api/v1/namespaces/{id}/entitlements/check', + 'description' => 'Check if a namespace has access to a feature', + 'body' => ['feature' => 'storage', 'quantity' => 1073741824], + ], + [ + 'name' => 'List API Keys', + 'method' => 'GET', + 'path' => '/api/v1/api-keys', + 'description' => 'Get all API keys for the workspace', + 'body' => null, + ], + [ + 'name' => 'Create API Key', + 'method' => 'POST', + 'path' => '/api/v1/api-keys', + 'description' => 'Create a new API key', + 'body' => ['name' => 'Production Key', 'scopes' => ['read:all'], 'rate_limit_tier' => 'pro'], + ], + ]; + + protected ApiSnippetService $snippetService; + + public function boot(ApiSnippetService $snippetService): void + { + $this->snippetService = $snippetService; + } + + public function mount(): void + { + // Set base URL from config + $this->baseUrl = config('api.base_url', config('app.url')); + + // Pre-select first endpoint + if (! empty($this->endpoints)) { + $this->selectEndpoint(0); + } + } + + public function selectEndpoint(int $index): void + { + if (! isset($this->endpoints[$index])) { + return; + } + + $endpoint = $this->endpoints[$index]; + $this->selectedEndpoint = (string) $index; + $this->method = $endpoint['method']; + $this->path = $endpoint['path']; + $this->bodyJson = $endpoint['body'] + ? json_encode($endpoint['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + : '{}'; + $this->response = null; + $this->error = null; + } + + public function getCodeSnippet(): string + { + $headers = [ + 'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + $body = null; + if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { + $body = json_decode($this->bodyJson, true); + } + + return $this->snippetService->generate( + $this->selectedLanguage, + $this->method, + $this->path, + $headers, + $body, + $this->baseUrl + ); + } + + public function getAllSnippets(): array + { + $headers = [ + 'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + $body = null; + if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { + $body = json_decode($this->bodyJson, true); + } + + return $this->snippetService->generateAll( + $this->method, + $this->path, + $headers, + $body, + $this->baseUrl + ); + } + + public function copyToClipboard(): void + { + $this->dispatch('copy-to-clipboard', code: $this->getCodeSnippet()); + } + + public function sendRequest(): void + { + if (empty($this->apiKey)) { + $this->error = 'Please enter your API key to send requests'; + + return; + } + + $this->isLoading = true; + $this->response = null; + $this->error = null; + + try { + $startTime = microtime(true); + + $url = rtrim($this->baseUrl, '/').'/'.ltrim($this->path, '/'); + + $options = [ + 'http' => [ + 'method' => $this->method, + 'header' => [ + "Authorization: Bearer {$this->apiKey}", + 'Content-Type: application/json', + 'Accept: application/json', + ], + 'timeout' => 30, + 'ignore_errors' => true, + ], + ]; + + if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { + $options['http']['content'] = $this->bodyJson; + } + + $context = stream_context_create($options); + $result = @file_get_contents($url, false, $context); + + $this->responseTime = (int) round((microtime(true) - $startTime) * 1000); + + if ($result === false) { + $this->error = 'Request failed - check your API key and endpoint'; + + return; + } + + // Parse response headers + $statusCode = 200; + if (isset($http_response_header[0])) { + preg_match('/HTTP\/\d+\.?\d* (\d+)/', $http_response_header[0], $matches); + $statusCode = (int) ($matches[1] ?? 200); + } + + $this->response = [ + 'status' => $statusCode, + 'body' => json_decode($result, true) ?? $result, + 'headers' => $http_response_header ?? [], + ]; + + } catch (\Exception $e) { + $this->error = $e->getMessage(); + } finally { + $this->isLoading = false; + } + } + + public function formatBody(): void + { + try { + $decoded = json_decode($this->bodyJson, true); + if (json_last_error() === JSON_ERROR_NONE) { + $this->bodyJson = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + } catch (\Exception $e) { + // Ignore + } + } + + public function render() + { + return view('mcp::web.api-explorer', [ + 'languages' => ApiSnippetService::getLanguages(), + 'snippet' => $this->getCodeSnippet(), + ]); + } +} diff --git a/src/Website/Mcp/View/Modal/ApiKeyManager.php b/src/Website/Mcp/View/Modal/ApiKeyManager.php new file mode 100644 index 0000000..a41114f --- /dev/null +++ b/src/Website/Mcp/View/Modal/ApiKeyManager.php @@ -0,0 +1,110 @@ +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::web.api-key-manager', [ + 'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(), + ]); + } +} diff --git a/src/Website/Mcp/View/Modal/Dashboard.php b/src/Website/Mcp/View/Modal/Dashboard.php new file mode 100644 index 0000000..1138fda --- /dev/null +++ b/src/Website/Mcp/View/Modal/Dashboard.php @@ -0,0 +1,188 @@ +resetPage(); + } + + public function updatingTypeFilter(): void + { + $this->resetPage(); + } + + public function updatingStatusFilter(): void + { + $this->resetPage(); + } + + public function getVendorsProperty() + { + try { + return Vendor::active()->withCount(['todos', 'releases'])->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getStatsProperty(): array + { + try { + return [ + 'total_vendors' => Vendor::active()->count(), + 'pending_todos' => UpstreamTodo::pending()->count(), + 'quick_wins' => UpstreamTodo::quickWins()->count(), + 'security_updates' => UpstreamTodo::pending()->where('type', 'security')->count(), + 'recent_releases' => \Mod\Uptelligence\Models\VersionRelease::recent(7)->count(), + 'in_progress' => UpstreamTodo::inProgress()->count(), + ]; + } catch (\Illuminate\Database\QueryException $e) { + return [ + 'total_vendors' => 0, + 'pending_todos' => 0, + 'quick_wins' => 0, + 'security_updates' => 0, + 'recent_releases' => 0, + 'in_progress' => 0, + ]; + } + } + + public function getTodosProperty() + { + try { + $query = UpstreamTodo::with('vendor') + ->orderByDesc('priority') + ->orderBy('effort'); + + if ($this->vendorFilter) { + $query->where('vendor_id', $this->vendorFilter); + } + + if ($this->typeFilter) { + $query->where('type', $this->typeFilter); + } + + if ($this->statusFilter) { + $query->where('status', $this->statusFilter); + } + + if ($this->effortFilter) { + $query->where('effort', $this->effortFilter); + } + + if ($this->quickWinsOnly) { + $query->where('effort', 'low')->where('priority', '>=', 5); + } + + return $query->paginate(15); + } catch (\Illuminate\Database\QueryException $e) { + return new \Illuminate\Pagination\LengthAwarePaginator([], 0, 15); + } + } + + public function getRecentLogsProperty() + { + try { + return AnalysisLog::with('vendor') + ->latest() + ->limit(10) + ->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getAssetsProperty() + { + try { + return Asset::active()->orderBy('type')->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getPatternsProperty() + { + try { + return Pattern::active()->orderBy('category')->limit(6)->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getAssetStatsProperty(): array + { + try { + return [ + 'total' => Asset::active()->count(), + 'updates_available' => Asset::active()->needsUpdate()->count(), + 'patterns' => Pattern::active()->count(), + ]; + } catch (\Illuminate\Database\QueryException $e) { + return [ + 'total' => 0, + 'updates_available' => 0, + 'patterns' => 0, + ]; + } + } + + public function markInProgress(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markInProgress(); + } + + public function markPorted(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markPorted(); + } + + public function markSkipped(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markSkipped(); + } + + public function render() + { + return view('mcp::web.dashboard'); + } +} diff --git a/src/Website/Mcp/View/Modal/McpMetrics.php b/src/Website/Mcp/View/Modal/McpMetrics.php new file mode 100644 index 0000000..00d20d6 --- /dev/null +++ b/src/Website/Mcp/View/Modal/McpMetrics.php @@ -0,0 +1,90 @@ +metricsService = $metricsService; + } + + public function setDays(int $days): void + { + // Bound days to a reasonable range (1-90) + $this->days = min(max($days, 1), 90); + } + + public function setTab(string $tab): void + { + $this->activeTab = $tab; + } + + public function getOverviewProperty(): array + { + return app(McpMetricsService::class)->getOverview($this->days); + } + + public function getDailyTrendProperty(): array + { + return app(McpMetricsService::class)->getDailyTrend($this->days); + } + + public function getTopToolsProperty(): array + { + return app(McpMetricsService::class)->getTopTools($this->days, 10); + } + + public function getServerStatsProperty(): array + { + return app(McpMetricsService::class)->getServerStats($this->days); + } + + public function getRecentCallsProperty(): array + { + return app(McpMetricsService::class)->getRecentCalls(20); + } + + public function getErrorBreakdownProperty(): array + { + return app(McpMetricsService::class)->getErrorBreakdown($this->days); + } + + public function getToolPerformanceProperty(): array + { + return app(McpMetricsService::class)->getToolPerformance($this->days, 10); + } + + public function getHourlyDistributionProperty(): array + { + return app(McpMetricsService::class)->getHourlyDistribution(); + } + + public function getPlanActivityProperty(): array + { + return app(McpMetricsService::class)->getPlanActivity($this->days, 10); + } + + public function render() + { + return view('mcp::web.mcp-metrics'); + } +} diff --git a/src/Website/Mcp/View/Modal/McpPlayground.php b/src/Website/Mcp/View/Modal/McpPlayground.php new file mode 100644 index 0000000..e879e99 --- /dev/null +++ b/src/Website/Mcp/View/Modal/McpPlayground.php @@ -0,0 +1,358 @@ + 'required|string', + 'selectedTool' => 'required|string', + 'inputJson' => 'required|json', + ]; + + public function mount(): void + { + $this->loadServers(); + + if (! empty($this->servers)) { + $this->selectedServer = $this->servers[0]['id']; + $this->loadTools(); + } + } + + public function updatedSelectedServer(): void + { + $this->loadTools(); + $this->selectedTool = ''; + $this->inputJson = '{}'; + $this->lastResult = null; + $this->lastError = null; + } + + public function updatedSelectedTool(): void + { + // Pre-fill example parameters based on tool definition + $this->prefillParameters(); + $this->lastResult = null; + $this->lastError = null; + } + + public function execute(): void + { + $this->validate(); + + // Rate limit: 10 executions per minute per user/IP + $rateLimitKey = 'mcp-playground:'.$this->getRateLimitKey(); + if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { + $this->lastError = 'Too many requests. Please wait before trying again.'; + + return; + } + RateLimiter::hit($rateLimitKey, 60); + + $this->isExecuting = true; + $this->lastResult = null; + $this->lastError = null; + + try { + $params = json_decode($this->inputJson, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->lastError = 'Invalid JSON: '.json_last_error_msg(); + + return; + } + + $startTime = microtime(true); + $result = $this->callTool($this->selectedServer, $this->selectedTool, $params); + $this->executionTime = (int) round((microtime(true) - $startTime) * 1000); + + if (isset($result['error'])) { + $this->lastError = $result['error']; + $this->lastResult = $result; + } else { + $this->lastResult = $result; + } + + } catch (\Exception $e) { + $this->lastError = $e->getMessage(); + } finally { + $this->isExecuting = false; + } + } + + /** + * Get rate limit key based on user or IP. + */ + protected function getRateLimitKey(): string + { + if (auth()->check()) { + return 'user:'.auth()->id(); + } + + return 'ip:'.request()->ip(); + } + + public function formatJson(): void + { + try { + $decoded = json_decode($this->inputJson, true); + if (json_last_error() === JSON_ERROR_NONE) { + $this->inputJson = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + } catch (\Exception $e) { + // Ignore formatting errors + } + } + + protected function loadServers(): void + { + $registry = $this->loadRegistry(); + + $this->servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerYaml($ref['id'])) + ->filter() + ->map(fn ($server) => [ + 'id' => $server['id'], + 'name' => $server['name'], + 'tagline' => $server['tagline'] ?? '', + 'tool_count' => count($server['tools'] ?? []), + ]) + ->values() + ->all(); + } + + protected function loadTools(): void + { + if (empty($this->selectedServer)) { + $this->tools = []; + + return; + } + + $server = $this->loadServerYaml($this->selectedServer); + + $this->tools = collect($server['tools'] ?? []) + ->map(fn ($tool) => [ + 'name' => $tool['name'], + 'purpose' => $tool['purpose'] ?? '', + 'parameters' => $tool['parameters'] ?? [], + ]) + ->values() + ->all(); + } + + protected function prefillParameters(): void + { + if (empty($this->selectedTool)) { + $this->inputJson = '{}'; + + return; + } + + $tool = collect($this->tools)->firstWhere('name', $this->selectedTool); + + if (! $tool || empty($tool['parameters'])) { + $this->inputJson = '{}'; + + return; + } + + // Build example params from parameter definitions + $params = []; + foreach ($tool['parameters'] as $paramName => $paramDef) { + if (is_array($paramDef)) { + $type = $paramDef['type'] ?? 'string'; + $default = $paramDef['default'] ?? null; + $required = $paramDef['required'] ?? false; + + if ($default !== null) { + $params[$paramName] = $default; + } elseif ($required) { + // Add placeholder + $params[$paramName] = match ($type) { + 'boolean' => false, + 'integer', 'number' => 0, + 'array' => [], + default => '', + }; + } + } + } + + $this->inputJson = json_encode($params, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + protected function callTool(string $serverId, string $toolName, array $params): array + { + $server = $this->loadServerYaml($serverId); + + if (! $server) { + return ['error' => 'Server not found']; + } + + $connection = $server['connection'] ?? []; + $type = $connection['type'] ?? 'stdio'; + + if ($type !== 'stdio') { + return ['error' => "Connection type '{$type}' not supported in playground"]; + } + + $command = $connection['command'] ?? null; + $args = $connection['args'] ?? []; + $cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd()); + + if (! $command) { + return ['error' => 'No command configured for this server']; + } + + // Build MCP tool call request + $request = json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $params, + ], + 'id' => 1, + ]); + + try { + $startTime = microtime(true); + + $fullCommand = array_merge([$command], $args); + $process = new Process($fullCommand, $cwd); + $process->setInput($request); + $process->setTimeout(30); + + $process->run(); + + $duration = (int) round((microtime(true) - $startTime) * 1000); + $output = $process->getOutput(); + + // Log the tool call + McpToolCall::log( + serverId: $serverId, + toolName: $toolName, + params: $params, + success: $process->isSuccessful(), + durationMs: $duration, + errorMessage: $process->isSuccessful() ? null : $process->getErrorOutput(), + ); + + if (! $process->isSuccessful()) { + return [ + 'error' => 'Process failed', + 'exit_code' => $process->getExitCode(), + 'stderr' => $process->getErrorOutput(), + ]; + } + + // Parse JSON-RPC response + $lines = explode("\n", trim($output)); + foreach ($lines as $line) { + $response = json_decode($line, true); + if ($response) { + if (isset($response['error'])) { + return [ + 'error' => $response['error']['message'] ?? 'Unknown error', + 'code' => $response['error']['code'] ?? null, + 'data' => $response['error']['data'] ?? null, + ]; + } + if (isset($response['result'])) { + return $response['result']; + } + } + } + + return [ + 'error' => 'No valid response received', + 'raw_output' => $output, + ]; + + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + } + + protected function loadRegistry(): array + { + return Cache::remember('mcp:registry', 0, function () { + $path = resource_path('mcp/registry.yaml'); + if (! file_exists($path)) { + return ['servers' => []]; + } + + return Yaml::parseFile($path); + }); + } + + protected function loadServerYaml(string $id): ?array + { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + 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); + } + + 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); + } + + public function render() + { + return view('mcp::web.mcp-playground'); + } +} diff --git a/src/Website/Mcp/View/Modal/Playground.php b/src/Website/Mcp/View/Modal/Playground.php new file mode 100644 index 0000000..cfccdd9 --- /dev/null +++ b/src/Website/Mcp/View/Modal/Playground.php @@ -0,0 +1,293 @@ +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; + } + + // Rate limit: 10 executions per minute per user/IP + $rateLimitKey = 'mcp-playground-api:'.$this->getRateLimitKey(); + if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { + $this->error = 'Too many requests. Please wait before trying again.'; + + return; + } + RateLimiter::hit($rateLimitKey, 60); + + $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::web.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 + { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + + $path = resource_path("mcp/servers/{$id}.yaml"); + + return file_exists($path) ? Yaml::parseFile($path) : null; + } + + /** + * Get rate limit key based on user or IP. + */ + protected function getRateLimitKey(): string + { + if (auth()->check()) { + return 'user:'.auth()->id(); + } + + return 'ip:'.request()->ip(); + } + + protected function loadServerSummary(string $id): ?array + { + $server = $this->loadServerFull($id); + if (! $server) { + return null; + } + + return [ + 'id' => $server['id'], + 'name' => $server['name'], + 'tagline' => $server['tagline'] ?? '', + ]; + } +} diff --git a/src/Website/Mcp/View/Modal/RequestLog.php b/src/Website/Mcp/View/Modal/RequestLog.php new file mode 100644 index 0000000..0e81606 --- /dev/null +++ b/src/Website/Mcp/View/Modal/RequestLog.php @@ -0,0 +1,100 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function selectRequest(int $id): void + { + $workspace = auth()->user()?->defaultHostWorkspace(); + + // Only allow selecting requests that belong to the user's workspace + $request = McpApiRequest::query() + ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) + ->find($id); + + if (! $request) { + $this->selectedRequestId = null; + $this->selectedRequest = null; + + return; + } + + $this->selectedRequestId = $id; + $this->selectedRequest = $request; + } + + 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::web.request-log', [ + 'requests' => $requests, + 'servers' => $servers, + ]); + } +} diff --git a/src/Website/Mcp/View/Modal/UnifiedSearch.php b/src/Website/Mcp/View/Modal/UnifiedSearch.php new file mode 100644 index 0000000..03bf000 --- /dev/null +++ b/src/Website/Mcp/View/Modal/UnifiedSearch.php @@ -0,0 +1,82 @@ +searchService = $searchService; + } + + public function updatedQuery(): void + { + // Debounce handled by wire:model.debounce + } + + public function toggleType(string $type): void + { + if (in_array($type, $this->selectedTypes)) { + $this->selectedTypes = array_values(array_diff($this->selectedTypes, [$type])); + } else { + $this->selectedTypes[] = $type; + } + } + + public function clearFilters(): void + { + $this->selectedTypes = []; + } + + public function getResultsProperty(): Collection + { + if (strlen($this->query) < 2) { + return collect(); + } + + return $this->searchService->search($this->query, $this->selectedTypes, $this->limit); + } + + public function getTypesProperty(): array + { + return UnifiedSearchService::getTypes(); + } + + public function getResultCountsByTypeProperty(): array + { + if (strlen($this->query) < 2) { + return []; + } + + $allResults = $this->searchService->search($this->query, [], 200); + + return $allResults->groupBy('type')->map->count()->toArray(); + } + + public function render() + { + return view('mcp::web.unified-search'); + } +}