Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4fd463e
feat: MCP サーバ用 OAuth2 scope と firewall を追加
dotani1111 Jun 8, 2026
5132fad
fix: OAuth クライアント一覧で identifier にハイフンを含むと 500 になるのを修正
dotani1111 Jun 17, 2026
99889fe
feat: 管理画面から MCP トークンを発行・失効できるようにする
dotani1111 Jun 18, 2026
2a41051
test: MCP トークン発行・失効の契約テストを追加
dotani1111 Jun 18, 2026
735a585
fix: OAuth authorize の同意フローテストを redirectUris 込みの fixture に修正
dotani1111 Jun 18, 2026
ab51b5a
feat: MCP の OAuth 自動ディスカバリ (.well-known / DCR) を追加
dotani1111 Jun 19, 2026
4940127
test: OAuth ディスカバリ/DCR の契約テストを追加
dotani1111 Jun 19, 2026
8d7932c
fix: MCP PAT クライアント生成を plugin enable から初回発行時に移す
dotani1111 Jun 19, 2026
9649345
fix: MCP サーバ前提の統合テストを本体 mcp ジョブへ委譲し firewall 期待値を更新
dotani1111 Jun 19, 2026
58009c5
feat: DCR 動的登録クライアントの死蔵掃除コマンドを追加
dotani1111 Jun 19, 2026
46d00fa
feat: 認可応答に RFC 9207 iss を付与する
dotani1111 Jun 19, 2026
4e9d66a
fix: iss を認可応答の query/fragment 両方に付与する
dotani1111 Jun 29, 2026
367b54b
docs(mcp): レビュー指摘でコメントの用語と説明を精緻化
dotani1111 Jun 30, 2026
d79221f
fix(mcp): /admin/mcp に mcp read scope を要求する access_control を追加する
dotani1111 Jul 21, 2026
20bd5f3
fix(mcp): MCP 未搭載の本体では access_control 追加を skip する
dotani1111 Jul 28, 2026
090b6e1
fix(mcp): access_control の role を直書きし本体非依存で phpstan を通す
dotani1111 Jul 28, 2026
3f8e5fd
fix(mcp): PR #190 レビュー対応 (scope 永続化 / base path / docblock / 有効日数)
dotani1111 Jul 30, 2026
2764f14
fix(mcp): PR #190 CodeRabbit 対応 (認可応答の iss 上書き / トークン画面の no-store)
dotani1111 Jul 31, 2026
40463b5
docs(mcp): 動作確認環境の PHP 要件を 8.2 以上に更新する
dotani1111 Jul 31, 2026
591ccac
fix(mcp): DCR レート制限を IP 単位→グローバルの順で消費する
dotani1111 Aug 3, 2026
3e68530
fix(mcp): 認可応答の iss 付与で redirect_uri のクエリを原文保持する
dotani1111 Aug 3, 2026
a6553b3
fix(mcp): MCP トークン発行を ADMIN 権限に限定する
dotani1111 Aug 3, 2026
150f7ec
feat(mcp): TRUSTED_HOSTS 未設定を警告し discovery の Host 偽装リスクを顕在化する
dotani1111 Aug 4, 2026
c2ffb10
fix(mcp): MCP トークンの有効日数上限を 180 日に短縮する
dotani1111 Aug 4, 2026
27c1ca4
fix(mcp): PKCE(S256) 必須を明示し public クライアントの認可コード横取りを防ぐ
dotani1111 Aug 4, 2026
50d5a71
test(mcp): 有効日数上限 180 日を回帰ガードするテストを追加
dotani1111 Aug 4, 2026
6ef142f
test(mcp): PKCE 必須が挙動として効くことを縛るテストを追加
dotani1111 Aug 4, 2026
76ac5e8
Merge branch '4.4' into feat/mcp-server-scorp
dotani1111 Aug 4, 2026
171d8ef
fix(mcp): コメント・表記をコードに合わせる
dotani1111 Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions Command/CleanupDcrClientsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/*
* This file is part of EC-CUBE
*
* Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
*
* http://www.ec-cube.co.jp/
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Plugin\Api44\Command;

use Plugin\Api44\Service\DcrClientCleaner;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

/**
* DCR (動的クライアント登録) で量産される死蔵クライアントを掃除する。
*
* 登録から --days を過ぎ、 有効な access/refresh トークンを持たない DCR クライアントを削除する。
* 定期実行 (cron) を想定。 --dry-run で対象だけ確認できる。
* 掃除対象は追跡レコードのある client のみ (導入前/記録漏れの client は対象外)。
*/
#[AsCommand(
name: 'eccube:api:mcp:cleanup-dcr-clients',
description: 'Remove abandoned DCR-registered OAuth clients that have no valid tokens.'
)]
class CleanupDcrClientsCommand extends Command
{
private const DEFAULT_GRACE_DAYS = 30;

public function __construct(
private readonly DcrClientCleaner $cleaner,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addOption('days', null, InputOption::VALUE_REQUIRED, 'Grace period in days; clients registered within this window are never removed', (string) self::DEFAULT_GRACE_DAYS)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'List clients that would be removed without deleting them');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

$days = filter_var($input->getOption('days'), FILTER_VALIDATE_INT);
if (false === $days || $days < 0) {
$io->error('--days must be a non-negative integer.');

return Command::INVALID;
}
$dryRun = (bool) $input->getOption('dry-run');

try {
$result = $this->cleaner->cleanup(new \DateTime(), $days, $dryRun);
} catch (\Throwable $e) {
// 破壊的操作の失敗を握り潰さず記録する (トランザクションは rollback 済み = 未変更)
$this->logger->error('DCR cleanup failed', ['exception' => $e, 'days' => $days, 'dry_run' => $dryRun]);
$io->error('DCR cleanup failed; no clients were removed. See logs for details.');

return Command::FAILURE;
}

// 破壊的・自動実行の監査証跡として、 いつ何件消したかを残す
$this->logger->info('DCR cleanup completed', [
'dry_run' => $dryRun,
'examined' => $result->examinedCount,
'kept_active' => $result->keptActiveCount,
'deleted' => $result->deletedCount(),
'deleted_client_ids' => $result->deletedClientIdentifiers,
]);

$io->title($dryRun ? 'DCR cleanup (dry-run)' : 'DCR cleanup');
$io->listing($result->deletedClientIdentifiers);
$io->table(['examined', 'kept (active)', $dryRun ? 'to delete' : 'deleted'], [
[$result->examinedCount, $result->keptActiveCount, $result->deletedCount()],
]);
$io->success(sprintf(
'%s %d abandoned DCR client(s); kept %d active.',
$dryRun ? 'Would remove' : 'Removed',
$result->deletedCount(),
$result->keptActiveCount,
));

return Command::SUCCESS;
}
}
114 changes: 114 additions & 0 deletions Controller/Admin/McpTokenController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

/*
* This file is part of EC-CUBE
*
* Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
*
* http://www.ec-cube.co.jp/
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Plugin\Api44\Controller\Admin;

use Eccube\Controller\AbstractController;
use Eccube\Entity\Master\Authority;
use Eccube\Entity\Member;
use Plugin\Api44\Form\Type\Admin\McpTokenType;
use Plugin\Api44\Repository\McpTokenRepository;
use Plugin\Api44\Service\McpTokenService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;

class McpTokenController extends AbstractController
{
public function __construct(
private readonly McpTokenService $mcpTokenService,
private readonly McpTokenRepository $mcpTokenRepository,
) {
}

/**
* MCP トークン発行画面。 送信時にトークンを発行し、 JWT を 1 度だけ表示する。
*/
#[Route(path: '/%eccube_admin_route%/api/oauth/mcp/new', name: 'admin_api_mcp_token_new', methods: ['GET', 'POST'])]
public function create(Request $request): Response
{
$form = $this->createForm(McpTokenType::class);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$member = $this->getUser();
// 制限管理者 (authority != ADMIN) は URL 認可で受注/顧客画面等を塞がれるが、 stateless な mcp firewall では
// その制限が再評価されない。 全 scope トークンを発行させると管理画面の権限制限を第二の扉で回避できるため、
// 発行時に ADMIN を要求する (対話型同意フロー AuthorizationRequestResolveListener と対称)。
if (!$member instanceof Member || Authority::ADMIN !== $member->getAuthority()?->getId()) {
throw new AccessDeniedHttpException();
}

try {
// sub はフォーム入力でなく操作中の Member 固定 (なりすまし防止)
$token = $this->mcpTokenService->issue(
$member,
(string) $form->get('label')->getData(),
$form->get('scopes')->getData(),
(int) $form->get('expire')->getData(),
);

// 発行直後のみ JWT を表示する (再表示不可)。 redirect すると失われるため render する
$response = $this->render('@Api44/admin/OAuth/mcp_token_issued.twig', [
'token' => $token,
'label' => (string) $form->get('label')->getData(),
]);
// bearer token を HTML に埋め込む画面なので、 ブラウザや共有端末のキャッシュに残さない
$response->headers->set('Cache-Control', 'no-store, private');

return $response;
} catch (\Exception $e) {
$this->addError(trans('admin.common.save_error'), 'admin');
// 例外クラスと発行者を残し、 万一の発行失敗を追跡可能にする
log_error('MCP トークン発行エラー', ['exception' => $e, 'member_id' => $member->getId()]);
}
}

return $this->render('@Api44/admin/OAuth/mcp_token.twig', [
'form' => $form->createView(),
]);
}

/**
* MCP トークンを失効する (league token を revoke → 即 401)。
*/
#[Route(path: '/%eccube_admin_route%/api/oauth/mcp/revoke/{id}', requirements: ['id' => '\d+'], name: 'admin_api_mcp_token_revoke', methods: ['DELETE'])]
public function revoke(Request $request, int $id): RedirectResponse
{
$this->isTokenValid();

$mcpToken = $this->mcpTokenRepository->find($id);
if (null === $mcpToken) {
$this->addError('admin.common.delete_error_already_deleted', 'admin');

return $this->redirectToRoute('admin_api_oauth');
}

try {
$found = $this->mcpTokenService->revoke($mcpToken);
if ($found) {
$this->addSuccess('admin.common.delete_complete', 'admin');
} else {
// 失効対象の league token が見つからなかった (期限切れ削除済み等)。 メタは削除したが操作者に明示する
$this->addWarning('api.admin.oauth.mcp_token.revoke__not_found', 'admin');
}
} catch (\Exception $e) {
$this->addError('admin.common.delete_error', 'admin');
log_error('MCP トークン失効エラー', ['exception' => $e, 'id' => $id]);
}

return $this->redirectToRoute('admin_api_oauth');
}
}
12 changes: 10 additions & 2 deletions Controller/Admin/OAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
use Plugin\Api44\Form\Type\Admin\ClientType;
use Plugin\Api44\Repository\McpTokenRepository;
use Plugin\Api44\Service\McpTokenService;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
Expand Down Expand Up @@ -58,6 +60,7 @@ public function __construct(
ClientManagerInterface $clientManager,
AccessTokenManagerInterface $accessTokenManager,
RefreshTokenManagerInterface $refreshTokenManager,
private readonly McpTokenRepository $mcpTokenRepository,
) {
$this->clientManager = $clientManager;
$this->accessTokenManager = $accessTokenManager;
Expand All @@ -74,10 +77,15 @@ public function __construct(
public function index(Request $request): Response
{
$criteria = ClientFilter::create();
$clients = $this->clientManager->list($criteria);
// MCP トークン用の内部クライアント (mcp_pat) は API クライアント一覧に出さない
$clients = array_values(array_filter(
$this->clientManager->list($criteria),
static fn (ClientInterface $client): bool => $client->getIdentifier() !== McpTokenService::CLIENT_IDENTIFIER,
));

return $this->render('@Api44/admin/OAuth/index.twig', [
'clients' => $clients,
'mcpTokens' => $this->mcpTokenRepository->findAllOrderByCreateDate(),
]);
}

Expand Down Expand Up @@ -129,7 +137,7 @@ public function create(Request $request): RedirectResponse|Response
*
* @return RedirectResponse
*/
#[Route(path: '/%eccube_admin_route%/api/oauth/delete/{identifier}', requirements: ['identifier' => '\w+'], name: 'admin_api_oauth_delete', methods: ['DELETE'])]
#[Route(path: '/%eccube_admin_route%/api/oauth/delete/{identifier}', requirements: ['identifier' => '[\w\-]+'], name: 'admin_api_oauth_delete', methods: ['DELETE'])]
public function delete(Request $request, string $identifier): RedirectResponse
{
$this->isTokenValid();
Expand Down
Loading