|
| 1 | +<?php |
| 2 | + |
| 3 | +/* |
| 4 | + * Copyright (c) 2025-2026 Netresearch DTT GmbH |
| 5 | + * SPDX-License-Identifier: GPL-2.0-or-later |
| 6 | + */ |
| 7 | + |
| 8 | +declare(strict_types=1); |
| 9 | + |
| 10 | +namespace Netresearch\NrLlm\Command; |
| 11 | + |
| 12 | +use Netresearch\NrLlm\Domain\ValueObject\McpImportReport; |
| 13 | +use Netresearch\NrLlm\Domain\ValueObject\McpServerRecord; |
| 14 | +use Netresearch\NrLlm\Service\Tool\Mcp\McpImportService; |
| 15 | +use Netresearch\NrLlm\Service\Tool\Mcp\McpServerRepository; |
| 16 | +use Symfony\Component\Console\Attribute\AsCommand; |
| 17 | +use Symfony\Component\Console\Command\Command; |
| 18 | +use Symfony\Component\Console\Input\InputArgument; |
| 19 | +use Symfony\Component\Console\Input\InputInterface; |
| 20 | +use Symfony\Component\Console\Input\InputOption; |
| 21 | +use Symfony\Component\Console\Output\OutputInterface; |
| 22 | +use Symfony\Component\Console\Style\SymfonyStyle; |
| 23 | + |
| 24 | +/** |
| 25 | + * Imports an MCP server's advertised catalogue from the CLI. |
| 26 | + * |
| 27 | + * A server record can be seeded, but until this existed its catalogue could |
| 28 | + * only arrive by a person opening the MCP Servers module and pressing **Import |
| 29 | + * catalogue**. An instance rebuilt by a deploy therefore had one manual step |
| 30 | + * after every fresh install, and no tools for that server until somebody |
| 31 | + * clicked. |
| 32 | + * |
| 33 | + * This is the module's button, not a second path: it calls the same |
| 34 | + * {@see McpImportService::import()} and adds nothing. Every refusal reason, the |
| 35 | + * SSRF gate, the operation budget (ADR-170) and the catalogue reconciliation |
| 36 | + * live in that service, which is why this class holds no policy of its own. |
| 37 | + * |
| 38 | + * It lives in `Classes/Command/` with every other command but belongs to the |
| 39 | + * tool module, and `ModuleSeamTest` names it for that reason: in a package |
| 40 | + * split it moves to nr_llm_tools with the MCP code it drives (ADR-090). |
| 41 | + * |
| 42 | + * Servers are addressed by identifier rather than uid, because a uid is not |
| 43 | + * knowable to whoever writes the deploy script and an identifier is what the |
| 44 | + * seed sets. Identifiers are not unique in the table — soft-deleted rows keep |
| 45 | + * theirs, which is why the service refuses an import when two ENABLED servers |
| 46 | + * share one rather than relying on a database constraint. The same ambiguity |
| 47 | + * can reach this command, so it is reported here rather than resolved by |
| 48 | + * picking a row. |
| 49 | + */ |
| 50 | +#[AsCommand( |
| 51 | + name: 'nrllm:mcp:import', |
| 52 | + description: "Import an MCP server's advertised tool catalogue.", |
| 53 | +)] |
| 54 | +/** |
| 55 | + * @internal Not part of the @api surface; may change without notice (ADR-127). |
| 56 | + */ |
| 57 | +final class ImportMcpCatalogueCommand extends Command |
| 58 | +{ |
| 59 | + public function __construct( |
| 60 | + private readonly McpImportService $importer, |
| 61 | + private readonly McpServerRepository $servers, |
| 62 | + ) { |
| 63 | + parent::__construct(); |
| 64 | + } |
| 65 | + |
| 66 | + protected function configure(): void |
| 67 | + { |
| 68 | + $this->addArgument( |
| 69 | + 'identifier', |
| 70 | + InputArgument::OPTIONAL, |
| 71 | + "The MCP server's identifier, as set on the record. Omit it and pass --all instead.", |
| 72 | + ); |
| 73 | + |
| 74 | + $this->addOption( |
| 75 | + 'all', |
| 76 | + 'a', |
| 77 | + InputOption::VALUE_NONE, |
| 78 | + 'Import every enabled server. One failing server does not stop the others.', |
| 79 | + ); |
| 80 | + } |
| 81 | + |
| 82 | + protected function execute(InputInterface $input, OutputInterface $output): int |
| 83 | + { |
| 84 | + $io = new SymfonyStyle($input, $output); |
| 85 | + $identifier = $input->getArgument('identifier'); |
| 86 | + $identifier = is_string($identifier) ? $identifier : null; |
| 87 | + |
| 88 | + $all = $input->getOption('all') === true; |
| 89 | + |
| 90 | + if ($all && $identifier !== null) { |
| 91 | + $io->error('Pass either an identifier or --all, not both.'); |
| 92 | + |
| 93 | + return Command::INVALID; |
| 94 | + } |
| 95 | + |
| 96 | + if (!$all && ($identifier === null || $identifier === '')) { |
| 97 | + $io->error('Name a server identifier, or pass --all to import every enabled server.'); |
| 98 | + |
| 99 | + return Command::INVALID; |
| 100 | + } |
| 101 | + |
| 102 | + $servers = $all ? $this->servers->findEnabled() : $this->resolve($identifier ?? '', $io); |
| 103 | + if ($servers === null) { |
| 104 | + return Command::FAILURE; |
| 105 | + } |
| 106 | + |
| 107 | + if ($servers === []) { |
| 108 | + // Not a failure: an installation with no enabled server is a valid |
| 109 | + // state, and a deploy that runs --all unconditionally must not go |
| 110 | + // red because this one has none yet. |
| 111 | + $io->warning('No enabled MCP server to import.'); |
| 112 | + |
| 113 | + return Command::SUCCESS; |
| 114 | + } |
| 115 | + |
| 116 | + $refused = 0; |
| 117 | + foreach ($servers as $server) { |
| 118 | + if (!$this->report($this->importer->import($server), $server, $io)) { |
| 119 | + ++$refused; |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + if ($refused > 0) { |
| 124 | + $io->error(sprintf('%d of %d server(s) refused the import.', $refused, count($servers))); |
| 125 | + |
| 126 | + return Command::FAILURE; |
| 127 | + } |
| 128 | + |
| 129 | + return Command::SUCCESS; |
| 130 | + } |
| 131 | + |
| 132 | + /** |
| 133 | + * The one enabled server carrying this identifier, or null with the reason printed. |
| 134 | + * |
| 135 | + * @return list<McpServerRecord>|null |
| 136 | + */ |
| 137 | + private function resolve(string $identifier, SymfonyStyle $io): ?array |
| 138 | + { |
| 139 | + $matches = array_values(array_filter( |
| 140 | + $this->servers->findEnabled(), |
| 141 | + static fn(McpServerRecord $server): bool => $server->identifier === $identifier, |
| 142 | + )); |
| 143 | + |
| 144 | + if ($matches === []) { |
| 145 | + $io->error(sprintf('No enabled MCP server has the identifier "%s".', $identifier)); |
| 146 | + |
| 147 | + return null; |
| 148 | + } |
| 149 | + |
| 150 | + if (count($matches) > 1) { |
| 151 | + // The service refuses this case too, but it would refuse it once per |
| 152 | + // row and read as two unrelated failures. Said here, once, in the |
| 153 | + // terms the operator has to act on. |
| 154 | + $io->error(sprintf( |
| 155 | + '%d enabled servers share the identifier "%s". Identifiers name the imported tools, so they must be unique among enabled servers.', |
| 156 | + count($matches), |
| 157 | + $identifier, |
| 158 | + )); |
| 159 | + |
| 160 | + return null; |
| 161 | + } |
| 162 | + |
| 163 | + return $matches; |
| 164 | + } |
| 165 | + |
| 166 | + /** |
| 167 | + * Prints one server's outcome. Returns false when the import was refused. |
| 168 | + */ |
| 169 | + private function report(McpImportReport $report, McpServerRecord $server, SymfonyStyle $io): bool |
| 170 | + { |
| 171 | + if ($report->refused) { |
| 172 | + $io->writeln(sprintf( |
| 173 | + '<error>%s: refused</error> — %s', |
| 174 | + $server->identifier, |
| 175 | + $report->skipReasons[0] ?? 'no reason given', |
| 176 | + )); |
| 177 | + |
| 178 | + return false; |
| 179 | + } |
| 180 | + |
| 181 | + // Printed even when all three are zero: "imported 0" after a successful |
| 182 | + // contact means the catalogue matched what was already stored, which is |
| 183 | + // the expected result of the second run and worth seeing. |
| 184 | + $io->writeln(sprintf( |
| 185 | + '<info>%s</info>: %d imported, %d skipped, %d orphaned', |
| 186 | + $server->identifier, |
| 187 | + $report->imported, |
| 188 | + $report->skipped, |
| 189 | + $report->orphaned, |
| 190 | + )); |
| 191 | + |
| 192 | + foreach ($report->skipReasons as $reason) { |
| 193 | + $io->writeln(' skipped: ' . $reason); |
| 194 | + } |
| 195 | + |
| 196 | + return true; |
| 197 | + } |
| 198 | +} |
0 commit comments