Add AI Mate component for MCP server integration

This commit is contained in:
Johannes Wachter
2025-12-16 20:05:06 +01:00
committed by Christopher Hertel
parent c5fe55c808
commit c151ab8388
97 changed files with 5704 additions and 0 deletions

View File

@@ -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

View File

@@ -6,5 +6,6 @@ Components
agent
chat
mate
platform
store

311
docs/components/mate.rst Normal file
View File

@@ -0,0 +1,311 @@
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 features
* ``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 bridges:
.. 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``.
Bridges 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.
See the `Symfony documentation on environment variables`_ for more information.
.. _`Symfony documentation on environment variables`: https://symfony.com/doc/current/configuration.html#configuration-based-on-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

View File

@@ -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.

View File

@@ -0,0 +1,134 @@
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 (see `JetBrains MCP documentation`_ for more details):
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 (see `Claude Desktop MCP documentation`_ for more details):
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 (see `Claude Code MCP documentation`_ for more details):
.. code-block:: terminal
$ claude mcp add mate $(pwd)/vendor/bin/mate serve --scope local
$ claude mcp list # Verify: mate - ✓ Connected
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.
.. _`JetBrains MCP documentation`: https://www.jetbrains.com/help/idea/model-context-protocol.html
.. _`Claude Desktop MCP documentation`: https://docs.anthropic.com/en/docs/build-with-claude/mcp
.. _`Claude Code MCP documentation`: https://docs.anthropic.com/en/docs/build-with-claude/claude-code

View File

@@ -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)

View File

@@ -15,6 +15,7 @@ AI capabilities to your application:
* :doc:`Agent Component </components/agent>`: Framework for building AI agents with tools and workflows
* :doc:`Chat Component </components/chat>`: API to interact with agents and store conversation history
* :doc:`Store Component </components/store>`: Data storage abstraction for vector databases and RAG applications
* :doc:`Mate Component </components/mate>`: MCP server for AI assistant integration with your application
* :doc:`AI Bundle </bundles/ai-bundle>`: Symfony integration bringing all components together
* :doc:`MCP Bundle </bundles/mcp-bundle>`: Integration for the Model Context Protocol SDK

View File

@@ -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"] }]

5
src/mate/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
vendor
# Allow test fixture vendor directories
!tests/Discovery/Fixtures/**/vendor
!tests/Discovery/Fixtures/**/vendor/**

7
src/mate/CHANGELOG.md Normal file
View File

@@ -0,0 +1,7 @@
CHANGELOG
=========
0.1
---
* Add component

86
src/mate/CLAUDE.md Normal file
View File

@@ -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 bridges
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 bridge discovery
- **ComposerTypeDiscovery**: Discovers MCP bridges 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/`: Bridge 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/bridges.php`: Enable/disable bridges
- `.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

19
src/mate/LICENSE Normal file
View File

@@ -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.

21
src/mate/README.md Normal file
View File

@@ -0,0 +1,21 @@
# 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
```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
* [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)

4
src/mate/bin/mate Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
include __DIR__ . '/mate.php';

41
src/mate/bin/mate.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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();

79
src/mate/composer.json Normal file
View File

@@ -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.46",
"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"
]
}
}
}

View File

@@ -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\\.$#"

22
src/mate/phpunit.xml.dist Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
colors="true"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Symony AI Mate Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

1
src/mate/resources/.mate/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env.local

View File

@@ -0,0 +1,10 @@
<?php
// This file is managed by 'mate discover'
// You can manually edit to enable/disable bridges
return [
// Bridges will be added automatically by 'mate discover'
// Example:
'symfony/ai-mate' => ['enabled' => true],
];

View File

@@ -0,0 +1,18 @@
<?php
// User's service configuration file
// This file is loaded into the Symfony DI container
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']) // This will load .mate/.env and .mate/.env.local
;
$container->services()
// Register your custom services here
;
};

View File

@@ -0,0 +1,10 @@
{
"mcpServers": {
"symfony-ai-mate": {
"command": "./vendor/bin/mate",
"args": [
"serve"
]
}
}
}

65
src/mate/src/App.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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();
}
}
}

View File

@@ -0,0 +1,3 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.git* export-ignore

View File

@@ -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!

View File

@@ -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!

View File

@@ -0,0 +1,4 @@
vendor/
composer.lock
phpunit.xml
.phpunit.result.cache

View File

@@ -0,0 +1,7 @@
CHANGELOG
=========
0.1
---
* Add bridge

View File

@@ -0,0 +1,199 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\LogEntry;
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.
*
* @phpstan-import-type LogEntryArray from LogEntry
*
* @author Johannes Wachter <johannes@sulu.io>
*/
final class LogSearchTool
{
public function __construct(
private LogReader $reader,
) {
}
/**
* @phpstan-return list<LogEntryArray>
*/
#[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);
}
/**
* @phpstan-return list<LogEntryArray>
*/
#[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);
}
/**
* @phpstan-return list<LogEntryArray>
*/
#[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);
}
/**
* @phpstan-return list<LogEntryArray>
*/
#[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<int, array{
* name: string,
* path: string,
* size: int,
* modified: string
* }>
*/
#[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).
*
* @phpstan-return list<LogEntryArray>
*/
#[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);
}
/**
* @phpstan-return list<LogEntryArray>
*/
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;
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
use Symfony\AI\Mate\Exception\InvalidArgumentException;
/**
* @author Johannes Wachter <johannes@sulu.io>
*
* @internal
*/
class LogFileNotFoundException extends InvalidArgumentException
{
public static function forPath(string $path): self
{
return new self(\sprintf('Log file not found: "%s"', $path));
}
}

View File

@@ -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.

View File

@@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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.
*
* @phpstan-type LogEntryArray array{
* datetime: string,
* channel: string,
* level: string,
* message: string,
* context: array<string, mixed>,
* extra: array<string, mixed>,
* source_file: string|null,
* line_number: int|null
* }
*
* @author Johannes Wachter <johannes@sulu.io>
*/
final class LogEntry
{
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $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,
) {
}
/**
* @phpstan-return LogEntryArray
*/
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));
}
}

View File

@@ -0,0 +1,69 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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;
}
}

View File

@@ -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)

View File

@@ -0,0 +1,324 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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 = '/^\[(?<datetime>\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z)?)\]\s+(?<channel>[\w.-]+)\.(?<level>\w+):\s+(?<rest>.+)$/';
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<string, mixed>|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<string, mixed> $context */
$context = \is_array($contextRaw) ? $contextRaw : [];
/** @var array<string, mixed> $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<string, mixed>, 2: array<string, mixed>}
*/
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<string, mixed>, 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<string, mixed> $validatedParsed */
$validatedParsed = $parsed;
return [$validatedParsed, rtrim($remaining)];
}
return null;
} catch (\JsonException) {
return null;
}
}
/**
* @param array<string, mixed> $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',
};
}
}

View File

@@ -0,0 +1,223 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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<LogEntry>
*/
public function readAll(?SearchCriteria $criteria = null): \Generator
{
$files = $this->getLogFiles();
yield from $this->readFiles($files, $criteria);
}
/**
* @return \Generator<LogEntry>
*/
public function readForEnvironment(string $environment, ?SearchCriteria $criteria = null): \Generator
{
$files = $this->getLogFilesForEnvironment($environment);
yield from $this->readFiles($files, $criteria);
}
/**
* @return \Generator<LogEntry>
*/
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<LogEntry>
*/
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);
}
}

View File

@@ -0,0 +1,120 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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']);
}
}
}

View File

@@ -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":[]}

View File

@@ -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 [] []

View File

@@ -0,0 +1,136 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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);
}
}

View File

@@ -0,0 +1,130 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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);
}
}

View File

@@ -0,0 +1,65 @@
{
"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.46",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-strict-rules": "^2.0"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"Symfony\\AI\\Mate\\Bridge\\Monolog\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/",
"Symfony\\AI\\Mate\\Bridge\\Monolog\\Tests\\": "Tests/"
}
},
"config": {
"sort-packages": true
},
"extra": {
"branch-alias": {
"dev-main": "0.x-dev"
},
"thanks": {
"name": "symfony/ai",
"url": "https://github.com/symfony/ai"
},
"ai-mate": {
"scan-dirs": ["Capability"],
"includes": ["config/services.php"]
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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),
]);
};

View File

@@ -0,0 +1,22 @@
includes:
- ../../../../../.phpstan/extension.neon
parameters:
level: 6
paths:
- .
- Tests/
excludePaths:
- vendor/
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

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnDeprecation="true"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony AI Mate Monolog Bridge Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true">
<include>
<directory>./</directory>
</include>
<exclude>
<directory>./Resources</directory>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</source>
</phpunit>

View File

@@ -0,0 +1,3 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.git* export-ignore

View File

@@ -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!

View File

@@ -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!

View File

@@ -0,0 +1,4 @@
vendor/
composer.lock
phpunit.xml
.phpunit.result.cache

View File

@@ -0,0 +1,7 @@
CHANGELOG
=========
0.1
---
* Add bridge

View File

@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <tobias.nyholm@gmail.com>
*/
class ServiceTool
{
public function __construct(
private string $cacheDir,
private ContainerProvider $provider,
) {
}
/**
* @return array<string, class-string|null>
*/
#[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;
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
use Symfony\AI\Mate\Exception\InvalidArgumentException;
/**
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class FileNotFoundException extends InvalidArgumentException
{
public static function forContainerXml(string $path): self
{
return new self(\sprintf('Container XML at "%s" does not exist', $path));
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
use Symfony\AI\Mate\Exception\InvalidArgumentException;
/**
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class XmlContainerCouldNotBeLoadedException extends InvalidArgumentException
{
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));
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <tobias.nyholm@gmail.com>
*/
class XmlContainerPathIsNotConfiguredException extends XmlContainerCouldNotBeLoadedException
{
public static function emptyPath(): self
{
return new self('Failed to configure path to Symfony container. You passed an empty string');
}
}

View File

@@ -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.

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <tobias.nyholm@gmail.com>
*/
class Container
{
/**
* @param array<string, ServiceDefinition> $services
*/
public function __construct(
public array $services,
) {
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <tobias.nyholm@gmail.com>
*/
class ServiceDefinition
{
/**
* @param ?class-string $class
* @param ?string $alias if this has a value, it is the "real" definition's id
* @param string[] $calls
* @param ServiceTag[] $tags
* @param array{0: string|null, 1: string} $constructor
*/
public function __construct(
public string $id,
public ?string $class,
public ?string $alias,
public array $calls,
public array $tags,
public array $constructor,
) {
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <tobias.nyholm@gmail.com>
*/
class ServiceTag
{
/**
* @param array<string, string> $attributes
*/
public function __construct(
public string $name,
public array $attributes = [],
) {
}
}

View File

@@ -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)

View File

@@ -0,0 +1,158 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <tobias.nyholm@gmail.com>
*/
class ContainerProvider
{
/**
* @var array<string, Container>
*/
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<string, ServiceDefinition> $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<string, string> $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;
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <tobias.nyholm@gmail.com>
*/
final class ServiceToolTest extends TestCase
{
public function testGetAllServices()
{
$tool = new ServiceTool(
\dirname(__DIR__).'/Fixtures',
new ContainerProvider()
);
$output = $tool->getAllServices();
$this->assertCount(6, $output);
}
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="service.logger" class="Psr\Log\NullLogger">
<tag name="monolog.logger" channel="app"/>
</service>
<service id="service.router" class="Symfony\Component\Routing\Router">
<call method="setContext"/>
<tag name="routing.router"/>
</service>
<service id="service.event_dispatcher" class="Symfony\Component\EventDispatcher\EventDispatcher">
<tag name="event_dispatcher"/>
</service>
<service id="service.cache" class="Symfony\Component\Cache\Adapter\FilesystemAdapter">
<factory class="Symfony\Component\Cache\Adapter\FilesystemAdapter" method="create"/>
</service>
<service id="service.http_client" class="Symfony\Component\HttpClient\HttpClient"/>
<service id="logger.alias" alias="service.logger"/>
</services>
</container>

View File

@@ -0,0 +1,65 @@
{
"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.46",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-strict-rules": "^2.0"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"Symfony\\AI\\Mate\\Bridge\\Symfony\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/",
"Symfony\\AI\\Mate\\Bridge\\Symfony\\Tests\\": "Tests/"
}
},
"config": {
"sort-packages": true
},
"extra": {
"branch-alias": {
"dev-main": "0.x-dev"
},
"thanks": {
"name": "symfony/ai",
"url": "https://github.com/symfony/ai"
},
"ai-mate": {
"scan-dirs": ["Capability"],
"includes": ["config/services.php"]
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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),
]);
};

View File

@@ -0,0 +1,22 @@
includes:
- ../../../../../.phpstan/extension.neon
parameters:
level: 6
paths:
- .
- Tests/
excludePaths:
- vendor/
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

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnDeprecation="true"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony AI Mate Symfony Bridge Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true">
<include>
<directory>./</directory>
</include>
<exclude>
<directory>./Resources</directory>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</source>
</phpunit>

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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();
}
}

View File

@@ -0,0 +1,102 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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: <info>%s</info>', $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';
}
}

View File

@@ -0,0 +1,167 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 bridges installed via Composer.
*
* Scans for packages with extra.ai-mate configuration
* and generates/updates .mate/bridges.php with discovered bridges.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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 Bridge Discovery');
$io->text('Scanning for packages with <info>extra.ai-mate</info> configuration...');
$io->newLine();
$discovery = new ComposerTypeDiscovery($this->rootDir, $this->logger);
$bridges = $discovery->discover([]);
$count = \count($bridges);
if (0 === $count) {
$io->warning([
'No MCP bridges found.',
'Packages must have "extra.ai-mate" configuration in their composer.json.',
]);
$io->note('Run "composer require vendor/package" to install MCP bridges.');
return Command::SUCCESS;
}
$bridgesFile = $this->rootDir.'/.mate/bridges.php';
$existingBridges = [];
$newPackages = [];
$removedPackages = [];
if (file_exists($bridgesFile)) {
$existingBridges = include $bridgesFile;
if (!\is_array($existingBridges)) {
$existingBridges = [];
}
}
foreach ($bridges as $packageName => $data) {
if (!isset($existingBridges[$packageName])) {
$newPackages[] = $packageName;
}
}
foreach ($existingBridges as $packageName => $data) {
if (!isset($bridges[$packageName])) {
$removedPackages[] = $packageName;
}
}
$io->section(\sprintf('Discovered %d Bridge%s', $count, 1 === $count ? '' : 's'));
$rows = [];
foreach ($bridges as $packageName => $data) {
$isNew = \in_array($packageName, $newPackages, true);
$status = $isNew ? '<fg=green>NEW</>' : '<fg=gray>existing</>';
$dirCount = \count($data['dirs']);
$rows[] = [
$status,
$packageName,
\sprintf('%d director%s', $dirCount, 1 === $dirCount ? 'y' : 'ies'),
];
}
$io->table(['Status', 'Package', 'Scan Directories'], $rows);
$finalBridges = [];
foreach ($bridges as $packageName => $data) {
$enabled = true;
if (isset($existingBridges[$packageName]) && \is_array($existingBridges[$packageName])) {
$enabled = $existingBridges[$packageName]['enabled'] ?? true;
if (!\is_bool($enabled)) {
$enabled = true;
}
}
$finalBridges[$packageName] = [
'enabled' => $enabled,
];
}
$this->writeBridgesFile($bridgesFile, $finalBridges);
$io->success(\sprintf('Configuration written to: %s', $bridgesFile));
if (\count($newPackages) > 0) {
$io->note(\sprintf('Added %d new bridge%s. All bridges are enabled by default.', \count($newPackages), 1 === \count($newPackages) ? '' : 's'));
}
if (\count($removedPackages) > 0) {
$io->warning([
\sprintf('Removed %d bridge%s no longer found:', \count($removedPackages), 1 === \count($removedPackages) ? '' : 's'),
...array_map(fn ($pkg) => ' • '.$pkg, $removedPackages),
]);
}
$io->comment([
'Next steps:',
' • Edit .mate/bridges.php to enable/disable specific bridges',
' • Run "vendor/bin/mate serve" to start the MCP server',
]);
return Command::SUCCESS;
}
/**
* @param array<string, array{enabled: bool}> $bridges
*/
private function writeBridgesFile(string $filePath, array $bridges): void
{
$dir = \dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$content = "<?php\n\n";
$content .= "// This file is managed by 'mate discover'\n";
$content .= "// You can manually edit to enable/disable bridges\n\n";
$content .= "return [\n";
foreach ($bridges as $packageName => $config) {
$enabled = $config['enabled'] ? 'true' : 'false';
$content .= " '$packageName' => ['enabled' => $enabled],\n";
}
$content .= "];\n";
file_put_contents($filePath, $content);
}
}

View File

@@ -0,0 +1,186 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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/bridges.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('<question>%s already exists. Overwrite?</question>', $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('<question>%s already exists. Replace with symlink?</question>', $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 bridges)'];
} 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 bridges',
' 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<array{string, string, string}>
*/
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;
}
}

View File

@@ -0,0 +1,114 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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);
$bridges = $this->getBridgesToLoad();
(new ServiceDiscovery())->registerServices($discovery, $this->container, $rootDir, $bridges);
$disabledVendorFeatures = $this->container->getParameter('mate.disabled_features') ?? [];
\assert(\is_array($disabledVendorFeatures));
/* @var array<string, array<string, array{enabled: bool}>> $disabledVendorFeatures */
$this->container->compile();
$loader = new FilteredDiscoveryLoader(
basePath: $rootDir,
bridges: $bridges,
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<string, array{dirs: string[], includes: string[]}>
*/
private function getBridgesToLoad(): array
{
$rootDir = $this->container->getParameter('mate.root_dir');
\assert(\is_string($rootDir));
$packageNames = $this->container->getParameter('mate.enabled_bridges');
\assert(\is_array($packageNames));
/** @var array<int, string> $packageNames */
/** @var array<string, array{dirs: array<string>, includes: array<string>}> $bridges */
$bridges = [];
foreach ($this->discovery->discover($packageNames) as $packageName => $data) {
$bridges[$packageName] = $data;
}
$bridges['_custom'] = $this->discovery->discoverRootProject();
return $bridges;
}
}

View File

@@ -0,0 +1,164 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 bridge configurations.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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');
$enabledBridges = $this->getEnabledBridges();
$container->setParameter('mate.enabled_bridges', $enabledBridges);
$container->setParameter('mate.root_dir', $this->rootDir);
$logger = $container->get(LoggerInterface::class);
\assert($logger instanceof LoggerInterface);
$discovery = new ComposerTypeDiscovery($this->rootDir, $logger);
if ([] !== $enabledBridges) {
foreach ($discovery->discover($enabledBridges) as $packageName => $data) {
$this->loadBridgeIncludes($container, $logger, $packageName, $data['includes']);
}
}
$rootProject = $discovery->discoverRootProject();
$this->loadUserServices($rootProject, $container);
$this->loadUserEnvVar($container);
return $container;
}
/**
* @return string[] Package names
*/
private function getEnabledBridges(): array
{
$bridgesFile = $this->rootDir.'/.mate/bridges.php';
if (!file_exists($bridgesFile)) {
return [];
}
$bridgesConfig = include $bridgesFile;
if (!\is_array($bridgesConfig)) {
return [];
}
$enabledBridges = [];
foreach ($bridgesConfig as $packageName => $config) {
if (\is_string($packageName) && \is_array($config) && ($config['enabled'] ?? false)) {
$enabledBridges[] = $packageName;
}
}
return $enabledBridges;
}
/**
* @param string[] $includeFiles
*/
private function loadBridgeIncludes(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 bridge include', [
'package' => $packageName,
'file' => $includeFile,
]);
} catch (\Throwable $e) {
$logger->warning('Failed to load bridge 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<string>, includes: array<string>} $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(),
]);
}
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class MateHelper
{
/**
* Disable specific MCP features from one or more bridges.
*
* This function allows you to disable specific tools, resources, prompts, or
* resource templates from MCP bridges 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/bridge' => ['badTool', 'semiBadTool']
* 'nyholm/example' => ['clock']
* ]);
*
* $container->parameters()
* ->set('mate.cache_dir', sys_get_temp_dir().'/mate')
* // ...
* }
* ```
*
* @param array<string, list<string>> $bridges
*/
public static function disableFeatures(ContainerConfigurator $container, array $bridges): void
{
$data = [];
foreach ($bridges as $bridge => $features) {
foreach ($features as $feature) {
$data[$bridge][$feature] = ['enabled' => false];
}
}
$container->parameters()->set('mate.disabled_features', $data);
}
}

View File

@@ -0,0 +1,312 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 bridges via extra.ai-mate config in composer.json.
*
* Bridges must declare themselves in composer.json:
* {
* "extra": {
* "ai-mate": {
* "scan-dirs": ["src"],
* "includes": ["config/services.php"]
* }
* }
* }
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ComposerTypeDiscovery
{
/**
* @var array<string, array{
* name: string,
* extra: array<string, mixed>,
* }>|null
*/
private ?array $installedPackages = null;
public function __construct(
private string $rootDir,
private LoggerInterface $logger,
) {
}
/**
* @param string[] $enabledBridges
*
* @return array<string, array{dirs: string[], includes: string[]}>
*/
public function discover(array $enabledBridges = []): array
{
$installed = $this->getInstalledPackages();
$bridges = [];
foreach ($installed as $package) {
$packageName = $package['name'];
$aiMateConfig = $package['extra']['ai-mate'] ?? null;
if (!\is_array($aiMateConfig)) {
continue;
}
if ([] !== $enabledBridges && !\in_array($packageName, $enabledBridges, true)) {
$this->logger->debug('Skipping package not enabled', ['package' => $packageName]);
continue;
}
$scanDirs = $this->extractScanDirs($package, $packageName);
$includeFiles = $this->extractIncludeFiles($package, $packageName);
if ([] !== $scanDirs || [] !== $includeFiles) {
$bridges[$packageName] = [
'dirs' => $scanDirs,
'includes' => $includeFiles,
];
}
}
return $bridges;
}
/**
* @return array{dirs: array<string>, includes: array<string>}
*/
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<string, array{
* name: string,
* extra: array<string, mixed>,
* }>
*/
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<string, mixed>,
* } $validPackage */
$validPackage = [
'name' => $package['name'],
'extra' => [],
];
if (isset($package['extra']) && \is_array($package['extra'])) {
/** @var array<string, mixed> $extra */
$extra = $package['extra'];
$validPackage['extra'] = $extra;
}
$indexed[$package['name']] = $validPackage;
}
return $this->installedPackages = $indexed;
}
/**
* @param array{
* name: string,
* extra: array<string, mixed>,
* } $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<string, mixed>,
* } $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;
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class FilteredDiscoveryLoader implements LoaderInterface
{
/**
* @param array<string, array{dirs: string[],includes: string[]}> $bridges
* @param array<string, array<string, array{enabled: bool}>> $disabledFeatures
*/
public function __construct(
private string $basePath,
private array $bridges,
private array $disabledFeatures,
private Discoverer $discoverer,
private LoggerInterface $logger,
) {
}
public function load(RegistryInterface $registry): void
{
$allTools = [];
$allResources = [];
$allPrompts = [];
$allResourceTemplates = [];
foreach ($this->bridges 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;
}
}

View File

@@ -0,0 +1,100 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ServiceDiscovery
{
/**
* Pre-register all discovered services in the container.
* Call this BEFORE container->compile().
*
* @param array<string, array{dirs: string[], includes: string[]}> $bridges
*/
public function registerServices(Discoverer $discoverer, ContainerBuilder $container, string $basePath, array $bridges): void
{
foreach ($bridges 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);
}
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Exception;
/**
* Base exception class for invalid argument errors in the Mate component.
*
* @internal
*
* @author Johannes Wachter <johannes@sulu.io>
*/
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class MissingDependencyException extends RuntimeException
{
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.');
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Exception;
/**
* Base exception class for runtime errors in the Mate component.
*
* @internal
*
* @author Johannes Wachter <johannes@sulu.io>
*/
class RuntimeException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class UnsupportedVersionException extends RuntimeException
{
public static function forConsole(): self
{
return new self('Unsupported version of symfony/console. We cannot add commands.');
}
}

View File

@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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);
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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)
;
};

View File

@@ -0,0 +1,196 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class DiscoverCommandTest extends TestCase
{
private string $fixturesDir;
protected function setUp(): void
{
$this->fixturesDir = __DIR__.'/../Discovery/Fixtures';
}
public function testDiscoversBridgesAndCreatesFile()
{
$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/bridges.php');
$bridges = include $tempDir.'/.mate/bridges.php';
$this->assertIsArray($bridges);
$this->assertArrayHasKey('vendor/package-a', $bridges);
$this->assertArrayHasKey('vendor/package-b', $bridges);
$this->assertIsArray($bridges['vendor/package-a']);
$this->assertIsArray($bridges['vendor/package-b']);
$this->assertTrue($bridges['vendor/package-a']['enabled']);
$this->assertTrue($bridges['vendor/package-b']['enabled']);
$output = $tester->getDisplay();
$this->assertStringContainsString('Discovered 2 Bridge', $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 bridges.php with package-a disabled
file_put_contents($tempDir.'/.mate/bridges.php', <<<'PHP'
<?php
return [
'vendor/package-a' => ['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([]);
$bridges = include $tempDir.'/.mate/bridges.php';
$this->assertIsArray($bridges);
$this->assertIsArray($bridges['vendor/package-a']);
$this->assertIsArray($bridges['vendor/package-b']);
$this->assertFalse($bridges['vendor/package-a']['enabled'], 'Should preserve disabled state');
$this->assertTrue($bridges['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 bridges.php with only package-a
file_put_contents($tempDir.'/.mate/bridges.php', <<<'PHP'
<?php
return [
'vendor/package-a' => ['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([]);
$bridges = include $tempDir.'/.mate/bridges.php';
$this->assertIsArray($bridges);
$this->assertIsArray($bridges['vendor/package-a']);
$this->assertIsArray($bridges['vendor/package-b']);
$this->assertFalse($bridges['vendor/package-a']['enabled'], 'Existing disabled state preserved');
$this->assertTrue($bridges['vendor/package-b']['enabled'], 'New package defaults to enabled');
} finally {
$this->removeDirectory($tempDir);
}
}
public function testDisplaysWarningWhenNoBridgesFound()
{
$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 bridges 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);
}
}

View File

@@ -0,0 +1,151 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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/bridges.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/bridges.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('bridges.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/bridges.php', '<?php return ["test" => "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/bridges.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/bridges.php', '<?php return ["test" => "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/bridges.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/bridges.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);
}
}

View File

@@ -0,0 +1,153 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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()
);
$bridges = $discovery->discover();
$this->assertCount(2, $bridges);
$this->assertArrayHasKey('vendor/package-a', $bridges);
$this->assertArrayHasKey('vendor/package-b', $bridges);
// Check package-a structure
$this->assertArrayHasKey('dirs', $bridges['vendor/package-a']);
$this->assertArrayHasKey('includes', $bridges['vendor/package-a']);
$this->assertContains('vendor/vendor/package-a/src', $bridges['vendor/package-a']['dirs']);
}
public function testIgnoresPackagesWithoutAiMateConfig()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/without-ai-mate-config',
new NullLogger()
);
$bridges = $discovery->discover();
$this->assertCount(0, $bridges);
}
public function testIgnoresPackagesWithoutExtraSection()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/no-extra-section',
new NullLogger()
);
$bridges = $discovery->discover();
$this->assertCount(0, $bridges);
}
public function testWhitelistFiltering()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/with-ai-mate-config',
new NullLogger()
);
$enabledBridges = [
'vendor/package-a',
];
$bridges = $discovery->discover($enabledBridges);
$this->assertCount(1, $bridges);
$this->assertArrayHasKey('vendor/package-a', $bridges);
$this->assertArrayNotHasKey('vendor/package-b', $bridges);
}
public function testWhitelistWithMultiplePackages()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/with-ai-mate-config',
new NullLogger()
);
$enabledBridges = [
'vendor/package-a',
'vendor/package-b',
];
$bridges = $discovery->discover($enabledBridges);
$this->assertCount(2, $bridges);
$this->assertArrayHasKey('vendor/package-a', $bridges);
$this->assertArrayHasKey('vendor/package-b', $bridges);
}
public function testExtractsIncludeFiles()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/with-includes',
new NullLogger()
);
$bridges = $discovery->discover();
$this->assertCount(1, $bridges);
$this->assertArrayHasKey('vendor/package-with-includes', $bridges);
$includes = $bridges['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()
);
$bridges = $discovery->discover();
$this->assertCount(0, $bridges);
}
public function testHandlesPackagesWithoutType()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/mixed-types',
new NullLogger()
);
$bridges = $discovery->discover();
// Should discover packages with ai-mate config regardless of type field
$this->assertGreaterThanOrEqual(1, $bridges);
}
}

View File

@@ -0,0 +1,12 @@
{
"packages": [
{
"name": "vendor/package-mixed",
"extra": {
"ai-mate": {
"scan-dirs": ["src"]
}
}
}
]
}

View File

@@ -0,0 +1,8 @@
{
"packages": [
{
"name": "vendor/no-extra",
"type": "library"
}
]
}

View File

@@ -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"]
}
}
}
]
}

View File

@@ -0,0 +1,14 @@
{
"packages": [
{
"name": "vendor/package-with-includes",
"type": "library",
"extra": {
"ai-mate": {
"scan-dirs": ["src"],
"includes": ["config/services.php"]
}
}
}
]
}

View File

@@ -0,0 +1,5 @@
<?php
// Dummy services file for testing
return static function (): void {
};

View File

@@ -0,0 +1,8 @@
{
"packages": [
{
"name": "vendor/regular-package",
"type": "library"
}
]
}

View File

@@ -0,0 +1,12 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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';