Add AI Mate component for MCP server integration

This commit is contained in:
Johannes Wachter
2025-12-16 20:05:06 +01:00
committed by Christopher Hertel
commit 78b4dbf803
46 changed files with 2562 additions and 0 deletions

5
.gitignore vendored Normal file
View File

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

7
CHANGELOG.md Normal file
View File

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

86
CLAUDE.md Normal file
View File

@@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Mate Component Overview
This is the Mate component of the Symfony AI monorepo - an MCP (Model Context Protocol) server that enables AI assistants to interact with Symfony applications. The component is standalone and does not integrate with the AI Bundle.
## Development Commands
### Testing
```bash
# Run all tests
vendor/bin/phpunit
# Run specific test
vendor/bin/phpunit tests/Command/InitCommandTest.php
# Run bridge tests
vendor/bin/phpunit src/Bridge/Symfony/Tests/
vendor/bin/phpunit src/Bridge/Monolog/Tests/
```
### Code Quality
```bash
# Run PHPStan static analysis
vendor/bin/phpstan analyse
# Fix code style (run from monorepo root)
cd ../../.. && vendor/bin/php-cs-fixer fix src/mate/
```
### Running the Server
```bash
# Initialize configuration
bin/mate init
# Discover bridges
bin/mate discover
# Start MCP server
bin/mate serve
# Clear cache
bin/mate clear-cache
```
## Architecture
### Core Classes
- **App**: Console application builder
- **ContainerFactory**: DI container management with bridge discovery
- **ComposerTypeDiscovery**: Discovers MCP bridges via `extra.ai-mate` in composer.json
- **FilteredDiscoveryLoader**: Loads MCP capabilities with feature filtering
- **ServiceDiscovery**: Registers discovered services in the DI container
### Key Directories
- `src/Command/`: CLI commands (serve, init, discover, clear-cache)
- `src/Container/`: DI container management
- `src/Discovery/`: Bridge discovery system
- `src/Capability/`: Built-in MCP tools
- `src/Bridge/`: Embedded bridge packages (Symfony, Monolog)
### Bridges
The component includes embedded bridge packages:
**Symfony Bridge** (`src/Bridge/Symfony/`):
- `ServiceTool`: Symfony container introspection
- `ContainerProvider`: Parses compiled container XML
**Monolog Bridge** (`src/Bridge/Monolog/`):
- `LogSearchTool`: Log search and analysis
- `LogParser`: Parses JSON and standard Monolog formats
- `LogReader`: Reads and filters log files
### Configuration
- `.mate/bridges.php`: Enable/disable bridges
- `.mate/services.php`: Custom service configuration
- `mate/`: Directory for user-defined MCP tools
## Testing Architecture
- Uses PHPUnit 11+ with strict configuration
- Bridge tests are located within their respective bridge directories
- Fixtures for discovery tests in `tests/Discovery/Fixtures/`
- Component follows Symfony coding standards

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2025-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# Symfony AI - Mate Component
The Mate component provides an MCP (Model Context Protocol) server that enables AI
assistants to interact with PHP applications (including Symfony) through standardized
tools. This is a development tool, not intended for production use.
## Installation
```bash
composer require symfony/ai-mate
```
**This repository is a READ-ONLY sub-tree split**. See
https://github.com/symfony/ai to create issues or submit pull requests.
## Resources
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/ai/issues) and
[send Pull Requests](https://github.com/symfony/ai/pulls)
in the [main Symfony AI repository](https://github.com/symfony/ai)

4
bin/mate Executable file
View File

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

41
bin/mate.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
$autoloadPaths = [
getcwd().'/vendor/autoload.php', // Project autoloader using current-working-directory (preferred)
__DIR__.'/../../../autoload.php', // Project autoloader
__DIR__.'/../vendor/autoload.php', // Package autoloader (fallback)
];
$root = null;
foreach ($autoloadPaths as $autoloadPath) {
if (file_exists($autoloadPath)) {
require_once $autoloadPath;
$root = dirname(realpath($autoloadPath), 2);
break;
}
}
if (!$root) {
echo 'Unable to locate the Composer vendor directory. Did you run composer install?'.\PHP_EOL;
exit(1);
}
// Set the root directory as an environment variable using $_ENV to be thread-safe
$_ENV['MATE_ROOT_DIR'] = $root;
use Symfony\AI\Mate\App;
use Symfony\AI\Mate\Container\ContainerFactory;
$containerFactory = new ContainerFactory($root);
$container = $containerFactory->create();
App::build($container)->run();

79
composer.json Normal file
View File

@@ -0,0 +1,79 @@
{
"name": "symfony/ai-mate",
"description": "AI development assistant MCP server for Symfony projects",
"license": "MIT",
"type": "library",
"keywords": [
"ai",
"mcp",
"model-context-protocol",
"symfony",
"debug",
"development"
],
"authors": [
{
"name": "Johannes Wachter",
"email": "johannes@sulu.io"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"bin": [
"bin/mate"
],
"require": {
"php": ">=8.2",
"mcp/sdk": "^0.1",
"psr/log": "^2.0|^3.0",
"symfony/config": "^7.3|^8.0",
"symfony/console": "^7.3|^8.0",
"symfony/dependency-injection": "^7.3|^8.0",
"symfony/finder": "^7.3|^8.0"
},
"require-dev": {
"ext-simplexml": "*",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.46",
"symfony/dotenv": "^7.3|^8.0"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"Symfony\\AI\\Mate\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\AI\\PHPStan\\": "../../.phpstan/",
"Symfony\\AI\\Mate\\Tests\\": "tests/"
}
},
"config": {
"allow-plugins": {
"php-http/discovery": true
},
"sort-packages": true
},
"extra": {
"branch-alias": {
"dev-main": "0.x-dev"
},
"thanks": {
"name": "symfony/ai",
"url": "https://github.com/symfony/ai"
},
"ai-mate": {
"scan-dirs": [
"src/Capability"
]
}
}
}

12
phpstan.neon.dist Normal file
View File

@@ -0,0 +1,12 @@
includes:
- ../../.phpstan/extension.neon
parameters:
level: 6
paths:
- src/
- tests/
treatPhpDocTypesAsCertain: false
ignoreErrors:
-
message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#"

22
phpunit.xml.dist Normal file
View File

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

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

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

View File

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

View File

@@ -0,0 +1,18 @@
<?php
// User's service configuration file
// This file is loaded into the Symfony DI container
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $container): void {
$container->parameters()
// Override default parameters here
// ->set('mate.cache_dir', sys_get_temp_dir().'/mate')
// ->set('mate.env_file', ['.env']) // This will load .mate/.env and .mate/.env.local
;
$container->services()
// Register your custom services here
;
};

10
resources/mcp.json Normal file
View File

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

65
src/App.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate;
use Psr\Log\LoggerInterface;
use Symfony\AI\Mate\Command\ClearCacheCommand;
use Symfony\AI\Mate\Command\DiscoverCommand;
use Symfony\AI\Mate\Command\InitCommand;
use Symfony\AI\Mate\Command\ServeCommand;
use Symfony\AI\Mate\Exception\UnsupportedVersionException;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class App
{
public static function build(ContainerBuilder $container): Application
{
$logger = $container->get(LoggerInterface::class);
\assert($logger instanceof LoggerInterface);
$rootDir = $container->getParameter('mate.root_dir');
\assert(\is_string($rootDir));
$cacheDir = $container->getParameter('mate.cache_dir');
\assert(\is_string($cacheDir));
$application = new Application('Symfony AI Mate', '0.1.0');
self::addCommand($application, new InitCommand($rootDir));
self::addCommand($application, new ServeCommand($logger, $container));
self::addCommand($application, new DiscoverCommand($rootDir, $logger));
self::addCommand($application, new ClearCacheCommand($cacheDir));
return $application;
}
/**
* Add commands in a way that works with all support symfony/console versions.
*/
private static function addCommand(Application $application, Command $command): void
{
// @phpstan-ignore function.alreadyNarrowedType
if (method_exists($application, 'addCommand')) {
$application->addCommand($command);
} elseif (method_exists($application, 'add')) {
$application->add($command);
} else {
throw UnsupportedVersionException::forConsole();
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Capability;
use Mcp\Capability\Attribute\McpTool;
/**
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class ServerInfo
{
#[McpTool('php-version', 'Get the version of PHP')]
public function phpVersion(): string
{
return \PHP_VERSION;
}
#[McpTool('operating-system', 'Get the current operating system')]
public function operatingSystem(): string
{
return \PHP_OS;
}
#[McpTool('operating-system-family', 'Get the current operating system family')]
public function operatingSystemFamily(): string
{
return \PHP_OS_FAMILY;
}
/**
* @return string[]
*/
#[McpTool('php-extensions', 'Get a list of PHP extensions')]
public function extensions(): array
{
return get_loaded_extensions();
}
}

View File

@@ -0,0 +1,102 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\Finder;
/**
* Clear the MCP server cache.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class ClearCacheCommand extends Command
{
public function __construct(
private string $cacheDir,
) {
parent::__construct(self::getDefaultName());
}
public static function getDefaultName(): ?string
{
return 'clear-cache';
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Cache Management');
$io->text(\sprintf('Cache directory: <info>%s</info>', $this->cacheDir));
$io->newLine();
$cacheDir = $this->cacheDir;
if (!is_dir($cacheDir)) {
$io->note('Cache directory does not exist. Nothing to clear.');
return Command::SUCCESS;
}
$finder = new Finder();
$finder->files()->in($cacheDir);
$count = 0;
$totalSize = 0;
$fileList = [];
foreach ($finder as $file) {
$size = $file->getSize();
$totalSize += $size;
$fileList[] = [
basename($file->getFilename()),
$this->formatBytes($size),
];
unlink($file->getRealPath());
++$count;
}
if ($count > 0) {
$io->section('Cleared Files');
$io->table(['File', 'Size'], $fileList);
$io->success(\sprintf(
'Successfully cleared %d cache file%s (%s)',
$count,
1 === $count ? '' : 's',
$this->formatBytes($totalSize)
));
} else {
$io->info('Cache directory is already empty.');
}
return Command::SUCCESS;
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes.' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 2).' KB';
}
return round($bytes / 1048576, 2).' MB';
}
}

View File

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

186
src/Command/InitCommand.php Normal file
View File

@@ -0,0 +1,186 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Add some config in the project root, automatically discover tools.
* Basically do every thing you need to set things up.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class InitCommand extends Command
{
public function __construct(
private string $rootDir,
) {
parent::__construct(self::getDefaultName());
}
public static function getDefaultName(): ?string
{
return 'init';
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('AI Mate Initialization');
$io->text('Setting up AI Mate configuration and directory structure...');
$io->newLine();
$actions = [];
$mateDir = $this->rootDir.'/.mate';
if (!is_dir($mateDir)) {
mkdir($mateDir, 0755, true);
$actions[] = ['✓', 'Created', '.mate/ directory'];
}
$files = ['.mate/bridges.php', '.mate/services.php', '.mate/.gitignore', 'mcp.json'];
foreach ($files as $file) {
$fullPath = $this->rootDir.'/'.$file;
if (!file_exists($fullPath)) {
$this->copyTemplate($file, $fullPath);
$actions[] = ['✓', 'Created', $file];
} elseif ($io->confirm(\sprintf('<question>%s already exists. Overwrite?</question>', $fullPath), false)) {
unlink($fullPath);
$this->copyTemplate($file, $fullPath);
$actions[] = ['✓', 'Updated', $file];
} else {
$actions[] = ['○', 'Skipped', $file.' (already exists)'];
}
}
// Create symlink from .mcp.json to mcp.json for compatibility
$mcpJsonPath = $this->rootDir.'/mcp.json';
$mcpJsonSymlink = $this->rootDir.'/.mcp.json';
if (file_exists($mcpJsonPath)) {
if (is_link($mcpJsonSymlink)) {
unlink($mcpJsonSymlink);
}
if (!file_exists($mcpJsonSymlink)) {
symlink('mcp.json', $mcpJsonSymlink);
$actions[] = ['✓', 'Created', '.mcp.json (symlink to mcp.json)'];
} elseif ($io->confirm(\sprintf('<question>%s already exists. Replace with symlink?</question>', $mcpJsonSymlink), false)) {
unlink($mcpJsonSymlink);
symlink('mcp.json', $mcpJsonSymlink);
$actions[] = ['✓', 'Updated', '.mcp.json (symlink to mcp.json)'];
} else {
$actions[] = ['○', 'Skipped', '.mcp.json (already exists)'];
}
}
$mateUserDir = $this->rootDir.'/mate';
if (!is_dir($mateUserDir)) {
mkdir($mateUserDir, 0755, true);
file_put_contents($mateUserDir.'/.gitignore', '');
$actions[] = ['✓', 'Created', 'mate/ directory (for custom bridges)'];
} else {
$actions[] = ['○', 'Exists', 'mate/ directory'];
}
$composerActions = $this->updateComposerJson();
$actions = array_merge($actions, $composerActions);
$io->section('Summary');
$io->table(['', 'Action', 'Item'], $actions);
$io->success('AI Mate initialization complete!');
$io->comment([
'Next steps:',
' 1. Run "composer dump-autoload" to update the autoloader',
' 2. Run "vendor/bin/mate discover" to find MCP bridges',
' 3. Add your custom MCP tools/resources/prompts to the mate/ directory',
' 4. Run "vendor/bin/mate serve" to start the MCP server',
]);
return Command::SUCCESS;
}
private function copyTemplate(string $template, string $destination): void
{
copy(__DIR__.'/../../resources/'.$template, $destination);
}
/**
* @return list<array{string, string, string}>
*/
private function updateComposerJson(): array
{
$composerJsonPath = $this->rootDir.'/composer.json';
$actions = [];
if (!file_exists($composerJsonPath)) {
$actions[] = ['⚠', 'Warning', 'composer.json not found in project root'];
return $actions;
}
$composerContent = file_get_contents($composerJsonPath);
if (false === $composerContent) {
$actions[] = ['✗', 'Error', 'Failed to read composer.json'];
return $actions;
}
$composerJson = json_decode($composerContent, true);
if (\JSON_ERROR_NONE !== json_last_error() || !\is_array($composerJson)) {
$actions[] = ['✗', 'Error', 'Failed to parse composer.json: '.json_last_error_msg()];
return $actions;
}
$modified = false;
$composerJson['extra'] = \is_array($composerJson['extra'] ?? null) ? $composerJson['extra'] : [];
$composerJson['autoload'] = \is_array($composerJson['autoload'] ?? null) ? $composerJson['autoload'] : [];
$composerJson['autoload']['psr-4'] = \is_array($composerJson['autoload']['psr-4'] ?? null) ? $composerJson['autoload']['psr-4'] : [];
if (!isset($composerJson['extra']['ai-mate'])) {
$composerJson['extra']['ai-mate'] = [
'scan-dirs' => ['mate'],
'includes' => ['services.php'],
];
$modified = true;
$actions[] = ['✓', 'Added', 'extra.ai-mate configuration'];
} else {
$actions[] = ['○', 'Exists', 'extra.ai-mate configuration'];
}
if (!isset($composerJson['autoload']['psr-4']['App\\Mate\\'])) {
$composerJson['autoload']['psr-4']['App\\Mate\\'] = 'mate/';
$modified = true;
$actions[] = ['✓', 'Added', 'App\\Mate\\ autoloader'];
} else {
$actions[] = ['○', 'Exists', 'App\\Mate\\ autoloader'];
}
if ($modified) {
file_put_contents(
$composerJsonPath,
json_encode($composerJson, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n"
);
$actions[] = ['✓', 'Updated', 'composer.json'];
}
return $actions;
}
}

View File

@@ -0,0 +1,114 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Command;
use Mcp\Capability\Discovery\Discoverer;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\StdioTransport;
use Psr\Log\LoggerInterface;
use Symfony\AI\Mate\Discovery\ComposerTypeDiscovery;
use Symfony\AI\Mate\Discovery\FilteredDiscoveryLoader;
use Symfony\AI\Mate\Discovery\ServiceDiscovery;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Starts the MCP server with stdio transport.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class ServeCommand extends Command
{
private ComposerTypeDiscovery $discovery;
public function __construct(
private LoggerInterface $logger,
private ContainerBuilder $container,
) {
parent::__construct(self::getDefaultName());
$rootDir = $container->getParameter('mate.root_dir');
\assert(\is_string($rootDir));
$this->discovery = new ComposerTypeDiscovery($rootDir, $logger);
}
public static function getDefaultName(): ?string
{
return 'serve';
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$rootDir = $this->container->getParameter('mate.root_dir');
\assert(\is_string($rootDir));
$cacheDir = $this->container->getParameter('mate.cache_dir');
\assert(\is_string($cacheDir));
$discovery = new Discoverer($this->logger);
$bridges = $this->getBridgesToLoad();
(new ServiceDiscovery())->registerServices($discovery, $this->container, $rootDir, $bridges);
$disabledVendorFeatures = $this->container->getParameter('mate.disabled_features') ?? [];
\assert(\is_array($disabledVendorFeatures));
/* @var array<string, array<string, array{enabled: bool}>> $disabledVendorFeatures */
$this->container->compile();
$loader = new FilteredDiscoveryLoader(
basePath: $rootDir,
bridges: $bridges,
disabledFeatures: $disabledVendorFeatures,
discoverer: $discovery,
logger: $this->logger
);
$server = Server::builder()
->setServerInfo('ai-mate', '0.1.0', 'AI development assistant MCP server')
->setContainer($this->container)
->addLoaders($loader)
->setSession(new FileSessionStore($cacheDir.'/sessions'))
->setLogger($this->logger)
->build();
$server->run(new StdioTransport());
return Command::SUCCESS;
}
/**
* @return array<string, array{dirs: string[], includes: string[]}>
*/
private function getBridgesToLoad(): array
{
$rootDir = $this->container->getParameter('mate.root_dir');
\assert(\is_string($rootDir));
$packageNames = $this->container->getParameter('mate.enabled_bridges');
\assert(\is_array($packageNames));
/** @var array<int, string> $packageNames */
/** @var array<string, array{dirs: array<string>, includes: array<string>}> $bridges */
$bridges = [];
foreach ($this->discovery->discover($packageNames) as $packageName => $data) {
$bridges[$packageName] = $data;
}
$bridges['_custom'] = $this->discovery->discoverRootProject();
return $bridges;
}
}

View File

@@ -0,0 +1,164 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Container;
use Psr\Log\LoggerInterface;
use Symfony\AI\Mate\Discovery\ComposerTypeDiscovery;
use Symfony\AI\Mate\Exception\MissingDependencyException;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\Dotenv\Dotenv;
/**
* Factory for building a Symfony DI Container with MCP bridge configurations.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ContainerFactory
{
public function __construct(
private string $rootDir,
) {
}
public function create(): ContainerBuilder
{
$container = new ContainerBuilder();
$loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__)));
$loader->load('default.services.php');
$enabledBridges = $this->getEnabledBridges();
$container->setParameter('mate.enabled_bridges', $enabledBridges);
$container->setParameter('mate.root_dir', $this->rootDir);
$logger = $container->get(LoggerInterface::class);
\assert($logger instanceof LoggerInterface);
$discovery = new ComposerTypeDiscovery($this->rootDir, $logger);
if ([] !== $enabledBridges) {
foreach ($discovery->discover($enabledBridges) as $packageName => $data) {
$this->loadBridgeIncludes($container, $logger, $packageName, $data['includes']);
}
}
$rootProject = $discovery->discoverRootProject();
$this->loadUserServices($rootProject, $container);
$this->loadUserEnvVar($container);
return $container;
}
/**
* @return string[] Package names
*/
private function getEnabledBridges(): array
{
$bridgesFile = $this->rootDir.'/.mate/bridges.php';
if (!file_exists($bridgesFile)) {
return [];
}
$bridgesConfig = include $bridgesFile;
if (!\is_array($bridgesConfig)) {
return [];
}
$enabledBridges = [];
foreach ($bridgesConfig as $packageName => $config) {
if (\is_string($packageName) && \is_array($config) && ($config['enabled'] ?? false)) {
$enabledBridges[] = $packageName;
}
}
return $enabledBridges;
}
/**
* @param string[] $includeFiles
*/
private function loadBridgeIncludes(ContainerBuilder $container, LoggerInterface $logger, string $packageName, array $includeFiles): void
{
foreach ($includeFiles as $includeFile) {
if (!file_exists($includeFile)) {
continue;
}
try {
$loader = new PhpFileLoader($container, new FileLocator(\dirname($includeFile)));
$loader->load(basename($includeFile));
$logger->debug('Loaded bridge include', [
'package' => $packageName,
'file' => $includeFile,
]);
} catch (\Throwable $e) {
$logger->warning('Failed to load bridge include', [
'package' => $packageName,
'file' => $includeFile,
'error' => $e->getMessage(),
]);
}
}
}
private function loadUserEnvVar(ContainerBuilder $container): void
{
$envFile = $container->getParameter('mate.env_file');
if (null === $envFile || !\is_string($envFile) || '' === $envFile) {
return;
}
if (!class_exists(Dotenv::class)) {
throw MissingDependencyException::forDotenv();
}
$extra = [];
$localFile = $this->rootDir.\DIRECTORY_SEPARATOR.$envFile.\DIRECTORY_SEPARATOR.'.local';
if (!file_exists($localFile)) {
$extra[] = $localFile;
}
(new Dotenv())->load($this->rootDir.\DIRECTORY_SEPARATOR.$envFile, ...$extra);
}
/**
* @param array{dirs: array<string>, includes: array<string>} $rootProject
*/
private function loadUserServices(array $rootProject, ContainerBuilder $container): void
{
$logger = $container->get(LoggerInterface::class);
\assert($logger instanceof LoggerInterface);
$loader = new PhpFileLoader($container, new FileLocator($this->rootDir.'/.mate'));
foreach ($rootProject['includes'] as $include) {
try {
$loader->load($include);
$logger->debug('Loaded user services', [
'file' => $include,
]);
} catch (\Throwable $e) {
$logger->warning('Failed to load user services', [
'file' => $include,
'error' => $e->getMessage(),
]);
}
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Container;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
/**
* Helper methods for configuring AI Mate in services.php.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class MateHelper
{
/**
* Disable specific MCP features from one or more bridges.
*
* This function allows you to disable specific tools, resources, prompts, or
* resource templates from MCP bridges at a granular level. It is useful for
* disabling features that are known to cause issues or are not needed in your
* project.
*
* Call this method only once. The second call will override the first one.
*
* Example usage in .mate/services.php:
* ```php
* use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
* use Symfony\AI\Mate\Container\MateHelper;
*
* return static function (ContainerConfigurator $container): void {
* MateHelper::disableFeatures($container, [
* 'vendor/bridge' => ['badTool', 'semiBadTool']
* 'nyholm/example' => ['clock']
* ]);
*
* $container->parameters()
* ->set('mate.cache_dir', sys_get_temp_dir().'/mate')
* // ...
* }
* ```
*
* @param array<string, list<string>> $bridges
*/
public static function disableFeatures(ContainerConfigurator $container, array $bridges): void
{
$data = [];
foreach ($bridges as $bridge => $features) {
foreach ($features as $feature) {
$data[$bridge][$feature] = ['enabled' => false];
}
}
$container->parameters()->set('mate.disabled_features', $data);
}
}

View File

@@ -0,0 +1,312 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Discovery;
use Psr\Log\LoggerInterface;
/**
* Discovers MCP bridges via extra.ai-mate config in composer.json.
*
* Bridges must declare themselves in composer.json:
* {
* "extra": {
* "ai-mate": {
* "scan-dirs": ["src"],
* "includes": ["config/services.php"]
* }
* }
* }
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ComposerTypeDiscovery
{
/**
* @var array<string, array{
* name: string,
* extra: array<string, mixed>,
* }>|null
*/
private ?array $installedPackages = null;
public function __construct(
private string $rootDir,
private LoggerInterface $logger,
) {
}
/**
* @param string[] $enabledBridges
*
* @return array<string, array{dirs: string[], includes: string[]}>
*/
public function discover(array $enabledBridges = []): array
{
$installed = $this->getInstalledPackages();
$bridges = [];
foreach ($installed as $package) {
$packageName = $package['name'];
$aiMateConfig = $package['extra']['ai-mate'] ?? null;
if (!\is_array($aiMateConfig)) {
continue;
}
if ([] !== $enabledBridges && !\in_array($packageName, $enabledBridges, true)) {
$this->logger->debug('Skipping package not enabled', ['package' => $packageName]);
continue;
}
$scanDirs = $this->extractScanDirs($package, $packageName);
$includeFiles = $this->extractIncludeFiles($package, $packageName);
if ([] !== $scanDirs || [] !== $includeFiles) {
$bridges[$packageName] = [
'dirs' => $scanDirs,
'includes' => $includeFiles,
];
}
}
return $bridges;
}
/**
* @return array{dirs: array<string>, includes: array<string>}
*/
public function discoverRootProject(): array
{
$composerContent = file_get_contents($this->rootDir.'/composer.json');
if (false === $composerContent) {
return [
'dirs' => [],
'includes' => [],
];
}
$rootComposer = json_decode($composerContent, true);
if (!\is_array($rootComposer)) {
return [
'dirs' => [],
'includes' => [],
];
}
$scanDirs = [];
if (isset($rootComposer['extra']) && \is_array($rootComposer['extra'])
&& isset($rootComposer['extra']['ai-mate']) && \is_array($rootComposer['extra']['ai-mate'])
&& isset($rootComposer['extra']['ai-mate']['scan-dirs']) && \is_array($rootComposer['extra']['ai-mate']['scan-dirs'])) {
$scanDirs = array_filter($rootComposer['extra']['ai-mate']['scan-dirs'], 'is_string');
}
$includes = [];
if (isset($rootComposer['extra']) && \is_array($rootComposer['extra'])
&& isset($rootComposer['extra']['ai-mate']) && \is_array($rootComposer['extra']['ai-mate'])
&& isset($rootComposer['extra']['ai-mate']['includes']) && \is_array($rootComposer['extra']['ai-mate']['includes'])) {
$includes = array_filter($rootComposer['extra']['ai-mate']['includes'], 'is_string');
}
return [
'dirs' => array_values($scanDirs),
'includes' => array_values($includes),
];
}
/**
* Check vendor/composer/installed.json for installed packages.
*
* @return array<string, array{
* name: string,
* extra: array<string, mixed>,
* }>
*/
private function getInstalledPackages(): array
{
if (null !== $this->installedPackages) {
return $this->installedPackages;
}
$installedJsonPath = $this->rootDir.'/vendor/composer/installed.json';
if (!file_exists($installedJsonPath)) {
$this->logger->warning('Composer installed.json not found', ['path' => $installedJsonPath]);
return $this->installedPackages = [];
}
$content = file_get_contents($installedJsonPath);
if (false === $content) {
$this->logger->warning('Could not read installed.json', ['path' => $installedJsonPath]);
return $this->installedPackages = [];
}
try {
$data = json_decode($content, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->logger->error('Invalid JSON in installed.json', ['error' => $e->getMessage()]);
return $this->installedPackages = [];
}
if (!\is_array($data)) {
return $this->installedPackages = [];
}
// Handle both formats: {"packages": [...]} and direct array
$packages = $data['packages'] ?? $data;
if (!\is_array($packages)) {
return $this->installedPackages = [];
}
$indexed = [];
foreach ($packages as $package) {
if (!\is_array($package) || !isset($package['name']) || !\is_string($package['name'])) {
continue;
}
/** @var array{
* name: string,
* extra: array<string, mixed>,
* } $validPackage */
$validPackage = [
'name' => $package['name'],
'extra' => [],
];
if (isset($package['extra']) && \is_array($package['extra'])) {
/** @var array<string, mixed> $extra */
$extra = $package['extra'];
$validPackage['extra'] = $extra;
}
$indexed[$package['name']] = $validPackage;
}
return $this->installedPackages = $indexed;
}
/**
* @param array{
* name: string,
* extra: array<string, mixed>,
* } $package
*
* @return string[] list of directories with paths relative to project root
*/
private function extractScanDirs(array $package, string $packageName): array
{
$aiMateConfig = $package['extra']['ai-mate'] ?? null;
if (null === $aiMateConfig) {
// Default: scan package root directory if no config provided
$defaultDir = 'vendor/'.$packageName;
if (is_dir($this->rootDir.'/'.$defaultDir)) {
return [$defaultDir];
}
$this->logger->warning('Package directory not found', [
'package' => $packageName,
'directory' => $defaultDir,
]);
return [];
}
if (!\is_array($aiMateConfig)) {
$this->logger->warning('Invalid ai-mate config in package', ['package' => $packageName]);
return [];
}
$scanDirs = $aiMateConfig['scan-dirs'] ?? [];
if (!\is_array($scanDirs)) {
$this->logger->warning('Invalid scan-dirs in ai-mate config', ['package' => $packageName]);
return [];
}
$validDirs = [];
foreach ($scanDirs as $dir) {
if (!\is_string($dir) || '' === trim($dir) || str_contains($dir, '..')) {
continue;
}
$fullPath = 'vendor/'.$packageName.'/'.ltrim($dir, '/');
if (!is_dir($this->rootDir.'/'.$fullPath)) {
$this->logger->warning('Scan directory does not exist', [
'package' => $packageName,
'directory' => $fullPath,
]);
continue;
}
$validDirs[] = $fullPath;
}
return $validDirs;
}
/**
* Extract include files from package extra config.
*
* Uses "includes" from extra.ai-mate config, e.g.:
* "extra": { "ai-mate": { "includes": ["config/services.php"] } }
*
* @param array{
* name: string,
* extra: array<string, mixed>,
* } $package
*
* @return string[] list of files with paths relative to project root
*/
private function extractIncludeFiles(array $package, string $packageName): array
{
$aiMateConfig = $package['extra']['ai-mate'] ?? null;
if (null === $aiMateConfig || !\is_array($aiMateConfig)) {
return [];
}
$includes = $aiMateConfig['includes'] ?? [];
// Support single file as string
if (\is_string($includes)) {
$includes = [$includes];
}
if (!\is_array($includes)) {
$this->logger->warning('Invalid includes in ai-mate config', ['package' => $packageName]);
return [];
}
$validFiles = [];
foreach ($includes as $file) {
if (!\is_string($file) || '' === trim($file) || str_contains($file, '..')) {
continue;
}
$fullPath = $this->rootDir.'/vendor/'.$packageName.'/'.ltrim($file, '/');
if (!file_exists($fullPath)) {
$this->logger->warning('Include file does not exist', [
'package' => $packageName,
'file' => $fullPath,
]);
continue;
}
$validFiles[] = $fullPath;
}
return $validFiles;
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Discovery;
use Mcp\Capability\Discovery\Discoverer;
use Mcp\Capability\Discovery\DiscoveryState;
use Mcp\Capability\Registry\Loader\LoaderInterface;
use Mcp\Capability\RegistryInterface;
use Psr\Log\LoggerInterface;
/**
* Create loaded that automatically discover MCP features.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class FilteredDiscoveryLoader implements LoaderInterface
{
/**
* @param array<string, array{dirs: string[],includes: string[]}> $bridges
* @param array<string, array<string, array{enabled: bool}>> $disabledFeatures
*/
public function __construct(
private string $basePath,
private array $bridges,
private array $disabledFeatures,
private Discoverer $discoverer,
private LoggerInterface $logger,
) {
}
public function load(RegistryInterface $registry): void
{
$allTools = [];
$allResources = [];
$allPrompts = [];
$allResourceTemplates = [];
foreach ($this->bridges as $packageName => $data) {
$discoveryState = $this->discoverer->discover($this->basePath, $data['dirs']);
foreach ($discoveryState->getTools() as $name => $tool) {
if (!$this->isFeatureAllowed($packageName, $name)) {
$this->logger->debug('Excluding tool by feature filter', [
'package' => $packageName,
'tool' => $name,
]);
continue;
}
$allTools[$name] = $tool;
}
foreach ($discoveryState->getResources() as $uri => $resource) {
if (!$this->isFeatureAllowed($packageName, $uri)) {
$this->logger->debug('Excluding resource by feature filter', [
'package' => $packageName,
'resource' => $uri,
]);
continue;
}
$allResources[$uri] = $resource;
}
foreach ($discoveryState->getPrompts() as $name => $prompt) {
if (!$this->isFeatureAllowed($packageName, $name)) {
$this->logger->debug('Excluding prompt by feature filter', [
'package' => $packageName,
'prompt' => $name,
]);
continue;
}
$allPrompts[$name] = $prompt;
}
foreach ($discoveryState->getResourceTemplates() as $uriTemplate => $template) {
if (!$this->isFeatureAllowed($packageName, $uriTemplate)) {
$this->logger->debug('Excluding resource template by feature filter', [
'package' => $packageName,
'template' => $uriTemplate,
]);
continue;
}
$allResourceTemplates[$uriTemplate] = $template;
}
}
$filteredState = new DiscoveryState(
$allTools,
$allResources,
$allPrompts,
$allResourceTemplates,
);
$registry->setDiscoveryState($filteredState);
$this->logger->info('Loaded filtered capabilities', [
'tools' => \count($allTools),
'resources' => \count($allResources),
'prompts' => \count($allPrompts),
'resourceTemplates' => \count($allResourceTemplates),
]);
}
private function isFeatureAllowed(string $packageName, string $feature): bool
{
$data = $this->disabledFeatures[$packageName][$feature] ?? [];
return $data['enabled'] ?? true;
}
}

View File

@@ -0,0 +1,100 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Discovery;
use Mcp\Capability\Discovery\Discoverer;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Discovery services to add to Symfony DI container.
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ServiceDiscovery
{
/**
* Pre-register all discovered services in the container.
* Call this BEFORE container->compile().
*
* @param array<string, array{dirs: string[], includes: string[]}> $bridges
*/
public function registerServices(Discoverer $discoverer, ContainerBuilder $container, string $basePath, array $bridges): void
{
foreach ($bridges as $data) {
$discoveryState = $discoverer->discover($basePath, $data['dirs']);
foreach ($discoveryState->getTools() as $tool) {
$this->maybeRegisterHandler($container, $tool->handler);
}
foreach ($discoveryState->getResources() as $resource) {
$this->maybeRegisterHandler($container, $resource->handler);
}
foreach ($discoveryState->getPrompts() as $prompt) {
$this->maybeRegisterHandler($container, $prompt->handler);
}
foreach ($discoveryState->getResourceTemplates() as $template) {
$this->maybeRegisterHandler($container, $template->handler);
}
}
}
/**
* @param \Closure|array{0: object|string, 1: string}|string $handler
*/
private function maybeRegisterHandler(ContainerBuilder $container, \Closure|array|string $handler): void
{
$className = $this->extractClassName($handler);
if (null === $className) {
return;
}
$this->registerService($container, $className);
}
/**
* @param \Closure|array{0: object|string, 1: string}|string $handler
*/
private function extractClassName(\Closure|array|string $handler): ?string
{
if ($handler instanceof \Closure) {
return null;
}
if (\is_string($handler)) {
return class_exists($handler) ? $handler : null;
}
$class = $handler[0];
if (\is_object($class)) {
return $class::class;
}
return class_exists($class) ? $class : null;
}
private function registerService(ContainerBuilder $container, string $className): void
{
if ($container->has($className)) {
$container->getDefinition($className)
->setPublic(true);
return;
}
$container->register($className, $className)
->setAutowired(true)
->setPublic(true);
}
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Exception;
/**
* @internal
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface ExceptionInterface extends \Throwable
{
}

View File

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

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Exception;
/**
* @internal
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class MissingDependencyException extends RuntimeException
{
public static function forDotenv(): self
{
return new self('Cannot load any environment file with out Symfony Dotenv. Please run run "composer require symfony/dotenv" and try again.');
}
}

View File

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

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Exception;
/**
* @internal
*
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class UnsupportedVersionException extends RuntimeException
{
public static function forConsole(): self
{
return new self('Unsupported version of symfony/console. We cannot add commands.');
}
}

49
src/Service/Logger.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Service;
use Psr\Log\AbstractLogger;
/**
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class Logger extends AbstractLogger
{
public function log($level, \Stringable|string $message, array $context = []): void
{
$debug = $_SERVER['MATE_DEBUG'] ?? false;
if (!$debug && 'debug' === $level) {
return;
}
$levelString = match (true) {
$level instanceof \Stringable => (string) $level,
\is_string($level) => $level,
default => 'unknown',
};
$logMessage = \sprintf(
"[%s] %s %s\n",
strtoupper($levelString),
$message,
([] === $context || !$debug) ? '' : json_encode($context),
);
if (($_SERVER['MATE_FILE_LOG'] ?? false) || !\defined('STDERR')) {
file_put_contents('dev.log', $logMessage, \FILE_APPEND);
} else {
fwrite(\STDERR, $logMessage);
}
}
}

32
src/default.services.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Psr\Log\LoggerInterface;
use Symfony\AI\Mate\Service\Logger;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $container): void {
$container->parameters()
->set('mate.root_dir', '%env(MATE_ROOT_DIR)%')
->set('mate.cache_dir', sys_get_temp_dir().'/mate')
->set('mate.env_file', null)
->set('mate.disabled_features', [])
;
$container->services()
->defaults()
->autowire()
->autoconfigure()
->set(LoggerInterface::class, Logger::class)
->alias(Logger::class, LoggerInterface::class)
;
};

View File

@@ -0,0 +1,196 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Tests\Command;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\AI\Mate\Command\DiscoverCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class DiscoverCommandTest extends TestCase
{
private string $fixturesDir;
protected function setUp(): void
{
$this->fixturesDir = __DIR__.'/../Discovery/Fixtures';
}
public function testDiscoversBridgesAndCreatesFile()
{
$tempDir = sys_get_temp_dir().'/mate-discover-test-'.uniqid();
mkdir($tempDir, 0755, true);
try {
$rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir);
$command = new DiscoverCommand($rootDir, new NullLogger());
$tester = new CommandTester($command);
$tester->execute([]);
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
$this->assertFileExists($tempDir.'/.mate/bridges.php');
$bridges = include $tempDir.'/.mate/bridges.php';
$this->assertIsArray($bridges);
$this->assertArrayHasKey('vendor/package-a', $bridges);
$this->assertArrayHasKey('vendor/package-b', $bridges);
$this->assertIsArray($bridges['vendor/package-a']);
$this->assertIsArray($bridges['vendor/package-b']);
$this->assertTrue($bridges['vendor/package-a']['enabled']);
$this->assertTrue($bridges['vendor/package-b']['enabled']);
$output = $tester->getDisplay();
$this->assertStringContainsString('Discovered 2 Bridge', $output);
$this->assertStringContainsString('vendor/package-a', $output);
$this->assertStringContainsString('vendor/package-b', $output);
} finally {
$this->removeDirectory($tempDir);
}
}
public function testPreservesExistingEnabledState()
{
$tempDir = sys_get_temp_dir().'/mate-discover-test-'.uniqid();
mkdir($tempDir.'/.mate', 0755, true);
try {
// Create existing bridges.php with package-a disabled
file_put_contents($tempDir.'/.mate/bridges.php', <<<'PHP'
<?php
return [
'vendor/package-a' => ['enabled' => false],
'vendor/package-b' => ['enabled' => true],
];
PHP
);
$rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir);
$command = new DiscoverCommand($rootDir, new NullLogger());
$tester = new CommandTester($command);
$tester->execute([]);
$bridges = include $tempDir.'/.mate/bridges.php';
$this->assertIsArray($bridges);
$this->assertIsArray($bridges['vendor/package-a']);
$this->assertIsArray($bridges['vendor/package-b']);
$this->assertFalse($bridges['vendor/package-a']['enabled'], 'Should preserve disabled state');
$this->assertTrue($bridges['vendor/package-b']['enabled'], 'Should preserve enabled state');
} finally {
$this->removeDirectory($tempDir);
}
}
public function testNewPackagesDefaultToEnabled()
{
$tempDir = sys_get_temp_dir().'/mate-discover-test-'.uniqid();
mkdir($tempDir.'/.mate', 0755, true);
try {
// Create existing bridges.php with only package-a
file_put_contents($tempDir.'/.mate/bridges.php', <<<'PHP'
<?php
return [
'vendor/package-a' => ['enabled' => false],
];
PHP
);
$rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir);
$command = new DiscoverCommand($rootDir, new NullLogger());
$tester = new CommandTester($command);
$tester->execute([]);
$bridges = include $tempDir.'/.mate/bridges.php';
$this->assertIsArray($bridges);
$this->assertIsArray($bridges['vendor/package-a']);
$this->assertIsArray($bridges['vendor/package-b']);
$this->assertFalse($bridges['vendor/package-a']['enabled'], 'Existing disabled state preserved');
$this->assertTrue($bridges['vendor/package-b']['enabled'], 'New package defaults to enabled');
} finally {
$this->removeDirectory($tempDir);
}
}
public function testDisplaysWarningWhenNoBridgesFound()
{
$tempDir = sys_get_temp_dir().'/mate-discover-test-'.uniqid();
mkdir($tempDir, 0755, true);
try {
$rootDir = $this->createConfiguration($this->fixturesDir.'/without-ai-mate-config', $tempDir);
$command = new DiscoverCommand($rootDir, new NullLogger());
$tester = new CommandTester($command);
$tester->execute([]);
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
$output = $tester->getDisplay();
$this->assertStringContainsString('No MCP bridges found', $output);
} finally {
$this->removeDirectory($tempDir);
}
}
private function createConfiguration(string $rootDir, string $tempDir): string
{
// Copy fixture to temp directory for testing
$this->copyDirectory($rootDir.'/vendor', $tempDir.'/vendor');
return $tempDir;
}
private function copyDirectory(string $src, string $dst): void
{
if (!is_dir($src)) {
return;
}
if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$files = array_diff(scandir($src) ?: [], ['.', '..']);
foreach ($files as $file) {
$srcPath = $src.'/'.$file;
$dstPath = $dst.'/'.$file;
if (is_dir($srcPath)) {
$this->copyDirectory($srcPath, $dstPath);
} else {
copy($srcPath, $dstPath);
}
}
}
private function removeDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir) ?: [], ['.', '..']);
foreach ($files as $file) {
$path = $dir.'/'.$file;
is_dir($path) ? $this->removeDirectory($path) : unlink($path);
}
rmdir($dir);
}
}

View File

@@ -0,0 +1,151 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Tests\Command;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Mate\Command\InitCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class InitCommandTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir().'/mate-test-'.uniqid();
mkdir($this->tempDir, 0755, true);
}
protected function tearDown(): void
{
$this->removeDirectory($this->tempDir);
}
public function testCreatesDirectoryAndConfigFile()
{
$command = new InitCommand($this->tempDir);
$tester = new CommandTester($command);
$tester->execute([]);
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
$this->assertDirectoryExists($this->tempDir.'/.mate');
$this->assertFileExists($this->tempDir.'/.mate/bridges.php');
$this->assertFileExists($this->tempDir.'/.mate/services.php');
$this->assertFileExists($this->tempDir.'/mcp.json');
$this->assertTrue(is_link($this->tempDir.'/.mcp.json'));
$this->assertSame('mcp.json', readlink($this->tempDir.'/.mcp.json'));
$content = file_get_contents($this->tempDir.'/.mate/bridges.php');
$this->assertIsString($content);
$this->assertStringContainsString('mate discover', $content);
$this->assertStringContainsString('enabled', $content);
}
public function testDisplaysSuccessMessage()
{
$command = new InitCommand($this->tempDir);
$tester = new CommandTester($command);
$tester->execute([]);
$output = $tester->getDisplay();
$this->assertStringContainsString('AI Mate Initialization', $output);
$this->assertStringContainsString('bridges.php', $output);
$this->assertStringContainsString('services.php', $output);
$this->assertStringContainsString('vendor/bin/mate discover', $output);
$this->assertStringContainsString('Summary', $output);
$this->assertStringContainsString('Created', $output);
}
public function testDoesNotOverwriteExistingFileWithoutConfirmation()
{
$command = new InitCommand($this->tempDir);
$tester = new CommandTester($command);
// Create existing file
mkdir($this->tempDir.'/.mate', 0755, true);
file_put_contents($this->tempDir.'/.mate/bridges.php', '<?php return ["test" => "value"];');
// Execute with 'no' response (twice for both files)
$tester->setInputs(['no', 'no']);
$tester->execute([]);
// File should still contain original content
$content = file_get_contents($this->tempDir.'/.mate/bridges.php');
$this->assertIsString($content);
$this->assertStringContainsString('test', $content);
$this->assertStringContainsString('value', $content);
}
public function testOverwritesExistingFileWithConfirmation()
{
$command = new InitCommand($this->tempDir);
$tester = new CommandTester($command);
// Create existing file
mkdir($this->tempDir.'/.mate', 0755, true);
file_put_contents($this->tempDir.'/.mate/bridges.php', '<?php return ["test" => "value"];');
// Execute with 'yes' response (twice for both files)
$tester->setInputs(['yes', 'yes']);
$tester->execute([]);
// File should be overwritten with template content
$content = file_get_contents($this->tempDir.'/.mate/bridges.php');
$this->assertIsString($content);
$this->assertStringNotContainsString('test', $content);
$this->assertStringContainsString('mate discover', $content);
$this->assertStringContainsString('enabled', $content);
}
public function testCreatesDirectoryIfNotExists()
{
$command = new InitCommand($this->tempDir);
$tester = new CommandTester($command);
// Ensure .mate directory doesn't exist
$this->assertDirectoryDoesNotExist($this->tempDir.'/.mate');
$tester->execute([]);
// Directory should be created
$this->assertDirectoryExists($this->tempDir.'/.mate');
$this->assertFileExists($this->tempDir.'/.mate/bridges.php');
$this->assertFileExists($this->tempDir.'/.mate/services.php');
}
private function removeDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir.'/'.$file;
if (is_link($path)) {
unlink($path);
} elseif (is_dir($path)) {
$this->removeDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
}

View File

@@ -0,0 +1,153 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Mate\Tests\Discovery;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\AI\Mate\Discovery\ComposerTypeDiscovery;
/**
* @author Johannes Wachter <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ComposerTypeDiscoveryTest extends TestCase
{
private string $fixturesDir;
protected function setUp(): void
{
$this->fixturesDir = __DIR__.'/Fixtures';
}
public function testDiscoverPackagesWithAiMateConfig()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/with-ai-mate-config',
new NullLogger()
);
$bridges = $discovery->discover();
$this->assertCount(2, $bridges);
$this->assertArrayHasKey('vendor/package-a', $bridges);
$this->assertArrayHasKey('vendor/package-b', $bridges);
// Check package-a structure
$this->assertArrayHasKey('dirs', $bridges['vendor/package-a']);
$this->assertArrayHasKey('includes', $bridges['vendor/package-a']);
$this->assertContains('vendor/vendor/package-a/src', $bridges['vendor/package-a']['dirs']);
}
public function testIgnoresPackagesWithoutAiMateConfig()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/without-ai-mate-config',
new NullLogger()
);
$bridges = $discovery->discover();
$this->assertCount(0, $bridges);
}
public function testIgnoresPackagesWithoutExtraSection()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/no-extra-section',
new NullLogger()
);
$bridges = $discovery->discover();
$this->assertCount(0, $bridges);
}
public function testWhitelistFiltering()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/with-ai-mate-config',
new NullLogger()
);
$enabledBridges = [
'vendor/package-a',
];
$bridges = $discovery->discover($enabledBridges);
$this->assertCount(1, $bridges);
$this->assertArrayHasKey('vendor/package-a', $bridges);
$this->assertArrayNotHasKey('vendor/package-b', $bridges);
}
public function testWhitelistWithMultiplePackages()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/with-ai-mate-config',
new NullLogger()
);
$enabledBridges = [
'vendor/package-a',
'vendor/package-b',
];
$bridges = $discovery->discover($enabledBridges);
$this->assertCount(2, $bridges);
$this->assertArrayHasKey('vendor/package-a', $bridges);
$this->assertArrayHasKey('vendor/package-b', $bridges);
}
public function testExtractsIncludeFiles()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/with-includes',
new NullLogger()
);
$bridges = $discovery->discover();
$this->assertCount(1, $bridges);
$this->assertArrayHasKey('vendor/package-with-includes', $bridges);
$includes = $bridges['vendor/package-with-includes']['includes'];
$this->assertNotEmpty($includes);
$this->assertStringContainsString('config/services.php', $includes[0]);
}
public function testHandlesMissingInstalledJson()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/no-installed-json',
new NullLogger()
);
$bridges = $discovery->discover();
$this->assertCount(0, $bridges);
}
public function testHandlesPackagesWithoutType()
{
$discovery = new ComposerTypeDiscovery(
$this->fixturesDir.'/mixed-types',
new NullLogger()
);
$bridges = $discovery->discover();
// Should discover packages with ai-mate config regardless of type field
$this->assertGreaterThanOrEqual(1, $bridges);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
{
"packages": [
{
"name": "vendor/package-a",
"type": "library",
"extra": {
"ai-mate": {
"scan-dirs": ["src"]
}
}
},
{
"name": "vendor/package-b",
"type": "library",
"extra": {
"ai-mate": {
"scan-dirs": ["src"]
}
}
}
]
}

View File

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

View File

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

View File

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

12
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
require_once __DIR__.'/../vendor/autoload.php';