diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 1e364961159..4b73524db88 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -227,6 +227,149 @@ jobs: name: playwright-${{ matrix.suite }}-logs path: var/log/ + ## MCP の Playwright E2E。 トークン発行 UI → Bearer で /admin/mcp にツール呼び出し、 の経路を通す。 + ## MCP は Api44 (OAuth2 / scope / firewall) 前提のため、 メインの playwright マトリクスとは別に、 + ## Api44 を導入した専用環境で mcp.spec.ts を実走する (Api44 導入手順は unit-test.yml の mcp ジョブと同じ)。 + ## 環境は既存 playwright ジョブと同じ混成 (.env=e2e / スキーマ・fixtures・Api44 導入は --env=dev)。 + ## ブラウザ経由の admin ログインが通る実績のある構成に合わせる。 + mcp: + name: Playwright (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 にはまだ無い。 当該ブランチが 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: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4 + with: + node-version: 20 + + - name: Initialize Composer + uses: ./.github/actions/composer + + - name: Generate ECCUBE_AUTH_MAGIC + run: echo "ECCUBE_AUTH_MAGIC=$(openssl rand -hex 32)" >> "$GITHUB_ENV" + + - name: Build Sass and JavaScript + run: | + npm ci + npm run build + + - name: Setup mock-package-api + run: | + ( 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 + + # 既存 playwright ジョブと同じ混成: .env は e2e、 スキーマ/fixtures/Api44 導入は + # --env=dev で流す (ブラウザ経由 admin ログインが通る実績のある構成)。 + - name: Setup EC-CUBE + env: + APP_ENV: 'e2e' + DATABASE_URL: postgres://postgres:password@127.0.0.1:5432/eccube_db + DATABASE_SERVER_VERSION: 16 + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + run: | + echo "APP_ENV=${APP_ENV}" > .env + echo "TRUSTED_HOSTS=127.0.0.1,localhost" >> .env + echo "ECCUBE_AUTH_MAGIC=${ECCUBE_AUTH_MAGIC}" >> .env + bin/console doctrine:database:create --env=dev + bin/console doctrine:schema:create --env=dev + bin/console eccube:fixtures:load --env=dev + + - name: Install Api44 + env: + APP_ENV: 'dev' + DATABASE_URL: postgres://postgres:password@127.0.0.1:5432/eccube_db + DATABASE_SERVER_VERSION: 16 + 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'" --env=dev + bin/console eccube:composer:require ec-cube/api44 --env=dev + bin/console eccube:plugin:enable --code=Api44 --env=dev + bin/console doctrine:schema:update --force --dump-sql --env=dev + bin/console cache:clear --no-warmup --env=dev + chmod 600 app/PluginData/Api44/oauth/private.key + + - name: Install Playwright dependencies + working-directory: e2e + run: npm ci + + - name: Install Playwright browsers + working-directory: e2e + run: npx playwright install --with-deps chromium + + - name: Start PHP Development Server + env: + APP_ENV: 'e2e' + DATABASE_URL: postgres://postgres:password@127.0.0.1:5432/eccube_db + DATABASE_SERVER_VERSION: 16 + ECCUBE_PACKAGE_API_URL: 'http://127.0.0.1:8080' + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + run: php -S 127.0.0.1:8000 codeception/router.php & + + - name: Run Playwright tests + working-directory: e2e + env: + BASE_URL: 'http://127.0.0.1:8000' + DATABASE_URL: postgres://postgres:password@127.0.0.1:5432/eccube_db + ECCUBE_ADMIN_ROUTE: 'admin' + ADMIN_USER: 'admin' + ADMIN_PASSWORD: 'password' + APP_ENV: 'e2e' + CI: 'true' + run: npx playwright test --project=setup --project=mcp-tests mcp.spec.ts + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: playwright-mcp-report + path: e2e/playwright-report/ + + - name: Upload ScreenShots + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: playwright-mcp-results + path: e2e/test-results/ + + - name: Upload logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: playwright-mcp-logs + path: var/log/ + ## Web インストーラは「EC-CUBE が未インストールであること」が前提のため、 ## playwright ジョブ (.env を作成し schema:create 済み) とは同居できず別ジョブにする。 ## playwright ジョブとの違い: diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 361dc7fba15..19b9b98e24f 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -138,7 +138,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 }} @@ -195,3 +195,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 6415497bd6b..db82d448d8c 100644 --- a/app/config/eccube/packages/dev/monolog.yml +++ b/app/config/eccube/packages/dev/monolog.yml @@ -6,7 +6,8 @@ monolog: level: debug formatter: eccube.log.formatter.line max_files: 10 - channels: ['!cookie_consent'] + # mcp は専用ハンドラ (mcp)、 cookie_consent は専用ハンドラに出すため main(site.log) から除外する + channels: ['!mcp', '!cookie_consent'] # Cookie consent: 同意操作の証跡を info レベルで専用ファイルへ直接出力する。 cookie_consent: type: rotating_file diff --git a/app/config/eccube/packages/e2e/monolog.yml b/app/config/eccube/packages/e2e/monolog.yml index 6415497bd6b..261bcffb2ad 100644 --- a/app/config/eccube/packages/e2e/monolog.yml +++ b/app/config/eccube/packages/e2e/monolog.yml @@ -6,7 +6,8 @@ monolog: level: debug formatter: eccube.log.formatter.line max_files: 10 - channels: ['!cookie_consent'] + # mcp は専用ハンドラ (mcp)、 cookie_consent は専用ハンドラに出すため main(site.log) から除外する + channels: ['!cookie_consent', '!mcp'] # Cookie consent: 同意操作の証跡を info レベルで専用ファイルへ直接出力する。 cookie_consent: type: rotating_file 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..6a5fba5acb7 --- /dev/null +++ b/app/config/eccube/packages/mcp.yaml @@ -0,0 +1,11 @@ +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 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 60fefd5910f..78766cd52b3 100644 --- a/app/config/eccube/packages/monolog.yml +++ b/app/config/eccube/packages/monolog.yml @@ -1,2 +1,14 @@ monolog: - channels: ['front', 'admin', 'cookie_consent'] + channels: ['front', 'admin', 'mcp', 'cookie_consent'] + 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 172cccb7ab1..1f4e0f815a4 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', '!cookie_consent'] + # mcp / cookie_consent は専用ハンドラに出すため main(site.log) からは除外する + channels: ['!doctrine', '!event', '!php', '!mcp', '!cookie_consent'] 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 093dc6ae577..009c310371f 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)%' @@ -32,6 +35,11 @@ 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)%' + # 許可リスト未設定時、 dev/test は Origin 検証を skip し prod は検証不能な Origin を拒否する + $skipUnvalidatedOrigin: '%kernel.debug%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -39,7 +47,9 @@ services: resource: '../../../src/Eccube/*' # you can exclude directories or files # but if a service is unused, it's removed anyway - exclude: '../../../src/Eccube/{Annotation,Common,Entity,Exception,Log,Plugin,ServiceProvider,Resource,Doctrine/ORM/tools/}' + # EccubeCliToolCommand は McpCliCommandPass がツールごとに定義を生成するため、 glob からは除外する + # (scalar 引数 $toolName を持ち autowire できず、 名前なしの console.command 自動登録も壊れるため)。 + exclude: '../../../src/Eccube/{Annotation,Common,Entity,Exception,Log,Plugin,ServiceProvider,Resource,Doctrine/ORM/tools/,Rector,Command/EccubeCliToolCommand.php}' Eccube\Common\EccubeConfig: public: true @@ -232,6 +242,32 @@ 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: tools/list を現トークンの scope で絞る (呼べない Tool を一覧から隠す = 最小権限)。 + # call 時の fail-closed 拒否は ScopeEnforcingReferenceHandler が担う二重防御。 + Eccube\Service\Mcp\ScopeFilteringRegistry: + decorates: mcp.registry + arguments: + $inner: '@.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 + # スロットリング用キャッシュプール(rate_limiter.cache)のバックエンド. # データストレージをファイルから DB へ変更するためのアダプタテンプレート. # framework 標準の cache.adapter.doctrine_dbal(abstract)を継承し, 位置引数 index_3 の 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 6884bb5ad46..a1f84ca4e2f 100644 --- a/composer.json +++ b/composer.json @@ -82,6 +82,7 @@ "symfony/lock": "^7.4", "symfony/mailer": "^7.4", "symfony/maker-bundle": "^1.0", + "symfony/mcp-bundle": "^0.12", "symfony/monolog-bridge": "^7.4", "symfony/monolog-bundle": "^3.1", "symfony/options-resolver": "^7.4", diff --git a/composer.lock b/composer.lock index 9157ce76322..efa151e0123 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": "35edcc3590aa585986967f7bd65793f7", + "content-hash": "c03cdf384ae21a0124c4b5870a09a407", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -1899,16 +1899,16 @@ }, { "name": "doctrine/orm", - "version": "3.6.7", + "version": "3.6.2", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c" + "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", - "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "url": "https://api.github.com/repos/doctrine/orm/zipball/4262eb495b4d2a53b45de1ac58881e0091f2970f", + "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f", "shasum": "" }, "require": { @@ -1981,22 +1981,22 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.7" + "source": "https://github.com/doctrine/orm/tree/3.6.2" }, - "time": "2026-05-25T16:45:47+00:00" + "time": "2026-01-30T21:41:41+00:00" }, { "name": "doctrine/persistence", - "version": "3.4.5", + "version": "3.4.3", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "703a29d8597336fb75f42eeabcdf3f2863156ca8" + "reference": "d59e6ef7caffe6a30f4b6f9e9079a75f52c64ae0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/703a29d8597336fb75f42eeabcdf3f2863156ca8", - "reference": "703a29d8597336fb75f42eeabcdf3f2863156ca8", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/d59e6ef7caffe6a30f4b6f9e9079a75f52c64ae0", + "reference": "d59e6ef7caffe6a30f4b6f9e9079a75f52c64ae0", "shasum": "" }, "require": { @@ -2019,7 +2019,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Persistence\\": "src" + "Doctrine\\Persistence\\": "src/Persistence" } }, "notification-url": "https://packagist.org/downloads/", @@ -2063,7 +2063,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/3.4.5" + "source": "https://github.com/doctrine/persistence/tree/3.4.3" }, "funding": [ { @@ -2079,7 +2079,7 @@ "type": "tidelift" } ], - "time": "2026-06-13T19:29:35+00:00" + "time": "2025-10-21T15:21:39+00:00" }, { "name": "doctrine/sql-formatter", @@ -2575,21 +2575,21 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.15.2", + "version": "7.15.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "744101956d78b7c1384d0cbf379db13e859167bf" + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", - "reference": "744101956d78b7c1384d0cbf379db13e859167bf", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/promises": "^2.5.2", "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", @@ -2683,7 +2683,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.2" + "source": "https://github.com/guzzle/guzzle/tree/7.15.3" }, "funding": [ { @@ -2699,20 +2699,20 @@ "type": "tidelift" } ], - "time": "2026-07-26T23:23:20+00:00" + "time": "2026-08-05T19:48:21+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.1", + "version": "2.5.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", "shasum": "" }, "require": { @@ -2767,7 +2767,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.1" + "source": "https://github.com/guzzle/promises/tree/2.5.2" }, "funding": [ { @@ -2783,7 +2783,7 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:48:39+00:00" + "time": "2026-08-05T19:30:54+00:00" }, { "name": "guzzlehttp/psr7", @@ -3283,6 +3283,90 @@ }, "time": "2026-06-23T18:43:15+00:00" }, + { + "name": "mcp/sdk", + "version": "v0.7.0", + "source": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/php-sdk.git", + "reference": "a6f415578fa789783d010274d11c922e97659a8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/a6f415578fa789783d010274d11c922e97659a8a", + "reference": "a6f415578fa789783d010274d11c922e97659a8a", + "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.7.0" + }, + "time": "2026-07-14T22:58:00+00:00" + }, { "name": "mobiledetect/mobiledetectlib", "version": "2.8.45", @@ -3681,6 +3765,196 @@ }, "time": "2026-07-04T14:30:18+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", @@ -3800,6 +4074,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": "phpseclib/phpseclib", "version": "3.0.55", @@ -3908,7 +4437,54 @@ "type": "tidelift" } ], - "time": "2026-06-14T23:24:10+00:00" + "time": "2026-06-14T23:24:10+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "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.3" + }, + "time": "2026-07-08T07:01:06+00:00" }, { "name": "psr/cache", @@ -4270,6 +4846,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", @@ -5477,16 +6166,16 @@ }, { "name": "symfony/cache", - "version": "v7.4.13", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673" + "reference": "c1e7abe8e8c9b315d6d8b86446ffd9cf73679303" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/4c09e18a92cce126cc0d1155825279fca8cd0673", - "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673", + "url": "https://api.github.com/repos/symfony/cache/zipball/c1e7abe8e8c9b315d6d8b86446ffd9cf73679303", + "reference": "c1e7abe8e8c9b315d6d8b86446ffd9cf73679303", "shasum": "" }, "require": { @@ -5557,7 +6246,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.13" + "source": "https://github.com/symfony/cache/tree/v7.4.15" }, "funding": [ { @@ -5577,20 +6266,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:43:14+00:00" + "time": "2026-07-29T04:03:42+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "225e8a254166bd3442e370c6f50145465db63831" + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", - "reference": "225e8a254166bd3442e370c6f50145465db63831", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { @@ -5637,7 +6326,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -5657,7 +6346,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:33:14+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/clock", @@ -5739,16 +6428,16 @@ }, { "name": "symfony/config", - "version": "v7.4.10", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57" + "reference": "b18e33881ef402ad940f36e85935420624009bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", - "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "url": "https://api.github.com/repos/symfony/config/zipball/b18e33881ef402ad940f36e85935420624009bf4", + "reference": "b18e33881ef402ad940f36e85935420624009bf4", "shasum": "" }, "require": { @@ -5794,7 +6483,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.10" + "source": "https://github.com/symfony/config/tree/v7.4.15" }, "funding": [ { @@ -5814,7 +6503,7 @@ "type": "tidelift" } ], - "time": "2026-05-03T14:20:49+00:00" + "time": "2026-07-22T12:54:40+00:00" }, { "name": "symfony/console", @@ -6060,16 +6749,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v7.4.13", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2" + "reference": "b7825671c553af46a98c744e23f37f972aee6427" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f299e20ce983be6c0744952533c6dfeaaa1448e2", - "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/b7825671c553af46a98c744e23f37f972aee6427", + "reference": "b7825671c553af46a98c744e23f37f972aee6427", "shasum": "" }, "require": { @@ -6120,7 +6809,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.13" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.15" }, "funding": [ { @@ -6140,7 +6829,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T14:07:29+00:00" + "time": "2026-07-22T08:40:50+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6478,16 +7167,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.8", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d49f6a19f326db41ae7103bdc38e3eb35a791261", + "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261", "shasum": "" }, "require": { @@ -6536,7 +7225,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + "source": "https://github.com/symfony/error-handler/tree/v7.4.15" }, "funding": [ { @@ -6556,20 +7245,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.14", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" + "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", - "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/336e7f3b9e95aba04f93ea9143920c2186abfbb9", + "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9", "shasum": "" }, "require": { @@ -6621,7 +7310,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.15" }, "funding": [ { @@ -6641,7 +7330,7 @@ "type": "tidelift" } ], - "time": "2026-06-06T11:10:32+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6793,16 +7482,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.11", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + "reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", - "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/ff16a16bf87fdf264638b8f6995b3515975e3c79", + "reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79", "shasum": "" }, "require": { @@ -6839,7 +7528,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.11" + "source": "https://github.com/symfony/filesystem/tree/v7.4.15" }, "funding": [ { @@ -6859,7 +7548,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:38:44+00:00" + "time": "2026-07-22T07:36:05+00:00" }, { "name": "symfony/finder", @@ -7107,16 +7796,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v7.4.8", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "180533cfbac2144349044267db31d5d3df9957cb" + "reference": "a430728797dda13ec60add8afd18e3fea50f5e93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/180533cfbac2144349044267db31d5d3df9957cb", - "reference": "180533cfbac2144349044267db31d5d3df9957cb", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a430728797dda13ec60add8afd18e3fea50f5e93", + "reference": "a430728797dda13ec60add8afd18e3fea50f5e93", "shasum": "" }, "require": { @@ -7125,7 +7814,7 @@ "php": ">=8.2", "symfony/cache": "^6.4.12|^7.0|^8.0", "symfony/config": "^7.4.4|^8.0.4", - "symfony/dependency-injection": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.15|~8.0.15|^8.1.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^7.3|^8.0", "symfony/event-dispatcher": "^6.4|^7.0|^8.0", @@ -7144,7 +7833,7 @@ "symfony/asset": "<6.4", "symfony/asset-mapper": "<6.4", "symfony/clock": "<6.4", - "symfony/console": "<6.4", + "symfony/console": "<6.4.43|>=7.0,<7.4.15|>=8.0,<8.0.15", "symfony/dom-crawler": "<6.4", "symfony/dotenv": "<6.4", "symfony/form": "<7.4", @@ -7152,7 +7841,7 @@ "symfony/lock": "<6.4", "symfony/mailer": "<6.4", "symfony/messenger": "<7.4", - "symfony/mime": "<6.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", @@ -7166,7 +7855,7 @@ "symfony/twig-bundle": "<6.4", "symfony/validator": "<6.4", "symfony/web-profiler-bundle": "<6.4", - "symfony/webhook": "<7.2", + "symfony/webhook": "<7.4", "symfony/workflow": "<7.4" }, "require-dev": { @@ -7178,7 +7867,7 @@ "symfony/asset-mapper": "^6.4|^7.0|^8.0", "symfony/browser-kit": "^6.4|^7.0|^8.0", "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4.43|^7.4.15|^8.0.15", "symfony/css-selector": "^6.4|^7.0|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/dotenv": "^6.4|^7.0|^8.0", @@ -7190,7 +7879,7 @@ "symfony/lock": "^6.4|^7.0|^8.0", "symfony/mailer": "^6.4|^7.0|^8.0", "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", "symfony/notifier": "^6.4|^7.0|^8.0", "symfony/object-mapper": "^7.3|^8.0", "symfony/polyfill-intl-icu": "~1.0", @@ -7210,10 +7899,10 @@ "symfony/uid": "^6.4|^7.0|^8.0", "symfony/validator": "^7.4|^8.0", "symfony/web-link": "^6.4|^7.0|^8.0", - "symfony/webhook": "^7.2|^8.0", + "symfony/webhook": "^7.4|^8.0", "symfony/workflow": "^7.4|^8.0", "symfony/yaml": "^7.3|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "type": "symfony-bundle", "autoload": { @@ -7241,7 +7930,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.15" }, "funding": [ { @@ -7261,7 +7950,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-07-26T12:33:49+00:00" }, { "name": "symfony/http-client", @@ -7448,16 +8137,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.13", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bc354f47c62301e990b7874fa662326368508e2c" + "reference": "1f898ee8188adda9417fb52cf8425a8342c254e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", - "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/1f898ee8188adda9417fb52cf8425a8342c254e7", + "reference": "1f898ee8188adda9417fb52cf8425a8342c254e7", "shasum": "" }, "require": { @@ -7506,7 +8195,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.15" }, "funding": [ { @@ -7526,20 +8215,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-07-29T07:12:33+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.13", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "9df847980c436451f4f51d1284491bb4356dd989" + "reference": "403275d94f94d5626c3288c599b3b48093ba24f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", - "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/403275d94f94d5626c3288c599b3b48093ba24f7", + "reference": "403275d94f94d5626c3288c599b3b48093ba24f7", "shasum": "" }, "require": { @@ -7597,7 +8286,7 @@ "symfony/validator": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -7625,7 +8314,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.15" }, "funding": [ { @@ -7645,7 +8334,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T08:31:43+00:00" + "time": "2026-07-29T11:40:42+00:00" }, { "name": "symfony/intl", @@ -8003,6 +8692,93 @@ ], "time": "2026-03-18T13:39:06+00:00" }, + { + "name": "symfony/mcp-bundle", + "version": "v0.12.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mcp-bundle.git", + "reference": "7ffd7bfab9d315a52a8224e35b32bd0db1765d44" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/7ffd7bfab9d315a52a8224e35b32bd0db1765d44", + "reference": "7ffd7bfab9d315a52a8224e35b32bd0db1765d44", + "shasum": "" + }, + "require": { + "mcp/sdk": "^0.7", + "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": { + "nyholm/psr7": "^1.8", + "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", + "symfony/twig-bundle": "^7.3|^8.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.12.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-07-21T01:04:45+00:00" + }, { "name": "symfony/mime", "version": "v7.4.13", @@ -9105,7 +9881,87 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "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-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" }, "classmap": [ "Resources/stubs" @@ -9125,7 +9981,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -9134,7 +9990,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" }, "funding": [ { @@ -9154,20 +10010,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:45:58+00:00" + "time": "2025-07-08T02:45:35+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -9185,7 +10041,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -9205,7 +10061,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -9214,7 +10070,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -9234,20 +10090,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/polyfill-php84", - "version": "v1.38.1", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -9265,7 +10121,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -9285,7 +10141,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -9294,7 +10150,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -9314,25 +10170,31 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "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": { @@ -9345,11 +10207,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Uuid\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -9357,24 +10216,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Symfony polyfill for uuid functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", "polyfill", "portable", - "shim" + "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -9394,7 +10253,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", @@ -9632,6 +10491,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", @@ -9708,16 +10655,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.13", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "url": "https://api.github.com/repos/symfony/routing/zipball/80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b", "shasum": "" }, "require": { @@ -9769,7 +10716,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.13" + "source": "https://github.com/symfony/routing/tree/v7.4.15" }, "funding": [ { @@ -9789,7 +10736,7 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "symfony/security-bundle", @@ -10978,6 +11925,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", @@ -11084,16 +12109,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.14", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", "shasum": "" }, "require": { @@ -11109,7 +12134,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -11147,7 +12172,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.15" }, "funding": [ { @@ -11167,7 +12192,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "symfony/var-exporter", @@ -11436,16 +12461,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.14", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" + "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", - "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", + "url": "https://api.github.com/repos/symfony/yaml/zipball/e101850ded5d2c0d44bf32abb8996404afec2dec", + "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec", "shasum": "" }, "require": { @@ -11488,7 +12513,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.14" + "source": "https://github.com/symfony/yaml/tree/v7.4.15" }, "funding": [ { @@ -11508,7 +12533,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "tecnickcom/tcpdf", @@ -11798,6 +12823,72 @@ } ], "time": "2026-05-27T13:05:51+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "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.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ @@ -12340,23 +13431,24 @@ }, { "name": "codeception/module-phpbrowser", - "version": "4.0.0", + "version": "4.1.0", "source": { "type": "git", "url": "https://github.com/Codeception/module-phpbrowser.git", - "reference": "495a2cf19c4d0f1004bc24e10ed9d3cf33ccdc51" + "reference": "56903df98e29dedb51dd9f8d2dc00b05063c2135" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-phpbrowser/zipball/495a2cf19c4d0f1004bc24e10ed9d3cf33ccdc51", - "reference": "495a2cf19c4d0f1004bc24e10ed9d3cf33ccdc51", + "url": "https://api.github.com/repos/Codeception/module-phpbrowser/zipball/56903df98e29dedb51dd9f8d2dc00b05063c2135", + "reference": "56903df98e29dedb51dd9f8d2dc00b05063c2135", "shasum": "" }, "require": { "codeception/codeception": "*@dev", "codeception/lib-innerbrowser": "*@dev", "ext-json": "*", - "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/guzzle": "^7.8.2 | ^8.0", + "guzzlehttp/psr7": "^2.6.3 | ^3.0", "php": "^8.2", "symfony/browser-kit": "^5.4 | ^6.0 | ^7.0 | ^8.0" }, @@ -12368,7 +13460,7 @@ "aws/aws-sdk-php": "^3.199", "codeception/module-rest": "^2.0 | *@dev", "ext-curl": "*", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^2.2.6", "squizlabs/php_codesniffer": "^3.10" }, "suggest": { @@ -12401,9 +13493,9 @@ ], "support": { "issues": "https://github.com/Codeception/module-phpbrowser/issues", - "source": "https://github.com/Codeception/module-phpbrowser/tree/4.0.0" + "source": "https://github.com/Codeception/module-phpbrowser/tree/4.1.0" }, - "time": "2026-01-23T13:24:41+00:00" + "time": "2026-07-27T06:11:37+00:00" }, { "name": "codeception/module-rest", diff --git a/docs/mcp/admin-mcp-scope-boundary.md b/docs/mcp/admin-mcp-scope-boundary.md new file mode 100644 index 00000000000..c00d1bbc7ed --- /dev/null +++ b/docs/mcp/admin-mcp-scope-boundary.md @@ -0,0 +1,26 @@ +# /admin/mcp の認可境界と scope 検査の規約 + +MCP の「閲覧専用・領域限定」は、認証(firewall)だけでなくツール層の領域 scope 検査で担保する。この二層の前提を崩さないための規約。 + +## 認可の二層 + +1. firewall: `/admin/mcp` は OAuth2 Bearer を Member として認証する。通過条件は `ROLE_ADMIN`。 +2. ツール層: 各ツール呼び出しは `ScopeEnforcingReferenceHandler` を必ず通り、`McpToolScopeMap` の領域 scope(例 `mcp:order:read`)を `ScopeChecker` で検査する。 + +領域・read の限定はツール層(2)でのみ効く。firewall(1)は `ROLE_ADMIN` しか見ない。 + +## 規約 + +- `/admin/mcp` 配下は MCP エンドポイント専用にする。通常の管理コントローラ(`ROLE_ADMIN` だけで通す URL)を置かない。 + - 理由: MCP トークンは Member(`ROLE_ADMIN`)として認証されるため、scope 非検査の URL を `/admin/mcp` 配下に置くと、read 専用トークン(例 `mcp:product:read` のみ)でも到達できてしまう。盗難トークンでも同様。 + - どうしても配下に足す場合は、そのコントローラでも領域 scope を検査する(`ROLE_ADMIN` だけに依存しない)。 +- 新規ツールは必ず `McpToolScopeMap` に登録する。未登録ツールは `ScopeEnforcingReferenceHandler` が実行時 deny する(fail-closed)。 + +## 回帰ガード + +クロス scope 拒否(ある領域の scope しか持たない token が他領域ツールを呼べない)は `McpScopeEnforcementIntegrationTest` で product / order / customer / plugin を実カーネル経由で縛る。領域ツールを増やしたら、この拒否テストにも 1 領域追加する。 + +## 補足(構造保証・運用) + +- firewall 側で「最低 1 つの mcp scope を要求する」access_control を足すと、mcp scope を一切持たない Member トークンの到達を構造的に塞げる(領域・read の区別は引き続きツール層)。firewall 定義は Api44 側にあるため、そちらのタスクとして扱う。 +- 盗難トークン対策は scope 封じ込め(本規約)に加え、最小権限のトークン発行・短い有効期限・失効・監査ログ(`McpAuditLogger`)で運用側が担う。 diff --git a/docs/mcp/scope-denied-response.md b/docs/mcp/scope-denied-response.md new file mode 100644 index 00000000000..2a4659117b5 --- /dev/null +++ b/docs/mcp/scope-denied-response.md @@ -0,0 +1,115 @@ +# 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) に乗る。 + +## tools/list の scope フィルタ (可視性) + +上記の call 時拒否とは別に、 `tools/list` の応答自体を現在のトークンの scope で絞る。 呼べない Tool は一覧に出さない (最小権限: LLM に呼べない Tool を見せない)。 + +- `Eccube\Service\Mcp\ScopeFilteringRegistry` が mcp-bundle の `mcp.registry` を装飾し、 `getTools()` だけを上書きする。 各 Tool の必要 scope を `McpToolScopeMap` で引き、 `AuthorizationCheckerInterface::isGranted()` で通ったものだけ返す。 +- 中央マップ未登録の Tool (= call 時 fail-closed deny) は一覧からも隠す。 +- 認証トークンが無い経路 (CLI 等) は絞り込まず素通しする。 + +これは可視性の制御であり、 呼び出しの拒否ではない。 一覧に出さないことと呼べないことは独立で、 実際の拒否は `ScopeEnforcingReferenceHandler` が call 時に fail-closed で担保する (二重防御: このフィルタが外れても未認可 Tool は実行されない)。 + +## 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/e2e/playwright.config.ts b/e2e/playwright.config.ts index da6449559e4..631b5384f03 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -64,6 +64,15 @@ export default defineConfig({ storageState: { cookies: [], origins: [] }, }, }, + { + name: 'mcp-tests', + testMatch: /mcp\.spec\.ts/, + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + storageState: path.join(__dirname, '.auth', 'admin.json'), + }, + }, { name: 'install-tests', testMatch: /install-.*\.spec\.ts/, diff --git a/e2e/setup-fixtures.php b/e2e/setup-fixtures.php index c4f31f080b5..064c0f66498 100644 --- a/e2e/setup-fixtures.php +++ b/e2e/setup-fixtures.php @@ -318,5 +318,15 @@ echo " Order-memo test order already exists\n"; } +// --- MCP 機能を有効化 (既定 OFF のため、 MCP e2e が /admin/mcp に到達できるようにする) --- +$BaseInfo = $entityManager->getRepository(\Eccube\Entity\BaseInfo::class)->find(1); +if ($BaseInfo !== null && !$BaseInfo->isMcpEnabled()) { + $BaseInfo->setMcpEnabled(true); + $entityManager->flush(); + echo " Enabled MCP feature (mcp_enabled)\n"; +} else { + echo " MCP feature already enabled\n"; +} + echo "Fixtures setup complete.\n"; $kernel->shutdown(); diff --git a/e2e/tests/mcp.spec.ts b/e2e/tests/mcp.spec.ts new file mode 100644 index 00000000000..5576a93397d --- /dev/null +++ b/e2e/tests/mcp.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '@playwright/test'; + +/** + * MCP サーバの End-to-End。 管理画面でトークンを発行し、 その Bearer で HTTP transport + * (`/{admin}/mcp`) に JSON-RPC を投げて、 一覧ツールがサマリ射影で返ることを確認する。 + * + * PHPUnit の `mcp` グループ (契約テスト) が触らない「トークン発行 UI → 実 HTTP でのツール呼び出し」 + * の経路を、 Api44 導入済み環境で 1 本通す。 OAuth の同意フロー (DCR/PKCE) は HTTPS 必須のため + * ここでは踏まず、 管理画面のトークン発行 UI 経由で Bearer を得る。 + */ + +const adminRoute = process.env.ECCUBE_ADMIN_ROUTE || 'admin'; +const mcpEndpoint = `/${adminRoute}/mcp`; + +/** MCP へ JSON-RPC を POST する。 token / session id を渡すと該当ヘッダを付与。 */ +async function mcpPost( + request: import('@playwright/test').APIRequestContext, + body: unknown, + opts: { token?: string; sessionId?: string } = {}, +) { + const headers: Record = { + 'Content-Type': 'application/json', + // MCP の HTTP transport は SSE も選択肢に含む Accept を要求する + Accept: 'application/json, text/event-stream', + }; + if (opts.token) { + headers['Authorization'] = `Bearer ${opts.token}`; + } + if (opts.sessionId) { + headers['Mcp-Session-Id'] = opts.sessionId; + } + + return request.post(mcpEndpoint, { headers, data: body }); +} + +test.describe('MCP サーバ', () => { + test('トークン発行 UI で発行した Bearer で search_products がサマリ射影を返す', async ({ page }) => { + // 1. トークン発行 UI でラベル・scope・有効期限を入力して発行 + await page.goto(`/${adminRoute}/api/oauth/mcp/new`); + await page.waitForLoadState('load'); + await expect(page.locator('#mcp_token_form')).toBeVisible(); + + await page.locator('#mcp_token_label').fill('e2e-token'); + // scope: mcp:product:read (expanded=true の複数チェックボックス。 value=mcp:product:read は index 0) + await page.locator('#mcp_token_scopes_0').check(); + // submit ボタンは dev の Symfony Web Debug Toolbar に覆われ得るため、 form を直接 submit する + await page.locator('#mcp_token_form').evaluate((f: HTMLFormElement) => f.requestSubmit()); + await page.waitForLoadState('load'); + + // 2. 発行画面の textarea から JWT を取得 (発行直後のみ表示される) + const token = await page.locator('#mcp_token_value').inputValue(); + expect(token, '発行トークンが空').not.toBe(''); + + // 3. initialize で handshake が成立する (200 + JSON-RPC result)。 session id はヘッダで返る + const initRes = await mcpPost(page.request, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + clientInfo: { name: 'e2e', version: '1' }, + capabilities: {}, + }, + }, { token }); + expect(initRes.status(), 'initialize が 200 でない (Bearer 認証を確認)').toBe(200); + + const sessionId = initRes.headers()['mcp-session-id']; + expect(sessionId, 'initialize が Mcp-Session-Id を返さない').toBeTruthy(); + + // 非 initialize リクエストには session id が必須。 まず initialized 通知を送る + const notifyRes = await mcpPost(page.request, { + jsonrpc: '2.0', + method: 'notifications/initialized', + }, { token, sessionId }); + expect(notifyRes.status()).toBe(202); + + // 4. tools/call で search_products を呼ぶ → items がサマリ射影で返る + const callRes = await mcpPost(page.request, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'search_products', arguments: { limit: 5 } }, + }, { token, sessionId }); + expect(callRes.status()).toBe(200); + + // MCP の tool 結果は content[].text に JSON 文字列で入る + const payload = await callRes.json(); + const text = payload?.result?.content?.[0]?.text; + expect(text, 'tool 結果に content text が無い').toBeTruthy(); + const data = JSON.parse(text); + + expect(Array.isArray(data.items)).toBe(true); + expect(data.items.length).toBeGreaterThan(0); + + for (const item of data.items) { + // サマリ射影のキーだけ: 重量フィールド (description_detail 等) は含まれない + expect(item).toHaveProperty('id'); + expect(item).toHaveProperty('price'); + expect(item.price).toHaveProperty('min'); + expect(item.price).toHaveProperty('max'); + expect(item).toHaveProperty('stock'); + expect(item).not.toHaveProperty('description_detail'); + expect(item).not.toHaveProperty('ProductClasses'); + } + }); + + test('Bearer なしの /admin/mcp は 401 と resource_metadata を返す', async ({ page }) => { + const res = await mcpPost(page.request, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', clientInfo: { name: 'e2e', version: '1' }, capabilities: {} }, + }); + + expect(res.status()).toBe(401); + expect(res.headers()['www-authenticate'] ?? '').toContain('resource_metadata='); + }); +}); diff --git a/rector.php b/rector.php index 066bef07ac7..4aac23ef35b 100644 --- a/rector.php +++ b/rector.php @@ -77,10 +77,19 @@ // 型解決(get(PurchaseFlow::class))に置き換えると両者の区別が失われテストが無意味化する ContainerGetNameToTypeInTestsRector::class => [ __DIR__.'/tests/Eccube/Tests/Service/PurchaseFlow/OrderMemoFlowTest.php', + // private / チャネル別ロガー ('security.firewall.map' / 'monolog.logger.mcp') も + // 型でなく文字列 ID で取得するため FQCN 変換を除外する + __DIR__.'/tests/Eccube/Tests/Service/Mcp/Contract/Api44LifecycleContractTest.php', + __DIR__.'/tests/Eccube/Tests/Service/Mcp/Contract/McpAuditLogIsolationContractTest.php', ], // 8.3以上で対応可能 AddTypeToConstRector::class, // [BC]定数に型を追加する PHP 8.3 以降で有効 RenameMethodRector::class, //addがaddCommandに変換されてしまうため一旦スキップ + // EccubeCliToolCommand の description は runtime (ツールの description) で組み立てるため、 + // #[AsCommand(description:)] へ移せない (属性は定数式のみ)。 このルールをスキップする。 + CommandConfigureToAttributeRector::class => [ + __DIR__.'/src/Eccube/Command/EccubeCliToolCommand.php', + ], ]) // 個別にルールを追加する場合はここに記述 ->withRules([ diff --git a/src/Eccube/Command/EccubeCliToolCommand.php b/src/Eccube/Command/EccubeCliToolCommand.php new file mode 100644 index 00000000000..643d9d439de --- /dev/null +++ b/src/Eccube/Command/EccubeCliToolCommand.php @@ -0,0 +1,142 @@ +` コマンド。 + * + * MCP サーバでできる操作を `bin/console` からも同じように行うための実行口 (playwright-mcp に対する + * playwright-cli の位置づけ)。 コンソール AI クライアント (Claude Code 等) が、 コンテキストを食う + * MCP サーバ接続なしに同じツールを叩ける。 結果は Markdown で返す。 + * + * 1 クラスで全ツールを表現し、 ツール名は {@see \Eccube\DependencyInjection\Compiler\McpCliCommandPass} + * が MCP registry から列挙してコマンドごとに注入する。 ツールを追加しても本クラスの変更は不要。 + * + * 認証・scope 強制は通さない (ローカル実行 = サーバ / DB にアクセスできる前提の「ローカル信頼」)。 + */ +final class EccubeCliToolCommand extends Command +{ + public function __construct( + private readonly string $toolName, + private readonly McpCliToolInvoker $invoker, + private readonly McpMarkdownFormatter $formatter, + ) { + parent::__construct(); + } + + #[\Override] + protected function configure(): void + { + $tool = $this->invoker->tool($this->toolName)->tool; + $this->setDescription(($tool->description ?? '').' (MCP ツール '.$this->toolName.' 相当・ローカル信頼・トークン不要)'); + + $schema = new ToolInputSchema($tool); + foreach ($schema->propertyNames() as $name) { + $mode = InputOption::VALUE_REQUIRED; + if ($schema->isArray($name)) { + $mode |= InputOption::VALUE_IS_ARRAY; + } + $this->addOption($name, null, $mode, $schema->description($name)); + } + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $schema = new ToolInputSchema($this->invoker->tool($this->toolName)->tool); + + $arguments = []; + foreach ($schema->propertyNames() as $name) { + $raw = $input->getOption($name); + + if ($schema->isArray($name)) { + if ([] === (array) $raw) { + continue; + } + $elementType = $schema->elementType($name); + $values = []; + foreach ((array) $raw as $element) { + $cast = $this->cast((string) $element, $elementType); + if (null === $cast) { + $io->error(sprintf('--%s の要素 "%s" は %s で指定してください。', $name, $element, $elementType)); + + return Command::INVALID; + } + $values[] = $cast; + } + $arguments[$name] = $values; + } elseif (null !== $raw) { + $type = $schema->baseType($name); + $cast = $this->cast((string) $raw, $type); + if (null === $cast) { + $io->error(sprintf('--%s は %s で指定してください。', $name, $type)); + + return Command::INVALID; + } + $arguments[$name] = $cast; + } + } + + // 必須検証はキャスト後に走るため、 不正な数値は上の cast 失敗で先に弾かれる + // (0 に化けて array_key_exists を素通りさせない)。 + foreach ($schema->requiredNames() as $required) { + if (!\array_key_exists($required, $arguments)) { + $io->error(sprintf('必須オプション --%s を指定してください。', $required)); + + return Command::INVALID; + } + } + + $result = $this->invoker->call($this->toolName, $arguments); + $result = \is_array($result) ? $result : ['result' => $result]; + + $output->writeln($this->formatter->format($result)); + + return Command::SUCCESS; + } + + /** + * CLI の文字列オプションを inputSchema の型へ検証しつつ変換する。 + * 数値・真偽型で不正な入力 (例 "abc" / "treu") は黙って 0・false にせず、 変換失敗として null を返す + * (成功値は決して null にならないため、 null が失敗を一意に表す)。 真偽型を寛容に false 化すると + * sdk 側の真偽値検証も握り潰すため、 認識できない値は失敗にして INVALID で弾く。 + * 要素型が不明な配列は文字列のまま渡す (数値らしい文字列を勝手に int 化して float や + * 先頭ゼロ ID を壊さない)。 + */ + private function cast(string $value, string $type): int|float|bool|string|null + { + return match ($type) { + 'integer' => false !== ($i = filter_var($value, FILTER_VALIDATE_INT)) ? $i : null, + 'number' => false !== ($f = filter_var($value, FILTER_VALIDATE_FLOAT)) ? $f : null, + 'boolean' => match (strtolower($value)) { + '1', 'true', 'yes', 'on' => true, + '0', 'false', 'no', 'off' => false, + default => null, + }, + default => $value, + }; + } +} 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/McpCliCommandPass.php b/src/Eccube/DependencyInjection/Compiler/McpCliCommandPass.php new file mode 100644 index 00000000000..4e8085661ce --- /dev/null +++ b/src/Eccube/DependencyInjection/Compiler/McpCliCommandPass.php @@ -0,0 +1,110 @@ +` コマンドを 1 個、 遅延サービスとして登録する。 + * + * name と description は compile 時に `mcp.tool` タグ付きサービスの `#[McpTool]` から取れる (name は属性に + * 明示、 無ければ SDK と同じくメソッド名 / `__invoke` はクラス短名にフォールバック)。 inputSchema は + * runtime にしか得られないが、 登録に要るのは name と description だけなので compile 時で足りる。 + * + * `console.command` タグ (lazy) に name と description の両方を渡すため、 `bin/console list`/`help` でも + * 実体化されず、 実際に呼ばれたコマンドだけがインスタンス化される (全 bin/console 呼び出しへ discovery + * コストを乗せない)。 本 pass は inner ReferenceHandler を定義する {@see McpScopeEnforcementPass} + * (優先度 -100) の後に走らせる。 + */ +final class McpCliCommandPass implements CompilerPassInterface +{ + #[\Override] + public function process(ContainerBuilder $container): void + { + // MCP 未配線 (builder 不在) なら守る対象も呼ぶ対象も無いのでスキップ。 + if (!$container->hasDefinition('mcp.server.builder')) { + return; + } + + foreach ($this->collectTools($container) as $toolName => $description) { + $definition = (new Definition(EccubeCliToolCommand::class)) + ->setArguments([ + $toolName, + new Reference(McpCliToolInvoker::class), + new Reference(McpMarkdownFormatter::class), + ]) + ->addTag('console.command', [ + 'command' => 'eccube:cli:'.$toolName, + 'description' => $description, + ]); + + $container->setDefinition('eccube.mcp.cli_command.'.$toolName, $definition); + } + } + + /** + * `mcp.tool` タグ付きサービスをリフレクションし、 ツール名 => 説明 を列挙する。 + * + * SDK の Discoverer に合わせ、 クラス属性 (invokable ツール) とメソッド属性の両方を、 + * `IS_INSTANCEOF` (McpTool を継承した独自属性も拾う) で走査する。 メソッドは public かつ + * static / abstract / コンストラクタでないものに限る (SDK が登録しないメソッドから + * 存在しないコマンドを生成しない)。 同名は先勝ちで 1 つに畳む。 + * + * @return array + */ + private function collectTools(ContainerBuilder $container): array + { + $tools = []; + foreach (array_keys($container->findTaggedServiceIds('mcp.tool')) as $serviceId) { + $class = $container->getDefinition($serviceId)->getClass(); + if (null === $class || !class_exists($class)) { + continue; + } + + $reflection = new \ReflectionClass($class); + + foreach ($reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + /** @var McpTool $instance */ + $instance = $attribute->newInstance(); + $name = $instance->name ?? $reflection->getShortName(); + $tools[$name] ??= (string) ($instance->description ?? ''); + } + + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isStatic() || $method->isAbstract() || $method->isConstructor()) { + continue; + } + foreach ($method->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + /** @var McpTool $instance */ + $instance = $attribute->newInstance(); + $name = $instance->name ?? ('__invoke' === $method->getName() + ? $method->getDeclaringClass()->getShortName() + : $method->getName()); + $tools[$name] ??= (string) ($instance->description ?? ''); + } + } + } + + return $tools; + } +} diff --git a/src/Eccube/DependencyInjection/Compiler/McpScopeEnforcementPass.php b/src/Eccube/DependencyInjection/Compiler/McpScopeEnforcementPass.php new file mode 100644 index 00000000000..fabb3d4b1ce --- /dev/null +++ b/src/Eccube/DependencyInjection/Compiler/McpScopeEnforcementPass.php @@ -0,0 +1,87 @@ +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 + { + // builder が無い (mcp-bundle 未配線) 環境では scope 強制を差し込む先が無い。 ここで先に + // return しないと、 配線されない ReferenceHandler 定義だけが残り、 builder 不在構成の + // コンテナコンパイルを乱す。 MCP サーバが無ければ守る対象も無いのでスキップしてよい。 + if (!$container->hasDefinition('mcp.server.builder')) { + return; + } + + // 本物の Tool 実行器に渡す Tool ServiceLocator を決める。 + // mcp-bundle が builder->setContainer に渡したロケータをそのまま再利用し、 集合の乖離を無くす。 + $toolLocator = $this->resolveToolLocator($container); + + $container->setDefinition( + self::INNER_REFERENCE_HANDLER_ID, + (new Definition(ReferenceHandler::class))->setArguments([$toolLocator]), + ); + + // 全 Tool 呼び出しが通る referenceHandler を scope 強制版に差し替える。 + $container->getDefinition('mcp.server.builder') + ->addMethodCall('setReferenceHandler', [new Reference(ScopeEnforcingReferenceHandler::class)]); + } + + /** + * builder の `setContainer(...)` 呼び出し引数 (= McpPass が組んだ Tool ServiceLocator) を取り出して返す。 + * 見つからなければ `mcp.tool` タグから自前で同等のロケータを構築する。 + */ + private function resolveToolLocator(ContainerBuilder $container): Reference + { + // 呼び出し元 (process) が 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/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 596126bf00d..997a4d53807 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -167,6 +167,9 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'ucp_catalog_requires_auth', type: Types::BOOLEAN, options: ['default' => false])] private bool $ucp_catalog_requires_auth = false; + #[ORM\Column(name: 'mcp_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $mcp_enabled = false; + /** * Get id. * @@ -934,4 +937,22 @@ public function isUcpCatalogRequiresAuth(): bool { return $this->ucp_catalog_requires_auth; } + + /** + * Set mcpEnabled. + */ + public function setMcpEnabled(bool $mcpEnabled): BaseInfo + { + $this->mcp_enabled = $mcpEnabled; + + return $this; + } + + /** + * Get mcpEnabled. + */ + public function isMcpEnabled(): bool + { + return $this->mcp_enabled; + } } 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/McpEnabledListener.php b/src/Eccube/EventListener/Mcp/McpEnabledListener.php new file mode 100644 index 00000000000..b78e4c17a79 --- /dev/null +++ b/src/Eccube/EventListener/Mcp/McpEnabledListener.php @@ -0,0 +1,74 @@ +/mcp` を + * **404** で塞ぐ。 HTTP 経路の tools/list・tools/call・handshake はすべてこの 1 本のルート配下を通るため、 + * ツール層でなく前段リスナ 1 点で止められる。 priority は既存の Origin/CT ガード (16)・レート制限 (14)・ + * admin firewall (8) のいずれよりも早く、 routing (RouterListener=32) よりも前で確定させる 33 に置き、 + * 認証・Origin 検査・レート消費・監査ログの手前で止める。 404 (存在秘匿) を返し、 無効の間は + * エンドポイントの存在自体を明かさない。 + * + * 本リスナが塞ぐのは HTTP のみ。 `eccube:cli:*` は同じフラグを {@see \Eccube\Service\Mcp\McpCliToolInvoker::call()} + * で参照して塞ぐ。 stdio (`mcp:server`) は shell アクセス前提の local-trust 経路のため本フラグの対象外 + * (トランスポート自体の有効化は mcp.yaml の `client_transports.stdio` で制御する)。 + */ +final readonly class McpEnabledListener implements EventSubscriberInterface +{ + private string $mcpPathPrefix; + + public function __construct( + string $eccubeAdminRoute, + private BaseInfoRepository $baseInfoRepository, + ) { + $this->mcpPathPrefix = '/'.$eccubeAdminRoute.'/mcp'; + } + + /** + * @return array + */ + #[\Override] + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onKernelRequest', 33], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + if (!str_starts_with($event->getRequest()->getPathInfo(), $this->mcpPathPrefix)) { + return; + } + + if ($this->baseInfoRepository->get()->isMcpEnabled()) { + return; + } + + $event->setResponse(new Response('', Response::HTTP_NOT_FOUND)); + } +} diff --git a/src/Eccube/EventListener/Mcp/OriginContentTypeListener.php b/src/Eccube/EventListener/Mcp/OriginContentTypeListener.php new file mode 100644 index 00000000000..675501d83cd --- /dev/null +++ b/src/Eccube/EventListener/Mcp/OriginContentTypeListener.php @@ -0,0 +1,167 @@ +/mcp` 配下のリクエストに対して Origin / Content-Type の前段ガードを行う。 + * + * - `Content-Type` が `application/json` で始まらない POST/PUT/DELETE 等は **415** で拒否 + * - `ECCUBE_MCP_ALLOWED_ORIGINS` が設定されている時、`Origin` ヘッダがその許可リストに無ければ **403** + * - 許可リスト未設定時: dev/test は Origin 検証を skip、 **prod では検証できない Origin (=ブラウザ発) を + * 403** で拒否 (Origin 無しの非ブラウザは通す)。 未設定のまま prod で DNS リバインディング等に晒さない + * - 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, + private bool $skipUnvalidatedOrigin, + ) { + $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) { + // 許可リスト未設定時: + // - dev/test ($skipUnvalidatedOrigin=true): 開発利便のため Origin 検証を skip + // - prod: Origin 無し (curl/サーバ間) は通し、 検証できない Origin (=ブラウザ発) は 403 + if ($this->skipUnvalidatedOrigin || null === $request->headers->get('Origin')) { + return; + } + + $this->auditLogger->logSecurityEvent(AuditResult::OriginInvalid, [ + 'path' => $request->getPathInfo(), + 'origin' => $request->headers->get('Origin'), + 'reason' => 'origin_not_configured', + ]); + $event->setResponse(new JsonResponse( + ['error' => 'forbidden', 'message' => 'Origin validation is not configured; browser-originated requests are rejected.'], + Response::HTTP_FORBIDDEN, + )); + + 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/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index eb5fa362bb7..04a033bf53b 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -225,6 +225,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void // エージェントコマース checkout の有効化フラグ (discovery / catalog は常時公開、checkout のみ制御) ->add('acp_checkout_enabled', ToggleSwitchType::class) ->add('ucp_checkout_enabled', ToggleSwitchType::class) + // MCP サーバ機能の有効化フラグ (既定 OFF。 OFF の間は ^/admin/mcp が 404) + ->add('mcp_enabled', ToggleSwitchType::class) ; $builder->add( diff --git a/src/Eccube/Kernel.php b/src/Eccube/Kernel.php index 1f7b0dfcad4..1b663acbdbc 100644 --- a/src/Eccube/Kernel.php +++ b/src/Eccube/Kernel.php @@ -17,6 +17,9 @@ use Eccube\Common\EccubeNav; use Eccube\Common\EccubeTwigBlock; use Eccube\DependencyInjection\Compiler\AutoConfigurationTagPass; +use Eccube\DependencyInjection\Compiler\McpAuditLoggerChannelLockPass; +use Eccube\DependencyInjection\Compiler\McpCliCommandPass; +use Eccube\DependencyInjection\Compiler\McpScopeEnforcementPass; use Eccube\DependencyInjection\Compiler\NavCompilerPass; use Eccube\DependencyInjection\Compiler\PaymentMethodPass; use Eccube\DependencyInjection\Compiler\PluginPass; @@ -295,6 +298,18 @@ 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); + + // MCP: 各ツールを eccube:cli: コマンドとして登録する。 inner ReferenceHandler を定義する + // McpScopeEnforcementPass (-100) の後に走らせるため、 それより低い優先度で登録する。 + $container->addCompilerPass(new McpCliCommandPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -200); } protected function addEntityExtensionPass(ContainerBuilder $container): void diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 45c73239142..d83422df96a 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1291,6 +1291,9 @@ admin.setting.shop.shop.agent_commerce: "Agent Commerce" admin.setting.shop.shop.agent_commerce.description: "Enable checkout via AI agents (ChatGPT / Gemini, etc.). Discovery and catalog are always public; only checkout is controlled here. Checkout is not yet available in Japan, so normally leave these disabled." admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "Enable ACP checkout" admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "Enable UCP checkout" +admin.setting.shop.shop.mcp: "MCP Server" +admin.setting.shop.shop.mcp.description: "Enables the MCP server (^/admin/mcp) that lets AI clients (Claude Desktop, Cursor, etc.) read management data. Read-only. Disabled by default; while disabled the endpoint returns 404. Requires the API plugin (api44) enabled with an OAuth2 token issued." +admin.setting.shop.shop.mcp_enabled: "Enable MCP server" #------------------------------------------------------------------------------------ # Settings:Store Settings:Trade Law Settings diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 0e7b5494748..16a28a2b750 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1290,6 +1290,9 @@ admin.setting.shop.shop.agent_commerce: "エージェントコマース" admin.setting.shop.shop.agent_commerce.description: "AIエージェント (ChatGPT / Gemini 等) 経由のチェックアウトを有効化します。Discovery / カタログは常時公開され、ここでは checkout のみを制御します。checkout は日本未提供のため、通常は無効のままにしてください。" admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "ACP チェックアウトを有効にする" admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "UCP チェックアウトを有効にする" +admin.setting.shop.shop.mcp: "MCP サーバ" +admin.setting.shop.shop.mcp.description: "AI クライアント(Claude Desktop / Cursor 等)が管理データを参照する MCP サーバ(^/admin/mcp)を有効化します。読み取り専用です。既定は無効で、無効の間はエンドポイントを 404 で塞ぎます。利用には API プラグイン(api44)が有効で、OAuth2 トークンを発行済みである必要があります。" +admin.setting.shop.shop.mcp_enabled: "MCP サーバを有効にする" #------------------------------------------------------------------------------------ # 設定:店舗設定:特定商取引法設定 diff --git a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig index 0e37636b8d0..5357b558d70 100644 --- a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig +++ b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig @@ -425,6 +425,21 @@ file that was distributed with this source code. +
+
{{ 'admin.setting.shop.shop.mcp'|trans }}
+
+

{{ 'admin.setting.shop.shop.mcp.description'|trans }}

+
+
+ {{ 'admin.setting.shop.shop.mcp_enabled'|trans }} +
+
+ {{ form_widget(form.mcp_enabled) }} + {{ form_errors(form.mcp_enabled) }} +
+
+
+
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 @@ +` に変換する。 + * + * $summarizeRelations に挙げた root 直下の関連は、 深さに関係なく要約 (Collection は各要素の + * `{id}` リスト、 単一 Entity は `{id}`) へ縮退する。 詳細系ツールで巨大・不要な関連 + * (例: Customer.Orders) を落としつつ本体フィールドは残す用途。 縮退は root (depth 0) の + * 直下関連にのみ効く。 + * + * @param list $summarizeRelations id 要約へ縮退する root 直下の関連プロパティ名 + * + * @return array + */ + public function toArray(object $entity, int $maxDepth = self::DEFAULT_MAX_DEPTH, array $summarizeRelations = []): array + { + $visited = []; + + return $this->convertEntity($entity, 0, $maxDepth, $visited, $summarizeRelations); + } + + /** + * 複数 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; + } + + /** + * Entity を「サマリ射影」で配列化する (一覧系ツール向けの軽量出力)。 + * + * $fields は「スカラ名 (例 `order_no`)」または 1 段のドット path (例 `OrderStatus.name`) のリスト。 + * full 経路 (toArray) と同じく allow_list を通過した項目だけを出力する。 allow_list で許可されない + * 項目だけをキーごと出さない (security)。 一方、 許可済みだが値が無いだけのもの (スカラ null / + * ドット path で関連が存在しない) はキーを null で残す。 関連 (Collection / Entity) は展開せず + * 値は null になる (キーは出るが中身は持ち込まない)。 よって出力は常にフラットで小さい。 + * + * @param list $fields + * + * @return array + */ + public function toSummary(object $entity, array $fields): array + { + $class = $this->resolveEntityClass($entity); + $result = []; + foreach ($fields as $field) { + if (str_contains($field, '.')) { + [$relation, $sub] = explode('.', $field, 2); + if (!$this->allowListResolver->isAllowed($class, $relation)) { + continue; // relation が allow_list 外 (security) — キーごと出さない + } + $related = $this->readProperty($entity, $relation); + if (!\is_object($related)) { + // 関連が存在しない (データ状態)。 security 由来のスキップと区別し、 + // スカラ経路と対称にキーは null で残す。 + $result[$relation] = null; + continue; + } + if (!$this->allowListResolver->isAllowed($this->resolveEntityClass($related), $sub)) { + continue; // sub が allow_list 外 (security) + } + $result[$relation][$sub] = $this->toScalar($this->readProperty($related, $sub)); + continue; + } + if (!$this->allowListResolver->isAllowed($class, $field)) { + continue; + } + $result[$field] = $this->toScalar($this->readProperty($entity, $field)); + } + + return $result; + } + + /** + * 複数 Entity をサマリ射影でまとめて変換する。 + * + * @param iterable $entities + * @param list $fields + * + * @return list> + */ + public function toSummaryList(iterable $entities, array $fields): array + { + $result = []; + foreach ($entities as $entity) { + $result[] = $this->toSummary($entity, $fields); + } + + return $result; + } + + /** + * サマリ出力用のスカラ化。 スカラはそのまま、 `\DateTimeInterface` は ISO 8601。 + * それ以外 (オブジェクト / 配列 / Collection) はすべて null (サマリでは関連を持ち込まない)。 + */ + private function toScalar(mixed $value): mixed + { + if (null === $value || \is_scalar($value)) { + return $value; + } + if ($value instanceof \DateTimeInterface) { + return $value->format(\DateTimeInterface::ATOM); + } + + return null; + } + + /** + * @param array $visited spl_object_id をキーにした訪問済みセット + * @param list $summarizeRelations root 直下で id 要約へ縮退する関連プロパティ名 + * + * @return array + */ + private function convertEntity(object $entity, int $depth, int $maxDepth, array &$visited, array $summarizeRelations = []): 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); + if (0 === $depth && \in_array($prop, $summarizeRelations, true)) { + // root 直下の指定関連は深さに関係なく id 要約へ縮退する + $result[$prop] = $this->summarizeRelation($value); + continue; + } + $result[$prop] = $this->convertValue($value, $depth + 1, $maxDepth, $visited); + } + + unset($visited[$oid]); + + return $result; + } + + /** + * root 直下の指定関連を要約へ縮退する。 Collection は各要素の `{id}` リスト、 単一 Entity は + * `{id}`、 関連が無ければ null。 id 露出可否は `summarize()` が allow_list で再判定する。 + */ + private function summarizeRelation(mixed $value): mixed + { + if ($value instanceof Collection || (\is_iterable($value) && !\is_array($value))) { + return $this->summarizeMany($value); + } + if (\is_object($value)) { + return $this->summarize($value); + } + + return null; + } + + /** + * @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/McpCliToolInvoker.php b/src/Eccube/Service/Mcp/McpCliToolInvoker.php new file mode 100644 index 00000000000..5584d4396f0 --- /dev/null +++ b/src/Eccube/Service/Mcp/McpCliToolInvoker.php @@ -0,0 +1,90 @@ +tool` に name / description / inputSchema)。 + */ + public function tool(string $name): ToolReference + { + $this->prime(); + + return $this->registry->getTool($name); + } + + /** + * ツールを実行し、 各ツールの生の返り値を返す。 + * + * @param array $arguments + */ + public function call(string $name, array $arguments): mixed + { + if (!$this->baseInfoRepository->get()->isMcpEnabled()) { + throw new \RuntimeException('MCP 機能は無効です。 店舗設定で MCP サーバを有効にしてください。'); + } + + $reference = $this->tool($name); + + // sdk の ReferenceHandler は arguments['_session'] を無条件参照する (本来はサーバが + // リクエスト時に注入する)。 CLI にセッションは無く各ツールも session を取らないため、 + // null を置いてキー欠落を避ける (RequestContext 経路は isset ガードで発火しない)。 + $arguments['_session'] = null; + + return $this->handler->handle($reference, $arguments); + } + + private function prime(): void + { + if ($this->primed) { + return; + } + + $this->builder->build(); + $this->primed = true; + } +} diff --git a/src/Eccube/Service/Mcp/McpMarkdownFormatter.php b/src/Eccube/Service/Mcp/McpMarkdownFormatter.php new file mode 100644 index 00000000000..52196529e7a --- /dev/null +++ b/src/Eccube/Service/Mcp/McpMarkdownFormatter.php @@ -0,0 +1,244 @@ + $result + */ + public function format(array $result): string + { + if (isset($result['items']) && \is_array($result['items'])) { + return $this->formatList($result); + } + + return $this->formatDetail($result); + } + + /** + * @param array $result + */ + private function formatList(array $result): string + { + /** @var list $items */ + $items = array_values((array) $result['items']); + + // items 以外: スカラーはメタ情報 (total / limit / offset 等)、 非スカラーはセクションにする + // (list 経路でも非スカラーの兄弟キーを脱落させない)。 + $meta = []; + $sections = []; + foreach ($result as $key => $value) { + if ('items' === $key) { + continue; + } + if ($this->isScalar($value)) { + $meta[] = '**'.$key.'**: '.$this->scalarToString($value); + } else { + $sections[] = $this->renderSection((string) $key, $value); + } + } + + $blocks = []; + if ([] !== $meta) { + $blocks[] = implode(' ・ ', $meta); + } + $blocks[] = [] === $items ? '該当なし' : $this->renderTable($items); + foreach ($sections as $section) { + $blocks[] = $section; + } + + return implode("\n\n", $blocks); + } + + /** + * @param array $result + */ + private function formatDetail(array $result): string + { + $scalarLines = []; + $sections = []; + + foreach ($result as $key => $value) { + if ($this->isScalar($value)) { + $scalarLines[] = '- **'.$key.'**: '.$this->scalarToString($value); + } else { + $sections[] = $this->renderSection((string) $key, $value); + } + } + + $blocks = []; + if ([] !== $scalarLines) { + $blocks[] = implode("\n", $scalarLines); + } + foreach ($sections as $section) { + $blocks[] = $section; + } + + return implode("\n\n", $blocks); + } + + /** + * ネストした値を小見出し付きセクションにする: オブジェクト配列 → 表、 単一オブジェクト → 箇条書き、 空 → (なし)。 + */ + private function renderSection(string $key, mixed $value): string + { + $array = (array) $value; + if ([] === $array) { + return '### '.$key."\n".'(なし)'; + } + if ($this->isListOfObjects($array)) { + return '### '.$key."\n".$this->renderTable(array_values($array)); + } + + return '### '.$key."\n".$this->renderKeyValueList($array); + } + + /** + * オブジェクトの list を Markdown 表にする。 列は全行キーの和集合 (行ごとにキーが異なっても列落ちさせない)。 + * + * @param list $rows + */ + private function renderTable(array $rows): string + { + $first = $rows[0]; + if (!\is_array($first)) { + // スカラーの list はそのまま箇条書き + return implode("\n", array_map(fn ($v): string => '- '.$this->cellToString($v), $rows)); + } + + // 列は全行キーの和集合 (行ごとにキーが異なっても列落ちさせない)。 + $columnSet = []; + foreach ($rows as $row) { + if (\is_array($row)) { + foreach (array_keys($row) as $column) { + $columnSet[$column] = true; + } + } + } + $columns = array_keys($columnSet); + $header = '| '.implode(' | ', $columns).' |'; + $separator = '| '.implode(' | ', array_fill(0, \count($columns), '---')).' |'; + + $body = []; + foreach ($rows as $row) { + if (!\is_array($row)) { + // 表の途中に紛れたスカラー要素も値を落とさず先頭列に出す (列数分パディング)。 + $body[] = '| '.implode(' | ', array_pad([$this->cellToString($row)], \count($columns), '')).' |'; + + continue; + } + $cells = []; + foreach ($columns as $column) { + $cells[] = $this->cellToString($row[$column] ?? null); + } + $body[] = '| '.implode(' | ', $cells).' |'; + } + + return implode("\n", array_merge([$header, $separator], $body)); + } + + /** + * @param array $data + */ + private function renderKeyValueList(array $data): string + { + $lines = []; + foreach ($data as $key => $value) { + $lines[] = '- **'.$key.'**: '.$this->cellToString($value); + } + + return implode("\n", $lines); + } + + /** + * 表のセル 1 個を 1 行文字列にする。 パイプと改行は表を壊すのでエスケープ / 空白化する。 + */ + private function cellToString(mixed $value): string + { + if ($this->isScalar($value)) { + return $this->escapePipe($this->scalarToString($value)); + } + + $array = (array) $value; + // {min, max} は価格 / 在庫の集約なので "min – max" 表記にする。 在庫は unlimited フラグを持つ。 + // min / max がスカラーのときだけレンジ扱いし、 そうでなければ下の JSON 経路に落とす。 + if (\array_key_exists('min', $array) && \array_key_exists('max', $array) + && $this->isScalar($array['min']) && $this->isScalar($array['max'])) { + // 文字列 "false" 等でも誤って無制限にしないよう真偽値として厳密に解釈する。 + $unlimited = filter_var($array['unlimited'] ?? false, FILTER_VALIDATE_BOOLEAN); + if (null === $array['min'] && null === $array['max']) { + return $unlimited ? '無制限' : ''; + } + $range = $this->scalarToString($array['min']).' – '.$this->scalarToString($array['max']); + + return $this->escapePipe($unlimited ? $range.' (一部無制限)' : $range); + } + + // それ以外のネストは 1 行 JSON でコンパクトに。 エンコード失敗は空セルに化けさせず可視化する。 + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return $this->escapePipe(false === $json ? '(encode error)' : $json); + } + + private function isScalar(mixed $value): bool + { + return null === $value || \is_scalar($value); + } + + private function scalarToString(mixed $value): string + { + if (null === $value) { + return ''; + } + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return (string) $value; + } + + /** + * 連想でない (0..n の list) かつ全要素が配列なら「オブジェクトの list」とみなす。 + * + * @param array $array + */ + private function isListOfObjects(array $array): bool + { + if (!array_is_list($array)) { + return false; + } + foreach ($array as $element) { + if (!\is_array($element)) { + return false; + } + } + + return true; + } + + private function escapePipe(string $value): string + { + return str_replace(['|', "\n"], ['\\|', ' '], $value); + } +} 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 ORDER = ['id', 'order_no', 'order_date', 'payment_total', 'name01', 'name02', 'email', 'OrderStatus.name']; + + /** + * @var list + */ + public const CUSTOMER = ['id', 'name01', 'name02', 'email', 'phone_number', 'buy_times', 'buy_total', 'last_buy_date']; + + /** + * @var list Product 基底のサマリ。 価格・在庫は `ProductPriceStockSummarizer` が別途付加する + */ + public const PRODUCT = ['id', 'name', 'Status.name']; +} diff --git a/src/Eccube/Service/Mcp/McpToolScopeMap.php b/src/Eccube/Service/Mcp/McpToolScopeMap.php new file mode 100644 index 00000000000..870273ee6cd --- /dev/null +++ b/src/Eccube/Service/Mcp/McpToolScopeMap.php @@ -0,0 +1,64 @@ + + */ + 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/ProductPriceStockSummarizer.php b/src/Eccube/Service/Mcp/ProductPriceStockSummarizer.php new file mode 100644 index 00000000000..9daf0ffee34 --- /dev/null +++ b/src/Eccube/Service/Mcp/ProductPriceStockSummarizer.php @@ -0,0 +1,87 @@ +allowListResolver->isAllowed(ProductClass::class, 'price02'); + $stockAllowed = $this->allowListResolver->isAllowed(ProductClass::class, 'stock'); + $unlimitedAllowed = $this->allowListResolver->isAllowed(ProductClass::class, 'stock_unlimited'); + + /** @var list $prices */ + $prices = []; + /** @var list $finiteStocks */ + $finiteStocks = []; + $hasUnlimited = false; + + foreach ($product->getProductClasses() ?? [] as $productClass) { + // 非公開の規格 (規格ありの placeholder 等) は店頭在庫・価格に出ないため集計しない + if (!$productClass->isVisible()) { + continue; + } + $price = $productClass->getPrice02(); + if (null !== $price) { + $prices[] = $price; + } + if ($productClass->isStockUnlimited()) { + $hasUnlimited = true; + continue; + } + $stock = $productClass->getStock(); + if (null !== $stock) { + $finiteStocks[] = $stock; + } + } + + return [ + 'price' => $priceAllowed + ? ['min' => [] === $prices ? null : min($prices), 'max' => [] === $prices ? null : max($prices)] + : null, + 'stock' => $stockAllowed + ? [ + 'min' => [] === $finiteStocks ? null : min($finiteStocks), + 'max' => [] === $finiteStocks ? null : max($finiteStocks), + 'unlimited' => $unlimitedAllowed && $hasUnlimited, + ] + : 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/ScopeFilteringRegistry.php b/src/Eccube/Service/Mcp/ScopeFilteringRegistry.php new file mode 100644 index 00000000000..689f8a5597c --- /dev/null +++ b/src/Eccube/Service/Mcp/ScopeFilteringRegistry.php @@ -0,0 +1,260 @@ +tokenStorage->getToken()) { + return $this->inner->getTools($limit, $cursor); + } + + // ページング前に全件を取得して scope で絞る。 内側で先にページングしてから間引くと、 + // 可視 Tool が pageSize を跨いだとき空ページ + 非 null カーソルが生じ、 一覧が欠ける。 + // そこで絞った後の集合に対して自前で (内側と同じ base64(offset) 方式で) ページングする。 + $visible = array_values(array_filter( + $this->inner->getTools()->references, + fn ($tool): bool => $tool instanceof Tool && $this->isVisible($tool->name), + )); + + if (null === $limit) { + return new Page($visible, null); + } + + $offset = $this->decodeCursor($cursor); + $nextOffset = $offset + $limit; + $nextCursor = $nextOffset < \count($visible) ? base64_encode((string) $nextOffset) : null; + + return new Page(array_slice($visible, $offset, $limit), $nextCursor); + } + + /** + * 現在のトークンがこの Tool を呼べるか。 中央マップ未登録 (= call 時 fail-closed deny) の Tool は + * 一覧からも隠す。 + */ + private function isVisible(string $toolName): bool + { + $role = McpToolScopeMap::requiredRole($toolName); + + return null !== $role && $this->authorizationChecker->isGranted($role); + } + + /** + * 内側 Registry と同じ base64(offset) 形式のカーソルを offset へ復号する。 不正なカーソルは + * 内側の `paginateResults` と同じく `InvalidCursorException` にする。 + */ + private function decodeCursor(?string $cursor): int + { + if (null === $cursor) { + return 0; + } + + $decoded = base64_decode($cursor, true); + if (false === $decoded || !ctype_digit($decoded)) { + throw new InvalidCursorException($cursor); + } + + return (int) $decoded; + } + + #[\Override] + public function registerTool(Tool $tool, callable|array|string $handler): ToolReference + { + return $this->inner->registerTool($tool, $handler); + } + + #[\Override] + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference + { + return $this->inner->registerResource($resource, $handler); + } + + /** + * @param array $completionProviders + */ + #[\Override] + public function registerResourceTemplate( + ResourceTemplate $template, + callable|array|string $handler, + array $completionProviders = [], + ): ResourceTemplateReference { + return $this->inner->registerResourceTemplate($template, $handler, $completionProviders); + } + + /** + * @param array $completionProviders + */ + #[\Override] + public function registerPrompt( + Prompt $prompt, + callable|array|string $handler, + array $completionProviders = [], + ): PromptReference { + return $this->inner->registerPrompt($prompt, $handler, $completionProviders); + } + + #[\Override] + public function unregisterTool(string $name): void + { + $this->inner->unregisterTool($name); + } + + #[\Override] + public function unregisterResource(string $uri): void + { + $this->inner->unregisterResource($uri); + } + + #[\Override] + public function unregisterResourceTemplate(string $uriTemplate): void + { + $this->inner->unregisterResourceTemplate($uriTemplate); + } + + #[\Override] + public function unregisterPrompt(string $name): void + { + $this->inner->unregisterPrompt($name); + } + + #[\Override] + public function hasTool(string $name): bool + { + return $this->inner->hasTool($name); + } + + #[\Override] + public function hasResource(string $uri): bool + { + return $this->inner->hasResource($uri); + } + + #[\Override] + public function hasResourceTemplate(string $uriTemplate): bool + { + return $this->inner->hasResourceTemplate($uriTemplate); + } + + #[\Override] + public function hasPrompt(string $name): bool + { + return $this->inner->hasPrompt($name); + } + + #[\Override] + public function hasTools(): bool + { + return $this->inner->hasTools(); + } + + #[\Override] + public function getTool(string $name): ToolReference + { + return $this->inner->getTool($name); + } + + #[\Override] + public function hasResources(): bool + { + return $this->inner->hasResources(); + } + + #[\Override] + public function getResources(?int $limit = null, ?string $cursor = null): Page + { + return $this->inner->getResources($limit, $cursor); + } + + #[\Override] + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference + { + return $this->inner->getResource($uri, $includeTemplates); + } + + #[\Override] + public function hasResourceTemplates(): bool + { + return $this->inner->hasResourceTemplates(); + } + + #[\Override] + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page + { + return $this->inner->getResourceTemplates($limit, $cursor); + } + + #[\Override] + public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference + { + return $this->inner->getResourceTemplate($uriTemplate); + } + + #[\Override] + public function hasPrompts(): bool + { + return $this->inner->hasPrompts(); + } + + #[\Override] + public function getPrompts(?int $limit = null, ?string $cursor = null): Page + { + return $this->inner->getPrompts($limit, $cursor); + } + + #[\Override] + public function getPrompt(string $name): PromptReference + { + return $this->inner->getPrompt($name); + } +} diff --git a/src/Eccube/Service/Mcp/Tool/GetCustomerOrdersTool.php b/src/Eccube/Service/Mcp/Tool/GetCustomerOrdersTool.php new file mode 100644 index 00000000000..7ff2e986c14 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetCustomerOrdersTool.php @@ -0,0 +1,109 @@ +>} + */ + #[McpTool( + name: 'get_customer_orders', + description: 'EC-CUBE の指定会員の購入履歴 (注文一覧) を取得する。 読み取り専用。 必要 scope: mcp:customer:read。', + )] + public function get(int $customerId, int $limit = 10, 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(); + // 顧客 scope から注文明細・配送先 PII を露出させないため、 search_orders と同じサマリ射影で返す + $items = $this->serializer->toSummaryList($paginator, McpSummaryFields::ORDER); + + 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..582e9a590d3 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetCustomerTool.php @@ -0,0 +1,72 @@ + 見つからなければ空配列 + */ + #[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 [ + // 注文一覧はサイズが大きく詳細に不要 (専用の get_customer_orders に委譲)。 + // お気に入りも詳細で不要なため id 要約へ縮退する。 + 'data' => $this->serializer->toArray($customer, summarizeRelations: ['Orders', 'CustomerFavoriteProducts']), + '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..334460c9996 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetOrderTool.php @@ -0,0 +1,87 @@ + 見つからなければ空配列 + */ + #[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 [ + // メール送信履歴は本文級で重く注文詳細に不要なため id 要約へ縮退する。 + // OrderItems / Shippings は注文詳細の本体なので残す。 + 'data' => $this->serializer->toArray($order, summarizeRelations: ['MailHistories']), + '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..7d200e574fa --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/GetProductTool.php @@ -0,0 +1,95 @@ + 見つからなければ空配列 + */ + #[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], + ]; + } + + // お気に入り登録は商品詳細に不要なため id 要約へ縮退する。 + // ProductClasses (規格・価格・在庫) / ProductImage は詳細の本体なので残す。 + $data = $this->serializer->toArray($product, summarizeRelations: ['CustomerFavoriteProducts']); + + 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..d0678f7a618 --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/SearchCustomersTool.php @@ -0,0 +1,184 @@ +>} + */ + #[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 = 10, + 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); + + // 明示指定した statusIds が 1 つも解決しないと customer_status フィルタが落ち、 + // 全会員 (PII 込み) を返してしまう。 絞り込み意図を尊重し 0 件を返す。 + if (null !== $statusIds && !isset($searchData['customer_status'])) { + return [ + 'data' => ['total' => 0, 'limit' => $limit, 'offset' => $offset, 'items' => []], + 'summary' => ['total' => 0, 'returned' => 0], + ]; + } + + $qb = $this->customerRepository->getQueryBuilderBySearchData($searchData); + $qb->setMaxResults($limit)->setFirstResult($offset); + + $paginator = new Paginator($qb, fetchJoinCollection: true); + + $total = $paginator->count(); + $items = $this->serializer->toSummaryList($paginator, McpSummaryFields::CUSTOMER); + + 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..cd1fa278efb --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/SearchOrdersTool.php @@ -0,0 +1,184 @@ +>} + */ + #[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 = 10, + 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); + + // 明示指定した statusIds が 1 つも解決しないと status フィルタが落ち、 + // 全注文 (PII 込み) を返してしまう。 絞り込み意図を尊重し 0 件を返す。 + if (null !== $statusIds && !isset($searchData['status'])) { + return [ + 'data' => ['total' => 0, 'limit' => $limit, 'offset' => $offset, 'items' => []], + 'summary' => ['total' => 0, 'returned' => 0], + ]; + } + + $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->toSummaryList($paginator, McpSummaryFields::ORDER); + + 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)) { + // OrderRepository は order_no を完全一致で検索するため、 前後空白を除去して渡す。 + $searchData['order_no'] = trim($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..de206c224dc --- /dev/null +++ b/src/Eccube/Service/Mcp/Tool/SearchProductsTool.php @@ -0,0 +1,180 @@ +>} + */ + #[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 = 10, + 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); + + // 明示指定した statusIds が 1 つも解決しないと、 admin 検索は status フィルタ落ちで + // 全状態 (非公開・廃止含む) を返してしまう。 絞り込み意図を尊重し 0 件を返す。 + if (null !== $statusIds && !isset($searchData['status'])) { + return [ + 'data' => ['total' => 0, 'limit' => $limit, 'offset' => $offset, 'items' => []], + 'summary' => ['total' => 0, 'returned' => 0], + ]; + } + + $qb = $this->productRepository->getQueryBuilderBySearchDataForAdmin($searchData); + + // 在庫での絞り込みは fetch-join した pc を直接制約すると、 Paginator(fetchJoinCollection) + // が ProductClasses を「条件に合う規格だけ」部分ハイドレートし、 価格/在庫レンジ集計 + // (ProductPriceStockSummarizer) が条件外の規格を落としてレンジが縮む。 商品単位の EXISTS + // 部分クエリで絞り込み、 pc の完全ハイドレート (=正しいレンジ) を保つ。 + // + // min/max は 1 本の EXISTS にまとめ、 同一規格が両方を満たすこと (=「在庫が [stockMin, stockMax] + // に入る規格を持つ商品」) を要求する。 別々の EXISTS だと min と max を別規格が満たしてもヒットし + // レンジ交差になる。 visible = true は、 出力レンジ側 (ProductPriceStockSummarizer が非表示規格を + // 除外して集計) と絞り込みの母集団を揃えるため (非表示規格だけが条件を満たす商品を除外する)。 + if (null !== $stockMin || null !== $stockMax) { + $dql = 'SELECT pcStock.id FROM '.ProductClass::class.' pcStock' + .' WHERE pcStock.Product = p AND pcStock.visible = true AND pcStock.stock_unlimited = false'; + if (null !== $stockMin) { + $dql .= ' AND pcStock.stock >= :mcpStockMin'; + } + if (null !== $stockMax) { + $dql .= ' AND pcStock.stock <= :mcpStockMax'; + } + $qb->andWhere($qb->expr()->exists($dql)); + if (null !== $stockMin) { + $qb->setParameter('mcpStockMin', $stockMin); + } + if (null !== $stockMax) { + $qb->setParameter('mcpStockMax', $stockMax); + } + } + + $qb->setMaxResults($limit)->setFirstResult($offset); + $paginator = new Paginator($qb, fetchJoinCollection: true); + + $total = $paginator->count(); + $items = []; + foreach ($paginator as $product) { + /** @var Product $product */ + // `+` は左辺優先。 サマリ (左) と price/stock (右) はキーが衝突しない前提。 + // McpSummaryFields::PRODUCT に price/stock と同名キーを足さないこと (足すと集約値が消える)。 + $items[] = $this->serializer->toSummary($product, McpSummaryFields::PRODUCT) + + $this->priceStockSummarizer->summarize($product); + } + + 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/ToolInputSchema.php b/src/Eccube/Service/Mcp/ToolInputSchema.php new file mode 100644 index 00000000000..26196161f99 --- /dev/null +++ b/src/Eccube/Service/Mcp/ToolInputSchema.php @@ -0,0 +1,118 @@ + */ + private array $properties; + + /** @var list */ + private array $required; + + public function __construct(Tool $tool) + { + // sdk は引数なしツールの properties を空配列でなく \stdClass に正規化する (Tool::normalizeSchemaProperties)。 + // inputSchema の PHPDoc は properties: array と宣言するが実体は array|\stdClass なので、 その型で受けて配列化する。 + /** @var array|\stdClass $rawProperties */ + $rawProperties = $tool->inputSchema['properties'] ?? []; + $this->properties = \is_array($rawProperties) ? $rawProperties : []; + + // required は上記の正規化対象外で常に配列 (SDK は required の型を変換しない)。 + $rawRequired = $tool->inputSchema['required'] ?? []; + $this->required = array_values(array_map(strval(...), $rawRequired)); + } + + /** + * @return list + */ + public function propertyNames(): array + { + return array_keys($this->properties); + } + + /** + * @return list + */ + public function requiredNames(): array + { + return $this->required; + } + + public function isArray(string $name): bool + { + return 'array' === $this->baseType($name); + } + + /** + * プロパティの基底型 (nullable を外した型。 例: ['null','integer'] → 'integer')。 + */ + public function baseType(string $name): string + { + return $this->normalizeType($this->spec($name)['type'] ?? 'string'); + } + + /** + * 配列プロパティの要素型 (items.type)。 未指定なら 'string'。 + */ + public function elementType(string $name): string + { + $items = $this->spec($name)['items'] ?? []; + + return $this->normalizeType(\is_array($items) ? ($items['type'] ?? 'string') : 'string'); + } + + public function description(string $name): string + { + return (string) ($this->spec($name)['description'] ?? ''); + } + + /** + * 1 プロパティのスキーマ断片を配列として得る。 SDK は各プロパティを JSON オブジェクト (=配列) で + * 渡すが型を過信せず、 非配列は空スキーマ扱いにして掘り先 (['type'] 等) の TypeError を防ぐ。 + * + * @return array + */ + private function spec(string $name): array + { + $spec = $this->properties[$name] ?? null; + + return \is_array($spec) ? $spec : []; + } + + private function normalizeType(mixed $type): string + { + if (\is_array($type)) { + $nonNull = array_values(array_filter($type, static fn ($candidate): bool => 'null' !== $candidate)); + + // 単一型ならその型。 複数型の union はどれか一意に決められないため、 誤って int 等で + // キャストして有効な他型入力を弾かないよう最も寛容な string として扱う。 + return 1 === \count($nonNull) ? (string) $nonNull[0] : 'string'; + } + + return \is_string($type) ? $type : 'string'; + } +} 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/Command/EccubeCliToolCommandTest.php b/tests/Eccube/Tests/Command/EccubeCliToolCommandTest.php new file mode 100644 index 00000000000..46feae1b295 --- /dev/null +++ b/tests/Eccube/Tests/Command/EccubeCliToolCommandTest.php @@ -0,0 +1,70 @@ +assertSame($expected, $this->cast($value, $type)); + } + + /** + * @return iterable + */ + public static function castCases(): iterable + { + // integer: 不正は黙って 0 にせず null (=失敗)。 0 は失敗でなく成功値として保持。 + yield 'integer' => ['42', 'integer', 42]; + yield 'integer zero is a value, not failure' => ['0', 'integer', 0]; + yield 'integer invalid is failure' => ['abc', 'integer', null]; + + // number: 整数らしい入力も float を保つ。 不正は null。 + yield 'number float' => ['1.5', 'number', 1.5]; + yield 'number integer-like stays float' => ['2', 'number', 2.0]; + yield 'number invalid is failure' => ['abc', 'number', null]; + + // boolean: 認識できる真偽トークンのみ受理。 それ以外は false 化せず null (=失敗)。 + yield 'boolean true token' => ['yes', 'boolean', true]; + yield 'boolean false token' => ['off', 'boolean', false]; + yield 'boolean invalid is failure' => ['maybe', 'boolean', null]; + + // 未知の型は素通し (数値らしい文字列を勝手に int 化しない)。 + yield 'unknown type passes through as string' => ['00123', 'object', '00123']; + } + + private function cast(string $value, string $type): int|float|bool|string|null + { + $command = (new \ReflectionClass(EccubeCliToolCommand::class))->newInstanceWithoutConstructor(); + + /** @var int|float|bool|string|null $result */ + $result = (new \ReflectionMethod(EccubeCliToolCommand::class, 'cast'))->invoke($command, $value, $type); + + return $result; + } +} diff --git a/tests/Eccube/Tests/Command/McpCliCommandTest.php b/tests/Eccube/Tests/Command/McpCliCommandTest.php new file mode 100644 index 00000000000..81b80f27c8c --- /dev/null +++ b/tests/Eccube/Tests/Command/McpCliCommandTest.php @@ -0,0 +1,169 @@ +` 動的コマンド群({@see \Eccube\Command\EccubeCliToolCommand})の結合テスト。 + * + * コマンドは McpCliCommandPass が MCP registry から生成する。 ツール集合・射影は Api44 の + * allow_list に依存するため、 Api44 導入済みの mcp ジョブで実走する。 + */ +#[Group('mcp')] +final class McpCliCommandTest extends EccubeTestCase +{ + use EnablesMcpTrait; + + private ?Application $application = null; + + public function setUp(): void + { + parent::setUp(); + $this->application = new Application(static::$kernel); + $this->setMcpEnabled(true); + } + + public function testToolsAreRegisteredAsCommands(): void + { + $names = array_keys($this->application->all('eccube:cli')); + + $this->assertContains('eccube:cli:search_products', $names); + $this->assertContains('eccube:cli:get_product', $names); + $this->assertContains('eccube:cli:get_customer_orders', $names); + } + + public function testSearchProductsReturnsMarkdownTable(): void + { + $product = $this->createProduct('MCPCLI Markdown Product'); + + $tester = $this->execute('eccube:cli:search_products', [ + '--keyword' => 'MCPCLI Markdown Product', + '--limit' => '5', + ]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $display = $tester->getDisplay(); + + // Markdown 表のヘッダと、 作成商品の行が出る + $this->assertStringContainsString('| id | name |', $display); + $this->assertStringContainsString($product->getName(), $display); + // 詳細フィールドは含まない (サマリ射影) + $this->assertStringNotContainsString('description_detail', $display); + } + + public function testGetProductReturnsMarkdownDetail(): void + { + $product = $this->createProduct('MCPCLI Detail Product'); + + $tester = $this->execute('eccube:cli:get_product', ['--id' => (string) $product->getId()]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $display = $tester->getDisplay(); + + // 詳細系: スカラーは定義リスト、 ネスト関連は小見出し + $this->assertStringContainsString('- **id**: '.$product->getId(), $display); + $this->assertStringContainsString('### ProductClasses', $display); + } + + public function testMissingRequiredOptionFails(): void + { + // get_customer_orders は customerId が必須 + $tester = $this->execute('eccube:cli:get_customer_orders', []); + + $this->assertSame(Command::INVALID, $tester->getStatusCode()); + $this->assertStringContainsString('customerId', $tester->getDisplay()); + } + + public function testInvalidNumericOptionFails(): void + { + // 数値型に非数値を渡すと黙って 0 にせず INVALID で弾く + $tester = $this->execute('eccube:cli:search_products', ['--limit' => 'abc']); + + $this->assertSame(Command::INVALID, $tester->getStatusCode()); + } + + public function testSearchProductsWithUnresolvableStatusReturnsNoData(): void + { + // 明示 statusIds が 1 つも解決しないときは、 全公開状態に広がらず該当なしを返す + $this->createProduct('MCPCLI Status Guard Product'); + + $tester = $this->execute('eccube:cli:search_products', [ + '--keyword' => 'MCPCLI Status Guard Product', + '--statusIds' => ['999'], + ]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('該当なし', $tester->getDisplay()); + } + + public function testSearchCustomersWithUnresolvableStatusReturnsNoData(): void + { + // 解決不能な statusIds で全会員 (PII 込み) に広がらず該当なしを返す + $this->createCustomer(); + + $tester = $this->execute('eccube:cli:search_customers', ['--statusIds' => ['999']]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('該当なし', $tester->getDisplay()); + } + + public function testSearchOrdersWithUnresolvableStatusReturnsNoData(): void + { + // 解決不能な statusIds で全注文 (PII 込み) に広がらず該当なしを返す + $this->createOrder($this->createCustomer()); + + $tester = $this->execute('eccube:cli:search_orders', ['--statusIds' => ['999']]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('該当なし', $tester->getDisplay()); + } + + public function testSearchProductsStockFilterExecutes(): void + { + // stock 絞り込みの EXISTS 部分クエリが DQL として妥当に実行される (エラーにならない) + $tester = $this->execute('eccube:cli:search_products', ['--stockMin' => '1', '--stockMax' => '1000']); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + } + + public function testToolExecutionFailsWhenMcpDisabled(): void + { + // 機能 OFF では McpCliToolInvoker::call() が例外を投げ、 ツールを実行させない + $this->setMcpEnabled(false); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('MCP 機能は無効です'); + + $this->execute('eccube:cli:search_products', ['--limit' => '1']); + } + + /** + * @param array> $input + */ + private function execute(string $name, array $input): CommandTester + { + $tester = new CommandTester($this->application->find($name)); + $tester->execute($input); + + return $tester; + } +} diff --git a/tests/Eccube/Tests/DependencyInjection/Compiler/McpScopeEnforcementPassTest.php b/tests/Eccube/Tests/DependencyInjection/Compiler/McpScopeEnforcementPassTest.php new file mode 100644 index 00000000000..6ed76062d49 --- /dev/null +++ b/tests/Eccube/Tests/DependencyInjection/Compiler/McpScopeEnforcementPassTest.php @@ -0,0 +1,75 @@ +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')); + // builder 不在なら配線されない inner ReferenceHandler 定義も残さない + // (残すと builder 不在構成のコンテナコンパイルを乱す) + $this->assertFalse( + $container->hasDefinition(McpScopeEnforcementPass::INNER_REFERENCE_HANDLER_ID), + 'builder 不在では inner ReferenceHandler を定義しない', + ); + } +} 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..519cca505ed --- /dev/null +++ b/tests/Eccube/Tests/EventListener/Mcp/OriginContentTypeListenerTest.php @@ -0,0 +1,188 @@ +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 + { + // dev/test (skip=true) は許可リスト未設定なら Origin 検証 skip + $event = $this->dispatch( + $this->makeListener(allowedOriginsCsv: ''), + $this->makeRequest('POST', '/admin/mcp', contentType: 'application/json', origin: 'http://anything'), + ); + + $this->assertNotInstanceOf(Response::class, $event->getResponse(), 'dev/test は許可リスト未設定で Origin 検証 skip'); + } + + public function testRejectsUnvalidatedBrowserOriginInProd(): void + { + // prod (skip=false) は許可リスト未設定でも、 検証できない Origin (=ブラウザ発) を 403 で拒否 + $event = $this->dispatch( + $this->makeListener(allowedOriginsCsv: '', skipUnvalidatedOrigin: false), + $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 testPassesOriginlessRequestInProdWhenAllowListEmpty(): void + { + // prod でも Origin 無し (curl/サーバ間) は検証不要で通す + $event = $this->dispatch( + $this->makeListener(allowedOriginsCsv: '', skipUnvalidatedOrigin: false), + $this->makeRequest('POST', '/admin/mcp', contentType: 'application/json'), + ); + + $this->assertNotInstanceOf(Response::class, $event->getResponse(), 'Origin 無しは prod でも通過'); + } + + 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 = '', bool $skipUnvalidatedOrigin = true): OriginContentTypeListener + { + $auditLogger = new McpAuditLogger(new NullLogger(), new RequestStack()); + + return new OriginContentTypeListener('admin', $allowedOriginsCsv, $auditLogger, $skipUnvalidatedOrigin); + } + + 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..6c8ac7c9456 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpAuditLogIsolationContractTest.php @@ -0,0 +1,97 @@ +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 testMainHandlerExcludesMcpChannelPerEnv(): void + { + $root = (string) static::getContainer()->getParameter('kernel.project_dir'); + + // site.log の main ハンドラを持つ環境はすべて mcp チャネルを除外する必要がある。 + // e2e は Playwright が MCP を実行する環境なので必須。 + foreach (['prod', 'dev', 'e2e'] 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/McpFeatureToggleContractTest.php b/tests/Eccube/Tests/Service/Mcp/Contract/McpFeatureToggleContractTest.php new file mode 100644 index 00000000000..91188422d46 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpFeatureToggleContractTest.php @@ -0,0 +1,93 @@ +request(); + + $this->assertSame( + Response::HTTP_NOT_FOUND, + $this->client->getResponse()->getStatusCode(), + 'MCP 機能 OFF では ^/admin/mcp は 404 (存在秘匿)', + ); + $this->assertSame( + '', + (string) $this->client->getResponse()->getContent(), + 'listener 由来の 404 は空ボディ (routing 由来 404 の HTML ではない)', + ); + } + + public function testReachesFirewallWhenEnabled(): void + { + $this->setMcpEnabled(true); + + $this->request(); + + $this->assertSame( + Response::HTTP_UNAUTHORIZED, + $this->client->getResponse()->getStatusCode(), + 'MCP 機能 ON では 404 でなく oauth2 firewall に到達し 401 (Bearer なし)', + ); + $this->assertTrue( + $this->client->getResponse()->headers->has('WWW-Authenticate'), + 'ON では mcp firewall の entry_point に到達し WWW-Authenticate が付く', + ); + } + + public function testDisabledMasks415ForInvalidContentType(): void + { + // OFF の間は Origin/CT ガード (415) より前に 404 で塞ぎ、 不正 CT でもエンドポイントの存在を漏らさない + $this->request('text/plain'); + + $this->assertSame( + Response::HTTP_NOT_FOUND, + $this->client->getResponse()->getStatusCode(), + 'OFF では不正 Content-Type でも 415 でなく 404 (listener が Origin/CT ガードより前)', + ); + } + + private function request(string $contentType = 'application/json'): void + { + $route = static::getContainer()->getParameter('eccube_admin_route'); + \assert(\is_string($route)); + + $this->client->request( + Request::METHOD_POST, + '/'.$route.'/mcp', + server: ['CONTENT_TYPE' => $contentType], + content: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"t","version":"1"},"capabilities":{}}}', + ); + } +} 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..3db36734703 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpFirewallContractTest.php @@ -0,0 +1,110 @@ +setMcpEnabled(true); + } + + public function testReturns401WithoutBearerHeader(): void + { + $this->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'); + + // RFC 9728: 401 の WWW-Authenticate に resource_metadata (well-known の場所) を載せ、 + // MCP クライアントが認可サーバを自動発見できることを担保する (OAuth ディスカバリの中核)。 + // entry_point は Api44 の McpAuthenticationEntryPoint。 + $header = (string) $response->headers->get('WWW-Authenticate'); + $this->assertStringContainsString('resource_metadata=', $header); + $this->assertStringContainsString('/.well-known/oauth-protected-resource', $header); + } + + 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..559b168b50e --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpScopeEnforcementIntegrationTest.php @@ -0,0 +1,315 @@ +clientManager = static::getContainer()->get(ClientManagerInterface::class); + $this->accessTokenManager = static::getContainer()->get(AccessTokenManagerInterface::class); + $this->setMcpEnabled(true); + } + + 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); + } + + public function testToolCallDeniedForCustomerScope(): void + { + // product scope のみの token で customer 領域の tool を呼ぶ → 拒否 + $jwt = $this->issueScopedJwt(['mcp:product:read']); + + $result = $this->callTool($jwt, 'search_customers', ['limit' => 1]); + + $this->assertTrue($result['result']['isError'] ?? false, 'scope 不足の tool は isError:true'); + $this->assertStringContainsString('Insufficient scope: mcp:customer:read', (string) ($result['result']['content'][0]['text'] ?? '')); + } + + public function testToolCallDeniedForPluginScope(): void + { + // product scope のみの token で plugin 領域の tool を呼ぶ → 拒否 + $jwt = $this->issueScopedJwt(['mcp:product:read']); + + $result = $this->callTool($jwt, 'list_plugins', []); + + $this->assertTrue($result['result']['isError'] ?? false, 'scope 不足の tool は isError:true'); + $this->assertStringContainsString('Insufficient scope: mcp:plugin:read', (string) ($result['result']['content'][0]['text'] ?? '')); + } + + public function testFirewallDeniesTokenWithoutAnyMcpScope(): void + { + // mcp scope を 1 つも持たない token は、 ツール到達前に /admin/mcp の access_control 段で 403。 + // (Member 認証で ROLE_ADMIN は付くが、 mcp read scope が無いと access_map の mcp ルールで弾く) + $jwt = $this->issueScopedJwt([]); + $path = '/'.$this->getAdminRoute().'/mcp'; + + $this->client->request(Request::METHOD_POST, $path, server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json, text/event-stream', + 'HTTP_AUTHORIZATION' => 'Bearer '.$jwt, + ], content: (string) json_encode([ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-03-26', 'clientInfo' => ['name' => 'it', 'version' => '1'], 'capabilities' => []], + ])); + + $this->assertSame( + Response::HTTP_FORBIDDEN, + $this->client->getResponse()->getStatusCode(), + (string) $this->client->getResponse()->getContent(), + ); + } + + public function testToolsListFilteredByGrantedScope(): void + { + // product scope のみの token では、 tools/list に product 系 Tool だけが現れ他領域は隠れる。 + // (呼び出し拒否とは別に、 一覧の可視性そのものを scope で絞る = 最小権限) + $jwt = $this->issueScopedJwt(['mcp:product:read']); + + $names = $this->listToolNames($jwt); + + $this->assertContains('search_products', $names); + $this->assertContains('get_product', $names); + $this->assertContains('get_product_stock', $names); + + $this->assertNotContains('search_orders', $names); + $this->assertNotContains('search_customers', $names); + $this->assertNotContains('list_plugins', $names); + } + + /** + * initialize → notifications/initialized → tools/list を実カーネルに流し、 返った Tool 名の一覧を返す。 + * + * @return list + */ + private function listToolNames(string $jwt): 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/list', + ])); + + $result = $this->decodeJsonRpc((string) $this->client->getResponse()->getContent()); + $this->assertArrayHasKey('result', $result); + $inner = $result['result']; + $this->assertIsArray($inner); + $this->assertArrayHasKey('tools', $inner); + $tools = $inner['tools']; + $this->assertIsArray($tools); + + $names = []; + foreach ($tools as $tool) { + $this->assertIsArray($tool); + $this->assertArrayHasKey('name', $tool); + $names[] = (string) $tool['name']; + } + + return $names; + } + + /** + * 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..2a94aa023bd --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Contract/McpTokenRevocationContractTest.php @@ -0,0 +1,215 @@ +clientManager = static::getContainer()->get(ClientManagerInterface::class); + $this->accessTokenManager = static::getContainer()->get(AccessTokenManagerInterface::class); + $this->setMcpEnabled(true); + } + + 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 (誤ルーティング等) を排除する。 + // discovery の entry_point (McpAuthenticationEntryPoint) が 401 に resource_metadata を付ける経路があるため、 先頭一致で見る。 + $this->assertStringStartsWith('Bearer', (string) $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->assertStringStartsWith('Bearer', (string) $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); + + // /admin/mcp の access_control は最低 1 つの mcp read scope を要求するため、 + // 認証経路 (正常/失効/無効化) を検証する有効フロー用に付与する。 + $scopeEntity = new ScopeEntity(); + $scopeEntity->setIdentifier('mcp:product:read'); + $tokenEntity->addScope($scopeEntity); + + $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/EnablesMcpTrait.php b/tests/Eccube/Tests/Service/Mcp/EnablesMcpTrait.php new file mode 100644 index 00000000000..8240426e987 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/EnablesMcpTrait.php @@ -0,0 +1,34 @@ +entityManager` に依存)。 + */ +trait EnablesMcpTrait +{ + private function setMcpEnabled(bool $enabled): void + { + static::getContainer()->get(BaseInfoRepository::class)->get()->setMcpEnabled($enabled); + $this->entityManager->flush(); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/EntityArraySerializerTest.php b/tests/Eccube/Tests/Service/Mcp/EntityArraySerializerTest.php new file mode 100644 index 00000000000..d308038311a --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/EntityArraySerializerTest.php @@ -0,0 +1,492 @@ +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, + ); + } + + public function testSummaryReturnsOnlyListedScalars(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'name', 'active'], + ]); + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->name = 'foo'; + $entity->active = true; + + // active は allow_list にあるがサマリ定義に無い → 出力されない + $this->assertSame( + ['id' => 1, 'name' => 'foo'], + $serializer->toSummary($entity, ['id', 'name']), + ); + } + + public function testSummarySkipsFieldsNotInAllowList(): void + { + // fail-closed: allow_list に無い項目はサマリ定義に入れても出力されない + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id'], // name は allow_list 外 + ]); + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->name = 'secret'; + + $this->assertSame(['id' => 1], $serializer->toSummary($entity, ['id', 'name'])); + } + + public function testSummaryDoesNotExpandCollections(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'items'], + SerializerDummyRelated::class => ['code'], + ]); + $related = new SerializerDummyRelated(); + $related->code = 'A'; + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->items = new ArrayCollection([$related]); + + // items は allow_list 許可だが Collection なので展開されず null になる + $this->assertSame(['id' => 1, 'items' => null], $serializer->toSummary($entity, ['id', 'items'])); + } + + public function testSummaryResolvesDottedRelationField(): 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-1'; + + $this->assertSame( + ['id' => 1, 'related' => ['code' => 'X-1']], + $serializer->toSummary($entity, ['id', 'related.code']), + ); + } + + public function testSummaryDottedSkippedWhenSubFieldNotAllowed(): void + { + // 親で related は許可だが、 子 Entity で code が許可されていない → ドット path をスキップ (security) + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'related'], + SerializerDummyRelated::class => [], + ]); + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->related = new SerializerDummyRelated(); + $entity->related->code = 'X-1'; + + $this->assertSame(['id' => 1], $serializer->toSummary($entity, ['id', 'related.code'])); + } + + public function testSummaryDottedKeepsNullKeyWhenRelationAbsent(): void + { + // relation は許可されているが値が無い (データ状態) → security スキップと区別し、 キーは null で残す + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'related'], + SerializerDummyRelated::class => ['code'], + ]); + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->related = null; + + $this->assertSame(['id' => 1, 'related' => null], $serializer->toSummary($entity, ['id', 'related.code'])); + } + + public function testSummaryFormatsDateTimeAsAtom(): 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->toSummary($entity, ['createDate']), + ); + } + + public function testSummarizeRelationsCollapsesRootCollectionToIds(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'items'], + SerializerDummyRelated::class => ['id', '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]); + + // 通常は items を full 展開するが、 summarizeRelations 指定で各要素 id のみに縮退する + $this->assertSame( + ['id' => 1, 'items' => [['id' => crc32('A')], ['id' => crc32('B')]]], + $serializer->toArray($entity, summarizeRelations: ['items']), + ); + } + + public function testSummarizeRelationsCollapsesRootSingleRelation(): void + { + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'related'], + SerializerDummyRelated::class => ['id', 'code'], + ]); + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->related = new SerializerDummyRelated(); + $entity->related->code = 'X'; + + // full なら related.code まで出るが、 縮退指定で {id} のみになる + $this->assertSame( + ['id' => 1, 'related' => ['id' => crc32('X')]], + $serializer->toArray($entity, summarizeRelations: ['related']), + ); + } + + public function testSummarizeRelationsOnlyAffectsListedRelations(): void + { + // 指定していない関連は従来どおり full 展開される + $serializer = $this->serializerWith([ + SerializerDummyEntity::class => ['id', 'related'], + SerializerDummyRelated::class => ['id', 'code'], + ]); + $entity = new SerializerDummyEntity(); + $entity->id = 1; + $entity->related = new SerializerDummyRelated(); + $entity->related->code = 'X'; + + $this->assertSame( + ['id' => 1, 'related' => ['id' => crc32('X'), 'code' => 'X']], + $serializer->toArray($entity, summarizeRelations: ['items']), + ); + } + + /** + * @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/McpMarkdownFormatterTest.php b/tests/Eccube/Tests/Service/Mcp/McpMarkdownFormatterTest.php new file mode 100644 index 00000000000..3f05b2f8ff4 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/McpMarkdownFormatterTest.php @@ -0,0 +1,118 @@ +format([ + 'total' => 2, + 'items' => [['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b']], + ]); + + $this->assertStringContainsString('**total**: 2', $md); + $this->assertStringContainsString('| id | name |', $md); + $this->assertStringContainsString('| 1 | a |', $md); + } + + public function testTableColumnsAreUnionOfAllRowKeys(): void + { + // 回帰: 列は先頭行でなく全行キーの和集合。 後続行のみが持つキーも列落ちしない。 + $md = $this->format(['items' => [['id' => 1], ['id' => 2, 'extra' => 'x']]]); + + $this->assertStringContainsString('| id | extra |', $md); + $this->assertStringContainsString('| 2 | x |', $md); + } + + public function testEmptyItemsRendersNoData(): void + { + $this->assertStringContainsString('該当なし', $this->format(['items' => []])); + } + + public function testDetailRendersDefinitionList(): void + { + $md = $this->format(['id' => 1, 'name' => 'a']); + + $this->assertStringContainsString('- **id**: 1', $md); + $this->assertStringContainsString('- **name**: a', $md); + } + + public function testMinMaxRenderedAsRange(): void + { + $md = $this->format(['items' => [['stock' => ['min' => 0, 'max' => 5]]]]); + + $this->assertStringContainsString('0 – 5', $md); + } + + public function testMinMaxBothNullWithUnlimited(): void + { + $md = $this->format(['items' => [['stock' => ['min' => null, 'max' => null, 'unlimited' => true]]]]); + + $this->assertStringContainsString('無制限', $md); + } + + public function testNonScalarMinMaxFallsBackToJson(): void + { + // round2 ガード: min/max が非スカラーならレンジ整形せず JSON 経路へ落とす。 + $md = $this->format(['items' => [['weird' => ['min' => [1, 2], 'max' => 5]]]]); + + $this->assertStringNotContainsString(' – ', $md); + $this->assertStringContainsString('"min"', $md); + } + + public function testPipeAndNewlineAreEscaped(): void + { + $md = $this->format(['items' => [['note' => "a|b\nc"]]]); + + $this->assertStringContainsString('a\\|b c', $md); + } + + public function testScalarRowAmongObjectsKeepsValue(): void + { + // 先頭がオブジェクトの list に紛れたスカラー要素も、 値を落とさず先頭列に出す。 + $md = $this->format(['items' => [['id' => 1, 'name' => 'a'], 'orphan']]); + + $this->assertStringContainsString('| id | name |', $md); + $this->assertStringContainsString('orphan', $md); + } + + public function testUnlimitedFlagAsStringFalseIsNotUnlimited(): void + { + // "false" 文字列を無制限と誤読しない。 + $md = $this->format(['items' => [['stock' => ['min' => 1, 'max' => 3, 'unlimited' => 'false']]]]); + + $this->assertStringContainsString('1 – 3', $md); + $this->assertStringNotContainsString('無制限', $md); + } + + /** + * @param array $result + */ + private function format(array $result): string + { + return (new McpMarkdownFormatter())->format($result); + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/McpSummaryFieldsTest.php b/tests/Eccube/Tests/Service/Mcp/McpSummaryFieldsTest.php new file mode 100644 index 00000000000..02113411912 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/McpSummaryFieldsTest.php @@ -0,0 +1,54 @@ +}> + */ + public static function summaryDefinitions(): iterable + { + yield 'order' => [McpSummaryFields::ORDER]; + yield 'customer' => [McpSummaryFields::CUSTOMER]; + yield 'product' => [McpSummaryFields::PRODUCT]; + } + + /** + * @param list $fields + */ + #[DataProvider(methodName: 'summaryDefinitions')] + public function testDefinitionsAreWellFormed(array $fields): void + { + $this->assertNotEmpty($fields); + foreach ($fields as $field) { + $this->assertNotSame('', $field); + // ドット path は 1 段まで (Rel.prop)。 多段ネストはサマリでは扱わない + $this->assertLessThanOrEqual(1, substr_count($field, '.'), sprintf('"%s" は多段ドット path', $field)); + } + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/ProductPriceStockSummarizerTest.php b/tests/Eccube/Tests/Service/Mcp/ProductPriceStockSummarizerTest.php new file mode 100644 index 00000000000..6e1f0acaa76 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/ProductPriceStockSummarizerTest.php @@ -0,0 +1,156 @@ +summarizerWith([ + ProductClass::class => ['price02', 'stock', 'stock_unlimited'], + ]); + $product = $this->productWith([ + ['price02' => '1000', 'stock' => '5'], + ['price02' => '1500', 'stock' => '30'], + ]); + + $this->assertSame( + [ + 'price' => ['min' => '1000', 'max' => '1500'], + 'stock' => ['min' => '5', 'max' => '30', 'unlimited' => false], + ], + $summarizer->summarize($product), + ); + } + + public function testUnlimitedClassExcludedFromStockRange(): void + { + // HIGH 回帰: 有限在庫(5) と無制限が混在しても stock.min が null に化けず、 + // 有限クラスだけで min/max を出し、 unlimited フラグで無制限を示す。 + $summarizer = $this->summarizerWith([ + ProductClass::class => ['price02', 'stock', 'stock_unlimited'], + ]); + $product = $this->productWith([ + ['price02' => '1000', 'stock' => '5'], + ['price02' => '1200', 'stock' => null, 'unlimited' => true], + ]); + + $this->assertSame( + [ + 'price' => ['min' => '1000', 'max' => '1200'], + 'stock' => ['min' => '5', 'max' => '5', 'unlimited' => true], + ], + $summarizer->summarize($product), + ); + } + + public function testAllUnlimitedYieldsNullStockRange(): void + { + $summarizer = $this->summarizerWith([ + ProductClass::class => ['price02', 'stock', 'stock_unlimited'], + ]); + $product = $this->productWith([ + ['price02' => '800', 'stock' => null, 'unlimited' => true], + ]); + + $this->assertSame( + ['min' => null, 'max' => null, 'unlimited' => true], + $summarizer->summarize($product)['stock'], + ); + } + + public function testInvisibleClassExcluded(): void + { + $summarizer = $this->summarizerWith([ + ProductClass::class => ['price02', 'stock', 'stock_unlimited'], + ]); + $product = $this->productWith([ + ['price02' => '1000', 'stock' => '5', 'visible' => true], + ['price02' => '9999', 'stock' => '999', 'visible' => false], + ]); + + $this->assertSame( + [ + 'price' => ['min' => '1000', 'max' => '1000'], + 'stock' => ['min' => '5', 'max' => '5', 'unlimited' => false], + ], + $summarizer->summarize($product), + ); + } + + public function testPriceNullWhenPrice02NotAllowed(): void + { + // price02 が allow_list に無ければ price は出さない (fail-closed)。 stock は許可されていれば出す。 + $summarizer = $this->summarizerWith([ + ProductClass::class => ['stock'], + ]); + $product = $this->productWith([['price02' => '1000', 'stock' => '5']]); + + $result = $summarizer->summarize($product); + + $this->assertNull($result['price']); + $this->assertSame(['min' => '5', 'max' => '5', 'unlimited' => false], $result['stock']); + } + + public function testUnlimitedFalseWhenStockUnlimitedNotAllowed(): void + { + // stock は許可だが stock_unlimited が未許可なら unlimited は常に false 扱い + $summarizer = $this->summarizerWith([ + ProductClass::class => ['stock'], + ]); + $product = $this->productWith([['stock' => null, 'unlimited' => true]]); + + $this->assertFalse($summarizer->summarize($product)['stock']['unlimited']); + } + + /** + * @param array> $allowMap + */ + private function summarizerWith(array $allowMap): ProductPriceStockSummarizer + { + return new ProductPriceStockSummarizer(new AllowListResolver([new FakeAllowList($allowMap)])); + } + + /** + * @param list $classes + */ + private function productWith(array $classes): Product + { + $product = new Product(); + foreach ($classes as $spec) { + $productClass = new ProductClass(); + $productClass->setVisible($spec['visible'] ?? true); + $productClass->setPrice02($spec['price02'] ?? null); + $productClass->setStock($spec['stock'] ?? null); + $productClass->setStockUnlimited($spec['unlimited'] ?? false); + $product->addProductClass($productClass); + } + + return $product; + } +} 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/ScopeFilteringRegistryTest.php b/tests/Eccube/Tests/Service/Mcp/ScopeFilteringRegistryTest.php new file mode 100644 index 00000000000..c4f49a6f402 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/ScopeFilteringRegistryTest.php @@ -0,0 +1,167 @@ + 'object'], null, null); + } + + /** + * @param array $tools + */ + private function registryReturning(array $tools): RegistryInterface&MockObject + { + $registry = $this->createMock(RegistryInterface::class); + $registry->method('getTools')->willReturn(new Page($tools, null)); + + return $registry; + } + + public function testHidesToolsTheTokenCannotCall(): void + { + $inner = $this->registryReturning([ + 'search_products' => $this->tool('search_products'), + 'search_orders' => $this->tool('search_orders'), + 'list_plugins' => $this->tool('list_plugins'), + ]); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->method('isGranted') + ->willReturnCallback(static fn ($role): bool => McpScope::ROLE_PRODUCT_READ === $role); + + $registry = new ScopeFilteringRegistry($inner, $authChecker, $this->tokenStorageWith(true)); + + $names = $this->toolNames($registry->getTools()); + + $this->assertSame(['search_products'], $names); + } + + public function testFiltersBeforePagingSoVisibleToolsSurvivePageBoundary(): void + { + // 不可視 Tool が先頭 pageSize を占めても、 可視 Tool が空ページに落ちない (ページング前フィルタ)。 + // order 3 件 (不可視) → product 3 件 (可視) の順で返し、 product scope で 2 件ずつ全ページ走査する。 + $inner = $this->registryReturning([ + 'search_orders' => $this->tool('search_orders'), + 'get_order' => $this->tool('get_order'), + 'get_shipping' => $this->tool('get_shipping'), + 'search_products' => $this->tool('search_products'), + 'get_product' => $this->tool('get_product'), + 'get_product_stock' => $this->tool('get_product_stock'), + ]); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->method('isGranted') + ->willReturnCallback(static fn ($role): bool => McpScope::ROLE_PRODUCT_READ === $role); + + $registry = new ScopeFilteringRegistry($inner, $authChecker, $this->tokenStorageWith(true)); + + // 1 ページ目は空にならず可視 Tool が入る (旧実装なら order だけ切り出し → 空 references + 非 null カーソル) + $this->assertNotEmpty($registry->getTools(2)->references); + + $collected = []; + $cursor = null; + do { + $page = $registry->getTools(2, $cursor); + $collected = array_merge($collected, $this->toolNames($page)); + $cursor = $page->nextCursor; + } while (null !== $cursor); + + // 可視 product 3 件が漏れなく得られ、 不可視 order は一切出ない + $this->assertSame(['search_products', 'get_product', 'get_product_stock'], $collected); + } + + public function testReturnsAllToolsWhenNoToken(): void + { + $page = new Page(['search_products' => $this->tool('search_products'), 'search_orders' => $this->tool('search_orders')], null); + $inner = $this->createMock(RegistryInterface::class); + $inner->method('getTools')->willReturn($page); + + // トークンが無い経路 (CLI 等) では認可判定を一切呼ばず素通しする + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->expects($this->never())->method('isGranted'); + + $registry = new ScopeFilteringRegistry($inner, $authChecker, $this->tokenStorageWith(false)); + + $this->assertSame($page, $registry->getTools()); + } + + public function testHidesToolWithNoScopeMapping(): void + { + // 中央マップ未登録の Tool は call 時 fail-closed deny なので、 一覧からも隠す + $inner = $this->registryReturning([ + 'search_products' => $this->tool('search_products'), + 'unknown_tool' => $this->tool('unknown_tool'), + ]); + + $authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authChecker->method('isGranted')->willReturn(true); + + $registry = new ScopeFilteringRegistry($inner, $authChecker, $this->tokenStorageWith(true)); + + $this->assertSame(['search_products'], $this->toolNames($registry->getTools())); + } + + public function testDelegatesNonToolMethods(): void + { + $inner = $this->createMock(RegistryInterface::class); + $inner->expects($this->once())->method('hasTools')->willReturn(true); + + $registry = new ScopeFilteringRegistry( + $inner, + $this->createStub(AuthorizationCheckerInterface::class), + $this->createStub(TokenStorageInterface::class), + ); + + $this->assertTrue($registry->hasTools()); + } + + private function tokenStorageWith(bool $hasToken): TokenStorageInterface + { + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturn($hasToken ? $this->createMock(TokenInterface::class) : null); + + return $tokenStorage; + } + + /** + * @return list + */ + private function toolNames(Page $page): array + { + return array_map(static fn (Tool $tool): string => $tool->name, array_values($page->references)); + } +} 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..78f187493f0 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetCustomerOrdersToolTest.php @@ -0,0 +1,86 @@ +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']); + } + + public function testItemsAreOrderSummaryShape(): void + { + // 顧客 scope から明細・配送先 PII を露出させないため、 各 Order はサマリ形のみ + // (OrderItems / Shippings を含まない)。 search_orders と同じ ORDER サマリキー。 + $customer = $this->createCustomer('mcp-customer-orders-shape@example.com'); + $this->createOrder($customer); + + $result = $this->tool->get(customerId: $customer->getId(), limit: 50); + $this->assertNotEmpty($result['items']); + + $summaryKeys = ['id', 'order_no', 'order_date', 'payment_total', 'name01', 'name02', 'email', 'OrderStatus']; + foreach ($result['items'] as $item) { + foreach (array_keys($item) as $key) { + $this->assertContains($key, $summaryKeys, sprintf('サマリ外のフィールド "%s" が出力された', $key)); + } + $this->assertArrayNotHasKey('OrderItems', $item); + $this->assertArrayNotHasKey('Shippings', $item); + } + } +} 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..a4f0ab199a3 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/GetCustomerToolTest.php @@ -0,0 +1,77 @@ +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); + } + + public function testCollapsesOrdersToIdSummary(): void + { + $customer = $this->createCustomer('mcp-getcustomer-orders@example.com'); + $generator = static::getContainer()->get(Generator::class); + $this->assertInstanceOf(Generator::class, $generator); + $order = $generator->createOrder($customer); + $customerId = $customer->getId(); + $orderId = $order->getId(); + + // createOrder は Customer.Orders コレクションを in-memory で更新しないため、 + // 実リクエストと同条件で DB から読み直させる + $this->entityManager->clear(); + + $result = $this->tool->get(id: $customerId); + + $this->assertArrayHasKey('Orders', $result); + $this->assertNotEmpty($result['Orders']); + foreach ($result['Orders'] as $orderSummary) { + // 縮退: 各注文は id のみ (order_no や明細等の full フィールドは出ない) + $this->assertSame(['id'], array_keys($orderSummary)); + } + $this->assertContains($orderId, array_column($result['Orders'], 'id')); + } +} 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..46310c29313 --- /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 testFiltersByRegularStatus(): void + { + $this->createCustomer('mcp-customer-regular@example.com'); + + $result = $this->tool->search(statusIds: [CustomerStatus::REGULAR], limit: 100); + + $this->assertGreaterThanOrEqual(1, $result['total'], '正会員 (REGULAR) が 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..f958ee1e8c0 --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/Tool/SearchProductsToolTest.php @@ -0,0 +1,216 @@ +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 testItemsReturnSummaryShape(): void + { + $this->createProduct('mcp-allow-001', 1); + + $result = $this->tool->search(limit: 5); + + $this->assertNotEmpty($result['items']); + + // サマリ射影のキーのみ: allow_list 由来 (id / name / Status) + 集約値 (price / stock)。 + // description_detail 等の重量フィールドが出ないことをホワイトリストで担保する。 + $summaryKeys = ['id', 'name', 'Status', 'price', 'stock']; + + foreach ($result['items'] as $item) { + foreach (array_keys($item) as $key) { + $this->assertContains($key, $summaryKeys, sprintf('サマリ外のフィールド "%s" が出力された', $key)); + } + $this->assertArrayHasKey('id', $item); + $this->assertArrayHasKey('price', $item); + $this->assertArrayHasKey('min', $item['price']); + $this->assertArrayHasKey('max', $item['price']); + $this->assertArrayHasKey('stock', $item); + $this->assertArrayHasKey('min', $item['stock']); + $this->assertArrayHasKey('max', $item['stock']); + $this->assertArrayHasKey('unlimited', $item['stock']); + } + } + + /** + * ② 回帰ガード: 在庫で絞り込んでも、 返る商品の在庫レンジは全表示規格ぶんのまま縮まない。 + * 商品単位の EXISTS で絞り、 fetch-join した規格を部分ハイドレートしないことを担保する。 + */ + public function testStockFilterDoesNotShrinkStockRange(): void + { + $name = 'mcp-stock-range-'.uniqid(); + $product = $this->makeProductWithStocks($name, [100, 900], hiddenStock: 0); + + $unfiltered = $this->findById($this->tool->search(keyword: $name, limit: 50), $product->getId()); + $this->assertNotNull($unfiltered, '絞り込み無しで作成商品が出る'); + + // 900 の規格だけが満たす条件でも商品はヒットし、 在庫レンジは 100〜900 のまま。 + $filtered = $this->findById($this->tool->search(keyword: $name, stockMin: 800, limit: 50), $product->getId()); + $this->assertNotNull($filtered, 'stockMin=800 でも作成商品はヒットする (900 の規格が満たす)'); + + $this->assertSame((int) $unfiltered['stock']['min'], (int) $filtered['stock']['min'], '絞り込み有無で stock.min が一致 (レンジが縮まない)'); + $this->assertSame((int) $unfiltered['stock']['max'], (int) $filtered['stock']['max'], '絞り込み有無で stock.max が一致'); + $this->assertSame(100, (int) $filtered['stock']['min']); + $this->assertSame(900, (int) $filtered['stock']['max']); + } + + /** + * A: 単一 EXISTS のセマンティクス。 [stockMin, stockMax] に入る規格が 1 つも無い商品はヒットしない + * (min と max を別々の規格が満たす「レンジ交差」ではヒットさせない)。 + */ + public function testStockRangeRequiresSingleClassWithinBounds(): void + { + $name = 'mcp-stock-cross-'.uniqid(); + // どの規格も [400,800] に入らない (300 と 900)。 交差解釈なら 900>=400 かつ 300<=800 でヒットしてしまう。 + $product = $this->makeProductWithStocks($name, [300, 900], hiddenStock: 0); + + $result = $this->tool->search(keyword: $name, stockMin: 400, stockMax: 800, limit: 50); + + $this->assertNull( + $this->findById($result, $product->getId()), + '[400,800] に入る規格が無い商品はヒットしない', + ); + } + + /** + * C: 在庫絞り込みは表示規格のみを母集団にする。 非表示規格だけが条件を満たす商品はヒットしない + * (出力レンジ = ProductPriceStockSummarizer も非表示規格を除外するため母集団を揃える)。 + */ + public function testStockFilterIgnoresInvisibleClasses(): void + { + $name = 'mcp-stock-hidden-'.uniqid(); + // 表示規格は 100、 非表示規格だけが 900。 stockMin=800 は非表示規格しか満たさない。 + $product = $this->makeProductWithStocks($name, [100], hiddenStock: 900); + + $result = $this->tool->search(keyword: $name, stockMin: 800, limit: 50); + + $this->assertNull( + $this->findById($result, $product->getId()), + '非表示規格だけが在庫条件を満たす商品はヒットしない', + ); + } + + /** + * @param array{items: list>} $result + * + * @return array|null + */ + private function findById(array $result, ?int $productId): ?array + { + foreach ($result['items'] as $item) { + if ((int) ($item['id'] ?? 0) === $productId) { + return $item; + } + } + + return null; + } + + /** + * 表示規格の在庫を $visibleStocks で、 (指定時) 非表示のデフォルト規格の在庫を $hiddenStock で固定した + * 商品を作る。 Generator は全表示規格を在庫ランダムで作るため、 テスト内で上書きする。 + * + * @param list $visibleStocks 表示規格に順に割り当てる在庫 + */ + private function makeProductWithStocks(string $name, array $visibleStocks, ?int $hiddenStock = null): Product + { + $product = $this->createProduct($name, \count($visibleStocks)); + + $visible = []; + $hidden = []; + foreach ($product->getProductClasses() ?? [] as $pc) { + if ($pc->isVisible()) { + $visible[] = $pc; + } else { + $hidden[] = $pc; + } + } + $this->assertCount(\count($visibleStocks), $visible, 'createProduct が期待どおりの表示規格数を作る'); + + foreach ($visible as $i => $pc) { + $pc->setStockUnlimited(false); + $pc->setStock((string) $visibleStocks[$i]); + } + if (null !== $hiddenStock) { + $this->assertNotEmpty($hidden, '非表示のデフォルト規格が存在する'); + $hidden[0]->setStockUnlimited(false); + $hidden[0]->setStock((string) $hiddenStock); + } + + $this->entityManager->flush(); + + return $product; + } +} diff --git a/tests/Eccube/Tests/Service/Mcp/ToolInputSchemaTest.php b/tests/Eccube/Tests/Service/Mcp/ToolInputSchemaTest.php new file mode 100644 index 00000000000..866adba96af --- /dev/null +++ b/tests/Eccube/Tests/Service/Mcp/ToolInputSchemaTest.php @@ -0,0 +1,131 @@ +schema([ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer'], 'name' => ['type' => 'string']], + 'required' => ['id'], + ]); + + $this->assertSame(['id', 'name'], $schema->propertyNames()); + $this->assertSame(['id'], $schema->requiredNames()); + } + + public function testBaseTypeStripsNullable(): void + { + $schema = $this->schema([ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => ['null', 'integer']], + 'name' => ['type' => 'string'], + ], + ]); + + $this->assertSame('integer', $schema->baseType('id')); + $this->assertSame('string', $schema->baseType('name')); + } + + public function testBaseTypeDefaultsToStringWhenTypeMissing(): void + { + $schema = $this->schema(['type' => 'object', 'properties' => ['x' => []]]); + + $this->assertSame('string', $schema->baseType('x')); + } + + public function testArrayPropertyAndElementType(): void + { + $schema = $this->schema([ + 'type' => 'object', + 'properties' => ['tags' => ['type' => 'array', 'items' => ['type' => 'integer']]], + ]); + + $this->assertTrue($schema->isArray('tags')); + $this->assertSame('integer', $schema->elementType('tags')); + } + + public function testElementTypeDefaultsToStringWhenItemsMissing(): void + { + $schema = $this->schema(['type' => 'object', 'properties' => ['tags' => ['type' => 'array']]]); + + $this->assertSame('string', $schema->elementType('tags')); + } + + public function testDescriptionFallsBackToEmptyString(): void + { + $schema = $this->schema([ + 'type' => 'object', + 'properties' => ['a' => ['description' => 'hello'], 'b' => ['type' => 'string']], + ]); + + $this->assertSame('hello', $schema->description('a')); + $this->assertSame('', $schema->description('b')); + } + + public function testNonArrayPropertyIsTreatedAsEmptySchema(): void + { + // SDK は各プロパティを配列で渡すが、 非配列でも掘り先で TypeError にせず既定に落ちる。 + $schema = $this->schema(['type' => 'object', 'properties' => ['broken' => 'not-an-array']]); + + $this->assertSame('string', $schema->baseType('broken')); + $this->assertSame('', $schema->description('broken')); + $this->assertFalse($schema->isArray('broken')); + } + + public function testEmptyPropertiesNormalizedBySdkYieldNoOptions(): void + { + // 引数なしツールは SDK が properties を \stdClass 化する (Tool::fromArray の実挙動)。 VO は空扱いにする。 + $tool = Tool::fromArray(['name' => 't', 'inputSchema' => ['type' => 'object', 'properties' => []]]); + + $this->assertSame([], (new ToolInputSchema($tool))->propertyNames()); + } + + public function testUnionOfMultipleTypesFallsBackToString(): void + { + // 単一 nullable は基底型に還元、 複数型 union は一意に決められず string 扱い (誤 reject 回避)。 + $schema = $this->schema([ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => ['null', 'integer']], + 'b' => ['type' => ['integer', 'string']], + ], + ]); + + $this->assertSame('integer', $schema->baseType('a')); + $this->assertSame('string', $schema->baseType('b')); + } + + /** + * @param array $inputSchema + */ + private function schema(array $inputSchema): ToolInputSchema + { + return new ToolInputSchema(new Tool('t', null, $inputSchema, null, null)); + } +} diff --git a/tests/Eccube/Tests/Web/Admin/Setting/Shop/ShopControllerTest.php b/tests/Eccube/Tests/Web/Admin/Setting/Shop/ShopControllerTest.php index f422fc34b69..1b2aa339536 100644 --- a/tests/Eccube/Tests/Web/Admin/Setting/Shop/ShopControllerTest.php +++ b/tests/Eccube/Tests/Web/Admin/Setting/Shop/ShopControllerTest.php @@ -133,6 +133,38 @@ public static function dataSanitizeCsvFormulasProvider(): \Iterator yield [false, false]; } + /** + * MCP サーバ有効化トグルが BaseInfo に保存されること. + * チェックボックスは未チェックをキー欠落で表すため, 無効化はキーを送らないことで再現する. + */ + #[DataProvider(methodName: 'dataMcpEnabledProvider')] + #[Group(name: 'cache-clear')] + public function testSubmitPersistsMcpEnabledOption(bool $checked, bool $expected): void + { + $formData = $this->createFormData(); + if ($checked) { + $formData['mcp_enabled'] = '1'; + } else { + unset($formData['mcp_enabled']); + } + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('admin_setting_shop'), + ['shop_master' => $formData] + ); + + $this->entityManager->clear(); + $BaseInfo = $this->entityManager->getRepository(BaseInfo::class)->find(1); + $this->assertInstanceOf(BaseInfo::class, $BaseInfo); + $this->assertSame($expected, $BaseInfo->isMcpEnabled()); + } + + public static function dataMcpEnabledProvider(): \Iterator + { + yield [true, true]; + yield [false, false]; + } + public static function dataSubmitProvider(): \Iterator { yield [false, false];