Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ jobs:
- run: wget https://getcomposer.org/download/latest-stable/composer.phar -O composer
- run: php composer req "laravel/framework:~13.0" --no-update
- run: php composer req "phpunit/phpunit:~12.0" --no-update --dev
- run: php composer req "laravel/mcp:^0.9" --no-update --dev
- restore_cache:
keys:
- php84-{{ checksum "composer.json" }}
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ headless distribution is the right choice:
- [Database](#database)
- [Installation](#installation)
- [Authentication](#authentication)
- [MCP bridge](#mcp-bridge)
- [Setup](#setup)
- [Test](#test)
- [Hints](#hints)
Expand Down Expand Up @@ -317,6 +318,41 @@ frontend too. To protect the new account, the command will ask you for a passwor
The same command can create limited accounts by using `--admin`, `--editor` or `--api`
instead of `--super` (access to everything).

## MCP bridge

Aimeos can expose its administration tools through the official Laravel MCP server.
The transport is optional so applications that don't use MCP, as well as Laravel 10
applications, keep working without the SDK. On a supported Laravel version, install it
in the application with:

```bash
composer require laravel/mcp:^0.9
```

Enable the endpoint in `config/shop.php` and protect it with the token guard used by
your application. This example uses Laravel Sanctum:

```php
'guards' => [
'mcp' => 'sanctum',
],
'routes' => [
'mcp' => [
'prefix' => 'admin/{site}/mcp',
'middleware' => ['api', 'auth:sanctum'],
],
],
```

The MCP endpoint is then available at `/admin/<site>/mcp`. It is disabled until this
route configuration and `laravel/mcp` are both present. Never enable it without
authentication: the server exposes administrative read and write operations.

The bridge applies the normal Laravel `admin` gate before starting the server. Each
tool also validates its input and checks the Aimeos permissions configured below
`admin/mcp/resource`. Superusers have access to all tools; assign explicit resource
permissions for limited admin, editor or API accounts.

## Setup

To reference images correctly, you have to adapt your `.env` file and set the `APP_URL`
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,18 @@
"aimeos/ai-client-jsonapi": "dev-master",
"aimeos/ai-cms-grapesjs": "dev-master",
"aimeos/ai-controller-jobs": "dev-master",
"aimeos/ai-controller-frontend": "dev-master"
"aimeos/ai-controller-frontend": "dev-master",
"aimeos/ai-admin-mcp": "dev-master"
},
"require-dev": {
"phpunit/phpunit": "^10.0||^11.0||^12.0",
"orchestra/testbench": "~8.0||~9.0||~10.0||~11.0",
"orchestra/testbench-browser-kit": "~8.0||~9.0||~10.0||~11.0",
"php-coveralls/php-coveralls": "~2.0"
},
"suggest": {
"laravel/mcp": "Exposes the Aimeos admin tools through the optional Laravel MCP bridge"
},
"autoload": {
"psr-4": {
"Aimeos\\Shop\\": "src/"
Expand Down
2 changes: 2 additions & 0 deletions config/shop.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
'pcntl_max' => 4, // maximum number of parallel command line processes when starting jobs
'version' => env( 'APP_VERSION', 1 ), // shop CSS/JS file version
'roles' => ['admin', 'editor'], // user groups allowed to access the admin backend
// 'guards' => ['mcp' => 'sanctum'], // authentication guards for named route groups
'panel' => 'dashboard', // panel shown in admin backend after login
'multishop' => env( 'SHOP_MULTISHOP', false ), // enable SaaS-like setups
'multilocale' => env( 'SHOP_MULTILOCALE', false ), // add locales in URLs
Expand All @@ -18,6 +19,7 @@
// 'admin' => ['prefix' => 'admin', 'middleware' => ['web']],
// 'jqadm' => ['prefix' => 'admin/{site}/jqadm', 'middleware' => ['web', 'auth']],
// 'graphql' => ['prefix' => 'admin/{site}/graphql', 'middleware' => ['web', 'auth']],
// 'mcp' => ['prefix' => 'admin/{site}/mcp', 'middleware' => ['api', 'auth:sanctum']],
// 'jsonapi' => ['prefix' => 'jsonapi', 'middleware' => ['web', 'api']],
// 'account' => ['prefix' => 'profile', 'middleware' => ['web', 'auth']],
// 'default' => ['prefix' => 'shop', 'middleware' => ['web']],
Expand Down
3 changes: 3 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
<testsuite name="controller">
<directory>./tests/Controller</directory>
</testsuite>
<testsuite name="mcp">
<directory>./tests/Mcp</directory>
</testsuite>
<testsuite name="helpers">
<file>./tests/HelpersTest.php</file>
</testsuite>
Expand Down
14 changes: 14 additions & 0 deletions routes/aimeos.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@
}


if( class_exists( \Laravel\Mcp\Facades\Mcp::class )
&& ( $conf = config( 'shop.routes.mcp', false ) ) !== false
) {

Route::group( $conf, function() {

\Laravel\Mcp\Facades\Mcp::web( '', \Aimeos\Shop\Mcp\Server::class )
->name( 'aimeos_shop_mcp' )
->where( ['site' => '[A-Za-z0-9\.\-]+'] );

});
}


if( ( $conf = config( 'shop.routes.jsonapi', ['prefix' => 'jsonapi', 'middleware' => ['web', 'api']] ) ) !== false ) {

Route::group( $conf, function() {
Expand Down
48 changes: 48 additions & 0 deletions src/Mcp/Server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

/**
* @license MIT, http://opensource.org/licenses/MIT
* @copyright Aimeos (aimeos.org), 2026
*/


namespace Aimeos\Shop\Mcp;

use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Request;


/**
* Laravel MCP server exposing the Aimeos administration tools
*/
class Server extends \Laravel\Mcp\Server
{
protected string $name = 'Aimeos Admin';
protected string $version = '1.0.0';
protected string $instructions = 'Manage the Aimeos shop using the available administrative tools.';


/**
* Initializes the Aimeos context and tool catalog for the current request.
*/
protected function boot() : void
{
if( config( 'shop.authorize', true ) ) {
Gate::authorize( 'admin', [Server::class, array_merge( config( 'shop.roles', ['admin', 'editor'] ), ['api'] )] );
}

$site = Request::route( 'site' ) ?? Request::get( 'site', config( 'shop.mshop.locale.site', 'default' ) );
$lang = Request::get( 'locale', config( 'app.locale', 'en' ) );

$context = app( 'aimeos.context' )->get( false, 'backend' );
$context->setI18n( app( 'aimeos.i18n' )->get( [$lang, 'en'] ) );
$context->setLocale( app( 'aimeos.locale' )->getBackend( $context, $site ) );
$context->config()->apply( $context->locale()->getSiteItem()->getConfig() );
$context->setView( app( 'aimeos.view' )->create( $context, [], $lang ) );

$this->tools = array_map(
fn( \Aimeos\Admin\Mcp\Tool $tool ) => new Tool( $tool ),
array_values( \Aimeos\Admin\Mcp\Tools::create( $context ) )
);
}
}
74 changes: 74 additions & 0 deletions src/Mcp/Tool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

/**
* @license MIT, http://opensource.org/licenses/MIT
* @copyright Aimeos (aimeos.org), 2026
*/


namespace Aimeos\Shop\Mcp;

use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;


/**
* Adapts one framework-neutral Aimeos tool to the Laravel MCP runtime
*/
class Tool extends \Laravel\Mcp\Server\Tool
{
public function __construct( private \Aimeos\Admin\Mcp\Tool $tool )
{
}


public function description() : string
{
return $this->tool->description();
}


public function name() : string
{
return $this->tool->name();
}


/**
* Executes the native Aimeos validation, authorization and tool action.
*/
public function handle( Request $request ) : Response|ResponseFactory
{
try {
$result = $this->tool->execute( $request->all() );
} catch( \Aimeos\Admin\Mcp\Exception $e ) {
return Response::error( $e->getMessage() );
}

if( $result === [] ) {
return Response::make( Response::text( '[]' ) )->withStructuredContent( $result );
}

return Response::structured( $result );
}


/**
* Returns the native JSON schema and behavior hints without translating them.
*
* @return array<string, mixed>
*/
public function toArray() : array
{
$schema = $this->tool->schema()->toArray();
$schema['properties'] ??= (object) [];

return [
'name' => $this->name(),
'description' => $this->description(),
'inputSchema' => $schema,
'annotations' => $this->tool->annotations(),
];
}
}
3 changes: 2 additions & 1 deletion tests/AimeosTestAbstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ protected function getEnvironmentSetUp( $app )
$app['config']->set( 'shop.admin.graphql.debug', true );
$app['config']->set( 'shop.routes.jqadm', ['prefix' => '{site}/jqadm'] );
$app['config']->set( 'shop.routes.graphql', ['prefix' => '{site}/graphql'] );
$app['config']->set( 'shop.routes.mcp', ['prefix' => '{site}/mcp'] );
$app['config']->set( 'shop.routes.jsonapi', ['prefix' => '{site}/jsonapi'] );
$app['config']->set( 'shop.routes.account', ['prefix' => '{site}/profile'] );
$app['config']->set( 'shop.routes.default', ['prefix' => '{site}/shop'] );
Expand All @@ -73,4 +74,4 @@ protected function getPackageProviders( $app )
{
return ['Aimeos\Shop\ShopServiceProvider'];
}
}
}
95 changes: 95 additions & 0 deletions tests/Mcp/ServerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

class ServerTest extends AimeosTestAbstract
{
public function testTools()
{
if( !class_exists( \Laravel\Mcp\Server::class ) ) {
$this->markTestSkipped( 'The optional laravel/mcp package is not installed' );
}

$context = new \Aimeos\MShop\Context();
$context->setConfig( new \Aimeos\Base\Config\PHPArray( [
'admin' => ['mcp' => ['tools' => [McpServerTool::class]]],
] ) );

$site = $this->createStub( \Aimeos\MShop\Locale\Item\Site\Iface::class );
$site->method( 'getConfig' )->willReturn( [] );
$locale = $this->createStub( \Aimeos\MShop\Locale\Item\Iface::class );
$locale->method( 'getSiteItem' )->willReturn( $site );

$view = new \Aimeos\Base\View\Standard();
$view->addHelper( 'access', new \Aimeos\Base\View\Helper\Access\All( $view ) );

$contexts = $this->createStub( \Aimeos\Shop\Base\Context::class );
$contexts->method( 'get' )->willReturn( $context );
$i18n = $this->createStub( \Aimeos\Shop\Base\I18n::class );
$i18n->method( 'get' )->willReturn( [] );
$locales = $this->createStub( \Aimeos\Shop\Base\Locale::class );
$locales->method( 'getBackend' )->willReturn( $locale );
$views = $this->createStub( \Aimeos\Shop\Base\View::class );
$views->method( 'create' )->willReturn( $view );

$this->app->instance( 'aimeos.context', $contexts );
$this->app->instance( 'aimeos.i18n', $i18n );
$this->app->instance( 'aimeos.locale', $locales );
$this->app->instance( 'aimeos.view', $views );

$server = new \Aimeos\Shop\Mcp\Server( new \Laravel\Mcp\Server\Transport\FakeTransporter() );
$server->start();
$tools = $server->createContext()->tools();

$this->assertCount( 1, $tools );
$this->assertInstanceOf( \Aimeos\Shop\Mcp\Tool::class, $tools->first() );
$this->assertSame( 'test-server', $tools->first()->name() );
}


public function testRoute()
{
if( !class_exists( \Laravel\Mcp\Facades\Mcp::class ) ) {
$this->markTestSkipped( 'The optional laravel/mcp package is not installed' );
}

$route = app( 'router' )->getRoutes()->getByName( 'aimeos_shop_mcp' );

$this->assertNotNull( $route );
$this->assertSame( '{site}/mcp', $route->uri() );
$this->assertSame( ['POST'], $route->methods() );
}


public function testRouteWithoutSdk()
{
if( class_exists( \Laravel\Mcp\Facades\Mcp::class ) ) {
$this->markTestSkipped( 'The optional laravel/mcp package is installed' );
}

$this->assertNull( app( 'router' )->getRoutes()->getByName( 'aimeos_shop_mcp' ) );
}
}


class McpServerTool extends \Aimeos\Admin\Mcp\Tool
{
protected const ACTION = 'server';
protected const DOMAIN = 'test';


public function description() : string
{
return 'Tests the server catalog.';
}


public function schema() : \Aimeos\Prisma\Schema\Schema
{
return $this->objectSchema( [] );
}


protected function run( array $arguments ) : array
{
return [];
}
}
Loading