+ */
+ 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/php/src/Mcp/Tools/ContentTools.php b/src/php/src/Mcp/Tools/ContentTools.php
new file mode 100644
index 0000000..1ae32f5
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/Tools/GetStats.php b/src/php/src/Mcp/Tools/GetStats.php
new file mode 100644
index 0000000..42ae06d
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/Tools/ListRoutes.php b/src/php/src/Mcp/Tools/ListRoutes.php
new file mode 100644
index 0000000..7afa3ff
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/Tools/ListSites.php b/src/php/src/Mcp/Tools/ListSites.php
new file mode 100644
index 0000000..bd4b627
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/Tools/ListTables.php b/src/php/src/Mcp/Tools/ListTables.php
new file mode 100644
index 0000000..ce3accb
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/Tools/QueryDatabase.php b/src/php/src/Mcp/Tools/QueryDatabase.php
new file mode 100644
index 0000000..4a31144
--- /dev/null
+++ b/src/php/src/Mcp/Tools/QueryDatabase.php
@@ -0,0 +1,417 @@
+validator = $this->createValidator();
+ $this->auditService = $auditService ?? app(QueryAuditService::class);
+ $this->executionService = $executionService ?? app(QueryExecutionService::class);
+ }
+
+ public function handle(Request $request): Response
+ {
+ $query = $request->input('query');
+ $explain = $request->input('explain', false);
+
+ // Extract context from request for audit logging
+ $workspaceId = $this->getWorkspaceId($request);
+ $userId = $this->getUserId($request);
+ $userIp = $this->getUserIp($request);
+ $sessionId = $request->input('session_id');
+
+ if (empty($query)) {
+ return $this->errorResponse('Query is required');
+ }
+
+ // Validate the query - log blocked queries
+ try {
+ $this->validator->validate($query);
+ } catch (ForbiddenQueryException $e) {
+ $this->auditService->recordBlocked(
+ query: $query,
+ bindings: [],
+ reason: $e->reason,
+ workspaceId: $workspaceId,
+ userId: $userId,
+ userIp: $userIp,
+ context: ['session_id' => $sessionId]
+ );
+
+ return $this->errorResponse($e->getMessage());
+ }
+
+ // Check for blocked tables
+ $blockedTable = $this->checkBlockedTables($query);
+ if ($blockedTable !== null) {
+ $this->auditService->recordBlocked(
+ query: $query,
+ bindings: [],
+ reason: "Access to blocked table: {$blockedTable}",
+ workspaceId: $workspaceId,
+ userId: $userId,
+ userIp: $userIp,
+ context: ['session_id' => $sessionId, 'blocked_table' => $blockedTable]
+ );
+
+ return $this->errorResponse(
+ sprintf("Access to table '%s' is not permitted", $blockedTable)
+ );
+ }
+
+ try {
+ $connection = $this->getConnection();
+
+ // If explain is requested, run EXPLAIN first
+ if ($explain) {
+ return $this->handleExplain($connection, $query, $workspaceId, $userId, $userIp, $sessionId);
+ }
+
+ // Execute query with tier-based limits, timeout, and audit logging
+ $result = $this->executionService->execute(
+ query: $query,
+ connection: $connection,
+ workspaceId: $workspaceId,
+ userId: $userId,
+ userIp: $userIp,
+ context: [
+ 'session_id' => $sessionId,
+ 'explain_requested' => false,
+ ]
+ );
+
+ // Build response with data and metadata
+ $response = [
+ 'data' => $result['data'],
+ 'meta' => $result['meta'],
+ ];
+
+ // Add warning if results were truncated
+ if ($result['meta']['truncated']) {
+ $response['warning'] = $result['meta']['warning'];
+ }
+
+ return Response::text(json_encode($response, JSON_PRETTY_PRINT));
+ } catch (QueryTimeoutException $e) {
+ return $this->errorResponse(
+ 'Query timed out: '.$e->getMessage().
+ ' Consider adding more specific filters or indexes.'
+ );
+ } 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 optimisation 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;
+ }
+
+ /**
+ * Extract workspace ID from request context.
+ */
+ private function getWorkspaceId(Request $request): ?int
+ {
+ // Try to get from request context or metadata
+ $workspaceId = $request->input('workspace_id');
+ if ($workspaceId !== null) {
+ return (int) $workspaceId;
+ }
+
+ // Try from auth context
+ if (function_exists('workspace') && workspace()) {
+ return workspace()->id;
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract user ID from request context.
+ */
+ private function getUserId(Request $request): ?int
+ {
+ // Try to get from request context
+ $userId = $request->input('user_id');
+ if ($userId !== null) {
+ return (int) $userId;
+ }
+
+ // Try from auth
+ if (auth()->check()) {
+ return auth()->id();
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract user IP from request context.
+ */
+ private function getUserIp(Request $request): ?string
+ {
+ // Try from request metadata
+ $ip = $request->input('user_ip');
+ if ($ip !== null) {
+ return $ip;
+ }
+
+ // Try from HTTP request
+ if (request()) {
+ return request()->ip();
+ }
+
+ return null;
+ }
+
+ /**
+ * 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,
+ ?int $workspaceId = null,
+ ?int $userId = null,
+ ?string $userIp = null,
+ ?string $sessionId = null
+ ): Response {
+ $startTime = microtime(true);
+
+ try {
+ // Run EXPLAIN on the query
+ $explainResults = DB::connection($connection)->select("EXPLAIN {$query}");
+ $durationMs = (int) ((microtime(true) - $startTime) * 1000);
+
+ // 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);
+
+ // Log the EXPLAIN query
+ $this->auditService->recordSuccess(
+ query: "EXPLAIN {$query}",
+ bindings: [],
+ durationMs: $durationMs,
+ rowCount: count($explainResults),
+ workspaceId: $workspaceId,
+ userId: $userId,
+ userIp: $userIp,
+ context: ['session_id' => $sessionId, 'explain_requested' => true]
+ );
+
+ return Response::text(json_encode($response, JSON_PRETTY_PRINT));
+ } catch (\Exception $e) {
+ $durationMs = (int) ((microtime(true) - $startTime) * 1000);
+
+ $this->auditService->recordError(
+ query: "EXPLAIN {$query}",
+ bindings: [],
+ errorMessage: $e->getMessage(),
+ durationMs: $durationMs,
+ workspaceId: $workspaceId,
+ userId: $userId,
+ userIp: $userIp,
+ context: ['session_id' => $sessionId, 'explain_requested' => true]
+ );
+
+ 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/php/src/Mcp/View/Blade/admin/analytics/dashboard.blade.php b/src/php/src/Mcp/View/Blade/admin/analytics/dashboard.blade.php
new file mode 100644
index 0000000..10a44b0
--- /dev/null
+++ b/src/php/src/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',
+ ])
+
+
+
+
+
+
+ Overview
+
+
+ All Tools
+
+
+ Errors
+
+
+ Combinations
+
+
+
+
+ @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)
+
+ @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)
+
+ @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/php/src/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php b/src/php/src/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php
new file mode 100644
index 0000000..c873cf3
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php b/src/php/src/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php
new file mode 100644
index 0000000..a03c517
--- /dev/null
+++ b/src/php/src/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php
@@ -0,0 +1,100 @@
+@props(['tools'])
+
+
+
+
+
+
+ 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
+
+
+
+
+ @forelse($tools as $tool)
+
+
+
+ {{ $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
+
+
+
+ @empty
+
+
+ No tool usage data available
+
+
+ @endforelse
+
+
diff --git a/src/php/src/Mcp/View/Blade/admin/analytics/tool-detail.blade.php b/src/php/src/Mcp/View/Blade/admin/analytics/tool-detail.blade.php
new file mode 100644
index 0000000..3166aaa
--- /dev/null
+++ b/src/php/src/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
+
+
+
+ @endif
+
+
+
+
+
+
+ Response Time Distribution
+
+
+
+
+
Fastest
+
{{ $this->formatDuration($this->stats->minDurationMs) }}
+
+
+
Average
+
{{ $this->formatDuration($this->stats->avgDurationMs) }}
+
+
+
Slowest
+
{{ $this->formatDuration($this->stats->maxDurationMs) }}
+
+
+
+
+
+
+
+
+ Daily Breakdown
+
+
+
+
+
+ Date
+ Calls
+ Errors
+ Error Rate
+ Avg Duration
+
+
+
+ @forelse($this->trends as $day)
+ @if($day['calls'] > 0)
+
+ {{ $day['date'] }}
+ {{ number_format($day['calls']) }}
+ {{ number_format($day['errors']) }}
+
+
+ {{ round($day['error_rate'], 1) }}%
+
+
+ {{ $this->formatDuration($day['avg_duration_ms']) }}
+
+ @endif
+ @empty
+
+
+ No data available for this period
+
+
+ @endforelse
+
+
+
+
+
diff --git a/src/php/src/Mcp/View/Blade/admin/api-key-manager.blade.php b/src/php/src/Mcp/View/Blade/admin/api-key-manager.blade.php
new file mode 100644
index 0000000..7226a73
--- /dev/null
+++ b/src/php/src/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
+
+
+
+
+ {{ __('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') }}
+
+
+
+
+ @foreach($keys as $key)
+
+
+ {{ $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') }}
+
+
+
+ @endforeach
+
+
+ @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.expiry_label') }}
+
+ {{ __('mcp::mcp.keys.create_modal.expiry_never') }}
+ {{ __('mcp::mcp.keys.create_modal.expiry_30') }}
+ {{ __('mcp::mcp.keys.create_modal.expiry_90') }}
+ {{ __('mcp::mcp.keys.create_modal.expiry_1year') }}
+
+
+
+
+
+ {{ __('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/php/src/Mcp/View/Blade/admin/audit-log-viewer.blade.php b/src/php/src/Mcp/View/Blade/admin/audit-log-viewer.blade.php
new file mode 100644
index 0000000..dbac118
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Blade/admin/mcp-playground.blade.php b/src/php/src/Mcp/View/Blade/admin/mcp-playground.blade.php
new file mode 100644
index 0000000..d5f5191
--- /dev/null
+++ b/src/php/src/Mcp/View/Blade/admin/mcp-playground.blade.php
@@ -0,0 +1,502 @@
+
+ {{-- Header --}}
+
+
+
+
MCP Playground
+
+ Interactive tool testing with documentation and examples
+
+
+
+
+
+
+
+ History
+ @if(count($conversationHistory) > 0)
+
+ {{ count($conversationHistory) }}
+
+ @endif
+
+
+
+
+
+ {{-- Error Display --}}
+ @if($error)
+
+ @endif
+
+
+ {{-- Left Sidebar: Tool Browser --}}
+
+
+ {{-- Server Selection --}}
+
+ Server
+
+ Select a server...
+ @foreach($servers as $server)
+ {{ $server['name'] }} ({{ $server['tool_count'] }})
+ @endforeach
+
+
+
+ @if($selectedServer)
+ {{-- Search --}}
+
+
+ {{-- Category Filter --}}
+ @if($categories->isNotEmpty())
+
+
Category
+
+
+ All
+
+ @foreach($categories as $category)
+
+ {{ $category }}
+
+ @endforeach
+
+
+ @endif
+
+ {{-- Tools List --}}
+
+ @forelse($toolsByCategory as $category => $categoryTools)
+
+
{{ $category }}
+
+ @foreach($categoryTools as $tool)
+
+
+
{{ $tool['name'] }}
+ @if($selectedTool === $tool['name'])
+
+
+
+ @endif
+
+ @if(!empty($tool['description']))
+ {{ Str::limit($tool['description'], 80) }}
+ @endif
+
+ @endforeach
+ @empty
+
+ @endforelse
+
+ @else
+
+
+
+
+
Select a server to browse tools
+
+ @endif
+
+
+
+ {{-- Center: Tool Details & Input Form --}}
+
+ {{-- API Key Authentication --}}
+
+
+
+
+
+ Authentication
+
+
+
+
API Key
+
+
Paste your API key to execute requests live
+
+
+
+ Validate Key
+
+ @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
+
+ Load examples
+
+
+
+ @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
+
+
+
+ {{ $name }}
+ @if($isRequired)
+ *
+ @endif
+
+
+ @if(isset($schema['enum']))
+
+ Select...
+ @foreach($schema['enum'] as $option)
+ {{ $option }}
+ @endforeach
+
+ @elseif($type === 'boolean')
+
+ Default
+ true
+ false
+
+ @elseif($type === 'integer' || $type === 'number')
+
+ @elseif($type === 'array' || $type === 'object')
+
+ @else
+
+ @endif
+
+ @if($description)
+
{{ $description }}
+ @endif
+
+ @endforeach
+
+ @else
+
This tool has no parameters.
+ @endif
+
+
+
+
+ @if($keyStatus === 'valid')
+ Execute Request
+ @else
+ Generate Request Preview
+ @endif
+
+
+
+
+
+
+ Executing...
+
+
+
+
+ @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)
+
+
+
+
+
+ Copy
+ Copied!
+
+
+
+ @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)
+
+ Clear All
+
+ @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
+
+
+
+
+ View
+
+
+ Re-run
+
+
+
+
+ @endforeach
+
+ @else
+
+
No history yet. Execute a tool to see it here.
+
+ @endif
+
+
+
diff --git a/src/php/src/Mcp/View/Blade/admin/playground.blade.php b/src/php/src/Mcp/View/Blade/admin/playground.blade.php
new file mode 100644
index 0000000..1077ee5
--- /dev/null
+++ b/src/php/src/Mcp/View/Blade/admin/playground.blade.php
@@ -0,0 +1,281 @@
+
+
+
{{ __('mcp::mcp.playground.title') }}
+
+ {{ __('mcp::mcp.playground.description') }}
+
+
+
+ {{-- Error Display --}}
+ @if($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)
+
+ @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)
+
+
+
+ {{ __('mcp::mcp.playground.response.copy') }}
+ {{ __('mcp::mcp.playground.response.copied') }}
+
+
+
{{ $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/php/src/Mcp/View/Blade/admin/quota-usage.blade.php b/src/php/src/Mcp/View/Blade/admin/quota-usage.blade.php
new file mode 100644
index 0000000..90f27fe
--- /dev/null
+++ b/src/php/src/Mcp/View/Blade/admin/quota-usage.blade.php
@@ -0,0 +1,186 @@
+
+ {{-- Header --}}
+
+
+
MCP Usage Quota
+
+ Current billing period resets {{ $this->resetDate }}
+
+
+
+
+ Refresh
+
+
+
+ {{-- 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
+
+
+
+
+ Month
+ Tool Calls
+ Input Tokens
+ Output Tokens
+ Total Tokens
+
+
+
+ @foreach($usageHistory as $record)
+
+
+ {{ $record->month_label }}
+
+
+ {{ number_format($record->tool_calls_count) }}
+
+
+ {{ number_format($record->input_tokens) }}
+
+
+ {{ number_format($record->output_tokens) }}
+
+
+ {{ number_format($record->total_tokens) }}
+
+
+ @endforeach
+
+
+
+
+ @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/php/src/Mcp/View/Blade/admin/request-log.blade.php b/src/php/src/Mcp/View/Blade/admin/request-log.blade.php
new file mode 100644
index 0000000..9086b55
--- /dev/null
+++ b/src/php/src/Mcp/View/Blade/admin/request-log.blade.php
@@ -0,0 +1,153 @@
+
+
+
{{ __('mcp::mcp.logs.title') }}
+
+ {{ __('mcp::mcp.logs.description') }}
+
+
+
+
+
+
+
+ {{ __('mcp::mcp.logs.filters.server') }}
+
+ {{ __('mcp::mcp.logs.filters.all_servers') }}
+ @foreach($servers as $server)
+ {{ $server }}
+ @endforeach
+
+
+
+ {{ __('mcp::mcp.logs.filters.status') }}
+
+ {{ __('mcp::mcp.logs.filters.all') }}
+ {{ __('mcp::mcp.logs.filters.success') }}
+ {{ __('mcp::mcp.logs.filters.failed') }}
+
+
+
+
+
+
+
+
+
+ @forelse($requests as $request)
+
+
+
+
+ {{ $request->response_status }}
+
+
+ {{ $request->server_id }}/{{ $request->tool_name }}
+
+
+
+ {{ $request->duration_for_humans }}
+
+
+
+ {{ $request->created_at->diffForHumans() }}
+ ·
+ {{ $request->request_id }}
+
+
+ @empty
+
+ {{ __('mcp::mcp.logs.empty') }}
+
+ @endforelse
+
+
+ @if($requests->hasPages())
+
+ {{ $requests->links() }}
+
+ @endif
+
+
+
+
+ @if($selectedRequest)
+
+
{{ __('mcp::mcp.logs.detail.title') }}
+
+
+
+
+
+
+
+
+ {{ __('mcp::mcp.logs.detail.status') }}
+
+ {{ $selectedRequest->response_status }}
+ {{ $selectedRequest->isSuccessful() ? __('mcp::mcp.logs.status_ok') : __('mcp::mcp.logs.status_error') }}
+
+
+
+
+
+
{{ __('mcp::mcp.logs.detail.request') }}
+
{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}
+
+
+
+
+
{{ __('mcp::mcp.logs.detail.response') }}
+
{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}
+
+
+ @if($selectedRequest->error_message)
+
+
{{ __('mcp::mcp.logs.detail.error') }}
+
{{ $selectedRequest->error_message }}
+
+ @endif
+
+
+
+
+ {{ __('mcp::mcp.logs.detail.replay_command') }}
+
+ {{ __('mcp::mcp.logs.detail.copy') }}
+ {{ __('mcp::mcp.logs.detail.copied') }}
+
+
+
{{ $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/php/src/Mcp/View/Blade/admin/tool-version-manager.blade.php b/src/php/src/Mcp/View/Blade/admin/tool-version-manager.blade.php
new file mode 100644
index 0000000..c0323e0
--- /dev/null
+++ b/src/php/src/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\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
+
+
+
+
+
+
+ @endif
+
diff --git a/src/php/src/Mcp/View/Modal/Admin/ApiKeyManager.php b/src/php/src/Mcp/View/Modal/Admin/ApiKeyManager.php
new file mode 100644
index 0000000..749449b
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Modal/Admin/AuditLogViewer.php b/src/php/src/Mcp/View/Modal/Admin/AuditLogViewer.php
new file mode 100644
index 0000000..7002ec5
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Modal/Admin/McpPlayground.php b/src/php/src/Mcp/View/Modal/Admin/McpPlayground.php
new file mode 100644
index 0000000..1941e88
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Modal/Admin/Playground.php b/src/php/src/Mcp/View/Modal/Admin/Playground.php
new file mode 100644
index 0000000..12b3d66
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Modal/Admin/QuotaUsage.php b/src/php/src/Mcp/View/Modal/Admin/QuotaUsage.php
new file mode 100644
index 0000000..1c40f52
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Modal/Admin/RequestLog.php b/src/php/src/Mcp/View/Modal/Admin/RequestLog.php
new file mode 100644
index 0000000..147266c
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php b/src/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php
new file mode 100644
index 0000000..5e8c3ea
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php b/src/php/src/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php
new file mode 100644
index 0000000..7353724
--- /dev/null
+++ b/src/php/src/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/php/src/Mcp/View/Modal/Admin/ToolVersionManager.php b/src/php/src/Mcp/View/Modal/Admin/ToolVersionManager.php
new file mode 100644
index 0000000..74a862e
--- /dev/null
+++ b/src/php/src/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/php/src/Website/Mcp/Boot.php b/src/php/src/Website/Mcp/Boot.php
new file mode 100644
index 0000000..5be5d94
--- /dev/null
+++ b/src/php/src/Website/Mcp/Boot.php
@@ -0,0 +1,55 @@
+loadViewsFrom(__DIR__.'/View/Blade', 'mcp');
+
+ // Register mcp layout into the layouts:: namespace
+ $layoutsPath = dirname(__DIR__, 2).'/Front/View/Blade/layouts';
+ $this->loadViewsFrom($layoutsPath, 'layouts');
+ Blade::anonymousComponentPath($layoutsPath, 'layouts');
+
+ $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
+ {
+ // HTML portal routes need web middleware (sessions, CSRF for Livewire)
+ Route::middleware('web')->group(__DIR__.'/Routes/web.php');
+ }
+}
diff --git a/src/php/src/Website/Mcp/Controllers/McpRegistryController.php b/src/php/src/Website/Mcp/Controllers/McpRegistryController.php
new file mode 100644
index 0000000..38c504c
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/Routes/web.php b/src/php/src/Website/Mcp/Routes/web.php
new file mode 100644
index 0000000..1240da3
--- /dev/null
+++ b/src/php/src/Website/Mcp/Routes/web.php
@@ -0,0 +1,46 @@
+name('mcp.')->group(function () {
+ // Agent discovery endpoint (always JSON, no auth)
+ Route::get('.well-known/mcp-servers.json', [McpRegistryController::class, 'registry'])
+ ->name('registry');
+
+ // ── Human-readable portal (optional auth) ────────────────────
+ Route::get('/', [McpRegistryController::class, 'landing'])
+ ->middleware(McpAuthenticate::class.':optional')
+ ->name('landing');
+
+ Route::get('servers', [McpRegistryController::class, 'index'])
+ ->middleware(McpAuthenticate::class.':optional')
+ ->name('servers.index');
+
+ Route::get('servers/{id}', [McpRegistryController::class, 'show'])
+ ->middleware(McpAuthenticate::class.':optional')
+ ->name('servers.show')
+ ->where('id', '[a-z0-9-]+');
+
+ 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/php/src/Website/Mcp/View/Blade/web/analytics.blade.php b/src/php/src/Website/Mcp/View/Blade/web/analytics.blade.php
new file mode 100644
index 0000000..07771c8
--- /dev/null
+++ b/src/php/src/Website/Mcp/View/Blade/web/analytics.blade.php
@@ -0,0 +1,115 @@
+
+ {{ $server['name'] }} Analytics
+
+
+
+
+
+
+
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/php/src/Website/Mcp/View/Blade/web/api-explorer.blade.php b/src/php/src/Website/Mcp/View/Blade/web/api-explorer.blade.php
new file mode 100644
index 0000000..cb87e4f
--- /dev/null
+++ b/src/php/src/Website/Mcp/View/Blade/web/api-explorer.blade.php
@@ -0,0 +1,219 @@
+
+
+
+
+
API Explorer
+
Interactive documentation with code snippets in 11 languages
+
+
+
+
+
+
+
+
+
+
API Key
+
+
+
+ Production
+ Homelab
+ Local
+
+
+
Enter your API key to enable live testing. Keys are not stored.
+
+
+
+
+
+
+
+
+
+
Endpoints
+
+
+ @foreach($endpoints as $index => $endpoint)
+
+
+
+ {{ $endpoint['method'] }}
+
+ {{ $endpoint['name'] }}
+
+ {{ $endpoint['path'] }}
+
+ @endforeach
+
+
+
+
+
+
+
+
+
+
Request
+
+
+
+
+ GET
+ POST
+ PUT
+ PATCH
+ DELETE
+
+
+
+
+ @if(in_array($method, ['POST', 'PUT', 'PATCH']))
+
+
+ Request Body (JSON)
+ Format
+
+
+
+ @endif
+
+
+ Send Request
+
+
+
+
+
+ Sending...
+
+
+
+
+
+
+
+
+
+
Code Snippet
+
+
+
+
+ Copy
+
+
+
+
+
+
+
+ @foreach($languages as $lang)
+
+ {{ $lang['name'] }}
+
+ @endforeach
+
+
+
+
+
+
+
+
+ @if($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/php/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php b/src/php/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php
new file mode 100644
index 0000000..cdfcc3f
--- /dev/null
+++ b/src/php/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php
@@ -0,0 +1,269 @@
+
+
+ @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
+
+
+
+
+ Name
+
+
+ Key
+
+
+ Scopes
+
+
+ Last Used
+
+
+ Expires
+
+
+ Actions
+
+
+
+
+ @foreach($keys as $key)
+
+
+ {{ $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
+
+
+
+ @endforeach
+
+
+ @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:
+
+ @php $mcpUrl = request()->getSchemeAndHttpHost(); @endphp
+
curl -X POST {{ $mcpUrl }}/tools/call \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "server": "openbrain",
+ "tool": "brain_recall",
+ "arguments": { "query": "recent decisions" }
+ }'
+
+
+
+
+
+
+
Create API Key
+
+
+
+
+
Key Name
+
+ @error('newKeyName')
+
{{ $message }}
+ @enderror
+
+
+
+
+
+
+
+ Expiration
+
+ Never expires
+ 30 days
+ 90 days
+ 1 year
+
+
+
+
+
+ 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/php/src/Website/Mcp/View/Blade/web/connect.blade.php b/src/php/src/Website/Mcp/View/Blade/web/connect.blade.php
new file mode 100644
index 0000000..419ff69
--- /dev/null
+++ b/src/php/src/Website/Mcp/View/Blade/web/connect.blade.php
@@ -0,0 +1,218 @@
+
+ Setup Guide
+
+ @php
+ $mcpUrl = request()->getSchemeAndHttpHost();
+ @endphp
+
+
+
+
Setup Guide
+
+ Connect AI agents to MCP servers via HTTP.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
HTTP API
+
+ All platforms
+
+
+
+
+
+ Call MCP tools from any language, platform, or AI agent using standard HTTP requests.
+ Works with Claude Code, Cursor, custom agents, webhooks, and any HTTP client.
+
+
+
1. Get your API key
+
+ Create an API key from your admin dashboard. Keys use the hk_ prefix.
+
+
+
2. Discover available servers
+
curl {{ $mcpUrl }}/servers.json \
+ -H "Authorization: Bearer YOUR_API_KEY"
+
+
3. Call a tool
+
curl -X POST {{ $mcpUrl }}/tools/call \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "server": "openbrain",
+ "tool": "brain_recall",
+ "arguments": { "query": "authentication decisions" }
+ }'
+
+
4. Read a resource
+
curl {{ $mcpUrl }}/resources/plans://all \
+ -H "Authorization: Bearer YOUR_API_KEY"
+
+
+
+
+
+ GET /.well-known/mcp-servers.json
+ Agent discovery
+
+
+ GET /servers
+ List all servers
+
+
+ GET /servers/{id}
+ Server details + tools
+
+
+ POST /tools/call
+ Execute a tool
+
+
+ GET /resources/{uri}
+ Read a resource
+
+
+
+
+
+
+
+
+
+
+
+
+
Code Examples
+
+
+
+
+
+
+
Python
+
import requests
+
+resp = requests.post(
+ "{{ $mcpUrl }}/tools/call",
+ headers={"Authorization": "Bearer hk_your_key"},
+ json={
+ "server": "openbrain",
+ "tool": "brain_recall",
+ "arguments": {"query": "recent decisions"}
+ }
+)
+print(resp.json())
+
+
+
+
+
JavaScript
+
const resp = await fetch("{{ $mcpUrl }}/tools/call", {
+ method: "POST",
+ headers: {
+ "Authorization": "Bearer hk_your_key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ server: "openbrain",
+ tool: "brain_recall",
+ arguments: { query: "recent decisions" },
+ }),
+});
+const data = await resp.json();
+
+
+
+
+
+
+
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.
+
+
+
+
+
Rate limiting
+
+ Requests are rate limited to 120 per minute. Rate limit headers
+ (X-RateLimit-Limit, X-RateLimit-Remaining)
+ are included in all responses.
+
+
+
+
+
+
+
Discovery
+
+ Agents discover available servers automatically via the well-known endpoint:
+
+
curl {{ $mcpUrl }}/.well-known/mcp-servers.json
+
+ Returns the server registry with capabilities and connection details.
+ No authentication required for discovery.
+
+
+
+
+
+
Need help setting up?
+
+
+ Browse Servers
+
+
+ OpenAPI Spec
+
+
+
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/dashboard.blade.php b/src/php/src/Website/Mcp/View/Blade/web/dashboard.blade.php
new file mode 100644
index 0000000..f530059
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/View/Blade/web/index.blade.php b/src/php/src/Website/Mcp/View/Blade/web/index.blade.php
new file mode 100644
index 0000000..6e25257
--- /dev/null
+++ b/src/php/src/Website/Mcp/View/Blade/web/index.blade.php
@@ -0,0 +1,132 @@
+
+ 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('openbrain')
+
+ @break
+ @case('analyticshost')
+
+ @break
+ @case('eaas')
+
+ @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/php/src/Website/Mcp/View/Blade/web/keys.blade.php b/src/php/src/Website/Mcp/View/Blade/web/keys.blade.php
new file mode 100644
index 0000000..92ff915
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/View/Blade/web/landing.blade.php b/src/php/src/Website/Mcp/View/Blade/web/landing.blade.php
new file mode 100644
index 0000000..5da9bfe
--- /dev/null
+++ b/src/php/src/Website/Mcp/View/Blade/web/landing.blade.php
@@ -0,0 +1,214 @@
+
+ MCP Portal
+ Connect AI agents to platform infrastructure via Model Context Protocol. Machine-readable, agent-optimised, human-friendly.
+
+
+
+
+ MCP Ecosystem
+
+
+ Connect AI agents to platform infrastructure via MCP.
+ Machine-readable •
+ Agent-optimised •
+ Human-friendly
+
+
+
+ Browse Servers
+
+
+ Setup Guide
+
+
+
+
+
+
+
+
+
+
+
+ @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
+
+
+ @php
+ $mcpUrl = request()->getSchemeAndHttpHost();
+ @endphp
+
+ Quick Start
+
+ Call MCP tools via HTTP with your API key:
+
+ curl -X POST {{ $mcpUrl }}/tools/call \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "server": "openbrain",
+ "tool": "brain_recall",
+ "arguments": { "query": "recent decisions" }
+ }'
+
+
+ Full Setup Guide
+
+
+ OpenAPI Spec
+
+
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php b/src/php/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php
new file mode 100644
index 0000000..fd550bc
--- /dev/null
+++ b/src/php/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'] }}
+
+
+
+
+
+
+
+ Overview
+
+
+ Performance
+
+
+ Errors
+
+
+ Activity Feed
+
+
+
+
+ @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)
+
+
+
+
+
+ Tool
+ Calls
+ Min
+ Avg
+ p50
+ p95
+ p99
+ Max
+
+
+
+ @forelse($this->toolPerformance as $tool)
+
+ {{ $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
+
+ @empty
+
+ No performance data recorded yet
+
+ @endforelse
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+ Tool
+ Error Code
+ Count
+
+
+
+ @forelse($this->errorBreakdown as $error)
+
+ {{ $error->tool_name }}
+
+
+ {{ $error->error_code ?? 'unknown' }}
+
+
+ {{ number_format($error->error_count) }}
+
+ @empty
+
+ No errors recorded - all systems healthy
+
+ @endforelse
+
+
+
+
+ @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/php/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php b/src/php/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php
new file mode 100644
index 0000000..df3ea74
--- /dev/null
+++ b/src/php/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
+
+
+
+ Server
+
+ Select a server...
+ @foreach($servers as $server)
+
+ {{ $server['name'] }} ({{ $server['tool_count'] }} tools)
+
+ @endforeach
+
+
+
+
+
+
Tool
+
+ Select a tool...
+ @foreach($tools as $tool)
+
+ {{ $tool['name'] }}
+
+ @endforeach
+
+ @if($selectedTool)
+ @php $currentTool = collect($tools)->firstWhere('name', $selectedTool); @endphp
+ @if($currentTool && !empty($currentTool['purpose']))
+
{{ $currentTool['purpose'] }}
+ @endif
+ @endif
+
+
+
+
+
+ Parameters (JSON)
+
+ Format JSON
+
+
+
+ @error('inputJson')
+
{{ $message }}
+ @enderror
+
+
+
+
+ Execute Tool
+
+
+
+
+
+ Executing...
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+ Name
+ Type
+ Required
+ Description
+
+
+
+ @foreach($currentTool['parameters'] as $paramName => $paramDef)
+
+ {{ $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 }}
+
+ @endforeach
+
+
+
+
+
+ @endif
+ @endif
+
+
+
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/playground.blade.php b/src/php/src/Website/Mcp/View/Blade/web/playground.blade.php
new file mode 100644
index 0000000..205a512
--- /dev/null
+++ b/src/php/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)
+
+ @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)
+
+
+
+ Copy
+ Copied
+
+
+
{{ $response }}
+
+ @else
+
+
+
Select a server and tool to get started.
+
+ @endif
+
+
+
+
+
API Reference
+
+
+ Endpoint:
+ {{ request()->getSchemeAndHttpHost() }}/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/php/src/Website/Mcp/View/Blade/web/request-log.blade.php b/src/php/src/Website/Mcp/View/Blade/web/request-log.blade.php
new file mode 100644
index 0000000..fc6a27b
--- /dev/null
+++ b/src/php/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.
+
+
+
+
+
+
+
+ Server
+
+ All servers
+ @foreach($servers as $server)
+ {{ $server }}
+ @endforeach
+
+
+
+ Status
+
+ All
+ Success
+ Failed
+
+
+
+
+
+
+
+
+
+ @forelse($requests as $request)
+
+
+
+
+ {{ $request->response_status }}
+
+
+ {{ $request->server_id }}/{{ $request->tool_name }}
+
+
+
+ {{ $request->duration_for_humans }}
+
+
+
+ {{ $request->created_at->diffForHumans() }}
+ ·
+ {{ $request->request_id }}
+
+
+ @empty
+
+ No requests found.
+
+ @endforelse
+
+
+ @if($requests->hasPages())
+
+ {{ $requests->links() }}
+
+ @endif
+
+
+
+
+ @if($selectedRequest)
+
+
Request Detail
+
+
+
+
+
+
+
+
+ Status
+
+ {{ $selectedRequest->response_status }}
+ {{ $selectedRequest->isSuccessful() ? 'OK' : 'Error' }}
+
+
+
+
+
+
Request
+
{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}
+
+
+
+
+
Response
+
{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}
+
+
+ @if($selectedRequest->error_message)
+
+
Error
+
{{ $selectedRequest->error_message }}
+
+ @endif
+
+
+
+
+ Replay Command
+
+ Copy
+ Copied
+
+
+
{{ $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/php/src/Website/Mcp/View/Blade/web/show.blade.php b/src/php/src/Website/Mcp/View/Blade/web/show.blade.php
new file mode 100644
index 0000000..c8aaad3
--- /dev/null
+++ b/src/php/src/Website/Mcp/View/Blade/web/show.blade.php
@@ -0,0 +1,243 @@
+
+ {{ $server['name'] }}
+ {{ $server['tagline'] ?? $server['description'] ?? '' }}
+
+
+
+
+
+ ← Back to Servers
+
+
+
+
+
+
+ @switch($server['id'])
+ @case('hosthub-agent')
+
+ @break
+ @case('commerce')
+
+ @break
+ @case('socialhost')
+
+ @break
+ @case('biohost')
+
+ @break
+ @case('supporthost')
+
+ @break
+ @case('openbrain')
+
+ @break
+ @case('analyticshost')
+
+ @break
+ @case('eaas')
+
+ @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
+
+
+
+ @php
+ $mcpUrl = request()->getSchemeAndHttpHost();
+ @endphp
+
+
Connection
+
+ Call tools on this server via HTTP:
+
+
curl -X POST {{ $mcpUrl }}/tools/call \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "server": "{{ $server['id'] }}",
+ "tool": "{{ !empty($server['tools']) ? $server['tools'][0]['name'] : 'tool_name' }}",
+ "arguments": {}
+ }'
+
+
+ Full setup guide →
+
+
+
+
+
+ @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)
+
+ {{ $step['action'] }}
+ @if(!empty($step['note']))
+ — {{ $step['note'] }}
+ @endif
+
+ @endforeach
+
+ @endif
+
+ @endforeach
+
+
+ @endif
+
+
+
+
diff --git a/src/php/src/Website/Mcp/View/Blade/web/unified-search.blade.php b/src/php/src/Website/Mcp/View/Blade/web/unified-search.blade.php
new file mode 100644
index 0000000..db7d1b3
--- /dev/null
+++ b/src/php/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)
+
+ @if($typeInfo['icon'] === 'wrench')
+
+ @elseif($typeInfo['icon'] === 'document')
+
+ @elseif($typeInfo['icon'] === 'globe-alt')
+
+ @elseif($typeInfo['icon'] === 'puzzle-piece')
+
+ @elseif($typeInfo['icon'] === 'cube')
+
+ @elseif($typeInfo['icon'] === 'clipboard-list')
+
+ @elseif($typeInfo['icon'] === 'map')
+
+ @endif
+ {{ $typeInfo['name'] }}
+ @if(isset($this->resultCountsByType[$typeKey]))
+ {{ $this->resultCountsByType[$typeKey] }}
+ @endif
+
+ @endforeach
+
+ @if(count($selectedTypes) > 0)
+
+ Clear filters
+
+ @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/php/src/Website/Mcp/View/Modal/ApiExplorer.php b/src/php/src/Website/Mcp/View/Modal/ApiExplorer.php
new file mode 100644
index 0000000..60d79c6
--- /dev/null
+++ b/src/php/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 current request (mcp domain)
+ $this->baseUrl = request()->getSchemeAndHttpHost();
+
+ // 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/php/src/Website/Mcp/View/Modal/ApiKeyManager.php b/src/php/src/Website/Mcp/View/Modal/ApiKeyManager.php
new file mode 100644
index 0000000..a41114f
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/View/Modal/Dashboard.php b/src/php/src/Website/Mcp/View/Modal/Dashboard.php
new file mode 100644
index 0000000..1138fda
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/View/Modal/McpMetrics.php b/src/php/src/Website/Mcp/View/Modal/McpMetrics.php
new file mode 100644
index 0000000..dc00b60
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/View/Modal/McpPlayground.php b/src/php/src/Website/Mcp/View/Modal/McpPlayground.php
new file mode 100644
index 0000000..fb8fbbc
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/View/Modal/Playground.php b/src/php/src/Website/Mcp/View/Modal/Playground.php
new file mode 100644
index 0000000..38785fc
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/View/Modal/RequestLog.php b/src/php/src/Website/Mcp/View/Modal/RequestLog.php
new file mode 100644
index 0000000..cac4380
--- /dev/null
+++ b/src/php/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/php/src/Website/Mcp/View/Modal/UnifiedSearch.php b/src/php/src/Website/Mcp/View/Modal/UnifiedSearch.php
new file mode 100644
index 0000000..4130fe4
--- /dev/null
+++ b/src/php/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');
+ }
+}
diff --git a/src/php/storage/app/.gitignore b/src/php/storage/app/.gitignore
new file mode 100644
index 0000000..8f4803c
--- /dev/null
+++ b/src/php/storage/app/.gitignore
@@ -0,0 +1,3 @@
+*
+!public/
+!.gitignore
diff --git a/src/php/storage/app/public/.gitignore b/src/php/storage/app/public/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/src/php/storage/app/public/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/src/php/storage/framework/.gitignore b/src/php/storage/framework/.gitignore
new file mode 100644
index 0000000..05c4471
--- /dev/null
+++ b/src/php/storage/framework/.gitignore
@@ -0,0 +1,9 @@
+compiled.php
+config.php
+down
+events.scanned.php
+maintenance.php
+routes.php
+routes.scanned.php
+schedule-*
+services.json
diff --git a/src/php/storage/framework/cache/.gitignore b/src/php/storage/framework/cache/.gitignore
new file mode 100644
index 0000000..01e4a6c
--- /dev/null
+++ b/src/php/storage/framework/cache/.gitignore
@@ -0,0 +1,3 @@
+*
+!data/
+!.gitignore
diff --git a/src/php/storage/framework/cache/data/.gitignore b/src/php/storage/framework/cache/data/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/src/php/storage/framework/cache/data/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/src/php/storage/framework/sessions/.gitignore b/src/php/storage/framework/sessions/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/src/php/storage/framework/sessions/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/src/php/storage/framework/testing/.gitignore b/src/php/storage/framework/testing/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/src/php/storage/framework/testing/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/src/php/storage/framework/views/.gitignore b/src/php/storage/framework/views/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/src/php/storage/framework/views/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/src/php/storage/logs/.gitignore b/src/php/storage/logs/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/src/php/storage/logs/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/src/php/tailwind.config.js b/src/php/tailwind.config.js
new file mode 100644
index 0000000..26e1310
--- /dev/null
+++ b/src/php/tailwind.config.js
@@ -0,0 +1,11 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./resources/**/*.blade.php",
+ "./resources/**/*.js",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/src/php/tests/Feature/.gitkeep b/src/php/tests/Feature/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/php/tests/Pest.php b/src/php/tests/Pest.php
new file mode 100644
index 0000000..4feefee
--- /dev/null
+++ b/src/php/tests/Pest.php
@@ -0,0 +1,41 @@
+in('Feature', 'Unit', '../src/Mcp/Tests/Unit');
+
+/*
+|--------------------------------------------------------------------------
+| Database Refresh
+|--------------------------------------------------------------------------
+|
+| Apply RefreshDatabase to Feature tests that need a clean database state.
+| Unit tests typically don't require database access.
+|
+*/
+
+uses(RefreshDatabase::class)->in('Feature', '../src/Mcp/Tests/Unit');
diff --git a/src/php/tests/TestCase.php b/src/php/tests/TestCase.php
new file mode 100644
index 0000000..fe1ffc2
--- /dev/null
+++ b/src/php/tests/TestCase.php
@@ -0,0 +1,10 @@
+isValid('SELECT * FROM users'))->toBeTrue();
+ expect($validator->isValid('SELECT id, name FROM users'))->toBeTrue();
+ expect($validator->isValid('SELECT `id`, `name` FROM `users`'))->toBeTrue();
+ });
+
+ it('allows SELECT with WHERE clause', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid("SELECT * FROM users WHERE id = 1"))->toBeTrue();
+ expect($validator->isValid("SELECT * FROM users WHERE name = 'John'"))->toBeTrue();
+ expect($validator->isValid("SELECT * FROM users WHERE id = 1 AND status = 'active'"))->toBeTrue();
+ expect($validator->isValid("SELECT * FROM users WHERE id = 1 OR id = 2"))->toBeTrue();
+ });
+
+ it('allows SELECT with ORDER BY', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid('SELECT * FROM users ORDER BY name'))->toBeTrue();
+ expect($validator->isValid('SELECT * FROM users ORDER BY name ASC'))->toBeTrue();
+ expect($validator->isValid('SELECT * FROM users ORDER BY name DESC'))->toBeTrue();
+ });
+
+ it('allows SELECT with LIMIT', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid('SELECT * FROM users LIMIT 10'))->toBeTrue();
+ expect($validator->isValid('SELECT * FROM users LIMIT 10, 20'))->toBeTrue();
+ });
+
+ it('allows COUNT queries', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid('SELECT COUNT(*) FROM users'))->toBeTrue();
+ expect($validator->isValid("SELECT COUNT(*) FROM users WHERE status = 'active'"))->toBeTrue();
+ });
+
+ it('allows queries with trailing semicolon', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid('SELECT * FROM users;'))->toBeTrue();
+ expect($validator->isValid('SELECT id FROM users WHERE id = 1;'))->toBeTrue();
+ });
+ });
+
+ describe('blocked data modification statements', function () {
+ it('blocks INSERT statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('INSERT INTO users (name) VALUES ("test")'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('INSERT users SET name = "test"'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks UPDATE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('UPDATE users SET name = "test"'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('UPDATE users SET name = "test" WHERE id = 1'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks DELETE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('DELETE FROM users'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('DELETE FROM users WHERE id = 1'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks REPLACE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('REPLACE INTO users (id, name) VALUES (1, "test")'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks TRUNCATE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('TRUNCATE TABLE users'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('blocked schema modification statements', function () {
+ it('blocks DROP statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('DROP TABLE users'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('DROP DATABASE mydb'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('DROP INDEX idx_name ON users'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks ALTER statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('ALTER TABLE users ADD column email VARCHAR(255)'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('ALTER TABLE users DROP column email'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks CREATE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('CREATE TABLE test (id INT)'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('CREATE INDEX idx ON users (name)'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('CREATE DATABASE newdb'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks RENAME statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('RENAME TABLE users TO customers'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('blocked permission and admin statements', function () {
+ it('blocks GRANT statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('GRANT SELECT ON users TO user@localhost'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks REVOKE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('REVOKE SELECT ON users FROM user@localhost'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks FLUSH statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('FLUSH PRIVILEGES'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('FLUSH TABLES'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks KILL statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('KILL 12345'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('KILL QUERY 12345'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks SET statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('SET GLOBAL max_connections = 500'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('SET SESSION sql_mode = ""'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('blocked execution statements', function () {
+ it('blocks EXECUTE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('EXECUTE prepared_stmt'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks PREPARE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('PREPARE stmt FROM "SELECT * FROM users"'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks CALL statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('CALL stored_procedure()'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks DEALLOCATE statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('DEALLOCATE PREPARE stmt'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('blocked file operations', function () {
+ it('blocks INTO OUTFILE', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users INTO OUTFILE '/tmp/users.csv'"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks INTO DUMPFILE', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users INTO DUMPFILE '/tmp/dump.txt'"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks LOAD_FILE', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT LOAD_FILE('/etc/passwd')"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks LOAD DATA', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("LOAD DATA INFILE '/tmp/data.csv' INTO TABLE users"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('SQL injection prevention - UNION attacks', function () {
+ it('blocks basic UNION injection', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 UNION SELECT * FROM passwords"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks UNION ALL injection', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 UNION ALL SELECT password FROM users"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks UNION with NULL padding', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT id, name FROM users WHERE id = 1 UNION SELECT NULL, password FROM admin"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks UNION with comment obfuscation', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 UN/**/ION SELECT * FROM admin"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 /*!UNION*/ SELECT * FROM admin"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks UNION with case variation', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 UnIoN SELECT * FROM admin"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 union SELECT * FROM admin"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('SQL injection prevention - stacked queries', function () {
+ it('blocks semicolon-separated statements', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users; DROP TABLE users"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT * FROM users; DELETE FROM users"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks stacked queries with comments', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users; -- DROP TABLE users"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT * FROM users;/* comment */DROP TABLE users"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks multiple semicolons', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT 1; SELECT 2; SELECT 3"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks semicolon not at end', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users; "))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('SQL injection prevention - time-based attacks', function () {
+ it('blocks SLEEP function', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 AND SLEEP(5)"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT SLEEP(5)"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks BENCHMARK function', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT BENCHMARK(10000000, SHA1('test'))"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 AND BENCHMARK(1000000, MD5('x'))"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('SQL injection prevention - encoding attacks', function () {
+ it('blocks hex-encoded strings', function () {
+ $validator = new SqlQueryValidator();
+
+ // 0x61646d696e = 'admin'
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE name = 0x61646d696e"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT 0x44524f50205441424c4520757365727320"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks CHAR function for string construction', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE name = CHAR(97, 100, 109, 105, 110)"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT CHAR(65)"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('SQL injection prevention - subquery restrictions', function () {
+ it('blocks subqueries in WHERE clause', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = (SELECT admin_id FROM admins)"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id IN (SELECT id FROM admins)"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks correlated subqueries', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM admins a WHERE a.user_id = u.id)"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('SQL injection prevention - system table access', function () {
+ it('blocks INFORMATION_SCHEMA access', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM INFORMATION_SCHEMA.TABLES"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT table_name FROM information_schema.columns"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks mysql system database access', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM mysql.user"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT host, user FROM mysql.db"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks performance_schema access', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM performance_schema.threads"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks sys schema access', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SELECT * FROM sys.session"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('SQL injection prevention - comment obfuscation', function () {
+ it('blocks inline comment keyword obfuscation', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SEL/**/ECT * FROM users"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE id = 1 OR/**/1=1"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('blocks MySQL conditional comments with harmful content', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("/*!50000 DROP TABLE users */"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('query structure validation', function () {
+ it('requires query to start with SELECT', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate("SHOW TABLES"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("DESCRIBE users"))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate("EXPLAIN SELECT * FROM users"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('validates query does not start with non-SELECT', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate(" UPDATE users SET name = 'test'"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+
+ describe('whitelist configuration', function () {
+ it('can disable whitelist checking', function () {
+ $validator = new SqlQueryValidator(useWhitelist: false);
+
+ // This complex query would fail whitelist but passes without it
+ // (still blocked by other checks, but testing the flag works)
+ expect($validator->isValid('SELECT * FROM users'))->toBeTrue();
+ });
+
+ it('can add custom whitelist patterns', function () {
+ $validator = new SqlQueryValidator();
+
+ // Add pattern for JOINs which aren't in default whitelist
+ $validator->addWhitelistPattern('/^\s*SELECT\s+.+\s+FROM\s+\w+\s+JOIN\s+\w+/i');
+
+ // Now JOIN queries should work (if they pass other checks)
+ // Note: The default whitelist may still reject, testing the method works
+ expect($validator)->toBeInstanceOf(SqlQueryValidator::class);
+ });
+
+ it('can replace entire whitelist', function () {
+ $validator = new SqlQueryValidator();
+
+ $validator->setWhitelist([
+ '/^\s*SELECT\s+1\s*;?\s*$/i',
+ ]);
+
+ expect($validator->isValid('SELECT 1'))->toBeTrue();
+ expect($validator->isValid('SELECT * FROM users'))->toBeFalse();
+ });
+ });
+
+ describe('exception details', function () {
+ it('includes query in exception for blocked keyword', function () {
+ $validator = new SqlQueryValidator();
+ $query = 'DROP TABLE users';
+
+ try {
+ $validator->validate($query);
+ test()->fail('Expected ForbiddenQueryException');
+ } catch (ForbiddenQueryException $e) {
+ expect($e->query)->toBe($query);
+ expect($e->reason)->toContain('DROP');
+ }
+ });
+
+ it('includes reason for structural issues', function () {
+ $validator = new SqlQueryValidator();
+ $query = 'SHOW TABLES';
+
+ try {
+ $validator->validate($query);
+ test()->fail('Expected ForbiddenQueryException');
+ } catch (ForbiddenQueryException $e) {
+ expect($e->reason)->toContain('SELECT');
+ }
+ });
+
+ it('includes reason for whitelist failure', function () {
+ $validator = new SqlQueryValidator();
+ // Complex query that passes keyword checks but fails whitelist
+ $query = 'SELECT @@version';
+
+ try {
+ $validator->validate($query);
+ test()->fail('Expected ForbiddenQueryException');
+ } catch (ForbiddenQueryException $e) {
+ expect($e->reason)->toContain('pattern');
+ }
+ });
+ });
+
+ describe('edge cases', function () {
+ it('handles empty query', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate(''))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('handles whitespace-only query', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate(' '))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('normalises excessive whitespace', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid("SELECT * FROM users"))->toBeTrue();
+ expect($validator->isValid("SELECT\n*\nFROM\nusers"))->toBeTrue();
+ expect($validator->isValid("SELECT\t*\tFROM\tusers"))->toBeTrue();
+ });
+
+ it('is case insensitive for keywords', function () {
+ $validator = new SqlQueryValidator();
+
+ expect(fn () => $validator->validate('drop TABLE users'))
+ ->toThrow(ForbiddenQueryException::class);
+
+ expect(fn () => $validator->validate('DrOp TaBlE users'))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+
+ it('handles queries with backtick-quoted identifiers', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid('SELECT `id`, `name` FROM `users`'))->toBeTrue();
+ });
+
+ it('handles queries with single-quoted strings', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid("SELECT * FROM users WHERE name = 'O''Brien'"))->toBeTrue();
+ });
+
+ it('handles queries with double-quoted strings', function () {
+ $validator = new SqlQueryValidator();
+
+ expect($validator->isValid('SELECT * FROM users WHERE name = "John"'))->toBeTrue();
+ });
+ });
+
+ describe('boolean-based injection prevention', function () {
+ it('allows legitimate OR conditions in WHERE', function () {
+ $validator = new SqlQueryValidator();
+
+ // Legitimate use
+ expect($validator->isValid("SELECT * FROM users WHERE id = 1 OR id = 2"))->toBeTrue();
+ });
+
+ it('blocks dangerous patterns even within valid structure', function () {
+ $validator = new SqlQueryValidator();
+
+ // These contain hex encoding which is always blocked
+ expect(fn () => $validator->validate("SELECT * FROM users WHERE name = 0x41"))
+ ->toThrow(ForbiddenQueryException::class);
+ });
+ });
+});
diff --git a/src/php/vite.config.js b/src/php/vite.config.js
new file mode 100644
index 0000000..421b569
--- /dev/null
+++ b/src/php/vite.config.js
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vite';
+import laravel from 'laravel-vite-plugin';
+
+export default defineConfig({
+ plugins: [
+ laravel({
+ input: ['resources/css/app.css', 'resources/js/app.js'],
+ refresh: true,
+ }),
+ ],
+});