mirror of
https://github.com/symfony/ai-mate.git
synced 2026-03-24 00:02:10 +01:00
Add AI Mate component for MCP server integration
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
vendor
|
||||
|
||||
# Allow test fixture vendor directories
|
||||
!tests/Discovery/Fixtures/**/vendor
|
||||
!tests/Discovery/Fixtures/**/vendor/**
|
||||
7
CHANGELOG.md
Normal file
7
CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.1
|
||||
---
|
||||
|
||||
* Add component
|
||||
86
CLAUDE.md
Normal file
86
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
LICENSE
Normal file
19
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
README.md
Normal file
21
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
bin/mate
Executable file
4
bin/mate
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
include __DIR__ . '/mate.php';
|
||||
41
bin/mate.php
Normal file
41
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
composer.json
Normal file
79
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
phpstan.neon.dist
Normal file
12
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
phpunit.xml.dist
Normal file
22
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
resources/.mate/.gitignore
vendored
Normal file
1
resources/.mate/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.local
|
||||
10
resources/.mate/bridges.php
Normal file
10
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
resources/.mate/services.php
Normal file
18
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
resources/mcp.json
Normal file
10
resources/mcp.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"symfony-ai-mate": {
|
||||
"command": "./vendor/bin/mate",
|
||||
"args": [
|
||||
"serve"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/App.php
Normal file
65
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/Capability/ServerInfo.php
Normal file
48
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/Command/ClearCacheCommand.php
Normal file
102
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/Command/DiscoverCommand.php
Normal file
167
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/Command/InitCommand.php
Normal file
186
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/Command/ServeCommand.php
Normal file
114
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/Container/ContainerFactory.php
Normal file
164
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/Container/MateHelper.php
Normal file
64
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/Discovery/ComposerTypeDiscovery.php
Normal file
312
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/Discovery/FilteredDiscoveryLoader.php
Normal file
123
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/Discovery/ServiceDiscovery.php
Normal file
100
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/Exception/ExceptionInterface.php
Normal file
22
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/Exception/InvalidArgumentException.php
Normal file
23
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/Exception/MissingDependencyException.php
Normal file
26
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/Exception/RuntimeException.php
Normal file
23
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/Exception/UnsupportedVersionException.php
Normal file
26
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/Service/Logger.php
Normal file
49
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/default.services.php
Normal file
32
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
tests/Command/DiscoverCommandTest.php
Normal file
196
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
tests/Command/InitCommandTest.php
Normal file
151
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
tests/Discovery/ComposerTypeDiscoveryTest.php
Normal file
153
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
tests/Discovery/Fixtures/mixed-types/vendor/composer/installed.json
vendored
Normal file
12
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
tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep
vendored
Normal file
0
tests/Discovery/Fixtures/mixed-types/vendor/vendor/package-mixed/src/.gitkeep
vendored
Normal file
8
tests/Discovery/Fixtures/no-extra-section/vendor/composer/installed.json
vendored
Normal file
8
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
tests/Discovery/Fixtures/with-ai-mate-config/vendor/composer/installed.json
vendored
Normal file
22
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
tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep
vendored
Normal file
0
tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-a/src/.gitkeep
vendored
Normal file
0
tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep
vendored
Normal file
0
tests/Discovery/Fixtures/with-ai-mate-config/vendor/vendor/package-b/src/.gitkeep
vendored
Normal file
14
tests/Discovery/Fixtures/with-includes/vendor/composer/installed.json
vendored
Normal file
14
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 {
|
||||
};
|
||||
0
tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/src/.gitkeep
vendored
Normal file
0
tests/Discovery/Fixtures/with-includes/vendor/vendor/package-with-includes/src/.gitkeep
vendored
Normal file
8
tests/Discovery/Fixtures/without-ai-mate-config/vendor/composer/installed.json
vendored
Normal file
8
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
tests/bootstrap.php
Normal file
12
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