diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml
index 41e88c636..5c6286c83 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: Mate Bridges
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Validate Mate bridge naming conventions
+ run: .github/scripts/validate-bridge-naming.sh mate mate
+
+ - name: Validate Mate bridges are in splitsh.json
+ run: .github/scripts/validate-bridge-splitsh.sh mate
+
+ - name: Validate Mate bridges have required files
+ run: .github/scripts/validate-bridge-files.sh mate
+
+ - name: Validate Mate bridges have correct type
+ run: .github/scripts/validate-bridge-type.sh mate
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..4c11faeac
--- /dev/null
+++ b/docs/components/mate.rst
@@ -0,0 +1,308 @@
+Symfony AI - Mate Component
+===========================
+
+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.
+
+Installation
+------------
+
+.. code-block:: terminal
+
+ $ composer require symfony/ai-mate
+
+Purpose
+-------
+
+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.
+
+**Important**: This is intended for development and debugging only, not for production
+deployment.
+
+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
+-----------
+
+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
+
+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::
+
+ // 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-symfony-mate``) provides container introspection tools
+for Symfony applications:
+
+* ``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-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
+* ``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..8c1107dda
--- /dev/null
+++ b/docs/components/mate/creating-extensions.rst
@@ -0,0 +1,269 @@
+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": "^0.1"
+ },
+ "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 App\MyApiClient;
+ 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..0dc88221b
--- /dev/null
+++ b/docs/components/mate/troubleshooting.rst
@@ -0,0 +1,167 @@
+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 to enable debug-level logging:
+
+.. code-block:: terminal
+
+ $ MATE_DEBUG=1 vendor/bin/mate serve
+
+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_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_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
+
+ {
+ "mcpServers": {
+ "symfony-ai-mate": {
+ "command": "php",
+ "args": ["vendor/bin/mate", "serve"],
+ "env": {
+ "MATE_DEBUG": "1",
+ "MATE_FILE_LOG": "1"
+ }
+ }
+ }
+ }
+
+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..5bd8aeb5d 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-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/mate/.gitignore b/src/mate/.gitignore
new file mode 100644
index 000000000..efaac59ce
--- /dev/null
+++ b/src/mate/.gitignore
@@ -0,0 +1,5 @@
+vendor
+
+# Allow test fixture vendor directories
+!tests/Discovery/Fixtures/**/vendor
+!tests/Discovery/Fixtures/**/vendor/**
diff --git a/src/mate/CHANGELOG.md b/src/mate/CHANGELOG.md
new file mode 100644
index 000000000..d9af1ce7f
--- /dev/null
+++ b/src/mate/CHANGELOG.md
@@ -0,0 +1,7 @@
+CHANGELOG
+=========
+
+0.1
+---
+
+ * Initial release
diff --git a/src/mate/CLAUDE.md b/src/mate/CLAUDE.md
new file mode 100644
index 000000000..68edecec6
--- /dev/null
+++ b/src/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/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/mate/LICENSE b/src/mate/LICENSE
new file mode 100644
index 000000000..bc38d714e
--- /dev/null
+++ b/src/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/mate/README.md b/src/mate/README.md
new file mode 100644
index 000000000..92b18e478
--- /dev/null
+++ b/src/mate/README.md
@@ -0,0 +1,26 @@
+# Symfony AI - Mate Component
+
+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)
+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/mate/bin/mate b/src/mate/bin/mate
new file mode 100755
index 000000000..f8664a1a2
--- /dev/null
+++ b/src/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/mate/composer.json b/src/mate/composer.json
new file mode 100644
index 000000000..9ddf33a28
--- /dev/null
+++ b/src/mate/composer.json
@@ -0,0 +1,79 @@
+{
+ "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",
+ "psr/log": "^2.0|^3.0",
+ "symfony/config": "^7.3|^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.1",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^11.5",
+ "symfony/dotenv": "^7.3|^8.0"
+ },
+ "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/mate/phpstan.neon.dist b/src/mate/phpstan.neon.dist
new file mode 100644
index 000000000..988f6c4d0
--- /dev/null
+++ b/src/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/mate/phpunit.xml.dist b/src/mate/phpunit.xml.dist
new file mode 100644
index 000000000..2df7b0a35
--- /dev/null
+++ b/src/mate/phpunit.xml.dist
@@ -0,0 +1,22 @@
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
diff --git a/src/mate/resources/.mate/.gitignore b/src/mate/resources/.mate/.gitignore
new file mode 100644
index 000000000..11ee75815
--- /dev/null
+++ b/src/mate/resources/.mate/.gitignore
@@ -0,0 +1 @@
+.env.local
diff --git a/src/mate/resources/.mate/extensions.php b/src/mate/resources/.mate/extensions.php
new file mode 100644
index 000000000..e00b5e12c
--- /dev/null
+++ b/src/mate/resources/.mate/extensions.php
@@ -0,0 +1,10 @@
+ ['enabled' => true],
+];
diff --git a/src/mate/resources/.mate/services.php b/src/mate/resources/.mate/services.php
new file mode 100644
index 000000000..edcf55dda
--- /dev/null
+++ b/src/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/mate/resources/mcp.json b/src/mate/resources/mcp.json
new file mode 100644
index 000000000..d41364630
--- /dev/null
+++ b/src/mate/resources/mcp.json
@@ -0,0 +1,10 @@
+{
+ "mcpServers": {
+ "symfony-ai-mate": {
+ "command": "./vendor/bin/mate",
+ "args": [
+ "serve"
+ ]
+ }
+ }
+}
diff --git a/src/mate/src/App.php b/src/mate/src/App.php
new file mode 100644
index 000000000..7dcd531cd
--- /dev/null
+++ b/src/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/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/.gitignore b/src/mate/src/Bridge/Monolog/.gitignore
new file mode 100644
index 000000000..76367ee5b
--- /dev/null
+++ b/src/mate/src/Bridge/Monolog/.gitignore
@@ -0,0 +1,4 @@
+vendor/
+composer.lock
+phpunit.xml
+.phpunit.result.cache
diff --git a/src/mate/src/Bridge/Monolog/CHANGELOG.md b/src/mate/src/Bridge/Monolog/CHANGELOG.md
new file mode 100644
index 000000000..fc132258b
--- /dev/null
+++ b/src/mate/src/Bridge/Monolog/CHANGELOG.md
@@ -0,0 +1,7 @@
+CHANGELOG
+=========
+
+0.1
+---
+
+ * Add bridge
diff --git a/src/mate/src/Bridge/Monolog/Capability/LogSearchTool.php b/src/mate/src/Bridge/Monolog/Capability/LogSearchTool.php
new file mode 100644
index 000000000..49ca33eaf
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Exception/ExceptionInterface.php b/src/mate/src/Bridge/Monolog/Exception/ExceptionInterface.php
new file mode 100644
index 000000000..406a168de
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php b/src/mate/src/Bridge/Monolog/Exception/LogFileNotFoundException.php
new file mode 100644
index 000000000..c009fdf13
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/LICENSE b/src/mate/src/Bridge/Monolog/LICENSE
new file mode 100644
index 000000000..bc38d714e
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Model/LogEntry.php b/src/mate/src/Bridge/Monolog/Model/LogEntry.php
new file mode 100644
index 000000000..0e0232326
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Model/SearchCriteria.php b/src/mate/src/Bridge/Monolog/Model/SearchCriteria.php
new file mode 100644
index 000000000..5be7a6493
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/README.md b/src/mate/src/Bridge/Monolog/README.md
new file mode 100644
index 000000000..9b686ddcd
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Service/LogParser.php b/src/mate/src/Bridge/Monolog/Service/LogParser.php
new file mode 100644
index 000000000..2e84106df
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Service/LogReader.php b/src/mate/src/Bridge/Monolog/Service/LogReader.php
new file mode 100644
index 000000000..6399a7132
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php b/src/mate/src/Bridge/Monolog/Tests/Capability/LogSearchToolTest.php
new file mode 100644
index 000000000..a04236fb2
--- /dev/null
+++ b/src/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
+ */
+final 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/mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log b/src/mate/src/Bridge/Monolog/Tests/Fixtures/sample.json.log
new file mode 100644
index 000000000..f2c75da89
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Tests/Fixtures/sample.log b/src/mate/src/Bridge/Monolog/Tests/Fixtures/sample.log
new file mode 100644
index 000000000..800b7b575
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php b/src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php
new file mode 100644
index 000000000..f9d0ff38d
--- /dev/null
+++ b/src/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
+ */
+final 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/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php b/src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php
new file mode 100644
index 000000000..e78c688fd
--- /dev/null
+++ b/src/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
+ */
+final 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/mate/src/Bridge/Monolog/composer.json b/src/mate/src/Bridge/Monolog/composer.json
new file mode 100644
index 000000000..779dd7d94
--- /dev/null
+++ b/src/mate/src/Bridge/Monolog/composer.json
@@ -0,0 +1,74 @@
+{
+ "name": "symfony/ai-monolog-mate",
+ "description": "Monolog bridge for AI Mate - provides log search and analysis tools",
+ "license": "MIT",
+ "type": "symfony-ai-mate",
+ "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",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-strict-rules": "^2.0"
+ },
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../.."
+ }
+ ],
+ "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,
+ "allow-plugins": {
+ "php-http/discovery": 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/mate/src/Bridge/Monolog/config/services.php b/src/mate/src/Bridge/Monolog/config/services.php
new file mode 100644
index 000000000..420c4f49a
--- /dev/null
+++ b/src/mate/src/Bridge/Monolog/config/services.php
@@ -0,0 +1,36 @@
+
+ *
+ * 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\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;
+
+return function (ContainerConfigurator $configurator) {
+ $configurator->parameters()
+ ->set('ai_mate_monolog.log_dir', '%mate.root_dir%/var/log');
+
+ $configurator->services()
+ ->set(LogParser::class)
+
+ ->set(LogReader::class)
+ ->args([
+ service(LogParser::class),
+ '%ai_mate_monolog.log_dir%',
+ ])
+
+ ->set(LogSearchTool::class)
+ ->args([
+ service(LogReader::class),
+ ]);
+};
diff --git a/src/mate/src/Bridge/Monolog/phpstan.dist.neon b/src/mate/src/Bridge/Monolog/phpstan.dist.neon
new file mode 100644
index 000000000..0454c590e
--- /dev/null
+++ b/src/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/mate/src/Bridge/Monolog/phpunit.xml.dist b/src/mate/src/Bridge/Monolog/phpunit.xml.dist
new file mode 100644
index 000000000..f364cc54f
--- /dev/null
+++ b/src/mate/src/Bridge/Monolog/phpunit.xml.dist
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+ ./Tests/
+
+
+
+
+
+ ./
+
+
+ ./Tests
+ ./vendor
+
+
+
diff --git a/src/mate/src/Bridge/Symfony/.gitattributes b/src/mate/src/Bridge/Symfony/.gitattributes
new file mode 100644
index 000000000..14c3c3594
--- /dev/null
+++ b/src/mate/src/Bridge/Symfony/.gitattributes
@@ -0,0 +1,3 @@
+/Tests export-ignore
+/phpunit.xml.dist export-ignore
+/.git* export-ignore
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/.gitignore b/src/mate/src/Bridge/Symfony/.gitignore
new file mode 100644
index 000000000..76367ee5b
--- /dev/null
+++ b/src/mate/src/Bridge/Symfony/.gitignore
@@ -0,0 +1,4 @@
+vendor/
+composer.lock
+phpunit.xml
+.phpunit.result.cache
diff --git a/src/mate/src/Bridge/Symfony/CHANGELOG.md b/src/mate/src/Bridge/Symfony/CHANGELOG.md
new file mode 100644
index 000000000..fc132258b
--- /dev/null
+++ b/src/mate/src/Bridge/Symfony/CHANGELOG.md
@@ -0,0 +1,7 @@
+CHANGELOG
+=========
+
+0.1
+---
+
+ * Add bridge
diff --git a/src/mate/src/Bridge/Symfony/Capability/ServiceTool.php b/src/mate/src/Bridge/Symfony/Capability/ServiceTool.php
new file mode 100644
index 000000000..b673347a6
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Exception/ExceptionInterface.php b/src/mate/src/Bridge/Symfony/Exception/ExceptionInterface.php
new file mode 100644
index 000000000..623adef4f
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Exception/FileNotFoundException.php b/src/mate/src/Bridge/Symfony/Exception/FileNotFoundException.php
new file mode 100644
index 000000000..66e6ce185
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php b/src/mate/src/Bridge/Symfony/Exception/XmlContainerCouldNotBeLoadedException.php
new file mode 100644
index 000000000..58506c567
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php b/src/mate/src/Bridge/Symfony/Exception/XmlContainerPathIsNotConfiguredException.php
new file mode 100644
index 000000000..f9d87d22b
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/LICENSE b/src/mate/src/Bridge/Symfony/LICENSE
new file mode 100644
index 000000000..bc38d714e
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Model/Container.php b/src/mate/src/Bridge/Symfony/Model/Container.php
new file mode 100644
index 000000000..adf007887
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Model/ServiceDefinition.php b/src/mate/src/Bridge/Symfony/Model/ServiceDefinition.php
new file mode 100644
index 000000000..1a59bb63e
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Model/ServiceTag.php b/src/mate/src/Bridge/Symfony/Model/ServiceTag.php
new file mode 100644
index 000000000..82e2964f2
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/README.md b/src/mate/src/Bridge/Symfony/README.md
new file mode 100644
index 000000000..f0f8f8025
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Service/ContainerProvider.php b/src/mate/src/Bridge/Symfony/Service/ContainerProvider.php
new file mode 100644
index 000000000..76df795ee
--- /dev/null
+++ b/src/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/mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php b/src/mate/src/Bridge/Symfony/Tests/Capability/ServiceToolTest.php
new file mode 100644
index 000000000..725017c80
--- /dev/null
+++ b/src/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
+ */
+final 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/mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml b/src/mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml
new file mode 100644
index 000000000..a1c626344
--- /dev/null
+++ b/src/mate/src/Bridge/Symfony/Tests/Fixtures/App_KernelDevDebugContainer.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/mate/src/Bridge/Symfony/composer.json b/src/mate/src/Bridge/Symfony/composer.json
new file mode 100644
index 000000000..fb73f7483
--- /dev/null
+++ b/src/mate/src/Bridge/Symfony/composer.json
@@ -0,0 +1,74 @@
+{
+ "name": "symfony/ai-symfony-mate",
+ "description": "Symfony bridge for AI Mate - provides Symfony container introspection tools",
+ "license": "MIT",
+ "type": "symfony-ai-mate",
+ "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": "^7.3|^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.5",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-strict-rules": "^2.0"
+ },
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../.."
+ }
+ ],
+ "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,
+ "allow-plugins": {
+ "php-http/discovery": 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/mate/src/Bridge/Symfony/config/services.php b/src/mate/src/Bridge/Symfony/config/services.php
new file mode 100644
index 000000000..75a4eb6e5
--- /dev/null
+++ b/src/mate/src/Bridge/Symfony/config/services.php
@@ -0,0 +1,30 @@
+
+ *
+ * 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\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;
+
+return function (ContainerConfigurator $configurator) {
+ $configurator->parameters()
+ ->set('ai_mate_symfony.cache_dir', '%mate.root_dir%/var/cache');
+
+ $configurator->services()
+ ->set(ContainerProvider::class)
+
+ ->set(ServiceTool::class)
+ ->args([
+ '%ai_mate_symfony.cache_dir%',
+ service(ContainerProvider::class),
+ ]);
+};
diff --git a/src/mate/src/Bridge/Symfony/phpstan.dist.neon b/src/mate/src/Bridge/Symfony/phpstan.dist.neon
new file mode 100644
index 000000000..0454c590e
--- /dev/null
+++ b/src/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
diff --git a/src/mate/src/Bridge/Symfony/phpunit.xml.dist b/src/mate/src/Bridge/Symfony/phpunit.xml.dist
new file mode 100644
index 000000000..ce6fcd204
--- /dev/null
+++ b/src/mate/src/Bridge/Symfony/phpunit.xml.dist
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+ ./Tests/
+
+
+
+
+
+ ./
+
+
+ ./Tests
+ ./vendor
+
+
+
diff --git a/src/mate/src/Capability/ServerInfo.php b/src/mate/src/Capability/ServerInfo.php
new file mode 100644
index 000000000..8c88063e1
--- /dev/null
+++ b/src/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/mate/src/Command/ClearCacheCommand.php b/src/mate/src/Command/ClearCacheCommand.php
new file mode 100644
index 000000000..fe9978b2c
--- /dev/null
+++ b/src/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/mate/src/Command/DiscoverCommand.php b/src/mate/src/Command/DiscoverCommand.php
new file mode 100644
index 000000000..2c5e0b97a
--- /dev/null
+++ b/src/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/mate/src/Command/InitCommand.php b/src/mate/src/Command/InitCommand.php
new file mode 100644
index 000000000..a937dd273
--- /dev/null
+++ b/src/mate/src/Command/InitCommand.php
@@ -0,0 +1,186 @@
+
+ *
+ * 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)'];
+ }
+ }
+
+ // 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);
+ 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/mate/src/Command/ServeCommand.php b/src/mate/src/Command/ServeCommand.php
new file mode 100644
index 000000000..ff321bf69
--- /dev/null
+++ b/src/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/mate/src/Container/ContainerFactory.php b/src/mate/src/Container/ContainerFactory.php
new file mode 100644
index 000000000..3114f64e9
--- /dev/null
+++ b/src/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/mate/src/Container/MateHelper.php b/src/mate/src/Container/MateHelper.php
new file mode 100644
index 000000000..c7446b4f2
--- /dev/null
+++ b/src/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/mate/src/Discovery/ComposerTypeDiscovery.php b/src/mate/src/Discovery/ComposerTypeDiscovery.php
new file mode 100644
index 000000000..0681719f1
--- /dev/null
+++ b/src/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/mate/src/Discovery/FilteredDiscoveryLoader.php b/src/mate/src/Discovery/FilteredDiscoveryLoader.php
new file mode 100644
index 000000000..39755d35d
--- /dev/null
+++ b/src/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/mate/src/Discovery/ServiceDiscovery.php b/src/mate/src/Discovery/ServiceDiscovery.php
new file mode 100644
index 000000000..2546b3f0a
--- /dev/null
+++ b/src/mate/src/Discovery/ServiceDiscovery.php
@@ -0,0 +1,100 @@
+
+ *
+ * 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)) {
+ $container->getDefinition($className)
+ ->setPublic(true);
+
+ return;
+ }
+
+ $container->register($className, $className)
+ ->setAutowired(true)
+ ->setPublic(true);
+ }
+}
diff --git a/src/mate/src/Exception/ExceptionInterface.php b/src/mate/src/Exception/ExceptionInterface.php
new file mode 100644
index 000000000..32962ef2f
--- /dev/null
+++ b/src/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/mate/src/Exception/MissingDependencyException.php b/src/mate/src/Exception/MissingDependencyException.php
new file mode 100644
index 000000000..ef6ade2f4
--- /dev/null
+++ b/src/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/mate/src/Exception/UnsupportedVersionException.php b/src/mate/src/Exception/UnsupportedVersionException.php
new file mode 100644
index 000000000..23cc63000
--- /dev/null
+++ b/src/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/mate/src/Service/Logger.php b/src/mate/src/Service/Logger.php
new file mode 100644
index 000000000..4c49cc305
--- /dev/null
+++ b/src/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['MATE_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['MATE_FILE_LOG'] ?? false) || !\defined('STDERR')) {
+ file_put_contents('dev.log', $logMessage, \FILE_APPEND);
+ } else {
+ fwrite(\STDERR, $logMessage);
+ }
+ }
+}
diff --git a/src/mate/src/default.services.php b/src/mate/src/default.services.php
new file mode 100644
index 000000000..0a4d7ffde
--- /dev/null
+++ b/src/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/mate/tests/Command/DiscoverCommandTest.php b/src/mate/tests/Command/DiscoverCommandTest.php
new file mode 100644
index 000000000..7c7fb3e58
--- /dev/null
+++ b/src/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
+ */
+final 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/mate/tests/Command/InitCommandTest.php b/src/mate/tests/Command/InitCommandTest.php
new file mode 100644
index 000000000..2d663177c
--- /dev/null
+++ b/src/mate/tests/Command/InitCommandTest.php
@@ -0,0 +1,151 @@
+
+ *
+ * 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
+ */
+final 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');
+ $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);
+ $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;
+ if (is_link($path)) {
+ unlink($path);
+ } elseif (is_dir($path)) {
+ $this->removeDirectory($path);
+ } else {
+ unlink($path);
+ }
+ }
+ rmdir($dir);
+ }
+}
diff --git a/src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php b/src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php
new file mode 100644
index 000000000..4228d9265
--- /dev/null
+++ b/src/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
+ */
+final 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/mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json b/src/mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json
new file mode 100644
index 000000000..eebe7caf8
--- /dev/null
+++ b/src/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/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
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json b/src/mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json
new file mode 100644
index 000000000..cf818cce2
--- /dev/null
+++ b/src/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/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
new file mode 100644
index 000000000..47b93956c
--- /dev/null
+++ b/src/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/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
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/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
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json b/src/mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json
new file mode 100644
index 000000000..ff1af6c74
--- /dev/null
+++ b/src/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/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
new file mode 100644
index 000000000..822b6f9b8
--- /dev/null
+++ b/src/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';