diff --git a/docs/example/config/prompt-templates.yml b/docs/example/config/prompt-templates.yml new file mode 100644 index 00000000..14b5c61e --- /dev/null +++ b/docs/example/config/prompt-templates.yml @@ -0,0 +1,75 @@ +prompts: + # Template for issues + - id: template-issue + description: Template for creating issues + type: template + messages: + - role: user + content: "Create a new issue with the following title and description: {{title}} {{description}}" + + # Template for bug issues, extending the base issue template + - id: bug-issue + description: Create a new bug issue + type: prompt + extend: + - id: template-issue + arguments: + title: 'Bug: {{title}}' + description: '{{description}}' + schema: + properties: + title: + description: The title of the bug + description: + description: The description of the bug + required: + - title + - description + + # Template for feature issues, extending the base issue template + - id: feature-issue + description: Create a new feature issue + type: prompt + extend: + - id: template-issue + arguments: + title: 'Feature: {{title}}' + description: '{{description}}' + schema: + properties: + title: + description: The title of the feature + description: + description: The description of the feature + required: + - title + - description + + # More complex template example, extending another template + - id: template-complex-issue + type: template + description: Template for complex issues with priority + extend: + - id: template-issue + arguments: + title: '{{type}}: {{title}}' + description: '{{description}} \n\n**Priority**: {{priority}}' + + # Priority bug issue using the complex template + - id: priority-bug-issue + description: Create a new priority bug issue + type: prompt + extend: + - id: template-complex-issue + arguments: + type: 'Bug' + priority: 'High' + schema: + properties: + title: + description: The title of the bug + description: + description: The description of the bug + required: + - title + - description diff --git a/json-schema.json b/json-schema.json index b544f419..f269a185 100644 --- a/json-schema.json +++ b/json-schema.json @@ -269,7 +269,10 @@ }, "type": { "type": "string", - "enum": ["run", "http"], + "enum": [ + "run", + "http" + ], "description": "Type of tool (run = command execution, http = HTTP requests)", "default": "run" }, @@ -309,7 +312,9 @@ } }, "then": { - "required": ["commands"] + "required": [ + "commands" + ] } }, { @@ -321,14 +326,18 @@ } }, "then": { - "required": ["requests"] + "required": [ + "requests" + ] } } ] }, "httpRequest": { "type": "object", - "required": ["url"], + "required": [ + "url" + ], "properties": { "url": { "type": "string", @@ -336,7 +345,15 @@ }, "method": { "type": "string", - "enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], + "enum": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS" + ], "description": "HTTP method to use", "default": "GET" }, @@ -423,8 +440,7 @@ "prompt": { "type": "object", "required": [ - "id", - "messages" + "id" ], "properties": { "id": { @@ -435,6 +451,15 @@ "type": "string", "description": "Human-readable description of the prompt" }, + "type": { + "type": "string", + "enum": [ + "prompt", + "template" + ], + "description": "Type of prompt (regular prompt or template)", + "default": "prompt" + }, "schema": { "$ref": "#/definitions/inputSchema", "description": "Defines input parameters for this prompt" @@ -459,13 +484,74 @@ }, "content": { "type": "string", - "description": "The content of the message, may contain variable placeholders like ${variableName}" + "description": "The content of the message, may contain variable placeholders like ${variableName} or {{variableName}}" + } + } + } + }, + "extend": { + "type": "array", + "description": "List of templates to extend", + "items": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "description": "ID of the template to extend" + }, + "arguments": { + "type": "object", + "description": "Arguments to pass to the template", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "prompt" } } }, - "minItems": 1 + "then": { + "anyOf": [ + { + "required": [ + "messages" + ] + }, + { + "required": [ + "extend" + ] + } + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "template" + } + } + }, + "then": { + "required": [ + "messages" + ] + } } - } + ] }, "document": { "type": "object", @@ -1257,7 +1343,9 @@ "description": "Patterns to include only specific paths" }, "maxFiles": { - "type": ["integer"], + "type": [ + "integer" + ], "description": "Maximum number of files to include (0 for no limit)", "minimum": 0, "default": 0 diff --git a/src/Application/Bootloader/VariableBootloader.php b/src/Application/Bootloader/VariableBootloader.php index d3851109..ad68d2eb 100644 --- a/src/Application/Bootloader/VariableBootloader.php +++ b/src/Application/Bootloader/VariableBootloader.php @@ -6,11 +6,14 @@ use Butschster\ContextGenerator\Config\Parser\VariablesParserPlugin; use Butschster\ContextGenerator\DirectoriesInterface; +use Butschster\ContextGenerator\Lib\Variable\CompositeProcessor; use Butschster\ContextGenerator\Lib\Variable\Provider\CompositeVariableProvider; use Butschster\ContextGenerator\Lib\Variable\Provider\ConfigVariableProvider; use Butschster\ContextGenerator\Lib\Variable\Provider\DotEnvVariableProvider; use Butschster\ContextGenerator\Lib\Variable\Provider\PredefinedVariableProvider; use Butschster\ContextGenerator\Lib\Variable\Provider\VariableProviderInterface; +use Butschster\ContextGenerator\Lib\Variable\VariableReplacementProcessor; +use Butschster\ContextGenerator\Lib\Variable\VariableReplacementProcessorInterface; use Butschster\ContextGenerator\Lib\Variable\VariableResolver; use Dotenv\Repository\RepositoryBuilder; use Spiral\Boot\Bootloader\Bootloader; @@ -50,6 +53,12 @@ public function defineSingletons(): array ); }, + VariableReplacementProcessorInterface::class => static fn( + VariableReplacementProcessor $replacementProcessor, + ) => new CompositeProcessor([ + $replacementProcessor, + ]), + VariableResolver::class => VariableResolver::class, ]; } diff --git a/src/Config/Import/ImportRegistry.php b/src/Config/Import/ImportRegistry.php index 81c5e388..8d53fc38 100644 --- a/src/Config/Import/ImportRegistry.php +++ b/src/Config/Import/ImportRegistry.php @@ -9,16 +9,18 @@ use Spiral\Core\Attribute\Singleton; /** - * @implements RegistryInterface + * @template TImport of SourceConfigInterface + * @implements RegistryInterface */ #[Singleton] final class ImportRegistry implements RegistryInterface { - /** @var array */ + /** @var list */ private array $imports = []; /** * Register an import in the registry + * @param TImport $import */ public function register(SourceConfigInterface $import): self { @@ -27,19 +29,11 @@ public function register(SourceConfigInterface $import): self return $this; } - /** - * Gets the type of the registry. - */ public function getType(): string { return 'import'; } - /** - * Gets all items in the registry. - * - * @return array - */ public function getItems(): array { return $this->imports; diff --git a/src/Config/Registry/RegistryInterface.php b/src/Config/Registry/RegistryInterface.php index 1079216c..85e9abc6 100644 --- a/src/Config/Registry/RegistryInterface.php +++ b/src/Config/Registry/RegistryInterface.php @@ -19,7 +19,7 @@ public function getType(): string; /** * Get all items in the registry * - * @return array + * @return list */ public function getItems(): array; } diff --git a/src/Console/GenerateCommand.php b/src/Console/GenerateCommand.php index f7d3bf14..0a0909f7 100644 --- a/src/Console/GenerateCommand.php +++ b/src/Console/GenerateCommand.php @@ -93,7 +93,10 @@ public function __invoke(Container $container, DirectoriesInterface $dirs): int $config = new ConfigRegistryAccessor($loader->load()); - $renderer->renderImports($config->getImports()); + $imports = $config->getImports(); + if ($imports !== null) { + $renderer->renderImports($imports); + } foreach ($config->getDocuments() as $document) { $this->logger->info(\sprintf('Compiling %s...', $document->description)); diff --git a/src/Document/DocumentRegistry.php b/src/Document/DocumentRegistry.php index cb526867..9e229802 100644 --- a/src/Document/DocumentRegistry.php +++ b/src/Document/DocumentRegistry.php @@ -7,12 +7,14 @@ use Butschster\ContextGenerator\Config\Registry\RegistryInterface; /** - * @implements RegistryInterface + * @template TDocument of Document + * @implements RegistryInterface + * @implements \ArrayAccess */ -final class DocumentRegistry implements RegistryInterface +final class DocumentRegistry implements RegistryInterface, \ArrayAccess { public function __construct( - /** @var array */ + /** @var list */ private array $documents = [], ) {} @@ -23,6 +25,7 @@ public function getType(): string /** * Register a document in the registry + * @param TDocument $document */ public function register(Document $document): self { @@ -45,4 +48,24 @@ public function getIterator(): \Traversable { return new \ArrayIterator($this->getItems()); } + + public function offsetExists(mixed $offset): bool + { + return \array_key_exists($offset, $this->documents); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->documents[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \BadMethodCallException('Cannot set value directly. Use register() method.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new \BadMethodCallException('Cannot unset value directly.'); + } } diff --git a/src/Lib/Variable/CompositeProcessor.php b/src/Lib/Variable/CompositeProcessor.php new file mode 100644 index 00000000..7f0d8b91 --- /dev/null +++ b/src/Lib/Variable/CompositeProcessor.php @@ -0,0 +1,24 @@ +processors as $processor) { + $text = $processor->process($text); + } + + return $text; + } +} diff --git a/src/Lib/Variable/VariableReplacementProcessor.php b/src/Lib/Variable/VariableReplacementProcessor.php index be8f5eb2..9ae44144 100644 --- a/src/Lib/Variable/VariableReplacementProcessor.php +++ b/src/Lib/Variable/VariableReplacementProcessor.php @@ -12,7 +12,7 @@ /** * Processor that replaces variable references in text */ -final readonly class VariableReplacementProcessor +final readonly class VariableReplacementProcessor implements VariableReplacementProcessorInterface { public function __construct( private VariableProviderInterface $provider = new PredefinedVariableProvider(), diff --git a/src/Lib/Variable/VariableReplacementProcessorInterface.php b/src/Lib/Variable/VariableReplacementProcessorInterface.php new file mode 100644 index 00000000..56b74309 --- /dev/null +++ b/src/Lib/Variable/VariableReplacementProcessorInterface.php @@ -0,0 +1,16 @@ +processor, + $processor, + ])); + } + /** * Resolve variables in the given text */ diff --git a/src/McpServer/Action/Prompts/GetPromptAction.php b/src/McpServer/Action/Prompts/GetPromptAction.php index 80eb7132..5cfbc530 100644 --- a/src/McpServer/Action/Prompts/GetPromptAction.php +++ b/src/McpServer/Action/Prompts/GetPromptAction.php @@ -5,12 +5,9 @@ namespace Butschster\ContextGenerator\McpServer\Action\Prompts; use Butschster\ContextGenerator\Application\Logger\LoggerPrefix; -use Butschster\ContextGenerator\Lib\Variable\VariableResolver; use Butschster\ContextGenerator\McpServer\Prompt\PromptProviderInterface; use Butschster\ContextGenerator\McpServer\Routing\Attribute\Get; use Mcp\Types\GetPromptResult; -use Mcp\Types\PromptMessage; -use Mcp\Types\TextContent; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; @@ -20,7 +17,6 @@ public function __construct( #[LoggerPrefix(prefix: 'prompts.get')] private LoggerInterface $logger, private PromptProviderInterface $prompts, - private VariableResolver $variables, ) {} #[Get(path: 'prompt/{id}', name: 'prompts.get')] @@ -33,44 +29,11 @@ public function __invoke(ServerRequestInterface $request): GetPromptResult return new GetPromptResult([]); } - $prompt = $this->prompts->get($id); - $messages = $this->processMessageTemplates($prompt->messages, $request->getAttributes()); + $prompt = $this->prompts->get($id, $request->getAttributes()); return new GetPromptResult( - messages: $messages, + messages: $prompt->messages, description: $prompt->prompt->description, ); } - - /** - * Processes message templates with the given arguments. - * - * @param array $messages The messages to process - * @param array $arguments The arguments to use - * @return array The processed messages - */ - private function processMessageTemplates(array $messages, array $arguments): array - { - $arguments = \array_combine( - \array_map(static fn($key) => '{{' . $key . '}}', \array_keys($arguments)), - \array_values($arguments), - ); - $variables = $this->variables; - - return \array_map(static function ($message) use ($variables, $arguments) { - $content = $message->content; - - if ($content instanceof TextContent) { - $text = \strtr($content->text, $arguments); - $text = $variables->resolve($text); - - $content = new TextContent($text); - } - - return new PromptMessage( - role: $message->role, - content: $content, - ); - }, $messages); - } } diff --git a/src/McpServer/Prompt/Exception/PromptParsingException.php b/src/McpServer/Prompt/Exception/PromptParsingException.php index 418b1b39..45cc243d 100644 --- a/src/McpServer/Prompt/Exception/PromptParsingException.php +++ b/src/McpServer/Prompt/Exception/PromptParsingException.php @@ -7,7 +7,7 @@ /** * Exception thrown when a prompt configuration cannot be parsed. */ -final class PromptParsingException extends \RuntimeException +class PromptParsingException extends \RuntimeException { // No additional methods needed, this is just a specialized exception type } diff --git a/src/McpServer/Prompt/Exception/TemplateResolutionException.php b/src/McpServer/Prompt/Exception/TemplateResolutionException.php new file mode 100644 index 00000000..d6438aff --- /dev/null +++ b/src/McpServer/Prompt/Exception/TemplateResolutionException.php @@ -0,0 +1,13 @@ +id, + prompt: $this->prompt, + messages: $messages, + type: $this->type, + extensions: $this->extensions, + ); + } + + public function jsonSerialize(): array + { + $schema = [ + 'properties' => [], + 'required' => [], + ]; + + foreach ($this->prompt->arguments as $argument) { + $schema['properties'][$argument->name] = [ + 'description' => $argument->description, + ]; + + if ($argument->required) { + $schema['required'][] = $argument->name; + } + } + + return \array_filter([ + 'id' => $this->id, + 'type' => $this->type->value, + 'description' => $this->prompt->description, + 'schema' => $schema, + 'messages' => $this->messages, + 'extend' => $this->serializeExtensions(), + ], static fn($value) => $value !== null && $value !== []); + } + + /** + * Serializes the extensions for JSON output. + * + * @return array|null The serialized extensions or null if empty + */ + private function serializeExtensions(): ?array + { + if (empty($this->extensions)) { + return null; + } + + // Convert extensions to the format used in configuration + return \array_map(static function (PromptExtension $ext) { + $args = []; + foreach ($ext->arguments as $arg) { + $args[$arg->name] = $arg->value; + } + return ['id' => $ext->templateId, 'arguments' => $args]; + }, $this->extensions); + } +} diff --git a/src/McpServer/Prompt/Extension/PromptExtension.php b/src/McpServer/Prompt/Extension/PromptExtension.php new file mode 100644 index 00000000..6c305a92 --- /dev/null +++ b/src/McpServer/Prompt/Extension/PromptExtension.php @@ -0,0 +1,49 @@ + $config The extension configuration + * @return self The created PromptExtension + * @throws \InvalidArgumentException If the configuration is invalid + */ + public static function fromArray(array $config): self + { + if (empty($config['id']) || !\is_string($config['id'])) { + throw new \InvalidArgumentException('Extension must have a template ID'); + } + + $arguments = []; + if (isset($config['arguments']) && \is_array($config['arguments'])) { + foreach ($config['arguments'] as $name => $value) { + if (!\is_string($name) || !\is_string($value)) { + throw new \InvalidArgumentException( + \sprintf('Extension argument "%s" must have a string value', $name), + ); + } + + $arguments[] = new PromptExtensionArgument($name, $value); + } + } + + return new self($config['id'], $arguments); + } +} diff --git a/src/McpServer/Prompt/Extension/PromptExtensionArgument.php b/src/McpServer/Prompt/Extension/PromptExtensionArgument.php new file mode 100644 index 00000000..febdf9af --- /dev/null +++ b/src/McpServer/Prompt/Extension/PromptExtensionArgument.php @@ -0,0 +1,16 @@ + The variables from extension arguments + */ + private array $variables; + + /** + * @param PromptExtensionArgument[] $arguments The extension arguments + */ + public function __construct(array $arguments) + { + $this->variables = $this->createVariablesFromArguments($arguments); + } + + public function has(string $name): bool + { + return \array_key_exists($name, $this->variables); + } + + public function get(string $name): ?string + { + return $this->variables[$name] ?? null; + } + + /** + * Creates a variables map from extension arguments. + * + * @param PromptExtensionArgument[] $arguments The extension arguments + * @return array The variables map + */ + private function createVariablesFromArguments(array $arguments): array + { + $variables = []; + + foreach ($arguments as $argument) { + $variables[$argument->name] = $argument->value; + } + + return $variables; + } +} diff --git a/src/McpServer/Prompt/Extension/TemplateResolver.php b/src/McpServer/Prompt/Extension/TemplateResolver.php new file mode 100644 index 00000000..5541abe4 --- /dev/null +++ b/src/McpServer/Prompt/Extension/TemplateResolver.php @@ -0,0 +1,146 @@ +extensions)) { + return $prompt; + } + + // Process each extension + $messages = $prompt->messages; + $processedExtensions = []; + + foreach ($prompt->extensions as $extension) { + // Prevent circular dependencies + if (\in_array($extension->templateId, $processedExtensions, true)) { + continue; + } + + $processedExtensions[] = $extension->templateId; + + // Get the template + try { + $template = $this->promptProvider->get($extension->templateId); + } catch (\InvalidArgumentException $e) { + throw new TemplateResolutionException( + \sprintf('Template "%s" not found', $extension->templateId), + previous: $e, + ); + } + + // Resolve nested templates first + if (!empty($template->extensions)) { + $template = $this->resolve($template); + } + + // Apply variable substitution + $messages = $this->mergeMessages( + $messages, + $template->messages, + $extension->arguments, + ); + } + + // Create a new prompt with the resolved messages + return new PromptDefinition( + id: $prompt->id, + prompt: $prompt->prompt, + messages: $messages, + type: $prompt->type, + extensions: $prompt->extensions, + ); + } + + /** + * Merges messages from a template with the prompt's messages, applying variable substitution. + * + * @param PromptMessage[] $promptMessages The prompt's messages + * @param PromptMessage[] $templateMessages The template's messages + * @param PromptExtensionArgument[] $arguments The variables to substitute + * @return PromptMessage[] The merged messages + */ + private function mergeMessages(array $promptMessages, array $templateMessages, array $arguments): array + { + // If the prompt has no messages, use the template's messages with substitution + if (empty($promptMessages)) { + return $this->substituteMessages($templateMessages, $arguments); + } + + foreach ($this->substituteMessages($templateMessages, $arguments) as $templateMessage) { + $promptMessages[] = $templateMessage; + } + + // Otherwise, keep the prompt's messages (extensions just provide structure) + return $promptMessages; + } + + /** + * Applies variable substitution to template messages. + * + * @param PromptMessage[] $messages The messages to process + * @param PromptExtensionArgument[] $arguments The variables to substitute + * @return PromptMessage[] The processed messages + */ + private function substituteMessages(array $messages, array $arguments): array + { + $result = []; + + // Create a variable provider for the extension arguments + $variableProvider = new PromptExtensionVariableProvider($arguments); + + // Create a resolver with the variable provider + $resolver = $this->variableResolver->with(new VariableReplacementProcessor($variableProvider, $this->logger)); + + foreach ($messages as $message) { + \assert($message->content instanceof TextContent); + $content = $message->content->text; + $substitutedContent = $resolver->resolve($content); + + $this->logger?->debug('Template message processed', [ + 'original' => $content, + 'resolved' => $substitutedContent, + ]); + + // Create a new message with the substituted content + $result[] = new PromptMessage( + role: $message->role, + content: new TextContent(text: $substitutedContent), + ); + } + + return $result; + } +} diff --git a/src/McpServer/Prompt/PromptConfigFactory.php b/src/McpServer/Prompt/PromptConfigFactory.php index e6eab867..21e0bb84 100644 --- a/src/McpServer/Prompt/PromptConfigFactory.php +++ b/src/McpServer/Prompt/PromptConfigFactory.php @@ -5,6 +5,8 @@ namespace Butschster\ContextGenerator\McpServer\Prompt; use Butschster\ContextGenerator\McpServer\Prompt\Exception\PromptParsingException; +use Butschster\ContextGenerator\McpServer\Prompt\Extension\PromptDefinition; +use Butschster\ContextGenerator\McpServer\Prompt\Extension\PromptExtension; use Mcp\Types\Prompt; use Mcp\Types\PromptArgument; use Mcp\Types\PromptMessage; @@ -40,6 +42,15 @@ public function createFromConfig(array $config): PromptDefinition $messages = $this->parseMessages($config['messages']); } + // Determine prompt type + $type = PromptType::fromString($config['type'] ?? null); + + // Parse extensions if provided + $extensions = []; + if (isset($config['extend']) && \is_array($config['extend'])) { + $extensions = $this->parseExtensions($config['extend']); + } + return new PromptDefinition( id: $config['id'], prompt: new Prompt( @@ -48,9 +59,49 @@ public function createFromConfig(array $config): PromptDefinition arguments: $arguments, ), messages: $messages, + type: $type, + extensions: $extensions, ); } + /** + * Parses extension configurations. + * + * @param array $extensionConfigs The extension configurations + * @return array The parsed extensions + * @throws PromptParsingException If the extension configuration is invalid + */ + private function parseExtensions(array $extensionConfigs): array + { + $extensions = []; + + foreach ($extensionConfigs as $index => $extensionConfig) { + if (!\is_array($extensionConfig)) { + throw new PromptParsingException( + \sprintf( + 'Extension at index %d must be an array', + $index, + ), + ); + } + + try { + $extensions[] = PromptExtension::fromArray($extensionConfig); + } catch (\InvalidArgumentException $e) { + throw new PromptParsingException( + \sprintf( + 'Invalid extension at index %d: %s', + $index, + $e->getMessage(), + ), + previous: $e, + ); + } + } + + return $extensions; + } + /** * Creates PromptArgument objects from a JSON schema. * diff --git a/src/McpServer/Prompt/PromptDefinition.php b/src/McpServer/Prompt/PromptDefinition.php deleted file mode 100644 index adc36467..00000000 --- a/src/McpServer/Prompt/PromptDefinition.php +++ /dev/null @@ -1,43 +0,0 @@ - [], - 'required' => [], - ]; - - foreach ($this->prompt->arguments as $argument) { - $schema['properties'][$argument->name] = [ - 'description' => $argument->description, - ]; - - if ($argument->required) { - $schema['required'][] = $argument->name; - } - } - - return \array_filter([ - 'id' => $this->id, - 'description' => $this->prompt->description, - 'schema' => $schema, - 'messages' => $this->messages, - ], static fn($value) => $value !== null && $value !== []); - } -} diff --git a/src/McpServer/Prompt/PromptMessageProcessor.php b/src/McpServer/Prompt/PromptMessageProcessor.php new file mode 100644 index 00000000..8ec659bc --- /dev/null +++ b/src/McpServer/Prompt/PromptMessageProcessor.php @@ -0,0 +1,41 @@ +variables; + + return $prompt->withMessages(\array_map(static function ($message) use ($variables, $arguments) { + $content = $message->content; + + if ($content instanceof TextContent) { + $text = $variables->with( + new VariableReplacementProcessor(new ConfigVariableProvider($arguments)), + )->resolve($content->text); + + $content = new TextContent($text); + } + + return new PromptMessage( + role: $message->role, + content: $content, + ); + }, $prompt->messages)); + } +} diff --git a/src/McpServer/Prompt/PromptParserPlugin.php b/src/McpServer/Prompt/PromptParserPlugin.php index 155cd511..c7d2d1ca 100644 --- a/src/McpServer/Prompt/PromptParserPlugin.php +++ b/src/McpServer/Prompt/PromptParserPlugin.php @@ -4,18 +4,23 @@ namespace Butschster\ContextGenerator\McpServer\Prompt; +use Butschster\ContextGenerator\Application\Logger\LoggerPrefix; use Butschster\ContextGenerator\Config\Parser\ConfigParserPluginInterface; use Butschster\ContextGenerator\Config\Registry\RegistryInterface; use Butschster\ContextGenerator\McpServer\Prompt\Exception\PromptParsingException; +use Butschster\ContextGenerator\McpServer\Prompt\Exception\TemplateResolutionException; +use Butschster\ContextGenerator\McpServer\Prompt\Extension\TemplateResolver; use Psr\Log\LoggerInterface; /** * Plugin for parsing 'prompts' section in configuration files. */ +#[LoggerPrefix(prefix: 'prompt.parser')] final readonly class PromptParserPlugin implements ConfigParserPluginInterface { public function __construct( private PromptRegistryInterface $promptRegistry, + private TemplateResolver $templateResolver, private PromptConfigFactory $promptFactory = new PromptConfigFactory(), private ?LoggerInterface $logger = null, ) {} @@ -37,6 +42,7 @@ public function parse(array $config, string $rootPath): ?RegistryInterface 'count' => \count($config['prompts']), ]); + // First pass: Register all prompts and templates foreach ($config['prompts'] as $index => $promptConfig) { try { $prompt = $this->promptFactory->createFromConfig($promptConfig); @@ -44,6 +50,7 @@ public function parse(array $config, string $rootPath): ?RegistryInterface $this->logger?->debug('Prompt parsed and registered', [ 'id' => $prompt->id, + 'type' => $prompt->type->value, ]); } catch (\Throwable $e) { $this->logger?->warning('Failed to parse prompt', [ @@ -58,6 +65,32 @@ public function parse(array $config, string $rootPath): ?RegistryInterface } } + // Second pass: Resolve templates for non-template prompts + $resolver = $this->templateResolver; + + foreach ($this->promptRegistry->getItems() as $id => $prompt) { + if (!empty($prompt->extensions)) { + try { + $resolvedPrompt = $resolver->resolve($prompt); + $this->promptRegistry->register($resolvedPrompt); + + $this->logger?->debug('Prompt template resolved', [ + 'id' => $resolvedPrompt->id, + ]); + } catch (TemplateResolutionException $e) { + $this->logger?->warning('Failed to resolve prompt template', [ + 'id' => $id, + 'error' => $e->getMessage(), + ]); + + throw new PromptParsingException( + \sprintf('Failed to resolve template for prompt "%s": %s', $id, $e->getMessage()), + previous: $e, + ); + } + } + } + return $this->promptRegistry; } diff --git a/src/McpServer/Prompt/PromptProviderInterface.php b/src/McpServer/Prompt/PromptProviderInterface.php index 523ec3b1..c2a27ab7 100644 --- a/src/McpServer/Prompt/PromptProviderInterface.php +++ b/src/McpServer/Prompt/PromptProviderInterface.php @@ -4,6 +4,8 @@ namespace Butschster\ContextGenerator\McpServer\Prompt; +use Butschster\ContextGenerator\McpServer\Prompt\Extension\PromptDefinition; + interface PromptProviderInterface { /** @@ -19,7 +21,7 @@ public function has(string $name): bool; * @param string $name The name of the prompt * @throws \InvalidArgumentException If no prompt with the given name exists */ - public function get(string $name): PromptDefinition; + public function get(string $name, array $arguments = []): PromptDefinition; /** * Gets all prompts. @@ -27,4 +29,11 @@ public function get(string $name): PromptDefinition; * @return array */ public function all(): array; + + /** + * Gets all non-template prompts. + * + * @return array + */ + public function allTemplates(): array; } diff --git a/src/McpServer/Prompt/PromptRegistry.php b/src/McpServer/Prompt/PromptRegistry.php index e9af4ce2..627e9812 100644 --- a/src/McpServer/Prompt/PromptRegistry.php +++ b/src/McpServer/Prompt/PromptRegistry.php @@ -5,6 +5,7 @@ namespace Butschster\ContextGenerator\McpServer\Prompt; use Butschster\ContextGenerator\Config\Registry\RegistryInterface; +use Butschster\ContextGenerator\McpServer\Prompt\Extension\PromptDefinition; use Spiral\Core\Attribute\Singleton; /** @@ -18,6 +19,10 @@ final class PromptRegistry implements RegistryInterface, PromptProviderInterface /** @var array */ private array $prompts = []; + public function __construct( + private readonly PromptMessageProcessor $promptMessageProcessor, + ) {} + public function register(PromptDefinition $prompt): void { /** @@ -26,7 +31,7 @@ public function register(PromptDefinition $prompt): void $this->prompts[$prompt->id] = $prompt; } - public function get(string $name): PromptDefinition + public function get(string $name, array $arguments = []): PromptDefinition { if (!$this->has($name)) { throw new \InvalidArgumentException( @@ -37,7 +42,7 @@ public function get(string $name): PromptDefinition ); } - return $this->prompts[$name]; + return $this->promptMessageProcessor->process($this->prompts[$name], $arguments); } public function has(string $name): bool @@ -50,28 +55,34 @@ public function all(): array return $this->prompts; } - /** - * Gets the type of the registry. - */ + public function allTemplates(): array + { + return \array_filter( + $this->prompts, + static fn(PromptDefinition $prompt) => $prompt->type === PromptType::Template, + ); + } + public function getType(): string { return 'prompts'; } - /** - * Gets all items in the registry. - * - * @return array - */ public function getItems(): array { - return \array_values($this->prompts); + return \array_values( + \array_filter( + $this->prompts, + static fn(PromptDefinition $prompt) => $prompt->type === PromptType::Prompt, + ), + ); } public function jsonSerialize(): array { + // Only serialize regular prompts, not templates return [ - 'prompts' => \array_values($this->prompts), + 'prompts' => $this->getItems(), ]; } diff --git a/src/McpServer/Prompt/PromptRegistryInterface.php b/src/McpServer/Prompt/PromptRegistryInterface.php index 50492c03..0ae3aa19 100644 --- a/src/McpServer/Prompt/PromptRegistryInterface.php +++ b/src/McpServer/Prompt/PromptRegistryInterface.php @@ -4,12 +4,12 @@ namespace Butschster\ContextGenerator\McpServer\Prompt; +use Butschster\ContextGenerator\McpServer\Prompt\Extension\PromptDefinition; + interface PromptRegistryInterface { /** * Registers a prompt in the registry. - * - * @throws \InvalidArgumentException If a prompt with the same name already exists */ public function register(PromptDefinition $prompt): void; } diff --git a/src/McpServer/Prompt/PromptType.php b/src/McpServer/Prompt/PromptType.php new file mode 100644 index 00000000..d6b7ee7b --- /dev/null +++ b/src/McpServer/Prompt/PromptType.php @@ -0,0 +1,29 @@ + self::Template, + default => self::Prompt, + }; + } +} diff --git a/src/McpServer/Tool/ToolProviderInterface.php b/src/McpServer/Tool/ToolProviderInterface.php index bc9bfffd..8dab5268 100644 --- a/src/McpServer/Tool/ToolProviderInterface.php +++ b/src/McpServer/Tool/ToolProviderInterface.php @@ -23,7 +23,7 @@ public function get(string $id): ToolDefinition; /** * Gets all tools. * - * @return array + * @return list */ public function all(): array; } diff --git a/src/McpServer/Tool/ToolRegistry.php b/src/McpServer/Tool/ToolRegistry.php index ec377222..b4fc6627 100644 --- a/src/McpServer/Tool/ToolRegistry.php +++ b/src/McpServer/Tool/ToolRegistry.php @@ -48,19 +48,11 @@ public function all(): array return $this->getItems(); } - /** - * Gets the type of the registry. - */ public function getType(): string { return 'tools'; } - /** - * Gets all items in the registry. - * - * @return array - */ public function getItems(): array { return \array_values($this->tools); diff --git a/src/McpServer/Tool/Types/HttpToolHandler.php b/src/McpServer/Tool/Types/HttpToolHandler.php index 9570ed8f..e9a984ec 100644 --- a/src/McpServer/Tool/Types/HttpToolHandler.php +++ b/src/McpServer/Tool/Types/HttpToolHandler.php @@ -9,6 +9,7 @@ use Butschster\ContextGenerator\Lib\HttpClient\HttpClientInterface; use Butschster\ContextGenerator\Lib\HttpClient\HttpResponse; use Butschster\ContextGenerator\Lib\Variable\VariableReplacementProcessor; +use Butschster\ContextGenerator\Lib\Variable\VariableResolver; use Butschster\ContextGenerator\McpServer\Tool\Config\HttpToolRequest; use Butschster\ContextGenerator\McpServer\Tool\Config\ToolDefinition; use Butschster\ContextGenerator\McpServer\Tool\Exception\ToolExecutionException; @@ -20,6 +21,7 @@ { public function __construct( private HttpClientInterface $httpClient, + private VariableResolver $variables, ?LoggerInterface $logger = null, ) { parent::__construct($logger); @@ -113,17 +115,17 @@ private function processRequestWithArguments( $argsProvider = new ToolArgumentsProvider($arguments, $tool->schema); // Create a processor for variable replacement - $processor = new VariableReplacementProcessor($argsProvider); + $variables = $this->variables->with(new VariableReplacementProcessor($argsProvider)); // Process URL if (isset($requestConfig['url'])) { - $requestConfig['url'] = $processor->process($requestConfig['url']); + $requestConfig['url'] = $variables->resolve($requestConfig['url']); } // Process headers if (isset($requestConfig['headers']) && \is_array($requestConfig['headers'])) { foreach ($requestConfig['headers'] as $key => $value) { - $requestConfig['headers'][$key] = $processor->process($value); + $requestConfig['headers'][$key] = $variables->resolve($value); } } @@ -136,14 +138,14 @@ private function processRequestWithArguments( // Process query parameters if (isset($requestConfig['query']) && \is_array($requestConfig['query'])) { foreach ($requestConfig['query'] as $key => $value) { - $data[$key] = $processor->process($value); + $data[$key] = $variables->resolve($value); } } // Process query parameters if (isset($requestConfig['body']) && \is_array($requestConfig['body'])) { foreach ($requestConfig['body'] as $key => $value) { - $data[$key] = $processor->process($value); + $data[$key] = $variables->resolve($value); } } diff --git a/tests/docs/feature-test-guidelines.md b/tests/docs/feature-test-guidelines.md new file mode 100644 index 00000000..3cd0e54e --- /dev/null +++ b/tests/docs/feature-test-guidelines.md @@ -0,0 +1,238 @@ +# Guidelines for Using FeatureTestCases to Write Feature Tests + +This document provides a comprehensive guide on how to use the `FeatureTestCases` abstract class to create feature tests for the Context Generator. + +## 1. Basic Structure + +The `FeatureTestCases` abstract class provides a foundation for creating feature tests that validate configuration loading and processing. To write a new feature test, extend this class and implement the required methods: + +```php +getFixturesDir('Path/to/your/config.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // Your assertions here + } +} +``` + +## 2. Creating Test Fixtures + +Test fixtures are the configuration files your tests will load and validate: + +- Place your test fixture YAML files in the `tests/fixtures/` directory +- Organize fixtures by feature (e.g., `tests/fixtures/Prompts/your_test_case.yaml`) +- Use the JSON schema from the project to ensure your YAML is valid +- Include a variety of configurations to test different edge cases + +Example YAML fixture: + +```yaml +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +variables: + test_var: Test Value + +prompts: + - id: test-prompt + description: A test prompt + messages: + - role: user + content: This is a test prompt with {{test_var}}. +``` + +## 3. Implementing Required Methods + +### a. Define `getConfigPath()` + +This method should return the path to your test configuration file: + +```php +protected function getConfigPath(): string +{ + return $this->getFixturesDir('YourFeature/your_config.yaml'); +} +``` + +- Use `getFixturesDir()` to get the base fixtures directory +- Provide a relative path to your YAML file + +### b. Implement `assertConfigItems()` + +This is where your test assertions go. You'll receive: + +- A `DocumentCompiler` instance +- A `ConfigRegistryAccessor` instance which gives you access to the parsed configuration + +```php +protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void +{ + // Get items from the configuration + $prompts = $config->getPrompts(); + + // Assert that expected items exist + $this->assertTrue($prompts->has('test-prompt')); + + // Get a specific item and assert its properties + $prompt = $prompts->get('test-prompt'); + $this->assertSame('A test prompt', $prompt->prompt->description); + + // Assert message content + $this->assertCount(1, $prompt->messages); + $this->assertStringContainsString('Test Value', $prompt->messages[0]->content->text); +} +``` + +## 4. Additional Testing Capabilities + +### Testing Error Cases + +If you expect your configuration to throw an exception during parsing, override the `compile()` method: + +```php +#[Test] +public function compile(): void +{ + $this->expectException(PromptParsingException::class); + $this->expectExceptionMessage('expected error message'); + + parent::compile(); +} +``` + +### Creating Temporary Files for Sub-tests + +For testing specific error cases that would prevent the entire configuration from loading: + +```php +#[Test] +public function testSpecificErrorCase(): void +{ + $configYaml = <<createTempFile($configYaml, '.yaml'); + + $this->expectException(ExpectedException::class); + + // Test code that would load this configuration +} +``` + +## 5. Best Practices for Feature Tests + +1. **Test One Aspect Per Class**: Create separate test classes for different aspects (basic functionality, error handling, edge cases) + +2. **Comprehensive Assertions**: Don't just check that a configuration loads; validate all important properties + +3. **Test Edge Cases**: Include tests for: + - Minimal configurations (only required fields) + - Complex configurations (all possible fields) + - Error cases (invalid configurations) + - Boundary conditions + +4. **Readable Test Names**: Use descriptive test class and method names + +5. **Clear Fixtures**: Add comments in your YAML fixtures to explain what you're testing + +6. **Isolated Tests**: Each test should be independent and not rely on state from other tests + +## 6. Example Test Cases + +Here are examples of different types of feature tests: + +### Basic Functionality Test + +Tests that valid configurations are correctly parsed: + +```php +final class BasicPromptTest extends FeatureTestCases +{ + protected function getConfigPath(): string + { + return $this->getFixturesDir('Prompts/basic_prompts.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + $prompt = $config->getPrompts()->get('test-prompt'); + $this->assertSame('test-prompt', $prompt->id); + // More assertions... + } +} +``` + +### Error Case Test + +Tests that invalid configurations throw the expected exceptions: + +```php +final class ErrorPromptTest extends FeatureTestCases +{ + protected function getConfigPath(): string + { + return $this->getFixturesDir('Prompts/error_prompts.yaml'); + } + + #[Test] + public function compile(): void + { + $this->expectException(PromptParsingException::class); + parent::compile(); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // This should not be reached due to the exception + } +} +``` + +## 7. Common Assertions for Prompt Tests + +When testing prompts specifically, here are common assertions: + +```php +// Test basic prompt properties +$this->assertSame('prompt-id', $prompt->id); +$this->assertSame('Description', $prompt->prompt->description); +$this->assertSame(PromptType::Prompt, $prompt->type); + +// Test message content +$this->assertCount(2, $prompt->messages); +$this->assertSame(Role::User, $prompt->messages[0]->role); +$this->assertSame('Expected message text', $prompt->messages[0]->content->text); + +// Test schema/arguments +$this->assertCount(2, $prompt->prompt->arguments); +$foundArg = null; +foreach ($prompt->prompt->arguments as $arg) { + if ($arg->name === 'expected-arg') { + $foundArg = $arg; + break; + } +} +$this->assertNotNull($foundArg); +$this->assertTrue($foundArg->required); + +// Test variable replacement +$this->assertStringContainsString('replaced value', $prompt->messages[0]->content->text); +``` diff --git a/tests/fixtures/McpServer/Prompts/basic_prompts.yaml b/tests/fixtures/McpServer/Prompts/basic_prompts.yaml new file mode 100644 index 00000000..7027ba90 --- /dev/null +++ b/tests/fixtures/McpServer/Prompts/basic_prompts.yaml @@ -0,0 +1,32 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +variables: + name: Basic Prompt Test + +prompts: + # Simple prompt with minimal configuration + - id: minimal-prompt + description: A minimal prompt with just the required fields + messages: + - role: user + content: You are a helpful assistant. + + # Prompt with multiple messages + - id: multi-message-prompt + description: A prompt with multiple messages + messages: + - role: user + content: You are a helpful assistant. + - role: assistant + content: I'm here to help you with your tasks. + - role: user + content: Tell me about context generation. + + # Prompt with variable substitution + - id: variable-prompt + description: A prompt with variable substitution + messages: + - role: user + content: You are a helpful assistant for {{name}}. + - role: assistant + content: I'm here to help with {{name}} tasks. diff --git a/tests/fixtures/McpServer/Prompts/json_serialization.yaml b/tests/fixtures/McpServer/Prompts/json_serialization.yaml new file mode 100644 index 00000000..a46fc5ba --- /dev/null +++ b/tests/fixtures/McpServer/Prompts/json_serialization.yaml @@ -0,0 +1,41 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +variables: + name: Serialization Test + +prompts: + # Test serialization of a complete prompt with all fields + - id: complete-prompt + description: A complete prompt with all possible fields + type: prompt + schema: + properties: + language: + description: Programming language + type: string + framework: + description: Framework to use + type: string + required: + - language + messages: + - role: user + content: You are a helpful assistant for {{language}} programming. + - role: assistant + content: I'll help with {{language}} and {{framework}} development. + + # Template with minimal fields + - id: minimal-template + type: template + description: A minimal template + messages: + - role: user + content: This is a minimal template. + + # Prompt with extensions but no messages + - id: extension-only-prompt + description: A prompt with extensions but no messages + extend: + - id: minimal-template + arguments: + key: value diff --git a/tests/fixtures/McpServer/Prompts/prompt_schema.yaml b/tests/fixtures/McpServer/Prompts/prompt_schema.yaml new file mode 100644 index 00000000..f937e8c3 --- /dev/null +++ b/tests/fixtures/McpServer/Prompts/prompt_schema.yaml @@ -0,0 +1,48 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +variables: + name: Schema Test + +prompts: + # Simple prompt with schema + - id: schema-prompt + description: A prompt with schema definition + schema: + properties: + language: + description: Programming language to use + type: string + experience: + description: User experience level + type: string + required: + - language + messages: + - role: user + content: You are a helpful assistant for {{language}} programming. + - role: assistant + content: I'll help you with {{language}} based on your {{experience}} level. + + # More complex schema + - id: complex-schema-prompt + description: A prompt with complex schema definition + schema: + properties: + project_name: + description: Name of the project + type: string + framework: + description: Framework to use + type: string + features: + description: Features to include + type: array + database: + description: Database configuration + type: object + required: + - project_name + - framework + messages: + - role: user + content: Create a new project named {{project_name}} using {{framework}}. diff --git a/tests/fixtures/McpServer/Prompts/registry_operations.yaml b/tests/fixtures/McpServer/Prompts/registry_operations.yaml new file mode 100644 index 00000000..255230b5 --- /dev/null +++ b/tests/fixtures/McpServer/Prompts/registry_operations.yaml @@ -0,0 +1,41 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +variables: + name: Registry Operations Test + +prompts: + # Regular prompts + - id: prompt-one + description: First regular prompt + messages: + - role: user + content: First prompt content + + - id: prompt-two + description: Second regular prompt + messages: + - role: user + content: Second prompt content + + # Templates + - id: template-one + type: template + description: First template + messages: + - role: user + content: First template content + + - id: template-two + type: template + description: Second template + messages: + - role: user + content: Second template content + + # Extended prompt + - id: extended-prompt + description: Prompt that extends a template + extend: + - id: template-one + arguments: + key: value diff --git a/tests/fixtures/McpServer/Prompts/templates.yaml b/tests/fixtures/McpServer/Prompts/templates.yaml new file mode 100644 index 00000000..c6b0cddb --- /dev/null +++ b/tests/fixtures/McpServer/Prompts/templates.yaml @@ -0,0 +1,44 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +variables: + name: Template Test + +prompts: + # Define a template + - id: base-template + type: template + description: A base template for other prompts + messages: + - role: user + content: You are a helpful assistant for {{context}}. + - role: assistant + content: I'm here to help with {{context}} tasks. + + # Another template + - id: greeting-template + type: template + description: A template with greeting + messages: + - role: user + content: Hello, I'm {{name}}. X + - role: assistant + content: Nice to meet you, {{name}}! How can I help you today? + + # Prompt extending a single template + - id: extended-prompt + description: A prompt extending a template + extend: + - id: base-template + arguments: + context: PHP development + + # Prompt extending multiple templates + - id: multi-extended-prompt + description: A prompt extending multiple templates + extend: + - id: base-template + arguments: + context: PHP development + - id: greeting-template + arguments: + name: Developer diff --git a/tests/fixtures/McpServer/Tool/basic_tools.yaml b/tests/fixtures/McpServer/Tool/basic_tools.yaml new file mode 100644 index 00000000..30179cc2 --- /dev/null +++ b/tests/fixtures/McpServer/Tool/basic_tools.yaml @@ -0,0 +1,15 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +tools: + - id: test-command-tool + description: A test command tool + type: run + commands: + - cmd: echo + args: + - Hello, World! + schema: + properties: + message: + type: string + description: Message to echo diff --git a/tests/fixtures/McpServer/Tool/error_tools.yaml b/tests/fixtures/McpServer/Tool/error_tools.yaml new file mode 100644 index 00000000..bb309531 --- /dev/null +++ b/tests/fixtures/McpServer/Tool/error_tools.yaml @@ -0,0 +1,28 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +tools: + - id: missing-required-field + # Missing description field (required) + type: run + commands: + - cmd: echo + args: + - Hello, World! + + - id: invalid-type-tool + description: A tool with invalid type + type: invalid-type + commands: + - cmd: echo + args: + - Test + + - id: run-tool-without-commands + description: A run-type tool without commands + type: run + # Missing commands array + + - id: http-tool-without-requests + description: A HTTP tool without requests + type: http + # Missing requests array diff --git a/tests/fixtures/McpServer/Tool/http_tools.yaml b/tests/fixtures/McpServer/Tool/http_tools.yaml new file mode 100644 index 00000000..1f94b2f2 --- /dev/null +++ b/tests/fixtures/McpServer/Tool/http_tools.yaml @@ -0,0 +1,36 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +tools: + - id: http-get-tool + description: A HTTP GET tool + type: http + requests: + - url: 'https://example.com/api/{{endpoint}}' + method: GET + headers: + Content-Type: application/json + Authorization: 'Bearer {{token}}' + schema: + properties: + endpoint: + type: string + description: API endpoint + token: + type: string + description: Auth token + + - id: http-post-tool + description: A HTTP POST tool + type: http + requests: + - url: 'https://example.com/api/submit' + method: POST + headers: + Content-Type: application/json + body: + data: '{{data}}' + schema: + properties: + data: + type: string + description: Data to submit diff --git a/tests/fixtures/McpServer/Tool/run_tools.yaml b/tests/fixtures/McpServer/Tool/run_tools.yaml new file mode 100644 index 00000000..5d139426 --- /dev/null +++ b/tests/fixtures/McpServer/Tool/run_tools.yaml @@ -0,0 +1,21 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +tools: + - id: variable-command-tool + description: A tool with variable arguments + type: run + commands: + - cmd: echo + args: + - '{{message}}' + - name: '--optional-arg' + when: '{{use_optional}}' + schema: + properties: + message: + type: string + description: Message to echo + use_optional: + type: boolean + description: Whether to use the optional argument + default: false diff --git a/tests/fixtures/Source/File/basic_file_source.yaml b/tests/fixtures/Source/File/basic_file_source.yaml new file mode 100644 index 00000000..d506ed0a --- /dev/null +++ b/tests/fixtures/Source/File/basic_file_source.yaml @@ -0,0 +1,13 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +documents: + - description: File Source Test Document + outputPath: file_source_test.md + overwrite: true + sources: + - type: file + description: Basic file source test + sourcePaths: test_files + filePattern: '*.txt' + treeView: + enabled: true diff --git a/tests/fixtures/Source/File/filtered_file_source.yaml b/tests/fixtures/Source/File/filtered_file_source.yaml new file mode 100644 index 00000000..7253aefa --- /dev/null +++ b/tests/fixtures/Source/File/filtered_file_source.yaml @@ -0,0 +1,16 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +documents: + - description: Filtered File Source Test + outputPath: filtered_file_source_test.md + overwrite: true + sources: + - type: file + description: File source with filters + sourcePaths: test_files + filePattern: '*.txt' + path: 'test_directory' + notPath: ['**/unwanted*.txt'] + contains: 'nested file' + treeView: + enabled: true diff --git a/tests/fixtures/Source/File/invalid_file_source.yaml b/tests/fixtures/Source/File/invalid_file_source.yaml new file mode 100644 index 00000000..72453a28 --- /dev/null +++ b/tests/fixtures/Source/File/invalid_file_source.yaml @@ -0,0 +1,11 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +documents: + - description: Invalid File Source Test + outputPath: invalid_file_source_test.md + overwrite: true + sources: + - type: file + description: File source with invalid paths + sourcePaths: non_existent_directory + filePattern: '*.txt' diff --git a/tests/fixtures/Source/File/test_files/test_directory/nested_file.txt b/tests/fixtures/Source/File/test_files/test_directory/nested_file.txt new file mode 100644 index 00000000..8884094d --- /dev/null +++ b/tests/fixtures/Source/File/test_files/test_directory/nested_file.txt @@ -0,0 +1 @@ +This is nested file content for testing directory traversal. \ No newline at end of file diff --git a/tests/fixtures/Source/File/test_files/test_file.txt b/tests/fixtures/Source/File/test_files/test_file.txt new file mode 100644 index 00000000..ecfb13c1 --- /dev/null +++ b/tests/fixtures/Source/File/test_files/test_file.txt @@ -0,0 +1 @@ +This is test content for the file source test. \ No newline at end of file diff --git a/tests/fixtures/Source/File/tree_view_file_source.yaml b/tests/fixtures/Source/File/tree_view_file_source.yaml new file mode 100644 index 00000000..cf051e6a --- /dev/null +++ b/tests/fixtures/Source/File/tree_view_file_source.yaml @@ -0,0 +1,18 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +documents: + - description: Tree View File Source Test + outputPath: tree_view_file_source_test.md + overwrite: true + sources: + - type: file + description: File source with custom tree view options + sourcePaths: test_files + filePattern: '*.txt' + treeView: + enabled: true + showSize: true + showLastModified: true + showCharCount: true + includeFiles: true + maxDepth: 2 diff --git a/tests/src/Feature/Compiler/AbstractCompilerTestCase.php b/tests/src/Feature/Compiler/AbstractCompilerTestCase.php index e81e1d90..7163d12f 100644 --- a/tests/src/Feature/Compiler/AbstractCompilerTestCase.php +++ b/tests/src/Feature/Compiler/AbstractCompilerTestCase.php @@ -4,59 +4,17 @@ namespace Tests\Feature\Compiler; -use Butschster\ContextGenerator\Application\AppScope; -use Butschster\ContextGenerator\Application\FSPath; -use Butschster\ContextGenerator\Config\ConfigurationProvider; use Butschster\ContextGenerator\Config\Registry\ConfigRegistryAccessor; use Butschster\ContextGenerator\Document\Compiler\DocumentCompiler; -use PHPUnit\Framework\Attributes\Test; -use Spiral\Core\Scope; -use Spiral\Files\Files; -use Tests\AppTestCase; +use Tests\Feature\FeatureTestCases; -abstract class AbstractCompilerTestCase extends AppTestCase +abstract class AbstractCompilerTestCase extends FeatureTestCases { - #[\Override] - public function rootDirectory(): string + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void { - return $this->getFixturesDir('Compiler'); - } - - #[Test] - public function compile(): void - { - $this->getContainer()->runScope( - bindings: new Scope( - name: AppScope::Compiler, - ), - scope: $this->compileDocuments(...), - ); - } - - #[\Override] - protected function tearDown(): void - { - parent::tearDown(); - - $files = new Files(); - $files->deleteDirectory($this->getContextsDir()); - } - - protected function getContextsDir(string $path = ''): string - { - return (string) FSPath::create($this->getFixturesDir('Compiler/.context'))->join($path); - } - - abstract protected function getConfigPath(): string; - - private function compileDocuments(DocumentCompiler $compiler, ConfigurationProvider $configProvider): void - { - $loader = $configProvider->fromPath($this->getConfigPath()); - $outputPaths = []; $results = []; - $config = new ConfigRegistryAccessor($loader->load()); foreach ($config->getDocuments() as $document) { $outputPaths[] = $this->getContextsDir($document->outputPath); $compiledDocument = $compiler->compile($document); diff --git a/tests/src/Feature/FeatureTestCases.php b/tests/src/Feature/FeatureTestCases.php new file mode 100644 index 00000000..5479fdeb --- /dev/null +++ b/tests/src/Feature/FeatureTestCases.php @@ -0,0 +1,65 @@ +getFixturesDir('Compiler'); + } + + #[Test] + public function compile(): void + { + $this->getContainer()->runScope( + bindings: new Scope( + name: AppScope::Compiler, + ), + scope: $this->compileDocuments(...), + ); + } + + #[\Override] + protected function tearDown(): void + { + parent::tearDown(); + + if (\is_dir($this->getContextsDir())) { + $files = new Files(); + $files->deleteDirectory($this->getContextsDir()); + } + } + + protected function getContextsDir(string $path = ''): string + { + return (string) FSPath::create($this->getFixturesDir('Compiler/.context'))->join($path); + } + + abstract protected function getConfigPath(): string; + + abstract protected function assertConfigItems( + DocumentCompiler $compiler, + ConfigRegistryAccessor $config, + ): void; + + private function compileDocuments(DocumentCompiler $compiler, ConfigurationProvider $configProvider): void + { + $loader = $configProvider->fromPath($this->getConfigPath()); + + $this->assertConfigItems($compiler, new ConfigRegistryAccessor($loader->load())); + } +} diff --git a/tests/src/Feature/McpServer/Prompt/BasicPromptTest.php b/tests/src/Feature/McpServer/Prompt/BasicPromptTest.php new file mode 100644 index 00000000..61516556 --- /dev/null +++ b/tests/src/Feature/McpServer/Prompt/BasicPromptTest.php @@ -0,0 +1,54 @@ +getFixturesDir('McpServer/Prompts/basic_prompts.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // Test minimal prompt + $minimalPrompt = $config->getPrompts()->get('minimal-prompt'); + $this->assertInstanceOf(PromptDefinition::class, $minimalPrompt); + $this->assertSame('minimal-prompt', $minimalPrompt->id); + $this->assertSame('A minimal prompt with just the required fields', $minimalPrompt->prompt->description); + $this->assertCount(1, $minimalPrompt->messages); + $this->assertSame(Role::USER, $minimalPrompt->messages[0]->role); + $this->assertInstanceOf(TextContent::class, $minimalPrompt->messages[0]->content); + $this->assertSame('You are a helpful assistant.', $minimalPrompt->messages[0]->content->text); + + // Test multi-message prompt + $multiMessagePrompt = $config->getPrompts()->get('multi-message-prompt'); + $this->assertInstanceOf(PromptDefinition::class, $multiMessagePrompt); + $this->assertCount(3, $multiMessagePrompt->messages); + $this->assertSame(Role::USER, $multiMessagePrompt->messages[0]->role); + $this->assertSame(Role::ASSISTANT, $multiMessagePrompt->messages[1]->role); + $this->assertSame(Role::USER, $multiMessagePrompt->messages[2]->role); + $this->assertSame('Tell me about context generation.', $multiMessagePrompt->messages[2]->content->text); + + // Test variable prompt + $variablePrompt = $config->getPrompts()->get('variable-prompt'); + $this->assertInstanceOf(PromptDefinition::class, $variablePrompt); + $this->assertCount(2, $variablePrompt->messages); + + // Variables should already be resolved in the messages + $this->assertStringContainsString('Basic Prompt Test', $variablePrompt->messages[0]->content->text); + $this->assertStringContainsString('Basic Prompt Test', $variablePrompt->messages[1]->content->text); + } +} diff --git a/tests/src/Feature/McpServer/Prompt/JsonSerializationTest.php b/tests/src/Feature/McpServer/Prompt/JsonSerializationTest.php new file mode 100644 index 00000000..30799353 --- /dev/null +++ b/tests/src/Feature/McpServer/Prompt/JsonSerializationTest.php @@ -0,0 +1,88 @@ +getFixturesDir('McpServer/Prompts/json_serialization.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // Test serialization of complete prompt + $completePrompt = $config->getPrompts()->get('complete-prompt'); + $this->assertInstanceOf(PromptDefinition::class, $completePrompt); + + $serialized = \json_encode($completePrompt); + $this->assertNotFalse($serialized, 'JSON serialization failed'); + + $decoded = \json_decode($serialized, true); + $this->assertIsArray($decoded); + + // Check that all important fields are present in the serialized output + $this->assertArrayHasKey('id', $decoded); + $this->assertArrayHasKey('type', $decoded); + $this->assertArrayHasKey('description', $decoded); + $this->assertArrayHasKey('schema', $decoded); + $this->assertArrayHasKey('messages', $decoded); + + // Check schema structure + $this->assertArrayHasKey('properties', $decoded['schema']); + $this->assertArrayHasKey('required', $decoded['schema']); + + // Check that required field contains 'language' + $this->assertContains('language', $decoded['schema']['required']); + + // Test serialization of minimal template + $minimalTemplate = $config->getPrompts()->get('minimal-template'); + $serializedTemplate = \json_encode($minimalTemplate); + $this->assertNotFalse($serializedTemplate, 'Template JSON serialization failed'); + + $decodedTemplate = \json_decode($serializedTemplate, true); + $this->assertIsArray($decodedTemplate); + + // Check that type is set to 'template' + $this->assertSame('template', $decodedTemplate['type']); + + // Test serialization of extension-only prompt + $extensionPrompt = $config->getPrompts()->get('extension-only-prompt'); + $serializedExtension = \json_encode($extensionPrompt); + $this->assertNotFalse($serializedExtension, 'Extension prompt JSON serialization failed'); + + $decodedExtension = \json_decode($serializedExtension, true); + $this->assertIsArray($decodedExtension); + + // Check that extend field is present and properly formatted + $this->assertArrayHasKey('extend', $decodedExtension); + $this->assertIsArray($decodedExtension['extend']); + $this->assertCount(1, $decodedExtension['extend']); + $this->assertSame('minimal-template', $decodedExtension['extend'][0]['id']); + + // Test registry serialization + $registry = $config->getPrompts(); + $serializedRegistry = \json_encode($registry); + $this->assertNotFalse($serializedRegistry, 'Registry JSON serialization failed'); + + $decodedRegistry = \json_decode($serializedRegistry, true); + $this->assertIsArray($decodedRegistry); + $this->assertArrayHasKey('prompts', $decodedRegistry); + + // The registry should only serialize regular prompts, not templates + $promptsInSerialized = $decodedRegistry['prompts']; + foreach ($promptsInSerialized as $prompt) { + $this->assertNotEquals('template', $prompt['type'] ?? 'prompt'); + } + } +} diff --git a/tests/src/Feature/McpServer/Prompt/RegistryOperationsTest.php b/tests/src/Feature/McpServer/Prompt/RegistryOperationsTest.php new file mode 100644 index 00000000..cd23f5df --- /dev/null +++ b/tests/src/Feature/McpServer/Prompt/RegistryOperationsTest.php @@ -0,0 +1,82 @@ +getFixturesDir('McpServer/Prompts/registry_operations.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + $registry = $config->getPrompts(); + $this->assertInstanceOf(PromptRegistry::class, $registry); + + // Test has() method + $this->assertTrue($registry->has('prompt-one'), 'Registry should have prompt-one'); + $this->assertTrue($registry->has('template-one'), 'Registry should have template-one'); + $this->assertFalse($registry->has('non-existent-prompt'), 'Registry should not have non-existent-prompt'); + + // Test get() method + $promptOne = $registry->get('prompt-one'); + $this->assertInstanceOf(PromptDefinition::class, $promptOne); + $this->assertSame('prompt-one', $promptOne->id); + + $templateOne = $registry->get('template-one'); + $this->assertInstanceOf(PromptDefinition::class, $templateOne); + $this->assertSame('template-one', $templateOne->id); + + // Test get() with invalid prompt + $this->expectException(\InvalidArgumentException::class); + $registry->get('non-existent-prompt'); + + // Restore expectations for the rest of the test + $this->expectNotToPerformAssertions(); + + // Test all() method + $allPrompts = $registry->all(); + $this->assertCount(5, $allPrompts, 'Registry should have 5 total prompts/templates'); + + // Test allTemplates() method + $templates = $registry->allTemplates(); + $this->assertCount(2, $templates, 'Registry should have 2 templates'); + + foreach ($templates as $template) { + $this->assertSame(PromptType::Template, $template->type, 'Templates should have Template type'); + } + + // Test getItems() method (should return only regular prompts, not templates) + $items = $registry->getItems(); + $this->assertCount(3, $items, 'Registry should have 3 regular prompts'); + + foreach ($items as $item) { + $this->assertSame(PromptType::Prompt, $item->type, 'Items should have Prompt type'); + } + + // Test getType() method + $this->assertSame('prompts', $registry->getType(), 'Registry type should be "prompts"'); + + // Test getIterator() method + $count = 0; + foreach ($registry as $item) { + $this->assertInstanceOf(PromptDefinition::class, $item); + $this->assertSame(PromptType::Prompt, $item->type); + $count++; + } + $this->assertSame(3, $count, 'Iterator should yield 3 items'); + } +} diff --git a/tests/src/Feature/McpServer/Prompt/SchemaPromptTest.php b/tests/src/Feature/McpServer/Prompt/SchemaPromptTest.php new file mode 100644 index 00000000..c776b1b3 --- /dev/null +++ b/tests/src/Feature/McpServer/Prompt/SchemaPromptTest.php @@ -0,0 +1,88 @@ +getFixturesDir('McpServer/Prompts/prompt_schema.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // Test basic schema prompt + $schemaPrompt = $config->getPrompts()->get('schema-prompt'); + $this->assertInstanceOf(PromptDefinition::class, $schemaPrompt); + + // Verify schema properties + $this->assertCount(2, $schemaPrompt->prompt->arguments); + + // Check first argument - language + $languageArg = null; + $experienceArg = null; + + foreach ($schemaPrompt->prompt->arguments as $arg) { + if ($arg->name === 'language') { + $languageArg = $arg; + } elseif ($arg->name === 'experience') { + $experienceArg = $arg; + } + } + + $this->assertNotNull($languageArg, 'Language argument not found'); + $this->assertNotNull($experienceArg, 'Experience argument not found'); + + $this->assertSame('Programming language to use', $languageArg->description); + $this->assertTrue($languageArg->required, 'Language argument should be required'); + + $this->assertSame('User experience level', $experienceArg->description); + $this->assertFalse($experienceArg->required, 'Experience argument should not be required'); + + // Verify messages with variable placeholders + $this->assertCount(2, $schemaPrompt->messages); + $this->assertStringContainsString('{{language}}', $schemaPrompt->messages[0]->content->text); + $this->assertStringContainsString('{{experience}}', $schemaPrompt->messages[1]->content->text); + + // Test complex schema prompt + $complexSchemaPrompt = $config->getPrompts()->get('complex-schema-prompt'); + $this->assertInstanceOf(PromptDefinition::class, $complexSchemaPrompt); + + // Verify schema properties + $this->assertCount(4, $complexSchemaPrompt->prompt->arguments); + + // Check the required arguments + $requiredArgs = \array_filter( + $complexSchemaPrompt->prompt->arguments, + static fn($arg) => $arg->required, + ); + + $this->assertCount(2, $requiredArgs, 'Should have 2 required arguments'); + + // Verify that all expected argument names exist + $argNames = \array_map( + static fn($arg) => $arg->name, + $complexSchemaPrompt->prompt->arguments, + ); + + $this->assertContains('project_name', $argNames); + $this->assertContains('framework', $argNames); + $this->assertContains('features', $argNames); + $this->assertContains('database', $argNames); + + // Verify messages with variable placeholders + $this->assertCount(1, $complexSchemaPrompt->messages); + $this->assertStringContainsString('{{project_name}}', $complexSchemaPrompt->messages[0]->content->text); + $this->assertStringContainsString('{{framework}}', $complexSchemaPrompt->messages[0]->content->text); + } +} diff --git a/tests/src/Feature/McpServer/Prompt/TemplatePromptTest.php b/tests/src/Feature/McpServer/Prompt/TemplatePromptTest.php new file mode 100644 index 00000000..97b2ba28 --- /dev/null +++ b/tests/src/Feature/McpServer/Prompt/TemplatePromptTest.php @@ -0,0 +1,60 @@ +getFixturesDir('McpServer/Prompts/templates.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // Test base templates + $baseTemplate = $config->getPrompts()->get('base-template'); + $this->assertInstanceOf(PromptDefinition::class, $baseTemplate); + $this->assertSame(PromptType::Template, $baseTemplate->type); + $this->assertCount(2, $baseTemplate->messages); + $this->assertStringContainsString('{{context}}', $baseTemplate->messages[0]->content->text); + + $greetingTemplate = $config->getPrompts()->get('greeting-template'); + $this->assertInstanceOf(PromptDefinition::class, $greetingTemplate); + $this->assertSame(PromptType::Template, $greetingTemplate->type); + $this->assertCount(2, $greetingTemplate->messages); + $this->assertStringContainsString('Hello, I\'m Template Test.', $greetingTemplate->messages[0]->content->text); + + // Test extended prompt (single template) + $extendedPrompt = $config->getPrompts()->get('extended-prompt'); + $this->assertInstanceOf(PromptDefinition::class, $extendedPrompt); + $this->assertSame(PromptType::Prompt, $extendedPrompt->type); + $this->assertCount(1, $extendedPrompt->extensions); + $this->assertSame('base-template', $extendedPrompt->extensions[0]->templateId); + + // The messages should be resolved with variables replaced + $this->assertCount(2, $extendedPrompt->messages); + $this->assertStringContainsString('PHP development', $extendedPrompt->messages[0]->content->text); + $this->assertStringContainsString('PHP development', $extendedPrompt->messages[1]->content->text); + + // Test multi-extension prompt + $multiExtendedPrompt = $config->getPrompts()->get('multi-extended-prompt'); + $this->assertInstanceOf(PromptDefinition::class, $multiExtendedPrompt); + + // The second extension should take precedence for messages + $this->assertCount(4, $multiExtendedPrompt->messages); + // Check that variables from both templates were properly resolved + $this->assertStringContainsString('Developer', $multiExtendedPrompt->messages[2]->content->text); + $this->assertStringContainsString('Developer', $multiExtendedPrompt->messages[3]->content->text); + } +} diff --git a/tests/src/Feature/McpServer/Tool/BasicToolConfigTest.php b/tests/src/Feature/McpServer/Tool/BasicToolConfigTest.php new file mode 100644 index 00000000..c6579349 --- /dev/null +++ b/tests/src/Feature/McpServer/Tool/BasicToolConfigTest.php @@ -0,0 +1,48 @@ +getFixturesDir('McpServer/Tool/basic_tools.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // Verify tools registry exists + $tools = $config->getTools(); + $this->assertNotNull($tools); + + // Verify expected tools exist + $this->assertTrue($tools->has('test-command-tool')); + + // Verify tool properties + $tool = $tools->get('test-command-tool'); + $this->assertInstanceOf(ToolDefinition::class, $tool); + $this->assertSame('test-command-tool', $tool->id); + $this->assertSame('A test command tool', $tool->description); + $this->assertSame('run', $tool->type); + + // Verify commands + $this->assertCount(1, $tool->commands); + $command = $tool->commands[0]; + $this->assertSame('echo', $command->cmd); + $this->assertCount(1, $command->args); + $this->assertSame('Hello, World!', (string) $command->args[0]); + + // Verify schema + $this->assertNotNull($tool->schema); + $properties = $tool->schema->getProperties(); + $this->assertArrayHasKey('message', $properties); + $this->assertSame('string', $properties['message']['type']); + } +} diff --git a/tests/src/Feature/McpServer/Tool/HttpToolTest.php b/tests/src/Feature/McpServer/Tool/HttpToolTest.php new file mode 100644 index 00000000..22b2d5b9 --- /dev/null +++ b/tests/src/Feature/McpServer/Tool/HttpToolTest.php @@ -0,0 +1,87 @@ +getFixturesDir('McpServer/Tool/http_tools.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // Get the tools registry + $tools = $config->getTools(); + $this->assertNotNull($tools); + + // Verify both HTTP tools exist + $this->assertTrue($tools->has('http-get-tool')); + $this->assertTrue($tools->has('http-post-tool')); + + // Test GET tool configuration + $getTool = $tools->get('http-get-tool'); + $this->assertInstanceOf(ToolDefinition::class, $getTool); + $this->assertSame('http-get-tool', $getTool->id); + $this->assertSame('A HTTP GET tool', $getTool->description); + $this->assertSame('http', $getTool->type); + + // Verify request configuration is stored in extra + $this->assertArrayHasKey('requests', $getTool->extra); + $this->assertIsArray($getTool->extra['requests']); + $this->assertCount(1, $getTool->extra['requests']); + + // Verify request details + $getRequest = $getTool->extra['requests'][0]; + $this->assertSame('https://example.com/api/{{endpoint}}', $getRequest['url']); + $this->assertSame('GET', $getRequest['method']); + $this->assertArrayHasKey('headers', $getRequest); + $this->assertSame('application/json', $getRequest['headers']['Content-Type']); + $this->assertSame('Bearer {{token}}', $getRequest['headers']['Authorization']); + + // Verify schema properties + $this->assertNotNull($getTool->schema); + $getProperties = $getTool->schema->getProperties(); + $this->assertArrayHasKey('endpoint', $getProperties); + $this->assertArrayHasKey('token', $getProperties); + + // Test POST tool configuration + $postTool = $tools->get('http-post-tool'); + $this->assertInstanceOf(ToolDefinition::class, $postTool); + $this->assertSame('http-post-tool', $postTool->id); + $this->assertSame('A HTTP POST tool', $postTool->description); + $this->assertSame('http', $postTool->type); + + // Verify request configuration + $this->assertArrayHasKey('requests', $postTool->extra); + $this->assertIsArray($postTool->extra['requests']); + $this->assertCount(1, $postTool->extra['requests']); + + // Verify request details + $postRequest = $postTool->extra['requests'][0]; + $this->assertSame('https://example.com/api/submit', $postRequest['url']); + $this->assertSame('POST', $postRequest['method']); + $this->assertArrayHasKey('headers', $postRequest); + $this->assertSame('application/json', $postRequest['headers']['Content-Type']); + $this->assertArrayHasKey('body', $postRequest); + $this->assertSame('{{data}}', $postRequest['body']['data']); + + // Verify schema properties + $this->assertNotNull($postTool->schema); + $postProperties = $postTool->schema->getProperties(); + $this->assertArrayHasKey('data', $postProperties); + + // Verify that HttpToolHandler would be used for these tools + $toolHandlerFactory = $this->getContainer()->get(\Butschster\ContextGenerator\McpServer\Tool\ToolHandlerFactory::class); + $handler = $toolHandlerFactory->createHandlerForTool($getTool); + $this->assertInstanceOf(HttpToolHandler::class, $handler); + } +} diff --git a/tests/src/Feature/McpServer/Tool/RunToolTest.php b/tests/src/Feature/McpServer/Tool/RunToolTest.php new file mode 100644 index 00000000..c12fda06 --- /dev/null +++ b/tests/src/Feature/McpServer/Tool/RunToolTest.php @@ -0,0 +1,72 @@ +getFixturesDir('McpServer/Tool/run_tools.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + // Get the tools registry + $tools = $config->getTools(); + $this->assertNotNull($tools); + + // Verify the variable command tool exists + $this->assertTrue($tools->has('variable-command-tool')); + + // Get the tool and verify its properties + $tool = $tools->get('variable-command-tool'); + $this->assertInstanceOf(ToolDefinition::class, $tool); + $this->assertSame('variable-command-tool', $tool->id); + $this->assertSame('A tool with variable arguments', $tool->description); + $this->assertSame('run', $tool->type); + + // Verify commands and arguments + $this->assertCount(1, $tool->commands); + $command = $tool->commands[0]; + $this->assertSame('echo', $command->cmd); + + // Verify variable argument + $this->assertCount(2, $command->args); + + // First argument is a simple variable placeholder + $this->assertSame('{{message}}', (string) $command->args[0]); + + // Second argument is conditional with a "when" clause + $this->assertInstanceOf(ToolArg::class, $command->args[1]); + $this->assertSame('--optional-arg', $command->args[1]->name); + $this->assertSame('{{use_optional}}', $command->args[1]->when); + + // Verify schema properties + $this->assertNotNull($tool->schema); + $properties = $tool->schema->getProperties(); + + // Check message property + $this->assertArrayHasKey('message', $properties); + $this->assertSame('string', $properties['message']['type']); + + // Check use_optional property with its default value + $this->assertArrayHasKey('use_optional', $properties); + $this->assertSame('boolean', $properties['use_optional']['type']); + $this->assertFalse($tool->schema->getDefaultValue('use_optional')); + + // Verify that RunToolHandler would be used for this tool + $toolHandlerFactory = $this->getContainer()->get(ToolHandlerFactory::class); + $handler = $toolHandlerFactory->createHandlerForTool($tool); + $this->assertInstanceOf(RunToolHandler::class, $handler); + } +} diff --git a/tests/src/Feature/McpServer/Tool/ToolHandlerTest.php b/tests/src/Feature/McpServer/Tool/ToolHandlerTest.php new file mode 100644 index 00000000..4fc24078 --- /dev/null +++ b/tests/src/Feature/McpServer/Tool/ToolHandlerTest.php @@ -0,0 +1,174 @@ +createMock(CommandExecutorInterface::class); + + // Configure mock to return a successful execution result + $commandExecutor + ->expects($this->once()) + ->method('execute') + ->willReturnCallback(function (ToolCommand $command) { + // Verify the command has been processed correctly with arguments + $this->assertSame('echo', $command->cmd); + $this->assertCount(1, $command->args); + $this->assertSame('test message', (string) $command->args[0]); + + return [ + 'output' => "test message\n", + 'exitCode' => 0, + ]; + }); + + // Create the RunToolHandler with our mock + $handler = new RunToolHandler($commandExecutor); + + // Create a ToolDefinition with a variable argument + $tool = new ToolDefinition( + id: 'test-run-tool', + description: 'Test run tool', + type: 'run', + commands: [ + new ToolCommand( + cmd: 'echo', + args: [ + new ToolArg('{{message}}'), + ], + ), + ], + schema: ToolSchema::fromArray([ + 'properties' => [ + 'message' => [ + 'type' => 'string', + 'description' => 'The message to echo', + ], + ], + ]), + ); + + // Execute the tool with arguments + $result = $handler->execute($tool, ['message' => 'test message']); + + // Verify the result + $this->assertTrue($result['success']); + $this->assertStringContainsString('test message', $result['output']); + $this->assertCount(1, $result['commands']); + $this->assertSame(0, $result['commands'][0]['exitCode']); + } + + /** + * Test HTTP tool execution with variable replacement + */ + #[Test] + public function testHttpToolExecution(): void + { + // Create mock for HttpClientInterface + /** @var HttpClientInterface&MockObject $httpClient */ + $httpClient = $this->createMock(HttpClientInterface::class); + + // Configure mock to return a successful response + $httpClient + ->expects($this->once()) + ->method('get') + ->willReturnCallback(function (string $url, array $headers) { + // Verify URL and headers have been processed correctly + $this->assertSame('https://example.com/api/test?param=value', $url); + $this->assertArrayHasKey('Authorization', $headers); + $this->assertSame('Bearer test-token', $headers['Authorization']); + + // Create mock response + $response = $this->createMock(HttpResponse::class); + $response->method('isSuccess')->willReturn(true); + $response->method('getJson')->willReturn(['success' => true, 'data' => 'test']); + $response->method('getBody')->willReturn('{"success":true,"data":"test"}'); + + return $response; + }); + + $variableResolver = $this->get(VariableResolver::class)->with( + new VariableReplacementProcessor(new ConfigVariableProvider([ + 'endpoint' => 'test', + 'token' => 'test-token', + ])), + ); + + // Create the HttpToolHandler with our mocks + $handler = new HttpToolHandler($httpClient, $variableResolver); + + // Create a ToolDefinition for an HTTP GET request + $tool = new ToolDefinition( + id: 'test-http-tool', + description: 'Test HTTP tool', + type: 'http', + schema: ToolSchema::fromArray([ + 'properties' => [ + 'endpoint' => [ + 'type' => 'string', + 'description' => 'API endpoint', + ], + 'token' => [ + 'type' => 'string', + 'description' => 'Auth token', + ], + 'param' => [ + 'type' => 'string', + 'description' => 'Query parameter', + ], + ], + ]), + extra: [ + 'requests' => [ + [ + 'url' => 'https://example.com/api/{{endpoint}}', + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer {{token}}', + ], + 'query' => [ + 'param' => 'value', + ], + ], + ], + ], + ); + + // Execute the tool with arguments + $result = $handler->execute($tool, [ + 'endpoint' => 'test', + 'token' => 'test-token', + 'param' => 'value', + ]); + + // Verify the result + $this->assertStringContainsString('success', $result['output']); + } +} diff --git a/tests/src/Feature/Source/File/AbstractFileSourceTestCase.php b/tests/src/Feature/Source/File/AbstractFileSourceTestCase.php new file mode 100644 index 00000000..0d7df523 --- /dev/null +++ b/tests/src/Feature/Source/File/AbstractFileSourceTestCase.php @@ -0,0 +1,77 @@ +getFixturesDir('Source/File'); + } + + /** + * Gets the first file source from the first document + */ + protected function getFileSourceFromConfig(ConfigRegistryAccessor $config): FileSource + { + $documents = $config->getDocuments(); + $this->assertCount(1, $documents, 'Should have exactly 1 document'); + + $document = $documents[0]; + $sources = $document->getSources(); + $this->assertCount(1, $sources, 'Document should have exactly 1 source'); + + $source = $sources[0]; + $this->assertInstanceOf(FileSource::class, $source, 'Source should be a FileSource instance'); + + return $source; + } + + /** + * Gets the type of a source from its JSON serialization + * + * @param SourceInterface $source The source to get the type from + * @return string The source type + */ + protected function getSourceType(SourceInterface $source): string + { + return \json_decode(\json_encode($source), true)['type'] ?? ''; + } + + /** + * Creates a test file structure for tests requiring file operations + */ + protected function createTestFileStructure(): string + { + $tempDir = $this->createTempDir(); + + // Create a basic structure + $rootFile = $tempDir . '/root_file.txt'; + \file_put_contents($rootFile, 'Root file content'); + + $subDir = $tempDir . '/subdir'; + \mkdir($subDir, 0777, true); + + $nestedFile = $subDir . '/nested_file.txt'; + \file_put_contents($nestedFile, 'Nested file content'); + + $deepDir = $subDir . '/deep'; + \mkdir($deepDir, 0777, true); + + $deepFile = $deepDir . '/deep_file.txt'; + \file_put_contents($deepFile, 'Deep nested file content'); + + return $tempDir; + } +} diff --git a/tests/src/Feature/Source/File/FileSourceFilterTest.php b/tests/src/Feature/Source/File/FileSourceFilterTest.php new file mode 100644 index 00000000..3103e5bd --- /dev/null +++ b/tests/src/Feature/Source/File/FileSourceFilterTest.php @@ -0,0 +1,45 @@ +getFixturesDir('Source/File/filtered_file_source.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + $documents = $config->getDocuments(); + $document = $documents[0]; + $this->assertEquals('Filtered File Source Test', $document->description); + $source = $this->getFileSourceFromConfig($config); + + // Verify filter properties + $this->assertEquals('test_directory', $source->path); + $this->assertEquals(['**/unwanted*.txt'], $source->notPath); + $this->assertEquals('nested file', $source->contains); + + // Compile and verify content filtering worked + $compiledDocument = $compiler->compile($document); + $content = (string) $compiledDocument->content; + + // Should include nested file but not the root test_file.txt + $this->assertStringContainsString('nested_file.txt', $content); + $this->assertStringContainsString('nested file content', $content); + $this->assertStringNotContainsString('test_file.txt', $content, 'Root test_file.txt should be filtered out'); + } +} diff --git a/tests/src/Feature/Source/File/FileSourceSerializationTest.php b/tests/src/Feature/Source/File/FileSourceSerializationTest.php new file mode 100644 index 00000000..00da95f1 --- /dev/null +++ b/tests/src/Feature/Source/File/FileSourceSerializationTest.php @@ -0,0 +1,99 @@ +getFixturesDir('Source/File/basic_file_source.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + $source = $this->getFileSourceFromConfig($config); + + // Test JSON serialization + $serialized = \json_encode($source); + $this->assertNotFalse($serialized, 'JSON serialization failed'); + + $decoded = \json_decode($serialized, true); + $this->assertIsArray($decoded); + + // Check required fields + $this->assertArrayHasKey('type', $decoded); + $this->assertEquals('file', $decoded['type']); + + $this->assertArrayHasKey('sourcePaths', $decoded); + $this->assertArrayHasKey('filePattern', $decoded); + $this->assertArrayHasKey('treeView', $decoded); + + // Create a new FileSource with minimal parameters + $minimalSource = new FileSource( + sourcePaths: '/test/path', + description: 'Minimal source', + ); + + $minimalSerialized = \json_encode($minimalSource); + $this->assertNotFalse($minimalSerialized, 'Minimal source JSON serialization failed'); + + $minimalDecoded = \json_decode($minimalSerialized, true); + $this->assertIsArray($minimalDecoded); + + // Check that optional fields are not included when empty + $this->assertArrayNotHasKey('path', $minimalDecoded); + $this->assertArrayNotHasKey('contains', $minimalDecoded); + $this->assertArrayNotHasKey('notContains', $minimalDecoded); + $this->assertArrayNotHasKey('size', $minimalDecoded); + $this->assertArrayNotHasKey('date', $minimalDecoded); + + // Test source with all options + $fullSource = new FileSource( + sourcePaths: '/test/path', + description: 'Full source', + filePattern: '*.php', + notPath: ['*/vendor/*'], + path: 'src/Controller', + contains: 'namespace', + notContains: 'private', + size: '> 1K', + date: 'since yesterday', + ignoreUnreadableDirs: true, + treeView: new TreeViewConfig( + enabled: true, + showSize: true, + showLastModified: true, + ), + maxFiles: 10, + ); + + $fullSerialized = \json_encode($fullSource); + $this->assertNotFalse($fullSerialized, 'Full source JSON serialization failed'); + + $fullDecoded = \json_decode($fullSerialized, true); + $this->assertIsArray($fullDecoded); + + // Check that all options are serialized + $this->assertArrayHasKey('path', $fullDecoded); + $this->assertArrayHasKey('contains', $fullDecoded); + $this->assertArrayHasKey('notContains', $fullDecoded); + $this->assertArrayHasKey('size', $fullDecoded); + $this->assertArrayHasKey('date', $fullDecoded); + $this->assertArrayHasKey('ignoreUnreadableDirs', $fullDecoded); + $this->assertArrayHasKey('maxFiles', $fullDecoded); + $this->assertEquals(10, $fullDecoded['maxFiles']); + } +} diff --git a/tests/src/Feature/Source/File/FileSourceTest.php b/tests/src/Feature/Source/File/FileSourceTest.php new file mode 100644 index 00000000..bb0bbc89 --- /dev/null +++ b/tests/src/Feature/Source/File/FileSourceTest.php @@ -0,0 +1,51 @@ +getFixturesDir('Source/File/basic_file_source.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + $documents = $config->getDocuments(); + $source = $this->getFileSourceFromConfig($config); + + // Check source type + $this->assertEquals('file', $this->getSourceType($source)); + + // Verify source properties + $this->assertEquals('Basic file source test', $source->getDescription()); + $this->assertEquals('*.txt', $source->filePattern); + $this->assertTrue($source->treeView->enabled); + + // Verify document properties + $document = $documents[0]; + $this->assertEquals('File Source Test Document', $document->description); + $compiledDocument = $compiler->compile($document); + $content = (string) $compiledDocument->content; + + // Check for expected content in the output + $this->assertStringContainsString('# File Source Test Document', $content); + $this->assertStringContainsString('```', $content); + + // Check that files from the fixture were included + $this->assertStringContainsString('test_file.txt', $content); + $this->assertStringContainsString('This is test content', $content); + } +} diff --git a/tests/src/Feature/Source/File/FileSourceTreeViewTest.php b/tests/src/Feature/Source/File/FileSourceTreeViewTest.php new file mode 100644 index 00000000..fd309c97 --- /dev/null +++ b/tests/src/Feature/Source/File/FileSourceTreeViewTest.php @@ -0,0 +1,53 @@ +getFixturesDir('Source/File/tree_view_file_source.yaml'); + } + + protected function assertConfigItems(DocumentCompiler $compiler, ConfigRegistryAccessor $config): void + { + $source = $this->getFileSourceFromConfig($config); + $document = $config->getDocuments()[0]; + $this->assertEquals('Tree View File Source Test', $document->description); + + // Verify tree view configuration + $treeView = $source->treeView; + $this->assertInstanceOf(TreeViewConfig::class, $treeView); + $this->assertTrue($treeView->enabled); + $this->assertTrue($treeView->showSize); + $this->assertTrue($treeView->showLastModified); + $this->assertTrue($treeView->showCharCount); + $this->assertTrue($treeView->includeFiles); + $this->assertEquals(2, $treeView->maxDepth); + + // Compile and verify tree view output + $compiledDocument = $compiler->compile($document); + $content = (string) $compiledDocument->content; + + // Check that the tree view formatting shows the configured information + $this->assertStringContainsString('B', $content, 'Tree view should show size'); + $this->assertStringContainsString('chars', $content, 'Tree view should show character count'); + + // Tree view should include both the root file and the nested directory + $this->assertStringContainsString('test_file.txt', $content); + $this->assertStringContainsString('test_directory', $content); + $this->assertStringContainsString('nested_file.txt', $content); + } +} diff --git a/tests/src/Unit/Lib/Variable/VariableResolverTest.php b/tests/src/Unit/Lib/Variable/VariableResolverTest.php index 346f2830..595a4237 100644 --- a/tests/src/Unit/Lib/Variable/VariableResolverTest.php +++ b/tests/src/Unit/Lib/Variable/VariableResolverTest.php @@ -154,27 +154,6 @@ public function get(string $name): ?string $this->assertSame($expected, $resolver->resolve($input)); } - #[Test] - public function it_should_use_default_processor_if_none_provided(): void - { - // Create resolver with default processor - $resolver = new VariableResolver(); - - // Use a test string with standard system variables that should be available - // in the default PredefinedVariableProvider - $input = 'System: ${OS}, User: ${USER}'; - - // Process should replace OS and USER variables - $result = $resolver->resolve($input); - - // Verify that variables were replaced - $this->assertStringNotContainsString('${OS}', $result); - $this->assertStringNotContainsString('${USER}', $result); - - // Verify that OS is in the result (actual value depends on system) - $this->assertStringContainsString(PHP_OS, $result); - } - /** * Create a custom provider with predefined variables for testing * diff --git a/tests/src/context.yaml b/tests/src/context.yaml new file mode 100644 index 00000000..d7dc07fd --- /dev/null +++ b/tests/src/context.yaml @@ -0,0 +1,40 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +documents: + - description: Prompts tests + outputPath: tests/prompts.md + sources: + - type: file + sourcePaths: + - Feature/McpServer/Prompt + - Feature/FeatureTestCases.php + - TestApp.php + - TestCase.php + showTreeView: true + + - description: Tools tests + outputPath: tests/tools.md + sources: + - type: file + sourcePaths: + - Feature/McpServer/Tool + - Feature/FeatureTestCases.php + - TestApp.php + - TestCase.php + showTreeView: true + + - description: Compiler tests + outputPath: tests/compiler.md + sources: + - type: file + sourcePaths: + - Feature/Compiler + - Feature/FeatureTestCases.php + - TestApp.php + - TestCase.php + showTreeView: true + + - type: file + sourcePaths: + - ../../../fixtures/Compiler + showTreeView: true \ No newline at end of file