From 839858cf04e2c702e6bef7e456d40281c4839d60 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Tue, 16 Dec 2025 20:05:06 +0100 Subject: [PATCH 01/10] [Mate] Add AI Mate component for MCP server integration This adds a new standalone component that provides an MCP (Model Context Protocol) server enabling AI assistants to interact with PHP applications. The component includes: - Core MCP server with extension discovery system - Symfony bridge for container introspection - Monolog bridge for log search and analysis - Built-in tools for PHP environment information - Comprehensive documentation --- .github/scripts/validate-bridge-naming.sh | 8 +- .github/workflows/validation.yaml | 18 + .gitignore | 4 + docs/components/index.rst | 1 + docs/components/mate.rst | 304 ++++++++++++++++ docs/components/mate/creating-extensions.rst | 268 +++++++++++++++ docs/components/mate/integration.rst | 140 ++++++++ docs/components/mate/troubleshooting.rst | 137 ++++++++ docs/index.rst | 1 + splitsh.json | 5 + src/ai-mate/CHANGELOG.md | 7 + src/ai-mate/CLAUDE.md | 86 +++++ src/ai-mate/LICENSE | 19 + src/ai-mate/README.md | 24 ++ src/ai-mate/bin/mate | 4 + src/ai-mate/bin/mate.php | 43 +++ src/ai-mate/composer.json | 82 +++++ src/ai-mate/phpstan.neon.dist | 12 + src/ai-mate/phpunit.xml.dist | 27 ++ src/ai-mate/resources/.mate/.gitignore | 1 + src/ai-mate/resources/.mate/extensions.php | 10 + src/ai-mate/resources/.mate/services.php | 18 + src/ai-mate/resources/mcp.json | 10 + src/ai-mate/src/App.php | 65 ++++ .../Monolog/Capability/LogSearchTool.php | 250 ++++++++++++++ .../Monolog/Exception/ExceptionInterface.php | 19 + .../Exception/LogFileNotFoundException.php | 25 ++ .../src/Bridge/Monolog/Model/LogEntry.php | 94 +++++ .../Bridge/Monolog/Model/SearchCriteria.php | 69 ++++ src/ai-mate/src/Bridge/Monolog/README.md | 12 + .../src/Bridge/Monolog/Service/LogParser.php | 324 ++++++++++++++++++ .../src/Bridge/Monolog/Service/LogReader.php | 223 ++++++++++++ .../Tests/Capability/LogSearchToolTest.php | 120 +++++++ .../Monolog/Tests/Fixtures/sample.json.log | 5 + .../Bridge/Monolog/Tests/Fixtures/sample.log | 6 + .../Monolog/Tests/Service/LogParserTest.php | 136 ++++++++ .../Monolog/Tests/Service/LogReaderTest.php | 130 +++++++ src/ai-mate/src/Bridge/Monolog/composer.json | 63 ++++ .../src/Bridge/Monolog/config/services.php | 26 ++ .../src/Bridge/Monolog/phpunit.xml.dist | 31 ++ .../Bridge/Symfony/Capability/ServiceTool.php | 60 ++++ .../Symfony/Exception/ExceptionInterface.php | 19 + .../Exception/FileNotFoundException.php | 25 ++ .../XmlContainerCouldNotBeLoadedException.php | 30 ++ ...lContainerPathIsNotConfiguredException.php | 25 ++ .../src/Bridge/Symfony/Model/Container.php | 28 ++ .../Symfony/Model/ServiceDefinition.php | 45 +++ .../src/Bridge/Symfony/Model/ServiceTag.php | 29 ++ src/ai-mate/src/Bridge/Symfony/README.md | 12 + .../Symfony/Service/ContainerProvider.php | 158 +++++++++ .../Tests/Capability/ServiceToolTest.php | 33 ++ .../Fixtures/App_KernelDevDebugContainer.xml | 22 ++ src/ai-mate/src/Bridge/Symfony/composer.json | 63 ++++ .../src/Bridge/Symfony/config/services.php | 23 ++ .../src/Bridge/Symfony/phpunit.xml.dist | 31 ++ src/ai-mate/src/Capability/ServerInfo.php | 48 +++ src/ai-mate/src/Command/ClearCacheCommand.php | 102 ++++++ src/ai-mate/src/Command/DiscoverCommand.php | 167 +++++++++ src/ai-mate/src/Command/InitCommand.php | 167 +++++++++ src/ai-mate/src/Command/ServeCommand.php | 114 ++++++ .../src/Container/ContainerFactory.php | 164 +++++++++ src/ai-mate/src/Container/MateHelper.php | 64 ++++ .../src/Discovery/ComposerTypeDiscovery.php | 312 +++++++++++++++++ .../src/Discovery/FilteredDiscoveryLoader.php | 123 +++++++ .../src/Discovery/ServiceDiscovery.php | 97 ++++++ .../src/Exception/ExceptionInterface.php | 22 ++ .../Exception/MissingDependencyException.php | 26 ++ .../Exception/UnsupportedVersionException.php | 26 ++ src/ai-mate/src/Service/Logger.php | 49 +++ src/ai-mate/src/default.services.php | 32 ++ .../tests/Command/DiscoverCommandTest.php | 196 +++++++++++ src/ai-mate/tests/Command/InitCommandTest.php | 142 ++++++++ .../Discovery/ComposerTypeDiscoveryTest.php | 153 +++++++++ .../vendor/composer/installed.json | 12 + .../vendor/vendor/package-mixed/src/.gitkeep | 0 .../vendor/composer/installed.json | 8 + .../vendor/composer/installed.json | 22 ++ .../vendor/vendor/package-a/src/.gitkeep | 0 .../vendor/vendor/package-b/src/.gitkeep | 0 .../vendor/composer/installed.json | 14 + .../package-with-includes/config/services.php | 5 + .../vendor/package-with-includes/src/.gitkeep | 0 .../vendor/composer/installed.json | 8 + src/ai-mate/tests/bootstrap.php | 12 + .../src/Bridge/Monolog/.phpunit.result.cache | 1 + .../src/Bridge/Symfony/.phpunit.result.cache | 1 + 86 files changed, 5516 insertions(+), 1 deletion(-) create mode 100644 docs/components/mate.rst create mode 100644 docs/components/mate/creating-extensions.rst create mode 100644 docs/components/mate/integration.rst create mode 100644 docs/components/mate/troubleshooting.rst create mode 100644 src/ai-mate/CHANGELOG.md create mode 100644 src/ai-mate/CLAUDE.md create mode 100644 src/ai-mate/LICENSE create mode 100644 src/ai-mate/README.md create mode 100755 src/ai-mate/bin/mate create mode 100644 src/ai-mate/bin/mate.php create mode 100644 src/ai-mate/composer.json create mode 100644 src/ai-mate/phpstan.neon.dist create mode 100644 src/ai-mate/phpunit.xml.dist create mode 100644 src/ai-mate/resources/.mate/.gitignore create mode 100644 src/ai-mate/resources/.mate/extensions.php create mode 100644 src/ai-mate/resources/.mate/services.php create mode 100644 src/ai-mate/resources/mcp.json create mode 100644 src/ai-mate/src/App.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Capability/LogSearchTool.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Exception/ExceptionInterface.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Model/LogEntry.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Model/SearchCriteria.php create mode 100644 src/ai-mate/src/Bridge/Monolog/README.md create mode 100644 src/ai-mate/src/Bridge/Monolog/Service/LogParser.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Service/LogReader.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log create mode 100644 src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.log create mode 100644 src/ai-mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php create mode 100644 src/ai-mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php create mode 100644 src/ai-mate/src/Bridge/Monolog/composer.json create mode 100644 src/ai-mate/src/Bridge/Monolog/config/services.php create mode 100644 src/ai-mate/src/Bridge/Monolog/phpunit.xml.dist create mode 100644 src/ai-mate/src/Bridge/Symfony/Capability/ServiceTool.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Exception/ExceptionInterface.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Exception/FileNotFoundException.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Model/Container.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Model/ServiceDefinition.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Model/ServiceTag.php create mode 100644 src/ai-mate/src/Bridge/Symfony/README.md create mode 100644 src/ai-mate/src/Bridge/Symfony/Service/ContainerProvider.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php create mode 100644 src/ai-mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml create mode 100644 src/ai-mate/src/Bridge/Symfony/composer.json create mode 100644 src/ai-mate/src/Bridge/Symfony/config/services.php create mode 100644 src/ai-mate/src/Bridge/Symfony/phpunit.xml.dist create mode 100644 src/ai-mate/src/Capability/ServerInfo.php create mode 100644 src/ai-mate/src/Command/ClearCacheCommand.php create mode 100644 src/ai-mate/src/Command/DiscoverCommand.php create mode 100644 src/ai-mate/src/Command/InitCommand.php create mode 100644 src/ai-mate/src/Command/ServeCommand.php create mode 100644 src/ai-mate/src/Container/ContainerFactory.php create mode 100644 src/ai-mate/src/Container/MateHelper.php create mode 100644 src/ai-mate/src/Discovery/ComposerTypeDiscovery.php create mode 100644 src/ai-mate/src/Discovery/FilteredDiscoveryLoader.php create mode 100644 src/ai-mate/src/Discovery/ServiceDiscovery.php create mode 100644 src/ai-mate/src/Exception/ExceptionInterface.php create mode 100644 src/ai-mate/src/Exception/MissingDependencyException.php create mode 100644 src/ai-mate/src/Exception/UnsupportedVersionException.php create mode 100644 src/ai-mate/src/Service/Logger.php create mode 100644 src/ai-mate/src/default.services.php create mode 100644 src/ai-mate/tests/Command/DiscoverCommandTest.php create mode 100644 src/ai-mate/tests/Command/InitCommandTest.php create mode 100644 src/ai-mate/tests/Discovery/ComposerTypeDiscoveryTest.php create mode 100644 src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json create mode 100644 src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep create mode 100644 src/ai-mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json create mode 100644 src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json create mode 100644 src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep create mode 100644 src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep create mode 100644 src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json create mode 100644 src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php create mode 100644 src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/src/.gitkeep create mode 100644 src/ai-mate/tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json create mode 100644 src/ai-mate/tests/bootstrap.php create mode 100644 src/mate/src/Bridge/Monolog/.phpunit.result.cache create mode 100644 src/mate/src/Bridge/Symfony/.phpunit.result.cache diff --git a/.github/scripts/validate-bridge-naming.sh b/.github/scripts/validate-bridge-naming.sh index e1bb7f830..177e3a840 100755 --- a/.github/scripts/validate-bridge-naming.sh +++ b/.github/scripts/validate-bridge-naming.sh @@ -42,7 +42,13 @@ for composer_file in ${BRIDGE_PATH}/composer.json; do # Expected package name format: symfony/ai-{lowercase-with-dashes}-{type} # Convert PascalCase to kebab-case (e.g., ChromaDb -> chroma-db) expected_kebab=$(echo "$bridge_name" | sed 's/\([a-z]\)\([A-Z]\)/\1-\2/g' | tr '[:upper:]' '[:lower:]') - expected_package="symfony/ai-${expected_kebab}-${BRIDGE_TYPE}" + + # Special case for AI Mate bridges: symfony/ai-mate-{lowercase} + if [[ "$BRIDGE_TYPE" == "mate" ]]; then + expected_package="symfony/ai-mate-${expected_kebab}" + else + expected_package="symfony/ai-${expected_kebab}-${BRIDGE_TYPE}" + fi if [[ "$package_name" != "$expected_package" ]]; then echo "::error file=$composer_file::Package name '$package_name' does not match expected '$expected_package' for bridge '$bridge_name'" diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml index 41e88c636..660d96f81 100644 --- a/.github/workflows/validation.yaml +++ b/.github/workflows/validation.yaml @@ -103,3 +103,21 @@ jobs: - name: Validate platform bridges have correct type run: .github/scripts/validate-bridge-type.sh platform + validate_mate_bridges: + name: AI Mate Bridges + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Validate AI Mate bridge naming conventions + run: .github/scripts/validate-bridge-naming.sh mate mate + + - name: Validate AI Mate bridges are in splitsh.json + run: .github/scripts/validate-bridge-splitsh.sh mate + + - name: Validate AI Mate bridges have required files + run: .github/scripts/validate-bridge-files.sh mate + + - name: Validate AI Mate bridges have correct type + run: .github/scripts/validate-bridge-type.sh mate diff --git a/.gitignore b/.gitignore index b86a9a51f..5b382e006 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ composer.lock vendor + +# Allow test fixture vendor directories +!src/ai-mate/tests/Discovery/Fixtures/**/vendor +!src/ai-mate/tests/Discovery/Fixtures/**/vendor/** diff --git a/docs/components/index.rst b/docs/components/index.rst index 2ed6bee2e..5027f8f75 100644 --- a/docs/components/index.rst +++ b/docs/components/index.rst @@ -6,5 +6,6 @@ Components agent chat + mate platform store diff --git a/docs/components/mate.rst b/docs/components/mate.rst new file mode 100644 index 000000000..091af1b60 --- /dev/null +++ b/docs/components/mate.rst @@ -0,0 +1,304 @@ +Symfony AI - Mate Component +=========================== + +The Mate component provides an MCP (Model Context Protocol) server that enables +AI assistants to interact with Symfony applications through standardized tools. + +Installation +------------ + +.. code-block:: terminal + + $ composer require symfony/ai-mate + +Purpose +------- + +Symfony AI Mate is a PHP application that creates a local MCP server to enhance your AI development assistant +(JetBrains AI, Claude, GitHub Copilot, Cursor, etc.) with specific knowledge about your application and environment. + +This is the core package that creates and manages your MCP server. It includes some standard tools, while framework or +project-specific tools live in their own packages (bridges). + +Unlike other Symfony AI components, Mate is a standalone component that does not +integrate with the AI Bundle. It runs as an independent MCP server. + +Quick Start +----------- + +Install with composer: + +.. code-block:: terminal + + $ composer require symfony/ai-mate + +Initialize configuration: + +.. code-block:: terminal + + $ vendor/bin/mate init + +This creates: + +* ``.mate/`` directory with configuration files +* ``mate/`` directory for custom extensions +* ``mcp.json`` for MCP client configuration + +It also updates your ``composer.json`` with the following configuration: + +.. code-block:: json + + { + "autoload": { + "psr-4": { + "App\\Mate\\": "mate/" + } + }, + "extra": { + "ai-mate": { + "scan-dirs": ["mate"], + "includes": ["services.php"] + } + } + } + +After running ``mate init``, update your autoloader: + +.. code-block:: terminal + + $ composer dump-autoload + +Discover available extensions: + +.. code-block:: terminal + + $ vendor/bin/mate discover + +Start the MCP server: + +.. code-block:: terminal + + $ vendor/bin/mate serve + +Adding Custom Tools +------------------- + +The easiest way to add tools is to create a ``mate`` folder next to your ``src`` and ``tests`` directories, +then add a class with a method using the ``#[McpTool]`` attribute:: + + // mate/MyTool.php + namespace App\Mate; + + use Mcp\Capability\Attribute\McpTool; + + class MyTool + { + #[McpTool(name: 'my_tool', description: 'My custom tool')] + public function execute(string $param): array + { + return ['result' => $param]; + } + } + +More about attributes and how to configure Prompts, Resources and more can be found at the +`MCP SDK documentation`_. + +Configuration +------------- + +The configuration folder is called ``.mate`` and is located in your project's root directory. +It contains two important files: + +* ``.mate/extensions.php`` - Enable/disable extensions +* ``.mate/services.php`` - Configure settings + +.. tip:: + + The folder and default configuration is automatically generated by running ``mate init``. + +Extensions Configuration +~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + // .mate/extensions.php + // This file is managed by 'mate discover' + // You can manually edit to enable/disable extensions + + return [ + 'vendor/package-name' => ['enabled' => true], + 'vendor/another-package' => ['enabled' => false], + ]; + +Services Configuration +~~~~~~~~~~~~~~~~~~~~~~ + +:: + + // .mate/services.php + use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + + return static function (ContainerConfigurator $container): void { + $container->parameters() + // Override default parameters here + // ->set('mate.cache_dir', sys_get_temp_dir().'/mate') + // ->set('mate.env_file', ['.env']) + ; + + $container->services() + // Register your custom services here + ; + }; + +Disabling Specific Features +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use the MateHelper class to disable specific features:: + + use Symfony\AI\Mate\Container\MateHelper; + use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + + return static function (ContainerConfigurator $container): void { + MateHelper::disableFeatures($container, [ + 'symfony/ai-mate' => ['php-version', 'operating-system'], + ]); + }; + +Environment Variables +~~~~~~~~~~~~~~~~~~~~~ + +Use ``%env(VAR_NAME)%`` syntax in service configuration to reference environment variables. + +Adding Third-Party Extensions +----------------------------- + +1. Install the package: + + .. code-block:: terminal + + $ composer require vendor/symfony-tools + +2. Discover available tools (auto-generates/updates ``.mate/extensions.php``): + + .. code-block:: terminal + + $ vendor/bin/mate discover + +3. Optionally disable specific extensions:: + + // .mate/extensions.php + return [ + 'vendor/symfony-tools' => ['enabled' => true], + 'vendor/unwanted-tools' => ['enabled' => false], + ]; + +To create a third party extension, see :doc:`mate/creating-extensions`. + +Available Bridges +----------------- + +Symfony Bridge +~~~~~~~~~~~~~~ + +The Symfony bridge (``symfony/ai-mate-symfony``) provides container introspection tools: + +* ``symfony-services`` - List all Symfony services from the compiled container + +Configure the cache directory:: + + $container->parameters() + ->set('ai_mate_symfony.cache_dir', '%root_dir%/var/cache'); + +**Troubleshooting** + +*Container not found*: + +Ensure the cache directory parameter points to the correct location. The bridge looks for +the compiled container XML file (e.g., ``App_KernelDevDebugContainer.xml``) in the cache directory. + +*Services not appearing*: + +1. Clear Symfony cache: ``bin/console cache:clear`` +2. Ensure the container is compiled (warm up cache) +3. Verify the container XML file exists in the cache directory + +Monolog Bridge +~~~~~~~~~~~~~~ + +The Monolog bridge (``symfony/ai-mate-monolog``) provides log search and analysis tools: + +* ``monolog-search`` - Search log entries by text term with optional filters +* ``monolog-search-regex`` - Search log entries using regex patterns +* ``monolog-context-search`` - Search logs by context field value +* ``monolog-tail`` - Get the last N log entries +* ``monolog-list-files`` - List available log files +* ``monolog-list-channels`` - List all log channels +* ``monolog-by-level`` - Get log entries filtered by level + +Configure the log directory:: + + $container->parameters() + ->set('ai_mate_monolog.log_dir', '%root_dir%/var/log'); + +**Troubleshooting** + +*Logs not found*: + +Ensure the log directory parameter points to the correct location where your Monolog +log files are stored. + +*Log parsing errors*: + +1. Verify log format is standard Monolog line format or JSON +2. Check file permissions on log files +3. Ensure log files are not empty or corrupted + +Built-in Tools +-------------- + +The core package provides basic system information tools: + +* ``php-version`` - Get the PHP version +* ``operating-system`` - Get the operating system +* ``operating-system-family`` - Get the OS family +* ``php-extensions`` - List loaded PHP extensions + +Commands +-------- + +``mate init`` + Initialize AI Mate configuration and create the ``.mate/`` directory. + +``mate discover`` + Scan for MCP extensions in installed packages. This command will: + + - Scan your vendor directory for packages with ``extra.ai-mate`` configuration + - Generate or update ``.mate/extensions.php`` with discovered extensions + - Preserve existing enabled/disabled states for known extensions + - Default new extensions to enabled + +``mate serve`` + Start the MCP server with stdio transport. + +``mate clear-cache`` + Clear the MCP server cache. + +Security +-------- + +For security, no vendor extensions are enabled by default. You must explicitly enable packages +in ``.mate/extensions.php`` by setting their ``enabled`` flag to ``true``. + +The local ``mate/`` directory is always enabled for rapid development. + +Further Reading +--------------- + +.. toctree:: + :maxdepth: 1 + + mate/integration + mate/creating-extensions + mate/troubleshooting + +.. _`MCP SDK documentation`: https://github.com/modelcontextprotocol/php-sdk diff --git a/docs/components/mate/creating-extensions.rst b/docs/components/mate/creating-extensions.rst new file mode 100644 index 000000000..27db46975 --- /dev/null +++ b/docs/components/mate/creating-extensions.rst @@ -0,0 +1,268 @@ +Creating MCP Extensions +======================= + +MCP extensions are Composer packages that declare themselves using a specific configuration +in ``composer.json``, similar to PHPStan extensions. + +Quick Start +----------- + +1. Configure composer.json +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: json + + { + "name": "vendor/my-extension", + "type": "library", + "require": { + "symfony/ai-mate": "^1.0" + }, + "extra": { + "ai-mate": { + "scan-dirs": ["src", "lib"] + } + } + } + +The ``extra.ai-mate`` section is required for your package to be discovered as an extension. + +2. Create MCP Capabilities +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + use Mcp\Capability\Attribute\McpTool; + use Psr\Log\LoggerInterface; + + class MyTool + { + // Dependencies are automatically injected + public function __construct( + private LoggerInterface $logger, + ) { + } + + #[McpTool(name: 'my-tool', description: 'What this tool does')] + public function execute(string $param): string + { + $this->logger->info('Tool executed', ['param' => $param]); + + return 'Result: ' . $param; + } + } + +3. Install and Enable +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: terminal + + $ composer require vendor/my-extension + $ vendor/bin/mate discover + +The ``discover`` command will automatically add your extension to ``.mate/extensions.php``:: + + return [ + 'vendor/my-extension' => ['enabled' => true], + ]; + +To disable an extension, set ``enabled`` to ``false``:: + + return [ + 'vendor/my-extension' => ['enabled' => true], + 'vendor/unwanted-extension' => ['enabled' => false], + ]; + +Dependency Injection +-------------------- + +Tools, Resources, and Prompts support constructor dependency injection via Symfony's DI Container. +Dependencies are automatically resolved and injected. + +Configuring Services +~~~~~~~~~~~~~~~~~~~~ + +Register service configuration files in your ``composer.json``: + +.. code-block:: json + + { + "extra": { + "ai-mate": { + "scan-dirs": ["src"], + "includes": [ + "config/services.php" + ] + } + } + } + +Create service configuration files using Symfony DI format:: + + // config/services.php + use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + + return function (ContainerConfigurator $configurator) { + $services = $configurator->services(); + + // Register a service with parameters + $services->set(MyApiClient::class) + ->arg('$apiKey', '%env(MY_API_KEY)%') + ->arg('$baseUrl', 'https://api.example.com'); + }; + +Configuration Reference +----------------------- + +Scan Directories +~~~~~~~~~~~~~~~~ + +``extra.ai-mate.scan-dirs`` (optional) + +- Default: Package root directory +- Relative to package root +- Multiple directories supported + +Service Includes +~~~~~~~~~~~~~~~~ + +``extra.ai-mate.includes`` (optional) + +- Array of service configuration file paths +- Standard Symfony DI configuration format (PHP files) +- Supports environment variables via ``%env()%`` + +Security +~~~~~~~~ + +Extensions must be explicitly enabled in ``.mate/extensions.php``: + +- The ``discover`` command automatically adds discovered extensions +- All extensions default to ``enabled: true`` when discovered +- Set ``enabled: false`` to disable an extension + +Troubleshooting +--------------- + +Extensions Not Discovered +~~~~~~~~~~~~~~~~~~~~~~~~~ + +If your extensions aren't being found: + +1. **Verify composer.json configuration**: + + Ensure your package has the ``extra.ai-mate`` section: + + .. code-block:: json + + { + "extra": { + "ai-mate": { + "scan-dirs": ["src"] + } + } + } + +2. **Run discovery**: + + .. code-block:: terminal + + $ vendor/bin/mate discover + +3. **Check the extensions file**: + + .. code-block:: terminal + + $ cat .mate/extensions.php + + Verify your package is listed and ``enabled`` is ``true``. + +Extensions Not Loading +~~~~~~~~~~~~~~~~~~~~~~ + +If extensions are discovered but not loading: + +1. **Check enabled status** in ``.mate/extensions.php``:: + + return [ + 'vendor/my-extension' => ['enabled' => true], // Must be true + ]; + +2. **Verify scan directories exist** and contain PHP files with MCP attributes. + +3. **Check for PHP errors** in your extension code: + + .. code-block:: terminal + + $ php -l src/MyTool.php + +Tools Not Appearing +~~~~~~~~~~~~~~~~~~~ + +If your MCP tools don't appear in the AI assistant: + +1. **Verify MCP attributes** are correctly applied:: + + use Mcp\Capability\Attribute\McpTool; + + class MyTool + { + #[McpTool(name: 'my-tool', description: 'Description here')] + public function execute(): string + { + return 'result'; + } + } + +2. **Check that classes are in scan directories** defined in ``composer.json``. + +3. **Restart your AI assistant** after making changes. + +4. **Check server logs** for registration errors. + +Tool Execution Fails +~~~~~~~~~~~~~~~~~~~~ + +If tools are visible but fail when called: + +1. **Check return types** - tools must return scalar values or arrays:: + + // Good + public function execute(): string { return 'result'; } + public function execute(): array { return ['key' => 'value']; } + + // Bad - objects are not directly serializable + public function execute(): object { return new stdClass(); } + +2. **Check for exceptions** in your tool code. + +3. **Verify dependencies** are properly injected. + +Dependency Injection Issues +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If dependencies aren't being injected: + +1. **Register services** in your ``services.php`` or ``config/services.php``:: + + $services->set(MyService::class) + ->autowire() + ->autoconfigure(); + +2. **Check interface bindings**:: + + $services->alias(MyInterface::class, MyImplementation::class); + +3. **Verify service configuration** is listed in ``composer.json``: + + .. code-block:: json + + { + "extra": { + "ai-mate": { + "includes": ["config/services.php"] + } + } + } + +For general server issues and debugging tips, see the :doc:`troubleshooting` guide. diff --git a/docs/components/mate/integration.rst b/docs/components/mate/integration.rst new file mode 100644 index 000000000..6241ee166 --- /dev/null +++ b/docs/components/mate/integration.rst @@ -0,0 +1,140 @@ +Integration +=========== + +This page explains how to integrate Symfony AI Mate with AI development tools. + +JetBrains AI Assistant +---------------------- + +To connect Symfony AI Mate to JetBrains AI Assistant: + +1. Press ``Cmd`` + ``,`` (macOS) or ``Ctrl`` + ``Alt`` + ``S`` (Windows/Linux) to open **Settings**. +2. Navigate to **Tools | AI Assistant | Model Context Protocol (MCP)**. +3. Click the **+** (Add) button. +4. Configure the server parameters: + + - **Name**: Symfony AI Mate + - **Command type**: Select ``stdio`` + - **Executable**: ``php`` + - **Arguments**: ``/absolute/path/to/vendor/bin/mate serve`` + +5. Click **OK** to save. + +.. note:: + + Replace ``/absolute/path/to/`` with the actual path to your project's vendor directory. + +Claude Desktop +-------------- + +To connect Symfony AI Mate to Claude Desktop: + +1. Open Claude Desktop. +2. Go to **Settings** > **Developer** and click **Edit Config**. + + Alternatively, open the file manually: + + - **macOS**: ``~/Library/Application Support/Claude/claude_desktop_config.json`` + - **Windows**: ``%APPDATA%\Claude\claude_desktop_config.json`` + +3. Add the server configuration to the ``mcpServers`` object: + + .. code-block:: json + + { + "mcpServers": { + "symfony-ai-mate": { + "command": "php", + "args": ["/absolute/path/to/vendor/bin/mate", "serve"] + } + } + } + +4. Save the file and restart Claude Desktop. + +.. note:: + + Replace ``/absolute/path/to/`` with the actual path to your project's vendor directory. + +Claude Code +----------- + +To add Symfony AI Mate to Claude Code: + +.. code-block:: terminal + + $ claude mcp add mate $(pwd)/vendor/bin/mate serve --scope local + $ claude mcp list # Verify: mate - ✓ Connected + +Cursor +------ + +Configuration for Cursor coming soon. + +GitHub Copilot +-------------- + +Configuration for GitHub Copilot coming soon. + +Troubleshooting +--------------- + +Claude Desktop Not Connecting +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Verify config file location**: + + - macOS: ``~/Library/Application Support/Claude/claude_desktop_config.json`` + - Windows: ``%APPDATA%\Claude\claude_desktop_config.json`` + +2. **Check JSON syntax**: + + .. code-block:: json + + { + "mcpServers": { + "symfony-ai-mate": { + "command": "php", + "args": ["/absolute/path/to/vendor/bin/mate", "serve"] + } + } + } + +3. **Use absolute paths** - relative paths often fail. + +4. **Restart Claude Desktop** after configuration changes. + +JetBrains AI Assistant Not Connecting +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Verify settings path**: Tools → AI Assistant → Model Context Protocol (MCP) + +2. **Check configuration**: + + - Command type: ``stdio`` + - Executable: ``php`` + - Arguments: ``/absolute/path/to/vendor/bin/mate serve`` + +3. **Test manually** from the same directory as your IDE. + +Claude Code Not Connecting +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Check connection status**: + + .. code-block:: terminal + + $ claude mcp list + + Look for ``mate - ✓ Connected`` + +2. **Re-add the server**: + + .. code-block:: terminal + + $ claude mcp remove mate + $ claude mcp add mate $(pwd)/vendor/bin/mate serve --scope local + +3. **Check for conflicting servers** with similar names. + +For general server issues and debugging tips, see the :doc:`troubleshooting` guide. diff --git a/docs/components/mate/troubleshooting.rst b/docs/components/mate/troubleshooting.rst new file mode 100644 index 000000000..d7dff807e --- /dev/null +++ b/docs/components/mate/troubleshooting.rst @@ -0,0 +1,137 @@ +Troubleshooting +=============== + +This page covers common issues when using Symfony AI Mate and how to resolve them. + +For specific issues, see also: + +* :doc:`integration` - AI assistant connection issues +* :doc:`creating-extensions` - Extension and tool issues + +Server Issues +------------- + +Server Not Starting +~~~~~~~~~~~~~~~~~~~ + +If the MCP server doesn't start: + +1. **Check PHP version** (requires 8.2+): + + .. code-block:: terminal + + $ php --version + +2. **Verify the binary exists**: + + .. code-block:: terminal + + $ ls -la vendor/bin/mate + +3. **Run manually to see errors**: + + .. code-block:: terminal + + $ vendor/bin/mate serve + + Look for error messages in the output. + +4. **Check for missing dependencies**: + + .. code-block:: terminal + + $ composer install + +Server Crashes on Startup +~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the server starts but immediately crashes: + +1. **Check for syntax errors** in your custom tools: + + .. code-block:: terminal + + $ php -l mate/MyTool.php + +2. **Verify service configuration**: + + .. code-block:: terminal + + $ php -r "require 'vendor/autoload.php'; include '.mate/services.php';" + +3. **Check for circular dependencies** in your service configuration. + +Permission Denied Errors +~~~~~~~~~~~~~~~~~~~~~~~~ + +If you get permission errors: + +.. code-block:: terminal + + $ chmod +x vendor/bin/mate + +On Windows, ensure PHP is in your PATH and run: + +.. code-block:: terminal + + > php vendor/bin/mate serve + +Debugging Tips +-------------- + +Enable Debug Logging +~~~~~~~~~~~~~~~~~~~~ + +Set the ``MATE_DEBUG`` environment variable: + +.. code-block:: terminal + + $ MATE_DEBUG=1 vendor/bin/mate serve + +This outputs detailed information to stderr. + +Log to File +~~~~~~~~~~~ + +Set the ``MATE_LOG_FILE`` environment variable: + +.. code-block:: terminal + + $ MATE_LOG_FILE=/tmp/mate.log vendor/bin/mate serve + +Check the log file for detailed error information. + +Test Tools Manually +~~~~~~~~~~~~~~~~~~~ + +Create a simple test script:: + + // test-tool.php + require 'vendor/autoload.php'; + + $tool = new App\Mate\MyTool(); + var_dump($tool->execute('test-param')); + +Clear Cache +~~~~~~~~~~~ + +If you're experiencing stale behavior: + +.. code-block:: terminal + + $ vendor/bin/mate clear-cache + +Getting Help +------------ + +If you're still experiencing issues: + +1. **Check the documentation**: Review the :doc:`../mate` main documentation +2. **Search existing issues**: https://github.com/symfony/ai/issues +3. **Create a new issue**: Include: + + - PHP version (``php --version``) + - Symfony AI Mate version + - Error messages or logs + - Steps to reproduce + - Your configuration files (sanitized) diff --git a/docs/index.rst b/docs/index.rst index 76446e3c2..ca4af79c8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,6 +15,7 @@ AI capabilities to your application: * :doc:`Agent Component `: Framework for building AI agents with tools and workflows * :doc:`Chat Component `: API to interact with agents and store conversation history * :doc:`Store Component `: Data storage abstraction for vector databases and RAG applications +* :doc:`Mate Component `: MCP server for AI assistant integration with your application * :doc:`AI Bundle `: Symfony integration bringing all components together * :doc:`MCP Bundle `: Integration for the Model Context Protocol SDK diff --git a/splitsh.json b/splitsh.json index c0d4ffa7b..0e89dfd77 100644 --- a/splitsh.json +++ b/splitsh.json @@ -28,6 +28,11 @@ "ai-pogocache-message-store": "src/chat/src/Bridge/Pogocache", "ai-redis-message-store": "src/chat/src/Bridge/Redis", "ai-surreal-db-message-store": "src/chat/src/Bridge/SurrealDb", + "ai-mate": { + "prefixes": [{ "from": "src/mate", "to": "", "excludes": ["src/Bridge"] }] + }, + "ai-mate-monolog": "src/ai-mate/src/Bridge/Monolog", + "ai-mate-symfony": "src/ai-mate/src/Bridge/Symfony", "mcp-bundle": "src/mcp-bundle", "ai-platform": { "prefixes": [{ "from": "src/platform", "to": "", "excludes": ["src/Bridge"] }] diff --git a/src/ai-mate/CHANGELOG.md b/src/ai-mate/CHANGELOG.md new file mode 100644 index 000000000..7cebab68f --- /dev/null +++ b/src/ai-mate/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +0.1 +--- + +* Initial release diff --git a/src/ai-mate/CLAUDE.md b/src/ai-mate/CLAUDE.md new file mode 100644 index 000000000..1837ed3b4 --- /dev/null +++ b/src/ai-mate/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Mate Component Overview + +This is the Mate component of the Symfony AI monorepo - an MCP (Model Context Protocol) server that enables AI assistants to interact with Symfony applications. The component is standalone and does not integrate with the AI Bundle. + +## Development Commands + +### Testing +```bash +# Run all tests +vendor/bin/phpunit + +# Run specific test +vendor/bin/phpunit tests/Command/InitCommandTest.php + +# Run bridge tests +vendor/bin/phpunit src/Bridge/Symfony/Tests/ +vendor/bin/phpunit src/Bridge/Monolog/Tests/ +``` + +### Code Quality +```bash +# Run PHPStan static analysis +vendor/bin/phpstan analyse + +# Fix code style (run from monorepo root) +cd ../../.. && vendor/bin/php-cs-fixer fix src/ai-mate/ +``` + +### Running the Server +```bash +# Initialize configuration +bin/mate init + +# Discover extensions +bin/mate discover + +# Start MCP server +bin/mate serve + +# Clear cache +bin/mate clear-cache +``` + +## Architecture + +### Core Classes +- **App**: Console application builder +- **ContainerFactory**: DI container management with extension discovery +- **ComposerTypeDiscovery**: Discovers MCP extensions via `extra.ai-mate` in composer.json +- **FilteredDiscoveryLoader**: Loads MCP capabilities with feature filtering +- **ServiceDiscovery**: Registers discovered services in the DI container + +### Key Directories +- `src/Command/`: CLI commands (serve, init, discover, clear-cache) +- `src/Container/`: DI container management +- `src/Discovery/`: Extension discovery system +- `src/Capability/`: Built-in MCP tools +- `src/Bridge/`: Embedded bridge packages (Symfony, Monolog) + +### Bridges +The component includes embedded bridge packages: + +**Symfony Bridge** (`src/Bridge/Symfony/`): +- `ServiceTool`: Symfony container introspection +- `ContainerProvider`: Parses compiled container XML + +**Monolog Bridge** (`src/Bridge/Monolog/`): +- `LogSearchTool`: Log search and analysis +- `LogParser`: Parses JSON and standard Monolog formats +- `LogReader`: Reads and filters log files + +### Configuration +- `.mate/extensions.php`: Enable/disable extensions +- `.mate/services.php`: Custom service configuration +- `mate/`: Directory for user-defined MCP tools + +## Testing Architecture + +- Uses PHPUnit 11+ with strict configuration +- Bridge tests are located within their respective bridge directories +- Fixtures for discovery tests in `tests/Discovery/Fixtures/` +- Component follows Symfony coding standards diff --git a/src/ai-mate/LICENSE b/src/ai-mate/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/ai-mate/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/ai-mate/README.md b/src/ai-mate/README.md new file mode 100644 index 000000000..6157a07ef --- /dev/null +++ b/src/ai-mate/README.md @@ -0,0 +1,24 @@ +# Symfony AI - Mate Component + +The Mate component provides an MCP (Model Context Protocol) server that enables AI assistants to interact with Symfony applications through standardized tools. + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +## Installation + +```bash +composer require symfony/ai-mate +``` + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ai to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/doc/current/ai/components/mate.html) +- [Report issues](https://github.com/symfony/ai/issues) and + [send Pull Requests](https://github.com/symfony/ai/pulls) + in the [main Symfony AI repository](https://github.com/symfony/ai) diff --git a/src/ai-mate/bin/mate b/src/ai-mate/bin/mate new file mode 100755 index 000000000..f8664a1a2 --- /dev/null +++ b/src/ai-mate/bin/mate @@ -0,0 +1,4 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +$autoloadPaths = [ + getcwd().'/vendor/autoload.php', // Project autoloader using current-working-directory (preferred) + __DIR__.'/../../../autoload.php', // Project autoloader + __DIR__.'/../vendor/autoload.php', // Package autoloader (fallback) +]; + +$root = null; +foreach ($autoloadPaths as $autoloadPath) { + if (file_exists($autoloadPath)) { + require_once $autoloadPath; + $root = dirname(realpath($autoloadPath), 2); + break; + } +} + +if (!$root) { + echo 'Unable to locate the Composer vendor directory. Did you run composer install?'.\PHP_EOL; + exit(1); +} + +// Set the root directory as an environment variable using $_ENV to be thread-safe +$_ENV['MATE_ROOT_DIR'] = $root; + +use Symfony\AI\Mate\App; +use Symfony\AI\Mate\Container\ContainerFactory; + +$containerFactory = new ContainerFactory($root); +$container = $containerFactory->create(); + +App::build($container)->run(); diff --git a/src/ai-mate/composer.json b/src/ai-mate/composer.json new file mode 100644 index 000000000..dc08e40d3 --- /dev/null +++ b/src/ai-mate/composer.json @@ -0,0 +1,82 @@ +{ + "name": "symfony/ai-mate", + "description": "AI development assistant MCP server for Symfony projects", + "license": "MIT", + "type": "library", + "keywords": [ + "ai", + "mcp", + "model-context-protocol", + "symfony", + "debug", + "development" + ], + "authors": [ + { + "name": "Johannes Wachter", + "email": "johannes@sulu.io" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "bin": [ + "bin/mate" + ], + "require": { + "php": ">=8.2", + "mcp/sdk": "^0.1.0", + "psr/log": "^2.0|^3.0", + "symfony/config": "^6.4|^7.3|^8.0", + "symfony/console": "^6.4|^7.4|^8.0", + "symfony/dependency-injection": "^6.4|^7.3|^8.0", + "symfony/finder": "^6.4|^7.3|^8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5", + "symfony/dotenv": "^6.4|^7.4|^8.0" + }, + "conflict": { + "symfony/dotenv": "<5.4.10" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\Mate\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\PHPStan\\": "../../.phpstan/", + "Symfony\\AI\\Mate\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + }, + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.x-dev" + }, + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + }, + "ai-mate": { + "scan-dirs": [ + "src/Capability" + ] + } + } +} diff --git a/src/ai-mate/phpstan.neon.dist b/src/ai-mate/phpstan.neon.dist new file mode 100644 index 000000000..988f6c4d0 --- /dev/null +++ b/src/ai-mate/phpstan.neon.dist @@ -0,0 +1,12 @@ +includes: + - ../../.phpstan/extension.neon + +parameters: + level: 6 + paths: + - src/ + - tests/ + treatPhpDocTypesAsCertain: false + ignoreErrors: + - + message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" diff --git a/src/ai-mate/phpunit.xml.dist b/src/ai-mate/phpunit.xml.dist new file mode 100644 index 000000000..281552a7c --- /dev/null +++ b/src/ai-mate/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + tests + src/Bridge/Symfony/Tests + src/Bridge/Monolog/Tests + + + + + + src + + + src/Bridge/*/Tests + + + diff --git a/src/ai-mate/resources/.mate/.gitignore b/src/ai-mate/resources/.mate/.gitignore new file mode 100644 index 000000000..11ee75815 --- /dev/null +++ b/src/ai-mate/resources/.mate/.gitignore @@ -0,0 +1 @@ +.env.local diff --git a/src/ai-mate/resources/.mate/extensions.php b/src/ai-mate/resources/.mate/extensions.php new file mode 100644 index 000000000..e00b5e12c --- /dev/null +++ b/src/ai-mate/resources/.mate/extensions.php @@ -0,0 +1,10 @@ + ['enabled' => true], +]; diff --git a/src/ai-mate/resources/.mate/services.php b/src/ai-mate/resources/.mate/services.php new file mode 100644 index 000000000..edcf55dda --- /dev/null +++ b/src/ai-mate/resources/.mate/services.php @@ -0,0 +1,18 @@ +parameters() + // Override default parameters here + // ->set('mate.cache_dir', sys_get_temp_dir().'/mate') + // ->set('mate.env_file', ['.env']) // This will load .mate/.env and .mate/.env.local + ; + + $container->services() + // Register your custom services here + ; +}; diff --git a/src/ai-mate/resources/mcp.json b/src/ai-mate/resources/mcp.json new file mode 100644 index 000000000..d41364630 --- /dev/null +++ b/src/ai-mate/resources/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "symfony-ai-mate": { + "command": "./vendor/bin/mate", + "args": [ + "serve" + ] + } + } +} diff --git a/src/ai-mate/src/App.php b/src/ai-mate/src/App.php new file mode 100644 index 000000000..7dcd531cd --- /dev/null +++ b/src/ai-mate/src/App.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate; + +use Psr\Log\LoggerInterface; +use Symfony\AI\Mate\Command\ClearCacheCommand; +use Symfony\AI\Mate\Command\DiscoverCommand; +use Symfony\AI\Mate\Command\InitCommand; +use Symfony\AI\Mate\Command\ServeCommand; +use Symfony\AI\Mate\Exception\UnsupportedVersionException; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @author Johannes Wachter + * @author Tobias Nyholm + */ +final class App +{ + public static function build(ContainerBuilder $container): Application + { + $logger = $container->get(LoggerInterface::class); + \assert($logger instanceof LoggerInterface); + + $rootDir = $container->getParameter('mate.root_dir'); + \assert(\is_string($rootDir)); + + $cacheDir = $container->getParameter('mate.cache_dir'); + \assert(\is_string($cacheDir)); + + $application = new Application('Symfony AI Mate', '0.1.0'); + + self::addCommand($application, new InitCommand($rootDir)); + self::addCommand($application, new ServeCommand($logger, $container)); + self::addCommand($application, new DiscoverCommand($rootDir, $logger)); + self::addCommand($application, new ClearCacheCommand($cacheDir)); + + return $application; + } + + /** + * Add commands in a way that works with all support symfony/console versions. + */ + private static function addCommand(Application $application, Command $command): void + { + // @phpstan-ignore function.alreadyNarrowedType + if (method_exists($application, 'addCommand')) { + $application->addCommand($command); + } elseif (method_exists($application, 'add')) { + $application->add($command); + } else { + throw UnsupportedVersionException::forConsole(); + } + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/Capability/LogSearchTool.php b/src/ai-mate/src/Bridge/Monolog/Capability/LogSearchTool.php new file mode 100644 index 000000000..49ca33eaf --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Capability/LogSearchTool.php @@ -0,0 +1,250 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Capability; + +use Mcp\Capability\Attribute\McpTool; +use Symfony\AI\Mate\Bridge\Monolog\Model\SearchCriteria; +use Symfony\AI\Mate\Bridge\Monolog\Service\LogReader; + +/** + * MCP tools for searching and analyzing Monolog log files. + * + * @author Johannes Wachter + */ +final class LogSearchTool +{ + public function __construct( + private LogReader $reader, + ) { + } + + /** + * @return array, + * extra: array, + * source_file: string|null, + * line_number: int|null + * }> + */ + #[McpTool('monolog-search', 'Search log entries by text term with optional level, channel, environment, and date filters')] + public function search( + string $term, + ?string $level = null, + ?string $channel = null, + ?string $environment = null, + ?string $from = null, + ?string $to = null, + int $limit = 100, + ): array { + $criteria = new SearchCriteria( + term: $term, + level: $level, + channel: $channel, + from: $this->parseDate($from), + to: $this->parseDate($to), + limit: $limit, + ); + + return $this->collectResults($criteria, $environment); + } + + /** + * @return array, + * extra: array, + * source_file: string|null, + * line_number: int|null + * }> + */ + #[McpTool('monolog-search-regex', 'Search log entries using a regex pattern')] + public function searchRegex( + string $pattern, + ?string $level = null, + ?string $channel = null, + ?string $environment = null, + int $limit = 100, + ): array { + // Ensure pattern has delimiters + if (!str_starts_with($pattern, '/') && !str_starts_with($pattern, '#')) { + $pattern = '/'.$pattern.'/i'; + } + + $criteria = new SearchCriteria( + regex: $pattern, + level: $level, + channel: $channel, + limit: $limit, + ); + + return $this->collectResults($criteria, $environment); + } + + /** + * @return array, + * extra: array, + * source_file: string|null, + * line_number: int|null + * }> + */ + #[McpTool('monolog-context-search', 'Search logs by context field value')] + public function searchContext( + string $key, + string $value, + ?string $level = null, + ?string $environment = null, + int $limit = 100, + ): array { + $criteria = new SearchCriteria( + level: $level, + contextKey: $key, + contextValue: $value, + limit: $limit, + ); + + return $this->collectResults($criteria, $environment); + } + + /** + * @return array, + * extra: array, + * source_file: string|null, + * line_number: int|null + * }> + */ + #[McpTool('monolog-tail', 'Get the last N log entries')] + public function tail(int $lines = 50, ?string $level = null, ?string $environment = null): array + { + $entries = $this->reader->tail($lines, $level, $environment); + + return array_values(array_map(static fn ($entry) => $entry->toArray(), $entries)); + } + + /** + * @return array + */ + #[McpTool('monolog-list-files', 'List available log files, optionally filtered by environment')] + public function listFiles(?string $environment = null): array + { + $files = null !== $environment + ? $this->reader->getLogFilesForEnvironment($environment) + : $this->reader->getLogFiles(); + $result = []; + + foreach ($files as $file) { + $result[] = [ + 'name' => basename($file), + 'path' => $file, + 'size' => filesize($file) ?: 0, + 'modified' => date(\DateTimeInterface::ATOM, filemtime($file) ?: 0), + ]; + } + + return $result; + } + + /** + * @return string[] + */ + #[McpTool('monolog-list-channels', 'List all log channels found in log files')] + public function listChannels(): array + { + return $this->reader->getUniqueChannels(); + } + + /** + * Get log entries by level (e.g., all ERROR logs). + * + * @return array, + * extra: array, + * source_file: string|null, + * line_number: int|null + * }> + */ + #[McpTool('monolog-by-level', 'Get log entries filtered by level (DEBUG, INFO, WARNING, ERROR, etc.)')] + public function byLevel(string $level, ?string $environment = null, int $limit = 100): array + { + $criteria = new SearchCriteria( + level: $level, + limit: $limit, + ); + + return $this->collectResults($criteria, $environment); + } + + /** + * @return array, + * extra: array, + * source_file: string|null, + * line_number: int|null + * }> + */ + private function collectResults(SearchCriteria $criteria, ?string $environment = null): array + { + $results = []; + + $generator = null !== $environment + ? $this->reader->readForEnvironment($environment, $criteria) + : $this->reader->readAll($criteria); + + foreach ($generator as $entry) { + $results[] = $entry->toArray(); + } + + return $results; + } + + private function parseDate(?string $date): ?\DateTimeImmutable + { + if (null === $date || '' === $date) { + return null; + } + + try { + return new \DateTimeImmutable($date); + } catch (\Exception) { + return null; + } + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/Exception/ExceptionInterface.php b/src/ai-mate/src/Bridge/Monolog/Exception/ExceptionInterface.php new file mode 100644 index 000000000..406a168de --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Exception; + +/** + * @author Johannes Wachter + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/ai-mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php b/src/ai-mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php new file mode 100644 index 000000000..c009fdf13 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Exception; + +/** + * @author Johannes Wachter + * + * @internal + */ +class LogFileNotFoundException extends \InvalidArgumentException implements ExceptionInterface +{ + public static function forPath(string $path): self + { + return new self(\sprintf('Log file not found: "%s"', $path)); + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/Model/LogEntry.php b/src/ai-mate/src/Bridge/Monolog/Model/LogEntry.php new file mode 100644 index 000000000..0e0232326 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Model/LogEntry.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Model; + +/** + * Represents a single log entry parsed from a Monolog log file. + * + * @author Johannes Wachter + */ +final class LogEntry +{ + /** + * @param array $context + * @param array $extra + */ + public function __construct( + public readonly \DateTimeImmutable $datetime, + public readonly string $channel, + public readonly string $level, + public readonly string $message, + public readonly array $context = [], + public readonly array $extra = [], + public readonly ?string $sourceFile = null, + public readonly ?int $lineNumber = null, + ) { + } + + /** + * @return array{ + * datetime: string, + * channel: string, + * level: string, + * message: string, + * context: array, + * extra: array, + * source_file: string|null, + * line_number: int|null + * } + */ + public function toArray(): array + { + return [ + 'datetime' => $this->datetime->format(\DateTimeInterface::ATOM), + 'channel' => $this->channel, + 'level' => $this->level, + 'message' => $this->message, + 'context' => $this->context, + 'extra' => $this->extra, + 'source_file' => $this->sourceFile, + 'line_number' => $this->lineNumber, + ]; + } + + public function matchesTerm(string $term): bool + { + $searchable = strtolower($this->message.' '.json_encode($this->context).' '.json_encode($this->extra)); + + return str_contains($searchable, strtolower($term)); + } + + public function matchesRegex(string $pattern): bool + { + $searchable = $this->message.' '.json_encode($this->context).' '.json_encode($this->extra); + + return (bool) preg_match($pattern, $searchable); + } + + public function hasContextValue(string $key, string $value): bool + { + if (!isset($this->context[$key])) { + return false; + } + + $contextValue = $this->context[$key]; + if (\is_string($contextValue)) { + return str_contains(strtolower($contextValue), strtolower($value)); + } + + if (\is_scalar($contextValue)) { + return strtolower((string) $contextValue) === strtolower($value); + } + + return str_contains(strtolower(json_encode($contextValue) ?: ''), strtolower($value)); + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/Model/SearchCriteria.php b/src/ai-mate/src/Bridge/Monolog/Model/SearchCriteria.php new file mode 100644 index 000000000..5be7a6493 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Model/SearchCriteria.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Model; + +/** + * Search criteria for filtering log entries. + * + * @author Johannes Wachter + */ +final class SearchCriteria +{ + public function __construct( + public readonly ?string $term = null, + public readonly ?string $regex = null, + public readonly ?string $level = null, + public readonly ?string $channel = null, + public readonly ?\DateTimeInterface $from = null, + public readonly ?\DateTimeInterface $to = null, + public readonly ?string $contextKey = null, + public readonly ?string $contextValue = null, + public readonly int $limit = 100, + public readonly int $offset = 0, + ) { + } + + public function matches(LogEntry $entry): bool + { + if (null !== $this->level && strtoupper($this->level) !== strtoupper($entry->level)) { + return false; + } + + if (null !== $this->channel && strtolower($this->channel) !== strtolower($entry->channel)) { + return false; + } + + if (null !== $this->from && $entry->datetime < $this->from) { + return false; + } + + if (null !== $this->to && $entry->datetime > $this->to) { + return false; + } + + if (null !== $this->term && !$entry->matchesTerm($this->term)) { + return false; + } + + if (null !== $this->regex && !$entry->matchesRegex($this->regex)) { + return false; + } + + if (null !== $this->contextKey && null !== $this->contextValue) { + if (!$entry->hasContextValue($this->contextKey, $this->contextValue)) { + return false; + } + } + + return true; + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/README.md b/src/ai-mate/src/Bridge/Monolog/README.md new file mode 100644 index 000000000..9b686ddcd --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/README.md @@ -0,0 +1,12 @@ +Monolog Bridge +============== + +Provides log search and analysis tools for Symfony AI Mate. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/ai/issues) and + [send Pull Requests](https://github.com/symfony/ai/pulls) + in the [main Symfony AI repository](https://github.com/symfony/ai) diff --git a/src/ai-mate/src/Bridge/Monolog/Service/LogParser.php b/src/ai-mate/src/Bridge/Monolog/Service/LogParser.php new file mode 100644 index 000000000..2e84106df --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Service/LogParser.php @@ -0,0 +1,324 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Service; + +use Symfony\AI\Mate\Bridge\Monolog\Model\LogEntry; + +/** + * Parses log lines from both JSON and standard Monolog line formats. + * + * @author Johannes Wachter + */ +final class LogParser +{ + /** + * Standard Monolog line format pattern. + * Matches: [2024-01-15 10:30:45] channel.LEVEL: Message {"context"} {"extra"}. + * Note: Context and extra JSON objects are parsed separately since regex cannot handle nested braces. + */ + private const LINE_PATTERN = '/^\[(?\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z)?)\]\s+(?[\w.-]+)\.(?\w+):\s+(?.+)$/'; + + public function parse(string $line, ?string $sourceFile = null, ?int $lineNumber = null): ?LogEntry + { + $line = trim($line); + + if ('' === $line) { + return null; + } + + return $this->tryParseJson($line, $sourceFile, $lineNumber) + ?? $this->tryParseText($line, $sourceFile, $lineNumber); + } + + private function tryParseJson(string $line, ?string $sourceFile, ?int $lineNumber): ?LogEntry + { + if (!str_starts_with($line, '{')) { + return null; + } + + try { + /** @var array|mixed $data */ + $data = json_decode($line, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + if (!\is_array($data)) { + return null; + } + + $datetime = $this->extractDateTime($data); + if (null === $datetime) { + return null; + } + + $channel = $data['channel'] ?? $data['channel_name'] ?? 'app'; + $channelStr = \is_string($channel) ? $channel : (\is_scalar($channel) ? (string) $channel : 'app'); + + $level = $data['level'] ?? $data['level_name'] ?? 'INFO'; + if (\is_int($level)) { + $levelStr = $this->levelNumberToName($level); + } else { + $levelStr = \is_string($level) ? $level : (\is_scalar($level) ? (string) $level : 'INFO'); + } + + $message = $data['message'] ?? $data['msg'] ?? ''; + $messageStr = \is_string($message) ? $message : (\is_scalar($message) ? (string) $message : ''); + + $contextRaw = $data['context'] ?? []; + $extraRaw = $data['extra'] ?? []; + + /** @var array $context */ + $context = \is_array($contextRaw) ? $contextRaw : []; + /** @var array $extra */ + $extra = \is_array($extraRaw) ? $extraRaw : []; + + return new LogEntry( + datetime: $datetime, + channel: $channelStr, + level: strtoupper($levelStr), + message: $messageStr, + context: $context, + extra: $extra, + sourceFile: $sourceFile, + lineNumber: $lineNumber, + ); + } + + private function tryParseText(string $line, ?string $sourceFile, ?int $lineNumber): ?LogEntry + { + if (!preg_match(self::LINE_PATTERN, $line, $matches)) { + return null; + } + + $datetime = $this->parseDateTime($matches['datetime']); + if (null === $datetime) { + return null; + } + + [$message, $context, $extra] = $this->parseMessageAndJson($matches['rest']); + + return new LogEntry( + datetime: $datetime, + channel: $matches['channel'], + level: strtoupper($matches['level']), + message: $message, + context: $context, + extra: $extra, + sourceFile: $sourceFile, + lineNumber: $lineNumber, + ); + } + + /** + * Parse message and trailing JSON objects from a log line rest. + * + * @return array{0: string, 1: array, 2: array} + */ + private function parseMessageAndJson(string $rest): array + { + $rest = trim($rest); + $context = []; + $extra = []; + $message = $rest; + + // Try to extract JSON objects from the end of the line + // Pattern: "message text {...} {...}" or "message text {...} []" or "message text [] []" + $jsonObjects = []; + $workingString = $rest; + + for ($i = 0; $i < 2; ++$i) { + $extracted = $this->extractTrailingJson($workingString); + if (null === $extracted) { + break; + } + + [$json, $remaining] = $extracted; + array_unshift($jsonObjects, $json); + $workingString = $remaining; + } + + if (2 === \count($jsonObjects)) { + $message = trim($workingString); + $context = $jsonObjects[0]; + $extra = $jsonObjects[1]; + } elseif (1 === \count($jsonObjects)) { + $message = trim($workingString); + $context = $jsonObjects[0]; + } + + return [$message, $context, $extra]; + } + + /** + * @return array{0: array, 1: string}|null Returns [parsed_json, remaining_string] or null + */ + private function extractTrailingJson(string $str): ?array + { + $str = rtrim($str); + + if ('' === $str) { + return null; + } + + $lastChar = $str[-1]; + + if ('}' !== $lastChar && ']' !== $lastChar) { + return null; + } + + $closingChar = $lastChar; + $openingChar = '}' === $closingChar ? '{' : '['; + $depth = 0; + $inString = false; + $escape = false; + $startPos = null; + + for ($i = \strlen($str) - 1; $i >= 0; --$i) { + $char = $str[$i]; + + if ($escape) { + $escape = false; + continue; + } + + if ('\\' === $char && $inString) { + $escape = true; + continue; + } + + if ('"' === $char) { + $inString = !$inString; + continue; + } + + if ($inString) { + continue; + } + + if ($char === $closingChar) { + ++$depth; + } elseif ($char === $openingChar) { + --$depth; + if (0 === $depth) { + $startPos = $i; + break; + } + } + } + + if (null === $startPos) { + return null; + } + + if ($startPos > 0 && ' ' !== $str[$startPos - 1]) { + return null; + } + + $jsonStr = substr($str, $startPos); + $remaining = substr($str, 0, $startPos); + + if ('[]' === $jsonStr || '{}' === $jsonStr) { + return [[], rtrim($remaining)]; + } + + try { + $parsed = json_decode($jsonStr, true, 512, \JSON_THROW_ON_ERROR); + if (\is_array($parsed)) { + /** @var array $validatedParsed */ + $validatedParsed = $parsed; + + return [$validatedParsed, rtrim($remaining)]; + } + + return null; + } catch (\JsonException) { + return null; + } + } + + /** + * @param array $data + */ + private function extractDateTime(array $data): ?\DateTimeImmutable + { + $datetimeValue = $data['datetime'] ?? $data['timestamp'] ?? $data['time'] ?? $data['@timestamp'] ?? null; + + if (null === $datetimeValue) { + return null; + } + + if (\is_array($datetimeValue)) { + $dateStr = $datetimeValue['date'] ?? null; + if (\is_string($dateStr)) { + return $this->parseDateTime($dateStr); + } + + return null; + } + + // Handle string format + if (\is_string($datetimeValue)) { + return $this->parseDateTime($datetimeValue); + } + + // Handle Unix timestamp + if (\is_int($datetimeValue) || \is_float($datetimeValue)) { + return (new \DateTimeImmutable())->setTimestamp((int) $datetimeValue); + } + + return null; + } + + private function parseDateTime(string $datetime): ?\DateTimeImmutable + { + $formats = [ + 'Y-m-d H:i:s.u', + 'Y-m-d H:i:s', + 'Y-m-d\TH:i:s.uP', + 'Y-m-d\TH:i:sP', + 'Y-m-d\TH:i:s.u', + 'Y-m-d\TH:i:s', + \DateTimeInterface::ATOM, + \DateTimeInterface::RFC3339, + \DateTimeInterface::RFC3339_EXTENDED, + ]; + + foreach ($formats as $format) { + $parsed = \DateTimeImmutable::createFromFormat($format, $datetime); + if (false !== $parsed) { + return $parsed; + } + } + + try { + return new \DateTimeImmutable($datetime); + } catch (\Exception) { + return null; + } + } + + private function levelNumberToName(int $level): string + { + return match ($level) { + 100 => 'DEBUG', + 200 => 'INFO', + 250 => 'NOTICE', + 300 => 'WARNING', + 400 => 'ERROR', + 500 => 'CRITICAL', + 550 => 'ALERT', + 600 => 'EMERGENCY', + default => 'INFO', + }; + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/Service/LogReader.php b/src/ai-mate/src/Bridge/Monolog/Service/LogReader.php new file mode 100644 index 000000000..6399a7132 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Service/LogReader.php @@ -0,0 +1,223 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Service; + +use Symfony\AI\Mate\Bridge\Monolog\Exception\LogFileNotFoundException; +use Symfony\AI\Mate\Bridge\Monolog\Model\LogEntry; +use Symfony\AI\Mate\Bridge\Monolog\Model\SearchCriteria; + +/** + * Reads and parses log files from a directory. + * + * @author Johannes Wachter + */ +final class LogReader +{ + public function __construct( + private LogParser $parser, + private string $logDir, + ) { + } + + /** + * @return string[] + */ + public function getLogFiles(): array + { + if (!is_dir($this->logDir)) { + return []; + } + + $files = glob($this->logDir.'/*.log'); + if (false === $files) { + return []; + } + + usort($files, static fn (string $a, string $b) => filemtime($b) <=> filemtime($a)); + + return $files; + } + + /** + * @return string[] + */ + public function getLogFilesForEnvironment(string $environment): array + { + $files = $this->getLogFiles(); + + return array_filter($files, static function (string $file) use ($environment) { + $filename = basename($file); + + // Match files like dev.log, prod.log, test.log + // Or files containing the environment name like app_dev.log + return str_contains($filename, $environment); + }); + } + + /** + * @return \Generator + */ + public function readAll(?SearchCriteria $criteria = null): \Generator + { + $files = $this->getLogFiles(); + + yield from $this->readFiles($files, $criteria); + } + + /** + * @return \Generator + */ + public function readForEnvironment(string $environment, ?SearchCriteria $criteria = null): \Generator + { + $files = $this->getLogFilesForEnvironment($environment); + + yield from $this->readFiles($files, $criteria); + } + + /** + * @return \Generator + */ + public function readFile(string $filePath, ?SearchCriteria $criteria = null): \Generator + { + if (!file_exists($filePath)) { + throw LogFileNotFoundException::forPath($filePath); + } + + yield from $this->readFiles([$filePath], $criteria); + } + + /** + * @param string[] $files + * + * @return \Generator + */ + public function readFiles(array $files, ?SearchCriteria $criteria = null): \Generator + { + $count = 0; + $limit = null !== $criteria ? $criteria->limit : \PHP_INT_MAX; + $offset = null !== $criteria ? $criteria->offset : 0; + $skipped = 0; + + foreach ($files as $file) { + if ($count >= $limit) { + return; + } + + if (!file_exists($file) || !is_readable($file)) { + continue; + } + + $handle = fopen($file, 'r'); + if (false === $handle) { + continue; + } + + try { + $lineNumber = 0; + $relativePath = $this->getRelativePath($file); + + while (false !== ($line = fgets($handle))) { + ++$lineNumber; + + $entry = $this->parser->parse($line, $relativePath, $lineNumber); + if (null === $entry) { + continue; + } + + if (null !== $criteria && !$criteria->matches($entry)) { + continue; + } + + if ($skipped < $offset) { + ++$skipped; + continue; + } + + yield $entry; + ++$count; + + if ($count >= $limit) { + return; + } + } + } finally { + fclose($handle); + } + } + } + + /** + * @return LogEntry[] + */ + public function tail(int $lines = 50, ?string $level = null, ?string $environment = null): array + { + $files = null !== $environment + ? $this->getLogFilesForEnvironment($environment) + : $this->getLogFiles(); + + if ([] === $files) { + return []; + } + + $file = $files[0]; + if (!file_exists($file) || !is_readable($file)) { + return []; + } + + $entries = []; + $allLines = file($file, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES); + if (false === $allLines) { + return []; + } + + $relativePath = $this->getRelativePath($file); + $totalLines = \count($allLines); + + for ($i = $totalLines - 1; $i >= 0 && \count($entries) < $lines; --$i) { + $entry = $this->parser->parse($allLines[$i], $relativePath, $i + 1); + if (null === $entry) { + continue; + } + + if (null !== $level && strtoupper($level) !== $entry->level) { + continue; + } + + $entries[] = $entry; + } + + return array_reverse($entries); + } + + /** + * @return string[] + */ + public function getUniqueChannels(): array + { + $channels = []; + + foreach ($this->readAll() as $entry) { + $channels[$entry->channel] = true; + } + + return array_keys($channels); + } + + private function getRelativePath(string $filePath): string + { + if (str_starts_with($filePath, $this->logDir)) { + return ltrim(substr($filePath, \strlen($this->logDir)), '/\\'); + } + + return basename($filePath); + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php b/src/ai-mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php new file mode 100644 index 000000000..8084bc1b4 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Tests\Capability; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Mate\Bridge\Monolog\Capability\LogSearchTool; +use Symfony\AI\Mate\Bridge\Monolog\Service\LogParser; +use Symfony\AI\Mate\Bridge\Monolog\Service\LogReader; + +/** + * @author Johannes Wachter + */ +class LogSearchToolTest extends TestCase +{ + private LogSearchTool $tool; + + protected function setUp(): void + { + $fixturesDir = \dirname(__DIR__).'/Fixtures'; + $reader = new LogReader(new LogParser(), $fixturesDir); + $this->tool = new LogSearchTool($reader); + } + + public function testSearch() + { + $results = $this->tool->search('database'); + + $this->assertCount(1, $results); + $this->assertStringContainsString('Database', $results[0]['message']); + } + + public function testSearchWithLevel() + { + $results = $this->tool->search('', 'ERROR'); + + $this->assertCount(2, $results); + foreach ($results as $result) { + $this->assertSame('ERROR', $result['level']); + } + } + + public function testSearchWithChannel() + { + $results = $this->tool->search('', null, 'security'); + + $this->assertCount(2, $results); + foreach ($results as $result) { + $this->assertSame('security', $result['channel']); + } + } + + public function testSearchWithLimit() + { + $results = $this->tool->search('', limit: 3); + + $this->assertCount(3, $results); + } + + public function testSearchRegex() + { + $results = $this->tool->searchRegex('/connection|timeout/i'); + + $this->assertGreaterThanOrEqual(1, \count($results)); + } + + public function testSearchContext() + { + $results = $this->tool->searchContext('user_id', '123'); + + $this->assertCount(1, $results); + $this->assertSame(123, $results[0]['context']['user_id']); + } + + public function testTail() + { + $results = $this->tool->tail(5); + + $this->assertCount(5, $results); + } + + public function testListFiles() + { + $files = $this->tool->listFiles(); + + $this->assertCount(2, $files); + foreach ($files as $file) { + $this->assertArrayHasKey('name', $file); + $this->assertArrayHasKey('path', $file); + $this->assertArrayHasKey('size', $file); + $this->assertArrayHasKey('modified', $file); + } + } + + public function testListChannels() + { + $channels = $this->tool->listChannels(); + + $this->assertContains('app', $channels); + $this->assertContains('security', $channels); + } + + public function testByLevel() + { + $results = $this->tool->byLevel('WARNING'); + + $this->assertGreaterThanOrEqual(1, \count($results)); + foreach ($results as $result) { + $this->assertSame('WARNING', $result['level']); + } + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log b/src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log new file mode 100644 index 000000000..f2c75da89 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log @@ -0,0 +1,5 @@ +{"datetime":"2024-01-15T11:00:00+00:00","channel":"app","level":"INFO","message":"API request received","context":{"endpoint":"/api/products"},"extra":[]} +{"datetime":"2024-01-15T11:01:00+00:00","channel":"app","level":"DEBUG","message":"Query executed","context":{"query":"SELECT * FROM users"},"extra":[]} +{"datetime":"2024-01-15T11:02:00+00:00","channel":"security","level":"INFO","message":"User authenticated","context":{"user_id":456},"extra":[]} +{"datetime":"2024-01-15T11:03:00+00:00","channel":"app","level":"ERROR","message":"Failed to process payment","context":{"error":"Invalid card"},"extra":[]} +{"datetime":"2024-01-15T11:04:00+00:00","channel":"app","level":"INFO","message":"Request completed","context":{"duration":125},"extra":[]} diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.log b/src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.log new file mode 100644 index 000000000..800b7b575 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.log @@ -0,0 +1,6 @@ +[2024-01-15 10:30:45] app.INFO: Application started [] [] +[2024-01-15 10:31:12] app.DEBUG: Processing request {"method":"GET","path":"/api/users"} [] +[2024-01-15 10:31:45] security.WARNING: Failed login attempt {"username":"admin","ip":"192.168.1.1"} [] +[2024-01-15 10:32:10] app.ERROR: Database connection failed {"error":"Connection timeout"} [] +[2024-01-15 10:32:55] app.INFO: User logged in {"user_id":123} [] +[2024-01-15 10:33:20] app.DEBUG: Cache cleared [] [] diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php b/src/ai-mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php new file mode 100644 index 000000000..cedf6592c --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Tests\Service; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Mate\Bridge\Monolog\Service\LogParser; + +/** + * @author Johannes Wachter + */ +class LogParserTest extends TestCase +{ + private LogParser $parser; + + protected function setUp(): void + { + $this->parser = new LogParser(); + } + + public function testParseLineFormat() + { + $line = '[2024-01-15 10:30:45] app.ERROR: Database connection failed {"exception":"PDOException"} {"retry":3}'; + + $entry = $this->parser->parse($line); + + $this->assertNotNull($entry); + $this->assertSame('2024-01-15', $entry->datetime->format('Y-m-d')); + $this->assertSame('10:30:45', $entry->datetime->format('H:i:s')); + $this->assertSame('app', $entry->channel); + $this->assertSame('ERROR', $entry->level); + $this->assertSame('Database connection failed', $entry->message); + $this->assertSame(['exception' => 'PDOException'], $entry->context); + $this->assertSame(['retry' => 3], $entry->extra); + } + + public function testParseLineFormatWithoutContext() + { + $line = '[2024-01-15 10:30:45] app.INFO: Simple message [] []'; + + $entry = $this->parser->parse($line); + + $this->assertNotNull($entry); + $this->assertSame('app', $entry->channel); + $this->assertSame('INFO', $entry->level); + $this->assertSame('Simple message', $entry->message); + $this->assertSame([], $entry->context); + $this->assertSame([], $entry->extra); + } + + public function testParseJsonFormat() + { + $line = '{"datetime":"2024-01-15T11:00:00+00:00","channel":"app","level":"INFO","message":"Test message","context":{"key":"value"},"extra":{}}'; + + $entry = $this->parser->parse($line); + + $this->assertNotNull($entry); + $this->assertSame('2024-01-15', $entry->datetime->format('Y-m-d')); + $this->assertSame('app', $entry->channel); + $this->assertSame('INFO', $entry->level); + $this->assertSame('Test message', $entry->message); + $this->assertSame(['key' => 'value'], $entry->context); + $this->assertSame([], $entry->extra); + } + + public function testParseJsonFormatWithNumericLevel() + { + $line = '{"datetime":"2024-01-15T11:00:00+00:00","channel":"app","level":400,"message":"Error occurred","context":{},"extra":{}}'; + + $entry = $this->parser->parse($line); + + $this->assertNotNull($entry); + $this->assertSame('ERROR', $entry->level); + } + + public function testParseEmptyLine() + { + $entry = $this->parser->parse(''); + + $this->assertNull($entry); + } + + public function testParseInvalidLine() + { + $entry = $this->parser->parse('This is not a valid log line'); + + $this->assertNull($entry); + } + + public function testParseInvalidJson() + { + $entry = $this->parser->parse('{invalid json}'); + + $this->assertNull($entry); + } + + public function testParseWithSourceFileAndLineNumber() + { + $line = '[2024-01-15 10:30:45] app.INFO: Test message [] []'; + + $entry = $this->parser->parse($line, 'dev.log', 42); + + $this->assertNotNull($entry); + $this->assertSame('dev.log', $entry->sourceFile); + $this->assertSame(42, $entry->lineNumber); + } + + public function testParseLineFormatWithTimezone() + { + $line = '[2024-01-15T10:30:45+01:00] app.INFO: Message with timezone [] []'; + + $entry = $this->parser->parse($line); + + $this->assertNotNull($entry); + $this->assertSame('app', $entry->channel); + $this->assertSame('INFO', $entry->level); + } + + public function testParseLineFormatWithMilliseconds() + { + $line = '[2024-01-15 10:30:45.123456] app.DEBUG: Message with microseconds [] []'; + + $entry = $this->parser->parse($line); + + $this->assertNotNull($entry); + $this->assertSame('DEBUG', $entry->level); + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php b/src/ai-mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php new file mode 100644 index 000000000..05e5cc701 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Monolog\Tests\Service; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Mate\Bridge\Monolog\Model\SearchCriteria; +use Symfony\AI\Mate\Bridge\Monolog\Service\LogParser; +use Symfony\AI\Mate\Bridge\Monolog\Service\LogReader; + +/** + * @author Johannes Wachter + */ +class LogReaderTest extends TestCase +{ + private LogReader $reader; + private string $fixturesDir; + + protected function setUp(): void + { + $this->fixturesDir = \dirname(__DIR__).'/Fixtures'; + $this->reader = new LogReader(new LogParser(), $this->fixturesDir); + } + + public function testGetLogFiles() + { + $files = $this->reader->getLogFiles(); + + $this->assertCount(2, $files); + $this->assertContains($this->fixturesDir.'/sample.log', $files); + $this->assertContains($this->fixturesDir.'/sample.json.log', $files); + } + + public function testReadAll() + { + $entries = iterator_to_array($this->reader->readAll()); + + // 6 entries in sample.log + 5 entries in sample.json.log = 11 total + $this->assertCount(11, $entries); + } + + public function testReadAllWithLimit() + { + $criteria = new SearchCriteria(limit: 5); + $entries = iterator_to_array($this->reader->readAll($criteria)); + + $this->assertCount(5, $entries); + } + + public function testReadAllWithLevelFilter() + { + $criteria = new SearchCriteria(level: 'ERROR'); + $entries = iterator_to_array($this->reader->readAll($criteria)); + + // 1 ERROR in sample.log + 1 ERROR in sample.json.log = 2 total + $this->assertCount(2, $entries); + foreach ($entries as $entry) { + $this->assertSame('ERROR', $entry->level); + } + } + + public function testReadAllWithChannelFilter() + { + $criteria = new SearchCriteria(channel: 'security'); + $entries = iterator_to_array($this->reader->readAll($criteria)); + + // 1 in sample.log + 1 in sample.json.log = 2 total + $this->assertCount(2, $entries); + foreach ($entries as $entry) { + $this->assertSame('security', $entry->channel); + } + } + + public function testReadAllWithTermSearch() + { + $criteria = new SearchCriteria(term: 'database'); + $entries = iterator_to_array($this->reader->readAll($criteria)); + + $this->assertCount(1, $entries); + $this->assertStringContainsString('Database', $entries[0]->message); + } + + public function testReadFile() + { + $entries = iterator_to_array($this->reader->readFile($this->fixturesDir.'/sample.log')); + + $this->assertCount(6, $entries); + } + + public function testTail() + { + $entries = $this->reader->tail(3); + + $this->assertCount(3, $entries); + } + + public function testTailWithLevel() + { + $entries = $this->reader->tail(10, 'ERROR'); + + // Only ERROR entries should be returned + foreach ($entries as $entry) { + $this->assertSame('ERROR', $entry->level); + } + } + + public function testGetChannels() + { + $channels = $this->reader->getUniqueChannels(); + + $this->assertContains('app', $channels); + $this->assertContains('security', $channels); + } + + public function testGetLogFilesForNonExistentDirectory() + { + $reader = new LogReader(new LogParser(), '/non/existent/path'); + $files = $reader->getLogFiles(); + + $this->assertSame([], $files); + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/composer.json b/src/ai-mate/src/Bridge/Monolog/composer.json new file mode 100644 index 000000000..af2cc6e6c --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/composer.json @@ -0,0 +1,63 @@ +{ + "name": "symfony/ai-mate-monolog", + "description": "Monolog bridge for AI Mate - provides log search and analysis tools", + "license": "MIT", + "type": "library", + "keywords": [ + "ai", + "mcp", + "monolog", + "logs", + "bridge" + ], + "authors": [ + { + "name": "Johannes Wachter", + "email": "johannes@sulu.io" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/ai-mate": "@dev", + "monolog/monolog": "^2.0|^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\Mate\\Bridge\\Monolog\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/", + "Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\": "Tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.x-dev" + }, + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + }, + "ai-mate": { + "scan-dirs": ["Capability"], + "includes": ["config/services.php"] + } + } +} diff --git a/src/ai-mate/src/Bridge/Monolog/config/services.php b/src/ai-mate/src/Bridge/Monolog/config/services.php new file mode 100644 index 000000000..8734e9ba4 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/config/services.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Mate\Bridge\Monolog; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +return function (ContainerConfigurator $configurator) { + $configurator->parameters() + ->set('ai_mate_monolog.log_dir', '%root_dir%/var/log'); + + $configurator->services() + ->set(Monolog\Service\LogParser::class) + + ->set(Monolog\Service\LogReader::class) + ->arg('$logDir', '%ai_mate_monolog.log_dir%') + + ->set(Monolog\Capability\LogSearchTool::class); +}; diff --git a/src/ai-mate/src/Bridge/Monolog/phpunit.xml.dist b/src/ai-mate/src/Bridge/Monolog/phpunit.xml.dist new file mode 100644 index 000000000..e4c120335 --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/ai-mate/src/Bridge/Symfony/Capability/ServiceTool.php b/src/ai-mate/src/Bridge/Symfony/Capability/ServiceTool.php new file mode 100644 index 000000000..b673347a6 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Capability/ServiceTool.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Capability; + +use Mcp\Capability\Attribute\McpTool; +use Symfony\AI\Mate\Bridge\Symfony\Model\Container; +use Symfony\AI\Mate\Bridge\Symfony\Service\ContainerProvider; + +/** + * @author Tobias Nyholm + */ +class ServiceTool +{ + public function __construct( + private string $cacheDir, + private ContainerProvider $provider, + ) { + } + + /** + * @return array + */ + #[McpTool('symfony-services', 'Get a list of all symfony services')] + public function getAllServices(): array + { + $container = $this->readContainer(); + if (null === $container) { + return []; + } + + $output = []; + foreach ($container->services as $service) { + $output[$service->id] = $service->class; + } + + return $output; + } + + private function readContainer(): ?Container + { + $environments = ['', '/dev', '/test', '/prod']; + foreach ($environments as $env) { + $file = $this->cacheDir."$env/App_KernelDevDebugContainer.xml"; + if (file_exists($file)) { + return $this->provider->getContainer($file); + } + } + + return null; + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/Exception/ExceptionInterface.php b/src/ai-mate/src/Bridge/Symfony/Exception/ExceptionInterface.php new file mode 100644 index 000000000..623adef4f --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Exception; + +/** + * @author Tobias Nyholm + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/ai-mate/src/Bridge/Symfony/Exception/FileNotFoundException.php b/src/ai-mate/src/Bridge/Symfony/Exception/FileNotFoundException.php new file mode 100644 index 000000000..66e6ce185 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Exception/FileNotFoundException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Exception; + +/** + * @internal + * + * @author Tobias Nyholm + */ +final class FileNotFoundException extends \InvalidArgumentException implements ExceptionInterface +{ + public static function forContainerXml(string $path): self + { + return new self(\sprintf('Container XML at "%s" does not exist', $path)); + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php b/src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php new file mode 100644 index 000000000..58506c567 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Exception; + +/** + * @internal + * + * @author Tobias Nyholm + */ +class XmlContainerCouldNotBeLoadedException extends \InvalidArgumentException implements ExceptionInterface +{ + public static function forContainerDoesNotExist(string $path): self + { + return new self(\sprintf('Container "%s" does not exist', $path)); + } + + public static function forContainerCannotBeParsed(string $path): self + { + return new self(\sprintf('Container "%s" cannot be parsed', $path)); + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php b/src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php new file mode 100644 index 000000000..f9d87d22b --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Exception; + +/** + * @internal + * + * @author Tobias Nyholm + */ +final class XmlContainerPathIsNotConfiguredException extends XmlContainerCouldNotBeLoadedException +{ + public static function emptyPath(): self + { + return new self('Failed to configure path to Symfony container. You passed an empty string'); + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/Model/Container.php b/src/ai-mate/src/Bridge/Symfony/Model/Container.php new file mode 100644 index 000000000..adf007887 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Model/Container.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Model; + +/** + * @internal + * + * @author Tobias Nyholm + */ +class Container +{ + public function __construct( + /** + * @var array + */ + public array $services, + ) { + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/Model/ServiceDefinition.php b/src/ai-mate/src/Bridge/Symfony/Model/ServiceDefinition.php new file mode 100644 index 000000000..1a59bb63e --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Model/ServiceDefinition.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Model; + +/** + * @internal + * + * @author Tobias Nyholm + */ +class ServiceDefinition +{ + public function __construct( + public string $id, + /** + * @var ?class-string + */ + public ?string $class, + /** + * If this has a value, it is the "real" definition's id. + */ + public ?string $alias, + /** + * @var array + */ + public array $calls, + /** + * @var list + */ + public array $tags, + /** + * @var array{0: string|null, 1: string} + */ + public array $constructor, + ) { + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/Model/ServiceTag.php b/src/ai-mate/src/Bridge/Symfony/Model/ServiceTag.php new file mode 100644 index 000000000..82e2964f2 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Model/ServiceTag.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Model; + +/** + * @internal + * + * @author Tobias Nyholm + */ +class ServiceTag +{ + public function __construct( + public string $name, + /** + * @var array + */ + public array $attributes = [], + ) { + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/README.md b/src/ai-mate/src/Bridge/Symfony/README.md new file mode 100644 index 000000000..f0f8f8025 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/README.md @@ -0,0 +1,12 @@ +Symfony Bridge +============== + +Provides Symfony container introspection tools for Symfony AI Mate. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/ai/issues) and + [send Pull Requests](https://github.com/symfony/ai/pulls) + in the [main Symfony AI repository](https://github.com/symfony/ai) diff --git a/src/ai-mate/src/Bridge/Symfony/Service/ContainerProvider.php b/src/ai-mate/src/Bridge/Symfony/Service/ContainerProvider.php new file mode 100644 index 000000000..76df795ee --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Service/ContainerProvider.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Service; + +use Symfony\AI\Mate\Bridge\Symfony\Exception\FileNotFoundException; +use Symfony\AI\Mate\Bridge\Symfony\Exception\XmlContainerCouldNotBeLoadedException; +use Symfony\AI\Mate\Bridge\Symfony\Exception\XmlContainerPathIsNotConfiguredException; +use Symfony\AI\Mate\Bridge\Symfony\Model\Container; +use Symfony\AI\Mate\Bridge\Symfony\Model\ServiceDefinition; +use Symfony\AI\Mate\Bridge\Symfony\Model\ServiceTag; + +/** + * This will parse an App_KernelDevDebugContainer.xml and return value objects. + * + * @author Tobias Nyholm + */ +class ContainerProvider +{ + /** + * @var array + */ + private array $container = []; + + /** + * @throws XmlContainerCouldNotBeLoadedException + */ + public function getContainer(string $containerXmlPath): Container + { + if (null === ($this->container[$containerXmlPath] ?? null)) { + $this->container[$containerXmlPath] = $this->read($containerXmlPath); + } + + return $this->container[$containerXmlPath]; + } + + /** + * @throws XmlContainerCouldNotBeLoadedException + */ + private function read(string $containerXmlPath): Container + { + $xml = $this->parseXml($containerXmlPath); + + /** @var array $services */ + $services = []; + /** @var ServiceDefinition[] $aliases */ + $aliases = []; + + if (isset($xml->services) && \count($xml->services) > 0) { + foreach ($xml->services->service as $def) { + /** @var \SimpleXMLElement $attrs */ + $attrs = $def->attributes(); + if (!isset($attrs->id)) { + continue; + } + + $calls = []; + foreach ($def->call as $call) { + $calls[] = (string) $call->attributes()->method; + } + + $serviceTags = []; + foreach ($def->tag as $tag) { + /** @var array $tagAttrs */ + $tagAttrs = ((array) $tag->attributes())['@attributes'] ?? []; + $tagName = $tagAttrs['name']; + unset($tagAttrs['name']); + + $serviceTags[] = new ServiceTag($tagName, $tagAttrs); + } + + /** @var ?class-string $class */ + $class = isset($attrs->class) ? (string) $attrs->class : null; + $constructor = '__construct'; + if (isset($attrs->constructor)) { + $constructor = (string) $attrs->constructor; + } + $constructor = [$class, $constructor]; + if (isset($def->factory)) { + $constructor = [(string) $def->factory->attributes()->class, (string) $def->factory->attributes()->method]; + } + + $service = new ServiceDefinition( + self::cleanServiceId((string) $attrs->id), + $class, + isset($attrs->alias) ? self::cleanServiceId((string) $attrs->alias) : null, + $calls, + $serviceTags, + $constructor, + ); + + if (null === $service->alias) { + $services[$service->id] = $service; + } else { + $aliases[] = $service; + } + } + } + + foreach ($aliases as $service) { + $alias = $service->alias; + if (null === $alias || !isset($services[$alias])) { + continue; + } + + $services[$service->id] = new ServiceDefinition( + $service->id, + $services[$alias]->class, + null, + $services[$alias]->calls, + $services[$alias]->tags, + $services[$alias]->constructor, + ); + } + + return new Container($services); + } + + private function cleanServiceId(string $id): string + { + return str_starts_with($id, '.') ? mb_substr($id, 1) : $id; + } + + /** + * @throws XmlContainerCouldNotBeLoadedException + * @throws FileNotFoundException + */ + private function parseXml(string $containerXmlPath): \SimpleXMLElement + { + if ('' === $containerXmlPath) { + throw XmlContainerPathIsNotConfiguredException::emptyPath(); + } + + if (!file_exists($containerXmlPath)) { + throw FileNotFoundException::forContainerXml($containerXmlPath); + } + + $fileContents = file_get_contents($containerXmlPath); + if (false === $fileContents) { + throw XmlContainerCouldNotBeLoadedException::forContainerDoesNotExist($containerXmlPath); + } + + $xml = @simplexml_load_string($fileContents); + if (false === $xml) { + throw XmlContainerCouldNotBeLoadedException::forContainerCannotBeParsed($containerXmlPath); + } + + return $xml; + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php b/src/ai-mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php new file mode 100644 index 000000000..4cc69242b --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Capability; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Mate\Bridge\Symfony\Capability\ServiceTool; +use Symfony\AI\Mate\Bridge\Symfony\Service\ContainerProvider; + +/** + * @author Tobias Nyholm + */ +class ServiceToolTest extends TestCase +{ + public function testAetAllServices() + { + $tool = new ServiceTool( + \dirname(__DIR__).'/Fixtures', + new ContainerProvider() + ); + + $output = $tool->getAllServices(); + $this->assertCount(6, $output); + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml b/src/ai-mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml new file mode 100644 index 000000000..a1c626344 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/ai-mate/src/Bridge/Symfony/composer.json b/src/ai-mate/src/Bridge/Symfony/composer.json new file mode 100644 index 000000000..6fcd19073 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/composer.json @@ -0,0 +1,63 @@ +{ + "name": "symfony/ai-mate-symfony", + "description": "Symfony bridge for AI Mate - provides Symfony container introspection tools", + "license": "MIT", + "type": "library", + "keywords": [ + "ai", + "mcp", + "symfony", + "debug", + "bridge" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Johannes Wachter", + "email": "johannes@sulu.io" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/ai-mate": "@dev", + "symfony/dependency-injection": "^6.4|^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\Mate\\Bridge\\Symfony\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/", + "Symfony\\AI\\Mate\\Bridge\\Symfony\\Tests\\": "Tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.x-dev" + }, + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + }, + "ai-mate": { + "scan-dirs": ["Capability"], + "includes": ["config/services.php"] + } + } +} diff --git a/src/ai-mate/src/Bridge/Symfony/config/services.php b/src/ai-mate/src/Bridge/Symfony/config/services.php new file mode 100644 index 000000000..d51ca1d41 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/config/services.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Mate\Bridge\Symfony; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +return function (ContainerConfigurator $configurator) { + $configurator->parameters() + ->set('ai_mate_symfony.cache_dir', '%root_dir%/cache'); + + $configurator->services() + ->set(Symfony\Capability\ServiceTool::class) + ->arg('$cacheDir', '%ai_mate_symfony.cache_dir%') + ->set(Symfony\Service\ContainerProvider::class); +}; diff --git a/src/ai-mate/src/Bridge/Symfony/phpunit.xml.dist b/src/ai-mate/src/Bridge/Symfony/phpunit.xml.dist new file mode 100644 index 000000000..35c34d7d5 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/ai-mate/src/Capability/ServerInfo.php b/src/ai-mate/src/Capability/ServerInfo.php new file mode 100644 index 000000000..8c88063e1 --- /dev/null +++ b/src/ai-mate/src/Capability/ServerInfo.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Capability; + +use Mcp\Capability\Attribute\McpTool; + +/** + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class ServerInfo +{ + #[McpTool('php-version', 'Get the version of PHP')] + public function phpVersion(): string + { + return \PHP_VERSION; + } + + #[McpTool('operating-system', 'Get the current operating system')] + public function operatingSystem(): string + { + return \PHP_OS; + } + + #[McpTool('operating-system-family', 'Get the current operating system family')] + public function operatingSystemFamily(): string + { + return \PHP_OS_FAMILY; + } + + /** + * @return string[] + */ + #[McpTool('php-extensions', 'Get a list of PHP extensions')] + public function extensions(): array + { + return get_loaded_extensions(); + } +} diff --git a/src/ai-mate/src/Command/ClearCacheCommand.php b/src/ai-mate/src/Command/ClearCacheCommand.php new file mode 100644 index 000000000..fe9978b2c --- /dev/null +++ b/src/ai-mate/src/Command/ClearCacheCommand.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Finder\Finder; + +/** + * Clear the MCP server cache. + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class ClearCacheCommand extends Command +{ + public function __construct( + private string $cacheDir, + ) { + parent::__construct(self::getDefaultName()); + } + + public static function getDefaultName(): ?string + { + return 'clear-cache'; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('Cache Management'); + $io->text(\sprintf('Cache directory: %s', $this->cacheDir)); + $io->newLine(); + + $cacheDir = $this->cacheDir; + + if (!is_dir($cacheDir)) { + $io->note('Cache directory does not exist. Nothing to clear.'); + + return Command::SUCCESS; + } + + $finder = new Finder(); + $finder->files()->in($cacheDir); + + $count = 0; + $totalSize = 0; + $fileList = []; + + foreach ($finder as $file) { + $size = $file->getSize(); + $totalSize += $size; + $fileList[] = [ + basename($file->getFilename()), + $this->formatBytes($size), + ]; + unlink($file->getRealPath()); + ++$count; + } + + if ($count > 0) { + $io->section('Cleared Files'); + $io->table(['File', 'Size'], $fileList); + + $io->success(\sprintf( + 'Successfully cleared %d cache file%s (%s)', + $count, + 1 === $count ? '' : 's', + $this->formatBytes($totalSize) + )); + } else { + $io->info('Cache directory is already empty.'); + } + + return Command::SUCCESS; + } + + private function formatBytes(int $bytes): string + { + if ($bytes < 1024) { + return $bytes.' B'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 2).' KB'; + } + + return round($bytes / 1048576, 2).' MB'; + } +} diff --git a/src/ai-mate/src/Command/DiscoverCommand.php b/src/ai-mate/src/Command/DiscoverCommand.php new file mode 100644 index 000000000..2c5e0b97a --- /dev/null +++ b/src/ai-mate/src/Command/DiscoverCommand.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Command; + +use Psr\Log\LoggerInterface; +use Symfony\AI\Mate\Discovery\ComposerTypeDiscovery; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Discover MCP extensions installed via Composer. + * + * Scans for packages with extra.ai-mate configuration + * and generates/updates .mate/extensions.php with discovered extensions. + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class DiscoverCommand extends Command +{ + public function __construct( + private string $rootDir, + private LoggerInterface $logger, + ) { + parent::__construct(self::getDefaultName()); + } + + public static function getDefaultName(): ?string + { + return 'discover'; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('MCP Extension Discovery'); + $io->text('Scanning for packages with extra.ai-mate configuration...'); + $io->newLine(); + + $discovery = new ComposerTypeDiscovery($this->rootDir, $this->logger); + + $extensions = $discovery->discover([]); + + $count = \count($extensions); + if (0 === $count) { + $io->warning([ + 'No MCP extensions found.', + 'Packages must have "extra.ai-mate" configuration in their composer.json.', + ]); + $io->note('Run "composer require vendor/package" to install MCP extensions.'); + + return Command::SUCCESS; + } + + $extensionsFile = $this->rootDir.'/.mate/extensions.php'; + $existingExtensions = []; + $newPackages = []; + $removedPackages = []; + if (file_exists($extensionsFile)) { + $existingExtensions = include $extensionsFile; + if (!\is_array($existingExtensions)) { + $existingExtensions = []; + } + } + + foreach ($extensions as $packageName => $data) { + if (!isset($existingExtensions[$packageName])) { + $newPackages[] = $packageName; + } + } + + foreach ($existingExtensions as $packageName => $data) { + if (!isset($extensions[$packageName])) { + $removedPackages[] = $packageName; + } + } + + $io->section(\sprintf('Discovered %d Extension%s', $count, 1 === $count ? '' : 's')); + $rows = []; + foreach ($extensions as $packageName => $data) { + $isNew = \in_array($packageName, $newPackages, true); + $status = $isNew ? 'NEW' : 'existing'; + $dirCount = \count($data['dirs']); + $rows[] = [ + $status, + $packageName, + \sprintf('%d director%s', $dirCount, 1 === $dirCount ? 'y' : 'ies'), + ]; + } + $io->table(['Status', 'Package', 'Scan Directories'], $rows); + + $finalExtensions = []; + foreach ($extensions as $packageName => $data) { + $enabled = true; + if (isset($existingExtensions[$packageName]) && \is_array($existingExtensions[$packageName])) { + $enabled = $existingExtensions[$packageName]['enabled'] ?? true; + if (!\is_bool($enabled)) { + $enabled = true; + } + } + + $finalExtensions[$packageName] = [ + 'enabled' => $enabled, + ]; + } + + $this->writeExtensionsFile($extensionsFile, $finalExtensions); + + $io->success(\sprintf('Configuration written to: %s', $extensionsFile)); + + if (\count($newPackages) > 0) { + $io->note(\sprintf('Added %d new extension%s. All extensions are enabled by default.', \count($newPackages), 1 === \count($newPackages) ? '' : 's')); + } + + if (\count($removedPackages) > 0) { + $io->warning([ + \sprintf('Removed %d extension%s no longer found:', \count($removedPackages), 1 === \count($removedPackages) ? '' : 's'), + ...array_map(fn ($pkg) => ' • '.$pkg, $removedPackages), + ]); + } + + $io->comment([ + 'Next steps:', + ' • Edit .mate/extensions.php to enable/disable specific extensions', + ' • Run "vendor/bin/mate serve" to start the MCP server', + ]); + + return Command::SUCCESS; + } + + /** + * @param array $extensions + */ + private function writeExtensionsFile(string $filePath, array $extensions): void + { + $dir = \dirname($filePath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $content = " $config) { + $enabled = $config['enabled'] ? 'true' : 'false'; + $content .= " '$packageName' => ['enabled' => $enabled],\n"; + } + + $content .= "];\n"; + + file_put_contents($filePath, $content); + } +} diff --git a/src/ai-mate/src/Command/InitCommand.php b/src/ai-mate/src/Command/InitCommand.php new file mode 100644 index 000000000..c4edd0990 --- /dev/null +++ b/src/ai-mate/src/Command/InitCommand.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Add some config in the project root, automatically discover tools. + * Basically do every thing you need to set things up. + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class InitCommand extends Command +{ + public function __construct( + private string $rootDir, + ) { + parent::__construct(self::getDefaultName()); + } + + public static function getDefaultName(): ?string + { + return 'init'; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('AI Mate Initialization'); + $io->text('Setting up AI Mate configuration and directory structure...'); + $io->newLine(); + + $actions = []; + + $mateDir = $this->rootDir.'/.mate'; + if (!is_dir($mateDir)) { + mkdir($mateDir, 0755, true); + $actions[] = ['✓', 'Created', '.mate/ directory']; + } + + $files = ['.mate/extensions.php', '.mate/services.php', '.mate/.gitignore', 'mcp.json']; + foreach ($files as $file) { + $fullPath = $this->rootDir.'/'.$file; + if (!file_exists($fullPath)) { + $this->copyTemplate($file, $fullPath); + $actions[] = ['✓', 'Created', $file]; + } elseif ($io->confirm(\sprintf('%s already exists. Overwrite?', $fullPath), false)) { + unlink($fullPath); + $this->copyTemplate($file, $fullPath); + $actions[] = ['✓', 'Updated', $file]; + } else { + $actions[] = ['○', 'Skipped', $file.' (already exists)']; + } + } + + $mateUserDir = $this->rootDir.'/mate'; + if (!is_dir($mateUserDir)) { + mkdir($mateUserDir, 0755, true); + file_put_contents($mateUserDir.'/.gitignore', ''); + $actions[] = ['✓', 'Created', 'mate/ directory (for custom extensions)']; + } else { + $actions[] = ['○', 'Exists', 'mate/ directory']; + } + + $composerActions = $this->updateComposerJson(); + $actions = array_merge($actions, $composerActions); + + $io->section('Summary'); + $io->table(['', 'Action', 'Item'], $actions); + + $io->success('AI Mate initialization complete!'); + + $io->comment([ + 'Next steps:', + ' 1. Run "composer dump-autoload" to update the autoloader', + ' 2. Run "vendor/bin/mate discover" to find MCP extensions', + ' 3. Add your custom MCP tools/resources/prompts to the mate/ directory', + ' 4. Run "vendor/bin/mate serve" to start the MCP server', + ]); + + return Command::SUCCESS; + } + + private function copyTemplate(string $template, string $destination): void + { + copy(__DIR__.'/../../resources/'.$template, $destination); + } + + /** + * @return list + */ + private function updateComposerJson(): array + { + $composerJsonPath = $this->rootDir.'/composer.json'; + $actions = []; + + if (!file_exists($composerJsonPath)) { + $actions[] = ['⚠', 'Warning', 'composer.json not found in project root']; + + return $actions; + } + + $composerContent = file_get_contents($composerJsonPath); + if (false === $composerContent) { + $actions[] = ['✗', 'Error', 'Failed to read composer.json']; + + return $actions; + } + + $composerJson = json_decode($composerContent, true); + + if (\JSON_ERROR_NONE !== json_last_error() || !\is_array($composerJson)) { + $actions[] = ['✗', 'Error', 'Failed to parse composer.json: '.json_last_error_msg()]; + + return $actions; + } + + $modified = false; + + $composerJson['extra'] = \is_array($composerJson['extra'] ?? null) ? $composerJson['extra'] : []; + $composerJson['autoload'] = \is_array($composerJson['autoload'] ?? null) ? $composerJson['autoload'] : []; + $composerJson['autoload']['psr-4'] = \is_array($composerJson['autoload']['psr-4'] ?? null) ? $composerJson['autoload']['psr-4'] : []; + + if (!isset($composerJson['extra']['ai-mate'])) { + $composerJson['extra']['ai-mate'] = [ + 'scan-dirs' => ['mate'], + 'includes' => ['services.php'], + ]; + $modified = true; + $actions[] = ['✓', 'Added', 'extra.ai-mate configuration']; + } else { + $actions[] = ['○', 'Exists', 'extra.ai-mate configuration']; + } + + if (!isset($composerJson['autoload']['psr-4']['App\\Mate\\'])) { + $composerJson['autoload']['psr-4']['App\\Mate\\'] = 'mate/'; + $modified = true; + $actions[] = ['✓', 'Added', 'App\\Mate\\ autoloader']; + } else { + $actions[] = ['○', 'Exists', 'App\\Mate\\ autoloader']; + } + + if ($modified) { + file_put_contents( + $composerJsonPath, + json_encode($composerJson, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n" + ); + $actions[] = ['✓', 'Updated', 'composer.json']; + } + + return $actions; + } +} diff --git a/src/ai-mate/src/Command/ServeCommand.php b/src/ai-mate/src/Command/ServeCommand.php new file mode 100644 index 000000000..ff321bf69 --- /dev/null +++ b/src/ai-mate/src/Command/ServeCommand.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Command; + +use Mcp\Capability\Discovery\Discoverer; +use Mcp\Server; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StdioTransport; +use Psr\Log\LoggerInterface; +use Symfony\AI\Mate\Discovery\ComposerTypeDiscovery; +use Symfony\AI\Mate\Discovery\FilteredDiscoveryLoader; +use Symfony\AI\Mate\Discovery\ServiceDiscovery; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Starts the MCP server with stdio transport. + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class ServeCommand extends Command +{ + private ComposerTypeDiscovery $discovery; + + public function __construct( + private LoggerInterface $logger, + private ContainerBuilder $container, + ) { + parent::__construct(self::getDefaultName()); + $rootDir = $container->getParameter('mate.root_dir'); + \assert(\is_string($rootDir)); + $this->discovery = new ComposerTypeDiscovery($rootDir, $logger); + } + + public static function getDefaultName(): ?string + { + return 'serve'; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $rootDir = $this->container->getParameter('mate.root_dir'); + \assert(\is_string($rootDir)); + + $cacheDir = $this->container->getParameter('mate.cache_dir'); + \assert(\is_string($cacheDir)); + + $discovery = new Discoverer($this->logger); + $extensions = $this->getExtensionsToLoad(); + (new ServiceDiscovery())->registerServices($discovery, $this->container, $rootDir, $extensions); + + $disabledVendorFeatures = $this->container->getParameter('mate.disabled_features') ?? []; + \assert(\is_array($disabledVendorFeatures)); + /* @var array> $disabledVendorFeatures */ + + $this->container->compile(); + + $loader = new FilteredDiscoveryLoader( + basePath: $rootDir, + extensions: $extensions, + disabledFeatures: $disabledVendorFeatures, + discoverer: $discovery, + logger: $this->logger + ); + + $server = Server::builder() + ->setServerInfo('ai-mate', '0.1.0', 'AI development assistant MCP server') + ->setContainer($this->container) + ->addLoaders($loader) + ->setSession(new FileSessionStore($cacheDir.'/sessions')) + ->setLogger($this->logger) + ->build(); + + $server->run(new StdioTransport()); + + return Command::SUCCESS; + } + + /** + * @return array + */ + private function getExtensionsToLoad(): array + { + $rootDir = $this->container->getParameter('mate.root_dir'); + \assert(\is_string($rootDir)); + + $packageNames = $this->container->getParameter('mate.enabled_extensions'); + \assert(\is_array($packageNames)); + /** @var array $packageNames */ + + /** @var array, includes: array}> $extensions */ + $extensions = []; + + foreach ($this->discovery->discover($packageNames) as $packageName => $data) { + $extensions[$packageName] = $data; + } + + $extensions['_custom'] = $this->discovery->discoverRootProject(); + + return $extensions; + } +} diff --git a/src/ai-mate/src/Container/ContainerFactory.php b/src/ai-mate/src/Container/ContainerFactory.php new file mode 100644 index 000000000..3114f64e9 --- /dev/null +++ b/src/ai-mate/src/Container/ContainerFactory.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Container; + +use Psr\Log\LoggerInterface; +use Symfony\AI\Mate\Discovery\ComposerTypeDiscovery; +use Symfony\AI\Mate\Exception\MissingDependencyException; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\Dotenv\Dotenv; + +/** + * Factory for building a Symfony DI Container with MCP extension configurations. + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +final class ContainerFactory +{ + public function __construct( + private string $rootDir, + ) { + } + + public function create(): ContainerBuilder + { + $container = new ContainerBuilder(); + $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__))); + $loader->load('default.services.php'); + + $enabledExtensions = $this->getEnabledExtensions(); + + $container->setParameter('mate.enabled_extensions', $enabledExtensions); + $container->setParameter('mate.root_dir', $this->rootDir); + + $logger = $container->get(LoggerInterface::class); + \assert($logger instanceof LoggerInterface); + + $discovery = new ComposerTypeDiscovery($this->rootDir, $logger); + + if ([] !== $enabledExtensions) { + foreach ($discovery->discover($enabledExtensions) as $packageName => $data) { + $this->loadExtensionIncludes($container, $logger, $packageName, $data['includes']); + } + } + + $rootProject = $discovery->discoverRootProject(); + $this->loadUserServices($rootProject, $container); + + $this->loadUserEnvVar($container); + + return $container; + } + + /** + * @return string[] Package names + */ + private function getEnabledExtensions(): array + { + $extensionsFile = $this->rootDir.'/.mate/extensions.php'; + + if (!file_exists($extensionsFile)) { + return []; + } + + $extensionsConfig = include $extensionsFile; + if (!\is_array($extensionsConfig)) { + return []; + } + + $enabledExtensions = []; + foreach ($extensionsConfig as $packageName => $config) { + if (\is_string($packageName) && \is_array($config) && ($config['enabled'] ?? false)) { + $enabledExtensions[] = $packageName; + } + } + + return $enabledExtensions; + } + + /** + * @param string[] $includeFiles + */ + private function loadExtensionIncludes(ContainerBuilder $container, LoggerInterface $logger, string $packageName, array $includeFiles): void + { + foreach ($includeFiles as $includeFile) { + if (!file_exists($includeFile)) { + continue; + } + + try { + $loader = new PhpFileLoader($container, new FileLocator(\dirname($includeFile))); + $loader->load(basename($includeFile)); + + $logger->debug('Loaded extension include', [ + 'package' => $packageName, + 'file' => $includeFile, + ]); + } catch (\Throwable $e) { + $logger->warning('Failed to load extension include', [ + 'package' => $packageName, + 'file' => $includeFile, + 'error' => $e->getMessage(), + ]); + } + } + } + + private function loadUserEnvVar(ContainerBuilder $container): void + { + $envFile = $container->getParameter('mate.env_file'); + + if (null === $envFile || !\is_string($envFile) || '' === $envFile) { + return; + } + + if (!class_exists(Dotenv::class)) { + throw MissingDependencyException::forDotenv(); + } + + $extra = []; + $localFile = $this->rootDir.\DIRECTORY_SEPARATOR.$envFile.\DIRECTORY_SEPARATOR.'.local'; + if (!file_exists($localFile)) { + $extra[] = $localFile; + } + + (new Dotenv())->load($this->rootDir.\DIRECTORY_SEPARATOR.$envFile, ...$extra); + } + + /** + * @param array{dirs: array, includes: array} $rootProject + */ + private function loadUserServices(array $rootProject, ContainerBuilder $container): void + { + $logger = $container->get(LoggerInterface::class); + \assert($logger instanceof LoggerInterface); + + $loader = new PhpFileLoader($container, new FileLocator($this->rootDir.'/.mate')); + foreach ($rootProject['includes'] as $include) { + try { + $loader->load($include); + + $logger->debug('Loaded user services', [ + 'file' => $include, + ]); + } catch (\Throwable $e) { + $logger->warning('Failed to load user services', [ + 'file' => $include, + 'error' => $e->getMessage(), + ]); + } + } + } +} diff --git a/src/ai-mate/src/Container/MateHelper.php b/src/ai-mate/src/Container/MateHelper.php new file mode 100644 index 000000000..c7446b4f2 --- /dev/null +++ b/src/ai-mate/src/Container/MateHelper.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Container; + +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +/** + * Helper methods for configuring AI Mate in services.php. + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class MateHelper +{ + /** + * Disable specific MCP features from one or more extensions. + * + * This function allows you to disable specific tools, resources, prompts, or + * resource templates from MCP extensions at a granular level. It is useful for + * disabling features that are known to cause issues or are not needed in your + * project. + * + * Call this method only once. The second call will override the first one. + * + * Example usage in .mate/services.php: + * ```php + * use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + * use Symfony\AI\Mate\Container\MateHelper; + * + * return static function (ContainerConfigurator $container): void { + * MateHelper::disableFeatures($container, [ + * 'vendor/extension' => ['badTool', 'semiBadTool'] + * 'nyholm/example' => ['clock'] + * ]); + * + * $container->parameters() + * ->set('mate.cache_dir', sys_get_temp_dir().'/mate') + * // ... + * } + * ``` + * + * @param array> $extensions + */ + public static function disableFeatures(ContainerConfigurator $container, array $extensions): void + { + $data = []; + foreach ($extensions as $extension => $features) { + foreach ($features as $feature) { + $data[$extension][$feature] = ['enabled' => false]; + } + } + + $container->parameters()->set('mate.disabled_features', $data); + } +} diff --git a/src/ai-mate/src/Discovery/ComposerTypeDiscovery.php b/src/ai-mate/src/Discovery/ComposerTypeDiscovery.php new file mode 100644 index 000000000..0681719f1 --- /dev/null +++ b/src/ai-mate/src/Discovery/ComposerTypeDiscovery.php @@ -0,0 +1,312 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Discovery; + +use Psr\Log\LoggerInterface; + +/** + * Discovers MCP extensions via extra.ai-mate config in composer.json. + * + * Extensions must declare themselves in composer.json: + * { + * "extra": { + * "ai-mate": { + * "scan-dirs": ["src"], + * "includes": ["config/services.php"] + * } + * } + * } + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +final class ComposerTypeDiscovery +{ + /** + * @var array, + * }>|null + */ + private ?array $installedPackages = null; + + public function __construct( + private string $rootDir, + private LoggerInterface $logger, + ) { + } + + /** + * @param string[] $enabledExtensions + * + * @return array + */ + public function discover(array $enabledExtensions = []): array + { + $installed = $this->getInstalledPackages(); + $extensions = []; + + foreach ($installed as $package) { + $packageName = $package['name']; + + $aiMateConfig = $package['extra']['ai-mate'] ?? null; + if (!\is_array($aiMateConfig)) { + continue; + } + + if ([] !== $enabledExtensions && !\in_array($packageName, $enabledExtensions, true)) { + $this->logger->debug('Skipping package not enabled', ['package' => $packageName]); + + continue; + } + + $scanDirs = $this->extractScanDirs($package, $packageName); + $includeFiles = $this->extractIncludeFiles($package, $packageName); + if ([] !== $scanDirs || [] !== $includeFiles) { + $extensions[$packageName] = [ + 'dirs' => $scanDirs, + 'includes' => $includeFiles, + ]; + } + } + + return $extensions; + } + + /** + * @return array{dirs: array, includes: array} + */ + public function discoverRootProject(): array + { + $composerContent = file_get_contents($this->rootDir.'/composer.json'); + if (false === $composerContent) { + return [ + 'dirs' => [], + 'includes' => [], + ]; + } + + $rootComposer = json_decode($composerContent, true); + if (!\is_array($rootComposer)) { + return [ + 'dirs' => [], + 'includes' => [], + ]; + } + + $scanDirs = []; + if (isset($rootComposer['extra']) && \is_array($rootComposer['extra']) + && isset($rootComposer['extra']['ai-mate']) && \is_array($rootComposer['extra']['ai-mate']) + && isset($rootComposer['extra']['ai-mate']['scan-dirs']) && \is_array($rootComposer['extra']['ai-mate']['scan-dirs'])) { + $scanDirs = array_filter($rootComposer['extra']['ai-mate']['scan-dirs'], 'is_string'); + } + + $includes = []; + if (isset($rootComposer['extra']) && \is_array($rootComposer['extra']) + && isset($rootComposer['extra']['ai-mate']) && \is_array($rootComposer['extra']['ai-mate']) + && isset($rootComposer['extra']['ai-mate']['includes']) && \is_array($rootComposer['extra']['ai-mate']['includes'])) { + $includes = array_filter($rootComposer['extra']['ai-mate']['includes'], 'is_string'); + } + + return [ + 'dirs' => array_values($scanDirs), + 'includes' => array_values($includes), + ]; + } + + /** + * Check vendor/composer/installed.json for installed packages. + * + * @return array, + * }> + */ + private function getInstalledPackages(): array + { + if (null !== $this->installedPackages) { + return $this->installedPackages; + } + + $installedJsonPath = $this->rootDir.'/vendor/composer/installed.json'; + if (!file_exists($installedJsonPath)) { + $this->logger->warning('Composer installed.json not found', ['path' => $installedJsonPath]); + + return $this->installedPackages = []; + } + + $content = file_get_contents($installedJsonPath); + if (false === $content) { + $this->logger->warning('Could not read installed.json', ['path' => $installedJsonPath]); + + return $this->installedPackages = []; + } + + try { + $data = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->error('Invalid JSON in installed.json', ['error' => $e->getMessage()]); + + return $this->installedPackages = []; + } + + if (!\is_array($data)) { + return $this->installedPackages = []; + } + + // Handle both formats: {"packages": [...]} and direct array + $packages = $data['packages'] ?? $data; + if (!\is_array($packages)) { + return $this->installedPackages = []; + } + + $indexed = []; + foreach ($packages as $package) { + if (!\is_array($package) || !isset($package['name']) || !\is_string($package['name'])) { + continue; + } + + /** @var array{ + * name: string, + * extra: array, + * } $validPackage */ + $validPackage = [ + 'name' => $package['name'], + 'extra' => [], + ]; + + if (isset($package['extra']) && \is_array($package['extra'])) { + /** @var array $extra */ + $extra = $package['extra']; + $validPackage['extra'] = $extra; + } + + $indexed[$package['name']] = $validPackage; + } + + return $this->installedPackages = $indexed; + } + + /** + * @param array{ + * name: string, + * extra: array, + * } $package + * + * @return string[] list of directories with paths relative to project root + */ + private function extractScanDirs(array $package, string $packageName): array + { + $aiMateConfig = $package['extra']['ai-mate'] ?? null; + if (null === $aiMateConfig) { + // Default: scan package root directory if no config provided + $defaultDir = 'vendor/'.$packageName; + if (is_dir($this->rootDir.'/'.$defaultDir)) { + return [$defaultDir]; + } + + $this->logger->warning('Package directory not found', [ + 'package' => $packageName, + 'directory' => $defaultDir, + ]); + + return []; + } + + if (!\is_array($aiMateConfig)) { + $this->logger->warning('Invalid ai-mate config in package', ['package' => $packageName]); + + return []; + } + + $scanDirs = $aiMateConfig['scan-dirs'] ?? []; + if (!\is_array($scanDirs)) { + $this->logger->warning('Invalid scan-dirs in ai-mate config', ['package' => $packageName]); + + return []; + } + + $validDirs = []; + foreach ($scanDirs as $dir) { + if (!\is_string($dir) || '' === trim($dir) || str_contains($dir, '..')) { + continue; + } + + $fullPath = 'vendor/'.$packageName.'/'.ltrim($dir, '/'); + if (!is_dir($this->rootDir.'/'.$fullPath)) { + $this->logger->warning('Scan directory does not exist', [ + 'package' => $packageName, + 'directory' => $fullPath, + ]); + continue; + } + + $validDirs[] = $fullPath; + } + + return $validDirs; + } + + /** + * Extract include files from package extra config. + * + * Uses "includes" from extra.ai-mate config, e.g.: + * "extra": { "ai-mate": { "includes": ["config/services.php"] } } + * + * @param array{ + * name: string, + * extra: array, + * } $package + * + * @return string[] list of files with paths relative to project root + */ + private function extractIncludeFiles(array $package, string $packageName): array + { + $aiMateConfig = $package['extra']['ai-mate'] ?? null; + if (null === $aiMateConfig || !\is_array($aiMateConfig)) { + return []; + } + + $includes = $aiMateConfig['includes'] ?? []; + + // Support single file as string + if (\is_string($includes)) { + $includes = [$includes]; + } + + if (!\is_array($includes)) { + $this->logger->warning('Invalid includes in ai-mate config', ['package' => $packageName]); + + return []; + } + + $validFiles = []; + foreach ($includes as $file) { + if (!\is_string($file) || '' === trim($file) || str_contains($file, '..')) { + continue; + } + + $fullPath = $this->rootDir.'/vendor/'.$packageName.'/'.ltrim($file, '/'); + if (!file_exists($fullPath)) { + $this->logger->warning('Include file does not exist', [ + 'package' => $packageName, + 'file' => $fullPath, + ]); + continue; + } + + $validFiles[] = $fullPath; + } + + return $validFiles; + } +} diff --git a/src/ai-mate/src/Discovery/FilteredDiscoveryLoader.php b/src/ai-mate/src/Discovery/FilteredDiscoveryLoader.php new file mode 100644 index 000000000..39755d35d --- /dev/null +++ b/src/ai-mate/src/Discovery/FilteredDiscoveryLoader.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Discovery; + +use Mcp\Capability\Discovery\Discoverer; +use Mcp\Capability\Discovery\DiscoveryState; +use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\RegistryInterface; +use Psr\Log\LoggerInterface; + +/** + * Create loaded that automatically discover MCP features. + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +final class FilteredDiscoveryLoader implements LoaderInterface +{ + /** + * @param array $extensions + * @param array> $disabledFeatures + */ + public function __construct( + private string $basePath, + private array $extensions, + private array $disabledFeatures, + private Discoverer $discoverer, + private LoggerInterface $logger, + ) { + } + + public function load(RegistryInterface $registry): void + { + $allTools = []; + $allResources = []; + $allPrompts = []; + $allResourceTemplates = []; + + foreach ($this->extensions as $packageName => $data) { + $discoveryState = $this->discoverer->discover($this->basePath, $data['dirs']); + + foreach ($discoveryState->getTools() as $name => $tool) { + if (!$this->isFeatureAllowed($packageName, $name)) { + $this->logger->debug('Excluding tool by feature filter', [ + 'package' => $packageName, + 'tool' => $name, + ]); + continue; + } + + $allTools[$name] = $tool; + } + + foreach ($discoveryState->getResources() as $uri => $resource) { + if (!$this->isFeatureAllowed($packageName, $uri)) { + $this->logger->debug('Excluding resource by feature filter', [ + 'package' => $packageName, + 'resource' => $uri, + ]); + continue; + } + + $allResources[$uri] = $resource; + } + + foreach ($discoveryState->getPrompts() as $name => $prompt) { + if (!$this->isFeatureAllowed($packageName, $name)) { + $this->logger->debug('Excluding prompt by feature filter', [ + 'package' => $packageName, + 'prompt' => $name, + ]); + continue; + } + + $allPrompts[$name] = $prompt; + } + + foreach ($discoveryState->getResourceTemplates() as $uriTemplate => $template) { + if (!$this->isFeatureAllowed($packageName, $uriTemplate)) { + $this->logger->debug('Excluding resource template by feature filter', [ + 'package' => $packageName, + 'template' => $uriTemplate, + ]); + continue; + } + + $allResourceTemplates[$uriTemplate] = $template; + } + } + + $filteredState = new DiscoveryState( + $allTools, + $allResources, + $allPrompts, + $allResourceTemplates, + ); + + $registry->setDiscoveryState($filteredState); + + $this->logger->info('Loaded filtered capabilities', [ + 'tools' => \count($allTools), + 'resources' => \count($allResources), + 'prompts' => \count($allPrompts), + 'resourceTemplates' => \count($allResourceTemplates), + ]); + } + + private function isFeatureAllowed(string $packageName, string $feature): bool + { + $data = $this->disabledFeatures[$packageName][$feature] ?? []; + + return $data['enabled'] ?? true; + } +} diff --git a/src/ai-mate/src/Discovery/ServiceDiscovery.php b/src/ai-mate/src/Discovery/ServiceDiscovery.php new file mode 100644 index 000000000..03291c2bc --- /dev/null +++ b/src/ai-mate/src/Discovery/ServiceDiscovery.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Discovery; + +use Mcp\Capability\Discovery\Discoverer; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Discovery services to add to Symfony DI container. + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +final class ServiceDiscovery +{ + /** + * Pre-register all discovered services in the container. + * Call this BEFORE container->compile(). + * + * @param array $extensions + */ + public function registerServices(Discoverer $discoverer, ContainerBuilder $container, string $basePath, array $extensions): void + { + foreach ($extensions as $data) { + $discoveryState = $discoverer->discover($basePath, $data['dirs']); + foreach ($discoveryState->getTools() as $tool) { + $this->maybeRegisterHandler($container, $tool->handler); + } + + foreach ($discoveryState->getResources() as $resource) { + $this->maybeRegisterHandler($container, $resource->handler); + } + + foreach ($discoveryState->getPrompts() as $prompt) { + $this->maybeRegisterHandler($container, $prompt->handler); + } + + foreach ($discoveryState->getResourceTemplates() as $template) { + $this->maybeRegisterHandler($container, $template->handler); + } + } + } + + /** + * @param \Closure|array{0: object|string, 1: string}|string $handler + */ + private function maybeRegisterHandler(ContainerBuilder $container, \Closure|array|string $handler): void + { + $className = $this->extractClassName($handler); + if (null === $className) { + return; + } + + $this->registerService($container, $className); + } + + /** + * @param \Closure|array{0: object|string, 1: string}|string $handler + */ + private function extractClassName(\Closure|array|string $handler): ?string + { + if ($handler instanceof \Closure) { + return null; + } + + if (\is_string($handler)) { + return class_exists($handler) ? $handler : null; + } + + $class = $handler[0]; + if (\is_object($class)) { + return $class::class; + } + + return class_exists($class) ? $class : null; + } + + private function registerService(ContainerBuilder $container, string $className): void + { + if ($container->has($className)) { + return; + } + + $container->register($className, $className) + ->setAutowired(true) + ->setPublic(true); + } +} diff --git a/src/ai-mate/src/Exception/ExceptionInterface.php b/src/ai-mate/src/Exception/ExceptionInterface.php new file mode 100644 index 000000000..32962ef2f --- /dev/null +++ b/src/ai-mate/src/Exception/ExceptionInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Exception; + +/** + * @internal + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/ai-mate/src/Exception/MissingDependencyException.php b/src/ai-mate/src/Exception/MissingDependencyException.php new file mode 100644 index 000000000..ef6ade2f4 --- /dev/null +++ b/src/ai-mate/src/Exception/MissingDependencyException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Exception; + +/** + * @internal + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +final class MissingDependencyException extends \RuntimeException implements ExceptionInterface +{ + public static function forDotenv(): self + { + return new self('Cannot load any environment file with out Symfony Dotenv. Please run run "composer require symfony/dotenv" and try again.'); + } +} diff --git a/src/ai-mate/src/Exception/UnsupportedVersionException.php b/src/ai-mate/src/Exception/UnsupportedVersionException.php new file mode 100644 index 000000000..23cc63000 --- /dev/null +++ b/src/ai-mate/src/Exception/UnsupportedVersionException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Exception; + +/** + * @internal + * + * @author Johannes Wachter + * @author Tobias Nyholm + */ +final class UnsupportedVersionException extends \RuntimeException implements ExceptionInterface +{ + public static function forConsole(): self + { + return new self('Unsupported version of symfony/console. We cannot add commands.'); + } +} diff --git a/src/ai-mate/src/Service/Logger.php b/src/ai-mate/src/Service/Logger.php new file mode 100644 index 000000000..3dbe38ad0 --- /dev/null +++ b/src/ai-mate/src/Service/Logger.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Service; + +use Psr\Log\AbstractLogger; + +/** + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class Logger extends AbstractLogger +{ + public function log($level, \Stringable|string $message, array $context = []): void + { + $debug = $_SERVER['DEBUG'] ?? false; + + if (!$debug && 'debug' === $level) { + return; + } + + $levelString = match (true) { + $level instanceof \Stringable => (string) $level, + \is_string($level) => $level, + default => 'unknown', + }; + + $logMessage = \sprintf( + "[%s] %s %s\n", + strtoupper($levelString), + $message, + ([] === $context || !$debug) ? '' : json_encode($context), + ); + + if (($_SERVER['FILE_LOG'] ?? false) || !\defined('STDERR')) { + file_put_contents('dev.log', $logMessage, \FILE_APPEND); + } else { + fwrite(\STDERR, $logMessage); + } + } +} diff --git a/src/ai-mate/src/default.services.php b/src/ai-mate/src/default.services.php new file mode 100644 index 000000000..0a4d7ffde --- /dev/null +++ b/src/ai-mate/src/default.services.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Psr\Log\LoggerInterface; +use Symfony\AI\Mate\Service\Logger; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +return static function (ContainerConfigurator $container): void { + $container->parameters() + ->set('mate.root_dir', '%env(MATE_ROOT_DIR)%') + ->set('mate.cache_dir', sys_get_temp_dir().'/mate') + ->set('mate.env_file', null) + ->set('mate.disabled_features', []) + ; + + $container->services() + ->defaults() + ->autowire() + ->autoconfigure() + + ->set(LoggerInterface::class, Logger::class) + ->alias(Logger::class, LoggerInterface::class) + ; +}; diff --git a/src/ai-mate/tests/Command/DiscoverCommandTest.php b/src/ai-mate/tests/Command/DiscoverCommandTest.php new file mode 100644 index 000000000..89bd4e1fc --- /dev/null +++ b/src/ai-mate/tests/Command/DiscoverCommandTest.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\AI\Mate\Command\DiscoverCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; + +/** + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class DiscoverCommandTest extends TestCase +{ + private string $fixturesDir; + + protected function setUp(): void + { + $this->fixturesDir = __DIR__.'/../Discovery/Fixtures'; + } + + public function testDiscoversExtensionsAndCreatesFile() + { + $tempDir = sys_get_temp_dir().'/mate-discover-test-'.uniqid(); + mkdir($tempDir, 0755, true); + + try { + $rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir); + $command = new DiscoverCommand($rootDir, new NullLogger()); + $tester = new CommandTester($command); + + $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertFileExists($tempDir.'/.mate/extensions.php'); + + $extensions = include $tempDir.'/.mate/extensions.php'; + $this->assertIsArray($extensions); + $this->assertArrayHasKey('vendor/package-a', $extensions); + $this->assertArrayHasKey('vendor/package-b', $extensions); + $this->assertIsArray($extensions['vendor/package-a']); + $this->assertIsArray($extensions['vendor/package-b']); + $this->assertTrue($extensions['vendor/package-a']['enabled']); + $this->assertTrue($extensions['vendor/package-b']['enabled']); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Discovered 2 Extension', $output); + $this->assertStringContainsString('vendor/package-a', $output); + $this->assertStringContainsString('vendor/package-b', $output); + } finally { + $this->removeDirectory($tempDir); + } + } + + public function testPreservesExistingEnabledState() + { + $tempDir = sys_get_temp_dir().'/mate-discover-test-'.uniqid(); + mkdir($tempDir.'/.mate', 0755, true); + + try { + // Create existing extensions.php with package-a disabled + file_put_contents($tempDir.'/.mate/extensions.php', <<<'PHP' + ['enabled' => false], + 'vendor/package-b' => ['enabled' => true], +]; +PHP + ); + + $rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir); + $command = new DiscoverCommand($rootDir, new NullLogger()); + $tester = new CommandTester($command); + + $tester->execute([]); + + $extensions = include $tempDir.'/.mate/extensions.php'; + $this->assertIsArray($extensions); + $this->assertIsArray($extensions['vendor/package-a']); + $this->assertIsArray($extensions['vendor/package-b']); + $this->assertFalse($extensions['vendor/package-a']['enabled'], 'Should preserve disabled state'); + $this->assertTrue($extensions['vendor/package-b']['enabled'], 'Should preserve enabled state'); + } finally { + $this->removeDirectory($tempDir); + } + } + + public function testNewPackagesDefaultToEnabled() + { + $tempDir = sys_get_temp_dir().'/mate-discover-test-'.uniqid(); + mkdir($tempDir.'/.mate', 0755, true); + + try { + // Create existing extensions.php with only package-a + file_put_contents($tempDir.'/.mate/extensions.php', <<<'PHP' + ['enabled' => false], +]; +PHP + ); + + $rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir); + $command = new DiscoverCommand($rootDir, new NullLogger()); + $tester = new CommandTester($command); + + $tester->execute([]); + + $extensions = include $tempDir.'/.mate/extensions.php'; + $this->assertIsArray($extensions); + $this->assertIsArray($extensions['vendor/package-a']); + $this->assertIsArray($extensions['vendor/package-b']); + $this->assertFalse($extensions['vendor/package-a']['enabled'], 'Existing disabled state preserved'); + $this->assertTrue($extensions['vendor/package-b']['enabled'], 'New package defaults to enabled'); + } finally { + $this->removeDirectory($tempDir); + } + } + + public function testDisplaysWarningWhenNoExtensionsFound() + { + $tempDir = sys_get_temp_dir().'/mate-discover-test-'.uniqid(); + mkdir($tempDir, 0755, true); + + try { + $rootDir = $this->createConfiguration($this->fixturesDir.'/without-ai-mate-config', $tempDir); + $command = new DiscoverCommand($rootDir, new NullLogger()); + $tester = new CommandTester($command); + + $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('No MCP extensions found', $output); + } finally { + $this->removeDirectory($tempDir); + } + } + + private function createConfiguration(string $rootDir, string $tempDir): string + { + // Copy fixture to temp directory for testing + $this->copyDirectory($rootDir.'/vendor', $tempDir.'/vendor'); + + return $tempDir; + } + + private function copyDirectory(string $src, string $dst): void + { + if (!is_dir($src)) { + return; + } + + if (!is_dir($dst)) { + mkdir($dst, 0755, true); + } + + $files = array_diff(scandir($src) ?: [], ['.', '..']); + foreach ($files as $file) { + $srcPath = $src.'/'.$file; + $dstPath = $dst.'/'.$file; + + if (is_dir($srcPath)) { + $this->copyDirectory($srcPath, $dstPath); + } else { + copy($srcPath, $dstPath); + } + } + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir) ?: [], ['.', '..']); + foreach ($files as $file) { + $path = $dir.'/'.$file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/src/ai-mate/tests/Command/InitCommandTest.php b/src/ai-mate/tests/Command/InitCommandTest.php new file mode 100644 index 000000000..368144e95 --- /dev/null +++ b/src/ai-mate/tests/Command/InitCommandTest.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Mate\Command\InitCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; + +/** + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class InitCommandTest extends TestCase +{ + private string $tempDir; + + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir().'/mate-test-'.uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testCreatesDirectoryAndConfigFile() + { + $command = new InitCommand($this->tempDir); + $tester = new CommandTester($command); + + $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertDirectoryExists($this->tempDir.'/.mate'); + $this->assertFileExists($this->tempDir.'/.mate/extensions.php'); + $this->assertFileExists($this->tempDir.'/.mate/services.php'); + + $content = file_get_contents($this->tempDir.'/.mate/extensions.php'); + $this->assertIsString($content); + $this->assertStringContainsString('mate discover', $content); + $this->assertStringContainsString('enabled', $content); + } + + public function testDisplaysSuccessMessage() + { + $command = new InitCommand($this->tempDir); + $tester = new CommandTester($command); + + $tester->execute([]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('AI Mate Initialization', $output); + $this->assertStringContainsString('extensions.php', $output); + $this->assertStringContainsString('services.php', $output); + $this->assertStringContainsString('vendor/bin/mate discover', $output); + $this->assertStringContainsString('Summary', $output); + $this->assertStringContainsString('Created', $output); + } + + public function testDoesNotOverwriteExistingFileWithoutConfirmation() + { + $command = new InitCommand($this->tempDir); + $tester = new CommandTester($command); + + // Create existing file + mkdir($this->tempDir.'/.mate', 0755, true); + file_put_contents($this->tempDir.'/.mate/extensions.php', ' "value"];'); + + // Execute with 'no' response (twice for both files) + $tester->setInputs(['no', 'no']); + $tester->execute([]); + + // File should still contain original content + $content = file_get_contents($this->tempDir.'/.mate/extensions.php'); + $this->assertIsString($content); + $this->assertStringContainsString('test', $content); + $this->assertStringContainsString('value', $content); + } + + public function testOverwritesExistingFileWithConfirmation() + { + $command = new InitCommand($this->tempDir); + $tester = new CommandTester($command); + + // Create existing file + mkdir($this->tempDir.'/.mate', 0755, true); + file_put_contents($this->tempDir.'/.mate/extensions.php', ' "value"];'); + + // Execute with 'yes' response (twice for both files) + $tester->setInputs(['yes', 'yes']); + $tester->execute([]); + + // File should be overwritten with template content + $content = file_get_contents($this->tempDir.'/.mate/extensions.php'); + $this->assertIsString($content); + $this->assertStringNotContainsString('test', $content); + $this->assertStringContainsString('mate discover', $content); + $this->assertStringContainsString('enabled', $content); + } + + public function testCreatesDirectoryIfNotExists() + { + $command = new InitCommand($this->tempDir); + $tester = new CommandTester($command); + + // Ensure .mate directory doesn't exist + $this->assertDirectoryDoesNotExist($this->tempDir.'/.mate'); + + $tester->execute([]); + + // Directory should be created + $this->assertDirectoryExists($this->tempDir.'/.mate'); + $this->assertFileExists($this->tempDir.'/.mate/extensions.php'); + $this->assertFileExists($this->tempDir.'/.mate/services.php'); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir.'/'.$file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/src/ai-mate/tests/Discovery/ComposerTypeDiscoveryTest.php b/src/ai-mate/tests/Discovery/ComposerTypeDiscoveryTest.php new file mode 100644 index 000000000..e9bd4388c --- /dev/null +++ b/src/ai-mate/tests/Discovery/ComposerTypeDiscoveryTest.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Tests\Discovery; + +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\AI\Mate\Discovery\ComposerTypeDiscovery; + +/** + * @author Johannes Wachter + * @author Tobias Nyholm + */ +class ComposerTypeDiscoveryTest extends TestCase +{ + private string $fixturesDir; + + protected function setUp(): void + { + $this->fixturesDir = __DIR__.'/Fixtures'; + } + + public function testDiscoverPackagesWithAiMateConfig() + { + $discovery = new ComposerTypeDiscovery( + $this->fixturesDir.'/with-ai-mate-config', + new NullLogger() + ); + + $extensions = $discovery->discover(); + + $this->assertCount(2, $extensions); + $this->assertArrayHasKey('vendor/package-a', $extensions); + $this->assertArrayHasKey('vendor/package-b', $extensions); + + // Check package-a structure + $this->assertArrayHasKey('dirs', $extensions['vendor/package-a']); + $this->assertArrayHasKey('includes', $extensions['vendor/package-a']); + + $this->assertContains('vendor/vendor/package-a/src', $extensions['vendor/package-a']['dirs']); + } + + public function testIgnoresPackagesWithoutAiMateConfig() + { + $discovery = new ComposerTypeDiscovery( + $this->fixturesDir.'/without-ai-mate-config', + new NullLogger() + ); + + $extensions = $discovery->discover(); + + $this->assertCount(0, $extensions); + } + + public function testIgnoresPackagesWithoutExtraSection() + { + $discovery = new ComposerTypeDiscovery( + $this->fixturesDir.'/no-extra-section', + new NullLogger() + ); + + $extensions = $discovery->discover(); + + $this->assertCount(0, $extensions); + } + + public function testWhitelistFiltering() + { + $discovery = new ComposerTypeDiscovery( + $this->fixturesDir.'/with-ai-mate-config', + new NullLogger() + ); + + $enabledExtensions = [ + 'vendor/package-a', + ]; + + $extensions = $discovery->discover($enabledExtensions); + + $this->assertCount(1, $extensions); + $this->assertArrayHasKey('vendor/package-a', $extensions); + $this->assertArrayNotHasKey('vendor/package-b', $extensions); + } + + public function testWhitelistWithMultiplePackages() + { + $discovery = new ComposerTypeDiscovery( + $this->fixturesDir.'/with-ai-mate-config', + new NullLogger() + ); + + $enabledExtensions = [ + 'vendor/package-a', + 'vendor/package-b', + ]; + + $extensions = $discovery->discover($enabledExtensions); + + $this->assertCount(2, $extensions); + $this->assertArrayHasKey('vendor/package-a', $extensions); + $this->assertArrayHasKey('vendor/package-b', $extensions); + } + + public function testExtractsIncludeFiles() + { + $discovery = new ComposerTypeDiscovery( + $this->fixturesDir.'/with-includes', + new NullLogger() + ); + + $extensions = $discovery->discover(); + + $this->assertCount(1, $extensions); + $this->assertArrayHasKey('vendor/package-with-includes', $extensions); + + $includes = $extensions['vendor/package-with-includes']['includes']; + $this->assertNotEmpty($includes); + $this->assertStringContainsString('config/services.php', $includes[0]); + } + + public function testHandlesMissingInstalledJson() + { + $discovery = new ComposerTypeDiscovery( + $this->fixturesDir.'/no-installed-json', + new NullLogger() + ); + + $extensions = $discovery->discover(); + + $this->assertCount(0, $extensions); + } + + public function testHandlesPackagesWithoutType() + { + $discovery = new ComposerTypeDiscovery( + $this->fixturesDir.'/mixed-types', + new NullLogger() + ); + + $extensions = $discovery->discover(); + + // Should discover packages with ai-mate config regardless of type field + $this->assertGreaterThanOrEqual(1, $extensions); + } +} diff --git a/src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json b/src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json new file mode 100644 index 000000000..eebe7caf8 --- /dev/null +++ b/src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json @@ -0,0 +1,12 @@ +{ + "packages": [ + { + "name": "vendor/package-mixed", + "extra": { + "ai-mate": { + "scan-dirs": ["src"] + } + } + } + ] +} diff --git a/src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep b/src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/ai-mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json b/src/ai-mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json new file mode 100644 index 000000000..cf818cce2 --- /dev/null +++ b/src/ai-mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json @@ -0,0 +1,8 @@ +{ + "packages": [ + { + "name": "vendor/no-extra", + "type": "library" + } + ] +} diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json b/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json new file mode 100644 index 000000000..47b93956c --- /dev/null +++ b/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json @@ -0,0 +1,22 @@ +{ + "packages": [ + { + "name": "vendor/package-a", + "type": "library", + "extra": { + "ai-mate": { + "scan-dirs": ["src"] + } + } + }, + { + "name": "vendor/package-b", + "type": "library", + "extra": { + "ai-mate": { + "scan-dirs": ["src"] + } + } + } + ] +} diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep b/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep b/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json b/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json new file mode 100644 index 000000000..ff1af6c74 --- /dev/null +++ b/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json @@ -0,0 +1,14 @@ +{ + "packages": [ + { + "name": "vendor/package-with-includes", + "type": "library", + "extra": { + "ai-mate": { + "scan-dirs": ["src"], + "includes": ["config/services.php"] + } + } + } + ] +} diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php b/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php new file mode 100644 index 000000000..822b6f9b8 --- /dev/null +++ b/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php @@ -0,0 +1,5 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +require_once __DIR__.'/../vendor/autoload.php'; diff --git a/src/mate/src/Bridge/Monolog/.phpunit.result.cache b/src/mate/src/Bridge/Monolog/.phpunit.result.cache new file mode 100644 index 000000000..648aad4f8 --- /dev/null +++ b/src/mate/src/Bridge/Monolog/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":2,"defects":[],"times":{"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearch":0.004,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchWithLevel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchWithChannel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchWithLimit":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchRegex":0.001,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchContext":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testTail":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testListFiles":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testListChannels":0.001,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testByLevel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseLineFormat":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseLineFormatWithoutContext":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseJsonFormat":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseJsonFormatWithNumericLevel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseEmptyLine":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseInvalidLine":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseInvalidJson":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseWithSourceFileAndLineNumber":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseLineFormatWithTimezone":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseLineFormatWithMilliseconds":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testGetLogFiles":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAll":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAllWithLimit":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAllWithLevelFilter":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAllWithChannelFilter":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAllWithTermSearch":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadFile":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testTail":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testTailWithLevel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testGetChannels":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testGetLogFilesForNonExistentDirectory":0}} \ No newline at end of file diff --git a/src/mate/src/Bridge/Symfony/.phpunit.result.cache b/src/mate/src/Bridge/Symfony/.phpunit.result.cache new file mode 100644 index 000000000..4670ee5da --- /dev/null +++ b/src/mate/src/Bridge/Symfony/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":2,"defects":[],"times":{"Symfony\\AI\\Mate\\Bridge\\Symfony\\Tests\\Capability\\ServiceToolTest::testAetAllServices":0.004}} \ No newline at end of file From 3a62fd0c8888e81a384cb2f5da024c6e43f3621a Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Tue, 16 Dec 2025 22:26:48 +0100 Subject: [PATCH 02/10] fix code review of @chr-hertel --- .github/scripts/validate-bridge-naming.sh | 7 +----- .gitignore | 4 ---- docs/components/mate.rst | 22 ++++++++++-------- splitsh.json | 4 ++-- src/ai-mate/.gitignore | 5 ++++ src/ai-mate/README.md | 4 +++- src/ai-mate/bin/mate.php | 2 -- src/ai-mate/composer.json | 13 ++++------- src/ai-mate/src/Bridge/Monolog/.gitignore | 4 ++++ src/ai-mate/src/Bridge/Monolog/CHANGELOG.md | 7 ++++++ src/ai-mate/src/Bridge/Monolog/LICENSE | 19 +++++++++++++++ src/ai-mate/src/Bridge/Monolog/composer.json | 17 +++++++++++--- .../src/Bridge/Monolog/phpstan.dist.neon | 23 +++++++++++++++++++ src/ai-mate/src/Bridge/Symfony/.gitattributes | 3 +++ src/ai-mate/src/Bridge/Symfony/.gitignore | 4 ++++ src/ai-mate/src/Bridge/Symfony/CHANGELOG.md | 7 ++++++ src/ai-mate/src/Bridge/Symfony/LICENSE | 19 +++++++++++++++ src/ai-mate/src/Bridge/Symfony/composer.json | 19 +++++++++++---- .../src/Bridge/Symfony/phpstan.dist.neon | 23 +++++++++++++++++++ 19 files changed, 167 insertions(+), 39 deletions(-) create mode 100644 src/ai-mate/.gitignore create mode 100644 src/ai-mate/src/Bridge/Monolog/.gitignore create mode 100644 src/ai-mate/src/Bridge/Monolog/CHANGELOG.md create mode 100644 src/ai-mate/src/Bridge/Monolog/LICENSE create mode 100644 src/ai-mate/src/Bridge/Monolog/phpstan.dist.neon create mode 100644 src/ai-mate/src/Bridge/Symfony/.gitattributes create mode 100644 src/ai-mate/src/Bridge/Symfony/.gitignore create mode 100644 src/ai-mate/src/Bridge/Symfony/CHANGELOG.md create mode 100644 src/ai-mate/src/Bridge/Symfony/LICENSE create mode 100644 src/ai-mate/src/Bridge/Symfony/phpstan.dist.neon diff --git a/.github/scripts/validate-bridge-naming.sh b/.github/scripts/validate-bridge-naming.sh index 177e3a840..2a62393d2 100755 --- a/.github/scripts/validate-bridge-naming.sh +++ b/.github/scripts/validate-bridge-naming.sh @@ -43,12 +43,7 @@ for composer_file in ${BRIDGE_PATH}/composer.json; do # Convert PascalCase to kebab-case (e.g., ChromaDb -> chroma-db) expected_kebab=$(echo "$bridge_name" | sed 's/\([a-z]\)\([A-Z]\)/\1-\2/g' | tr '[:upper:]' '[:lower:]') - # Special case for AI Mate bridges: symfony/ai-mate-{lowercase} - if [[ "$BRIDGE_TYPE" == "mate" ]]; then - expected_package="symfony/ai-mate-${expected_kebab}" - else - expected_package="symfony/ai-${expected_kebab}-${BRIDGE_TYPE}" - fi + expected_package="symfony/ai-${expected_kebab}-${BRIDGE_TYPE}" if [[ "$package_name" != "$expected_package" ]]; then echo "::error file=$composer_file::Package name '$package_name' does not match expected '$expected_package' for bridge '$bridge_name'" diff --git a/.gitignore b/.gitignore index 5b382e006..b86a9a51f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,3 @@ composer.lock vendor - -# Allow test fixture vendor directories -!src/ai-mate/tests/Discovery/Fixtures/**/vendor -!src/ai-mate/tests/Discovery/Fixtures/**/vendor/** diff --git a/docs/components/mate.rst b/docs/components/mate.rst index 091af1b60..595b6b843 100644 --- a/docs/components/mate.rst +++ b/docs/components/mate.rst @@ -2,7 +2,8 @@ Symfony AI - Mate Component =========================== The Mate component provides an MCP (Model Context Protocol) server that enables -AI assistants to interact with Symfony applications through standardized tools. +AI assistants to interact with PHP applications (including Symfony) through +standardized tools. This is a development tool, not intended for production use. Installation ------------ @@ -14,14 +15,16 @@ Installation Purpose ------- -Symfony AI Mate is a PHP application that creates a local MCP server to enhance your AI development assistant -(JetBrains AI, Claude, GitHub Copilot, Cursor, etc.) with specific knowledge about your application and environment. +Symfony AI Mate is a **development tool** that creates a local MCP server to enhance +your AI assistant (JetBrains AI, Claude, GitHub Copilot, Cursor, etc.) with specific +knowledge about your PHP application and development environment. -This is the core package that creates and manages your MCP server. It includes some standard tools, while framework or -project-specific tools live in their own packages (bridges). +**Important**: This is intended for development and debugging only, not for production +deployment. -Unlike other Symfony AI components, Mate is a standalone component that does not -integrate with the AI Bundle. It runs as an independent MCP server. +This is the core package that creates and manages your MCP server. It works with any +PHP application - while it includes Symfony-specific tools via bridges, the core +functionality is framework-agnostic. Quick Start ----------- @@ -200,7 +203,8 @@ Available Bridges Symfony Bridge ~~~~~~~~~~~~~~ -The Symfony bridge (``symfony/ai-mate-symfony``) provides container introspection tools: +The Symfony bridge (``symfony/ai-symfony-mate``) provides container introspection tools +for Symfony applications: * ``symfony-services`` - List all Symfony services from the compiled container @@ -225,7 +229,7 @@ the compiled container XML file (e.g., ``App_KernelDevDebugContainer.xml``) in t Monolog Bridge ~~~~~~~~~~~~~~ -The Monolog bridge (``symfony/ai-mate-monolog``) provides log search and analysis tools: +The Monolog bridge (``symfony/ai-monolog-mate``) provides log search and analysis tools: * ``monolog-search`` - Search log entries by text term with optional filters * ``monolog-search-regex`` - Search log entries using regex patterns diff --git a/splitsh.json b/splitsh.json index 0e89dfd77..c7db48150 100644 --- a/splitsh.json +++ b/splitsh.json @@ -31,8 +31,8 @@ "ai-mate": { "prefixes": [{ "from": "src/mate", "to": "", "excludes": ["src/Bridge"] }] }, - "ai-mate-monolog": "src/ai-mate/src/Bridge/Monolog", - "ai-mate-symfony": "src/ai-mate/src/Bridge/Symfony", + "ai-monolog-mate": "src/ai-mate/src/Bridge/Monolog", + "ai-symfony-mate": "src/ai-mate/src/Bridge/Symfony", "mcp-bundle": "src/mcp-bundle", "ai-platform": { "prefixes": [{ "from": "src/platform", "to": "", "excludes": ["src/Bridge"] }] diff --git a/src/ai-mate/.gitignore b/src/ai-mate/.gitignore new file mode 100644 index 000000000..efaac59ce --- /dev/null +++ b/src/ai-mate/.gitignore @@ -0,0 +1,5 @@ +vendor + +# Allow test fixture vendor directories +!tests/Discovery/Fixtures/**/vendor +!tests/Discovery/Fixtures/**/vendor/** diff --git a/src/ai-mate/README.md b/src/ai-mate/README.md index 6157a07ef..92b18e478 100644 --- a/src/ai-mate/README.md +++ b/src/ai-mate/README.md @@ -1,6 +1,8 @@ # Symfony AI - Mate Component -The Mate component provides an MCP (Model Context Protocol) server that enables AI assistants to interact with Symfony applications through standardized tools. +The Mate component provides an MCP (Model Context Protocol) server that enables AI +assistants to interact with PHP applications (including Symfony) through standardized +tools. This is a development tool, not intended for production use. **This Component is experimental**. [Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) diff --git a/src/ai-mate/bin/mate.php b/src/ai-mate/bin/mate.php index 2daecfbb2..6c9326eb1 100644 --- a/src/ai-mate/bin/mate.php +++ b/src/ai-mate/bin/mate.php @@ -1,7 +1,5 @@ =8.2", "mcp/sdk": "^0.1.0", "psr/log": "^2.0|^3.0", - "symfony/config": "^6.4|^7.3|^8.0", - "symfony/console": "^6.4|^7.4|^8.0", - "symfony/dependency-injection": "^6.4|^7.3|^8.0", - "symfony/finder": "^6.4|^7.3|^8.0" + "symfony/config": "^7.3|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.3|^8.0", + "symfony/finder": "^7.3|^8.0" }, "require-dev": { "ext-simplexml": "*", "phpstan/phpstan": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5", - "symfony/dotenv": "^6.4|^7.4|^8.0" - }, - "conflict": { - "symfony/dotenv": "<5.4.10" + "symfony/dotenv": "^7.4|^8.0" }, "minimum-stability": "dev", "autoload": { diff --git a/src/ai-mate/src/Bridge/Monolog/.gitignore b/src/ai-mate/src/Bridge/Monolog/.gitignore new file mode 100644 index 000000000..76367ee5b --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache diff --git a/src/ai-mate/src/Bridge/Monolog/CHANGELOG.md b/src/ai-mate/src/Bridge/Monolog/CHANGELOG.md new file mode 100644 index 000000000..7cebab68f --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +0.1 +--- + +* Initial release diff --git a/src/ai-mate/src/Bridge/Monolog/LICENSE b/src/ai-mate/src/Bridge/Monolog/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/ai-mate/src/Bridge/Monolog/composer.json b/src/ai-mate/src/Bridge/Monolog/composer.json index af2cc6e6c..4838f5dfa 100644 --- a/src/ai-mate/src/Bridge/Monolog/composer.json +++ b/src/ai-mate/src/Bridge/Monolog/composer.json @@ -1,5 +1,5 @@ { - "name": "symfony/ai-mate-monolog", + "name": "symfony/ai-monolog-mate", "description": "Monolog bridge for AI Mate - provides log search and analysis tools", "license": "MIT", "type": "library", @@ -30,8 +30,16 @@ "monolog/monolog": "^2.0|^3.0" }, "require-dev": { - "phpunit/phpunit": "^11.5" + "phpunit/phpunit": "^11.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0" }, + "repositories": [ + { + "type": "path", + "url": "../../.." + } + ], "minimum-stability": "dev", "autoload": { "psr-4": { @@ -45,7 +53,10 @@ } }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true + } }, "extra": { "branch-alias": { diff --git a/src/ai-mate/src/Bridge/Monolog/phpstan.dist.neon b/src/ai-mate/src/Bridge/Monolog/phpstan.dist.neon new file mode 100644 index 000000000..0454c590e --- /dev/null +++ b/src/ai-mate/src/Bridge/Monolog/phpstan.dist.neon @@ -0,0 +1,23 @@ +includes: + - ../../../../../.phpstan/extension.neon + +parameters: + level: 6 + paths: + - Capability/ + - Model/ + - Service/ + - Exception/ + - Tests/ + treatPhpDocTypesAsCertain: false + ignoreErrors: + - + message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" + - + identifier: missingType.iterableValue + path: Tests/* + reportUnmatched: false + - + identifier: 'symfonyAi.forbidNativeException' + path: Tests/* + reportUnmatched: false diff --git a/src/ai-mate/src/Bridge/Symfony/.gitattributes b/src/ai-mate/src/Bridge/Symfony/.gitattributes new file mode 100644 index 000000000..14c3c3594 --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/ai-mate/src/Bridge/Symfony/.gitignore b/src/ai-mate/src/Bridge/Symfony/.gitignore new file mode 100644 index 000000000..76367ee5b --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache diff --git a/src/ai-mate/src/Bridge/Symfony/CHANGELOG.md b/src/ai-mate/src/Bridge/Symfony/CHANGELOG.md new file mode 100644 index 000000000..7cebab68f --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +0.1 +--- + +* Initial release diff --git a/src/ai-mate/src/Bridge/Symfony/LICENSE b/src/ai-mate/src/Bridge/Symfony/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/ai-mate/src/Bridge/Symfony/composer.json b/src/ai-mate/src/Bridge/Symfony/composer.json index 6fcd19073..c1b1ce4ef 100644 --- a/src/ai-mate/src/Bridge/Symfony/composer.json +++ b/src/ai-mate/src/Bridge/Symfony/composer.json @@ -1,5 +1,5 @@ { - "name": "symfony/ai-mate-symfony", + "name": "symfony/ai-symfony-mate", "description": "Symfony bridge for AI Mate - provides Symfony container introspection tools", "license": "MIT", "type": "library", @@ -27,11 +27,19 @@ "require": { "php": ">=8.2", "symfony/ai-mate": "@dev", - "symfony/dependency-injection": "^6.4|^7.0" + "symfony/dependency-injection": "^7.3|^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.5" + "phpunit/phpunit": "^11.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0" }, + "repositories": [ + { + "type": "path", + "url": "../../.." + } + ], "minimum-stability": "dev", "autoload": { "psr-4": { @@ -45,7 +53,10 @@ } }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true + } }, "extra": { "branch-alias": { diff --git a/src/ai-mate/src/Bridge/Symfony/phpstan.dist.neon b/src/ai-mate/src/Bridge/Symfony/phpstan.dist.neon new file mode 100644 index 000000000..0454c590e --- /dev/null +++ b/src/ai-mate/src/Bridge/Symfony/phpstan.dist.neon @@ -0,0 +1,23 @@ +includes: + - ../../../../../.phpstan/extension.neon + +parameters: + level: 6 + paths: + - Capability/ + - Model/ + - Service/ + - Exception/ + - Tests/ + treatPhpDocTypesAsCertain: false + ignoreErrors: + - + message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" + - + identifier: missingType.iterableValue + path: Tests/* + reportUnmatched: false + - + identifier: 'symfonyAi.forbidNativeException' + path: Tests/* + reportUnmatched: false From fe845045f0e81e7b9ab65a6f8445f58d6fab48ea Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Tue, 16 Dec 2025 22:43:11 +0100 Subject: [PATCH 03/10] fix indentation of the Changelog.md's --- .github/scripts/validate-bridge-naming.sh | 1 - src/ai-mate/CHANGELOG.md | 2 +- src/ai-mate/src/Bridge/Monolog/CHANGELOG.md | 2 +- src/ai-mate/src/Bridge/Symfony/CHANGELOG.md | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/scripts/validate-bridge-naming.sh b/.github/scripts/validate-bridge-naming.sh index 2a62393d2..e1bb7f830 100755 --- a/.github/scripts/validate-bridge-naming.sh +++ b/.github/scripts/validate-bridge-naming.sh @@ -42,7 +42,6 @@ for composer_file in ${BRIDGE_PATH}/composer.json; do # Expected package name format: symfony/ai-{lowercase-with-dashes}-{type} # Convert PascalCase to kebab-case (e.g., ChromaDb -> chroma-db) expected_kebab=$(echo "$bridge_name" | sed 's/\([a-z]\)\([A-Z]\)/\1-\2/g' | tr '[:upper:]' '[:lower:]') - expected_package="symfony/ai-${expected_kebab}-${BRIDGE_TYPE}" if [[ "$package_name" != "$expected_package" ]]; then diff --git a/src/ai-mate/CHANGELOG.md b/src/ai-mate/CHANGELOG.md index 7cebab68f..d9af1ce7f 100644 --- a/src/ai-mate/CHANGELOG.md +++ b/src/ai-mate/CHANGELOG.md @@ -4,4 +4,4 @@ CHANGELOG 0.1 --- -* Initial release + * Initial release diff --git a/src/ai-mate/src/Bridge/Monolog/CHANGELOG.md b/src/ai-mate/src/Bridge/Monolog/CHANGELOG.md index 7cebab68f..d9af1ce7f 100644 --- a/src/ai-mate/src/Bridge/Monolog/CHANGELOG.md +++ b/src/ai-mate/src/Bridge/Monolog/CHANGELOG.md @@ -4,4 +4,4 @@ CHANGELOG 0.1 --- -* Initial release + * Initial release diff --git a/src/ai-mate/src/Bridge/Symfony/CHANGELOG.md b/src/ai-mate/src/Bridge/Symfony/CHANGELOG.md index 7cebab68f..d9af1ce7f 100644 --- a/src/ai-mate/src/Bridge/Symfony/CHANGELOG.md +++ b/src/ai-mate/src/Bridge/Symfony/CHANGELOG.md @@ -4,4 +4,4 @@ CHANGELOG 0.1 --- -* Initial release + * Initial release From 201d3844d4efde9a5900b35a9c012ba0eee1e794 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Tue, 16 Dec 2025 23:10:22 +0100 Subject: [PATCH 04/10] moved the directory from src/ai-mate to src/mate --- splitsh.json | 4 ++-- src/{ai-mate => mate}/.gitignore | 0 src/{ai-mate => mate}/CHANGELOG.md | 0 src/{ai-mate => mate}/CLAUDE.md | 2 +- src/{ai-mate => mate}/LICENSE | 0 src/{ai-mate => mate}/README.md | 0 src/{ai-mate => mate}/bin/mate | 0 src/{ai-mate => mate}/bin/mate.php | 0 src/{ai-mate => mate}/composer.json | 0 src/{ai-mate => mate}/phpstan.neon.dist | 0 src/{ai-mate => mate}/phpunit.xml.dist | 0 src/{ai-mate => mate}/resources/.mate/.gitignore | 0 src/{ai-mate => mate}/resources/.mate/extensions.php | 0 src/{ai-mate => mate}/resources/.mate/services.php | 0 src/{ai-mate => mate}/resources/mcp.json | 0 src/{ai-mate => mate}/src/App.php | 0 src/{ai-mate => mate}/src/Bridge/Monolog/.gitignore | 0 src/{ai-mate => mate}/src/Bridge/Monolog/CHANGELOG.md | 0 .../src/Bridge/Monolog/Capability/LogSearchTool.php | 0 .../src/Bridge/Monolog/Exception/ExceptionInterface.php | 0 .../src/Bridge/Monolog/Exception/LogFileNotFoundException.php | 0 src/{ai-mate => mate}/src/Bridge/Monolog/LICENSE | 0 src/{ai-mate => mate}/src/Bridge/Monolog/Model/LogEntry.php | 0 .../src/Bridge/Monolog/Model/SearchCriteria.php | 0 src/{ai-mate => mate}/src/Bridge/Monolog/README.md | 0 .../src/Bridge/Monolog/Service/LogParser.php | 0 .../src/Bridge/Monolog/Service/LogReader.php | 0 .../src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php | 0 .../src/Bridge/Monolog/Tests/Fixtures/sample.json.log | 0 .../src/Bridge/Monolog/Tests/Fixtures/sample.log | 0 .../src/Bridge/Monolog/Tests/Service/LogParserTest.php | 0 .../src/Bridge/Monolog/Tests/Service/LogReaderTest.php | 0 src/{ai-mate => mate}/src/Bridge/Monolog/composer.json | 0 src/{ai-mate => mate}/src/Bridge/Monolog/config/services.php | 0 src/{ai-mate => mate}/src/Bridge/Monolog/phpstan.dist.neon | 0 src/{ai-mate => mate}/src/Bridge/Monolog/phpunit.xml.dist | 0 src/{ai-mate => mate}/src/Bridge/Symfony/.gitattributes | 0 src/{ai-mate => mate}/src/Bridge/Symfony/.gitignore | 0 src/{ai-mate => mate}/src/Bridge/Symfony/CHANGELOG.md | 0 .../src/Bridge/Symfony/Capability/ServiceTool.php | 0 .../src/Bridge/Symfony/Exception/ExceptionInterface.php | 0 .../src/Bridge/Symfony/Exception/FileNotFoundException.php | 0 .../Exception/XmlContainerCouldNotBeLoadedException.php | 0 .../Exception/XmlContainerPathIsNotConfiguredException.php | 0 src/{ai-mate => mate}/src/Bridge/Symfony/LICENSE | 0 src/{ai-mate => mate}/src/Bridge/Symfony/Model/Container.php | 0 .../src/Bridge/Symfony/Model/ServiceDefinition.php | 0 src/{ai-mate => mate}/src/Bridge/Symfony/Model/ServiceTag.php | 0 src/{ai-mate => mate}/src/Bridge/Symfony/README.md | 0 .../src/Bridge/Symfony/Service/ContainerProvider.php | 0 .../src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php | 0 .../Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml | 0 src/{ai-mate => mate}/src/Bridge/Symfony/composer.json | 0 src/{ai-mate => mate}/src/Bridge/Symfony/config/services.php | 0 src/{ai-mate => mate}/src/Bridge/Symfony/phpstan.dist.neon | 0 src/{ai-mate => mate}/src/Bridge/Symfony/phpunit.xml.dist | 0 src/{ai-mate => mate}/src/Capability/ServerInfo.php | 0 src/{ai-mate => mate}/src/Command/ClearCacheCommand.php | 0 src/{ai-mate => mate}/src/Command/DiscoverCommand.php | 0 src/{ai-mate => mate}/src/Command/InitCommand.php | 0 src/{ai-mate => mate}/src/Command/ServeCommand.php | 0 src/{ai-mate => mate}/src/Container/ContainerFactory.php | 0 src/{ai-mate => mate}/src/Container/MateHelper.php | 0 src/{ai-mate => mate}/src/Discovery/ComposerTypeDiscovery.php | 0 .../src/Discovery/FilteredDiscoveryLoader.php | 0 src/{ai-mate => mate}/src/Discovery/ServiceDiscovery.php | 0 src/{ai-mate => mate}/src/Exception/ExceptionInterface.php | 0 .../src/Exception/MissingDependencyException.php | 0 .../src/Exception/UnsupportedVersionException.php | 0 src/{ai-mate => mate}/src/Service/Logger.php | 0 src/{ai-mate => mate}/src/default.services.php | 0 src/{ai-mate => mate}/tests/Command/DiscoverCommandTest.php | 0 src/{ai-mate => mate}/tests/Command/InitCommandTest.php | 0 .../tests/Discovery/ComposerTypeDiscoveryTest.php | 0 .../Fixtures/mixed-types/vendor/composer/installed.json | 0 .../mixed-types/vendor/vendor/package-mixed/src/.gitkeep | 0 .../Fixtures/no-extra-section/vendor/composer/installed.json | 0 .../with-ai-mate-config/vendor/composer/installed.json | 0 .../with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep | 0 .../with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep | 0 .../Fixtures/with-includes/vendor/composer/installed.json | 0 .../vendor/vendor/package-with-includes/config/services.php | 0 .../vendor/vendor/package-with-includes/src/.gitkeep | 0 .../without-ai-mate-config/vendor/composer/installed.json | 0 src/{ai-mate => mate}/tests/bootstrap.php | 0 85 files changed, 3 insertions(+), 3 deletions(-) rename src/{ai-mate => mate}/.gitignore (100%) rename src/{ai-mate => mate}/CHANGELOG.md (100%) rename src/{ai-mate => mate}/CLAUDE.md (97%) rename src/{ai-mate => mate}/LICENSE (100%) rename src/{ai-mate => mate}/README.md (100%) rename src/{ai-mate => mate}/bin/mate (100%) rename src/{ai-mate => mate}/bin/mate.php (100%) rename src/{ai-mate => mate}/composer.json (100%) rename src/{ai-mate => mate}/phpstan.neon.dist (100%) rename src/{ai-mate => mate}/phpunit.xml.dist (100%) rename src/{ai-mate => mate}/resources/.mate/.gitignore (100%) rename src/{ai-mate => mate}/resources/.mate/extensions.php (100%) rename src/{ai-mate => mate}/resources/.mate/services.php (100%) rename src/{ai-mate => mate}/resources/mcp.json (100%) rename src/{ai-mate => mate}/src/App.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/.gitignore (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/CHANGELOG.md (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Capability/LogSearchTool.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Exception/ExceptionInterface.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Exception/LogFileNotFoundException.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/LICENSE (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Model/LogEntry.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Model/SearchCriteria.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/README.md (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Service/LogParser.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Service/LogReader.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Tests/Fixtures/sample.json.log (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Tests/Fixtures/sample.log (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Tests/Service/LogParserTest.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/Tests/Service/LogReaderTest.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/composer.json (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/config/services.php (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/phpstan.dist.neon (100%) rename src/{ai-mate => mate}/src/Bridge/Monolog/phpunit.xml.dist (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/.gitattributes (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/.gitignore (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/CHANGELOG.md (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Capability/ServiceTool.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Exception/ExceptionInterface.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Exception/FileNotFoundException.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/LICENSE (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Model/Container.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Model/ServiceDefinition.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Model/ServiceTag.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/README.md (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Service/ContainerProvider.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/composer.json (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/config/services.php (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/phpstan.dist.neon (100%) rename src/{ai-mate => mate}/src/Bridge/Symfony/phpunit.xml.dist (100%) rename src/{ai-mate => mate}/src/Capability/ServerInfo.php (100%) rename src/{ai-mate => mate}/src/Command/ClearCacheCommand.php (100%) rename src/{ai-mate => mate}/src/Command/DiscoverCommand.php (100%) rename src/{ai-mate => mate}/src/Command/InitCommand.php (100%) rename src/{ai-mate => mate}/src/Command/ServeCommand.php (100%) rename src/{ai-mate => mate}/src/Container/ContainerFactory.php (100%) rename src/{ai-mate => mate}/src/Container/MateHelper.php (100%) rename src/{ai-mate => mate}/src/Discovery/ComposerTypeDiscovery.php (100%) rename src/{ai-mate => mate}/src/Discovery/FilteredDiscoveryLoader.php (100%) rename src/{ai-mate => mate}/src/Discovery/ServiceDiscovery.php (100%) rename src/{ai-mate => mate}/src/Exception/ExceptionInterface.php (100%) rename src/{ai-mate => mate}/src/Exception/MissingDependencyException.php (100%) rename src/{ai-mate => mate}/src/Exception/UnsupportedVersionException.php (100%) rename src/{ai-mate => mate}/src/Service/Logger.php (100%) rename src/{ai-mate => mate}/src/default.services.php (100%) rename src/{ai-mate => mate}/tests/Command/DiscoverCommandTest.php (100%) rename src/{ai-mate => mate}/tests/Command/InitCommandTest.php (100%) rename src/{ai-mate => mate}/tests/Discovery/ComposerTypeDiscoveryTest.php (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/src/.gitkeep (100%) rename src/{ai-mate => mate}/tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json (100%) rename src/{ai-mate => mate}/tests/bootstrap.php (100%) diff --git a/splitsh.json b/splitsh.json index c7db48150..5bd8aeb5d 100644 --- a/splitsh.json +++ b/splitsh.json @@ -31,8 +31,8 @@ "ai-mate": { "prefixes": [{ "from": "src/mate", "to": "", "excludes": ["src/Bridge"] }] }, - "ai-monolog-mate": "src/ai-mate/src/Bridge/Monolog", - "ai-symfony-mate": "src/ai-mate/src/Bridge/Symfony", + "ai-monolog-mate": "src/mate/src/Bridge/Monolog", + "ai-symfony-mate": "src/mate/src/Bridge/Symfony", "mcp-bundle": "src/mcp-bundle", "ai-platform": { "prefixes": [{ "from": "src/platform", "to": "", "excludes": ["src/Bridge"] }] diff --git a/src/ai-mate/.gitignore b/src/mate/.gitignore similarity index 100% rename from src/ai-mate/.gitignore rename to src/mate/.gitignore diff --git a/src/ai-mate/CHANGELOG.md b/src/mate/CHANGELOG.md similarity index 100% rename from src/ai-mate/CHANGELOG.md rename to src/mate/CHANGELOG.md diff --git a/src/ai-mate/CLAUDE.md b/src/mate/CLAUDE.md similarity index 97% rename from src/ai-mate/CLAUDE.md rename to src/mate/CLAUDE.md index 1837ed3b4..68edecec6 100644 --- a/src/ai-mate/CLAUDE.md +++ b/src/mate/CLAUDE.md @@ -27,7 +27,7 @@ vendor/bin/phpunit src/Bridge/Monolog/Tests/ vendor/bin/phpstan analyse # Fix code style (run from monorepo root) -cd ../../.. && vendor/bin/php-cs-fixer fix src/ai-mate/ +cd ../../.. && vendor/bin/php-cs-fixer fix src/mate/ ``` ### Running the Server diff --git a/src/ai-mate/LICENSE b/src/mate/LICENSE similarity index 100% rename from src/ai-mate/LICENSE rename to src/mate/LICENSE diff --git a/src/ai-mate/README.md b/src/mate/README.md similarity index 100% rename from src/ai-mate/README.md rename to src/mate/README.md diff --git a/src/ai-mate/bin/mate b/src/mate/bin/mate similarity index 100% rename from src/ai-mate/bin/mate rename to src/mate/bin/mate diff --git a/src/ai-mate/bin/mate.php b/src/mate/bin/mate.php similarity index 100% rename from src/ai-mate/bin/mate.php rename to src/mate/bin/mate.php diff --git a/src/ai-mate/composer.json b/src/mate/composer.json similarity index 100% rename from src/ai-mate/composer.json rename to src/mate/composer.json diff --git a/src/ai-mate/phpstan.neon.dist b/src/mate/phpstan.neon.dist similarity index 100% rename from src/ai-mate/phpstan.neon.dist rename to src/mate/phpstan.neon.dist diff --git a/src/ai-mate/phpunit.xml.dist b/src/mate/phpunit.xml.dist similarity index 100% rename from src/ai-mate/phpunit.xml.dist rename to src/mate/phpunit.xml.dist diff --git a/src/ai-mate/resources/.mate/.gitignore b/src/mate/resources/.mate/.gitignore similarity index 100% rename from src/ai-mate/resources/.mate/.gitignore rename to src/mate/resources/.mate/.gitignore diff --git a/src/ai-mate/resources/.mate/extensions.php b/src/mate/resources/.mate/extensions.php similarity index 100% rename from src/ai-mate/resources/.mate/extensions.php rename to src/mate/resources/.mate/extensions.php diff --git a/src/ai-mate/resources/.mate/services.php b/src/mate/resources/.mate/services.php similarity index 100% rename from src/ai-mate/resources/.mate/services.php rename to src/mate/resources/.mate/services.php diff --git a/src/ai-mate/resources/mcp.json b/src/mate/resources/mcp.json similarity index 100% rename from src/ai-mate/resources/mcp.json rename to src/mate/resources/mcp.json diff --git a/src/ai-mate/src/App.php b/src/mate/src/App.php similarity index 100% rename from src/ai-mate/src/App.php rename to src/mate/src/App.php diff --git a/src/ai-mate/src/Bridge/Monolog/.gitignore b/src/mate/src/Bridge/Monolog/.gitignore similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/.gitignore rename to src/mate/src/Bridge/Monolog/.gitignore diff --git a/src/ai-mate/src/Bridge/Monolog/CHANGELOG.md b/src/mate/src/Bridge/Monolog/CHANGELOG.md similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/CHANGELOG.md rename to src/mate/src/Bridge/Monolog/CHANGELOG.md diff --git a/src/ai-mate/src/Bridge/Monolog/Capability/LogSearchTool.php b/src/mate/src/Bridge/Monolog/Capability/LogSearchTool.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Capability/LogSearchTool.php rename to src/mate/src/Bridge/Monolog/Capability/LogSearchTool.php diff --git a/src/ai-mate/src/Bridge/Monolog/Exception/ExceptionInterface.php b/src/mate/src/Bridge/Monolog/Exception/ExceptionInterface.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Exception/ExceptionInterface.php rename to src/mate/src/Bridge/Monolog/Exception/ExceptionInterface.php diff --git a/src/ai-mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php b/src/mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php rename to src/mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php diff --git a/src/ai-mate/src/Bridge/Monolog/LICENSE b/src/mate/src/Bridge/Monolog/LICENSE similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/LICENSE rename to src/mate/src/Bridge/Monolog/LICENSE diff --git a/src/ai-mate/src/Bridge/Monolog/Model/LogEntry.php b/src/mate/src/Bridge/Monolog/Model/LogEntry.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Model/LogEntry.php rename to src/mate/src/Bridge/Monolog/Model/LogEntry.php diff --git a/src/ai-mate/src/Bridge/Monolog/Model/SearchCriteria.php b/src/mate/src/Bridge/Monolog/Model/SearchCriteria.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Model/SearchCriteria.php rename to src/mate/src/Bridge/Monolog/Model/SearchCriteria.php diff --git a/src/ai-mate/src/Bridge/Monolog/README.md b/src/mate/src/Bridge/Monolog/README.md similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/README.md rename to src/mate/src/Bridge/Monolog/README.md diff --git a/src/ai-mate/src/Bridge/Monolog/Service/LogParser.php b/src/mate/src/Bridge/Monolog/Service/LogParser.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Service/LogParser.php rename to src/mate/src/Bridge/Monolog/Service/LogParser.php diff --git a/src/ai-mate/src/Bridge/Monolog/Service/LogReader.php b/src/mate/src/Bridge/Monolog/Service/LogReader.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Service/LogReader.php rename to src/mate/src/Bridge/Monolog/Service/LogReader.php diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php b/src/mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php rename to src/mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log b/src/mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log rename to src/mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.log b/src/mate/src/Bridge/Monolog/Tests/Fixtures/sample.log similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Tests/Fixtures/sample.log rename to src/mate/src/Bridge/Monolog/Tests/Fixtures/sample.log diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php b/src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php rename to src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php diff --git a/src/ai-mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php b/src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php rename to src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php diff --git a/src/ai-mate/src/Bridge/Monolog/composer.json b/src/mate/src/Bridge/Monolog/composer.json similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/composer.json rename to src/mate/src/Bridge/Monolog/composer.json diff --git a/src/ai-mate/src/Bridge/Monolog/config/services.php b/src/mate/src/Bridge/Monolog/config/services.php similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/config/services.php rename to src/mate/src/Bridge/Monolog/config/services.php diff --git a/src/ai-mate/src/Bridge/Monolog/phpstan.dist.neon b/src/mate/src/Bridge/Monolog/phpstan.dist.neon similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/phpstan.dist.neon rename to src/mate/src/Bridge/Monolog/phpstan.dist.neon diff --git a/src/ai-mate/src/Bridge/Monolog/phpunit.xml.dist b/src/mate/src/Bridge/Monolog/phpunit.xml.dist similarity index 100% rename from src/ai-mate/src/Bridge/Monolog/phpunit.xml.dist rename to src/mate/src/Bridge/Monolog/phpunit.xml.dist diff --git a/src/ai-mate/src/Bridge/Symfony/.gitattributes b/src/mate/src/Bridge/Symfony/.gitattributes similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/.gitattributes rename to src/mate/src/Bridge/Symfony/.gitattributes diff --git a/src/ai-mate/src/Bridge/Symfony/.gitignore b/src/mate/src/Bridge/Symfony/.gitignore similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/.gitignore rename to src/mate/src/Bridge/Symfony/.gitignore diff --git a/src/ai-mate/src/Bridge/Symfony/CHANGELOG.md b/src/mate/src/Bridge/Symfony/CHANGELOG.md similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/CHANGELOG.md rename to src/mate/src/Bridge/Symfony/CHANGELOG.md diff --git a/src/ai-mate/src/Bridge/Symfony/Capability/ServiceTool.php b/src/mate/src/Bridge/Symfony/Capability/ServiceTool.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Capability/ServiceTool.php rename to src/mate/src/Bridge/Symfony/Capability/ServiceTool.php diff --git a/src/ai-mate/src/Bridge/Symfony/Exception/ExceptionInterface.php b/src/mate/src/Bridge/Symfony/Exception/ExceptionInterface.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Exception/ExceptionInterface.php rename to src/mate/src/Bridge/Symfony/Exception/ExceptionInterface.php diff --git a/src/ai-mate/src/Bridge/Symfony/Exception/FileNotFoundException.php b/src/mate/src/Bridge/Symfony/Exception/FileNotFoundException.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Exception/FileNotFoundException.php rename to src/mate/src/Bridge/Symfony/Exception/FileNotFoundException.php diff --git a/src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php b/src/mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php rename to src/mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php diff --git a/src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php b/src/mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php rename to src/mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php diff --git a/src/ai-mate/src/Bridge/Symfony/LICENSE b/src/mate/src/Bridge/Symfony/LICENSE similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/LICENSE rename to src/mate/src/Bridge/Symfony/LICENSE diff --git a/src/ai-mate/src/Bridge/Symfony/Model/Container.php b/src/mate/src/Bridge/Symfony/Model/Container.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Model/Container.php rename to src/mate/src/Bridge/Symfony/Model/Container.php diff --git a/src/ai-mate/src/Bridge/Symfony/Model/ServiceDefinition.php b/src/mate/src/Bridge/Symfony/Model/ServiceDefinition.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Model/ServiceDefinition.php rename to src/mate/src/Bridge/Symfony/Model/ServiceDefinition.php diff --git a/src/ai-mate/src/Bridge/Symfony/Model/ServiceTag.php b/src/mate/src/Bridge/Symfony/Model/ServiceTag.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Model/ServiceTag.php rename to src/mate/src/Bridge/Symfony/Model/ServiceTag.php diff --git a/src/ai-mate/src/Bridge/Symfony/README.md b/src/mate/src/Bridge/Symfony/README.md similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/README.md rename to src/mate/src/Bridge/Symfony/README.md diff --git a/src/ai-mate/src/Bridge/Symfony/Service/ContainerProvider.php b/src/mate/src/Bridge/Symfony/Service/ContainerProvider.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Service/ContainerProvider.php rename to src/mate/src/Bridge/Symfony/Service/ContainerProvider.php diff --git a/src/ai-mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php b/src/mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php rename to src/mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php diff --git a/src/ai-mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml b/src/mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml rename to src/mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml diff --git a/src/ai-mate/src/Bridge/Symfony/composer.json b/src/mate/src/Bridge/Symfony/composer.json similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/composer.json rename to src/mate/src/Bridge/Symfony/composer.json diff --git a/src/ai-mate/src/Bridge/Symfony/config/services.php b/src/mate/src/Bridge/Symfony/config/services.php similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/config/services.php rename to src/mate/src/Bridge/Symfony/config/services.php diff --git a/src/ai-mate/src/Bridge/Symfony/phpstan.dist.neon b/src/mate/src/Bridge/Symfony/phpstan.dist.neon similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/phpstan.dist.neon rename to src/mate/src/Bridge/Symfony/phpstan.dist.neon diff --git a/src/ai-mate/src/Bridge/Symfony/phpunit.xml.dist b/src/mate/src/Bridge/Symfony/phpunit.xml.dist similarity index 100% rename from src/ai-mate/src/Bridge/Symfony/phpunit.xml.dist rename to src/mate/src/Bridge/Symfony/phpunit.xml.dist diff --git a/src/ai-mate/src/Capability/ServerInfo.php b/src/mate/src/Capability/ServerInfo.php similarity index 100% rename from src/ai-mate/src/Capability/ServerInfo.php rename to src/mate/src/Capability/ServerInfo.php diff --git a/src/ai-mate/src/Command/ClearCacheCommand.php b/src/mate/src/Command/ClearCacheCommand.php similarity index 100% rename from src/ai-mate/src/Command/ClearCacheCommand.php rename to src/mate/src/Command/ClearCacheCommand.php diff --git a/src/ai-mate/src/Command/DiscoverCommand.php b/src/mate/src/Command/DiscoverCommand.php similarity index 100% rename from src/ai-mate/src/Command/DiscoverCommand.php rename to src/mate/src/Command/DiscoverCommand.php diff --git a/src/ai-mate/src/Command/InitCommand.php b/src/mate/src/Command/InitCommand.php similarity index 100% rename from src/ai-mate/src/Command/InitCommand.php rename to src/mate/src/Command/InitCommand.php diff --git a/src/ai-mate/src/Command/ServeCommand.php b/src/mate/src/Command/ServeCommand.php similarity index 100% rename from src/ai-mate/src/Command/ServeCommand.php rename to src/mate/src/Command/ServeCommand.php diff --git a/src/ai-mate/src/Container/ContainerFactory.php b/src/mate/src/Container/ContainerFactory.php similarity index 100% rename from src/ai-mate/src/Container/ContainerFactory.php rename to src/mate/src/Container/ContainerFactory.php diff --git a/src/ai-mate/src/Container/MateHelper.php b/src/mate/src/Container/MateHelper.php similarity index 100% rename from src/ai-mate/src/Container/MateHelper.php rename to src/mate/src/Container/MateHelper.php diff --git a/src/ai-mate/src/Discovery/ComposerTypeDiscovery.php b/src/mate/src/Discovery/ComposerTypeDiscovery.php similarity index 100% rename from src/ai-mate/src/Discovery/ComposerTypeDiscovery.php rename to src/mate/src/Discovery/ComposerTypeDiscovery.php diff --git a/src/ai-mate/src/Discovery/FilteredDiscoveryLoader.php b/src/mate/src/Discovery/FilteredDiscoveryLoader.php similarity index 100% rename from src/ai-mate/src/Discovery/FilteredDiscoveryLoader.php rename to src/mate/src/Discovery/FilteredDiscoveryLoader.php diff --git a/src/ai-mate/src/Discovery/ServiceDiscovery.php b/src/mate/src/Discovery/ServiceDiscovery.php similarity index 100% rename from src/ai-mate/src/Discovery/ServiceDiscovery.php rename to src/mate/src/Discovery/ServiceDiscovery.php diff --git a/src/ai-mate/src/Exception/ExceptionInterface.php b/src/mate/src/Exception/ExceptionInterface.php similarity index 100% rename from src/ai-mate/src/Exception/ExceptionInterface.php rename to src/mate/src/Exception/ExceptionInterface.php diff --git a/src/ai-mate/src/Exception/MissingDependencyException.php b/src/mate/src/Exception/MissingDependencyException.php similarity index 100% rename from src/ai-mate/src/Exception/MissingDependencyException.php rename to src/mate/src/Exception/MissingDependencyException.php diff --git a/src/ai-mate/src/Exception/UnsupportedVersionException.php b/src/mate/src/Exception/UnsupportedVersionException.php similarity index 100% rename from src/ai-mate/src/Exception/UnsupportedVersionException.php rename to src/mate/src/Exception/UnsupportedVersionException.php diff --git a/src/ai-mate/src/Service/Logger.php b/src/mate/src/Service/Logger.php similarity index 100% rename from src/ai-mate/src/Service/Logger.php rename to src/mate/src/Service/Logger.php diff --git a/src/ai-mate/src/default.services.php b/src/mate/src/default.services.php similarity index 100% rename from src/ai-mate/src/default.services.php rename to src/mate/src/default.services.php diff --git a/src/ai-mate/tests/Command/DiscoverCommandTest.php b/src/mate/tests/Command/DiscoverCommandTest.php similarity index 100% rename from src/ai-mate/tests/Command/DiscoverCommandTest.php rename to src/mate/tests/Command/DiscoverCommandTest.php diff --git a/src/ai-mate/tests/Command/InitCommandTest.php b/src/mate/tests/Command/InitCommandTest.php similarity index 100% rename from src/ai-mate/tests/Command/InitCommandTest.php rename to src/mate/tests/Command/InitCommandTest.php diff --git a/src/ai-mate/tests/Discovery/ComposerTypeDiscoveryTest.php b/src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php similarity index 100% rename from src/ai-mate/tests/Discovery/ComposerTypeDiscoveryTest.php rename to src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php diff --git a/src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json b/src/mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json rename to src/mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json diff --git a/src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep b/src/mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep rename to src/mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep diff --git a/src/ai-mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json b/src/mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json rename to src/mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json b/src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json rename to src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep b/src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep rename to src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep b/src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep rename to src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json b/src/mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json rename to src/mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php b/src/mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php rename to src/mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/config/services.php diff --git a/src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/src/.gitkeep b/src/mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/src/.gitkeep similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/src/.gitkeep rename to src/mate/tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/src/.gitkeep diff --git a/src/ai-mate/tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json b/src/mate/tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json similarity index 100% rename from src/ai-mate/tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json rename to src/mate/tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json diff --git a/src/ai-mate/tests/bootstrap.php b/src/mate/tests/bootstrap.php similarity index 100% rename from src/ai-mate/tests/bootstrap.php rename to src/mate/tests/bootstrap.php From 6274e1474bd8b931512da451bacd4ec287309cbd Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Wed, 17 Dec 2025 21:56:51 +0100 Subject: [PATCH 05/10] [Mate] Refactor bridge configuration and standardize directory structure - Rename mcp.json to .mcp.json following MCP convention - Standardize parameter naming: use mate.root_dir consistently - Follow Symfony directory conventions: var/cache and var/log paths - Improve DI configuration with explicit args() and service() helpers - Ensure service visibility by setting public flag on existing services --- src/mate/resources/{mcp.json => .mcp.json} | 0 src/mate/src/Bridge/Monolog/config/services.php | 13 ++++++++++--- src/mate/src/Bridge/Symfony/config/services.php | 11 ++++++++--- src/mate/src/Command/InitCommand.php | 2 +- src/mate/src/Discovery/ServiceDiscovery.php | 3 +++ 5 files changed, 22 insertions(+), 7 deletions(-) rename src/mate/resources/{mcp.json => .mcp.json} (100%) diff --git a/src/mate/resources/mcp.json b/src/mate/resources/.mcp.json similarity index 100% rename from src/mate/resources/mcp.json rename to src/mate/resources/.mcp.json diff --git a/src/mate/src/Bridge/Monolog/config/services.php b/src/mate/src/Bridge/Monolog/config/services.php index 8734e9ba4..7103c9154 100644 --- a/src/mate/src/Bridge/Monolog/config/services.php +++ b/src/mate/src/Bridge/Monolog/config/services.php @@ -11,16 +11,23 @@ use Symfony\AI\Mate\Bridge\Monolog; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use function Symfony\Component\DependencyInjection\Loader\Configurator\service; return function (ContainerConfigurator $configurator) { $configurator->parameters() - ->set('ai_mate_monolog.log_dir', '%root_dir%/var/log'); + ->set('ai_mate_monolog.log_dir', '%mate.root_dir%/var/log'); $configurator->services() ->set(Monolog\Service\LogParser::class) ->set(Monolog\Service\LogReader::class) - ->arg('$logDir', '%ai_mate_monolog.log_dir%') + ->args([ + service(Monolog\Service\LogParser::class), + '%ai_mate_monolog.log_dir%', + ]) - ->set(Monolog\Capability\LogSearchTool::class); + ->set(Monolog\Capability\LogSearchTool::class) + ->args([ + service(Monolog\Service\LogReader::class), + ]); }; diff --git a/src/mate/src/Bridge/Symfony/config/services.php b/src/mate/src/Bridge/Symfony/config/services.php index d51ca1d41..66fdc1796 100644 --- a/src/mate/src/Bridge/Symfony/config/services.php +++ b/src/mate/src/Bridge/Symfony/config/services.php @@ -11,13 +11,18 @@ use Symfony\AI\Mate\Bridge\Symfony; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use function Symfony\Component\DependencyInjection\Loader\Configurator\service; return function (ContainerConfigurator $configurator) { $configurator->parameters() - ->set('ai_mate_symfony.cache_dir', '%root_dir%/cache'); + ->set('ai_mate_symfony.cache_dir', '%mate.root_dir%/var/cache'); $configurator->services() + ->set(Symfony\Service\ContainerProvider::class) + ->set(Symfony\Capability\ServiceTool::class) - ->arg('$cacheDir', '%ai_mate_symfony.cache_dir%') - ->set(Symfony\Service\ContainerProvider::class); + ->args([ + '%ai_mate_symfony.cache_dir%', + service(Symfony\Service\ContainerProvider::class), + ]); }; diff --git a/src/mate/src/Command/InitCommand.php b/src/mate/src/Command/InitCommand.php index c4edd0990..57a029cd7 100644 --- a/src/mate/src/Command/InitCommand.php +++ b/src/mate/src/Command/InitCommand.php @@ -52,7 +52,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $actions[] = ['✓', 'Created', '.mate/ directory']; } - $files = ['.mate/extensions.php', '.mate/services.php', '.mate/.gitignore', 'mcp.json']; + $files = ['.mate/extensions.php', '.mate/services.php', '.mate/.gitignore', '.mcp.json']; foreach ($files as $file) { $fullPath = $this->rootDir.'/'.$file; if (!file_exists($fullPath)) { diff --git a/src/mate/src/Discovery/ServiceDiscovery.php b/src/mate/src/Discovery/ServiceDiscovery.php index 03291c2bc..2546b3f0a 100644 --- a/src/mate/src/Discovery/ServiceDiscovery.php +++ b/src/mate/src/Discovery/ServiceDiscovery.php @@ -87,6 +87,9 @@ private function extractClassName(\Closure|array|string $handler): ?string private function registerService(ContainerBuilder $container, string $className): void { if ($container->has($className)) { + $container->getDefinition($className) + ->setPublic(true); + return; } From 44edd6c7b239f8373b00c98fc2e772337822c3db Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Wed, 17 Dec 2025 22:09:55 +0100 Subject: [PATCH 06/10] [Mate] Use mcp.json as primary file with .mcp.json symlink - Rename resources/.mcp.json to resources/mcp.json as primary template - Update InitCommand to create mcp.json and symlink .mcp.json to it - Add symlink handling in InitCommand with user confirmation - Update tests to verify mcp.json creation and .mcp.json symlink - Improve removeDirectory helper to handle symlinks properly --- src/mate/resources/{.mcp.json => mcp.json} | 0 src/mate/src/Command/InitCommand.php | 21 ++++++++++++++++++++- src/mate/tests/Command/InitCommandTest.php | 11 ++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) rename src/mate/resources/{.mcp.json => mcp.json} (100%) diff --git a/src/mate/resources/.mcp.json b/src/mate/resources/mcp.json similarity index 100% rename from src/mate/resources/.mcp.json rename to src/mate/resources/mcp.json diff --git a/src/mate/src/Command/InitCommand.php b/src/mate/src/Command/InitCommand.php index 57a029cd7..a937dd273 100644 --- a/src/mate/src/Command/InitCommand.php +++ b/src/mate/src/Command/InitCommand.php @@ -52,7 +52,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $actions[] = ['✓', 'Created', '.mate/ directory']; } - $files = ['.mate/extensions.php', '.mate/services.php', '.mate/.gitignore', '.mcp.json']; + $files = ['.mate/extensions.php', '.mate/services.php', '.mate/.gitignore', 'mcp.json']; foreach ($files as $file) { $fullPath = $this->rootDir.'/'.$file; if (!file_exists($fullPath)) { @@ -67,6 +67,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } + // Create symlink from .mcp.json to mcp.json for compatibility + $mcpJsonPath = $this->rootDir.'/mcp.json'; + $mcpJsonSymlink = $this->rootDir.'/.mcp.json'; + if (file_exists($mcpJsonPath)) { + if (is_link($mcpJsonSymlink)) { + unlink($mcpJsonSymlink); + } + if (!file_exists($mcpJsonSymlink)) { + symlink('mcp.json', $mcpJsonSymlink); + $actions[] = ['✓', 'Created', '.mcp.json (symlink to mcp.json)']; + } elseif ($io->confirm(\sprintf('%s already exists. Replace with symlink?', $mcpJsonSymlink), false)) { + unlink($mcpJsonSymlink); + symlink('mcp.json', $mcpJsonSymlink); + $actions[] = ['✓', 'Updated', '.mcp.json (symlink to mcp.json)']; + } else { + $actions[] = ['○', 'Skipped', '.mcp.json (already exists)']; + } + } + $mateUserDir = $this->rootDir.'/mate'; if (!is_dir($mateUserDir)) { mkdir($mateUserDir, 0755, true); diff --git a/src/mate/tests/Command/InitCommandTest.php b/src/mate/tests/Command/InitCommandTest.php index 368144e95..afa9a0783 100644 --- a/src/mate/tests/Command/InitCommandTest.php +++ b/src/mate/tests/Command/InitCommandTest.php @@ -46,6 +46,9 @@ public function testCreatesDirectoryAndConfigFile() $this->assertDirectoryExists($this->tempDir.'/.mate'); $this->assertFileExists($this->tempDir.'/.mate/extensions.php'); $this->assertFileExists($this->tempDir.'/.mate/services.php'); + $this->assertFileExists($this->tempDir.'/mcp.json'); + $this->assertTrue(is_link($this->tempDir.'/.mcp.json')); + $this->assertSame('mcp.json', readlink($this->tempDir.'/.mcp.json')); $content = file_get_contents($this->tempDir.'/.mate/extensions.php'); $this->assertIsString($content); @@ -135,7 +138,13 @@ private function removeDirectory(string $dir): void $files = array_diff(scandir($dir), ['.', '..']); foreach ($files as $file) { $path = $dir.'/'.$file; - is_dir($path) ? $this->removeDirectory($path) : unlink($path); + if (is_link($path)) { + unlink($path); + } elseif (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } } rmdir($dir); } From bcb4d3313f5bf5f0fbfad88a66f8b83e394c576d Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Wed, 17 Dec 2025 22:14:45 +0100 Subject: [PATCH 07/10] [Mate] Rename environment variables to MATE_DEBUG and MATE_FILE_LOG - Change DEBUG to MATE_DEBUG for debug logging - Change FILE_LOG to MATE_FILE_LOG for file output - Update Logger service to use new variable names - Update troubleshooting documentation with new names - More specific names avoid conflicts with other tools --- docs/components/mate/troubleshooting.rst | 40 ++++++++++++++++--- .../src/Bridge/Monolog/config/services.php | 1 + .../src/Bridge/Symfony/config/services.php | 1 + src/mate/src/Service/Logger.php | 4 +- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/components/mate/troubleshooting.rst b/docs/components/mate/troubleshooting.rst index d7dff807e..0dc88221b 100644 --- a/docs/components/mate/troubleshooting.rst +++ b/docs/components/mate/troubleshooting.rst @@ -82,24 +82,54 @@ Debugging Tips Enable Debug Logging ~~~~~~~~~~~~~~~~~~~~ -Set the ``MATE_DEBUG`` environment variable: +Set the ``MATE_DEBUG`` environment variable to enable debug-level logging: .. code-block:: terminal $ MATE_DEBUG=1 vendor/bin/mate serve -This outputs detailed information to stderr. +This outputs detailed debug information to stderr, including: + +- Service registration details +- Extension discovery information +- Tool execution logs +- Internal state changes Log to File ~~~~~~~~~~~ -Set the ``MATE_LOG_FILE`` environment variable: +Set the ``MATE_FILE_LOG`` environment variable to redirect logs to a file: + +.. code-block:: terminal + + $ MATE_FILE_LOG=1 vendor/bin/mate serve + +This creates a ``dev.log`` file in the current directory with all log output. +This is particularly useful when running the server through AI assistants (like Claude Code) +where stderr output may not be easily accessible. + +You can combine both environment variables for debug logging to file: .. code-block:: terminal - $ MATE_LOG_FILE=/tmp/mate.log vendor/bin/mate serve + $ MATE_DEBUG=1 MATE_FILE_LOG=1 vendor/bin/mate serve + +For AI assistant integration (e.g., Claude Code MCP configuration), add these to the server configuration: + +.. code-block:: json -Check the log file for detailed error information. + { + "mcpServers": { + "symfony-ai-mate": { + "command": "php", + "args": ["vendor/bin/mate", "serve"], + "env": { + "MATE_DEBUG": "1", + "MATE_FILE_LOG": "1" + } + } + } + } Test Tools Manually ~~~~~~~~~~~~~~~~~~~ diff --git a/src/mate/src/Bridge/Monolog/config/services.php b/src/mate/src/Bridge/Monolog/config/services.php index 7103c9154..174581156 100644 --- a/src/mate/src/Bridge/Monolog/config/services.php +++ b/src/mate/src/Bridge/Monolog/config/services.php @@ -11,6 +11,7 @@ use Symfony\AI\Mate\Bridge\Monolog; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + use function Symfony\Component\DependencyInjection\Loader\Configurator\service; return function (ContainerConfigurator $configurator) { diff --git a/src/mate/src/Bridge/Symfony/config/services.php b/src/mate/src/Bridge/Symfony/config/services.php index 66fdc1796..f6356a586 100644 --- a/src/mate/src/Bridge/Symfony/config/services.php +++ b/src/mate/src/Bridge/Symfony/config/services.php @@ -11,6 +11,7 @@ use Symfony\AI\Mate\Bridge\Symfony; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + use function Symfony\Component\DependencyInjection\Loader\Configurator\service; return function (ContainerConfigurator $configurator) { diff --git a/src/mate/src/Service/Logger.php b/src/mate/src/Service/Logger.php index 3dbe38ad0..4c49cc305 100644 --- a/src/mate/src/Service/Logger.php +++ b/src/mate/src/Service/Logger.php @@ -21,7 +21,7 @@ class Logger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { - $debug = $_SERVER['DEBUG'] ?? false; + $debug = $_SERVER['MATE_DEBUG'] ?? false; if (!$debug && 'debug' === $level) { return; @@ -40,7 +40,7 @@ public function log($level, \Stringable|string $message, array $context = []): v ([] === $context || !$debug) ? '' : json_encode($context), ); - if (($_SERVER['FILE_LOG'] ?? false) || !\defined('STDERR')) { + if (($_SERVER['MATE_FILE_LOG'] ?? false) || !\defined('STDERR')) { file_put_contents('dev.log', $logMessage, \FILE_APPEND); } else { fwrite(\STDERR, $logMessage); From 759a20ed0155813039fe2a041f6fd51c9cb8b108 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 19 Dec 2025 07:40:03 +0100 Subject: [PATCH 08/10] fix validation workflow --- .github/workflows/validation.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml index 660d96f81..5c6286c83 100644 --- a/.github/workflows/validation.yaml +++ b/.github/workflows/validation.yaml @@ -104,20 +104,20 @@ jobs: run: .github/scripts/validate-bridge-type.sh platform validate_mate_bridges: - name: AI Mate Bridges + name: Mate Bridges runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - - name: Validate AI Mate bridge naming conventions + - name: Validate Mate bridge naming conventions run: .github/scripts/validate-bridge-naming.sh mate mate - - name: Validate AI Mate bridges are in splitsh.json + - name: Validate Mate bridges are in splitsh.json run: .github/scripts/validate-bridge-splitsh.sh mate - - name: Validate AI Mate bridges have required files + - name: Validate Mate bridges have required files run: .github/scripts/validate-bridge-files.sh mate - - name: Validate AI Mate bridges have correct type + - name: Validate Mate bridges have correct type run: .github/scripts/validate-bridge-type.sh mate From 6dc896537acc5640a1b7cc37d0b6f175190c96f2 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 19 Dec 2025 20:33:53 +0100 Subject: [PATCH 09/10] fix code review --- docs/components/mate.rst | 4 ++-- docs/components/mate/creating-extensions.rst | 3 ++- src/mate/composer.json | 8 ++++---- src/mate/phpunit.xml.dist | 11 +++-------- src/mate/src/Bridge/Monolog/.phpunit.result.cache | 1 - src/mate/src/Bridge/Monolog/CHANGELOG.md | 2 +- .../Monolog/Tests/Capability/LogSearchToolTest.php | 2 +- .../Bridge/Monolog/Tests/Service/LogParserTest.php | 2 +- .../Bridge/Monolog/Tests/Service/LogReaderTest.php | 2 +- src/mate/src/Bridge/Monolog/composer.json | 4 ++-- src/mate/src/Bridge/Monolog/config/services.php | 14 ++++++++------ src/mate/src/Bridge/Monolog/phpunit.xml.dist | 2 +- src/mate/src/Bridge/Symfony/.phpunit.result.cache | 1 - src/mate/src/Bridge/Symfony/CHANGELOG.md | 2 +- .../Symfony/Tests/Capability/ServiceToolTest.php | 2 +- src/mate/src/Bridge/Symfony/composer.json | 4 ++-- src/mate/src/Bridge/Symfony/config/services.php | 9 +++++---- src/mate/src/Bridge/Symfony/phpunit.xml.dist | 2 +- src/mate/tests/Command/DiscoverCommandTest.php | 2 +- src/mate/tests/Command/InitCommandTest.php | 2 +- .../tests/Discovery/ComposerTypeDiscoveryTest.php | 2 +- 21 files changed, 39 insertions(+), 42 deletions(-) delete mode 100644 src/mate/src/Bridge/Monolog/.phpunit.result.cache delete mode 100644 src/mate/src/Bridge/Symfony/.phpunit.result.cache diff --git a/docs/components/mate.rst b/docs/components/mate.rst index 595b6b843..4c11faeac 100644 --- a/docs/components/mate.rst +++ b/docs/components/mate.rst @@ -83,8 +83,8 @@ Start the MCP server: $ vendor/bin/mate serve -Adding Custom Tools -------------------- +Add Custom Tools +---------------- The easiest way to add tools is to create a ``mate`` folder next to your ``src`` and ``tests`` directories, then add a class with a method using the ``#[McpTool]`` attribute:: diff --git a/docs/components/mate/creating-extensions.rst b/docs/components/mate/creating-extensions.rst index 27db46975..8c1107dda 100644 --- a/docs/components/mate/creating-extensions.rst +++ b/docs/components/mate/creating-extensions.rst @@ -16,7 +16,7 @@ Quick Start "name": "vendor/my-extension", "type": "library", "require": { - "symfony/ai-mate": "^1.0" + "symfony/ai-mate": "^0.1" }, "extra": { "ai-mate": { @@ -100,6 +100,7 @@ Register service configuration files in your ``composer.json``: Create service configuration files using Symfony DI format:: // config/services.php + use App\MyApiClient; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return function (ContainerConfigurator $configurator) { diff --git a/src/mate/composer.json b/src/mate/composer.json index 321a336ee..9ddf33a28 100644 --- a/src/mate/composer.json +++ b/src/mate/composer.json @@ -30,19 +30,19 @@ ], "require": { "php": ">=8.2", - "mcp/sdk": "^0.1.0", + "mcp/sdk": "^0.1", "psr/log": "^2.0|^3.0", "symfony/config": "^7.3|^8.0", - "symfony/console": "^7.4|^8.0", + "symfony/console": "^7.3|^8.0", "symfony/dependency-injection": "^7.3|^8.0", "symfony/finder": "^7.3|^8.0" }, "require-dev": { "ext-simplexml": "*", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5", - "symfony/dotenv": "^7.4|^8.0" + "symfony/dotenv": "^7.3|^8.0" }, "minimum-stability": "dev", "autoload": { diff --git a/src/mate/phpunit.xml.dist b/src/mate/phpunit.xml.dist index 281552a7c..2df7b0a35 100644 --- a/src/mate/phpunit.xml.dist +++ b/src/mate/phpunit.xml.dist @@ -1,7 +1,7 @@ - + tests - src/Bridge/Symfony/Tests - src/Bridge/Monolog/Tests @@ -20,8 +18,5 @@ src - - src/Bridge/*/Tests - diff --git a/src/mate/src/Bridge/Monolog/.phpunit.result.cache b/src/mate/src/Bridge/Monolog/.phpunit.result.cache deleted file mode 100644 index 648aad4f8..000000000 --- a/src/mate/src/Bridge/Monolog/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -{"version":2,"defects":[],"times":{"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearch":0.004,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchWithLevel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchWithChannel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchWithLimit":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchRegex":0.001,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testSearchContext":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testTail":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testListFiles":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testListChannels":0.001,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Capability\\LogSearchToolTest::testByLevel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseLineFormat":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseLineFormatWithoutContext":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseJsonFormat":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseJsonFormatWithNumericLevel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseEmptyLine":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseInvalidLine":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseInvalidJson":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseWithSourceFileAndLineNumber":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseLineFormatWithTimezone":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogParserTest::testParseLineFormatWithMilliseconds":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testGetLogFiles":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAll":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAllWithLimit":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAllWithLevelFilter":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAllWithChannelFilter":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadAllWithTermSearch":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testReadFile":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testTail":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testTailWithLevel":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testGetChannels":0,"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\Service\\LogReaderTest::testGetLogFilesForNonExistentDirectory":0}} \ No newline at end of file diff --git a/src/mate/src/Bridge/Monolog/CHANGELOG.md b/src/mate/src/Bridge/Monolog/CHANGELOG.md index d9af1ce7f..fc132258b 100644 --- a/src/mate/src/Bridge/Monolog/CHANGELOG.md +++ b/src/mate/src/Bridge/Monolog/CHANGELOG.md @@ -4,4 +4,4 @@ CHANGELOG 0.1 --- - * Initial release + * Add bridge diff --git a/src/mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php b/src/mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php index 8084bc1b4..a04236fb2 100644 --- a/src/mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php +++ b/src/mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php @@ -19,7 +19,7 @@ /** * @author Johannes Wachter */ -class LogSearchToolTest extends TestCase +final class LogSearchToolTest extends TestCase { private LogSearchTool $tool; diff --git a/src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php b/src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php index cedf6592c..f9d0ff38d 100644 --- a/src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php +++ b/src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php @@ -17,7 +17,7 @@ /** * @author Johannes Wachter */ -class LogParserTest extends TestCase +final class LogParserTest extends TestCase { private LogParser $parser; diff --git a/src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php b/src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php index 05e5cc701..e78c688fd 100644 --- a/src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php +++ b/src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php @@ -19,7 +19,7 @@ /** * @author Johannes Wachter */ -class LogReaderTest extends TestCase +final class LogReaderTest extends TestCase { private LogReader $reader; private string $fixturesDir; diff --git a/src/mate/src/Bridge/Monolog/composer.json b/src/mate/src/Bridge/Monolog/composer.json index 4838f5dfa..ead95b19d 100644 --- a/src/mate/src/Bridge/Monolog/composer.json +++ b/src/mate/src/Bridge/Monolog/composer.json @@ -2,7 +2,7 @@ "name": "symfony/ai-monolog-mate", "description": "Monolog bridge for AI Mate - provides log search and analysis tools", "license": "MIT", - "type": "library", + "type": "symfony-ai-mate-extension", "keywords": [ "ai", "mcp", @@ -31,7 +31,7 @@ }, "require-dev": { "phpunit/phpunit": "^11.5", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1", "phpstan/phpstan-strict-rules": "^2.0" }, "repositories": [ diff --git a/src/mate/src/Bridge/Monolog/config/services.php b/src/mate/src/Bridge/Monolog/config/services.php index 174581156..420c4f49a 100644 --- a/src/mate/src/Bridge/Monolog/config/services.php +++ b/src/mate/src/Bridge/Monolog/config/services.php @@ -9,7 +9,9 @@ * file that was distributed with this source code. */ -use Symfony\AI\Mate\Bridge\Monolog; +use Symfony\AI\Mate\Bridge\Monolog\Capability\LogSearchTool; +use Symfony\AI\Mate\Bridge\Monolog\Service\LogParser; +use Symfony\AI\Mate\Bridge\Monolog\Service\LogReader; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -19,16 +21,16 @@ ->set('ai_mate_monolog.log_dir', '%mate.root_dir%/var/log'); $configurator->services() - ->set(Monolog\Service\LogParser::class) + ->set(LogParser::class) - ->set(Monolog\Service\LogReader::class) + ->set(LogReader::class) ->args([ - service(Monolog\Service\LogParser::class), + service(LogParser::class), '%ai_mate_monolog.log_dir%', ]) - ->set(Monolog\Capability\LogSearchTool::class) + ->set(LogSearchTool::class) ->args([ - service(Monolog\Service\LogReader::class), + service(LogReader::class), ]); }; diff --git a/src/mate/src/Bridge/Monolog/phpunit.xml.dist b/src/mate/src/Bridge/Monolog/phpunit.xml.dist index e4c120335..f364cc54f 100644 --- a/src/mate/src/Bridge/Monolog/phpunit.xml.dist +++ b/src/mate/src/Bridge/Monolog/phpunit.xml.dist @@ -1,7 +1,7 @@ */ -class ServiceToolTest extends TestCase +final class ServiceToolTest extends TestCase { public function testAetAllServices() { diff --git a/src/mate/src/Bridge/Symfony/composer.json b/src/mate/src/Bridge/Symfony/composer.json index c1b1ce4ef..051b3148f 100644 --- a/src/mate/src/Bridge/Symfony/composer.json +++ b/src/mate/src/Bridge/Symfony/composer.json @@ -2,7 +2,7 @@ "name": "symfony/ai-symfony-mate", "description": "Symfony bridge for AI Mate - provides Symfony container introspection tools", "license": "MIT", - "type": "library", + "type": "symfony-ai-mate-extension", "keywords": [ "ai", "mcp", @@ -31,7 +31,7 @@ }, "require-dev": { "phpunit/phpunit": "^11.5", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1", "phpstan/phpstan-strict-rules": "^2.0" }, "repositories": [ diff --git a/src/mate/src/Bridge/Symfony/config/services.php b/src/mate/src/Bridge/Symfony/config/services.php index f6356a586..75a4eb6e5 100644 --- a/src/mate/src/Bridge/Symfony/config/services.php +++ b/src/mate/src/Bridge/Symfony/config/services.php @@ -9,7 +9,8 @@ * file that was distributed with this source code. */ -use Symfony\AI\Mate\Bridge\Symfony; +use Symfony\AI\Mate\Bridge\Symfony\Capability\ServiceTool; +use Symfony\AI\Mate\Bridge\Symfony\Service\ContainerProvider; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -19,11 +20,11 @@ ->set('ai_mate_symfony.cache_dir', '%mate.root_dir%/var/cache'); $configurator->services() - ->set(Symfony\Service\ContainerProvider::class) + ->set(ContainerProvider::class) - ->set(Symfony\Capability\ServiceTool::class) + ->set(ServiceTool::class) ->args([ '%ai_mate_symfony.cache_dir%', - service(Symfony\Service\ContainerProvider::class), + service(ContainerProvider::class), ]); }; diff --git a/src/mate/src/Bridge/Symfony/phpunit.xml.dist b/src/mate/src/Bridge/Symfony/phpunit.xml.dist index 35c34d7d5..ce6fcd204 100644 --- a/src/mate/src/Bridge/Symfony/phpunit.xml.dist +++ b/src/mate/src/Bridge/Symfony/phpunit.xml.dist @@ -1,7 +1,7 @@ * @author Tobias Nyholm */ -class DiscoverCommandTest extends TestCase +final class DiscoverCommandTest extends TestCase { private string $fixturesDir; diff --git a/src/mate/tests/Command/InitCommandTest.php b/src/mate/tests/Command/InitCommandTest.php index afa9a0783..2d663177c 100644 --- a/src/mate/tests/Command/InitCommandTest.php +++ b/src/mate/tests/Command/InitCommandTest.php @@ -20,7 +20,7 @@ * @author Johannes Wachter * @author Tobias Nyholm */ -class InitCommandTest extends TestCase +final class InitCommandTest extends TestCase { private string $tempDir; diff --git a/src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php b/src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php index e9bd4388c..4228d9265 100644 --- a/src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php +++ b/src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php @@ -19,7 +19,7 @@ * @author Johannes Wachter * @author Tobias Nyholm */ -class ComposerTypeDiscoveryTest extends TestCase +final class ComposerTypeDiscoveryTest extends TestCase { private string $fixturesDir; From 93bc9dae239df53214b4988ce67c375befb16ae6 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 19 Dec 2025 20:42:29 +0100 Subject: [PATCH 10/10] add missing files to bridges --- src/mate/src/Bridge/Monolog/.gitattributes | 3 +++ .../Monolog/.github/PULL_REQUEST_TEMPLATE.md | 8 ++++++++ .../.github/workflows/close-pull-request.yml | 20 +++++++++++++++++++ src/mate/src/Bridge/Monolog/composer.json | 2 +- .../Symfony/.github/PULL_REQUEST_TEMPLATE.md | 8 ++++++++ .../.github/workflows/close-pull-request.yml | 20 +++++++++++++++++++ src/mate/src/Bridge/Symfony/composer.json | 2 +- 7 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/mate/src/Bridge/Monolog/.gitattributes create mode 100644 src/mate/src/Bridge/Monolog/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/mate/src/Bridge/Monolog/.github/workflows/close-pull-request.yml create mode 100644 src/mate/src/Bridge/Symfony/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/mate/src/Bridge/Symfony/.github/workflows/close-pull-request.yml diff --git a/src/mate/src/Bridge/Monolog/.gitattributes b/src/mate/src/Bridge/Monolog/.gitattributes new file mode 100644 index 000000000..14c3c3594 --- /dev/null +++ b/src/mate/src/Bridge/Monolog/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/mate/src/Bridge/Monolog/.github/PULL_REQUEST_TEMPLATE.md b/src/mate/src/Bridge/Monolog/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..fcb87228a --- /dev/null +++ b/src/mate/src/Bridge/Monolog/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/mate/src/Bridge/Monolog/.github/workflows/close-pull-request.yml b/src/mate/src/Bridge/Monolog/.github/workflows/close-pull-request.yml new file mode 100644 index 000000000..bb5a02835 --- /dev/null +++ b/src/mate/src/Bridge/Monolog/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/mate/src/Bridge/Monolog/composer.json b/src/mate/src/Bridge/Monolog/composer.json index ead95b19d..779dd7d94 100644 --- a/src/mate/src/Bridge/Monolog/composer.json +++ b/src/mate/src/Bridge/Monolog/composer.json @@ -2,7 +2,7 @@ "name": "symfony/ai-monolog-mate", "description": "Monolog bridge for AI Mate - provides log search and analysis tools", "license": "MIT", - "type": "symfony-ai-mate-extension", + "type": "symfony-ai-mate", "keywords": [ "ai", "mcp", diff --git a/src/mate/src/Bridge/Symfony/.github/PULL_REQUEST_TEMPLATE.md b/src/mate/src/Bridge/Symfony/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..fcb87228a --- /dev/null +++ b/src/mate/src/Bridge/Symfony/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/mate/src/Bridge/Symfony/.github/workflows/close-pull-request.yml b/src/mate/src/Bridge/Symfony/.github/workflows/close-pull-request.yml new file mode 100644 index 000000000..bb5a02835 --- /dev/null +++ b/src/mate/src/Bridge/Symfony/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/mate/src/Bridge/Symfony/composer.json b/src/mate/src/Bridge/Symfony/composer.json index 051b3148f..fb73f7483 100644 --- a/src/mate/src/Bridge/Symfony/composer.json +++ b/src/mate/src/Bridge/Symfony/composer.json @@ -2,7 +2,7 @@ "name": "symfony/ai-symfony-mate", "description": "Symfony bridge for AI Mate - provides Symfony container introspection tools", "license": "MIT", - "type": "symfony-ai-mate-extension", + "type": "symfony-ai-mate", "keywords": [ "ai", "mcp",