loadRegistry(); $servers = collect($registry['servers'] ?? []) ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) ->filter() ->values(); return response()->json([ 'servers' => $servers, 'count' => $servers->count(), ]); } /** * Get server details with tools and resources. * * GET /api/v1/mcp/servers/{id} */ public function server(Request $request, string $id): JsonResponse { $server = $this->loadServerFull($id); if (! $server) { return response()->json(['error' => 'Server not found'], 404); } return response()->json($server); } /** * List tools for a specific server. * * GET /api/v1/mcp/servers/{id}/tools * * Query params: * - include_versions: bool - include version info for each tool */ public function tools(Request $request, string $id): JsonResponse { $server = $this->loadServerFull($id); if (! $server) { return response()->json(['error' => 'Server not found'], 404); } $tools = $server['tools'] ?? []; $includeVersions = $request->boolean('include_versions', false); // Optionally enrich tools with version information if ($includeVersions) { $versionService = app(ToolVersionService::class); $tools = collect($tools)->map(function ($tool) use ($id, $versionService) { $toolName = $tool['name'] ?? ''; $latestVersion = $versionService->getLatestVersion($id, $toolName); $tool['versioning'] = [ 'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION, 'is_versioned' => $latestVersion !== null, 'deprecated' => $latestVersion?->is_deprecated ?? false, ]; // If version exists, use its schema (may differ from YAML) if ($latestVersion?->input_schema) { $tool['inputSchema'] = $latestVersion->input_schema; } return $tool; })->all(); } return response()->json([ 'server' => $id, 'tools' => $tools, 'count' => count($tools), ]); } /** * Execute a tool on an MCP server. * * POST /api/v1/mcp/tools/call * * Request body: * - server: string (required) * - tool: string (required) * - arguments: array (optional) * - version: string (optional) - semver version to use, defaults to latest */ public function callTool(Request $request): JsonResponse { $validated = $request->validate([ 'server' => 'required|string|max:64', 'tool' => 'required|string|max:128', 'arguments' => 'nullable|array', 'version' => 'nullable|string|max:32', ]); $server = $this->loadServerFull($validated['server']); if (! $server) { return response()->json(['error' => 'Server not found'], 404); } // Verify tool exists in server definition $toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']); if (! $toolDef) { return response()->json(['error' => 'Tool not found'], 404); } // Version resolution $versionService = app(ToolVersionService::class); $versionResult = $versionService->resolveVersion( $validated['server'], $validated['tool'], $validated['version'] ?? null ); // If version was requested but is sunset, block the call if ($versionResult['error']) { $error = $versionResult['error']; // Sunset versions return 410 Gone $status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400; return response()->json([ 'success' => false, 'error' => $error['message'] ?? 'Version error', 'error_code' => $error['code'] ?? 'VERSION_ERROR', 'server' => $validated['server'], 'tool' => $validated['tool'], 'requested_version' => $validated['version'] ?? null, 'latest_version' => $error['latest_version'] ?? null, 'migration_notes' => $error['migration_notes'] ?? null, ], $status); } /** @var McpToolVersion|null $toolVersion */ $toolVersion = $versionResult['version']; $deprecationWarning = $versionResult['warning']; // Use versioned schema if available for validation $schemaForValidation = $toolVersion?->input_schema ?? $toolDef['inputSchema'] ?? null; if ($schemaForValidation) { $validationErrors = $this->validateToolArguments( ['inputSchema' => $schemaForValidation], $validated['arguments'] ?? [] ); if (! empty($validationErrors)) { return response()->json([ 'success' => false, 'error' => 'Validation failed', 'error_code' => 'VALIDATION_ERROR', 'validation_errors' => $validationErrors, 'server' => $validated['server'], 'tool' => $validated['tool'], 'version' => $toolVersion?->version ?? 'unversioned', ], 422); } } // Get API key for logging $apiKey = $request->attributes->get('api_key'); $workspace = $apiKey?->workspace; $startTime = microtime(true); try { // Execute the tool via artisan command $result = $this->executeToolViaArtisan( $validated['server'], $validated['tool'], $validated['arguments'] ?? [] ); $durationMs = (int) ((microtime(true) - $startTime) * 1000); // Log the call $this->logToolCall($apiKey, $validated, $result, $durationMs, true); // Dispatch webhooks $this->dispatchWebhook($apiKey, $validated, true, $durationMs); $response = [ 'success' => true, 'server' => $validated['server'], 'tool' => $validated['tool'], 'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION, 'result' => $result, 'duration_ms' => $durationMs, ]; // Include deprecation warning if applicable if ($deprecationWarning) { $response['_warnings'] = [$deprecationWarning]; } // Log full request for debugging/replay $this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey); // Build response with deprecation headers if needed $jsonResponse = response()->json($response); if ($deprecationWarning) { $jsonResponse->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated'); if (isset($deprecationWarning['sunset_at'])) { $jsonResponse->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']); } if (isset($deprecationWarning['latest_version'])) { $jsonResponse->header('X-MCP-Latest-Version', $deprecationWarning['latest_version']); } } return $jsonResponse; } catch (\Throwable $e) { $durationMs = (int) ((microtime(true) - $startTime) * 1000); $this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage()); // Dispatch webhooks (even on failure) $this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage()); $response = [ 'success' => false, 'error' => $e->getMessage(), 'server' => $validated['server'], 'tool' => $validated['tool'], 'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION, ]; // Log full request for debugging/replay $this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage()); return response()->json($response, 500); } } /** * Validate tool arguments against a JSON schema. * * @return array Validation error messages */ protected function validateToolArguments(array $toolDef, array $arguments): array { $inputSchema = $toolDef['inputSchema'] ?? null; if (! $inputSchema || ! is_array($inputSchema)) { return []; } $errors = []; $properties = $inputSchema['properties'] ?? []; $required = $inputSchema['required'] ?? []; // Check required properties foreach ($required as $requiredProp) { if (! array_key_exists($requiredProp, $arguments)) { $errors[] = "Missing required argument: {$requiredProp}"; } } // Type validation for provided arguments foreach ($arguments as $key => $value) { if (! isset($properties[$key])) { if (($inputSchema['additionalProperties'] ?? true) === false) { $errors[] = "Unknown argument: {$key}"; } continue; } $propSchema = $properties[$key]; $expectedType = $propSchema['type'] ?? null; if ($expectedType && ! $this->validateType($value, $expectedType)) { $errors[] = "Argument '{$key}' must be of type {$expectedType}"; } // Validate enum values if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) { $allowedValues = implode(', ', $propSchema['enum']); $errors[] = "Argument '{$key}' must be one of: {$allowedValues}"; } } return $errors; } /** * Validate a value against a JSON Schema type. */ protected function validateType(mixed $value, string $type): bool { return match ($type) { 'string' => is_string($value), 'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value), 'number' => is_numeric($value), 'boolean' => is_bool($value), 'array' => is_array($value) && array_is_list($value), 'object' => is_array($value) && ! array_is_list($value), 'null' => is_null($value), default => true, }; } /** * Get version history for a specific tool. * * GET /api/v1/mcp/servers/{server}/tools/{tool}/versions */ public function toolVersions(Request $request, string $server, string $tool): JsonResponse { $serverConfig = $this->loadServerFull($server); if (! $serverConfig) { return response()->json(['error' => 'Server not found'], 404); } // Verify tool exists in server definition $toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool); if (! $toolDef) { return response()->json(['error' => 'Tool not found'], 404); } $versionService = app(ToolVersionService::class); $versions = $versionService->getVersionHistory($server, $tool); return response()->json([ 'server' => $server, 'tool' => $tool, 'versions' => $versions->map(fn (McpToolVersion $v) => $v->toApiArray())->values(), 'count' => $versions->count(), ]); } /** * Get a specific version of a tool. * * GET /api/v1/mcp/servers/{server}/tools/{tool}/versions/{version} */ public function toolVersion(Request $request, string $server, string $tool, string $version): JsonResponse { $versionService = app(ToolVersionService::class); $toolVersion = $versionService->getToolAtVersion($server, $tool, $version); if (! $toolVersion) { return response()->json(['error' => 'Version not found'], 404); } $response = response()->json($toolVersion->toApiArray()); // Add deprecation headers if applicable if ($deprecationWarning = $toolVersion->getDeprecationWarning()) { $response->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated'); if (isset($deprecationWarning['sunset_at'])) { $response->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']); } } return $response; } /** * Read a resource from an MCP server. * * GET /api/v1/mcp/resources/{uri} */ public function resource(Request $request, string $uri): JsonResponse { // Parse URI format: server://resource/path if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) { return response()->json(['error' => 'Invalid resource URI format'], 400); } $serverId = $matches[1]; $resourcePath = $matches[2]; $server = $this->loadServerFull($serverId); if (! $server) { return response()->json(['error' => 'Server not found'], 404); } try { $result = $this->readResourceViaArtisan($serverId, $resourcePath); return response()->json([ 'uri' => $uri, 'content' => $result, ]); } catch (\Throwable $e) { return response()->json([ 'error' => $e->getMessage(), 'uri' => $uri, ], 500); } } /** * Execute tool via artisan MCP server command. */ protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed { $commandMap = [ 'hosthub-agent' => 'mcp:agent-server', 'socialhost' => 'mcp:socialhost-server', 'biohost' => 'mcp:biohost-server', 'commerce' => 'mcp:commerce-server', 'supporthost' => 'mcp:support-server', 'upstream' => 'mcp:upstream-server', ]; $command = $commandMap[$server] ?? null; if (! $command) { throw new \RuntimeException("Unknown server: {$server}"); } // Build MCP request $mcpRequest = [ 'jsonrpc' => '2.0', 'id' => uniqid(), 'method' => 'tools/call', 'params' => [ 'name' => $tool, 'arguments' => $arguments, ], ]; // Execute via process $process = proc_open( ['php', 'artisan', $command], [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ], $pipes, base_path() ); if (! is_resource($process)) { throw new \RuntimeException('Failed to start MCP server process'); } fwrite($pipes[0], json_encode($mcpRequest)."\n"); fclose($pipes[0]); $output = stream_get_contents($pipes[1]); fclose($pipes[1]); fclose($pipes[2]); proc_close($process); $response = json_decode($output, true); if (isset($response['error'])) { throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed'); } return $response['result'] ?? null; } /** * Read resource via artisan MCP server command. */ protected function readResourceViaArtisan(string $server, string $path): mixed { // Similar to executeToolViaArtisan but with resources/read method // Simplified for now - can expand later return ['path' => $path, 'content' => 'Resource reading not yet implemented']; } /** * Log full API request for debugging and replay. */ protected function logApiRequest( Request $request, array $validated, int $status, array $response, int $durationMs, ?ApiKey $apiKey, ?string $error = null ): void { try { McpApiRequest::log( method: $request->method(), path: '/tools/call', requestBody: $validated, responseStatus: $status, responseBody: $response, durationMs: $durationMs, workspaceId: $apiKey?->workspace_id, apiKeyId: $apiKey?->id, serverId: $validated['server'], toolName: $validated['tool'], errorMessage: $error, ipAddress: $request->ip(), headers: $request->headers->all() ); } catch (\Throwable $e) { // Don't let logging failures affect API response report($e); } } /** * Dispatch webhook for tool execution. */ protected function dispatchWebhook( ?ApiKey $apiKey, array $request, bool $success, int $durationMs, ?string $error = null ): void { if (! $apiKey?->workspace_id) { return; } try { $dispatcher = new McpWebhookDispatcher; $dispatcher->dispatchToolExecuted( workspaceId: $apiKey->workspace_id, serverId: $request['server'], toolName: $request['tool'], arguments: $request['arguments'] ?? [], success: $success, durationMs: $durationMs, errorMessage: $error ); } catch (\Throwable $e) { // Don't let webhook failures affect API response report($e); } } /** * Log tool call for analytics. */ protected function logToolCall( ?ApiKey $apiKey, array $request, mixed $result, int $durationMs, bool $success, ?string $error = null ): void { McpToolCall::log( serverId: $request['server'], toolName: $request['tool'], params: $request['arguments'] ?? [], success: $success, durationMs: $durationMs, errorMessage: $error, workspaceId: $apiKey?->workspace_id ); } // Registry loading methods (shared with McpRegistryController) protected function loadRegistry(): array { return Cache::remember('mcp:registry', 600, function () { $path = resource_path('mcp/registry.yaml'); return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; }); } protected function loadServerFull(string $id): ?array { return Cache::remember("mcp:server:{$id}", 600, function () use ($id) { $path = resource_path("mcp/servers/{$id}.yaml"); return file_exists($path) ? Yaml::parseFile($path) : null; }); } protected function loadServerSummary(string $id): ?array { $server = $this->loadServerFull($id); if (! $server) { return null; } return [ 'id' => $server['id'], 'name' => $server['name'], 'tagline' => $server['tagline'] ?? '', 'status' => $server['status'] ?? 'available', 'tool_count' => count($server['tools'] ?? []), 'resource_count' => count($server['resources'] ?? []), ]; } }