diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index c8f74c9957f..134741d7dd6 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -117,7 +117,7 @@ jobs: timeout_minutes: 10 max_attempts: 2 retry_on: error - command: vendor/bin/phpunit --exclude-group cache-clear --exclude-group cache-clear-install --exclude-group update-schema-doctrine --exclude-group plugin-service + command: vendor/bin/phpunit --exclude-group cache-clear --exclude-group cache-clear-install --exclude-group update-schema-doctrine --exclude-group plugin-service --exclude-group mcp env: APP_ENV: 'test' DATABASE_URL: ${{ matrix.database_url }} @@ -174,3 +174,97 @@ jobs: echo "session.save_path=$PWD/var/sessions/test" > php.ini echo "memory_limit=512M" >> php.ini php -c php.ini vendor/bin/phpunit --group plugin-service + + ## MCP は Api44 (OAuth2 / scope / allow_list) が前提のため、 メインのマトリクスからは + ## --exclude-group mcp で外し、 ここで Api44 を導入した専用環境で --group mcp を実走する。 + ## Api44 は packagist 非公開のため、 eccube-api4 を checkout → mock-package-api で配信して + ## eccube:composer:require する (Api44 自身の CI と同じ方式)。 + mcp: + name: PHPUnit (mcp) + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + ## MCP の scope 認可と /admin/mcp 用 OAuth2 firewall は Api44 の feat/mcp-server-scorp に実装されており、 + ## eccube-api4 の 4.4 にはまだ無い (このブランチに無いと firewall が prepend されず /admin/mcp が + ## ログインへ 302 リダイレクトし、 McpFirewallContractTest 等が 401/200 を得られず失敗する)。 + ## 当該ブランチが 4.4 にマージされたら ref を '4.4' に戻すこと。 + - name: Checkout Api44 plugin + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: 'EC-CUBE/eccube-api4' + ref: 'feat/mcp-server-scorp' + path: 'eccube-api4' + + - name: Setup PostgreSQL + uses: ankane/setup-postgres@6d3ffa1aa7498a42b79e9f9f5838b99971839300 # v1 + with: + postgres-version: '16' + user: postgres + - name: Configure PostgreSQL + run: | + for _ in $(seq 1 30); do pg_isready -h 127.0.0.1 && break; sleep 1; done + pg_isready -h 127.0.0.1 || { echo 'PostgreSQL did not become ready within 30s'; exit 1; } + psql -h 127.0.0.1 -U postgres -c "ALTER USER postgres PASSWORD 'password'" + + - name: Setup PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + github-token: '' + extensions: :xdebug, redis + - name: Initialize Composer + uses: ./.github/actions/composer + + - name: Generate ECCUBE_AUTH_MAGIC + run: echo "ECCUBE_AUTH_MAGIC=$(openssl rand -hex 32)" >> $GITHUB_ENV + + - name: Setup mock-package-api + run: | + # eccube-api4 自身の CI と同じ形式で固める (.git/.github 等の dotfiles を除外し ./* で固める)。 + # .git を含めると composer の dist 展開が失敗する (Install of ec-cube/api44 failed)。 + ( cd eccube-api4 && tar cvzf "$GITHUB_WORKSPACE/Api44.tar.gz" ./* ) + mkdir -p /tmp/repos + cp "$GITHUB_WORKSPACE/Api44.tar.gz" /tmp/repos/Api44.tgz + docker run --name package-api -d -v /tmp/repos:/repos -e MOCK_REPO_DIR=/repos -p 8080:8080 eccube/mock-package-api:composer2 + + - name: Setup EC-CUBE + env: + APP_ENV: 'test' + DATABASE_URL: postgres://postgres:password@127.0.0.1:5432/eccube_db + DATABASE_SERVER_VERSION: 16 + DATABASE_CHARSET: utf8 + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + run: | + bin/console doctrine:database:create + bin/console doctrine:schema:create + bin/console eccube:fixtures:load + + - name: Install Api44 + env: + APP_ENV: 'test' + DATABASE_URL: postgres://postgres:password@127.0.0.1:5432/eccube_db + DATABASE_SERVER_VERSION: 16 + DATABASE_CHARSET: utf8 + ECCUBE_PACKAGE_API_URL: 'http://127.0.0.1:8080' + USE_SELFSIGNED_SSL_CERTIFICATE: '1' + run: | + bin/console doctrine:query:sql "update dtb_base_info set authentication_key='dummy'" + bin/console eccube:composer:require ec-cube/api44 + bin/console eccube:plugin:enable --code=Api44 + bin/console doctrine:schema:update --force --dump-sql + bin/console cache:clear --no-warmup + chmod 600 app/PluginData/Api44/oauth/private.key + + - name: PHPUnit (mcp) + env: + APP_ENV: 'test' + DATABASE_URL: postgres://postgres:password@127.0.0.1:5432/eccube_db + DATABASE_SERVER_VERSION: 16 + DATABASE_CHARSET: utf8 + MAILER_URL: 'smtp://127.0.0.11025' + run: | + echo "session.save_path=$PWD/var/sessions/test" > php.ini + echo "memory_limit=512M" >> php.ini + php -c php.ini vendor/bin/phpunit --group mcp diff --git a/app/config/eccube/bundles.php b/app/config/eccube/bundles.php index c80c04091b1..a459ad08d3a 100644 --- a/app/config/eccube/bundles.php +++ b/app/config/eccube/bundles.php @@ -27,4 +27,5 @@ DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Eccube\EccubeBundle::class => ['all' => true], + Symfony\AI\McpBundle\McpBundle::class => ['all' => true], ]; diff --git a/app/config/eccube/packages/dev/monolog.yml b/app/config/eccube/packages/dev/monolog.yml index 7b6e0636b9c..5707b0336f2 100644 --- a/app/config/eccube/packages/dev/monolog.yml +++ b/app/config/eccube/packages/dev/monolog.yml @@ -6,6 +6,8 @@ monolog: level: debug formatter: eccube.log.formatter.line max_files: 10 + # mcp は専用ハンドラ (mcp) に出すため main(site.log) からは除外する + channels: ['!mcp'] console: type: console process_psr_3_messages: false diff --git a/app/config/eccube/packages/http_discovery.yaml b/app/config/eccube/packages/http_discovery.yaml new file mode 100644 index 00000000000..2a789e73c90 --- /dev/null +++ b/app/config/eccube/packages/http_discovery.yaml @@ -0,0 +1,10 @@ +services: + Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory' + + http_discovery.psr17_factory: + class: Http\Discovery\Psr17Factory diff --git a/app/config/eccube/packages/mcp.yaml b/app/config/eccube/packages/mcp.yaml new file mode 100644 index 00000000000..7dce3bb9287 --- /dev/null +++ b/app/config/eccube/packages/mcp.yaml @@ -0,0 +1,14 @@ +mcp: + app: 'EC-CUBE MCP Server' + version: '4.4.0' + description: 'EC-CUBE 4.4 の管理データ (商品/在庫・注文・顧客会員・プラグイン管理) を AI クライアントから自然言語で参照する読み取り専用 MCP サーバ。 認証認可は API プラグイン (api44) の OAuth2 / scope に委譲する。' + client_transports: + http: true + stdio: true + http: + path: '/%eccube_admin_route%/mcp' + session: + store: file + discovery: + scan_dirs: + - src/Eccube/Service/Mcp/Tool diff --git a/app/config/eccube/packages/mcp_rate_limiter.yaml b/app/config/eccube/packages/mcp_rate_limiter.yaml new file mode 100644 index 00000000000..2fb3f8f4fe6 --- /dev/null +++ b/app/config/eccube/packages/mcp_rate_limiter.yaml @@ -0,0 +1,19 @@ +# MCP サーバの Rate Limiter 設定 (設計 §5「Rate Limiter 連携」)。 +# +# 2 段構成: +# - mcp_ip: リモート IP 単位の制限 (firewall 前で消費、 認証エラー連発攻撃にも効く) +# - mcp_client: OAuth2 client_id 単位の制限 (firewall 通過後の OAuth2Token から client_id を取得して消費) +# +# 既定値は PoC レベルの控えめな値。 GA 運用開始時に再評価する。 +framework: + rate_limiter: + mcp_ip: + policy: fixed_window + limit: 60 + interval: '1 minute' + cache_pool: rate_limiter.cache + mcp_client: + policy: fixed_window + limit: 300 + interval: '1 minute' + cache_pool: rate_limiter.cache diff --git a/app/config/eccube/packages/monolog.yml b/app/config/eccube/packages/monolog.yml index 286794002d7..3eefa31b34e 100644 --- a/app/config/eccube/packages/monolog.yml +++ b/app/config/eccube/packages/monolog.yml @@ -1,2 +1,14 @@ monolog: - channels: ['front', 'admin'] + channels: ['front', 'admin', 'mcp'] + handlers: + # MCP 監査ログ: PII を含み得るため site.log と分離した専用ファイルに、 1 レコード 1 JSON で出力する。 + # fingers_crossed を挟まず info から常時書き出す (監査記録は error 連動で握り潰してはならない)。 + # 保管日数は ECCUBE_MCP_LOG_RETENTION_DAYS (既定 90)。 ファイルは所有者/グループのみ読める権限にする。 + mcp: + type: rotating_file + path: '%kernel.logs_dir%/%kernel.environment%/mcp.log' + channels: ['mcp'] + level: info + formatter: eccube.mcp.log.formatter.json + max_files: '%env(int:ECCUBE_MCP_LOG_RETENTION_DAYS)%' + file_permission: 0640 diff --git a/app/config/eccube/packages/prod/monolog.yml b/app/config/eccube/packages/prod/monolog.yml index 988ebd01912..f9a8bbfb689 100644 --- a/app/config/eccube/packages/prod/monolog.yml +++ b/app/config/eccube/packages/prod/monolog.yml @@ -8,7 +8,8 @@ monolog: handler: main_rotating_file excluded_http_codes: [404, 405] buffer_size: 50 - channels: ['!doctrine', '!event', '!php'] + # mcp は専用ハンドラ (mcp) に出すため main(site.log) からは除外する + channels: ['!doctrine', '!event', '!php', '!mcp'] main_rotating_file: type: rotating_file max_files: 60 diff --git a/app/config/eccube/routes.yaml b/app/config/eccube/routes.yaml index 914e3b140a9..923605d4cc6 100644 --- a/app/config/eccube/routes.yaml +++ b/app/config/eccube/routes.yaml @@ -4,6 +4,9 @@ controllers: customize_controllers: resource: ../../../app/Customize/Controller type: attribute +mcp: + resource: . + type: mcp # prefix: /{_locale} # prefix: / diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index ebdebaa9853..e772810801f 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -8,6 +8,9 @@ parameters: env(ECCUBE_LOCALE): 'ja' env(ECCUBE_TIMEZONE): 'Asia/Tokyo' env(ECCUBE_CURRENCY): 'JPY' + env(ECCUBE_MCP_ALLOWED_ORIGINS): '' + # MCP 監査ログ (mcp.log) の保管日数 (rotating_file の世代数)。 設計 §4.2 + env(ECCUBE_MCP_LOG_RETENTION_DAYS): '90' locale: '%env(ECCUBE_LOCALE)%' timezone: '%env(ECCUBE_TIMEZONE)%' currency: '%env(ECCUBE_CURRENCY)%' @@ -28,6 +31,9 @@ services: $shoppingPurchaseFlow: '@eccube.purchase.flow.shopping' $orderPurchaseFlow: '@eccube.purchase.flow.order' $_orderStateMachine: '@state_machine.order' + # MCP サーバ用 (path prefix と Origin 許可リスト) + $eccubeAdminRoute: '%eccube_admin_route%' + $mcpAllowedOriginsCsv: '%env(ECCUBE_MCP_ALLOWED_ORIGINS)%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -215,6 +221,25 @@ services: Eccube\EventListener\RateLimiterListener: arguments: [ !tagged_locator { tag: 'eccube_rate_limiter' } ] + # MCP: Api44 (ec-cube/api44) の allow_list (`eccube.api.allow_list` タグ) を集約する + Eccube\Service\Mcp\AllowListResolver: + arguments: + $allowLists: !tagged_iterator eccube.api.allow_list + + # MCP: scope 強制層。 $inner (本物の Tool 実行器) は McpScopeEnforcementPass が構築する + Eccube\Service\Mcp\ScopeEnforcingReferenceHandler: + arguments: + $inner: '@eccube.mcp.reference_handler.inner' + + # MCP: 監査ログとして記録するため、唯一の書き手として設定します。 + Eccube\Service\Mcp\McpAuditLogger: + arguments: + $mcpLogger: '@monolog.logger.mcp' + + # MCP: 監査ログ (mcp.log) は 1 レコード 1 JSON で出力する (機械可読・設計 §4.2) + eccube.mcp.log.formatter.json: + class: Monolog\Formatter\JsonFormatter + Eccube\Security\PasswordHasher\PasswordHasher: arguments: - '%eccube_auth_magic%' diff --git a/codeception/_data/plugins/Bundle-1.0.0/DependencyInjection/Compiler/BundleCompilerPass.php b/codeception/_data/plugins/Bundle-1.0.0/DependencyInjection/Compiler/BundleCompilerPass.php index 9c033a5a4c3..bbf2b67090b 100644 --- a/codeception/_data/plugins/Bundle-1.0.0/DependencyInjection/Compiler/BundleCompilerPass.php +++ b/codeception/_data/plugins/Bundle-1.0.0/DependencyInjection/Compiler/BundleCompilerPass.php @@ -13,6 +13,7 @@ namespace Plugin\Bundle\DependencyInjection\Compiler; +use League\Bundle\OAuth2ServerBundle\EventListener\AddClientDefaultScopesListener; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -22,8 +23,8 @@ public function process(ContainerBuilder $container) { $plugins = $container->getParameter('eccube.plugins.enabled'); if (!in_array('Bundle', $plugins)) { - if ($container->hasDefinition('League\Bundle\OAuth2ServerBundle\EventListener\AddClientDefaultScopesListener')) { - $def = $container->getDefinition('League\Bundle\OAuth2ServerBundle\EventListener\AddClientDefaultScopesListener'); + if ($container->hasDefinition(AddClientDefaultScopesListener::class)) { + $def = $container->getDefinition(AddClientDefaultScopesListener::class); $def->clearTags(); } } diff --git a/codeception/_data/plugins/Bundle-1.0.1/DependencyInjection/Compiler/BundleCompilerPass.php b/codeception/_data/plugins/Bundle-1.0.1/DependencyInjection/Compiler/BundleCompilerPass.php index 9c033a5a4c3..bbf2b67090b 100644 --- a/codeception/_data/plugins/Bundle-1.0.1/DependencyInjection/Compiler/BundleCompilerPass.php +++ b/codeception/_data/plugins/Bundle-1.0.1/DependencyInjection/Compiler/BundleCompilerPass.php @@ -13,6 +13,7 @@ namespace Plugin\Bundle\DependencyInjection\Compiler; +use League\Bundle\OAuth2ServerBundle\EventListener\AddClientDefaultScopesListener; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -22,8 +23,8 @@ public function process(ContainerBuilder $container) { $plugins = $container->getParameter('eccube.plugins.enabled'); if (!in_array('Bundle', $plugins)) { - if ($container->hasDefinition('League\Bundle\OAuth2ServerBundle\EventListener\AddClientDefaultScopesListener')) { - $def = $container->getDefinition('League\Bundle\OAuth2ServerBundle\EventListener\AddClientDefaultScopesListener'); + if ($container->hasDefinition(AddClientDefaultScopesListener::class)) { + $def = $container->getDefinition(AddClientDefaultScopesListener::class); $def->clearTags(); } } diff --git a/composer.json b/composer.json index fc737569d9d..211d778733c 100644 --- a/composer.json +++ b/composer.json @@ -77,6 +77,7 @@ "symfony/lock": "^7.4", "symfony/mailer": "^7.4", "symfony/maker-bundle": "^1.0", + "symfony/mcp-bundle": "^0.9", "symfony/monolog-bridge": "^7.4", "symfony/monolog-bundle": "^3.1", "symfony/options-resolver": "^7.4", diff --git a/composer.lock b/composer.lock index ac85cf5fffc..a78a884cae3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "98f03b30b0f09f78545a7ae3f43316e9", + "content-hash": "e28841c55946abb8625d945e7b051cdd", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -3446,6 +3446,90 @@ }, "time": "2025-07-25T09:04:22+00:00" }, + { + "name": "mcp/sdk", + "version": "v0.5.0", + "source": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/php-sdk.git", + "reference": "fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8", + "reference": "fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "opis/json-schema": "^2.4", + "php": "^8.1", + "php-http/discovery": "^1.20", + "phpdocumentor/reflection-docblock": "^5.6 || ^6.0", + "psr/clock": "^1.0", + "psr/container": "^1.0 || ^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "require-dev": { + "composer/semver": "^3.0", + "ext-openssl": "*", + "firebase/php-jwt": "^6.10 || ^7.0", + "laminas/laminas-httphandlerrunner": "^2.12", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "phar-io/composer-distributor": "^1.0.2", + "php-cs-fixer/shim": "^3.91", + "phpdocumentor/shim": "^3", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "psr/simple-cache": "^2.0 || ^3.0", + "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "suggest": { + "symfony/finder": "Required for file-based discovery." + }, + "type": "library", + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "Model Context Protocol SDK for Client and Server applications in PHP", + "support": { + "issues": "https://github.com/modelcontextprotocol/php-sdk/issues", + "source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.5.0" + }, + "time": "2026-04-26T13:37:40+00:00" + }, { "name": "mobiledetect/mobiledetectlib", "version": "2.8.45", @@ -3845,6 +3929,196 @@ }, "time": "2025-12-06T11:56:16+00:00" }, + { + "name": "opis/json-schema", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" + }, + "time": "2025-10-17T12:46:48+00:00" + }, + { + "name": "opis/string", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -3964,6 +4238,261 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -4149,6 +4678,53 @@ ], "time": "2026-04-27T07:02:15+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -4509,6 +5085,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "2.0.0", @@ -8073,8 +8762,93 @@ "scaffolding" ], "support": { - "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" + "issues": "https://github.com/symfony/maker-bundle/issues", + "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-18T13:39:06+00:00" + }, + { + "name": "symfony/mcp-bundle", + "version": "v0.9.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mcp-bundle.git", + "reference": "654f639e94f4d7694771e6628380a9ff04c9d9c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/654f639e94f4d7694771e6628380a9ff04c9d9c1", + "reference": "654f639e94f4d7694771e6628380a9ff04c9d9c1", + "shasum": "" + }, + "require": { + "mcp/sdk": "^0.5", + "php-http/discovery": "^1.20", + "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", + "symfony/framework-bundle": "^7.3|^8.0", + "symfony/http-foundation": "^7.3|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/psr-http-message-bridge": "^7.3|^8.0", + "symfony/routing": "^7.3|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.53", + "symfony/monolog-bundle": "^3.10 || ^4.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\McpBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)", + "support": { + "source": "https://github.com/symfony/mcp-bundle/tree/v0.9.0" }, "funding": [ { @@ -8094,7 +8868,7 @@ "type": "tidelift" } ], - "time": "2026-03-18T13:39:06+00:00" + "time": "2026-05-15T23:41:17+00:00" }, { "name": "symfony/mime", @@ -9489,6 +10263,89 @@ ], "time": "2026-05-26T02:25:22+00:00" }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/process", "version": "v7.4.11", @@ -9725,6 +10582,94 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "76f1a57719a4a04c0ea18678a6c9305b5dcb9da8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/76f1a57719a4a04c0ea18678a6c9305b5dcb9da8", + "reference": "76f1a57719a4a04c0ea18678a6c9305b5dcb9da8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/rate-limiter", "version": "v7.4.7", @@ -11071,6 +12016,84 @@ ], "time": "2026-04-22T15:21:55+00:00" }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, { "name": "symfony/validator", "version": "v7.4.7", @@ -11975,6 +12998,72 @@ } ], "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.0" + }, + "time": "2026-05-20T13:07:01+00:00" } ], "packages-dev": [ diff --git a/docs/mcp/scope-denied-response.md b/docs/mcp/scope-denied-response.md new file mode 100644 index 00000000000..18085b07980 --- /dev/null +++ b/docs/mcp/scope-denied-response.md @@ -0,0 +1,105 @@ +# MCP scope 拒否レスポンスの仕様 (補足) + +`ISSUE_mcp_server_final_design.md` §4.3 の補足。 設計時に想定した「HTTP 403 + `insufficient_scope`」を採用しなかった経緯と、 実装で採用した代替仕様の根拠をまとめる。 + +## 結論 + +scope 不足時の応答は **HTTP 200 + JSON-RPC `result.isError = true` + `content[0].text` に scope 不足メッセージ** を返す。 HTTP 403 化はしない (できない)。 + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + {"type": "text", "text": "Insufficient scope: mcp:order:read"} + ], + "isError": true + } +} +``` + +## なぜ HTTP 403 化を諦めたか + +`symfony/mcp-bundle` (内部で `mcp/sdk`) の `CallToolHandler::handle()` が **Tool 呼び出し中の全例外を catch** し、 JSON-RPC レスポンスに変換してから HTTP 層に渡す。 該当ソース: + +```php +// vendor/mcp/sdk/src/Server/Handler/Request/CallToolHandler.php +try { + $result = $this->referenceHandler->handle($reference, $arguments); + // ... 成功時 + return new Response($request->getId(), $result); +} catch (ToolCallException $e) { // ← (1) 専用パス + $errorContent = [new TextContent($e->getMessage())]; + return new Response($request->getId(), CallToolResult::error($errorContent)); +} catch (\Throwable $e) { // ← (2) フォールバック + return Error::forInternalError('Error while executing tool', $request->getId()); +} +``` + +つまり Tool が何の例外を投げても **`kernel.exception` には届かず**、 HTTP は常に 200 で返る。 `kernel.exception` listener で 403 化する案は構造上不可能。 + +回避策の選択肢と却下理由: + +| 案 | 内容 | 却下理由 | +|---|---|---| +| A. `kernel.response` listener で書き換え | JSON-RPC ボディを覗いて isError=true を見つけたら 403 に差し替える | レスポンスが SSE (`text/event-stream`) の場合に成立しない。 mcp-bundle の HTTP transport は streamable HTTP で SSE 経由のパスがあり、 single response で完結しない応答も含む | +| B. mcp-bundle を fork | `CallToolHandler` の catch を override | ライブラリのアップグレード追従コストが永続的に乗る。 採用しない | +| C. mcp-bundle の controller を完全自前化 | `/admin/mcp` の controller を自作し、 Tool 呼び出し前に scope を弾く | MCP プロトコル (initialize / notifications / tools/list / tools/call / SSE) を全て自分で実装することになる。 過大 | + +選択肢 (1)「**`ToolCallException` を投げる**」は mcp-bundle が用意している正式な拒否経路。 これに乗せるのが筋。 + +## 実装 + +`Eccube\Service\Mcp\ScopeChecker::require()` で `Mcp\Exception\ToolCallException` を投げる: + +```php +public function require(string $role): void +{ + if (!$this->authorizationChecker->isGranted($role)) { + throw new ToolCallException(sprintf('Insufficient scope: %s', $this->roleToScope($role))); + } +} +``` + +- role → scope 名変換 (`ROLE_OAUTH2_MCP:ORDER:READ` → `mcp:order:read`) を行い、 OAuth2 仕様のキーで LLM 側に伝える。 +- `ToolInvoker` は `ToolCallException` を専用 catch し、 監査ログに `AuditResult::ScopeDenied` を記録してから再 throw する。 mcp-bundle の専用 catch (1) に乗る。 + +## LLM クライアントから見える挙動 + +- HTTP は 200 OK で返る (LLM クライアントは「ネットワーク的には成功」と認識)。 +- ただし `result.isError = true` を見て「Tool は失敗した」と理解できる。 +- `content[0].text` に「Insufficient scope: mcp:order:read」とあるため、 どの scope が不足しているかを読める。 +- LLM 側は: (a) 別 token (該当 scope を持つもの) で再試行する、 (b) ユーザーに「この scope が必要」と提示する、 のいずれかが可能。 + +## 設計 AC との差分 + +| 項目 | 設計 §4.3 当初案 | 実装 | +|---|---|---| +| HTTP ステータス | 403 | 200 | +| ボディの error key | `error: "insufficient_scope"` | `content[0].text: "Insufficient scope: "` | +| 必要 scope の伝達 | `required_scope: "mcp:order:read"` | テキスト中の `` 部分 | +| LLM 側の判別容易性 | HTTP 層で判定可 | `result.isError === true` で判定可 | + +意味論は等価。 HTTP ステータスでの判別ができない代わりに、 mcp-bundle のプロトコル準拠な拒否経路に乗っている。 + +## 受入基準テストの形 + +```bash +# scope を絞った token で scope 外 Tool を叩く +CREDENTIALS_FILE=/tmp/mcp-product-only-creds bash /tmp/mcp-oauth-test.sh search_orders +# 期待: +# HTTP 200 +# JSON: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Insufficient scope: mcp:order:read"}],"isError":true}} +``` + +PHPUnit 側は `expectException(Mcp\Exception\ToolCallException::class)` で確認する (11 Tool 全てに `testThrowsWhenScopeIsAbsent` 系を配置済み)。 + +## 認証エラー / Origin / Content-Type との関係 + +scope 拒否だけは Tool 呼び出し中に発生するため上記の制約に従う。 一方: + +- **認証エラー (Bearer 不正 / 期限切れ)**: Api44 OAuth2 リソースサーバが firewall 層で処理 → **HTTP 401**。 mcp-bundle に到達しないので影響なし。 +- **Origin 違反 / Content-Type 違反**: `OriginContentTypeListener` が `kernel.request` priority 16 で発火 → mcp-bundle / firewall 到達前に **HTTP 403 / 415** を返す。 + +つまり、 「HTTP 層で弾けるもの」 は HTTP 層で弾き、 「Tool 呼び出し中にしか判定できない scope 拒否」 だけが JSON-RPC level の応答になる。 結果として、 「Tool が呼ばれた = 認証も Origin も Content-Type も通った」という不変条件は壊れていない。 diff --git a/rector.php b/rector.php index 09ed62892ae..5c3140db897 100644 --- a/rector.php +++ b/rector.php @@ -69,6 +69,12 @@ // 8.3以上で対応可能 AddTypeToConstRector::class, // [BC]定数に型を追加する PHP 8.3 以降で有効 RenameMethodRector::class, //addがaddCommandに変換されてしまうため一旦スキップ + // 文字列 service ID (private / チャネル別ロガー等) を FQCN に変換すると解決できないテスト + // ('security.firewall.map'、 'monolog.logger.mcp' は型ではなく ID で取得する必要がある) + \Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class => [ + __DIR__.'/tests/Eccube/Tests/Service/Mcp/Contract/Api44LifecycleContractTest.php', + __DIR__.'/tests/Eccube/Tests/Service/Mcp/Contract/McpAuditLogIsolationContractTest.php', + ], ]) // 個別にルールを追加する場合はここに記述 ->withRules([ diff --git a/src/Eccube/DependencyInjection/Compiler/McpAuditLoggerChannelLockPass.php b/src/Eccube/DependencyInjection/Compiler/McpAuditLoggerChannelLockPass.php new file mode 100644 index 00000000000..31920c09556 --- /dev/null +++ b/src/Eccube/DependencyInjection/Compiler/McpAuditLoggerChannelLockPass.php @@ -0,0 +1,71 @@ +hasDefinition(self::MCP_LOGGER_SERVICE_ID) && !$container->has(self::MCP_LOGGER_SERVICE_ID)) { + return; + } + + // チャンネルがあるのに想定 id の alias が無い = 命名規約変更等で本 pass が空振りした証拠。 + // 黙って通すと監査チャンネルが誰でも書ける状態に戻るため、 build を止めて気付かせる (fail loud)。 + if (!$container->hasAlias(self::MCP_LOGGER_AUTOWIRE_ALIAS_ID)) { + throw new \LogicException(sprintf('MCP 監査ログ保護に失敗しました: autowire alias "%s" が見つかりません。 monolog のバージョン/命名規約変更の可能性があるため、 %s の alias id を見直してください。', self::MCP_LOGGER_AUTOWIRE_ALIAS_ID, self::class)); + } + + $container->removeAlias(self::MCP_LOGGER_AUTOWIRE_ALIAS_ID); + + if ($container->hasAlias(self::MCP_LOGGER_INTERNAL_ALIAS_ID)) { + $container->removeAlias(self::MCP_LOGGER_INTERNAL_ALIAS_ID); + } + } +} diff --git a/src/Eccube/DependencyInjection/Compiler/McpScopeEnforcementPass.php b/src/Eccube/DependencyInjection/Compiler/McpScopeEnforcementPass.php new file mode 100644 index 00000000000..08a66a6400d --- /dev/null +++ b/src/Eccube/DependencyInjection/Compiler/McpScopeEnforcementPass.php @@ -0,0 +1,84 @@ +setContainer に渡す本家 pass + */ +final class McpScopeEnforcementPass implements CompilerPassInterface +{ + /** 本物の Tool 実行器 (ScopeEnforcingReferenceHandler の委譲先) のサービス ID。 */ + public const INNER_REFERENCE_HANDLER_ID = 'eccube.mcp.reference_handler.inner'; + + #[\Override] + public function process(ContainerBuilder $container): void + { + // 本物の Tool 実行器に渡す Tool ServiceLocator を決める。 + // mcp-bundle が builder->setContainer に渡したロケータをそのまま再利用し、 集合の乖離を無くす。 + // (builder 不在 = mcp-bundle 未導入時は mcp.tool タグから自前収集する安全フォールバック) + $toolLocator = $this->resolveToolLocator($container); + + $container->setDefinition( + self::INNER_REFERENCE_HANDLER_ID, + (new Definition(ReferenceHandler::class))->setArguments([$toolLocator]), + ); + + // 全 Tool 呼び出しが通る referenceHandler を scope 強制版に差し替える。 + if ($container->hasDefinition('mcp.server.builder')) { + $container->getDefinition('mcp.server.builder') + ->addMethodCall('setReferenceHandler', [new Reference(ScopeEnforcingReferenceHandler::class)]); + } + } + + /** + * builder の `setContainer(...)` 呼び出し引数 (= McpPass が組んだ Tool ServiceLocator) を取り出して返す。 + * 見つからなければ `mcp.tool` タグから自前で同等のロケータを構築する。 + */ + private function resolveToolLocator(ContainerBuilder $container): Reference + { + if ($container->hasDefinition('mcp.server.builder')) { + foreach ($container->getDefinition('mcp.server.builder')->getMethodCalls() as [$method, $arguments]) { + if ('setContainer' === $method && isset($arguments[0]) && $arguments[0] instanceof Reference) { + return $arguments[0]; + } + } + } + + $serviceReferences = []; + foreach (array_keys($container->findTaggedServiceIds('mcp.tool')) as $serviceId) { + $serviceReferences[$serviceId] = new Reference($serviceId); + } + + return ServiceLocatorTagPass::register($container, $serviceReferences); + } +} diff --git a/src/Eccube/EventListener/Mcp/AuthFailureAuditListener.php b/src/Eccube/EventListener/Mcp/AuthFailureAuditListener.php new file mode 100644 index 00000000000..cd54f481e1a --- /dev/null +++ b/src/Eccube/EventListener/Mcp/AuthFailureAuditListener.php @@ -0,0 +1,91 @@ +/mcp` の認証失敗 (401) を監査ログ (mcp.log) に残す。 + * + * scope 拒否やレート制限と並び、 認証失敗も MCP 境界で起きたイベントとして client / IP 粒度で記録する。 + * 401 応答自体は api44 の OAuth2 resource server / firewall が生成するため、 ここでは記録のみを行う。 + * + * 認証失敗は `LoginFailureEvent` だけでは拾い切れない (トークン未送信の 401 は authenticator が起動せず + * イベントが発火しない)。 そのため mcp パスの **401 レスポンスそのもの** を `kernel.response` で拾う。 + * mcp パスで 401 を返すのは認証失敗のみ (scope 拒否は 200、 レート制限は 429/503、 Origin/CT 違反は 403/415)。 + */ +final readonly class AuthFailureAuditListener implements EventSubscriberInterface +{ + private string $mcpPathPrefix; + + public function __construct( + string $eccubeAdminRoute, + private McpAuditLogger $auditLogger, + private LoggerInterface $logger, + ) { + $this->mcpPathPrefix = '/'.$eccubeAdminRoute.'/mcp'; + } + + /** + * @return array + */ + #[\Override] + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::RESPONSE => 'onKernelResponse', + ]; + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + if (!str_starts_with($event->getRequest()->getPathInfo(), $this->mcpPathPrefix)) { + return; + } + if (Response::HTTP_UNAUTHORIZED !== $event->getResponse()->getStatusCode()) { + return; + } + + $this->safeAudit($event->getResponse()); + } + + /** + * 監査ログ書き込みが失敗しても 401 応答を壊さないよう、 例外は default チャネルに記録して握り潰す。 + */ + private function safeAudit(Response $response): void + { + try { + // client_id は best-effort。 401 時点でトークンは無効なため取得できず IP のみで記録する + // (IP は McpAuditLogger が常に付与する)。 reason は WWW-Authenticate ヘッダ (PII を含まない)。 + $this->auditLogger->logAuthEvent( + AuditResult::TokenInvalid, + null, + ['reason' => $response->headers->get('WWW-Authenticate') ?? 'unauthorized'], + ); + } catch (\Throwable $e) { + $this->logger->error('mcp 認証失敗の監査ログ書き込みに失敗', ['exception' => $e]); + } + } +} diff --git a/src/Eccube/EventListener/Mcp/OriginContentTypeListener.php b/src/Eccube/EventListener/Mcp/OriginContentTypeListener.php new file mode 100644 index 00000000000..ca0947d02fc --- /dev/null +++ b/src/Eccube/EventListener/Mcp/OriginContentTypeListener.php @@ -0,0 +1,148 @@ +/mcp` 配下のリクエストに対して Origin / Content-Type の前段ガードを行う。 + * + * - `Content-Type` が `application/json` で始まらない POST/PUT/DELETE 等は **415** で拒否 + * - `ECCUBE_MCP_ALLOWED_ORIGINS` が設定されている時、`Origin` ヘッダがその許可リストに無ければ **403** + * - GET / HEAD / OPTIONS は対象外 (Content-Type を伴わない、 CORS preflight も含むため) + * + * 拒否時のレスポンスは JSON 一本 (MCP クライアント前提)。 違反は `McpAuditLogger` に + * `origin_invalid` として warning で記録する。 リスナの priority は admin firewall (priority 8) + * より早い 16 で実行し、 認証層に到達する前にブロックする。 + */ +final readonly class OriginContentTypeListener implements EventSubscriberInterface +{ + /** @var list */ + private array $allowedOrigins; + + private string $mcpPathPrefix; + + public function __construct( + string $eccubeAdminRoute, + string $mcpAllowedOriginsCsv, + private McpAuditLogger $auditLogger, + ) { + $this->mcpPathPrefix = '/'.$eccubeAdminRoute.'/mcp'; + $this->allowedOrigins = array_values(array_filter( + array_map(trim(...), explode(',', $mcpAllowedOriginsCsv)), + static fn (string $v): bool => '' !== $v, + )); + } + + /** + * @return array + */ + #[\Override] + public static function getSubscribedEvents(): array + { + // admin firewall (priority 8) より前に動かす。 認証前にブロックすることでログ汚染や + // 不要な認証処理を避ける。 + return [ + KernelEvents::REQUEST => ['onKernelRequest', 16], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + if (!str_starts_with($request->getPathInfo(), $this->mcpPathPrefix)) { + return; + } + + // GET / HEAD / OPTIONS はリクエストボディを持たない (MCP 仕様: GET は SSE 予約、 OPTIONS は preflight)。 + if (\in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'], true)) { + return; + } + + $contentTypeRejection = $this->checkContentType($request->headers->get('Content-Type')); + if (null !== $contentTypeRejection) { + $this->auditLogger->logSecurityEvent(AuditResult::OriginInvalid, [ + 'path' => $request->getPathInfo(), + 'method' => $request->getMethod(), + 'content_type' => $request->headers->get('Content-Type'), + 'reason' => 'invalid_content_type', + ]); + $event->setResponse($contentTypeRejection); + + return; + } + + if ([] === $this->allowedOrigins) { + // 許可リスト未設定 = 開発時用に Origin 検証 skip + return; + } + + $originRejection = $this->checkOrigin($request->headers->get('Origin')); + if (null !== $originRejection) { + $this->auditLogger->logSecurityEvent(AuditResult::OriginInvalid, [ + 'path' => $request->getPathInfo(), + 'origin' => $request->headers->get('Origin'), + 'allowed' => $this->allowedOrigins, + 'reason' => 'origin_not_allowed', + ]); + $event->setResponse($originRejection); + } + } + + /** + * `Content-Type: application/json` で始まらなければ 415 を返す Response を生成する。 + */ + private function checkContentType(?string $contentType): ?Response + { + if (null !== $contentType && str_starts_with(strtolower($contentType), 'application/json')) { + return null; + } + + return new JsonResponse( + ['error' => 'unsupported_media_type', 'message' => 'Content-Type must be application/json.'], + Response::HTTP_UNSUPPORTED_MEDIA_TYPE, + ); + } + + /** + * Origin ヘッダが許可リストにあるか確認する。 Origin が無いリクエスト (curl 等) は許容。 + */ + private function checkOrigin(?string $origin): ?Response + { + if (null === $origin) { + // ブラウザ以外 (curl 等) は Origin を送らないため通す + return null; + } + if (\in_array($origin, $this->allowedOrigins, true)) { + return null; + } + + return new JsonResponse( + ['error' => 'forbidden', 'message' => 'Origin not allowed.'], + Response::HTTP_FORBIDDEN, + ); + } +} diff --git a/src/Eccube/EventListener/Mcp/RateLimitListener.php b/src/Eccube/EventListener/Mcp/RateLimitListener.php new file mode 100644 index 00000000000..5bbf4a2b506 --- /dev/null +++ b/src/Eccube/EventListener/Mcp/RateLimitListener.php @@ -0,0 +1,214 @@ +/mcp` 配下のリクエストに 2 段のレート制限を適用する (設計 §5 「Rate Limiter 連携」)。 + * + * - **IP 単位 (mcp_ip limiter)**: `kernel.request` priority 14 で発火。 admin firewall (priority 8) + * より早く動くため、 認証エラー連発攻撃でも IP 単位の枠を消費させて DoS を抑制できる。 + * - **client_id 単位 (mcp_client limiter)**: `kernel.controller` priority 0 で発火。 firewall を + * 通過して OAuth2 認証済みトークンが `TokenStorage` に乗った後にのみ消費する。 認証済みクライアントを + * 識別して、 IP 制限より細かい (= 通常は緩い) 制限を適用する。 + * + * 超過時の挙動は両者共通で: + * - HTTP 429 + `{"error":"rate_limited","retry_after_seconds":}` を返す + * - `Retry-After: ` と `X-RateLimit-Remaining: 0` ヘッダを付与 + * - `McpAuditLogger::logSecurityEvent(AuditResult::RateLimited)` で監査ログに記録 + * + * `kernel.controller` イベントは `setResponse()` を持たないため、 controller を「429 を返す + * callable」に差し替えることでレスポンスを返す。 引数解決等の後続処理は controller の戻り値が + * Response なのでスキップされる。 + */ +final readonly class RateLimitListener implements EventSubscriberInterface +{ + private string $mcpPathPrefix; + + public function __construct( + string $eccubeAdminRoute, + private RateLimiterFactory $mcpIpLimiter, + private RateLimiterFactory $mcpClientLimiter, + private TokenStorageInterface $tokenStorage, + private McpAuditLogger $auditLogger, + private LoggerInterface $logger, + ) { + $this->mcpPathPrefix = '/'.$eccubeAdminRoute.'/mcp'; + } + + /** + * @return array + */ + #[\Override] + public static function getSubscribedEvents(): array + { + return [ + // Origin/CT ガード (priority 16) の後、 admin firewall (priority 8) の前で IP 制限 + KernelEvents::REQUEST => ['onKernelRequest', 14], + // firewall 通過後、 引数解決前のタイミングで client_id 制限 + KernelEvents::CONTROLLER => ['onKernelController', 0], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + if (!str_starts_with($request->getPathInfo(), $this->mcpPathPrefix)) { + return; + } + + $ip = $request->getClientIp() ?? 'unknown'; + $rejection = $this->check($this->mcpIpLimiter, 'mcp:ip:'.$ip, 'ip', ['ip' => $ip]); + if (null !== $rejection) { + $event->setResponse($rejection); + } + } + + public function onKernelController(ControllerEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + if (!str_starts_with($request->getPathInfo(), $this->mcpPathPrefix)) { + return; + } + + $token = $this->tokenStorage->getToken(); + // OAuth2 認証済みトークン (Api44 由来) のみ client_id 単位の制限を掛ける。 本体は Api44/league + // に依存しないため、 具象クラスではなく client_id を返すメソッドの有無で判定する。 未認証経路や + // admin Cookie firewall のトークンは持たないためスキップされ、 認証エラーは firewall が 401 を返す。 + if (null === $token || !method_exists($token, 'getOAuthClientId')) { + return; + } + + $clientId = (string) $token->getOAuthClientId(); + $rejection = $this->check($this->mcpClientLimiter, 'mcp:client:'.$clientId, 'client_id', ['client_id' => $clientId]); + if (null !== $rejection) { + // ControllerEvent は setResponse を持たないため、 controller を「拒否レスポンスを返す callable」 に差し替える + $event->setController(static fn (): Response => $rejection); + } + } + + /** + * レート制限を 1 回消費し、 通してよければ null、 拒否なら送るべき Response を返す。 + * + * **fail-closed**: cache (カウンタ保存先) 障害等で `consume()` が例外を投げた場合、 「数えられない= + * 通さない」 に倒し、 503 を返す (監査エンドポイントなので、 制限を強制できないなら供給しない方針)。 + * なお例外を投げず黙ってカウンタを失う劣化 (例: Redis ダウン時に miss 扱い) は信号が無く本層では + * 検知できない。 その場合は cache アダプタ側のエラーログで気付く想定。 + * + * @param array $auditContext 監査ログに添える識別情報 (ip / client_id) + */ + private function check(RateLimiterFactory $limiter, string $key, string $kind, array $auditContext): ?Response + { + try { + $limit = $limiter->create($key)->consume(); + } catch (\Throwable $e) { + $this->safeAudit(AuditResult::InternalError, [ + 'kind' => $kind, + 'reason' => 'rate_limiter_unavailable', + 'message' => $e->getMessage(), + ...$auditContext, + ]); + + return new JsonResponse( + ['error' => 'rate_limiter_unavailable'], + Response::HTTP_SERVICE_UNAVAILABLE, + ['Retry-After' => '60'], + ); + } + + if ($limit->isAccepted()) { + return null; + } + + $this->safeAudit(AuditResult::RateLimited, [ + 'kind' => $kind, + 'retry_after_seconds' => $this->retryAfterSeconds($limit), + ...$auditContext, + ]); + + return $this->buildRateLimitedResponse($limit); + } + + /** + * 拒否/エラーレスポンス (429 / 503) の返却を、 監査ログ書き込みの失敗で崩さないための保護。 + * + * 監査が失敗しても応答は守るが、 完全沈黙はしない。 mcp 監査チャンネルの障害が不可視にならないよう、 + * フォールバック先 (default チャンネル) へ失敗を 1 行残してから握り潰す。 + * + * @param array $context + */ + private function safeAudit(AuditResult $result, array $context): void + { + try { + $this->auditLogger->logSecurityEvent($result, $context); + } catch (\Throwable $e) { + $this->logger->error('mcp 監査ログの書き込みに失敗 (拒否レスポンスは維持)', [ + 'result' => $result->value, + 'exception' => $e, + ]); + } + } + + private function buildRateLimitedResponse(RateLimit $limit): JsonResponse + { + $retryAfter = $this->retryAfterSeconds($limit); + + return new JsonResponse( + [ + 'error' => 'rate_limited', + 'retry_after_seconds' => $retryAfter, + ], + Response::HTTP_TOO_MANY_REQUESTS, + [ + 'Retry-After' => (string) $retryAfter, + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Limit' => (string) $limit->getLimit(), + ], + ); + } + + /** + * `RateLimit::getRetryAfter()` の DateTimeImmutable から現在時刻までの秒数を求める。 + * 0 以下は 1 に丸める (`Retry-After: 0` は仕様上避ける)。 + */ + private function retryAfterSeconds(RateLimit $limit): int + { + $now = new \DateTimeImmutable(); + $diff = $limit->getRetryAfter()->getTimestamp() - $now->getTimestamp(); + + return max(1, $diff); + } +} diff --git a/src/Eccube/Kernel.php b/src/Eccube/Kernel.php index 646da72a203..7ec6ac13293 100644 --- a/src/Eccube/Kernel.php +++ b/src/Eccube/Kernel.php @@ -17,6 +17,8 @@ use Eccube\Common\EccubeNav; use Eccube\Common\EccubeTwigBlock; use Eccube\DependencyInjection\Compiler\AutoConfigurationTagPass; +use Eccube\DependencyInjection\Compiler\McpAuditLoggerChannelLockPass; +use Eccube\DependencyInjection\Compiler\McpScopeEnforcementPass; use Eccube\DependencyInjection\Compiler\NavCompilerPass; use Eccube\DependencyInjection\Compiler\PaymentMethodPass; use Eccube\DependencyInjection\Compiler\PluginPass; @@ -286,6 +288,14 @@ protected function build(ContainerBuilder $container): void $container->addCompilerPass(new PurchaseFlowPass()); // StripReportFieldsArgPass は DoctrineOrmMappingsPass の後に実行する必要があるため、優先度を-1000に設定 $container->addCompilerPass(new StripReportFieldsArgPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); + + // MCP: 全 Tool 呼び出しの手前で scope を強制する referenceHandler を mcp-bundle の builder に差し込む。 + // mcp-bundle の McpPass (優先度 0、 builder->setContainer を組む) の後に走らせるため負の優先度で登録する。 + $container->addCompilerPass(new McpScopeEnforcementPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); + + // MCP: 監査ログ (mcp チャンネル) の autowire alias を削除し、 書き手を McpAuditLogger に縛る。 + // monolog の LoggerChannelPass (優先度 0) が alias を作った後に走らせるため負の優先度で登録する。 + $container->addCompilerPass(new McpAuditLoggerChannelLockPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); } protected function addEntityExtensionPass(ContainerBuilder $container): void diff --git a/src/Eccube/Service/Mcp/AllowListResolver.php b/src/Eccube/Service/Mcp/AllowListResolver.php new file mode 100644 index 00000000000..b1161382463 --- /dev/null +++ b/src/Eccube/Service/Mcp/AllowListResolver.php @@ -0,0 +1,116 @@ +> + */ + private array $merged = []; + + /** + * @param iterable $allowLists `eccube.api.allow_list` タグ付きサービスの一覧 + */ + public function __construct(iterable $allowLists) + { + foreach ($allowLists as $allowList) { + $this->mergeFrom($allowList); + } + } + + /** + * 指定 Entity に対して公開可能なプロパティ名の一覧を返す。 + * + * Api44 が未配線 / 当該 Entity が allow_list に登録されていない場合は空配列を返す。 + * + * @return list + */ + public function getAllowedProperties(string $entityFqcn): array + { + return $this->merged[$entityFqcn] ?? []; + } + + /** + * 単一プロパティが公開可能かを判定する。 + */ + public function isAllowed(string $entityFqcn, string $propertyName): bool + { + return \in_array($propertyName, $this->getAllowedProperties($entityFqcn), true); + } + + /** + * allow_list 1 件分を `$merged` に追記する。 同一 FQCN は集約 (union)。 + */ + private function mergeFrom(object $allowList): void + { + $raw = $this->extractAllows($allowList); + foreach ($raw as $fqcn => $props) { + if (!\is_string($fqcn) || !\is_array($props)) { + continue; + } + $existing = $this->merged[$fqcn] ?? []; + /** @var list $merged */ + $merged = array_values(array_unique([...$existing, ...array_filter($props, is_string(...))])); + $this->merged[$fqcn] = $merged; + } + } + + /** + * Api44 の `Plugin\Api44\GraphQL\AllowList::$allows` (private) を抽出する。 + * + * 元のサービス定義 (services.yaml) では `ArrayObject` を引数 1 個で受けて生成され、 + * Api44 の Compiler Pass がクラスを `AllowList` に書き換える。 取り出した値は + * 通常 `array` だが、`ArrayObject` のままになる経路にも備える。 + * + * 戻り値の型は意図的に `array` まで緩め、 値側の型検証は `mergeFrom()` + * に委ねる (allow_list は外部設定由来でデータ形が必ずしも保証されないため)。 + * + * @return array + */ + private function extractAllows(object $allowList): array + { + $reflection = new \ReflectionObject($allowList); + if (!$reflection->hasProperty('allows')) { + return []; + } + $prop = $reflection->getProperty('allows'); + $value = $prop->getValue($allowList); + + if (\is_array($value)) { + return $value; + } + if ($value instanceof \ArrayObject) { + return $value->getArrayCopy(); + } + + return []; + } +} diff --git a/src/Eccube/Service/Mcp/AuditResult.php b/src/Eccube/Service/Mcp/AuditResult.php new file mode 100644 index 00000000000..86f1203b135 --- /dev/null +++ b/src/Eccube/Service/Mcp/AuditResult.php @@ -0,0 +1,33 @@ +` に変換する。 + * + * @return array + */ + public function toArray(object $entity, int $maxDepth = self::DEFAULT_MAX_DEPTH): array + { + $visited = []; + + return $this->convertEntity($entity, 0, $maxDepth, $visited); + } + + /** + * 複数 Entity をまとめて変換する。 + * + * @param iterable $entities + * + * @return list> + */ + public function toArrayList(iterable $entities, int $maxDepth = self::DEFAULT_MAX_DEPTH): array + { + $result = []; + foreach ($entities as $entity) { + $result[] = $this->toArray($entity, $maxDepth); + } + + return $result; + } + + /** + * @param array $visited spl_object_id をキーにした訪問済みセット + * + * @return array + */ + private function convertEntity(object $entity, int $depth, int $maxDepth, array &$visited): array + { + $oid = spl_object_id($entity); + if (isset($visited[$oid])) { + // 循環: 要約のみ返す。 深さ判定より優先する。 + return $this->summarize($entity); + } + $visited[$oid] = true; + + $allowedProps = $this->allowListResolver->getAllowedProperties($this->resolveEntityClass($entity)); + $result = []; + foreach ($allowedProps as $prop) { + $value = $this->readProperty($entity, $prop); + $result[$prop] = $this->convertValue($value, $depth + 1, $maxDepth, $visited); + } + + unset($visited[$oid]); + + return $result; + } + + /** + * @param array $visited + */ + private function convertValue(mixed $value, int $depth, int $maxDepth, array &$visited): mixed + { + if (null === $value || \is_scalar($value)) { + return $value; + } + if ($value instanceof \DateTimeInterface) { + return $value->format(\DateTimeInterface::ATOM); + } + if ($value instanceof Collection || (\is_iterable($value) && !\is_array($value))) { + if ($depth > $maxDepth) { + return $this->summarizeMany($value); + } + $items = []; + foreach ($value as $element) { + $items[] = $this->convertValue($element, $depth, $maxDepth, $visited); + } + + return $items; + } + if (\is_array($value)) { + $items = []; + foreach ($value as $k => $v) { + $items[$k] = $this->convertValue($v, $depth, $maxDepth, $visited); + } + + return $items; + } + if (\is_object($value)) { + if ($depth > $maxDepth) { + return $this->summarize($value); + } + + return $this->convertEntity($value, $depth, $maxDepth, $visited); + } + + return null; + } + + /** + * 深さ超過 / 循環時の要約: `id` を出せれば出す、それだけ。 + * + * @return array + */ + private function summarize(object $entity): array + { + // 要約経路でも「allow_list のみ公開」を崩さない。 id が許可されていない関連 Entity の + // 内部 ID を、 深さ超過 / 循環の縮退をすり抜けて露出させないようにする。 + if ( + $this->allowListResolver->isAllowed($this->resolveEntityClass($entity), 'id') + && method_exists($entity, 'getId') + ) { + try { + $id = $entity->getId(); + if (null !== $id) { + return ['id' => $id]; + } + } catch (\Throwable) { + // 何らかの理由で id を取れない場合は空要約。 + } + } + + return []; + } + + /** + * @param iterable $collection + * + * @return list> + */ + private function summarizeMany(iterable $collection): array + { + $items = []; + foreach ($collection as $element) { + if (\is_object($element)) { + $items[] = $this->summarize($element); + } + } + + return $items; + } + + /** + * `name01` → `getName01()`、 `order_no` → `getOrderNo()`、 `OrderItems` → `getOrderItems()` の規則で値を取得する。 + * + * getter が見つからない場合は `is*` を試し、 最後に Reflection でプロパティを直接読む。 + */ + private function readProperty(object $entity, string $propertyName): mixed + { + foreach ([$this->buildAccessor('get', $propertyName), $this->buildAccessor('is', $propertyName)] as $method) { + if (method_exists($entity, $method) && \is_callable([$entity, $method])) { + try { + return $entity->{$method}(); + } catch (\Throwable) { + return null; + } + } + } + + try { + $reflection = new \ReflectionObject($entity); + if ($reflection->hasProperty($propertyName)) { + $prop = $reflection->getProperty($propertyName); + if ($prop->isInitialized($entity)) { + return $prop->getValue($entity); + } + } + } catch (\Throwable) { + // ignore + } + + return null; + } + + private function buildAccessor(string $prefix, string $propertyName): string + { + $parts = explode('_', $propertyName); + + return $prefix.implode('', array_map(ucfirst(...), $parts)); + } + + /** + * Doctrine Lazy Proxy が渡された場合に実 entity の class 名を返す。 + * + * Proxy インスタンスの `::class` は `Proxies\__CG__\Eccube\Entity\...` 等の自動生成 class 名で、 + * allow_list (実 entity FQCN で登録) と一致せず lookup が外れる。 `Doctrine\Persistence\Proxy` + * インタフェース実装オブジェクトは親クラスが実 entity なので、 そちらを採用する。 + */ + private function resolveEntityClass(object $entity): string + { + if ($entity instanceof Proxy) { + $parent = get_parent_class($entity); + if (false !== $parent) { + return $parent; + } + } + + return $entity::class; + } +} diff --git a/src/Eccube/Service/Mcp/McpAuditLogger.php b/src/Eccube/Service/Mcp/McpAuditLogger.php new file mode 100644 index 00000000000..bbe26b61d87 --- /dev/null +++ b/src/Eccube/Service/Mcp/McpAuditLogger.php @@ -0,0 +1,150 @@ + $args Tool に渡された引数 (個人情報を含み得る点に注意) + * @param array|null $resultSummary 結果の要約 (件数等)。 結果本体は記録しない + */ + public function logToolCall( + string $toolName, + array $args, + AuditResult $result, + float $durationMs, + ?array $resultSummary = null, + ?string $clientId = null, + ?int $memberId = null, + ): void { + $this->emit( + level: $this->levelFor($result), + message: 'mcp.tool.'.$toolName, + context: [ + 'tool_name' => $toolName, + 'tool_args' => $args, + 'result_status' => $result->value, + 'result_summary' => $resultSummary, + 'duration_ms' => $durationMs, + 'client_id' => $clientId, + 'member_id' => $memberId, + ], + ); + } + + /** + * 認証成否を記録する (Api44 の `kernel.exception` / `security.authentication.success` + * 等のイベントを拾う listener から呼ぶ想定)。 + * + * @param array $context + */ + public function logAuthEvent( + AuditResult $result, + ?string $clientId = null, + array $context = [], + ): void { + $this->emit( + level: $this->levelFor($result), + message: 'mcp.auth.'.$result->value, + context: ['result_status' => $result->value, 'client_id' => $clientId] + $context, + ); + } + + /** + * Origin / Content-Type ガード違反、 Rate Limit 超過などのセキュリティ事象を記録する。 + * + * @param array $context + */ + public function logSecurityEvent(AuditResult $result, array $context = []): void + { + $this->emit( + level: $this->levelFor($result), + message: 'mcp.security.'.$result->value, + context: ['result_status' => $result->value] + $context, + ); + } + + /** + * 監査ログのレベルを `AuditResult` から決める。 設計 §3.3 / §4.2 と一致。 + */ + private function levelFor(AuditResult $result): string + { + return match ($result) { + AuditResult::Success => 'info', + // error はサーバ障害のみ。 認証失敗 (TokenInvalid) や拒否系はクライアント都合なので warning + AuditResult::InternalError => 'error', + default => 'warning', + }; + } + + /** + * @param array $context + */ + private function emit(string $level, string $message, array $context): void + { + $request = $this->requestStack->getCurrentRequest(); + $base = [ + 'request_id' => $this->resolveRequestId($request), + 'client_ip' => $request?->getClientIp(), + ]; + + $this->mcpLogger->log($level, $message, [...$base, ...$context]); + } + + /** + * リクエストに `mcp_request_id` がなければ ULID を割り当てる (同一リクエスト内のログを束ねる)。 + */ + private function resolveRequestId(?Request $request): string + { + if (null === $request) { + return (new Ulid())->toBase32(); + } + + $existing = $request->attributes->get(self::REQUEST_ID_ATTRIBUTE); + if (\is_string($existing) && '' !== $existing) { + return $existing; + } + + $generated = (new Ulid())->toBase32(); + $request->attributes->set(self::REQUEST_ID_ATTRIBUTE, $generated); + + return $generated; + } +} diff --git a/src/Eccube/Service/Mcp/McpScope.php b/src/Eccube/Service/Mcp/McpScope.php new file mode 100644 index 00000000000..407e9ec15f2 --- /dev/null +++ b/src/Eccube/Service/Mcp/McpScope.php @@ -0,0 +1,40 @@ + + */ + public const MAP = [ + // 商品/在庫 (mcp:product:read) + 'search_products' => McpScope::ROLE_PRODUCT_READ, + 'get_product' => McpScope::ROLE_PRODUCT_READ, + 'get_product_stock' => McpScope::ROLE_PRODUCT_READ, + // 注文 (mcp:order:read) + 'search_orders' => McpScope::ROLE_ORDER_READ, + 'get_order' => McpScope::ROLE_ORDER_READ, + 'get_shipping' => McpScope::ROLE_ORDER_READ, + // 顧客会員 (mcp:customer:read) + 'search_customers' => McpScope::ROLE_CUSTOMER_READ, + 'get_customer' => McpScope::ROLE_CUSTOMER_READ, + 'get_customer_orders' => McpScope::ROLE_CUSTOMER_READ, + // プラグイン管理 (mcp:plugin:read) + 'list_plugins' => McpScope::ROLE_PLUGIN_READ, + 'get_plugin' => McpScope::ROLE_PLUGIN_READ, + ]; + + private function __construct() + { + } + + /** + * Tool 名に対応する必要 role を返す。 未登録なら null (= 呼び出し側で fail-closed deny)。 + */ + public static function requiredRole(string $toolName): ?string + { + return self::MAP[$toolName] ?? null; + } +} diff --git a/src/Eccube/Service/Mcp/ScopeChecker.php b/src/Eccube/Service/Mcp/ScopeChecker.php new file mode 100644 index 00000000000..c04a300aa2f --- /dev/null +++ b/src/Eccube/Service/Mcp/ScopeChecker.php @@ -0,0 +1,63 @@ +authorizationChecker->isGranted($role)) { + throw new ToolCallException(sprintf('Insufficient scope: %s', $this->roleToScope($role))); + } + } + + /** + * `ROLE_OAUTH2_MCP:ORDER:READ` → `mcp:order:read` のように、 内部 role 名を + * OAuth2 仕様の scope 名に戻す。 規則は `role_prefix: ROLE_OAUTH2_` + 小文字化。 + */ + private function roleToScope(string $role): string + { + $prefix = 'ROLE_OAUTH2_'; + if (str_starts_with($role, $prefix)) { + return strtolower(substr($role, \strlen($prefix))); + } + + return $role; + } +} diff --git a/src/Eccube/Service/Mcp/ScopeEnforcingReferenceHandler.php b/src/Eccube/Service/Mcp/ScopeEnforcingReferenceHandler.php new file mode 100644 index 00000000000..02e99225c0b --- /dev/null +++ b/src/Eccube/Service/Mcp/ScopeEnforcingReferenceHandler.php @@ -0,0 +1,92 @@ + $arguments + */ + #[\Override] + public function handle(ElementReference $reference, array $arguments): mixed + { + if ($reference instanceof ToolReference) { + $this->enforce($reference->tool->name); + } + + return $this->inner->handle($reference, $arguments); + } + + /** + * Tool 名に対応する必要 scope を中央マップで引き、 認可する。 拒否時は監査ログを残して + * `ToolCallException` を投げる。 + */ + private function enforce(string $toolName): void + { + $role = McpToolScopeMap::requiredRole($toolName); + + if (null === $role) { + // 中央マップ未登録 = fail-closed。 新規 Tool の scope 登録漏れは「全 deny」 に倒す + $this->auditLogger->logSecurityEvent(AuditResult::ScopeDenied, [ + 'tool' => $toolName, + 'reason' => 'no_scope_mapping', + ]); + + throw new ToolCallException(sprintf('Tool "%s" is not authorized: no scope mapping registered.', $toolName)); + } + + try { + $this->scopeChecker->require($role); + } catch (ToolCallException $e) { + $this->auditLogger->logSecurityEvent(AuditResult::ScopeDenied, [ + 'tool' => $toolName, + 'reason' => 'insufficient_scope', + 'message' => $e->getMessage(), + ]); + + throw $e; + } + } +} diff --git a/src/Eccube/Service/Mcp/Tool/GetCustomerOrdersTool.php b/src/Eccube/Service/Mcp/Tool/GetCustomerOrdersTool.php new file mode 100644 index 00000000000..afde62d0a80 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetCustomerOrdersTool.php @@ -0,0 +1,105 @@ +>} + */ + #[McpTool( + name: 'get_customer_orders', + description: 'EC-CUBE の指定会員の購入履歴 (注文一覧) を取得する。 読み取り専用。 必要 scope: mcp:customer:read。', + )] + public function get(int $customerId, int $limit = 20, int $offset = 0): array + { + /** @var array{customer_id:int|null,total:int,limit:int,offset:int,items:list>} $result */ + $result = $this->invoker->invoke( + toolName: 'get_customer_orders', + args: ['customerId' => $customerId, 'limit' => $limit, 'offset' => $offset], + work: function () use ($customerId, $limit, $offset): array { + $limit = max(1, min(200, $limit)); + $offset = max(0, $offset); + + $customer = $this->customerRepository->find($customerId); + if (null === $customer) { + return [ + 'data' => [ + 'customer_id' => null, + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'items' => [], + ], + 'summary' => ['found' => false], + ]; + } + + $qb = $this->orderRepository->getQueryBuilderByCustomer($customer); + $qb->setMaxResults($limit)->setFirstResult($offset); + $paginator = new Paginator($qb, fetchJoinCollection: true); + + $total = $paginator->count(); + $items = $this->serializer->toArrayList($paginator); + + return [ + 'data' => [ + 'customer_id' => $customer->getId(), + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + 'items' => $items, + ], + 'summary' => [ + 'found' => true, + 'customer_id' => $customer->getId(), + 'total' => $total, + 'returned' => \count($items), + ], + ]; + }, + ); + + return $result; + } +} diff --git a/src/Eccube/Service/Mcp/Tool/GetCustomerTool.php b/src/Eccube/Service/Mcp/Tool/GetCustomerTool.php new file mode 100644 index 00000000000..84911078b33 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetCustomerTool.php @@ -0,0 +1,70 @@ + 見つからなければ空配列 + */ + #[McpTool( + name: 'get_customer', + description: 'EC-CUBE の会員 ID から会員詳細 (氏名・連絡先・住所など) を取得する。 読み取り専用。 必要 scope: mcp:customer:read。 allow_list に PII が含まれる。', + )] + public function get(int $id): array + { + return $this->invoker->invoke( + toolName: 'get_customer', + args: ['id' => $id], + work: function () use ($id): array { + $customer = $this->customerRepository->find($id); + if (null === $customer) { + return [ + 'data' => ['found' => false], + 'summary' => ['found' => false], + ]; + } + + return [ + 'data' => $this->serializer->toArray($customer), + 'summary' => ['found' => true, 'customer_id' => $customer->getId()], + ]; + }, + ); + } +} diff --git a/src/Eccube/Service/Mcp/Tool/GetOrderTool.php b/src/Eccube/Service/Mcp/Tool/GetOrderTool.php new file mode 100644 index 00000000000..4bd40cbeedb --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetOrderTool.php @@ -0,0 +1,85 @@ + 見つからなければ空配列 + */ + #[McpTool( + name: 'get_order', + description: 'EC-CUBE の注文 ID または注文番号から注文詳細 (明細 / 配送状況 / 支払状況等) を取得する。 読み取り専用。 必要 scope: mcp:order:read。 allow_list に氏名・住所等の PII が含まれ得る。', + )] + public function get(?int $id = null, ?string $orderNo = null): array + { + return $this->invoker->invoke( + toolName: 'get_order', + args: compact('id', 'orderNo'), + work: function () use ($id, $orderNo): array { + $order = $this->resolveOrder($id, $orderNo); + if (null === $order) { + return [ + 'data' => ['found' => false], + 'summary' => ['found' => false], + ]; + } + + return [ + 'data' => $this->serializer->toArray($order), + 'summary' => ['found' => true, 'order_id' => $order->getId()], + ]; + }, + ); + } + + private function resolveOrder(?int $id, ?string $orderNo): ?Order + { + if (null !== $id) { + return $this->orderRepository->find($id); + } + + if (null !== $orderNo && '' !== trim($orderNo)) { + return $this->orderRepository->findOneBy(['order_no' => trim($orderNo)]); + } + + return null; + } +} diff --git a/src/Eccube/Service/Mcp/Tool/GetPluginTool.php b/src/Eccube/Service/Mcp/Tool/GetPluginTool.php new file mode 100644 index 00000000000..1741c47c60d --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetPluginTool.php @@ -0,0 +1,127 @@ +/composer.json` を読んで `description` と `require` (依存関係) を含める。 + * **個別プラグインの設定値 (機微データ含み得る) には踏み込まない**。 + */ +final readonly class GetPluginTool +{ + public function __construct( + private PluginRepository $pluginRepository, + private EntityArraySerializer $serializer, + private EccubeConfig $eccubeConfig, + private ToolInvoker $invoker, + ) { + } + + /** + * プラグイン ID または code から詳細を取得する。 + * + * @param int|null $id プラグイン ID (id, code いずれか必須) + * @param string|null $code プラグインコード (id, code いずれか必須) + * + * @return array 見つからなければ空配列 + */ + #[McpTool( + name: 'get_plugin', + description: 'EC-CUBE のプラグイン ID または code から詳細 (Plugin entity + composer.json の description / require 依存関係) を取得する。 読み取り専用。 必要 scope: mcp:plugin:read。 プラグイン設定値は含まれない。', + )] + public function get(?int $id = null, ?string $code = null): array + { + return $this->invoker->invoke( + toolName: 'get_plugin', + args: compact('id', 'code'), + work: function () use ($id, $code): array { + $plugin = $this->resolvePlugin($id, $code); + if (null === $plugin) { + return [ + 'data' => ['found' => false], + 'summary' => ['found' => false], + ]; + } + + $entityData = $this->serializer->toArray($plugin); + $composer = $this->readComposerJson($plugin->getCode()); + + return [ + 'data' => [...$entityData, 'composer' => $composer], + 'summary' => ['found' => true, 'plugin_code' => $plugin->getCode()], + ]; + }, + ); + } + + private function resolvePlugin(?int $id, ?string $code): ?Plugin + { + if (null !== $id) { + return $this->pluginRepository->find($id); + } + + if (null !== $code && '' !== trim($code)) { + return $this->pluginRepository->findByCode(trim($code)); + } + + return null; + } + + /** + * `app/Plugin//composer.json` を読み、 description と require を抽出する。 + * 設定値 (API キー等) は含まれない (composer.json は依存定義のみ)。 + * + * @return array{description:string|null,require:array}|null + */ + private function readComposerJson(string $code): ?array + { + $projectDir = $this->eccubeConfig->get('kernel.project_dir'); + if (!\is_string($projectDir)) { + return null; + } + + $path = $projectDir.'/app/Plugin/'.$code.'/composer.json'; + if (!is_file($path) || !is_readable($path)) { + return null; + } + + $raw = file_get_contents($path); + if (false === $raw) { + return null; + } + + $decoded = json_decode($raw, true); + if (!\is_array($decoded)) { + return null; + } + + $require = $decoded['require'] ?? []; + + return [ + 'description' => \is_string($decoded['description'] ?? null) ? $decoded['description'] : null, + 'require' => \is_array($require) ? array_filter($require, is_string(...)) : [], + ]; + } +} diff --git a/src/Eccube/Service/Mcp/Tool/GetProductStockTool.php b/src/Eccube/Service/Mcp/Tool/GetProductStockTool.php new file mode 100644 index 00000000000..b553b03b0c4 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetProductStockTool.php @@ -0,0 +1,130 @@ +,items:list>} + */ + #[McpTool( + name: 'get_product_stock', + description: 'EC-CUBE の商品 (および商品規格) 単位の在庫数を取得する。 stock_unlimited フラグの規格は stock が null。 読み取り専用。 必要 scope: mcp:product:read。', + )] + public function get(int $productId): array + { + /** @var array{summary:array,items:list>} $result */ + $result = $this->invoker->invoke( + toolName: 'get_product_stock', + args: ['productId' => $productId], + work: function () use ($productId): array { + $product = $this->productRepository->find($productId); + if (null === $product) { + return [ + 'data' => ['summary' => ['found' => false, 'total_classes' => 0], 'items' => []], + 'summary' => ['found' => false], + ]; + } + + $classes = $this->visibleProductClasses($product); + $items = $this->serializer->toArrayList($classes); + + $summary = $this->buildStockSummary($classes); + + return [ + 'data' => [ + 'summary' => $summary, + 'items' => $items, + ], + 'summary' => [ + 'found' => true, + 'product_id' => $product->getId(), + 'total_classes' => $summary['total_classes'], + ], + ]; + }, + ); + + return $result; + } + + /** + * @return list + */ + private function visibleProductClasses(Product $product): array + { + $visible = []; + foreach ($product->getProductClasses() as $class) { + if ($class->isVisible()) { + $visible[] = $class; + } + } + + return $visible; + } + + /** + * @param list $classes + * + * @return array{total_classes:int,total_stock:int|null,stock_unlimited:bool} + */ + private function buildStockSummary(array $classes): array + { + $totalStock = 0; + $stockUnlimited = false; + foreach ($classes as $class) { + if ($class->isStockUnlimited()) { + $stockUnlimited = true; + continue; + } + $stock = $class->getStock(); + if (null !== $stock) { + $totalStock += (int) $stock; + } + } + + return [ + 'total_classes' => \count($classes), + 'total_stock' => $stockUnlimited ? null : $totalStock, + 'stock_unlimited' => $stockUnlimited, + ]; + } +} diff --git a/src/Eccube/Service/Mcp/Tool/GetProductTool.php b/src/Eccube/Service/Mcp/Tool/GetProductTool.php new file mode 100644 index 00000000000..652ae42bcad --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetProductTool.php @@ -0,0 +1,93 @@ + 見つからなければ空配列 + */ + #[McpTool( + name: 'get_product', + description: 'EC-CUBE の商品 ID または商品コードから商品詳細 (規格 / 画像 / カテゴリ等を含む) を取得する。 読み取り専用。 必要 scope: mcp:product:read。', + )] + public function get(?int $id = null, ?string $code = null): array + { + return $this->invoker->invoke( + toolName: 'get_product', + args: compact('id', 'code'), + work: function () use ($id, $code): array { + $product = $this->resolveProduct($id, $code); + if (null === $product) { + return [ + 'data' => ['found' => false], + 'summary' => ['found' => false], + ]; + } + + $data = $this->serializer->toArray($product); + + return [ + 'data' => $data, + 'summary' => ['found' => true, 'product_id' => $product->getId()], + ]; + }, + ); + } + + private function resolveProduct(?int $id, ?string $code): ?Product + { + if (null !== $id) { + return $this->productRepository->find($id); + } + + if (null !== $code && '' !== trim($code)) { + return $this->productRepository->createQueryBuilder('p') + ->innerJoin('p.ProductClasses', 'pc') + ->andWhere('pc.code = :code') + ->setParameter('code', trim($code)) + ->getQuery() + ->setMaxResults(1) + ->getOneOrNullResult(); + } + + return null; + } +} diff --git a/src/Eccube/Service/Mcp/Tool/GetShippingTool.php b/src/Eccube/Service/Mcp/Tool/GetShippingTool.php new file mode 100644 index 00000000000..51eb982e5ff --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetShippingTool.php @@ -0,0 +1,84 @@ +>} + */ + #[McpTool( + name: 'get_shipping', + description: 'EC-CUBE の注文 ID に紐づく配送情報 (出荷ステータス / 配送日 / 追跡番号 / 配送先) の一覧を取得する。 読み取り専用。 必要 scope: mcp:order:read。 配送先住所等の PII が含まれ得る。', + )] + public function get(int $orderId): array + { + /** @var array{order_id:int|null,items:list>} $result */ + $result = $this->invoker->invoke( + toolName: 'get_shipping', + args: ['orderId' => $orderId], + work: function () use ($orderId): array { + $order = $this->orderRepository->find($orderId); + if (null === $order) { + return [ + 'data' => ['order_id' => null, 'items' => []], + 'summary' => ['found' => false], + ]; + } + + $shippings = $order->getShippings()->toArray(); + $items = $this->serializer->toArrayList($shippings); + + return [ + 'data' => [ + 'order_id' => $order->getId(), + 'items' => $items, + ], + 'summary' => [ + 'found' => true, + 'order_id' => $order->getId(), + 'count' => \count($items), + ], + ]; + }, + ); + + return $result; + } +} diff --git a/src/Eccube/Service/Mcp/Tool/ListPluginsTool.php b/src/Eccube/Service/Mcp/Tool/ListPluginsTool.php new file mode 100644 index 00000000000..b1c06b7b389 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/ListPluginsTool.php @@ -0,0 +1,76 @@ +>} + */ + #[McpTool( + name: 'list_plugins', + description: 'EC-CUBE にインストール済みのプラグイン一覧 (code / name / version / enabled / initialized 等) を取得する。 読み取り専用。 必要 scope: mcp:plugin:read。 個別プラグインの設定値は含まれない。', + )] + public function list(?bool $enabledOnly = null): array + { + /** @var array{total:int,items:list>} $result */ + $result = $this->invoker->invoke( + toolName: 'list_plugins', + args: ['enabledOnly' => $enabledOnly], + work: function () use ($enabledOnly): array { + $plugins = match ($enabledOnly) { + true => $this->pluginRepository->findAllEnabled(), + false => $this->pluginRepository->findBy(['enabled' => false]), + null => $this->pluginRepository->findBy([], ['code' => 'ASC']), + }; + + $items = $this->serializer->toArrayList($plugins); + + return [ + 'data' => [ + 'total' => \count($items), + 'items' => $items, + ], + 'summary' => ['total' => \count($items)], + ]; + }, + ); + + return $result; + } +} diff --git a/src/Eccube/Service/Mcp/Tool/SearchCustomersTool.php b/src/Eccube/Service/Mcp/Tool/SearchCustomersTool.php new file mode 100644 index 00000000000..8df5275fa92 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/SearchCustomersTool.php @@ -0,0 +1,173 @@ +>} + */ + #[McpTool( + name: 'search_customers', + description: 'EC-CUBE の会員を キーワード / 電話 / ステータス / 登録期間 / 購入合計 / 購入回数 で検索する。 読み取り専用。 必要 scope: mcp:customer:read。 氏名・メール・電話等の PII を含み得る。', + )] + public function search( + ?string $keyword = null, + ?string $phoneNumber = null, + ?array $statusIds = null, + ?string $createDateFrom = null, + ?string $createDateTo = null, + ?int $buyTotalMin = null, + ?int $buyTotalMax = null, + ?int $buyTimesMin = null, + ?int $buyTimesMax = null, + int $limit = 20, + int $offset = 0, + ): array { + /** @var array{total:int,limit:int,offset:int,items:list>} $result */ + $result = $this->invoker->invoke( + toolName: 'search_customers', + args: compact('keyword', 'phoneNumber', 'statusIds', 'createDateFrom', 'createDateTo', 'buyTotalMin', 'buyTotalMax', 'buyTimesMin', 'buyTimesMax', 'limit', 'offset'), + work: function () use ($keyword, $phoneNumber, $statusIds, $createDateFrom, $createDateTo, $buyTotalMin, $buyTotalMax, $buyTimesMin, $buyTimesMax, $limit, $offset): array { + $limit = max(1, min(200, $limit)); + $offset = max(0, $offset); + + $searchData = $this->buildSearchData($keyword, $phoneNumber, $statusIds, $createDateFrom, $createDateTo, $buyTotalMin, $buyTotalMax, $buyTimesMin, $buyTimesMax); + $qb = $this->customerRepository->getQueryBuilderBySearchData($searchData); + $qb->setMaxResults($limit)->setFirstResult($offset); + + $paginator = new Paginator($qb, fetchJoinCollection: true); + + $total = $paginator->count(); + $items = $this->serializer->toArrayList($paginator); + + return [ + 'data' => [ + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + 'items' => $items, + ], + 'summary' => ['total' => $total, 'returned' => \count($items)], + ]; + }, + ); + + return $result; + } + + /** + * @param int[]|null $statusIds + * + * @return array + */ + private function buildSearchData( + ?string $keyword, + ?string $phoneNumber, + ?array $statusIds, + ?string $createDateFrom, + ?string $createDateTo, + ?int $buyTotalMin, + ?int $buyTotalMax, + ?int $buyTimesMin, + ?int $buyTimesMax, + ): array { + $searchData = []; + + if (null !== $keyword && '' !== trim($keyword)) { + $searchData['multi'] = $keyword; + } + if (null !== $phoneNumber && '' !== trim($phoneNumber)) { + $searchData['phone_number'] = $phoneNumber; + } + if (null !== $statusIds && [] !== $statusIds) { + $statuses = $this->customerStatusRepository->findBy(['id' => $statusIds]); + if ([] !== $statuses) { + $searchData['customer_status'] = $statuses; + } + } + if (null !== $createDateFrom && '' !== trim($createDateFrom)) { + $searchData['create_date_start'] = $this->parseSearchDate($createDateFrom, 'createDateFrom'); + } + if (null !== $createDateTo && '' !== trim($createDateTo)) { + $searchData['create_date_end'] = $this->parseSearchDate($createDateTo, 'createDateTo'); + } + if (null !== $buyTotalMin) { + $searchData['buy_total_start'] = $buyTotalMin; + } + if (null !== $buyTotalMax) { + $searchData['buy_total_end'] = $buyTotalMax; + } + if (null !== $buyTimesMin) { + $searchData['buy_times_start'] = $buyTimesMin; + } + if (null !== $buyTimesMax) { + $searchData['buy_times_end'] = $buyTimesMax; + } + + return $searchData; + } + + /** + * MCP クライアント由来の日付文字列を `\DateTime` に変換する。 + * 不正な書式は `new \DateTime()` の不透明な例外ではなく、 引数名を示す `\InvalidArgumentException` に変換する。 + */ + private function parseSearchDate(string $value, string $field): \DateTime + { + try { + return new \DateTime($value); + } catch (\Exception $e) { + throw new \InvalidArgumentException(sprintf('Invalid %s format: %s', $field, $value), 0, $e); + } + } +} diff --git a/src/Eccube/Service/Mcp/Tool/SearchOrdersTool.php b/src/Eccube/Service/Mcp/Tool/SearchOrdersTool.php new file mode 100644 index 00000000000..2ce6dbb1727 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/SearchOrdersTool.php @@ -0,0 +1,172 @@ +>} + */ + #[McpTool( + name: 'search_orders', + description: 'EC-CUBE の注文を キーワード / 注文番号 / ステータス / 期間 / 金額レンジ / 顧客 ID で検索する。 読み取り専用。 必要 scope: mcp:order:read。 PII を含み得る。', + )] + public function search( + ?string $keyword = null, + ?string $orderNo = null, + ?array $statusIds = null, + ?string $email = null, + ?int $totalMin = null, + ?int $totalMax = null, + ?string $orderDateFrom = null, + ?string $orderDateTo = null, + ?int $customerId = null, + int $limit = 20, + int $offset = 0, + ): array { + /** @var array{total:int,limit:int,offset:int,items:list>} $result */ + $result = $this->invoker->invoke( + toolName: 'search_orders', + args: compact('keyword', 'orderNo', 'statusIds', 'email', 'totalMin', 'totalMax', 'orderDateFrom', 'orderDateTo', 'customerId', 'limit', 'offset'), + work: function () use ($keyword, $orderNo, $statusIds, $email, $totalMin, $totalMax, $orderDateFrom, $orderDateTo, $customerId, $limit, $offset): array { + $limit = max(1, min(200, $limit)); + $offset = max(0, $offset); + + $searchData = $this->buildSearchData($keyword, $orderNo, $statusIds, $email, $totalMin, $totalMax, $orderDateFrom, $orderDateTo); + $qb = $this->orderRepository->getQueryBuilderBySearchDataForAdmin($searchData); + + if (null !== $customerId) { + $qb->andWhere('o.Customer = :mcpCustomerId') + ->setParameter('mcpCustomerId', $customerId); + } + + $qb->setMaxResults($limit)->setFirstResult($offset); + $paginator = new Paginator($qb, fetchJoinCollection: true); + + $total = $paginator->count(); + $items = $this->serializer->toArrayList($paginator); + + return [ + 'data' => [ + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + 'items' => $items, + ], + 'summary' => ['total' => $total, 'returned' => \count($items)], + ]; + }, + ); + + return $result; + } + + /** + * @param int[]|null $statusIds + * + * @return array + */ + private function buildSearchData( + ?string $keyword, + ?string $orderNo, + ?array $statusIds, + ?string $email, + ?int $totalMin, + ?int $totalMax, + ?string $orderDateFrom, + ?string $orderDateTo, + ): array { + $searchData = []; + + if (null !== $keyword && '' !== trim($keyword)) { + $searchData['multi'] = $keyword; + } + if (null !== $orderNo && '' !== trim($orderNo)) { + $searchData['order_no'] = $orderNo; + } + if (null !== $statusIds && [] !== $statusIds) { + $statuses = $this->orderStatusRepository->findBy(['id' => $statusIds]); + if ([] !== $statuses) { + $searchData['status'] = $statuses; + } + } + if (null !== $email && '' !== trim($email)) { + $searchData['email'] = $email; + } + if (null !== $totalMin) { + $searchData['payment_total_start'] = $totalMin; + } + if (null !== $totalMax) { + $searchData['payment_total_end'] = $totalMax; + } + if (null !== $orderDateFrom && '' !== trim($orderDateFrom)) { + $searchData['order_date_start'] = $this->parseSearchDate($orderDateFrom, 'orderDateFrom'); + } + if (null !== $orderDateTo && '' !== trim($orderDateTo)) { + $searchData['order_date_end'] = $this->parseSearchDate($orderDateTo, 'orderDateTo'); + } + + return $searchData; + } + + /** + * MCP クライアント由来の日付文字列を `\DateTime` に変換する。 + * 不正な書式は `new \DateTime()` の不透明な例外ではなく、 引数名を示す `\InvalidArgumentException` に変換する。 + */ + private function parseSearchDate(string $value, string $field): \DateTime + { + try { + return new \DateTime($value); + } catch (\Exception $e) { + throw new \InvalidArgumentException(sprintf('Invalid %s format: %s', $field, $value), 0, $e); + } + } +} diff --git a/src/Eccube/Service/Mcp/Tool/SearchProductsTool.php b/src/Eccube/Service/Mcp/Tool/SearchProductsTool.php new file mode 100644 index 00000000000..a9fbcfc9fc7 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/SearchProductsTool.php @@ -0,0 +1,140 @@ +>} + */ + #[McpTool( + name: 'search_products', + description: 'EC-CUBE の商品をキーワード / カテゴリ / 公開ステータス / 在庫数で検索し、商品一覧を返す。 読み取り専用。 必要 scope: mcp:product:read。', + )] + public function search( + ?string $keyword = null, + ?int $categoryId = null, + ?array $statusIds = null, + ?int $stockMin = null, + ?int $stockMax = null, + int $limit = 20, + int $offset = 0, + ): array { + /** @var array{total:int,limit:int,offset:int,items:list>} $result */ + $result = $this->invoker->invoke( + toolName: 'search_products', + args: compact('keyword', 'categoryId', 'statusIds', 'stockMin', 'stockMax', 'limit', 'offset'), + work: function () use ($keyword, $categoryId, $statusIds, $stockMin, $stockMax, $limit, $offset): array { + $limit = max(1, min(200, $limit)); + $offset = max(0, $offset); + + $searchData = $this->buildSearchData($keyword, $categoryId, $statusIds); + $qb = $this->productRepository->getQueryBuilderBySearchDataForAdmin($searchData); + + if (null !== $stockMin) { + $qb->andWhere('pc.stock_unlimited = false AND pc.stock >= :mcpStockMin') + ->setParameter('mcpStockMin', $stockMin); + } + if (null !== $stockMax) { + $qb->andWhere('pc.stock_unlimited = false AND pc.stock <= :mcpStockMax') + ->setParameter('mcpStockMax', $stockMax); + } + + $qb->setMaxResults($limit)->setFirstResult($offset); + $paginator = new Paginator($qb, fetchJoinCollection: true); + + $total = $paginator->count(); + $items = $this->serializer->toArrayList($paginator); + + return [ + 'data' => [ + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + 'items' => $items, + ], + 'summary' => ['total' => $total, 'returned' => \count($items)], + ]; + }, + ); + + return $result; + } + + /** + * @param int[]|null $statusIds + * + * @return array + */ + private function buildSearchData(?string $keyword, ?int $categoryId, ?array $statusIds): array + { + $searchData = []; + + if (null !== $keyword && '' !== trim($keyword)) { + $searchData['id'] = $keyword; + } + + if (null !== $categoryId) { + $category = $this->categoryRepository->find($categoryId); + if (null !== $category) { + $searchData['category_id'] = $category; + } + } + + $statusIds ??= [ProductStatus::DISPLAY_SHOW]; + $statuses = $this->productStatusRepository->findBy(['id' => $statusIds]); + if ([] !== $statuses) { + $searchData['status'] = $statuses; + } + + return $searchData; + } +} diff --git a/src/Eccube/Service/Mcp/ToolInvoker.php b/src/Eccube/Service/Mcp/ToolInvoker.php new file mode 100644 index 00000000000..d42dcb6ac6c --- /dev/null +++ b/src/Eccube/Service/Mcp/ToolInvoker.php @@ -0,0 +1,94 @@ +, summary?: array|null} + */ +final readonly class ToolInvoker +{ + public function __construct( + private McpAuditLogger $auditLogger, + ) { + } + + /** + * Tool を実行する。 `$work` は `{data: ..., summary?: ...}` を返す callable。 + * + * @param array $args 監査ログに記録する引数 (個人情報を含み得る) + * @param callable(): array $work 業務処理本体。 戻り値は `{data, summary?}` + * + * @return array work() が返した `data` 部分。 そのまま MCP の JSON-RPC result に渡る + */ + public function invoke( + string $toolName, + array $args, + callable $work, + ): array { + $startedAt = microtime(true); + + try { + $outcome = $work(); + } catch (\Throwable $e) { + $this->auditLogger->logToolCall( + toolName: $toolName, + args: $args, + result: AuditResult::InternalError, + durationMs: $this->elapsedMs($startedAt), + ); + throw $e; + } + + // data キー欠落・非配列は Tool 実装の契約違反。 空の正常応答に化けさせず内部エラーとして扱う + // (静かに success へ握り潰すと、 クライアントも監査ログも実装バグを検知できない)。 + if (!\array_key_exists('data', $outcome) || !\is_array($outcome['data'])) { + $this->auditLogger->logToolCall( + toolName: $toolName, + args: $args, + result: AuditResult::InternalError, + durationMs: $this->elapsedMs($startedAt), + ); + + throw new \UnexpectedValueException('Tool result must contain an array `data` key.'); + } + + $summary = $outcome['summary'] ?? null; + + $this->auditLogger->logToolCall( + toolName: $toolName, + args: $args, + result: AuditResult::Success, + durationMs: $this->elapsedMs($startedAt), + resultSummary: \is_array($summary) ? $summary : null, + ); + + return $outcome['data']; + } + + private function elapsedMs(float $startedAt): float + { + return (microtime(true) - $startedAt) * 1000; + } +} diff --git a/tests/Eccube/Tests/DependencyInjection/Compiler/McpScopeEnforcementPassTest.php b/tests/Eccube/Tests/DependencyInjection/Compiler/McpScopeEnforcementPassTest.php new file mode 100644 index 00000000000..9903e5f9216 --- /dev/null +++ b/tests/Eccube/Tests/DependencyInjection/Compiler/McpScopeEnforcementPassTest.php @@ -0,0 +1,69 @@ +setDefinition('mcp.server.builder', new Definition(\stdClass::class)); + // mcp.tool タグの Tool を 1 つ用意 (locator 構築対象) + $container->setDefinition('dummy.tool', (new Definition(\stdClass::class))->addTag('mcp.tool')); + + (new McpScopeEnforcementPass())->process($container); + + // 1. builder に setReferenceHandler が ScopeEnforcingReferenceHandler 参照付きで差し込まれている + $calls = $container->getDefinition('mcp.server.builder')->getMethodCalls(); + $setReferenceHandlerCalls = array_filter($calls, static fn (array $c): bool => 'setReferenceHandler' === $c[0]); + $this->assertCount(1, $setReferenceHandlerCalls, 'builder に setReferenceHandler が 1 回差し込まれる'); + + $call = array_values($setReferenceHandlerCalls)[0]; + $argument = $call[1][0]; + $this->assertInstanceOf(Reference::class, $argument); + $this->assertSame(ScopeEnforcingReferenceHandler::class, (string) $argument, 'scope 強制版 handler が渡される'); + + // 2. 委譲先の inner ReferenceHandler サービスが構築されている + $this->assertTrue( + $container->hasDefinition(McpScopeEnforcementPass::INNER_REFERENCE_HANDLER_ID), + 'inner ReferenceHandler サービスが定義される', + ); + } + + public function testNoWiringWhenBuilderAbsent(): void + { + // mcp-bundle 未導入 (builder 無し) では配線をスキップする (例外も出さない) + $container = new ContainerBuilder(); + + (new McpScopeEnforcementPass())->process($container); + + $this->assertFalse($container->hasDefinition('mcp.server.builder')); + } +} diff --git a/tests/Eccube/Tests/EventListener/Mcp/AuthFailureAuditListenerTest.php b/tests/Eccube/Tests/EventListener/Mcp/AuthFailureAuditListenerTest.php new file mode 100644 index 00000000000..62a589adee5 --- /dev/null +++ b/tests/Eccube/Tests/EventListener/Mcp/AuthFailureAuditListenerTest.php @@ -0,0 +1,145 @@ +recordingLogger(); + $this->buildListener($recorder)->onKernelResponse( + $this->responseEvent('/admin/mcp', Response::HTTP_UNAUTHORIZED), + ); + + $this->assertCount(1, $recorder->records, 'mcp パスの 401 を 1 行記録する'); + $record = $recorder->records[0]; + $this->assertSame('warning', $record['level'], '認証失敗はクライアント都合なので warning'); + $this->assertSame('mcp.auth.token_invalid', $record['message']); + $this->assertSame('token_invalid', $record['context']['result_status'] ?? null); + $this->assertArrayHasKey('client_id', $record['context']); + $this->assertNull($record['context']['client_id'], 'client_id は best-effort で null'); + $this->assertSame('unauthorized', $record['context']['reason'] ?? null, 'WWW-Authenticate 無しは fallback'); + } + + public function testReasonComesFromWwwAuthenticateHeader(): void + { + $recorder = $this->recordingLogger(); + $this->buildListener($recorder)->onKernelResponse($this->responseEvent( + '/admin/mcp', + Response::HTTP_UNAUTHORIZED, + ['WWW-Authenticate' => 'Bearer error="invalid_token"'], + )); + + $this->assertCount(1, $recorder->records); + $this->assertSame('Bearer error="invalid_token"', $recorder->records[0]['context']['reason'] ?? null); + } + + public function testIgnoresNon401Response(): void + { + $recorder = $this->recordingLogger(); + $this->buildListener($recorder)->onKernelResponse( + $this->responseEvent('/admin/mcp', Response::HTTP_OK), + ); + + $this->assertSame([], $recorder->records, 'mcp パスでも 401 以外は記録しない (scope 拒否=200 等)'); + } + + public function testIgnoresNonMcpPath(): void + { + $recorder = $this->recordingLogger(); + $this->buildListener($recorder)->onKernelResponse( + $this->responseEvent('/admin/product', Response::HTTP_UNAUTHORIZED), + ); + + $this->assertSame([], $recorder->records, 'mcp 以外のパスの 401 は記録しない'); + } + + public function testAuditFailureDoesNotBreakResponse(): void + { + $throwingAudit = new McpAuditLogger( + new class extends AbstractLogger { + /** + * @param array $context + */ + public function log(mixed $level, string|\Stringable $message, array $context = []): void + { + throw new \RuntimeException('mcp channel down'); + } + }, + new RequestStack(), + ); + $listener = new AuthFailureAuditListener('admin', $throwingAudit, new NullLogger()); + + // 監査が throw しても例外が伝播しない (401 応答を壊さない) + $listener->onKernelResponse($this->responseEvent('/admin/mcp', Response::HTTP_UNAUTHORIZED)); + + $this->addToAssertionCount(1); + } + + private function buildListener(AbstractLogger $recorder): AuthFailureAuditListener + { + // recorder を mcp チャネルロガーに据え、 監査出力 (logAuthEvent) を捕捉する。 fallback は使わない + return new AuthFailureAuditListener('admin', new McpAuditLogger($recorder, new RequestStack()), new NullLogger()); + } + + /** + * @return AbstractLogger&object{records: list}>} + */ + private function recordingLogger(): AbstractLogger + { + return new class extends AbstractLogger { + /** @var list}> */ + public array $records = []; + + /** + * @param array $context + */ + public function log(mixed $level, string|\Stringable $message, array $context = []): void + { + $this->records[] = ['level' => $level, 'message' => (string) $message, 'context' => $context]; + } + }; + } + + /** + * @param array $headers + */ + private function responseEvent(string $path, int $statusCode, array $headers = []): ResponseEvent + { + return new ResponseEvent( + $this->createStub(HttpKernelInterface::class), + Request::create($path, Request::METHOD_POST), + HttpKernelInterface::MAIN_REQUEST, + new Response('', $statusCode, $headers), + ); + } +} diff --git a/tests/Eccube/Tests/EventListener/Mcp/OriginContentTypeListenerTest.php b/tests/Eccube/Tests/EventListener/Mcp/OriginContentTypeListenerTest.php new file mode 100644 index 00000000000..57a4cc7757c --- /dev/null +++ b/tests/Eccube/Tests/EventListener/Mcp/OriginContentTypeListenerTest.php @@ -0,0 +1,163 @@ +dispatch( + $this->makeListener(), + $this->makeRequest('POST', '/admin/product', contentType: 'text/html'), + ); + + $this->assertNotInstanceOf(Response::class, $event->getResponse(), '対象外パスは何もしない'); + } + + public function testIgnoresGetHeadOptions(): void + { + $listener = $this->makeListener(); + + foreach (['GET', 'HEAD', 'OPTIONS'] as $method) { + $event = $this->dispatch($listener, $this->makeRequest($method, '/admin/mcp', contentType: 'text/html')); + $this->assertNotInstanceOf(Response::class, $event->getResponse(), sprintf('%s は Content-Type 検証対象外', $method)); + } + } + + public function testPassesPostWithJsonContentType(): void + { + $event = $this->dispatch( + $this->makeListener(), + $this->makeRequest('POST', '/admin/mcp', contentType: 'application/json'), + ); + + $this->assertNotInstanceOf(Response::class, $event->getResponse(), '正常な JSON POST は通過'); + } + + public function testRejectsPostWithNonJsonContentType(): void + { + $event = $this->dispatch( + $this->makeListener(), + $this->makeRequest('POST', '/admin/mcp', contentType: 'text/html'), + ); + + $response = $event->getResponse(); + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, $response->getStatusCode(), (string) $response->getContent()); + } + + public function testRejectsPostWithoutContentTypeHeader(): void + { + $event = $this->dispatch( + $this->makeListener(), + $this->makeRequest('POST', '/admin/mcp', contentType: null), + ); + + $response = $event->getResponse(); + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, $response->getStatusCode(), (string) $response->getContent()); + } + + public function testRejectsDisallowedOrigin(): void + { + $event = $this->dispatch( + $this->makeListener(allowedOriginsCsv: 'https://example.com'), + $this->makeRequest('POST', '/admin/mcp', contentType: 'application/json', origin: 'https://evil.example'), + ); + + $response = $event->getResponse(); + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode(), (string) $response->getContent()); + } + + public function testPassesAllowedOrigin(): void + { + $event = $this->dispatch( + $this->makeListener(allowedOriginsCsv: 'https://example.com,http://localhost:6274'), + $this->makeRequest('POST', '/admin/mcp', contentType: 'application/json', origin: 'http://localhost:6274'), + ); + + $this->assertNotInstanceOf(Response::class, $event->getResponse(), '許可リストにある Origin は通過'); + } + + public function testSkipsOriginCheckWhenAllowListIsEmpty(): void + { + $event = $this->dispatch( + $this->makeListener(allowedOriginsCsv: ''), + $this->makeRequest('POST', '/admin/mcp', contentType: 'application/json', origin: 'http://anything'), + ); + + $this->assertNotInstanceOf(Response::class, $event->getResponse(), '許可リスト未設定なら Origin 検証 skip'); + } + + public function testSkipsOriginCheckWhenOriginHeaderAbsent(): void + { + $event = $this->dispatch( + $this->makeListener(allowedOriginsCsv: 'https://example.com'), + $this->makeRequest('POST', '/admin/mcp', contentType: 'application/json'), + ); + + $this->assertNotInstanceOf(Response::class, $event->getResponse(), 'Origin 無し (curl 等) は通過'); + } + + private function makeListener(string $allowedOriginsCsv = ''): OriginContentTypeListener + { + $auditLogger = new McpAuditLogger(new NullLogger(), new RequestStack()); + + return new OriginContentTypeListener('admin', $allowedOriginsCsv, $auditLogger); + } + + private function makeRequest(string $method, string $path, ?string $contentType, ?string $origin = null): Request + { + $server = ['REQUEST_METHOD' => $method, 'REQUEST_URI' => $path]; + if (null !== $contentType) { + $server['CONTENT_TYPE'] = $contentType; + } + $request = Request::create($path, $method, server: $server); + if (null !== $contentType) { + $request->headers->set('Content-Type', $contentType); + } + if (null !== $origin) { + $request->headers->set('Origin', $origin); + } + + return $request; + } + + private function dispatch(OriginContentTypeListener $listener, Request $request): RequestEvent + { + $event = new RequestEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); + $listener->onKernelRequest($event); + + return $event; + } +} diff --git a/tests/Eccube/Tests/EventListener/Mcp/RateLimitListenerTest.php b/tests/Eccube/Tests/EventListener/Mcp/RateLimitListenerTest.php new file mode 100644 index 00000000000..dfed798e814 --- /dev/null +++ b/tests/Eccube/Tests/EventListener/Mcp/RateLimitListenerTest.php @@ -0,0 +1,289 @@ + 'mcp_ip', 'policy' => 'fixed_window', 'limit' => 2, 'interval' => '1 minute'], + new InMemoryStorage(), + ); + $clientLimiter = new RateLimiterFactory( + ['id' => 'mcp_client', 'policy' => 'fixed_window', 'limit' => 2, 'interval' => '1 minute'], + new InMemoryStorage(), + ); + $this->tokenStorage = new TokenStorage(); + + $this->listener = new RateLimitListener( + eccubeAdminRoute: 'admin', + mcpIpLimiter: $ipLimiter, + mcpClientLimiter: $clientLimiter, + tokenStorage: $this->tokenStorage, + auditLogger: new McpAuditLogger(new NullLogger(), new RequestStack()), + logger: new NullLogger(), + ); + } + + public function testIpLimitAllowsUpToLimit(): void + { + $event1 = $this->buildRequestEvent('192.0.2.1', '/admin/mcp'); + $event2 = $this->buildRequestEvent('192.0.2.1', '/admin/mcp'); + $this->listener->onKernelRequest($event1); + $this->listener->onKernelRequest($event2); + + $this->assertNotInstanceOf(Response::class, $event1->getResponse(), '1 回目は通過'); + $this->assertNotInstanceOf(Response::class, $event2->getResponse(), '2 回目も通過 (limit=2)'); + } + + public function testIpLimitBlocksOverLimit(): void + { + for ($i = 0; $i < 2; ++$i) { + $this->listener->onKernelRequest($this->buildRequestEvent('192.0.2.2', '/admin/mcp')); + } + + $event3 = $this->buildRequestEvent('192.0.2.2', '/admin/mcp'); + $this->listener->onKernelRequest($event3); + + $response = $event3->getResponse(); + $this->assertInstanceOf(Response::class, $response, '3 回目で 429 を期待'); + $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode(), (string) $response->getContent()); + $this->assertNotNull($response->headers->get('Retry-After')); + $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); + + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('rate_limited', $body['error'] ?? null); + $this->assertIsInt($body['retry_after_seconds'] ?? null); + } + + public function testIpLimitIsScopedToMcpPath(): void + { + for ($i = 0; $i < 5; ++$i) { + // 別パス (例: 通常の admin) では limiter は消費されない + $this->listener->onKernelRequest($this->buildRequestEvent('192.0.2.3', '/admin/dashboard')); + } + + $event = $this->buildRequestEvent('192.0.2.3', '/admin/mcp'); + $this->listener->onKernelRequest($event); + $this->assertNotInstanceOf(Response::class, $event->getResponse(), 'MCP 以外の path は消費しない'); + } + + public function testClientIdLimitConsumesOnlyWhenOAuth2TokenPresent(): void + { + // token なし: client_id 制限は消費されない + for ($i = 0; $i < 5; ++$i) { + $event = $this->buildControllerEvent('/admin/mcp'); + $this->listener->onKernelController($event); + } + + $this->tokenStorage->setToken($this->buildOAuth2Token('test-client')); + $event1 = $this->buildControllerEvent('/admin/mcp'); + $event2 = $this->buildControllerEvent('/admin/mcp'); + $this->listener->onKernelController($event1); + $this->listener->onKernelController($event2); + + // 差し替えが起きていなければ、 元の controller が `Response('original')` を返す + $this->assertSame('original', $event1->getController()()->getContent(), '1 回目は通過 (controller 据え置き)'); + $this->assertSame('original', $event2->getController()()->getContent(), '2 回目も通過 (limit=2)'); + } + + public function testClientIdLimitBlocksOverLimit(): void + { + $this->tokenStorage->setToken($this->buildOAuth2Token('over-limit-client')); + + for ($i = 0; $i < 2; ++$i) { + $this->listener->onKernelController($this->buildControllerEvent('/admin/mcp')); + } + + $event3 = $this->buildControllerEvent('/admin/mcp'); + $this->listener->onKernelController($event3); + + $controller = $event3->getController(); + $this->assertIsCallable($controller); + $response = $controller(); + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode(), (string) $response->getContent()); + + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('rate_limited', $body['error'] ?? null); + } + + public function testFailsClosedWhenLimiterStorageThrows(): void + { + // cache (カウンタ保存先) 障害を模した、 fetch で例外を投げる storage + $brokenLimiter = new RateLimiterFactory( + ['id' => 'mcp_ip', 'policy' => 'fixed_window', 'limit' => 2, 'interval' => '1 minute'], + new class implements StorageInterface { + #[\Override] + public function save(LimiterStateInterface $limiterState): void + { + throw new \RuntimeException('cache down'); + } + + #[\Override] + public function fetch(string $limiterStateId): ?LimiterStateInterface + { + throw new \RuntimeException('cache down'); + } + + #[\Override] + public function delete(string $limiterStateId): void + { + } + }, + ); + + $listener = new RateLimitListener( + eccubeAdminRoute: 'admin', + mcpIpLimiter: $brokenLimiter, + mcpClientLimiter: $brokenLimiter, + tokenStorage: new TokenStorage(), + auditLogger: new McpAuditLogger(new NullLogger(), new RequestStack()), + logger: new NullLogger(), + ); + + $event = $this->buildRequestEvent('192.0.2.9', '/admin/mcp'); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + $this->assertInstanceOf(Response::class, $response, 'cache 障害時は素通しせず拒否する (fail-closed)'); + $this->assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode(), (string) $response->getContent()); + + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('rate_limiter_unavailable', $body['error'] ?? null); + } + + public function testAuditFailureIsRecordedToFallbackAndResponsePreserved(): void + { + // mcp 監査チャンネル書き込みが落ちる状況を模す + $throwingAuditLogger = new McpAuditLogger( + new class extends AbstractLogger { + public function log($level, string|\Stringable $message, array $context = []): void + { + throw new \RuntimeException('mcp channel down'); + } + }, + new RequestStack(), + ); + // フォールバック先 (default チャンネル) の記録を捕捉する spy + $fallback = new class extends AbstractLogger { + /** @var list */ + public array $messages = []; + + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->messages[] = (string) $message; + } + }; + $ipLimiter = new RateLimiterFactory( + ['id' => 'mcp_ip', 'policy' => 'fixed_window', 'limit' => 1, 'interval' => '1 minute'], + new InMemoryStorage(), + ); + + $listener = new RateLimitListener( + eccubeAdminRoute: 'admin', + mcpIpLimiter: $ipLimiter, + mcpClientLimiter: $ipLimiter, + tokenStorage: new TokenStorage(), + auditLogger: $throwingAuditLogger, + logger: $fallback, + ); + + // limit=1: 2 回目で 429 → RateLimited 監査 → 監査が throw → safeAudit が fallback に記録 + $listener->onKernelRequest($this->buildRequestEvent('192.0.2.50', '/admin/mcp')); + $event = $this->buildRequestEvent('192.0.2.50', '/admin/mcp'); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + $this->assertInstanceOf(Response::class, $response, '監査失敗でも 429 は返る'); + $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode(), (string) $response->getContent()); + $this->assertNotEmpty($fallback->messages, '監査失敗が fallback logger に記録される (完全沈黙しない)'); + $this->assertStringContainsString('mcp 監査ログの書き込みに失敗', $fallback->messages[0]); + } + + private function buildRequestEvent(string $ip, string $path): RequestEvent + { + $request = Request::create($path, Request::METHOD_POST); + $request->server->set('REMOTE_ADDR', $ip); + + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + private function buildControllerEvent(string $path): ControllerEvent + { + $request = Request::create($path, Request::METHOD_POST); + + return new ControllerEvent( + $this->createStub(HttpKernelInterface::class), + static fn (): Response => new Response('original'), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + /** + * client_id 単位の制限は、 listener が `getOAuthClientId()` の有無で対象を判定する。 + * league の具象 OAuth2Token に依存しないよう、 同メソッドを持つ最小トークンで代替する。 + */ + private function buildOAuth2Token(string $clientId): TokenInterface + { + return new class($clientId) extends AbstractToken { + public function __construct(private readonly string $oauthClientId) + { + parent::__construct(); + } + + public function getOAuthClientId(): string + { + return $this->oauthClientId; + } + }; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/AllowListResolverTest.php b/tests/Eccube/Tests/Service/Mcp/AllowListResolverTest.php new file mode 100644 index 00000000000..65bc5a3e341 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/AllowListResolverTest.php @@ -0,0 +1,83 @@ +assertSame([], $resolver->getAllowedProperties(\stdClass::class)); + $this->assertFalse($resolver->isAllowed(\stdClass::class, 'anything')); + } + + public function testReadsPropertiesFromSingleAllowList(): void + { + $resolver = new AllowListResolver([ + new FakeAllowList([ + \stdClass::class => ['id', 'name'], + ]), + ]); + + $this->assertSame(['id', 'name'], $resolver->getAllowedProperties(\stdClass::class)); + $this->assertTrue($resolver->isAllowed(\stdClass::class, 'name')); + $this->assertFalse($resolver->isAllowed(\stdClass::class, 'secret')); + } + + public function testUnionsMultipleAllowLists(): void + { + $resolver = new AllowListResolver([ + new FakeAllowList([\stdClass::class => ['id', 'name']]), + new FakeAllowList([\stdClass::class => ['name', 'email']]), + ]); + + $merged = $resolver->getAllowedProperties(\stdClass::class); + sort($merged); + $this->assertSame(['email', 'id', 'name'], $merged); + } + + public function testAcceptsArrayObjectBackedAllows(): void + { + $allows = new \ArrayObject([\stdClass::class => ['id']]); + $resolver = new AllowListResolver([new FakeAllowList($allows)]); + + $this->assertSame(['id'], $resolver->getAllowedProperties(\stdClass::class)); + } + + public function testIgnoresMalformedAllowsEntries(): void + { + $resolver = new AllowListResolver([ + new FakeAllowList([ + \stdClass::class => ['id'], + 42 => ['ignored'], // 数値キー → 無視 + 'no-such-class' => 'oops', // 値が配列じゃない → 無視 + ]), + new \stdClass(), // `$allows` プロパティ無し → 無視 + ]); + + $this->assertSame(['id'], $resolver->getAllowedProperties(\stdClass::class)); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/AuditResultUsageTest.php b/tests/Eccube/Tests/Service/Mcp/AuditResultUsageTest.php new file mode 100644 index 00000000000..711df42bc7a --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/AuditResultUsageTest.php @@ -0,0 +1,76 @@ +mcpSourceContents(); + + foreach (AuditResult::cases() as $case) { + $needle = 'AuditResult::'.$case->name; + $this->assertStringContainsString( + $needle, + $sources, + sprintf('AuditResult::%s が src から参照されていない (孤児 case)', $case->name), + ); + } + } + + /** + * src/Eccube/{Service,EventListener}/Mcp 配下の PHP を 1 つの文字列に連結して返す + * (AuditResult.php 自身は case 定義なので除外)。 + */ + private function mcpSourceContents(): string + { + // .../src/Eccube/Service/Mcp/AuditResult.php から 5 つ上が project root + $base = \dirname((string) (new \ReflectionClass(AuditResult::class))->getFileName(), 5); + $dirs = [ + $base.'/src/Eccube/Service/Mcp', + $base.'/src/Eccube/EventListener/Mcp', + ]; + + $contents = ''; + foreach ($dirs as $dir) { + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if (!$file instanceof \SplFileInfo || 'php' !== $file->getExtension()) { + continue; + } + if ('AuditResult.php' === $file->getFilename()) { + continue; + } + $fileContents = file_get_contents($file->getPathname()); + if (false === $fileContents) { + throw new \RuntimeException(sprintf('ファイル読み取り失敗: %s', $file->getPathname())); + } + $contents .= $fileContents; + } + } + + return $contents; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Contract/AllowListContractTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/AllowListContractTest.php new file mode 100644 index 00000000000..4a4adae7d7e --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/AllowListContractTest.php @@ -0,0 +1,112 @@ +resolver = static::getContainer()->get(AllowListResolver::class); + $this->serializer = static::getContainer()->get(EntityArraySerializer::class); + } + + public function testProductOutputKeysAreSubsetOfAllowList(): void + { + $product = $this->createProduct('mcp-contract-product', 1); + $output = $this->serializer->toArray($product); + + $this->assertSubsetOfAllowList(Product::class, $output); + } + + public function testCustomerOutputKeysAreSubsetOfAllowList(): void + { + $customer = $this->createCustomer('mcp-contract-customer@example.com'); + $output = $this->serializer->toArray($customer); + + $this->assertSubsetOfAllowList(Customer::class, $output); + } + + public function testOrderOutputKeysAreSubsetOfAllowList(): void + { + $customer = $this->createCustomer('mcp-contract-order@example.com'); + $order = $this->createOrder($customer); + // PROCESSING (デフォルト) のままだと検索系で除外されるが、 シリアライズ自体には影響しない + $orderStatusRepo = $this->entityManager->getRepository(OrderStatus::class); + $order->setOrderStatus($orderStatusRepo->find(OrderStatus::NEW)); + $this->entityManager->flush(); + + $output = $this->serializer->toArray($order); + + $this->assertSubsetOfAllowList(Order::class, $output); + } + + public function testAllowListIsNotEmptyForCoreEntitiesWhenApi44Installed(): void + { + // Api44 が install されていれば、 これらの entity は少なくとも 1 つ以上のプロパティが allow_list に乗る + foreach ([Product::class, Customer::class, Order::class] as $fqcn) { + $props = $this->resolver->getAllowedProperties($fqcn); + $this->assertNotEmpty($props, "{$fqcn} の allow_list が Api44 経由で取れている"); + } + } + + /** + * 出力 keys が allow_list の subset であり、 余分な key が無いことを確認。 + * + * @param array $output + */ + private function assertSubsetOfAllowList(string $entityFqcn, array $output): void + { + $allowed = $this->resolver->getAllowedProperties($entityFqcn); + $outputKeys = array_keys($output); + + $extra = array_diff($outputKeys, $allowed); + + $this->assertEmpty( + $extra, + sprintf( + '%s の出力に allow_list 外の key が含まれている: [%s]。 allow_list: [%s]', + $entityFqcn, + implode(', ', $extra), + implode(', ', $allowed), + ), + ); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Contract/Api44LifecycleContractTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/Api44LifecycleContractTest.php new file mode 100644 index 00000000000..45a338964de --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/Api44LifecycleContractTest.php @@ -0,0 +1,105 @@ +pluginRepository = static::getContainer()->get(PluginRepository::class); + // 'security.firewall.map' は private service ID で、 test container では class FQCN 解決できない + // ため文字列 ID で取得する (Symfony FrameworkBundle TestContainer の制約)。 Rector の + // ContainerGetNameToTypeInTestsRector は rector.php で本ファイルだけ skip 指定済み。 + $this->firewallMap = static::getContainer()->get('security.firewall.map'); + } + + public function testApi44IsInstalledAndEnabled(): void + { + $plugin = $this->pluginRepository->findByCode('Api44'); + + $this->assertInstanceOf(Plugin::class, $plugin, 'Api44 が install されている (テスト DB に dtb_plugin レコードあり)'); + $this->assertTrue($plugin->isEnabled(), 'Api44 が enabled'); + $this->assertTrue($plugin->isInitialized(), 'Api44 が initialized'); + } + + public function testMcpFirewallIsMappedForAdminMcpPath(): void + { + $request = Request::create('/'.$this->getAdminRoute().'/mcp'); + $config = $this->firewallMap->getFirewallConfig($request); + + $this->assertInstanceOf(FirewallConfig::class, $config, '/admin/mcp に対する firewall が解決される'); + $this->assertSame('mcp', $config->getName(), 'mcp firewall (Api44 が prepend) が当たる'); + $this->assertTrue($config->isStateless(), 'stateless = OAuth2 resource server 動作'); + } + + public function testNonMcpAdminPathStillUsesAdminFirewall(): void + { + // /admin/dashboard 等の通常 admin パスは admin firewall に当たる (cookie based) + $request = Request::create('/'.$this->getAdminRoute().'/'); + $config = $this->firewallMap->getFirewallConfig($request); + + $this->assertInstanceOf(FirewallConfig::class, $config); + $this->assertSame('admin', $config->getName(), '通常 admin path は cookie based admin firewall'); + } + + public function testMcpRoleConstantsAreDefinedForAllDomains(): void + { + // 設計 §4.1: 4 領域 (product / order / customer / plugin) の read scope に対応する role 定数 + $expected = [ + McpScope::ROLE_PRODUCT_READ => 'ROLE_OAUTH2_MCP:PRODUCT:READ', + McpScope::ROLE_ORDER_READ => 'ROLE_OAUTH2_MCP:ORDER:READ', + McpScope::ROLE_CUSTOMER_READ => 'ROLE_OAUTH2_MCP:CUSTOMER:READ', + McpScope::ROLE_PLUGIN_READ => 'ROLE_OAUTH2_MCP:PLUGIN:READ', + ]; + + foreach ($expected as $constantValue => $expectedString) { + $this->assertSame($expectedString, $constantValue); + } + } + + private function getAdminRoute(): string + { + $route = static::getContainer()->getParameter('eccube_admin_route'); + \assert(\is_string($route)); + + return $route; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Contract/McpAuditLogIsolationContractTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/McpAuditLogIsolationContractTest.php new file mode 100644 index 00000000000..3919671903b --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpAuditLogIsolationContractTest.php @@ -0,0 +1,95 @@ +get('monolog.logger.mcp'); + $this->assertInstanceOf(LoggerInterface::class, $logger); + $logger->info($probe); + + $logDir = $this->currentLogDir(); + + // 専用ファイル mcp.log (rotating → mcp-YYYY-MM-DD.log) に書かれる + $mcpLogs = glob($logDir.'/mcp*.log') ?: []; + $this->assertNotEmpty($mcpLogs, 'mcp 専用ログファイルが生成される'); + $this->assertTrue( + $this->anyContains($mcpLogs, $probe), + 'mcp チャネルのログは mcp.log に書かれる', + ); + + // site.log に漏れない (site ログハンドラが存在する環境でのみ意味を持つ) + foreach (glob($logDir.'/site*.log') ?: [] as $siteLog) { + $this->assertStringNotContainsString( + $probe, + (string) file_get_contents($siteLog), + 'mcp チャネルのログが site.log に漏れている', + ); + } + } + + public function testProdAndDevMainHandlerExcludesMcpChannel(): void + { + $root = (string) static::getContainer()->getParameter('kernel.project_dir'); + + foreach (['prod', 'dev'] as $env) { + $config = Yaml::parseFile($root.'/app/config/eccube/packages/'.$env.'/monolog.yml'); + $channels = $config['monolog']['handlers']['main']['channels'] ?? []; + + $this->assertContains( + '!mcp', + $channels, + sprintf('%s の main(site.log) ハンドラは mcp チャネルを除外する必要がある', $env), + ); + } + } + + /** + * @param list $files + */ + private function anyContains(array $files, string $needle): bool + { + foreach ($files as $file) { + if (str_contains((string) file_get_contents($file), $needle)) { + return true; + } + } + + return false; + } + + private function currentLogDir(): string + { + $container = static::getContainer(); + + return $container->getParameter('kernel.logs_dir').'/'.$container->getParameter('kernel.environment'); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Contract/McpFirewallContractTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/McpFirewallContractTest.php new file mode 100644 index 00000000000..0bca83d0b61 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpFirewallContractTest.php @@ -0,0 +1,94 @@ +client->request( + Request::METHOD_POST, + '/'.$this->getAdminRoute().'/mcp', + server: ['CONTENT_TYPE' => 'application/json'], + content: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"t","version":"1"},"capabilities":{}}}', + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode(), 'Bearer なしは admin Cookie firewall ではなく oauth2 firewall で 401'); + } + + public function testReturns401WithInvalidBearer(): void + { + $this->client->request( + Request::METHOD_POST, + '/'.$this->getAdminRoute().'/mcp', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer invalid-opaque-token-that-does-not-match-any-jwt', + ], + content: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"t","version":"1"},"capabilities":{}}}', + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode(), '不正な Bearer は league の JWT 検証で 401'); + } + + public function testReturns401WithMalformedJwt(): void + { + // 「JWT に見える」 が署名が不正な値。 league の SignedJWT validator で reject される + $this->client->request( + Request::METHOD_POST, + '/'.$this->getAdminRoute().'/mcp', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtY3AtdGVzdCJ9.invalid_signature', + ], + content: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"t","version":"1"},"capabilities":{}}}', + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode(), '署名不正な JWT は 401'); + } + + private function getAdminRoute(): string + { + $route = static::getContainer()->getParameter('eccube_admin_route'); + \assert(\is_string($route)); + + return $route; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Contract/McpScopeEnforcementIntegrationTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/McpScopeEnforcementIntegrationTest.php new file mode 100644 index 00000000000..6812bbe38f1 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpScopeEnforcementIntegrationTest.php @@ -0,0 +1,199 @@ +clientManager = static::getContainer()->get(ClientManagerInterface::class); + $this->accessTokenManager = static::getContainer()->get(AccessTokenManagerInterface::class); + } + + public function testToolCallSucceedsWhenScopeGranted(): void + { + $jwt = $this->issueScopedJwt(['mcp:product:read']); + + $result = $this->callTool($jwt, 'search_products', ['limit' => 1]); + + $this->assertArrayHasKey('result', $result, (string) json_encode($result)); + $this->assertNotTrue($result['result']['isError'] ?? false, 'scope 充足の tool は isError にならない'); + $this->assertArrayHasKey('structuredContent', $result['result']); + } + + public function testToolCallDeniedWhenScopeMissing(): void + { + // product scope のみの token で order tool を呼ぶ + $jwt = $this->issueScopedJwt(['mcp:product:read']); + + $result = $this->callTool($jwt, 'search_orders', ['limit' => 1]); + + $this->assertTrue($result['result']['isError'] ?? false, 'scope 不足の tool は isError:true'); + $text = $result['result']['content'][0]['text'] ?? ''; + $this->assertStringContainsString('Insufficient scope: mcp:order:read', (string) $text); + } + + /** + * initialize → notifications/initialized → tools/call の handshake を実カーネルに流し、 + * tools/call の JSON-RPC レスポンス (デコード済み) を返す。 + * + * @param array $arguments + * + * @return array + */ + private function callTool(string $jwt, string $toolName, array $arguments): array + { + $path = '/'.$this->getAdminRoute().'/mcp'; + $headers = [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json, text/event-stream', + 'HTTP_AUTHORIZATION' => 'Bearer '.$jwt, + ]; + + $this->client->request(Request::METHOD_POST, $path, server: $headers, content: (string) json_encode([ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-03-26', 'clientInfo' => ['name' => 'it', 'version' => '1'], 'capabilities' => []], + ])); + $initResponse = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $initResponse->getStatusCode(), (string) $initResponse->getContent()); + $sessionId = $initResponse->headers->get('mcp-session-id'); + $this->assertNotNull($sessionId, 'initialize で Mcp-Session-Id が返る'); + + $headers['HTTP_MCP_SESSION_ID'] = $sessionId; + $this->client->request(Request::METHOD_POST, $path, server: $headers, content: (string) json_encode([ + 'jsonrpc' => '2.0', 'method' => 'notifications/initialized', + ])); + + $this->client->request(Request::METHOD_POST, $path, server: $headers, content: (string) json_encode([ + 'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', + 'params' => ['name' => $toolName, 'arguments' => $arguments], + ])); + + return $this->decodeJsonRpc((string) $this->client->getResponse()->getContent()); + } + + /** + * JSON または SSE (data: 行) のどちらでも JSON-RPC ボディをデコードする。 + * + * @return array + */ + private function decodeJsonRpc(string $body): array + { + foreach (explode("\n", $body) as $line) { + $line = str_starts_with($line, 'data: ') ? substr($line, 6) : $line; + $line = trim($line); + if ('' === $line) { + continue; + } + $decoded = json_decode($line, true); + if (\is_array($decoded) && isset($decoded['jsonrpc'])) { + return $decoded; + } + } + + $this->fail('JSON-RPC レスポンスをデコードできなかった: '.$body); + } + + /** + * 指定 scope を claim に持つ JWT を発行する (revoked-check 用の AccessToken Model も保存)。 + * + * @param list $scopes + */ + private function issueScopedJwt(array $scopes): string + { + $member = $this->createMember(); + $client = $this->ensureClient(); + $identifier = 'mcp-scope-it-'.uniqid(); + $expiry = new \DateTimeImmutable('+1 hour'); + $userIdentifier = $member->getUsername(); + + // revoked 照合用に Model を保存 (scope は JWT claim 側で表現するので Model は空で可) + $this->accessTokenManager->save(new AccessTokenModel($identifier, $expiry, $client, $userIdentifier, [])); + + $tokenEntity = new AccessTokenEntity(); + $tokenEntity->setIdentifier($identifier); + $tokenEntity->setExpiryDateTime($expiry); + $tokenEntity->setUserIdentifier($userIdentifier); + $clientEntity = new ClientEntity(); + $clientEntity->setIdentifier($client->getIdentifier()); + $tokenEntity->setClient($clientEntity); + foreach ($scopes as $scope) { + $scopeEntity = new ScopeEntity(); + $scopeEntity->setIdentifier($scope); + $tokenEntity->addScope($scopeEntity); + } + + $privateKeyPath = static::getContainer()->getParameter('kernel.project_dir').'/app/PluginData/Api44/oauth/private.key'; + \assert(\is_string($privateKeyPath)); + $tokenEntity->setPrivateKey(new CryptKey($privateKeyPath, null, keyPermissionsCheck: false)); + + return $tokenEntity->toString(); + } + + private function ensureClient(): ClientInterface + { + $existing = $this->clientManager->find(self::TEST_CLIENT_ID); + if (null !== $existing) { + return $existing; + } + + $client = new ClientModel('MCP scope integration test', self::TEST_CLIENT_ID, null); + $client->setScopes(new ScopeValue('mcp:product:read'), new ScopeValue('mcp:order:read')); + $client->setGrants(new Grant('authorization_code'), new Grant('refresh_token')); + $this->clientManager->save($client); + + return $client; + } + + private function getAdminRoute(): string + { + $route = static::getContainer()->getParameter('eccube_admin_route'); + \assert(\is_string($route)); + + return $route; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Contract/McpTokenRevocationContractTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/McpTokenRevocationContractTest.php new file mode 100644 index 00000000000..d1b37d455fd --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpTokenRevocationContractTest.php @@ -0,0 +1,203 @@ +clientManager = static::getContainer()->get(ClientManagerInterface::class); + $this->accessTokenManager = static::getContainer()->get(AccessTokenManagerInterface::class); + } + + public function testValidJwtIsAcceptedByFirewall(): void + { + $member = $this->createMember(); + $client = $this->ensureClient(); + $jwt = $this->issueJwt('valid-token-'.uniqid(), $client, $member, revoked: false); + + $this->mcpRequest($jwt); + + $response = $this->client->getResponse(); + $body = (string) $response->getContent(); + // 200 + JSON-RPC result まで見ることで、 firewall を通過し initialize handshake が成立したことを確認する + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), $body); + + $decoded = json_decode($body, true); + $this->assertIsArray($decoded, $body); + $this->assertArrayHasKey('result', $decoded, 'initialize の JSON-RPC result が返る (handshake 成立)'); + $this->assertArrayNotHasKey('error', $decoded); + } + + public function testRevokedAccessTokenReturns401(): void + { + $member = $this->createMember(); + $client = $this->ensureClient(); + $identifier = 'revoked-token-'.uniqid(); + $jwt = $this->issueJwt($identifier, $client, $member, revoked: false); + + // 同じ identifier を `revoked=true` で再保存 → league の AccessTokenRepository::isAccessTokenRevoked が true を返す + $token = $this->accessTokenManager->find($identifier); + $this->assertInstanceOf(AccessTokenInterface::class, $token); + $token->revoke(); + $this->accessTokenManager->save($token); + + $this->mcpRequest($jwt); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode(), 'revoked token は 401'); + // WWW-Authenticate: Bearer で「oauth2 の bearer 拒否」 を確認し、 無関係な 401 (誤ルーティング等) を排除する + $this->assertSame('Bearer', $response->headers->get('WWW-Authenticate')); + } + + public function testDisabledMemberReturns401(): void + { + $member = $this->createMember(); + $client = $this->ensureClient(); + $jwt = $this->issueJwt('disabled-member-token-'.uniqid(), $client, $member, revoked: false); + + // Member を「削除済」 (Work=HIDDEN) に変更 → MemberProvider でロードできない or UserChecker で reject + $this->disableMember($member); + + $this->mcpRequest($jwt); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode(), '無効化された Member の token は 401'); + $this->assertSame('Bearer', $response->headers->get('WWW-Authenticate')); + // token 検証は通り user 解決 (MemberProvider) で失敗する経路。 token 拒否 (revoke / 署名不正) とは + // body が分かれる (Symfony Security の "Bad credentials")。 これで Member 無効化の経路を識別する + $this->assertStringContainsString('Bad credentials', (string) $response->getContent()); + } + + private function mcpRequest(string $bearerJwt): void + { + $this->client->request( + Request::METHOD_POST, + '/'.$this->getAdminRoute().'/mcp', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer '.$bearerJwt, + ], + content: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"r","version":"1"},"capabilities":{}}}', + ); + } + + private function ensureClient(): ClientInterface + { + $existing = $this->clientManager->find(self::TEST_CLIENT_ID); + if (null !== $existing) { + return $existing; + } + + $client = new ClientModel('MCP revocation test', self::TEST_CLIENT_ID, null); + $client->setScopes(new ScopeValue('mcp:product:read')); + $client->setGrants(new Grant('authorization_code'), new Grant('refresh_token')); + $this->clientManager->save($client); + + return $client; + } + + /** + * AccessToken Model を保存し、 同じ identifier の JWT を発行して返す。 + */ + private function issueJwt(string $identifier, ClientInterface $client, Member $member, bool $revoked): string + { + $expiry = new \DateTimeImmutable('+1 hour'); + // MemberProvider::loadUserByIdentifier は login_id で検索する。 sub claim には login_id を入れる。 + $userIdentifier = $member->getUsername(); + + // 1) Model を DB に保存 (league の isAccessTokenRevoked 照合先) + $tokenModel = new AccessTokenModel($identifier, $expiry, $client, $userIdentifier, []); + if ($revoked) { + $tokenModel->revoke(); + } + $this->accessTokenManager->save($tokenModel); + + // 2) Bearer 用の JWT を発行 (通常の /token エンドポイント経由の応答と同等) + $tokenEntity = new AccessTokenEntity(); + $tokenEntity->setIdentifier($identifier); + $tokenEntity->setExpiryDateTime($expiry); + $tokenEntity->setUserIdentifier($userIdentifier); + + $clientEntity = new ClientEntity(); + $clientEntity->setIdentifier($client->getIdentifier()); + $tokenEntity->setClient($clientEntity); + + $privateKeyPath = static::getContainer()->getParameter('kernel.project_dir').'/app/PluginData/Api44/oauth/private.key'; + \assert(\is_string($privateKeyPath)); + // テスト環境では private key の permission チェックを無効化 (CI / docker 環境差異への対応) + $tokenEntity->setPrivateKey(new CryptKey($privateKeyPath, null, keyPermissionsCheck: false)); + + return $tokenEntity->toString(); + } + + private function disableMember(Member $member): void + { + // MemberProvider は Work::ACTIVE のみロードする。 Work::NON_ACTIVE にすると次回 loadUserByIdentifier で 401 + $workRepo = $this->entityManager->getRepository(Work::class); + $nonActive = $workRepo->find(Work::NON_ACTIVE); + $this->assertInstanceOf(Work::class, $nonActive); + $member->setWork($nonActive); + $this->entityManager->flush(); + } + + private function getAdminRoute(): string + { + $route = static::getContainer()->getParameter('eccube_admin_route'); + \assert(\is_string($route)); + + return $route; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Contract/McpToolScopeMapContractTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/McpToolScopeMapContractTest.php new file mode 100644 index 00000000000..1a404b3b9f5 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpToolScopeMapContractTest.php @@ -0,0 +1,89 @@ +discoverToolNames(); + $this->assertNotEmpty($toolNames, 'Tool ディレクトリから #[McpTool] を発見できる'); + + foreach ($toolNames as $toolName) { + $this->assertNotNull( + McpToolScopeMap::requiredRole($toolName), + sprintf('Tool "%s" の必要 scope が McpToolScopeMap に登録されていない (fail-closed で全 deny になる)', $toolName), + ); + } + } + + public function testScopeMapHasNoStaleEntries(): void + { + $toolNames = $this->discoverToolNames(); + + foreach (array_keys(McpToolScopeMap::MAP) as $mappedName) { + $this->assertContains( + $mappedName, + $toolNames, + sprintf('McpToolScopeMap の "%s" は実在する Tool に対応していない (typo / 削除済み Tool の残骸)', $mappedName), + ); + } + } + + /** + * `src/Eccube/Service/Mcp/Tool/` を走査し、 各クラスの `#[McpTool]` 属性から tool 名を集める。 + * + * @return list + */ + private function discoverToolNames(): array + { + $toolDir = \dirname((string) (new \ReflectionClass(SearchProductsTool::class))->getFileName()); + $files = glob($toolDir.'/*.php'); + $this->assertIsArray($files); + + $names = []; + foreach ($files as $file) { + $class = 'Eccube\\Service\\Mcp\\Tool\\'.basename($file, '.php'); + if (!class_exists($class)) { + continue; + } + + foreach ((new \ReflectionClass($class))->getMethods() as $method) { + foreach ($method->getAttributes(McpTool::class) as $attribute) { + /** @var McpTool $instance */ + $instance = $attribute->newInstance(); + $names[] = $instance->name; + } + } + } + + return $names; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Contract/ToolsListContractTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/ToolsListContractTest.php new file mode 100644 index 00000000000..937873f8d3c --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/ToolsListContractTest.php @@ -0,0 +1,106 @@ +> [tool name => [class名]] + */ + public static function provideExpectedTools(): array + { + return [ + 'search_products' => [SearchProductsTool::class], + 'get_product' => [GetProductTool::class], + 'get_product_stock' => [GetProductStockTool::class], + 'search_orders' => [SearchOrdersTool::class], + 'get_order' => [GetOrderTool::class], + 'get_shipping' => [GetShippingTool::class], + 'search_customers' => [SearchCustomersTool::class], + 'get_customer' => [GetCustomerTool::class], + 'get_customer_orders' => [GetCustomerOrdersTool::class], + 'list_plugins' => [ListPluginsTool::class], + 'get_plugin' => [GetPluginTool::class], + ]; + } + + public function testElevenToolsAreRegistered(): void + { + $this->assertCount(11, self::provideExpectedTools(), '設計 §8 #1 「全 11 ツール」 と一致'); + } + + #[DataProvider(methodName: 'provideExpectedTools')] + public function testEachToolIsContainerRegistered(string $toolClass): void + { + $instance = static::getContainer()->get($toolClass); + $this->assertNotNull($instance, "{$toolClass} が DI コンテナに登録されている"); + $this->assertInstanceOf($toolClass, $instance); + } + + #[DataProvider(methodName: 'provideExpectedTools')] + public function testEachToolHasMcpToolAttribute(string $toolClass): void + { + $expectedName = $this->resolveExpectedName($toolClass); + + $reflection = new \ReflectionClass($toolClass); + $attributes = []; + foreach ($reflection->getMethods() as $method) { + foreach ($method->getAttributes(McpTool::class) as $attr) { + /** @var McpTool $instance */ + $instance = $attr->newInstance(); + $attributes[] = $instance->name; + } + } + + $this->assertCount(1, $attributes, "{$toolClass} は #[McpTool] を 1 つ持つ"); + $this->assertSame($expectedName, $attributes[0], "{$toolClass} の Tool name が期待値と一致"); + } + + private function resolveExpectedName(string $toolClass): string + { + foreach (self::provideExpectedTools() as $name => [$class]) { + if ($class === $toolClass) { + return $name; + } + } + + throw new \LogicException("Unknown tool class: {$toolClass}"); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/EntityArraySerializerTest.php b/tests/Eccube/Tests/Service/Mcp/EntityArraySerializerTest.php new file mode 100644 index 00000000000..b359d40b8c0 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/EntityArraySerializerTest.php @@ -0,0 +1,331 @@ +assertSame([], $serializer->toArray(new SerializerDummyEntity())); + } + + public function testScalarPropertiesPassThrough(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'name', 'active'], + ]); + + $entity = new SerializerDummyEntity(); + $entity->id = 42; + $entity->name = 'foo'; + $entity->active = true; + + $this->assertSame( + ['id' => 42, 'name' => 'foo', 'active' => true], + $serializer->toArray($entity), + ); + } + + public function testDateTimeFormattedAsAtom(): void + { + $serializer = $this->serializerWith([SerializerDummyEntity::class => ['createDate']]); + $entity = new SerializerDummyEntity(); + $entity->createDate = new \DateTime('2026-06-04T12:34:56+09:00'); + + $this->assertSame( + ['createDate' => '2026-06-04T12:34:56+09:00'], + $serializer->toArray($entity), + ); + } + + public function testRelatedEntityRecurses(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'related'], + SerializerDummyRelated::class => ['code'], + ]); + + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->related = new SerializerDummyRelated(); + $entity->related->code = 'X-001'; + + $this->assertSame( + ['id' => 1, 'related' => ['code' => 'X-001']], + $serializer->toArray($entity), + ); + } + + public function testCollectionExpanded(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'items'], + SerializerDummyRelated::class => ['code'], + ]); + + $a = new SerializerDummyRelated(); + $a->code = 'A'; + $b = new SerializerDummyRelated(); + $b->code = 'B'; + + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->items = new ArrayCollection([$a, $b]); + + $this->assertSame( + ['id' => 1, 'items' => [['code' => 'A'], ['code' => 'B']]], + $serializer->toArray($entity), + ); + } + + public function testMaxDepthSummarizesDeepRelations(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['related'], + SerializerDummyRelated::class => ['nested'], + SerializerDummyNested::class => ['inner'], + SerializerDummyInner::class => ['id', 'name'], + ]); + + $inner = new SerializerDummyInner(); + $inner->id = 5; + $inner->name = 'deep'; + $nested = new SerializerDummyNested(); + $nested->inner = $inner; + $related = new SerializerDummyRelated(); + $related->nested = $nested; + $entity = new SerializerDummyEntity(); + $entity->related = $related; + + // maxDepth = 2 → entity(d0) → related(d1) → nested(d2) → inner(d3) は要約 (id のみ) + $result = $serializer->toArray($entity, maxDepth: 2); + + $this->assertSame( + ['related' => ['nested' => ['inner' => ['id' => 5]]]], + $result, + ); + } + + public function testSummaryOmitsIdWhenNotAllowed(): void + { + // 深さ超過の要約でも「allow_list のみ公開」を守る。 getId があっても allow_list に 'id' が + // 無い関連 Entity は、 縮退経路で内部 ID を露出させない。 + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['related'], + SerializerDummyRelated::class => ['nested'], + SerializerDummyNested::class => ['inner'], + SerializerDummyInner::class => ['name'], // 'id' を意図的に外す (getId は存在する) + ]); + + $inner = new SerializerDummyInner(); + $inner->id = 5; + $inner->name = 'deep'; + $nested = new SerializerDummyNested(); + $nested->inner = $inner; + $related = new SerializerDummyRelated(); + $related->nested = $nested; + $entity = new SerializerDummyEntity(); + $entity->related = $related; + + // maxDepth=2 → inner(d3) は要約。 allow_list に 'id' が無いので空要約になる。 + $result = $serializer->toArray($entity, maxDepth: 2); + + $this->assertSame( + ['related' => ['nested' => ['inner' => []]]], + $result, + ); + } + + public function testCircularReferenceSummarized(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'related'], + SerializerDummyRelated::class => ['back'], + ]); + + $entity = new SerializerDummyEntity(); + $entity->id = 7; + $related = new SerializerDummyRelated(); + $entity->related = $related; + $related->back = $entity; + + $result = $serializer->toArray($entity); + + // 循環: entity → related → back == entity (visited) → 要約 (id のみ) + $this->assertSame( + ['id' => 7, 'related' => ['back' => ['id' => 7]]], + $result, + ); + } + + public function testUnknownEntityYieldsEmptyArray(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['related'], + // SerializerDummyRelated は allow_list 未登録 + ]); + $entity = new SerializerDummyEntity(); + $entity->related = new SerializerDummyRelated(); + $entity->related->code = 'leaked?'; + + $result = $serializer->toArray($entity); + + // related は allow_list 未登録 → 空配列で返る (= プロパティが漏れない) + $this->assertSame(['related' => []], $result); + } + + public function testResolvesEntityClassThroughDoctrineProxy(): void + { + // 実機での `Status: []` バグの再現テスト: + // Doctrine が Lazy Proxy (Proxies\__CG__\... の自動生成 class) を返した時に、 + // allow_list が proxy class 名で引かれて未登録扱いになり空配列が返る問題。 + // 修正後は親クラス (= 実 entity FQCN) で lookup されるため正しく展開される。 + $serializer = $this->serializerWith([ + SerializerProxiableEntity::class => ['id', 'name'], + ]); + + $proxy = new class extends SerializerProxiableEntity implements Proxy { + #[\Override] + public function __load(): void + { + } + + #[\Override] + public function __isInitialized(): bool + { + return true; + } + }; + $proxy->id = 99; + $proxy->name = 'proxied'; + + $result = $serializer->toArray($proxy); + + $this->assertSame(['id' => 99, 'name' => 'proxied'], $result); + } + + public function testDefaultMaxDepthOneSummarizesSiblingInnerRelations(): void + { + // デフォルト maxDepth=1 の検証。 root の直下 (depth 1) までは展開、 さらにその子 (depth 2) + // は要約に縮退。 get_product_stock などで sibling Entity の中身が大量に重複表示される + // ノイズを抑止するための仕様変更 (旧デフォルト 2 → 1)。 + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'related'], + SerializerDummyRelated::class => ['code', 'nested'], + SerializerDummyNested::class => ['inner'], + SerializerDummyInner::class => ['id'], + ]); + + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->related = new SerializerDummyRelated(); + $entity->related->code = 'A'; + $entity->related->nested = new SerializerDummyNested(); + $entity->related->nested->inner = new SerializerDummyInner(); + $entity->related->nested->inner->id = 100; + + $result = $serializer->toArray($entity); // 引数省略 = DEFAULT_MAX_DEPTH (1) + + // related (depth 1) は full、 nested (depth 2) は要約 (SerializerDummyNested に getId 無し → 空 []) + $this->assertSame( + ['id' => 1, 'related' => ['code' => 'A', 'nested' => []]], + $result, + ); + } + + /** + * @param array> $allowMap + */ + private function serializerWith(array $allowMap): EntityArraySerializer + { + return new EntityArraySerializer(new AllowListResolver([new FakeAllowList($allowMap)])); + } +} + +/** @internal テスト用ダミー */ +final class SerializerDummyEntity +{ + public ?int $id = null; + public ?string $name = null; + public ?bool $active = null; + public ?\DateTime $createDate = null; + public ?SerializerDummyRelated $related = null; + + /** @var ArrayCollection|null */ + public ?ArrayCollection $items = null; + + public function getId(): ?int + { + return $this->id; + } +} + +/** @internal */ +final class SerializerDummyRelated +{ + public ?string $code = null; + public ?SerializerDummyNested $nested = null; + public ?SerializerDummyEntity $back = null; + + public function getId(): ?int + { + return null === $this->code ? null : crc32($this->code); + } +} + +/** @internal */ +final class SerializerDummyNested +{ + public ?SerializerDummyInner $inner = null; +} + +/** @internal Doctrine Proxy 互換テスト用 (non-final で extend 可) */ +class SerializerProxiableEntity +{ + public ?int $id = null; + public ?string $name = null; + + public function getId(): ?int + { + return $this->id; + } +} + +/** @internal */ +final class SerializerDummyInner +{ + public ?int $id = null; + public ?string $name = null; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/FakeAllowList.php b/tests/Eccube/Tests/Service/Mcp/FakeAllowList.php new file mode 100644 index 00000000000..bec71f62226 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/FakeAllowList.php @@ -0,0 +1,37 @@ +|\ArrayObject $allows + */ + public function __construct( + public array|\ArrayObject $allows, + ) { + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/McpAuditLoggerTest.php b/tests/Eccube/Tests/Service/Mcp/McpAuditLoggerTest.php new file mode 100644 index 00000000000..e5ed1948b11 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/McpAuditLoggerTest.php @@ -0,0 +1,141 @@ +captureLogger(); + $auditLogger = new McpAuditLogger($logger, new RequestStack()); + + $auditLogger->logToolCall( + toolName: 'search_products', + args: ['limit' => 20], + result: AuditResult::Success, + durationMs: 12.3, + resultSummary: ['total' => 5], + ); + + $records = $logger->records; + $this->assertCount(1, $records); + $this->assertSame('info', $records[0]['level']); + $this->assertSame('mcp.tool.search_products', $records[0]['message']); + $this->assertSame('search_products', $records[0]['context']['tool_name']); + $this->assertSame(['limit' => 20], $records[0]['context']['tool_args']); + $this->assertSame('success', $records[0]['context']['result_status']); + $this->assertSame(['total' => 5], $records[0]['context']['result_summary']); + $this->assertEqualsWithDelta(12.3, $records[0]['context']['duration_ms'], PHP_FLOAT_EPSILON); + } + + public function testScopeDeniedLogsAtWarningLevel(): void + { + $logger = $this->captureLogger(); + $auditLogger = new McpAuditLogger($logger, new RequestStack()); + + $auditLogger->logToolCall('search_products', [], AuditResult::ScopeDenied, 0.5); + + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertSame('scope_denied', $logger->records[0]['context']['result_status']); + } + + public function testInternalErrorLogsAtErrorLevel(): void + { + $logger = $this->captureLogger(); + $auditLogger = new McpAuditLogger($logger, new RequestStack()); + + $auditLogger->logToolCall('search_products', [], AuditResult::InternalError, 0.5); + + $this->assertSame('error', $logger->records[0]['level']); + } + + public function testSecurityEventUsesWarningLevel(): void + { + $logger = $this->captureLogger(); + $auditLogger = new McpAuditLogger($logger, new RequestStack()); + + $auditLogger->logSecurityEvent(AuditResult::OriginInvalid, ['origin' => 'http://evil']); + + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertSame('mcp.security.origin_invalid', $logger->records[0]['message']); + $this->assertSame('http://evil', $logger->records[0]['context']['origin']); + } + + public function testRequestIdIsStableWithinSameRequest(): void + { + $logger = $this->captureLogger(); + $stack = new RequestStack(); + $stack->push(new Request()); + $auditLogger = new McpAuditLogger($logger, $stack); + + $auditLogger->logToolCall('a', [], AuditResult::Success, 1.0); + $auditLogger->logSecurityEvent(AuditResult::OriginInvalid); + + $first = $logger->records[0]['context']['request_id']; + $second = $logger->records[1]['context']['request_id']; + $this->assertNotNull($first); + $this->assertSame($first, $second, '同一リクエスト中は request_id が一定'); + } + + public function testRequestIdFallbackWhenNoRequest(): void + { + $logger = $this->captureLogger(); + $auditLogger = new McpAuditLogger($logger, new RequestStack()); + + $auditLogger->logToolCall('a', [], AuditResult::Success, 1.0); + + $requestId = $logger->records[0]['context']['request_id']; + $this->assertIsString($requestId); + $this->assertNotSame('', $requestId); + } + + private function captureLogger(): CapturingLogger + { + return new CapturingLogger(); + } +} + +/** + * @internal テストでログレコードを収集する PSR-3 ロガー。 名前付きクラスにしている理由は + * PHPStan が anonymous class の `->records` アクセスを type-narrow できないため。 + */ +final class CapturingLogger extends AbstractLogger +{ + /** @var list}> */ + public array $records = []; + + #[\Override] + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->records[] = [ + 'level' => (string) $level, + 'message' => (string) $message, + 'context' => $context, + ]; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/RecordingReferenceHandler.php b/tests/Eccube/Tests/Service/Mcp/RecordingReferenceHandler.php new file mode 100644 index 00000000000..10b6b4abe0a --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/RecordingReferenceHandler.php @@ -0,0 +1,39 @@ +calls; + + return $this->returnValue; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/ScopeEnforcingReferenceHandlerTest.php b/tests/Eccube/Tests/Service/Mcp/ScopeEnforcingReferenceHandlerTest.php new file mode 100644 index 00000000000..087d0bdb4c9 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/ScopeEnforcingReferenceHandlerTest.php @@ -0,0 +1,150 @@ +buildHandler($inner, [McpScope::ROLE_PRODUCT_READ]); + + $result = $handler->handle($this->toolReference('search_products'), ['_session' => null]); + + $this->assertSame('INNER_RESULT', $result); + $this->assertSame(1, $inner->calls, 'scope 充足時は inner に委譲される'); + } + + public function testThrowsAndSkipsInnerWhenScopeInsufficient(): void + { + $inner = new RecordingReferenceHandler('INNER_RESULT'); + // order scope を持たない token で order tool を呼ぶ + $handler = $this->buildHandler($inner, [McpScope::ROLE_PRODUCT_READ]); + + try { + $handler->handle($this->toolReference('search_orders'), ['_session' => null]); + $this->fail('ToolCallException が投げられるべき'); + } catch (ToolCallException $e) { + $this->assertStringContainsString('Insufficient scope: mcp:order:read', $e->getMessage()); + } + + $this->assertSame(0, $inner->calls, 'scope 不足時は inner を呼ばない'); + } + + public function testFailClosedForUnmappedTool(): void + { + $inner = new RecordingReferenceHandler('INNER_RESULT'); + // 全 scope を与えても、 中央マップ未登録の tool は呼べない + $handler = $this->buildHandler($inner, [ + McpScope::ROLE_PRODUCT_READ, + McpScope::ROLE_ORDER_READ, + McpScope::ROLE_CUSTOMER_READ, + McpScope::ROLE_PLUGIN_READ, + ]); + + try { + $handler->handle($this->toolReference('some_unregistered_tool'), ['_session' => null]); + $this->fail('未登録 tool は fail-closed で拒否されるべき'); + } catch (ToolCallException $e) { + $this->assertStringContainsString('no scope mapping', $e->getMessage()); + } + + $this->assertSame(0, $inner->calls, '未登録 tool は inner を呼ばない'); + } + + public function testPassesThroughNonToolReference(): void + { + $inner = new RecordingReferenceHandler('PROMPT_RESULT'); + // scope を一切持たない token でも、 prompt 参照は scope 検査されず素通し + $handler = $this->buildHandler($inner, []); + + $result = $handler->handle($this->promptReference('some_prompt'), ['_session' => null]); + + $this->assertSame('PROMPT_RESULT', $result); + $this->assertSame(1, $inner->calls, '非 Tool 参照は素通しで inner に委譲される'); + } + + /** + * @param list $roles token に付与する role + */ + private function buildHandler(ReferenceHandlerInterface $inner, array $roles): ScopeEnforcingReferenceHandler + { + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken(new UsernamePasswordToken( + new InMemoryUser('mcp-tester', null, $roles), + 'mcp', + $roles, + )); + + // role ベースの認可のみ必要 (scope は ROLE_OAUTH2_* role に変換済み) + $authChecker = new AuthorizationChecker( + $tokenStorage, + new AccessDecisionManager([new RoleVoter()]), + ); + + return new ScopeEnforcingReferenceHandler( + $inner, + new ScopeChecker($authChecker), + new McpAuditLogger(new NullLogger(), new RequestStack()), + ); + } + + private function toolReference(string $name): ToolReference + { + $tool = new Tool($name, null, ['type' => 'object', 'properties' => [], 'required' => null], $name, null); + + return new ToolReference($tool, static fn () => null); + } + + private function promptReference(string $name): PromptReference + { + $prompt = new Prompt($name); + + return new PromptReference($prompt, static fn () => null); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/GetCustomerOrdersToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/GetCustomerOrdersToolTest.php new file mode 100644 index 00000000000..46535e6b43d --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetCustomerOrdersToolTest.php @@ -0,0 +1,66 @@ +tool = static::getContainer()->get(GetCustomerOrdersTool::class); + } + + public function testReturnsCustomerOrders(): void + { + $customer = $this->createCustomer('mcp-customer-orders@example.com'); + $this->createOrder($customer); + $this->createOrder($customer); + + $result = $this->tool->get(customerId: $customer->getId(), limit: 50); + + $this->assertSame($customer->getId(), $result['customer_id']); + $this->assertGreaterThanOrEqual(2, $result['total']); + $this->assertSame(50, $result['limit']); + } + + public function testReturnsEmptyForUnknownCustomer(): void + { + $result = $this->tool->get(customerId: 99999999); + + $this->assertNull($result['customer_id']); + $this->assertSame(0, $result['total']); + $this->assertSame([], $result['items']); + } + + public function testLimitClamp(): void + { + $customer = $this->createCustomer('mcp-customer-clamp@example.com'); + + $result = $this->tool->get(customerId: $customer->getId(), limit: 999); + + $this->assertSame(200, $result['limit']); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/GetCustomerToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/GetCustomerToolTest.php new file mode 100644 index 00000000000..bd485b4f0ce --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetCustomerToolTest.php @@ -0,0 +1,52 @@ +tool = static::getContainer()->get(GetCustomerTool::class); + } + + public function testReturnsCustomerById(): void + { + $customer = $this->createCustomer('mcp-getcustomer@example.com'); + + $result = $this->tool->get(id: $customer->getId()); + + $this->assertSame($customer->getId(), $result['id']); + $this->assertSame('mcp-getcustomer@example.com', $result['email']); + } + + public function testReturnsEmptyWhenNotFound(): void + { + $result = $this->tool->get(id: 99999999); + + $this->assertSame(['found' => false], $result); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/GetOrderToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/GetOrderToolTest.php new file mode 100644 index 00000000000..3c0f1659629 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetOrderToolTest.php @@ -0,0 +1,70 @@ +tool = static::getContainer()->get(GetOrderTool::class); + } + + public function testReturnsOrderById(): void + { + $customer = $this->createCustomer('mcp-getorder@example.com'); + $order = $this->createOrder($customer); + + $result = $this->tool->get(id: $order->getId()); + + $this->assertSame($order->getId(), $result['id']); + $this->assertSame($order->getOrderNo(), $result['order_no']); + } + + public function testReturnsOrderByOrderNo(): void + { + $customer = $this->createCustomer('mcp-getorder-no@example.com'); + $order = $this->createOrder($customer); + + $result = $this->tool->get(orderNo: $order->getOrderNo()); + + $this->assertSame($order->getId(), $result['id']); + } + + public function testReturnsEmptyWhenNotFound(): void + { + $result = $this->tool->get(id: 99999999); + + $this->assertSame(['found' => false], $result); + } + + public function testReturnsEmptyWhenNeitherIdNorOrderNo(): void + { + $result = $this->tool->get(); + + $this->assertSame(['found' => false], $result); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/GetPluginToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/GetPluginToolTest.php new file mode 100644 index 00000000000..ac54e4ddad4 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetPluginToolTest.php @@ -0,0 +1,70 @@ +tool = static::getContainer()->get(GetPluginTool::class); + } + + public function testReturnsPluginByCode(): void + { + $result = $this->tool->get(code: 'Api44'); + + $this->assertSame('Api44', $result['code']); + $this->assertArrayHasKey('composer', $result); + } + + public function testReturnsEmptyWhenNotFound(): void + { + $result = $this->tool->get(code: 'NoSuchPlugin'); + + $this->assertSame(['found' => false], $result); + } + + public function testReturnsEmptyWhenNeitherIdNorCode(): void + { + $result = $this->tool->get(); + + $this->assertSame(['found' => false], $result); + } + + public function testIncludesComposerJsonDescriptionAndRequire(): void + { + $result = $this->tool->get(code: 'Api44'); + + $composer = $result['composer'] ?? null; + $this->assertIsArray($composer, 'composer キーが配列で含まれる'); + $this->assertArrayHasKey('description', $composer); + $this->assertArrayHasKey('require', $composer); + $this->assertIsArray($composer['require']); + // Api44 は league/oauth2-server-bundle を require している + $this->assertArrayHasKey('league/oauth2-server-bundle', $composer['require']); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/GetProductStockToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/GetProductStockToolTest.php new file mode 100644 index 00000000000..5ed5a11e097 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetProductStockToolTest.php @@ -0,0 +1,107 @@ +tool = static::getContainer()->get(GetProductStockTool::class); + } + + public function testReturnsStockForProductWithMultipleClasses(): void + { + $product = $this->createProduct('mcp-stock-multi', 3); + + $result = $this->tool->get(productId: $product->getId()); + + $this->assertArrayHasKey('summary', $result); + $this->assertArrayHasKey('items', $result); + $this->assertSame(3, $result['summary']['total_classes']); + $this->assertCount(3, $result['items']); + $this->assertArrayHasKey('total_stock', $result['summary']); + $this->assertArrayHasKey('stock_unlimited', $result['summary']); + } + + public function testReturnsStockForProductWithoutClasses(): void + { + // 規格数 0 を指定しても代表 ProductClass が 1 つ生成される + $product = $this->createProduct('mcp-stock-single', 0); + + $result = $this->tool->get(productId: $product->getId()); + + $this->assertSame(1, $result['summary']['total_classes']); + $this->assertCount(1, $result['items']); + } + + public function testReturnsEmptyForUnknownProduct(): void + { + $result = $this->tool->get(productId: 99999999); + + $this->assertSame(0, $result['summary']['total_classes']); + $this->assertSame([], $result['items']); + } + + public function testStockUnlimitedReflectedInSummary(): void + { + $product = $this->createProduct('mcp-stock-unlimited', 2); + + // 1 つの規格を在庫無制限に設定 + $first = $product->getProductClasses()->first(); + $this->assertNotFalse($first); + $first->setStockUnlimited(true); + $this->entityManager->flush(); + + $result = $this->tool->get(productId: $product->getId()); + + $this->assertTrue($result['summary']['stock_unlimited'], '無制限規格が 1 つあれば summary に反映'); + $this->assertNull($result['summary']['total_stock'], '無制限規格があるとき total_stock は null'); + } + + public function testItemFieldsAreSubsetOfAllowList(): void + { + $product = $this->createProduct('mcp-stock-allow', 1); + + $result = $this->tool->get(productId: $product->getId()); + + // ProductClass の allow_list (api44 services.yaml より) + $allowed = [ + 'id', 'code', 'stock', 'stock_unlimited', 'sale_limit', + 'price01', 'price02', 'delivery_fee', 'visible', 'create_date', + 'update_date', 'currency_code', 'point_rate', + 'ProductStock', 'TaxRule', 'Product', 'SaleType', + 'ClassCategory1', 'ClassCategory2', 'DeliveryDuration', 'Creator', + ]; + + $this->assertNotEmpty($result['items']); + foreach ($result['items'] as $item) { + foreach (array_keys($item) as $key) { + $this->assertContains($key, $allowed, sprintf('出力フィールド "%s" は ProductClass allow_list 外', $key)); + } + } + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/GetProductToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/GetProductToolTest.php new file mode 100644 index 00000000000..e649e62bcc1 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetProductToolTest.php @@ -0,0 +1,93 @@ +tool = static::getContainer()->get(GetProductTool::class); + } + + public function testReturnsProductById(): void + { + $product = $this->createProduct('mcp-get-001', 2); + + $result = $this->tool->get(id: $product->getId()); + + $this->assertArrayHasKey('id', $result); + $this->assertSame($product->getId(), $result['id']); + $this->assertArrayHasKey('name', $result); + $this->assertSame('mcp-get-001', $result['name']); + } + + public function testReturnsEmptyWhenNotFound(): void + { + $result = $this->tool->get(id: 99999999); + + $this->assertSame(['found' => false], $result, '不在 ID は found:false を返す'); + } + + public function testReturnsEmptyWhenNeitherIdNorCode(): void + { + $result = $this->tool->get(); + + $this->assertSame(['found' => false], $result, '両方未指定は found:false を返す'); + } + + public function testReturnsProductByCode(): void + { + $product = $this->createProduct('mcp-by-code', 1); + $firstClass = $product->getProductClasses()->first(); + $this->assertNotFalse($firstClass); + $code = $firstClass->getCode(); + $this->assertNotNull($code); + + $result = $this->tool->get(code: $code); + + $this->assertSame($product->getId(), $result['id']); + $this->assertSame('mcp-by-code', $result['name']); + } + + public function testOutputFieldsAreSubsetOfAllowList(): void + { + $product = $this->createProduct('mcp-allow', 1); + + $result = $this->tool->get(id: $product->getId()); + + $allowed = [ + 'id', 'name', 'note', 'description_list', 'description_detail', + 'search_word', 'free_area', 'create_date', 'update_date', + 'ProductCategories', 'ProductClasses', 'ProductImage', + 'ProductTag', 'CustomerFavoriteProducts', 'Creator', 'Status', + ]; + + foreach (array_keys($result) as $key) { + $this->assertContains($key, $allowed, sprintf('出力フィールド "%s" は allow_list 外', $key)); + } + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/GetShippingToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/GetShippingToolTest.php new file mode 100644 index 00000000000..030c8be682f --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetShippingToolTest.php @@ -0,0 +1,80 @@ +tool = static::getContainer()->get(GetShippingTool::class); + } + + public function testReturnsShippingsForOrder(): void + { + $customer = $this->createCustomer('mcp-shipping@example.com'); + $order = $this->createOrder($customer); + + $result = $this->tool->get(orderId: $order->getId()); + + $this->assertSame($order->getId(), $result['order_id']); + $this->assertArrayHasKey('items', $result); + $this->assertGreaterThanOrEqual(1, \count($result['items']), '通常 createOrder は最低 1 つの Shipping を持つ'); + } + + public function testReturnsEmptyForUnknownOrder(): void + { + $result = $this->tool->get(orderId: 99999999); + + $this->assertNull($result['order_id']); + $this->assertSame([], $result['items']); + } + + public function testItemFieldsAreSubsetOfShippingAllowList(): void + { + $customer = $this->createCustomer('mcp-shipping-allow@example.com'); + $order = $this->createOrder($customer); + + $result = $this->tool->get(orderId: $order->getId()); + $this->assertNotEmpty($result['items']); + + // Api44 の allow_list の `Eccube\Entity\Shipping` + $allowed = [ + 'id', 'name01', 'name02', 'kana01', 'kana02', 'company_name', + 'phone_number', 'postal_code', 'addr01', 'addr02', + 'shipping_delivery_name', 'time_id', 'shipping_delivery_time', + 'shipping_delivery_date', 'shipping_date', 'tracking_number', + 'note', 'sort_no', 'create_date', 'update_date', 'mail_send_date', + 'Order', 'OrderItems', 'Country', 'Pref', 'Delivery', 'Creator', + ]; + + foreach ($result['items'] as $item) { + foreach (array_keys($item) as $key) { + $this->assertContains($key, $allowed, sprintf('出力フィールド "%s" は Shipping allow_list 外', $key)); + } + } + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/ListPluginsToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/ListPluginsToolTest.php new file mode 100644 index 00000000000..a13991e8b11 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/ListPluginsToolTest.php @@ -0,0 +1,75 @@ +tool = static::getContainer()->get(ListPluginsTool::class); + } + + public function testReturnsPluginsWithScope(): void + { + $result = $this->tool->list(); + + $this->assertArrayHasKey('total', $result); + $this->assertArrayHasKey('items', $result); + $this->assertGreaterThanOrEqual(1, $result['total'], 'Api44 が install されているので最低 1 件'); + $this->assertContains( + 'Api44', + array_column($result['items'], 'code'), + 'install 済みの Api44 が一覧に含まれる', + ); + } + + public function testEnabledFilterReturnsOnlyEnabled(): void + { + $enabled = $this->tool->list(enabledOnly: true); + + // 空配列だと foreach が無検証で通るため、 enabled な Api44 が居ることを先に担保する。 + $this->assertNotEmpty($enabled['items'], 'enabled なプラグイン (Api44) が居るので空ではない'); + foreach ($enabled['items'] as $item) { + $this->assertTrue($item['enabled'] ?? false, sprintf('プラグイン "%s" は enabled のはず', $item['code'] ?? '?')); + } + } + + public function testItemFieldsAreSubsetOfPluginAllowList(): void + { + $result = $this->tool->list(); + $this->assertNotEmpty($result['items']); + + $allowed = ['id', 'name', 'code', 'enabled', 'version', 'source', 'initialized', 'create_date', 'update_date']; + + foreach ($result['items'] as $item) { + foreach (array_keys($item) as $key) { + $this->assertContains($key, $allowed, sprintf('出力フィールド "%s" は Plugin allow_list 外', $key)); + } + } + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/SearchCustomersToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/SearchCustomersToolTest.php new file mode 100644 index 00000000000..638149c6eb1 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/SearchCustomersToolTest.php @@ -0,0 +1,91 @@ +tool = static::getContainer()->get(SearchCustomersTool::class); + } + + public function testReturnsCustomersWithScope(): void + { + $customer = $this->createCustomer('mcp-customer-1@example.com'); + + // 既存データで緑にならないよう、 作成した会員のメールで絞り込み、 その id が結果に出ることまで確認する。 + $result = $this->tool->search(keyword: 'mcp-customer-1@example.com', limit: 50); + + $this->assertArrayHasKey('total', $result); + $this->assertGreaterThanOrEqual(1, $result['total']); + $this->assertContains( + $customer->getId(), + array_column($result['items'], 'id'), + '作成した会員が検索結果に含まれる', + ); + } + + public function testFiltersByActiveStatus(): void + { + $this->createCustomer('mcp-customer-active@example.com'); + + $result = $this->tool->search(statusIds: [CustomerStatus::ACTIVE], limit: 100); + + $this->assertGreaterThanOrEqual(1, $result['total'], 'ACTIVE 会員が 1 件以上'); + } + + public function testLimitClampedToUpperBound(): void + { + $result = $this->tool->search(limit: 500); + + $this->assertSame(200, $result['limit']); + } + + public function testItemFieldsAreSubsetOfAllowList(): void + { + $this->createCustomer('mcp-customer-allow@example.com'); + + $result = $this->tool->search(limit: 5); + $this->assertNotEmpty($result['items']); + + $allowed = [ + 'id', 'name01', 'name02', 'kana01', 'kana02', 'company_name', + 'postal_code', 'addr01', 'addr02', 'email', 'phone_number', 'birth', + 'first_buy_date', 'last_buy_date', 'buy_times', 'buy_total', 'note', + 'reset_expire', 'point', 'create_date', 'update_date', + 'CustomerFavoriteProducts', 'CustomerAddresses', 'Orders', + 'Status', 'Sex', 'Job', 'Country', 'Pref', + ]; + + foreach ($result['items'] as $item) { + foreach (array_keys($item) as $key) { + $this->assertContains($key, $allowed, sprintf('出力フィールド "%s" は Customer allow_list 外', $key)); + } + } + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/SearchOrdersToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/SearchOrdersToolTest.php new file mode 100644 index 00000000000..0c806ffcf5c --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/SearchOrdersToolTest.php @@ -0,0 +1,114 @@ +tool = static::getContainer()->get(SearchOrdersTool::class); + } + + public function testReturnsOrdersWithScope(): void + { + $customer = $this->createCustomer(); + $this->createOrderInDefaultSearchable($customer); + + $result = $this->tool->search(limit: 50); + + $this->assertArrayHasKey('total', $result); + $this->assertArrayHasKey('items', $result); + $this->assertGreaterThanOrEqual(1, $result['total']); + } + + public function testLimitClampedToUpperBound(): void + { + $result = $this->tool->search(limit: 500); + + $this->assertSame(200, $result['limit']); + } + + public function testFiltersByCustomerId(): void + { + $customerA = $this->createCustomer('mcp-order-a@example.com'); + $customerB = $this->createCustomer('mcp-order-b@example.com'); + $this->createOrderInDefaultSearchable($customerA); + $this->createOrderInDefaultSearchable($customerA); + $this->createOrderInDefaultSearchable($customerB); + + $result = $this->tool->search(customerId: $customerA->getId(), limit: 100); + + $this->assertGreaterThanOrEqual(2, $result['total'], 'customerA の注文だけがカウントされる'); + } + + public function testItemFieldsAreSubsetOfAllowList(): void + { + $customer = $this->createCustomer('mcp-order-allow@example.com'); + $this->createOrderInDefaultSearchable($customer); + + $result = $this->tool->search(limit: 5); + $this->assertNotEmpty($result['items']); + + // Api44 の allow_list の `Eccube\Entity\Order` 列挙項目 + $allowed = [ + 'id', 'pre_order_id', 'order_no', 'message', + 'name01', 'name02', 'kana01', 'kana02', 'company_name', 'email', 'phone_number', + 'postal_code', 'addr01', 'addr02', 'birth', + 'subtotal', 'discount', 'delivery_fee_total', 'charge', 'tax', 'total', 'payment_total', + 'payment_method', 'note', 'create_date', 'update_date', 'order_date', 'payment_date', + 'currency_code', 'complete_message', 'complete_mail_message', 'add_point', 'use_point', + 'OrderItems', 'Shippings', 'MailHistories', 'Customer', 'Country', 'Pref', + 'Sex', 'Job', 'Payment', 'DeviceType', + 'CustomerOrderStatus', 'OrderStatusColor', 'OrderStatus', + ]; + + foreach ($result['items'] as $item) { + foreach (array_keys($item) as $key) { + $this->assertContains($key, $allowed, sprintf('出力フィールド "%s" は allow_list 外', $key)); + } + } + } + + /** + * `getQueryBuilderBySearchDataForAdmin` のデフォルトは PROCESSING / PENDING を除外する。 + * 一方 `createOrder` ヘルパは PROCESSING を付与するため、 検索結果に出ない。 + * 検索可能な status (NEW) の Order を作るためのヘルパ。 + */ + private function createOrderInDefaultSearchable(Customer $customer): Order + { + $generator = static::getContainer()->get(Generator::class); + $this->assertInstanceOf(Generator::class, $generator); + + return $generator->createOrder($customer, [], null, 0, 0, OrderStatus::NEW); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/Tool/SearchProductsToolTest.php b/tests/Eccube/Tests/Service/Mcp/Tool/SearchProductsToolTest.php new file mode 100644 index 00000000000..c295e50ac7c --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/SearchProductsToolTest.php @@ -0,0 +1,101 @@ +tool = static::getContainer()->get(SearchProductsTool::class); + } + + public function testReturnsProductsWithScope(): void + { + $product = $this->createProduct('mcp-search-001', 3); + + // 既存データに紛れて緑にならないよう、 作成した商品名で絞り込み、 その id が結果に出ることまで確認する。 + $result = $this->tool->search(keyword: 'mcp-search-001', limit: 50); + + $this->assertArrayHasKey('total', $result); + $this->assertArrayHasKey('items', $result); + $this->assertGreaterThanOrEqual(1, $result['total']); + $this->assertContains( + $product->getId(), + array_column($result['items'], 'id'), + '作成した商品が検索結果に含まれる', + ); + $this->assertSame(50, $result['limit']); + $this->assertSame(0, $result['offset']); + } + + public function testLimitClampedToUpperBound(): void + { + $result = $this->tool->search(limit: 500); + + $this->assertSame(200, $result['limit'], 'limit は 200 にクランプされる'); + } + + public function testLimitClampedToLowerBound(): void + { + $result = $this->tool->search(limit: 0); + + $this->assertSame(1, $result['limit'], 'limit は最小 1 にクランプされる'); + } + + public function testOffsetClampedToZero(): void + { + $result = $this->tool->search(limit: 1, offset: -5); + + $this->assertSame(0, $result['offset'], 'offset は最小 0 にクランプされる'); + } + + public function testItemFieldsAreSubsetOfAllowList(): void + { + $this->createProduct('mcp-allow-001', 1); + + $result = $this->tool->search(limit: 5); + + $this->assertNotEmpty($result['items']); + + // Api44 の core.api.allow_list で `Eccube\Entity\Product` に列挙されている項目 + $allowed = [ + 'id', 'name', 'note', 'description_list', 'description_detail', + 'search_word', 'free_area', 'create_date', 'update_date', + 'ProductCategories', 'ProductClasses', 'ProductImage', + 'ProductTag', 'CustomerFavoriteProducts', 'Creator', 'Status', + ]; + + foreach ($result['items'] as $item) { + foreach (array_keys($item) as $key) { + $this->assertContains($key, $allowed, sprintf('出力フィールド "%s" は allow_list 外', $key)); + } + } + } +}