From eed8488c5229c1be3432e53d725905e73097cbbb Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 8 Aug 2026 11:49:20 +0100 Subject: [PATCH] refactor(mcp): stop importing the consumer, and drop what agent already owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This package imported Core\Mod\Agentic\Models\* in four files while dappcore/agent is meant to depend on it — a library importing the thing that imports it. AX-8: the arrow points one way. Three files go because agent already owns them, verified rather than assumed: AgentToolRegistry agent's has all 13 shared methods plus 6 more, none only here. Unreferenced in this package — even McpApiController reaches for agent's FQCN, not this. AgentSessionService agent's has all 20 shared plus replay and getReplayContext, none only here. Zero references. McpAgentServerCommand 2064 lines. Its 29 tool methods each map to an extracted class in agent's Mcp\Tools\Agent, and agent's Boot::onMcpTools registers every one — checked for registration, not just for the files existing. Its five resource handlers were the one thing agent did NOT have: agent's server answered resources/list with a hardcoded empty array and had no resources/read. Deleting this first would have silently dropped plans://all, plans://{slug}, plans://{slug}/phases/{order}, plans://{slug}/state/{key} and sessions://{id}/context. They were ported to agent first, in dAppCore/agent#20, and only then is this removed. McpApiController carried a third copy of the same rendering, and that is what made the cycle a compile-time one. The plan and session renderers are replaced by AgentResourceProvider, an interface this package owns and the module that owns the data implements, resolved from the container and absent without complaint when agent is not installed. Both model imports go with them. Interim worth stating plainly: nothing is bound to AgentResourceProvider yet, so this package's HTTP plans:// and sessions:// endpoints answer "not found" until agent binds its registry to it. Agent implementing the interface needs agent to depend on dappcore/mcp, which is still blocked on the Core\Mcp\ PSR-4 root being claimed by both packages. The data itself is not lost — agent's own MCP server serves all five URIs as of #20; it is the HTTP mirror here that is degraded until that lands. Two soft couplings remain by design, both container lookups by string with no import and a guard: AgentToolRegistry in executeTool and AgentApiKeyService in McpAuthenticate. Neither is a compile-time dependency and neither creates a composer cycle; both would be better behind interfaces, and are left for when the namespace collision is resolved. Suite unchanged: 21 failed, 299 passed. 2786 deletions, 102 insertions. Co-Authored-By: Virgil --- php/src/Mcp/Boot.php | 7 +- .../Commands/McpAgentServerCommand.php | 2064 ----------------- php/src/Mcp/Controllers/McpApiController.php | 185 +- .../Contracts/AgentResourceProvider.php | 52 + php/src/Mcp/Services/AgentSessionService.php | 336 --- php/src/Mcp/Services/AgentToolRegistry.php | 244 -- 6 files changed, 102 insertions(+), 2786 deletions(-) delete mode 100644 php/src/Mcp/Console/Commands/McpAgentServerCommand.php create mode 100644 php/src/Mcp/Resources/Contracts/AgentResourceProvider.php delete mode 100644 php/src/Mcp/Services/AgentSessionService.php delete mode 100644 php/src/Mcp/Services/AgentToolRegistry.php diff --git a/php/src/Mcp/Boot.php b/php/src/Mcp/Boot.php index 1cf546c..4b4839f 100644 --- a/php/src/Mcp/Boot.php +++ b/php/src/Mcp/Boot.php @@ -128,14 +128,15 @@ public function onConsole(ConsoleBooting $event): void $event->middleware('mcp.quota', Middleware\CheckMcpQuota::class); $event->middleware('mcp.dependencies', Middleware\ValidateToolDependencies::class); - $event->command(Console\Commands\McpAgentServerCommand::class); $event->command(Console\Commands\PruneMetricsCommand::class); $event->command(Console\Commands\VerifyAuditLogCommand::class); } public function onMcpTools(McpToolsRegistering $event): void { - // MCP tool handlers will be registered here once extracted - // from the monolithic McpAgentServerCommand + // Nothing to register. The agent tool surface — plans, phases, + // sessions, state, content — belongs to dappcore/agent, which + // registers its own tools and now serves its own resources. This + // package holds the protocol, not the agent's domain. } } diff --git a/php/src/Mcp/Console/Commands/McpAgentServerCommand.php b/php/src/Mcp/Console/Commands/McpAgentServerCommand.php deleted file mode 100644 index fe49ed8..0000000 --- a/php/src/Mcp/Console/Commands/McpAgentServerCommand.php +++ /dev/null @@ -1,2064 +0,0 @@ -registerTools(); - $this->registerResources(); - - // Run MCP server loop - while (($line = fgets(STDIN)) !== false) { - $line = trim($line); - if (empty($line)) { - continue; - } - - try { - $request = json_decode($line, true, 512, JSON_THROW_ON_ERROR); - $response = $this->handleRequest($request); - - if ($response !== null) { - $this->sendResponse($response); - } - } catch (Throwable $e) { - Log::error('MCP Agent Server error', [ - 'error' => $e->getMessage(), - 'line' => $line, - ]); - - $this->sendResponse([ - 'jsonrpc' => '2.0', - 'id' => null, - 'error' => [ - 'code' => -32700, - 'message' => 'Parse error: '.$e->getMessage(), - ], - ]); - } - } - - return 0; - } - - protected function registerTools(): void - { - // Plan management tools - $this->tools['plan_list'] = [ - 'description' => 'List all work plans with their current status and progress', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'status' => [ - 'type' => 'string', - 'description' => 'Filter by status (draft, active, paused, completed, archived)', - 'enum' => ['draft', 'active', 'paused', 'completed', 'archived'], - ], - 'include_archived' => [ - 'type' => 'boolean', - 'description' => 'Include archived plans (default: false)', - ], - ], - ], - 'handler' => 'toolPlanList', - ]; - - $this->tools['plan_create'] = [ - 'description' => 'Create a new work plan with phases and tasks', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'title' => [ - 'type' => 'string', - 'description' => 'Plan title', - ], - 'slug' => [ - 'type' => 'string', - 'description' => 'URL-friendly identifier (auto-generated if not provided)', - ], - 'description' => [ - 'type' => 'string', - 'description' => 'Plan description', - ], - 'context' => [ - 'type' => 'object', - 'description' => 'Additional context (related files, dependencies, etc.)', - ], - 'phases' => [ - 'type' => 'array', - 'description' => 'Array of phase definitions with name, description, and tasks', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string'], - 'description' => ['type' => 'string'], - 'tasks' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - ], - ], - ], - ], - ], - 'required' => ['title'], - ], - 'handler' => 'toolPlanCreate', - ]; - - $this->tools['plan_get'] = [ - 'description' => 'Get detailed information about a specific plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'format' => [ - 'type' => 'string', - 'description' => 'Output format: json or markdown', - 'enum' => ['json', 'markdown'], - ], - ], - 'required' => ['slug'], - ], - 'handler' => 'toolPlanGet', - ]; - - $this->tools['plan_update_status'] = [ - 'description' => 'Update the status of a plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'status' => [ - 'type' => 'string', - 'description' => 'New status', - 'enum' => ['draft', 'active', 'paused', 'completed'], - ], - ], - 'required' => ['slug', 'status'], - ], - 'handler' => 'toolPlanUpdateStatus', - ]; - - $this->tools['plan_archive'] = [ - 'description' => 'Archive a completed or abandoned plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'reason' => [ - 'type' => 'string', - 'description' => 'Reason for archiving', - ], - ], - 'required' => ['slug'], - ], - 'handler' => 'toolPlanArchive', - ]; - - // Phase tools - $this->tools['phase_get'] = [ - 'description' => 'Get details of a specific phase within a plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - ], - 'required' => ['plan_slug', 'phase'], - ], - 'handler' => 'toolPhaseGet', - ]; - - $this->tools['phase_update_status'] = [ - 'description' => 'Update the status of a phase', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - 'status' => [ - 'type' => 'string', - 'description' => 'New status', - 'enum' => ['pending', 'in_progress', 'completed', 'blocked', 'skipped'], - ], - 'notes' => [ - 'type' => 'string', - 'description' => 'Optional notes about the status change', - ], - ], - 'required' => ['plan_slug', 'phase', 'status'], - ], - 'handler' => 'toolPhaseUpdateStatus', - ]; - - $this->tools['phase_add_checkpoint'] = [ - 'description' => 'Add a checkpoint note to a phase', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - 'note' => [ - 'type' => 'string', - 'description' => 'Checkpoint note', - ], - 'context' => [ - 'type' => 'object', - 'description' => 'Additional context data', - ], - ], - 'required' => ['plan_slug', 'phase', 'note'], - ], - 'handler' => 'toolPhaseAddCheckpoint', - ]; - - // Task tools - $this->tools['task_toggle'] = [ - 'description' => 'Toggle a task completion status', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - 'task_index' => [ - 'type' => 'integer', - 'description' => 'Task index (0-based)', - ], - ], - 'required' => ['plan_slug', 'phase', 'task_index'], - ], - 'handler' => 'toolTaskToggle', - ]; - - $this->tools['task_update'] = [ - 'description' => 'Update task details (status, notes)', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - 'task_index' => [ - 'type' => 'integer', - 'description' => 'Task index (0-based)', - ], - 'status' => [ - 'type' => 'string', - 'description' => 'New status', - 'enum' => ['pending', 'in_progress', 'completed', 'blocked', 'skipped'], - ], - 'notes' => [ - 'type' => 'string', - 'description' => 'Task notes', - ], - ], - 'required' => ['plan_slug', 'phase', 'task_index'], - ], - 'handler' => 'toolTaskUpdate', - ]; - - // Session tools (for multi-agent handoff) - $this->tools['session_start'] = [ - 'description' => 'Start a new agent session for a plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'agent_type' => [ - 'type' => 'string', - 'description' => 'Type of agent (e.g., opus, sonnet, haiku)', - ], - 'context' => [ - 'type' => 'object', - 'description' => 'Initial session context', - ], - ], - 'required' => ['agent_type'], - ], - 'handler' => 'toolSessionStart', - ]; - - $this->tools['session_log'] = [ - 'description' => 'Log an entry in the current session', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'message' => [ - 'type' => 'string', - 'description' => 'Log message', - ], - 'type' => [ - 'type' => 'string', - 'description' => 'Log type', - 'enum' => ['info', 'progress', 'decision', 'error', 'checkpoint'], - ], - 'data' => [ - 'type' => 'object', - 'description' => 'Additional data to log', - ], - ], - 'required' => ['message'], - ], - 'handler' => 'toolSessionLog', - ]; - - $this->tools['session_artifact'] = [ - 'description' => 'Record an artifact created/modified during the session', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'path' => [ - 'type' => 'string', - 'description' => 'File or resource path', - ], - 'action' => [ - 'type' => 'string', - 'description' => 'Action performed', - 'enum' => ['created', 'modified', 'deleted', 'reviewed'], - ], - 'description' => [ - 'type' => 'string', - 'description' => 'Description of changes', - ], - ], - 'required' => ['path', 'action'], - ], - 'handler' => 'toolSessionArtifact', - ]; - - $this->tools['session_handoff'] = [ - 'description' => 'Prepare session for handoff to another agent', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'summary' => [ - 'type' => 'string', - 'description' => 'Summary of work done', - ], - 'next_steps' => [ - 'type' => 'array', - 'description' => 'Recommended next steps', - 'items' => ['type' => 'string'], - ], - 'blockers' => [ - 'type' => 'array', - 'description' => 'Any blockers encountered', - 'items' => ['type' => 'string'], - ], - 'context_for_next' => [ - 'type' => 'object', - 'description' => 'Context to pass to next agent', - ], - ], - 'required' => ['summary'], - ], - 'handler' => 'toolSessionHandoff', - ]; - - $this->tools['session_end'] = [ - 'description' => 'End the current session', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'status' => [ - 'type' => 'string', - 'description' => 'Final session status', - 'enum' => ['completed', 'handed_off', 'paused', 'failed'], - ], - 'summary' => [ - 'type' => 'string', - 'description' => 'Final summary', - ], - ], - 'required' => ['status'], - ], - 'handler' => 'toolSessionEnd', - ]; - - // State tools (persistent workspace state) - $this->tools['state_get'] = [ - 'description' => 'Get a workspace state value', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'key' => [ - 'type' => 'string', - 'description' => 'State key', - ], - ], - 'required' => ['plan_slug', 'key'], - ], - 'handler' => 'toolStateGet', - ]; - - $this->tools['state_set'] = [ - 'description' => 'Set a workspace state value', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'key' => [ - 'type' => 'string', - 'description' => 'State key', - ], - 'value' => [ - 'type' => ['string', 'number', 'boolean', 'object', 'array'], - 'description' => 'State value', - ], - 'category' => [ - 'type' => 'string', - 'description' => 'State category for organisation', - ], - ], - 'required' => ['plan_slug', 'key', 'value'], - ], - 'handler' => 'toolStateSet', - ]; - - $this->tools['state_list'] = [ - 'description' => 'List all state values for a plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'category' => [ - 'type' => 'string', - 'description' => 'Filter by category', - ], - ], - 'required' => ['plan_slug'], - ], - 'handler' => 'toolStateList', - ]; - - // Template tools - $this->tools['template_list'] = [ - 'description' => 'List available plan templates', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'category' => [ - 'type' => 'string', - 'description' => 'Filter by category', - ], - ], - ], - 'handler' => 'toolTemplateList', - ]; - - $this->tools['template_preview'] = [ - 'description' => 'Preview a template with variables', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'template' => [ - 'type' => 'string', - 'description' => 'Template name/slug', - ], - 'variables' => [ - 'type' => 'object', - 'description' => 'Variable values for the template', - ], - ], - 'required' => ['template'], - ], - 'handler' => 'toolTemplatePreview', - ]; - - $this->tools['template_create_plan'] = [ - 'description' => 'Create a new plan from a template', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'template' => [ - 'type' => 'string', - 'description' => 'Template name/slug', - ], - 'variables' => [ - 'type' => 'object', - 'description' => 'Variable values for the template', - ], - 'slug' => [ - 'type' => 'string', - 'description' => 'Custom slug for the plan', - ], - ], - 'required' => ['template', 'variables'], - ], - 'handler' => 'toolTemplateCreatePlan', - ]; - - // Content generation tools - $this->tools['content_status'] = [ - 'description' => 'Get content generation pipeline status (AI provider availability, brief counts)', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [], - ], - 'handler' => 'toolContentStatus', - ]; - - $this->tools['content_brief_create'] = [ - 'description' => 'Create a content brief for AI generation', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'title' => [ - 'type' => 'string', - 'description' => 'Content title', - ], - 'content_type' => [ - 'type' => 'string', - 'description' => 'Type of content', - 'enum' => ['help_article', 'blog_post', 'landing_page', 'social_post'], - ], - 'service' => [ - 'type' => 'string', - 'description' => 'Service context (e.g., BioHost, QRHost)', - ], - 'keywords' => [ - 'type' => 'array', - 'description' => 'SEO keywords to include', - 'items' => ['type' => 'string'], - ], - 'target_word_count' => [ - 'type' => 'integer', - 'description' => 'Target word count (default: 800)', - ], - 'description' => [ - 'type' => 'string', - 'description' => 'Brief description of what to write about', - ], - 'difficulty' => [ - 'type' => 'string', - 'description' => 'Target audience level', - 'enum' => ['beginner', 'intermediate', 'advanced'], - ], - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Link to an existing plan', - ], - ], - 'required' => ['title', 'content_type'], - ], - 'handler' => 'toolContentBriefCreate', - ]; - - $this->tools['content_brief_list'] = [ - 'description' => 'List content briefs with optional status filter', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'status' => [ - 'type' => 'string', - 'description' => 'Filter by status', - 'enum' => ['pending', 'queued', 'generating', 'review', 'published', 'failed'], - ], - 'limit' => [ - 'type' => 'integer', - 'description' => 'Maximum results (default: 20)', - ], - ], - ], - 'handler' => 'toolContentBriefList', - ]; - - $this->tools['content_brief_get'] = [ - 'description' => 'Get details of a specific content brief including generated content', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'integer', - 'description' => 'Brief ID', - ], - ], - 'required' => ['id'], - ], - 'handler' => 'toolContentBriefGet', - ]; - - $this->tools['content_generate'] = [ - 'description' => 'Generate content for a brief using AI pipeline (Gemini draft → Claude refine)', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'brief_id' => [ - 'type' => 'integer', - 'description' => 'Brief ID to generate content for', - ], - 'mode' => [ - 'type' => 'string', - 'description' => 'Generation mode', - 'enum' => ['draft', 'refine', 'full'], - ], - 'sync' => [ - 'type' => 'boolean', - 'description' => 'Run synchronously (wait for result) vs queue for async processing', - ], - ], - 'required' => ['brief_id'], - ], - 'handler' => 'toolContentGenerate', - ]; - - $this->tools['content_batch_generate'] = [ - 'description' => 'Queue multiple briefs for batch content generation', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'limit' => [ - 'type' => 'integer', - 'description' => 'Maximum briefs to process (default: 5)', - ], - 'mode' => [ - 'type' => 'string', - 'description' => 'Generation mode', - 'enum' => ['draft', 'refine', 'full'], - ], - ], - ], - 'handler' => 'toolContentBatchGenerate', - ]; - - $this->tools['content_from_plan'] = [ - 'description' => 'Create content briefs from plan tasks and queue for generation', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug to generate content from', - ], - 'content_type' => [ - 'type' => 'string', - 'description' => 'Type of content to generate', - 'enum' => ['help_article', 'blog_post', 'landing_page', 'social_post'], - ], - 'service' => [ - 'type' => 'string', - 'description' => 'Service context', - ], - 'limit' => [ - 'type' => 'integer', - 'description' => 'Maximum briefs to create (default: 5)', - ], - 'target_word_count' => [ - 'type' => 'integer', - 'description' => 'Target word count per article', - ], - ], - 'required' => ['plan_slug'], - ], - 'handler' => 'toolContentFromPlan', - ]; - - $this->tools['content_usage_stats'] = [ - 'description' => 'Get AI usage statistics (tokens, costs) for content generation', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'period' => [ - 'type' => 'string', - 'description' => 'Time period for stats', - 'enum' => ['day', 'week', 'month', 'year'], - ], - ], - ], - 'handler' => 'toolContentUsageStats', - ]; - } - - protected function registerResources(): void - { - $this->resources['plans://all'] = [ - 'name' => 'All Plans Overview', - 'description' => 'Overview of all work plans and their status', - 'mimeType' => 'text/markdown', - 'handler' => 'resourceAllPlans', - ]; - - // Dynamic plan resources are handled in getResourcesList - } - - protected function handleRequest(array $request): ?array - { - $method = $request['method'] ?? ''; - $id = $request['id'] ?? null; - $params = $request['params'] ?? []; - - return match ($method) { - 'initialize' => $this->handleInitialize($id, $params), - 'tools/list' => $this->handleToolsList($id), - 'tools/call' => $this->handleToolsCall($id, $params), - 'resources/list' => $this->handleResourcesList($id), - 'resources/read' => $this->handleResourcesRead($id, $params), - 'notifications/initialized' => null, - default => $this->errorResponse($id, -32601, "Method not found: {$method}"), - }; - } - - protected function handleInitialize(mixed $id, array $params): array - { - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'protocolVersion' => '2024-11-05', - 'capabilities' => [ - 'tools' => ['listChanged' => true], - 'resources' => ['subscribe' => false, 'listChanged' => true], - ], - 'serverInfo' => [ - 'name' => 'hosthub-agent', - 'version' => '1.0.0', - ], - ], - ]; - } - - protected function handleToolsList(mixed $id): array - { - $tools = []; - - foreach ($this->tools as $name => $tool) { - $tools[] = [ - 'name' => $name, - 'description' => $tool['description'], - 'inputSchema' => $tool['inputSchema'], - ]; - } - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => ['tools' => $tools], - ]; - } - - protected function handleToolsCall(mixed $id, array $params): array - { - $toolName = $params['name'] ?? ''; - $args = $params['arguments'] ?? []; - $startTime = microtime(true); - - if (! isset($this->tools[$toolName])) { - return $this->errorResponse($id, -32602, "Unknown tool: {$toolName}"); - } - - try { - $handler = $this->tools[$toolName]['handler']; - $result = $this->$handler($args); - - // Log tool call - $this->logToolCall($toolName, $args, $result, $startTime, true); - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'content' => [ - [ - 'type' => 'text', - 'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), - ], - ], - ], - ]; - } catch (Throwable $e) { - $this->logToolCall($toolName, $args, ['error' => $e->getMessage()], $startTime, false); - - Log::error('MCP tool error', [ - 'tool' => $toolName, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - return $this->errorResponse($id, -32603, $e->getMessage()); - } - } - - protected function handleResourcesList(mixed $id): array - { - $resources = []; - - // Static resources - foreach ($this->resources as $uri => $resource) { - $resources[] = [ - 'uri' => $uri, - 'name' => $resource['name'], - 'description' => $resource['description'], - 'mimeType' => $resource['mimeType'], - ]; - } - - // Dynamic plan resources - $plans = AgentPlan::notArchived()->get(); - foreach ($plans as $plan) { - $resources[] = [ - 'uri' => "plans://{$plan->slug}", - 'name' => $plan->title, - 'description' => "Work plan: {$plan->title}", - 'mimeType' => 'text/markdown', - ]; - } - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => ['resources' => $resources], - ]; - } - - protected function handleResourcesRead(mixed $id, array $params): array - { - $uri = $params['uri'] ?? ''; - - // Handle static resources - if (isset($this->resources[$uri])) { - $handler = $this->resources[$uri]['handler']; - $content = $this->$handler(); - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'contents' => [ - [ - 'uri' => $uri, - 'mimeType' => $this->resources[$uri]['mimeType'], - 'text' => $content, - ], - ], - ], - ]; - } - - // Handle dynamic plan resources - if (str_starts_with($uri, 'plans://')) { - $path = substr($uri, 9); // Remove 'plans://' - $parts = explode('/', $path); - $slug = $parts[0]; - - // plans://{slug}/phases/{order} - if (count($parts) === 3 && $parts[1] === 'phases') { - $content = $this->resourcePhaseChecklist($slug, (int) $parts[2]); - } - // plans://{slug}/state/{key} - elseif (count($parts) === 3 && $parts[1] === 'state') { - $content = $this->resourceStateValue($slug, $parts[2]); - } - // plans://{slug} - else { - $content = $this->resourcePlanDocument($slug); - } - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'contents' => [ - [ - 'uri' => $uri, - 'mimeType' => 'text/markdown', - 'text' => $content, - ], - ], - ], - ]; - } - - // Handle session resources - if (str_starts_with($uri, 'sessions://')) { - $path = substr($uri, 11); - $parts = explode('/', $path); - - if (count($parts) === 2 && $parts[1] === 'context') { - $content = $this->resourceSessionContext($parts[0]); - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'contents' => [ - [ - 'uri' => $uri, - 'mimeType' => 'text/markdown', - 'text' => $content, - ], - ], - ], - ]; - } - } - - return $this->errorResponse($id, -32602, "Resource not found: {$uri}"); - } - - protected function sendResponse(array $response): void - { - echo json_encode($response, JSON_UNESCAPED_SLASHES)."\n"; - flush(); - } - - protected function logToolCall(string $tool, array $args, array $result, float $startTime, bool $success): void - { - $duration = (int) ((microtime(true) - $startTime) * 1000); - - // Use the log() method which updates daily stats automatically - McpToolCall::log( - serverId: 'hosthub-agent', - toolName: $tool, - params: $args, - success: $success, - durationMs: $duration, - errorMessage: $success ? null : ($result['error'] ?? null), - errorCode: $success ? null : ($result['code'] ?? null), - resultSummary: $success ? $result : null, - sessionId: $this->currentSessionId, - ); - } - - // ===== TOOL IMPLEMENTATIONS ===== - - protected function toolPlanList(array $args): array - { - $query = AgentPlan::with('agentPhases') - ->orderBy('updated_at', 'desc'); - - if (! ($args['include_archived'] ?? false)) { - $query->notArchived(); - } - - if (! empty($args['status'])) { - $query->where('status', $args['status']); - } - - $plans = $query->get(); - - return [ - 'plans' => $plans->map(fn ($plan) => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'status' => $plan->status, - 'progress' => $plan->getProgress(), - 'updated_at' => $plan->updated_at->toIso8601String(), - ])->all(), - 'total' => $plans->count(), - ]; - } - - protected function toolPlanCreate(array $args): array - { - $slug = $args['slug'] ?? Str::slug($args['title']).'-'.Str::random(6); - - if (AgentPlan::where('slug', $slug)->exists()) { - return ['error' => "Plan with slug '{$slug}' already exists"]; - } - - $plan = AgentPlan::create([ - 'slug' => $slug, - 'title' => $args['title'], - 'description' => $args['description'] ?? null, - 'status' => 'draft', - 'context' => $args['context'] ?? [], - ]); - - // Create phases if provided - if (! empty($args['phases'])) { - foreach ($args['phases'] as $order => $phaseData) { - $tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [ - 'name' => $task, - 'status' => 'pending', - ])->all(); - - AgentPhase::create([ - 'agent_plan_id' => $plan->id, - 'name' => $phaseData['name'], - 'description' => $phaseData['description'] ?? null, - 'order' => $order + 1, - 'status' => 'pending', - 'tasks' => $tasks, - ]); - } - } - - $plan->load('agentPhases'); - - return [ - 'success' => true, - 'plan' => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'status' => $plan->status, - 'phases' => $plan->agentPhases->count(), - ], - ]; - } - - protected function toolPlanGet(array $args): array - { - $plan = AgentPlan::with('agentPhases') - ->where('slug', $args['slug']) - ->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['slug']}"]; - } - - $format = $args['format'] ?? 'json'; - - if ($format === 'markdown') { - return ['markdown' => $plan->toMarkdown()]; - } - - return [ - 'plan' => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'description' => $plan->description, - 'status' => $plan->status, - 'context' => $plan->context, - 'progress' => $plan->getProgress(), - 'phases' => $plan->agentPhases->map(fn ($phase) => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'description' => $phase->description, - 'status' => $phase->status, - 'tasks' => $phase->tasks, - 'checkpoints' => $phase->checkpoints, - ])->all(), - 'created_at' => $plan->created_at->toIso8601String(), - 'updated_at' => $plan->updated_at->toIso8601String(), - ], - ]; - } - - protected function toolPlanUpdateStatus(array $args): array - { - $plan = AgentPlan::where('slug', $args['slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['slug']}"]; - } - - $plan->update(['status' => $args['status']]); - - return [ - 'success' => true, - 'plan' => [ - 'slug' => $plan->slug, - 'status' => $plan->fresh()->status, - ], - ]; - } - - protected function toolPlanArchive(array $args): array - { - $plan = AgentPlan::where('slug', $args['slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['slug']}"]; - } - - $plan->archive($args['reason'] ?? null); - - return [ - 'success' => true, - 'plan' => [ - 'slug' => $plan->slug, - 'status' => 'archived', - 'archived_at' => $plan->archived_at->toIso8601String(), - ], - ]; - } - - protected function toolPhaseGet(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - return [ - 'phase' => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'description' => $phase->description, - 'status' => $phase->status, - 'tasks' => $phase->tasks, - 'checkpoints' => $phase->checkpoints, - 'dependencies' => $phase->dependencies, - ], - ]; - } - - protected function toolPhaseUpdateStatus(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - $updateData = ['status' => $args['status']]; - - if (! empty($args['notes'])) { - $phase->addCheckpoint($args['notes'], ['status_change' => $args['status']]); - } - - $phase->update($updateData); - - return [ - 'success' => true, - 'phase' => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'status' => $phase->fresh()->status, - ], - ]; - } - - protected function toolPhaseAddCheckpoint(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - $phase->addCheckpoint($args['note'], $args['context'] ?? []); - - return [ - 'success' => true, - 'checkpoints' => $phase->fresh()->checkpoints, - ]; - } - - protected function toolTaskToggle(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - $tasks = $phase->tasks ?? []; - $index = $args['task_index']; - - if (! isset($tasks[$index])) { - return ['error' => "Task not found at index: {$index}"]; - } - - $currentStatus = is_string($tasks[$index]) - ? 'pending' - : ($tasks[$index]['status'] ?? 'pending'); - - $newStatus = $currentStatus === 'completed' ? 'pending' : 'completed'; - - if (is_string($tasks[$index])) { - $tasks[$index] = [ - 'name' => $tasks[$index], - 'status' => $newStatus, - ]; - } else { - $tasks[$index]['status'] = $newStatus; - } - - $phase->update(['tasks' => $tasks]); - - return [ - 'success' => true, - 'task' => $tasks[$index], - 'plan_progress' => $plan->fresh()->getProgress(), - ]; - } - - protected function toolTaskUpdate(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - $tasks = $phase->tasks ?? []; - $index = $args['task_index']; - - if (! isset($tasks[$index])) { - return ['error' => "Task not found at index: {$index}"]; - } - - if (is_string($tasks[$index])) { - $tasks[$index] = ['name' => $tasks[$index], 'status' => 'pending']; - } - - if (isset($args['status'])) { - $tasks[$index]['status'] = $args['status']; - } - - if (isset($args['notes'])) { - $tasks[$index]['notes'] = $args['notes']; - } - - $phase->update(['tasks' => $tasks]); - - return [ - 'success' => true, - 'task' => $tasks[$index], - ]; - } - - protected function toolSessionStart(array $args): array - { - $plan = null; - if (! empty($args['plan_slug'])) { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - } - - $sessionId = 'ses_'.Str::random(12); - $this->currentSessionId = $sessionId; - - $session = AgentSession::create([ - 'session_id' => $sessionId, - 'agent_plan_id' => $plan?->id, - 'agent_type' => $args['agent_type'], - 'status' => 'active', - 'started_at' => now(), - 'context_summary' => $args['context'] ?? [], - ]); - - return [ - 'success' => true, - 'session' => [ - 'session_id' => $session->session_id, - 'agent_type' => $session->agent_type, - 'plan' => $plan?->slug, - 'status' => $session->status, - ], - ]; - } - - protected function toolSessionLog(array $args): array - { - if (! $this->currentSessionId) { - return ['error' => 'No active session. Call session_start first.']; - } - - $session = AgentSession::where('session_id', $this->currentSessionId)->first(); - - if (! $session) { - return ['error' => 'Session not found']; - } - - $session->addWorkLogEntry( - $args['message'], - $args['type'] ?? 'info', - $args['data'] ?? [] - ); - - return ['success' => true, 'logged' => $args['message']]; - } - - protected function toolSessionArtifact(array $args): array - { - if (! $this->currentSessionId) { - return ['error' => 'No active session. Call session_start first.']; - } - - $session = AgentSession::where('session_id', $this->currentSessionId)->first(); - - if (! $session) { - return ['error' => 'Session not found']; - } - - $session->addArtifact( - $args['path'], - $args['action'], - $args['description'] ?? null - ); - - return ['success' => true, 'artifact' => $args['path']]; - } - - protected function toolSessionHandoff(array $args): array - { - if (! $this->currentSessionId) { - return ['error' => 'No active session. Call session_start first.']; - } - - $session = AgentSession::where('session_id', $this->currentSessionId)->first(); - - if (! $session) { - return ['error' => 'Session not found']; - } - - $session->prepareHandoff( - $args['summary'], - $args['next_steps'] ?? [], - $args['blockers'] ?? [], - $args['context_for_next'] ?? [] - ); - - return [ - 'success' => true, - 'handoff_context' => $session->getHandoffContext(), - ]; - } - - protected function toolSessionEnd(array $args): array - { - if (! $this->currentSessionId) { - return ['error' => 'No active session']; - } - - $session = AgentSession::where('session_id', $this->currentSessionId)->first(); - - if (! $session) { - return ['error' => 'Session not found']; - } - - $session->end($args['status'], $args['summary'] ?? null); - $this->currentSessionId = null; - - return [ - 'success' => true, - 'session' => [ - 'session_id' => $session->session_id, - 'status' => $session->status, - 'duration' => $session->getDurationFormatted(), - ], - ]; - } - - protected function toolStateGet(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $state = $plan->states()->where('key', $args['key'])->first(); - - if (! $state) { - return ['error' => "State not found: {$args['key']}"]; - } - - return [ - 'key' => $state->key, - 'value' => $state->value, - 'category' => $state->category, - 'updated_at' => $state->updated_at->toIso8601String(), - ]; - } - - protected function toolStateSet(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $state = AgentWorkspaceState::updateOrCreate( - [ - 'agent_plan_id' => $plan->id, - 'key' => $args['key'], - ], - [ - 'value' => $args['value'], - 'category' => $args['category'] ?? 'general', - ] - ); - - return [ - 'success' => true, - 'state' => [ - 'key' => $state->key, - 'value' => $state->value, - 'category' => $state->category, - ], - ]; - } - - protected function toolStateList(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $query = $plan->states(); - - if (! empty($args['category'])) { - $query->where('category', $args['category']); - } - - $states = $query->get(); - - return [ - 'states' => $states->map(fn ($state) => [ - 'key' => $state->key, - 'value' => $state->value, - 'category' => $state->category, - ])->all(), - 'total' => $states->count(), - ]; - } - - protected function toolTemplateList(array $args): array - { - $templateService = app(PlanTemplateService::class); - $templates = $templateService->listTemplates(); - - if (! empty($args['category'])) { - $templates = array_filter($templates, fn ($t) => ($t['category'] ?? '') === $args['category']); - } - - return [ - 'templates' => array_values($templates), - 'total' => count($templates), - ]; - } - - protected function toolTemplatePreview(array $args): array - { - $templateService = app(PlanTemplateService::class); - $templateSlug = $args['template']; - $variables = $args['variables'] ?? []; - - $preview = $templateService->previewTemplate($templateSlug, $variables); - - if (! $preview) { - return ['error' => "Template not found: {$templateSlug}"]; - } - - return [ - 'template' => $templateSlug, - 'preview' => $preview, - ]; - } - - protected function toolTemplateCreatePlan(array $args): array - { - $templateService = app(PlanTemplateService::class); - $templateSlug = $args['template']; - $variables = $args['variables'] ?? []; - - $options = []; - - if (! empty($args['slug'])) { - $options['slug'] = $args['slug']; - } - - $plan = $templateService->createPlan($templateSlug, $variables, $options); - - if (! $plan) { - return ['error' => 'Failed to create plan from template']; - } - - return [ - 'success' => true, - 'plan' => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'status' => $plan->status, - 'phases' => $plan->agentPhases->count(), - 'total_tasks' => $plan->getProgress()['total'], - ], - 'commands' => [ - 'view' => "php artisan plan:show {$plan->slug}", - 'activate' => "php artisan plan:status {$plan->slug} --set=active", - ], - ]; - } - - // ===== CONTENT GENERATION TOOL IMPLEMENTATIONS ===== - - protected function toolContentStatus(array $args): array - { - $gateway = app(AIGatewayService::class); - - return [ - 'providers' => [ - 'gemini' => $gateway->isGeminiAvailable(), - 'claude' => $gateway->isClaudeAvailable(), - ], - 'pipeline_available' => $gateway->isAvailable(), - 'briefs' => [ - 'pending' => ContentBrief::pending()->count(), - 'queued' => ContentBrief::where('status', ContentBrief::STATUS_QUEUED)->count(), - 'generating' => ContentBrief::where('status', ContentBrief::STATUS_GENERATING)->count(), - 'review' => ContentBrief::needsReview()->count(), - 'published' => ContentBrief::where('status', ContentBrief::STATUS_PUBLISHED)->count(), - 'failed' => ContentBrief::where('status', ContentBrief::STATUS_FAILED)->count(), - ], - ]; - } - - protected function toolContentBriefCreate(array $args): array - { - $plan = null; - if (! empty($args['plan_slug'])) { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - } - - $brief = ContentBrief::create([ - 'title' => $args['title'], - 'slug' => Str::slug($args['title']).'-'.Str::random(6), - 'content_type' => $args['content_type'], - 'service' => $args['service'] ?? null, - 'description' => $args['description'] ?? null, - 'keywords' => $args['keywords'] ?? null, - 'target_word_count' => $args['target_word_count'] ?? 800, - 'difficulty' => $args['difficulty'] ?? null, - 'status' => ContentBrief::STATUS_PENDING, - 'metadata' => $plan ? [ - 'plan_id' => $plan->id, - 'plan_slug' => $plan->slug, - ] : null, - ]); - - return [ - 'success' => true, - 'brief' => [ - 'id' => $brief->id, - 'title' => $brief->title, - 'slug' => $brief->slug, - 'status' => $brief->status, - 'content_type' => $brief->content_type, - ], - ]; - } - - protected function toolContentBriefList(array $args): array - { - $query = ContentBrief::query()->orderBy('created_at', 'desc'); - - if (! empty($args['status'])) { - $query->where('status', $args['status']); - } - - $limit = $args['limit'] ?? 20; - $briefs = $query->limit($limit)->get(); - - return [ - 'briefs' => $briefs->map(fn ($brief) => [ - 'id' => $brief->id, - 'title' => $brief->title, - 'status' => $brief->status, - 'content_type' => $brief->content_type, - 'service' => $brief->service, - 'created_at' => $brief->created_at->toIso8601String(), - ])->all(), - 'total' => $briefs->count(), - ]; - } - - protected function toolContentBriefGet(array $args): array - { - $brief = ContentBrief::find($args['id']); - - if (! $brief) { - return ['error' => "Brief not found: {$args['id']}"]; - } - - return [ - 'brief' => [ - 'id' => $brief->id, - 'title' => $brief->title, - 'slug' => $brief->slug, - 'status' => $brief->status, - 'content_type' => $brief->content_type, - 'service' => $brief->service, - 'description' => $brief->description, - 'keywords' => $brief->keywords, - 'target_word_count' => $brief->target_word_count, - 'difficulty' => $brief->difficulty, - 'draft_output' => $brief->draft_output, - 'refined_output' => $brief->refined_output, - 'final_content' => $brief->final_content, - 'best_content' => $brief->best_content, - 'error_message' => $brief->error_message, - 'generation_log' => $brief->generation_log, - 'total_cost' => $brief->total_cost, - 'created_at' => $brief->created_at->toIso8601String(), - 'generated_at' => $brief->generated_at?->toIso8601String(), - 'refined_at' => $brief->refined_at?->toIso8601String(), - ], - ]; - } - - protected function toolContentGenerate(array $args): array - { - $brief = ContentBrief::find($args['brief_id']); - - if (! $brief) { - return ['error' => "Brief not found: {$args['brief_id']}"]; - } - - $gateway = app(AIGatewayService::class); - - if (! $gateway->isAvailable()) { - return ['error' => 'AI providers not configured. Set GOOGLE_AI_API_KEY and ANTHROPIC_API_KEY.']; - } - - $mode = $args['mode'] ?? 'full'; - $sync = $args['sync'] ?? false; - - if ($sync) { - try { - if ($mode === 'full') { - $result = $gateway->generateAndRefine($brief); - - return [ - 'success' => true, - 'brief_id' => $brief->id, - 'status' => $brief->fresh()->status, - 'draft' => [ - 'model' => $result['draft']->model, - 'tokens' => $result['draft']->totalTokens(), - 'cost' => $result['draft']->estimateCost(), - ], - 'refined' => [ - 'model' => $result['refined']->model, - 'tokens' => $result['refined']->totalTokens(), - 'cost' => $result['refined']->estimateCost(), - ], - ]; - } elseif ($mode === 'draft') { - $response = $gateway->generateDraft($brief); - $brief->markDraftComplete($response->content); - - return [ - 'success' => true, - 'brief_id' => $brief->id, - 'status' => $brief->fresh()->status, - 'draft' => [ - 'model' => $response->model, - 'tokens' => $response->totalTokens(), - 'cost' => $response->estimateCost(), - ], - ]; - } elseif ($mode === 'refine') { - if (! $brief->isGenerated()) { - return ['error' => 'No draft to refine. Generate draft first.']; - } - $response = $gateway->refineDraft($brief, $brief->draft_output); - $brief->markRefined($response->content); - - return [ - 'success' => true, - 'brief_id' => $brief->id, - 'status' => $brief->fresh()->status, - 'refined' => [ - 'model' => $response->model, - 'tokens' => $response->totalTokens(), - 'cost' => $response->estimateCost(), - ], - ]; - } - } catch (\Exception $e) { - $brief->markFailed($e->getMessage()); - - return ['error' => $e->getMessage()]; - } - } - - // Async - queue for processing - $brief->markQueued(); - GenerateContentJob::dispatch($brief, $mode); - - return [ - 'success' => true, - 'queued' => true, - 'brief_id' => $brief->id, - 'mode' => $mode, - 'message' => 'Brief queued for generation', - ]; - } - - protected function toolContentBatchGenerate(array $args): array - { - $limit = $args['limit'] ?? 5; - $mode = $args['mode'] ?? 'full'; - - $briefs = ContentBrief::readyToProcess()->limit($limit)->get(); - - if ($briefs->isEmpty()) { - return ['message' => 'No briefs ready for processing', 'queued' => 0]; - } - - foreach ($briefs as $brief) { - GenerateContentJob::dispatch($brief, $mode); - } - - return [ - 'success' => true, - 'queued' => $briefs->count(), - 'mode' => $mode, - 'brief_ids' => $briefs->pluck('id')->all(), - ]; - } - - protected function toolContentFromPlan(array $args): array - { - $plan = AgentPlan::with('agentPhases') - ->where('slug', $args['plan_slug']) - ->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $limit = $args['limit'] ?? 5; - $contentType = $args['content_type'] ?? 'help_article'; - $service = $args['service'] ?? ($plan->metadata['service'] ?? null); - $wordCount = $args['target_word_count'] ?? 800; - - $phases = $plan->agentPhases() - ->whereIn('status', ['pending', 'in_progress']) - ->get(); - - if ($phases->isEmpty()) { - return ['message' => 'No pending phases in plan', 'created' => 0]; - } - - $briefsCreated = []; - - foreach ($phases as $phase) { - $tasks = $phase->getTasks(); - - foreach ($tasks as $index => $task) { - if (count($briefsCreated) >= $limit) { - break 2; - } - - $taskName = is_string($task) ? $task : ($task['name'] ?? ''); - $taskStatus = is_array($task) ? ($task['status'] ?? 'pending') : 'pending'; - - if ($taskStatus === 'completed') { - continue; - } - - $brief = ContentBrief::create([ - 'title' => $taskName, - 'slug' => Str::slug($taskName).'-'.time(), - 'content_type' => $contentType, - 'service' => $service, - 'target_word_count' => $wordCount, - 'status' => ContentBrief::STATUS_QUEUED, - 'metadata' => [ - 'plan_id' => $plan->id, - 'plan_slug' => $plan->slug, - 'phase_id' => $phase->id, - 'phase_order' => $phase->order, - 'task_index' => $index, - ], - ]); - - GenerateContentJob::dispatch($brief, 'full'); - $briefsCreated[] = [ - 'id' => $brief->id, - 'title' => $brief->title, - ]; - } - } - - return [ - 'success' => true, - 'plan' => $plan->slug, - 'created' => count($briefsCreated), - 'briefs' => $briefsCreated, - ]; - } - - protected function toolContentUsageStats(array $args): array - { - $period = $args['period'] ?? 'month'; - $stats = AIUsage::statsForWorkspace(null, $period); - - return [ - 'period' => $period, - 'total_requests' => $stats['total_requests'], - 'total_input_tokens' => $stats['total_input_tokens'], - 'total_output_tokens' => $stats['total_output_tokens'], - 'total_cost' => number_format($stats['total_cost'], 4), - 'by_provider' => $stats['by_provider'], - 'by_purpose' => $stats['by_purpose'], - ]; - } - - // ===== RESOURCE IMPLEMENTATIONS ===== - - protected function resourceAllPlans(): string - { - $plans = AgentPlan::with('agentPhases')->notArchived()->orderBy('updated_at', 'desc')->get(); - - $md = "# Work Plans\n\n"; - $md .= '**Total:** '.$plans->count()." plan(s)\n\n"; - - foreach ($plans->groupBy('status') as $status => $group) { - $md .= '## '.ucfirst($status).' ('.$group->count().")\n\n"; - - foreach ($group as $plan) { - $progress = $plan->getProgress(); - $md .= "- **[{$plan->slug}]** {$plan->title} - {$progress['percentage']}%\n"; - } - $md .= "\n"; - } - - return $md; - } - - protected function resourcePlanDocument(string $slug): string - { - $plan = AgentPlan::with('agentPhases')->where('slug', $slug)->first(); - - if (! $plan) { - return "Plan not found: {$slug}"; - } - - return $plan->toMarkdown(); - } - - protected function resourcePhaseChecklist(string $slug, int $phaseOrder): string - { - $plan = AgentPlan::where('slug', $slug)->first(); - - if (! $plan) { - return "Plan not found: {$slug}"; - } - - $phase = $plan->agentPhases()->where('order', $phaseOrder)->first(); - - if (! $phase) { - return "Phase not found: {$phaseOrder}"; - } - - $md = "# Phase {$phase->order}: {$phase->name}\n\n"; - $md .= "**Status:** {$phase->getStatusIcon()} {$phase->status}\n\n"; - - if ($phase->description) { - $md .= "{$phase->description}\n\n"; - } - - $md .= "## Tasks\n\n"; - - foreach ($phase->tasks ?? [] as $task) { - $status = is_string($task) ? 'pending' : ($task['status'] ?? 'pending'); - $name = is_string($task) ? $task : ($task['name'] ?? 'Unknown'); - $icon = $status === 'completed' ? '✅' : '⬜'; - $md .= "- {$icon} {$name}\n"; - } - - return $md; - } - - protected function resourceStateValue(string $slug, string $key): string - { - $plan = AgentPlan::where('slug', $slug)->first(); - - if (! $plan) { - return "Plan not found: {$slug}"; - } - - $state = $plan->states()->where('key', $key)->first(); - - if (! $state) { - return "State key not found: {$key}"; - } - - return $state->getFormattedValue(); - } - - protected function resourceSessionContext(string $sessionId): string - { - $session = AgentSession::where('session_id', $sessionId)->first(); - - if (! $session) { - return "Session not found: {$sessionId}"; - } - - $context = $session->getHandoffContext(); - - $md = "# Session: {$session->session_id}\n\n"; - $md .= "**Agent:** {$session->agent_type}\n"; - $md .= "**Status:** {$session->status}\n"; - $md .= "**Duration:** {$session->getDurationFormatted()}\n\n"; - - if ($session->plan) { - $md .= "## Plan\n\n"; - $md .= "**{$session->plan->title}** ({$session->plan->slug})\n\n"; - } - - if (! empty($context['context_summary'])) { - $md .= "## Context Summary\n\n"; - $md .= json_encode($context['context_summary'], JSON_PRETTY_PRINT)."\n\n"; - } - - if (! empty($context['handoff_notes'])) { - $md .= "## Handoff Notes\n\n"; - $md .= json_encode($context['handoff_notes'], JSON_PRETTY_PRINT)."\n\n"; - } - - if (! empty($context['artifacts'])) { - $md .= "## Artifacts\n\n"; - foreach ($context['artifacts'] as $artifact) { - $md .= "- {$artifact['action']}: {$artifact['path']}\n"; - } - $md .= "\n"; - } - - return $md; - } - - // ===== HELPERS ===== - - protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase - { - if (is_numeric($identifier)) { - return $plan->agentPhases()->where('order', (int) $identifier)->first(); - } - - return $plan->agentPhases() - ->where(function ($query) use ($identifier) { - $query->where('name', $identifier) - ->orWhere('order', $identifier); - }) - ->first(); - } - - protected function errorResponse(mixed $id, int $code, string $message): array - { - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'error' => [ - 'code' => $code, - 'message' => $message, - ], - ]; - } -} diff --git a/php/src/Mcp/Controllers/McpApiController.php b/php/src/Mcp/Controllers/McpApiController.php index e05fed9..38ba2e8 100644 --- a/php/src/Mcp/Controllers/McpApiController.php +++ b/php/src/Mcp/Controllers/McpApiController.php @@ -4,19 +4,19 @@ namespace Core\Mcp\Controllers; +use Core\Api\Models\ApiKey; use Core\Front\Controller; +use Core\Mcp\Models\McpApiRequest; +use Core\Mcp\Models\McpToolCall; +use Core\Mcp\Resources\Contracts\AgentResourceProvider; use Core\Mcp\Services\McpQuotaService; -use Core\Mod\Agentic\Models\AgentPlan; -use Core\Mod\Agentic\Models\AgentSession; +use Core\Mcp\Services\McpWebhookDispatcher; +use Core\Mod\Agentic\Services\AgentToolRegistry; use Core\Mod\Content\Models\ContentItem; +use Core\Tenant\Models\Workspace; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; -use Core\Api\Models\ApiKey; -use Core\Mcp\Models\McpApiRequest; -use Core\Mcp\Models\McpToolCall; -use Core\Mcp\Services\McpWebhookDispatcher; -use Core\Tenant\Models\Workspace; use Symfony\Component\Yaml\Yaml; /** @@ -228,17 +228,22 @@ public function resource(Request $request, string $uri): JsonResponse */ protected function readResourceContent(string $scheme, string $uri): ?array { - if (str_starts_with($uri, 'plans://')) { - return [ - 'mimeType' => 'text/markdown', - 'text' => $this->resourcePlanContent($uri), - ]; - } + // plans:// and sessions:// are agent-domain. This package used to render + // them itself, importing Core\Mod\Agentic\Models\* — a library + // importing its own consumer. Whoever owns the data now supplies it + // through AgentResourceProvider. + if (str_starts_with($uri, 'plans://') || str_starts_with($uri, 'sessions://')) { + $provider = $this->agentResourceProvider(); + + if (! $provider) { + return null; + } - if (str_starts_with($uri, 'sessions://')) { - return [ - 'mimeType' => 'text/markdown', - 'text' => $this->resourceSessionContent($uri), + $contents = $provider->read($uri); + + return $contents === null ? null : [ + 'mimeType' => $contents['mimeType'], + 'text' => $contents['text'], ]; } @@ -252,125 +257,6 @@ protected function readResourceContent(string $scheme, string $uri): ?array return $this->resourceServerContent($scheme, $uri); } - /** - * Render plan resources. - */ - protected function resourcePlanContent(string $uri): string - { - if ($uri === 'plans://all') { - $plans = AgentPlan::with('agentPhases')->notArchived()->orderBy('updated_at', 'desc')->get(); - - $md = "# Work Plans\n\n"; - $md .= '**Total:** '.$plans->count()." plan(s)\n\n"; - - foreach ($plans->groupBy('status') as $status => $group) { - $md .= '## '.ucfirst($status).' ('.$group->count().")\n\n"; - - foreach ($group as $plan) { - $progress = $plan->getProgress(); - $md .= "- **[{$plan->slug}]** {$plan->title} - {$progress['percentage']}%\n"; - } - $md .= "\n"; - } - - return $md; - } - - $path = substr($uri, 9); // Remove "plans://" - $parts = explode('/', $path); - $slug = $parts[0]; - - $plan = AgentPlan::with('agentPhases')->where('slug', $slug)->first(); - if (! $plan) { - return "Plan not found: {$slug}"; - } - - if (count($parts) === 3 && $parts[1] === 'phases') { - $phase = $plan->agentPhases()->where('order', (int) $parts[2])->first(); - if (! $phase) { - return "Phase not found: {$parts[2]}"; - } - - $md = "# Phase {$phase->order}: {$phase->name}\n\n"; - $md .= "**Status:** {$phase->getStatusIcon()} {$phase->status}\n\n"; - - if ($phase->description) { - $md .= "{$phase->description}\n\n"; - } - - $md .= "## Tasks\n\n"; - - foreach ($phase->tasks ?? [] as $task) { - $status = is_string($task) ? 'pending' : ($task['status'] ?? 'pending'); - $name = is_string($task) ? $task : ($task['name'] ?? 'Unknown'); - $icon = $status === 'completed' ? '✅' : '⬜'; - $md .= "- {$icon} {$name}\n"; - } - - return $md; - } - - if (count($parts) === 3 && $parts[1] === 'state') { - $state = $plan->states()->where('key', $parts[2])->first(); - if (! $state) { - return "State key not found: {$parts[2]}"; - } - - return $state->getFormattedValue(); - } - - return $plan->toMarkdown(); - } - - /** - * Render session resources. - */ - protected function resourceSessionContent(string $uri): string - { - $path = substr($uri, 11); // Remove "sessions://" - $parts = explode('/', $path); - - if (count($parts) !== 2 || $parts[1] !== 'context') { - return "Resource not found: {$uri}"; - } - - $session = AgentSession::where('session_id', $parts[0])->first(); - if (! $session) { - return "Session not found: {$parts[0]}"; - } - - $md = "# Session: {$session->session_id}\n\n"; - $md .= "**Agent:** {$session->agent_type}\n"; - $md .= "**Status:** {$session->status}\n"; - $md .= "**Duration:** {$session->getDurationFormatted()}\n\n"; - - if ($session->plan) { - $md .= "## Plan\n\n"; - $md .= "**{$session->plan->title}** ({$session->plan->slug})\n\n"; - } - - $context = $session->getHandoffContext(); - if (! empty($context['summary'])) { - $md .= "## Summary\n\n{$context['summary']}\n\n"; - } - if (! empty($context['next_steps'])) { - $md .= "## Next Steps\n\n"; - foreach ((array) $context['next_steps'] as $step) { - $md .= "- {$step}\n"; - } - $md .= "\n"; - } - if (! empty($context['blockers'])) { - $md .= "## Blockers\n\n"; - foreach ((array) $context['blockers'] as $blocker) { - $md .= "- {$blocker}\n"; - } - $md .= "\n"; - } - - return $md; - } - /** * Render content resources. */ @@ -383,7 +269,7 @@ protected function resourceContentItem(string $uri): string $path = substr($uri, 10); // Remove "content://" $parts = explode('/', $path, 2); if (count($parts) < 2) { - return "Invalid URI format. Expected: content://{workspace}/{slug}"; + return 'Invalid URI format. Expected: content://{workspace}/{slug}'; } [$workspaceSlug, $contentSlug] = $parts; @@ -492,6 +378,27 @@ protected function resourceServerContent(string $scheme, string $uri): ?array return null; } + /** + * Resolve the bound agent resource provider, if the agent module supplies one. + * + * Absent when this package is installed without dappcore/agent, which is a + * legitimate deployment — the protocol surface stands on its own and the + * agent resources are an optional extension to it. + * + * @example + * $provider = $this->agentResourceProvider(); + */ + protected function agentResourceProvider(): ?AgentResourceProvider + { + if (! app()->bound(AgentResourceProvider::class)) { + return null; + } + + $provider = app(AgentResourceProvider::class); + + return $provider instanceof AgentResourceProvider ? $provider : null; + } + /** * Execute a tool via the in-process AgentToolRegistry. * @@ -502,10 +409,10 @@ protected function resourceServerContent(string $scheme, string $uri): ?array */ protected function executeTool(string $tool, array $arguments, ?ApiKey $apiKey): mixed { - $registryClass = \Core\Mod\Agentic\Services\AgentToolRegistry::class; + $registryClass = AgentToolRegistry::class; if (! app()->bound($registryClass)) { - throw new \RuntimeException("AgentToolRegistry not available — is the agentic module installed?"); + throw new \RuntimeException('AgentToolRegistry not available — is the agentic module installed?'); } $registry = app($registryClass); diff --git a/php/src/Mcp/Resources/Contracts/AgentResourceProvider.php b/php/src/Mcp/Resources/Contracts/AgentResourceProvider.php new file mode 100644 index 0000000..ac7bb18 --- /dev/null +++ b/php/src/Mcp/Resources/Contracts/AgentResourceProvider.php @@ -0,0 +1,52 @@ + + * + * @example + * $provider->entries(); // [['uri' => 'plans://all', ...]] + */ + public function entries(): array; + + /** + * Read one resource, or null when nothing serves that URI. + * + * Null covers both "no resource matches this shape" and "the shape matched + * but names something that does not exist", so a caller can answer a proper + * error either way instead of returning prose like "Plan not found" as the + * resource body — which a client cannot tell apart from a real document + * whose text happens to say so. + * + * @return array{uri: string, mimeType: string, text: string}|null + * + * @example + * $provider->read('plans://all'); + */ + public function read(string $uri): ?array; +} diff --git a/php/src/Mcp/Services/AgentSessionService.php b/php/src/Mcp/Services/AgentSessionService.php deleted file mode 100644 index dac3aad..0000000 --- a/php/src/Mcp/Services/AgentSessionService.php +++ /dev/null @@ -1,336 +0,0 @@ -update(['workspace_id' => $workspaceId]); - } - - if (! empty($initialContext)) { - $session->updateContextSummary($initialContext); - } - - // Cache the active session ID for quick lookup - $this->cacheActiveSession($session); - - return $session; - } - - /** - * Get an active session by ID. - */ - public function get(string $sessionId): ?AgentSession - { - return AgentSession::where('session_id', $sessionId)->first(); - } - - /** - * Resume an existing session. - */ - public function resume(string $sessionId): ?AgentSession - { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - // Only resume if paused or was handed off - if ($session->status === AgentSession::STATUS_PAUSED) { - $session->resume(); - } - - // Update activity timestamp - $session->touchActivity(); - - // Cache as active - $this->cacheActiveSession($session); - - return $session; - } - - /** - * Get active sessions for a workspace. - */ - public function getActiveSessions(?int $workspaceId = null): Collection - { - $query = AgentSession::active(); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - return $query->orderBy('last_active_at', 'desc')->get(); - } - - /** - * Get sessions for a specific plan. - */ - public function getSessionsForPlan(AgentPlan $plan): Collection - { - return AgentSession::forPlan($plan) - ->orderBy('created_at', 'desc') - ->get(); - } - - /** - * Get the most recent session for a plan. - */ - public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession - { - return AgentSession::forPlan($plan) - ->orderBy('created_at', 'desc') - ->first(); - } - - /** - * End a session. - */ - public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession - { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - $session->end($status, $summary); - - // Remove from active cache - $this->clearCachedSession($session); - - return $session; - } - - /** - * Pause a session for later resumption. - */ - public function pause(string $sessionId): ?AgentSession - { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - $session->pause(); - - return $session; - } - - /** - * Prepare a session for handoff to another agent. - */ - public function prepareHandoff( - string $sessionId, - string $summary, - array $nextSteps = [], - array $blockers = [], - array $contextForNext = [] - ): ?AgentSession { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - $session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext); - - return $session; - } - - /** - * Get handoff context from a session. - */ - public function getHandoffContext(string $sessionId): ?array - { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - return $session->getHandoffContext(); - } - - /** - * Create a follow-up session continuing from a previous one. - */ - public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession - { - $previousSession = $this->get($previousSessionId); - - if (! $previousSession) { - return null; - } - - // Get the handoff context - $handoffContext = $previousSession->getHandoffContext(); - - // Create new session with context from previous - $newSession = $this->start( - $newAgentType, - $previousSession->plan, - $previousSession->workspace_id, - [ - 'continued_from' => $previousSessionId, - 'previous_agent' => $previousSession->agent_type, - 'handoff_notes' => $handoffContext['handoff_notes'] ?? null, - 'inherited_context' => $handoffContext['context_summary'] ?? null, - ] - ); - - // Mark previous session as handed off - $previousSession->end('handed_off', 'Handed off to '.$newAgentType); - - return $newSession; - } - - /** - * Store custom state in session cache for fast access. - */ - public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void - { - $cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key; - Cache::put($cacheKey, $value, $ttl ?? $this->getCacheTtl()); - } - - /** - * Get custom state from session cache. - */ - public function getState(string $sessionId, string $key, mixed $default = null): mixed - { - $cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key; - - return Cache::get($cacheKey, $default); - } - - /** - * Check if a session exists and is valid. - */ - public function exists(string $sessionId): bool - { - return AgentSession::where('session_id', $sessionId)->exists(); - } - - /** - * Check if a session is active. - */ - public function isActive(string $sessionId): bool - { - $session = $this->get($sessionId); - - return $session !== null && $session->isActive(); - } - - /** - * Get session statistics. - */ - public function getSessionStats(?int $workspaceId = null, int $days = 7): array - { - $query = AgentSession::where('created_at', '>=', now()->subDays($days)); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - $sessions = $query->get(); - - $byStatus = $sessions->groupBy('status')->map->count(); - $byAgent = $sessions->groupBy('agent_type')->map->count(); - - $completedSessions = $sessions->where('status', AgentSession::STATUS_COMPLETED); - $avgDuration = $completedSessions->avg(fn ($s) => $s->getDuration() ?? 0); - - return [ - 'total' => $sessions->count(), - 'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(), - 'by_status' => $byStatus->toArray(), - 'by_agent_type' => $byAgent->toArray(), - 'avg_duration_minutes' => round($avgDuration, 1), - 'period_days' => $days, - ]; - } - - /** - * Clean up stale sessions (active but not touched in X hours). - */ - public function cleanupStaleSessions(int $hoursInactive = 24): int - { - $cutoff = now()->subHours($hoursInactive); - - $staleSessions = AgentSession::active() - ->where('last_active_at', '<', $cutoff) - ->get(); - - foreach ($staleSessions as $session) { - $session->fail('Session timed out due to inactivity'); - $this->clearCachedSession($session); - } - - return $staleSessions->count(); - } - - /** - * Cache the active session for quick lookup. - */ - protected function cacheActiveSession(AgentSession $session): void - { - $cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id; - Cache::put($cacheKey, [ - 'session_id' => $session->session_id, - 'agent_type' => $session->agent_type, - 'plan_id' => $session->agent_plan_id, - 'workspace_id' => $session->workspace_id, - 'started_at' => $session->started_at?->toIso8601String(), - ], $this->getCacheTtl()); - } - - /** - * Clear cached session data. - */ - protected function clearCachedSession(AgentSession $session): void - { - $cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id; - Cache::forget($cacheKey); - } -} diff --git a/php/src/Mcp/Services/AgentToolRegistry.php b/php/src/Mcp/Services/AgentToolRegistry.php deleted file mode 100644 index 22b102f..0000000 --- a/php/src/Mcp/Services/AgentToolRegistry.php +++ /dev/null @@ -1,244 +0,0 @@ - - */ - protected array $tools = []; - - /** - * Register a tool. - * - * If the tool implements HasDependencies, its dependencies - * are automatically registered with the ToolDependencyService. - */ - public function register(AgentToolInterface $tool): self - { - $this->tools[$tool->name()] = $tool; - - // Auto-register dependencies if tool declares them - if ($tool instanceof HasDependencies && method_exists($tool, 'dependencies')) { - $dependencies = $tool->dependencies(); - if (! empty($dependencies)) { - app(ToolDependencyService::class)->register($tool->name(), $dependencies); - } - } - - return $this; - } - - /** - * Register multiple tools at once. - * - * @param array $tools - */ - public function registerMany(array $tools): self - { - foreach ($tools as $tool) { - $this->register($tool); - } - - return $this; - } - - /** - * Check if a tool is registered. - */ - public function has(string $name): bool - { - return isset($this->tools[$name]); - } - - /** - * Get a tool by name. - */ - public function get(string $name): ?AgentToolInterface - { - return $this->tools[$name] ?? null; - } - - /** - * Get all registered tools. - * - * @return Collection - */ - public function all(): Collection - { - return collect($this->tools); - } - - /** - * Get tools filtered by category. - * - * @return Collection - */ - public function byCategory(string $category): Collection - { - return $this->all()->filter( - fn (AgentToolInterface $tool) => $tool->category() === $category - ); - } - - /** - * Get tools accessible by an API key. - * - * @return Collection - */ - public function forApiKey(ApiKey $apiKey): Collection - { - return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) { - // Check if API key has required scopes - foreach ($tool->requiredScopes() as $scope) { - if (! $apiKey->hasScope($scope)) { - return false; - } - } - - // Check if API key has tool-level permission - return $this->apiKeyCanAccessTool($apiKey, $tool->name()); - }); - } - - /** - * Check if an API key can access a specific tool. - */ - public function apiKeyCanAccessTool(ApiKey $apiKey, string $toolName): bool - { - $allowedTools = $apiKey->tool_scopes ?? null; - - // Null means all tools allowed - if ($allowedTools === null) { - return true; - } - - return in_array($toolName, $allowedTools, true); - } - - /** - * Execute a tool with permission and dependency checking. - * - * @param string $name Tool name - * @param array $args Tool arguments - * @param array $context Execution context - * @param ApiKey|null $apiKey Optional API key for permission checking - * @param bool $validateDependencies Whether to validate dependencies - * @return array Tool result - * - * @throws \InvalidArgumentException If tool not found - * @throws \RuntimeException If permission denied - * @throws \Core\Mcp\Exceptions\MissingDependencyException If dependencies not met - */ - public function execute( - string $name, - array $args, - array $context = [], - ?ApiKey $apiKey = null, - bool $validateDependencies = true - ): array { - $tool = $this->get($name); - - if (! $tool) { - throw new \InvalidArgumentException("Unknown tool: {$name}"); - } - - // Permission check if API key provided - if ($apiKey !== null) { - // Check scopes - foreach ($tool->requiredScopes() as $scope) { - if (! $apiKey->hasScope($scope)) { - throw new \RuntimeException( - "Permission denied: API key missing scope '{$scope}' for tool '{$name}'" - ); - } - } - - // Check tool-level permission - if (! $this->apiKeyCanAccessTool($apiKey, $name)) { - throw new \RuntimeException( - "Permission denied: API key does not have access to tool '{$name}'" - ); - } - } - - // Dependency check - if ($validateDependencies) { - $sessionId = $context['session_id'] ?? 'anonymous'; - $dependencyService = app(ToolDependencyService::class); - - $dependencyService->validateDependencies($sessionId, $name, $context, $args); - } - - $result = $tool->handle($args, $context); - - // Record successful tool call for dependency tracking - if ($validateDependencies && ($result['success'] ?? true) !== false) { - $sessionId = $context['session_id'] ?? 'anonymous'; - app(ToolDependencyService::class)->recordToolCall($sessionId, $name, $args); - } - - return $result; - } - - /** - * Get all tools as MCP tool definitions. - * - * @param ApiKey|null $apiKey Filter by API key permissions - */ - public function toMcpDefinitions(?ApiKey $apiKey = null): array - { - $tools = $apiKey !== null - ? $this->forApiKey($apiKey) - : $this->all(); - - return $tools->map(fn (AgentToolInterface $tool) => $tool->toMcpDefinition()) - ->values() - ->all(); - } - - /** - * Get tool categories with counts. - */ - public function categories(): Collection - { - return $this->all() - ->groupBy(fn (AgentToolInterface $tool) => $tool->category()) - ->map(fn ($tools) => $tools->count()); - } - - /** - * Get all tool names. - * - * @return array - */ - public function names(): array - { - return array_keys($this->tools); - } - - /** - * Get tool count. - */ - public function count(): int - { - return count($this->tools); - } -}