mirror of
https://github.com/symfony/ai.git
synced 2026-03-24 07:52:13 +01:00
Add AI Mate component for MCP server integration
This commit is contained in:
committed by
Christopher Hertel
parent
c5fe55c808
commit
c151ab8388
18
.github/workflows/validation.yaml
vendored
18
.github/workflows/validation.yaml
vendored
@@ -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
|
||||
|
||||
@@ -6,5 +6,6 @@ Components
|
||||
|
||||
agent
|
||||
chat
|
||||
mate
|
||||
platform
|
||||
store
|
||||
|
||||
311
docs/components/mate.rst
Normal file
311
docs/components/mate.rst
Normal 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
|
||||
269
docs/components/mate/creating-extensions.rst
Normal file
269
docs/components/mate/creating-extensions.rst
Normal 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.
|
||||
134
docs/components/mate/integration.rst
Normal file
134
docs/components/mate/integration.rst
Normal 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
|
||||
167
docs/components/mate/troubleshooting.rst
Normal file
167
docs/components/mate/troubleshooting.rst
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
5
src/mate/.gitignore
vendored
Normal 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
7
src/mate/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.1
|
||||
---
|
||||
|
||||
* Add component
|
||||
86
src/mate/CLAUDE.md
Normal file
86
src/mate/CLAUDE.md
Normal 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
19
src/mate/LICENSE
Normal 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
21
src/mate/README.md
Normal 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
4
src/mate/bin/mate
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
include __DIR__ . '/mate.php';
|
||||
41
src/mate/bin/mate.php
Normal file
41
src/mate/bin/mate.php
Normal 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
79
src/mate/composer.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/mate/phpstan.neon.dist
Normal file
12
src/mate/phpstan.neon.dist
Normal 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
22
src/mate/phpunit.xml.dist
Normal 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
1
src/mate/resources/.mate/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.local
|
||||
10
src/mate/resources/.mate/bridges.php
Normal file
10
src/mate/resources/.mate/bridges.php
Normal 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],
|
||||
];
|
||||
18
src/mate/resources/.mate/services.php
Normal file
18
src/mate/resources/.mate/services.php
Normal 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
|
||||
;
|
||||
};
|
||||
10
src/mate/resources/mcp.json
Normal file
10
src/mate/resources/mcp.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"symfony-ai-mate": {
|
||||
"command": "./vendor/bin/mate",
|
||||
"args": [
|
||||
"serve"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/mate/src/App.php
Normal file
65
src/mate/src/App.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/mate/src/Bridge/Monolog/.gitattributes
vendored
Normal file
3
src/mate/src/Bridge/Monolog/.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/Tests export-ignore
|
||||
/phpunit.xml.dist export-ignore
|
||||
/.git* export-ignore
|
||||
8
src/mate/src/Bridge/Monolog/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
8
src/mate/src/Bridge/Monolog/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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!
|
||||
20
src/mate/src/Bridge/Monolog/.github/workflows/close-pull-request.yml
vendored
Normal file
20
src/mate/src/Bridge/Monolog/.github/workflows/close-pull-request.yml
vendored
Normal 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!
|
||||
4
src/mate/src/Bridge/Monolog/.gitignore
vendored
Normal file
4
src/mate/src/Bridge/Monolog/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
vendor/
|
||||
composer.lock
|
||||
phpunit.xml
|
||||
.phpunit.result.cache
|
||||
7
src/mate/src/Bridge/Monolog/CHANGELOG.md
Normal file
7
src/mate/src/Bridge/Monolog/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.1
|
||||
---
|
||||
|
||||
* Add bridge
|
||||
199
src/mate/src/Bridge/Monolog/Capability/LogSearchTool.php
Normal file
199
src/mate/src/Bridge/Monolog/Capability/LogSearchTool.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
19
src/mate/src/Bridge/Monolog/LICENSE
Normal file
19
src/mate/src/Bridge/Monolog/LICENSE
Normal 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.
|
||||
96
src/mate/src/Bridge/Monolog/Model/LogEntry.php
Normal file
96
src/mate/src/Bridge/Monolog/Model/LogEntry.php
Normal 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));
|
||||
}
|
||||
}
|
||||
69
src/mate/src/Bridge/Monolog/Model/SearchCriteria.php
Normal file
69
src/mate/src/Bridge/Monolog/Model/SearchCriteria.php
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/mate/src/Bridge/Monolog/README.md
Normal file
12
src/mate/src/Bridge/Monolog/README.md
Normal 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)
|
||||
324
src/mate/src/Bridge/Monolog/Service/LogParser.php
Normal file
324
src/mate/src/Bridge/Monolog/Service/LogParser.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
223
src/mate/src/Bridge/Monolog/Service/LogReader.php
Normal file
223
src/mate/src/Bridge/Monolog/Service/LogReader.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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":[]}
|
||||
6
src/mate/src/Bridge/Monolog/Tests/Fixtures/sample.log
Normal file
6
src/mate/src/Bridge/Monolog/Tests/Fixtures/sample.log
Normal 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 [] []
|
||||
136
src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php
Normal file
136
src/mate/src/Bridge/Monolog/Tests/Service/LogParserTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
130
src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php
Normal file
130
src/mate/src/Bridge/Monolog/Tests/Service/LogReaderTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
65
src/mate/src/Bridge/Monolog/composer.json
Normal file
65
src/mate/src/Bridge/Monolog/composer.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/mate/src/Bridge/Monolog/config/services.php
Normal file
36
src/mate/src/Bridge/Monolog/config/services.php
Normal 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),
|
||||
]);
|
||||
};
|
||||
22
src/mate/src/Bridge/Monolog/phpstan.dist.neon
Normal file
22
src/mate/src/Bridge/Monolog/phpstan.dist.neon
Normal 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
|
||||
32
src/mate/src/Bridge/Monolog/phpunit.xml.dist
Normal file
32
src/mate/src/Bridge/Monolog/phpunit.xml.dist
Normal 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>
|
||||
3
src/mate/src/Bridge/Symfony/.gitattributes
vendored
Normal file
3
src/mate/src/Bridge/Symfony/.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/Tests export-ignore
|
||||
/phpunit.xml.dist export-ignore
|
||||
/.git* export-ignore
|
||||
8
src/mate/src/Bridge/Symfony/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
8
src/mate/src/Bridge/Symfony/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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!
|
||||
20
src/mate/src/Bridge/Symfony/.github/workflows/close-pull-request.yml
vendored
Normal file
20
src/mate/src/Bridge/Symfony/.github/workflows/close-pull-request.yml
vendored
Normal 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!
|
||||
4
src/mate/src/Bridge/Symfony/.gitignore
vendored
Normal file
4
src/mate/src/Bridge/Symfony/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
vendor/
|
||||
composer.lock
|
||||
phpunit.xml
|
||||
.phpunit.result.cache
|
||||
7
src/mate/src/Bridge/Symfony/CHANGELOG.md
Normal file
7
src/mate/src/Bridge/Symfony/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.1
|
||||
---
|
||||
|
||||
* Add bridge
|
||||
60
src/mate/src/Bridge/Symfony/Capability/ServiceTool.php
Normal file
60
src/mate/src/Bridge/Symfony/Capability/ServiceTool.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
19
src/mate/src/Bridge/Symfony/LICENSE
Normal file
19
src/mate/src/Bridge/Symfony/LICENSE
Normal 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.
|
||||
28
src/mate/src/Bridge/Symfony/Model/Container.php
Normal file
28
src/mate/src/Bridge/Symfony/Model/Container.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
37
src/mate/src/Bridge/Symfony/Model/ServiceDefinition.php
Normal file
37
src/mate/src/Bridge/Symfony/Model/ServiceDefinition.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
29
src/mate/src/Bridge/Symfony/Model/ServiceTag.php
Normal file
29
src/mate/src/Bridge/Symfony/Model/ServiceTag.php
Normal 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 = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
12
src/mate/src/Bridge/Symfony/README.md
Normal file
12
src/mate/src/Bridge/Symfony/README.md
Normal 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)
|
||||
158
src/mate/src/Bridge/Symfony/Service/ContainerProvider.php
Normal file
158
src/mate/src/Bridge/Symfony/Service/ContainerProvider.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
65
src/mate/src/Bridge/Symfony/composer.json
Normal file
65
src/mate/src/Bridge/Symfony/composer.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/mate/src/Bridge/Symfony/config/services.php
Normal file
30
src/mate/src/Bridge/Symfony/config/services.php
Normal 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),
|
||||
]);
|
||||
};
|
||||
22
src/mate/src/Bridge/Symfony/phpstan.dist.neon
Normal file
22
src/mate/src/Bridge/Symfony/phpstan.dist.neon
Normal 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
|
||||
32
src/mate/src/Bridge/Symfony/phpunit.xml.dist
Normal file
32
src/mate/src/Bridge/Symfony/phpunit.xml.dist
Normal 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>
|
||||
48
src/mate/src/Capability/ServerInfo.php
Normal file
48
src/mate/src/Capability/ServerInfo.php
Normal 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();
|
||||
}
|
||||
}
|
||||
102
src/mate/src/Command/ClearCacheCommand.php
Normal file
102
src/mate/src/Command/ClearCacheCommand.php
Normal 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';
|
||||
}
|
||||
}
|
||||
167
src/mate/src/Command/DiscoverCommand.php
Normal file
167
src/mate/src/Command/DiscoverCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
186
src/mate/src/Command/InitCommand.php
Normal file
186
src/mate/src/Command/InitCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
114
src/mate/src/Command/ServeCommand.php
Normal file
114
src/mate/src/Command/ServeCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
164
src/mate/src/Container/ContainerFactory.php
Normal file
164
src/mate/src/Container/ContainerFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/mate/src/Container/MateHelper.php
Normal file
64
src/mate/src/Container/MateHelper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
312
src/mate/src/Discovery/ComposerTypeDiscovery.php
Normal file
312
src/mate/src/Discovery/ComposerTypeDiscovery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
123
src/mate/src/Discovery/FilteredDiscoveryLoader.php
Normal file
123
src/mate/src/Discovery/FilteredDiscoveryLoader.php
Normal 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;
|
||||
}
|
||||
}
|
||||
100
src/mate/src/Discovery/ServiceDiscovery.php
Normal file
100
src/mate/src/Discovery/ServiceDiscovery.php
Normal 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);
|
||||
}
|
||||
}
|
||||
22
src/mate/src/Exception/ExceptionInterface.php
Normal file
22
src/mate/src/Exception/ExceptionInterface.php
Normal 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
|
||||
{
|
||||
}
|
||||
23
src/mate/src/Exception/InvalidArgumentException.php
Normal file
23
src/mate/src/Exception/InvalidArgumentException.php
Normal 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
|
||||
{
|
||||
}
|
||||
26
src/mate/src/Exception/MissingDependencyException.php
Normal file
26
src/mate/src/Exception/MissingDependencyException.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
23
src/mate/src/Exception/RuntimeException.php
Normal file
23
src/mate/src/Exception/RuntimeException.php
Normal 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
|
||||
{
|
||||
}
|
||||
26
src/mate/src/Exception/UnsupportedVersionException.php
Normal file
26
src/mate/src/Exception/UnsupportedVersionException.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
49
src/mate/src/Service/Logger.php
Normal file
49
src/mate/src/Service/Logger.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/mate/src/default.services.php
Normal file
32
src/mate/src/default.services.php
Normal 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)
|
||||
;
|
||||
};
|
||||
196
src/mate/tests/Command/DiscoverCommandTest.php
Normal file
196
src/mate/tests/Command/DiscoverCommandTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
151
src/mate/tests/Command/InitCommandTest.php
Normal file
151
src/mate/tests/Command/InitCommandTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
153
src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php
Normal file
153
src/mate/tests/Discovery/ComposerTypeDiscoveryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json
vendored
Normal file
12
src/mate/tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"name": "vendor/package-mixed",
|
||||
"extra": {
|
||||
"ai-mate": {
|
||||
"scan-dirs": ["src"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
0
src/mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep
vendored
Normal file
0
src/mate/tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep
vendored
Normal file
8
src/mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json
vendored
Normal file
8
src/mate/tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"name": "vendor/no-extra",
|
||||
"type": "library"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json
vendored
Normal file
22
src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json
vendored
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
0
src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep
vendored
Normal file
0
src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep
vendored
Normal file
0
src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep
vendored
Normal file
0
src/mate/tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep
vendored
Normal file
14
src/mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json
vendored
Normal file
14
src/mate/tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"name": "vendor/package-with-includes",
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"ai-mate": {
|
||||
"scan-dirs": ["src"],
|
||||
"includes": ["config/services.php"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
// Dummy services file for testing
|
||||
return static function (): void {
|
||||
};
|
||||
8
src/mate/tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json
vendored
Normal file
8
src/mate/tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"name": "vendor/regular-package",
|
||||
"type": "library"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
src/mate/tests/bootstrap.php
Normal file
12
src/mate/tests/bootstrap.php
Normal 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';
|
||||
Reference in New Issue
Block a user