Skip to content

Commit adde2c2

Browse files
sudoghutclaude
andcommitted
MCP 端點的限流補上 GET,並改用具名 limiter(#1249
`Mcp::web()` 會註冊兩條路由(GET 是 MCP 規範要求的 SSE 佔位、恆回 405;POST 是 真正的伺服器),但它只回傳 POST,所以 routes/ai.php 的 ->middleware([...]) 只套 到 POST。GET 因此沒有任何路由層 middleware,僅靠 api 群組寬鬆的 600/分把關—— 同一個 URI 一半有閘一半沒有,成了便宜的請求放大面。 - 改用群組套限流(Route::middleware('throttle:mcp')->group(...)),一次涵蓋 Mcp::web() 內部註冊的兩條路由,不必依賴「同 URI 後註冊者會逐出前者」這種 RouteCollection 實作細節。 - 改用具名 limiter:數值型 throttle 的 key 是 sha1(domain|ip),不含路由、方法與 上限值,所以路由上的 120,1 會與 api 群組的 600,1 共用同一個計數器——每個請求 被 hit() 兩次,實測 120 的上限實際只有約 60/分,且該 IP 打其他 /api/* 也會吃掉 MCP 的額度。具名 limiter 的 key 有命名空間隔離。 - 另以 withoutMiddleware('throttle:600,1') 拿掉 api 群組繼承的那條:只換具名 limiter 還不夠,600 仍會套用,於是其他 /api 流量把 600 桶打滿時,MCP 自己的 120 還有額度卻照樣 429。拿掉之後才真的是獨立預算。 - 設定值加上範圍防護:0/負數退回預設(Limit::perMinute(0) 實測會放行第一個 請求,等於幾乎不設限,那種值只可能是設定錯誤),上限夾在 600——因為已排除 api 那條,不夾住的話一個過大的設定值就會放寬全站上限。 - GET 刻意不加 auth:sanctum:回應是固定 405、不碰資料庫、不含任何資料,加認證 沒保護到東西,卻會讓未認證的 MCP 客戶端在探測階段拿到 401 而非規範所期待的 「不支援 SSE」405。 回歸測試 McpEndpointMiddlewareTest(7 tests):兩個 verb 都掛具名 limiter、解析後 只剩那一條 throttle(守住與 Kernel 的字串耦合)、限流真的會擋、設定值夾範圍 (含 0/負數/超大值/null/非數字)、GET 仍回 405、POST 閘門未被動到、POST 無 token 仍被擋。守衛刻意不用 markTestSkipped,並斷言路由恰好一條以偵測殘影。 已知且不在本次範圍:未認證的 POST 在 401 時完全繞過限流(框架 $middlewarePriority 把認證排在限流之前,全站 auth 路由皆然),已另開 #1254 追。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f1a54be commit adde2c2

5 files changed

Lines changed: 258 additions & 10 deletions

File tree

API.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,9 +1532,13 @@ v2 提案流程上線前的舊機制,仍在線但**建議一律改用 `/api/v2
15321532

15331533
### 14.6 MCP(Model Context Protocol)
15341534

1535-
`POST /api/mcp` — 供 AI 客戶端以 MCP 協定唯讀查詢 CBDB。需要 **Bearer token 且具備 `mcp:read` 能力**(見 14.2),另有獨立限流 120 次/分鐘。這是全站唯一會檢查 token abilities 的端點。
1535+
`POST /api/mcp` — 供 AI 客戶端以 MCP 協定唯讀查詢 CBDB。需要 **Bearer token 且具備 `mcp:read` 能力**(見 14.2)。這是全站唯一會檢查 token abilities 的端點。
15361536

1537-
同一路徑的 `GET` 只是協定要求的佔位,**恆回 405 且不做任何認證**,不要拿它當健康檢查。
1537+
限流:MCP 有自己的專屬額度(**預設 120 次/分鐘**,可由部署設定調整,但不會超過 600),獨立於其他 `/api/*` 端點共用的 600 次/分鐘(兩者互不排擠——打其他 API 不會消耗 MCP 的額度,反之亦然)。計數方式依請求而定:通過認證的 `POST`**帳號**計數;未帶憑證的 `GET`**來源 IP** 計數。
1538+
1539+
**注意**:未帶(或帶錯)憑證的 `POST` 會在認證階段就被擋下回 401,**不會**進到這個限流;框架的中間件順序是認證先於限流,這是全站 `auth` 路由的共同行為。
1540+
1541+
同一路徑的 `GET` 只是 MCP 協定要求的 SSE 佔位,**恆回 405**(意思是「本伺服器不支援 SSE」)。它刻意不要求認證——回應不含任何資料,加認證會讓探測階段拿到 401 而非協定所期待的 405——但同樣受上述 120 次/分鐘的限流保護。不要拿它當健康檢查。
15381542

15391543
### 14.7 人物頁 API
15401544

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44

55
## 2026-08
66

7+
### MCP 端點的限流補上 GET,並改用具名 limiter(#1249
8+
- 起因:`Mcp::web()` 會註冊**兩條**路由,但它只回傳 POST,所以 `routes/ai.php``->middleware(['auth:sanctum', 'mcp.ability:…', 'throttle:120,1'])` 只套到 POST。套件的 GET 是 MCP 規範要求的 SSE 佔位(恆回 405),**沒有任何路由層 middleware**,僅靠 `api` 群組寬鬆的 600/分把關——同一個 URI 一半有閘一半沒有,成了便宜的請求放大面。
9+
- 修法改為**用群組套限流**`Route::middleware('throttle:mcp')->group(...)`),一次涵蓋 `Mcp::web()` 內部註冊的兩條路由,不必依賴「同 URI 後註冊者會逐出前者」這種 `RouteCollection` 實作細節。
10+
- **同時改用具名 limiter `mcp`**(原本的 `throttle:120,1` 是數值型)。這一點是 review 實測抓出來的真缺陷:數值型 throttle 的 key 是 `sha1(domain|ip)`,**不含路由、方法與上限值**,所以路由上的 `120,1` 會與 `api` 群組的 `600,1` 共用同一個計數器——每個請求被 `hit()` 兩次,實測 120 的上限實際只有約 60/分,而且該 IP 打其他 `/api/*` 端點也會吃掉 MCP 的額度(同一個 NAT 後面的機構共用這個桶,合法客戶端可能在探測階段就 429)。具名 limiter 的 key 有命名空間隔離,上限才是真正的 config 值。**另以 `withoutMiddleware('throttle:600,1')` 拿掉 api 群組繼承來的那條**——這是 codex 覆核指出的:只換具名 limiter 還不夠,600 仍會套用,於是同 IP 的其他 `/api` 流量把 600 桶打滿時,MCP 自己的 120 還有額度卻照樣 429。拿掉之後才真的是獨立預算(120 本來就比 600 嚴格,不放寬任何東西);該字串與 `app/Http/Kernel.php` 的耦合由測試斷言 resolved middleware 守住。
11+
- **刻意只補限流、不在 GET 加 `auth:sanctum`**:回應是固定的 405、不碰資料庫、不含任何資料,加認證沒保護到東西,卻會讓未認證的 MCP 客戶端在探測階段拿到 401 而不是規範所期待的「不支援 SSE」405。
12+
- 設定值加上範圍防護:`MCP_RATE_LIMIT_PER_MINUTE` 為 0/負數時退回預設(`Limit::perMinute(0)` 實測會放行第一個請求,等於幾乎不設限,那種值只可能是設定錯誤),上限夾在 600——因為已排除 api 群組那條,若不夾住,一個過大的設定值就會把全站上限放寬。
13+
- 回歸測試 `McpEndpointMiddlewareTest`(7 tests):GET 與 POST 都掛具名 limiter、**解析後只剩那一條 throttle**(守住與 Kernel 的字串耦合)、**限流真的會擋**(把上限調小後打過頭確認 429,同時證明 limiter 在請求時才讀 config)、GET 仍回 405、POST 的閘門未被動到、POST 無 token 仍被擋。守衛刻意不用 `markTestSkipped`(那會讓「誤關 `MCP_ENABLED`」變成全綠),並斷言路由恰好一條以偵測殘影。已驗證還原改動後測試會紅。
14+
- 已知且不在本次範圍:**未認證的 POST 在 401 時完全繞過限流**——框架的 `$middlewarePriority``AuthenticatesRequests` 排在 `ThrottleRequests` 之前,這是全站 auth 路由的共同行為(每個 401 仍會查一次 `personal_access_tokens`),已另開 #1254 追。
15+
716
### 清掉 13 條無用路由(其中 11 條指向不存在的控制器方法),並加測試防復發(#1250
817
- 起因:`/api/select/codes` 指向從未存在的 `ApiController@codes`,命中時由基底 `Controller::__call``BadMethodCallException`=HTTP 500。這類路由不會在啟動時報錯(Laravel 只在請求進來才解析 action),所以能長期潛伏,只在被外部掃描或誤點時變成錯誤日誌噪音。
918
- 掃完全站後發現不只一條:`Route::resource('operations', ...)``Route::resource('crowdsourcing', ...)` 各生 7 條路由,但兩個控制器只實作 `index()` 與一個**空的 `store()`**,於是 `create``show``edit``update``destroy` 共 10 條全是 500。兩者的 `index` 早已在上方以顯式路由宣告(`crowdsourcing` 那條同在 superadmin 群組內,保護不變),空 `store` 無呼叫端,因此整段移除;已確認全庫沒有引用被拿掉的路由名稱(grep 命中的都是 SQL 欄名 `operations.created_at` 與翻譯鍵 `operations.edit_proposal` 之類的假陽性)。

app/Providers/RouteServiceProvider.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,48 @@ protected function configureRateLimiting() {
4646

4747
return Limit::perMinute($limit)->by($request->user()?->id ?: $request->ip());
4848
});
49+
50+
/*
51+
* MCP 端點(#1249)。**必須是具名 limiter,不能寫成 `throttle:120,1`**:
52+
*
53+
* 數值型 throttle 的 key 是 `sha1(domain|ip)`——不含路由、方法與上限值
54+
* (ThrottleRequests::resolveRequestSignature())。因此路由上的 `throttle:120,1`
55+
* 會與 api 群組的 `throttle:600,1` **共用同一個計數器**,實測後果有兩個:
56+
* 1. 每個請求被 hit() 兩次 → 120 的上限實際只有約 60/分;
57+
* 2. 該 IP 打其他 /api/* 端點也會吃掉 MCP 的額度(反之亦然),於是同一個 NAT
58+
* 後面的機構共用這個桶,合法 MCP 客戶端可能在探測階段就拿到 429。
59+
*
60+
* 具名 limiter 的 key 是 `md5($limiterName.$limit->key)`,有命名空間隔離,
61+
* 上限才是真正的 config 值。與上面 qa-answer 同一個理由:callback 在每次請求時
62+
* 才讀 config,測試可用 config([...]) 動態調整。
63+
*/
64+
RateLimiter::for('mcp', function (Request $request) {
65+
return Limit::perMinute(self::mcpRateLimit())->by($request->user()?->id ?: $request->ip());
66+
});
67+
}
68+
69+
/** MCP 端點在設定值不合理時退回的預設上限(與 config/mcp.php 的預設一致)。 */
70+
public const MCP_RATE_LIMIT_DEFAULT = 120;
71+
72+
/**
73+
* MCP 端點限流的上限,且**永不放寬到超過 api 群組的 600/分**。
74+
*
75+
* 為什麼需要夾範圍(#1249 codex 覆核):
76+
* - `MCP_RATE_LIMIT_PER_MINUTE=0`/負數不會變成「零額度」,`Limit::perMinute(0)` 實測是
77+
* 「第一個請求仍放行、第二個才 429」——一個想關閉端點的設定值反而變成幾乎不設限。
78+
* 這種值只可能是設定錯誤,退回預設比照字面解讀安全。
79+
* - MCP 群組已用 withoutMiddleware() 拿掉 api 群組繼承的 `throttle:600,1`(否則其他 /api
80+
* 流量會吃掉 MCP 的額度),代價是這裡若填一個大於 600 的值,就會**放寬**原本的全站
81+
* 上限。上限夾在 600 讓「不放寬任何東西」這個承諾在設定層也成立。
82+
*/
83+
public static function mcpRateLimit(): int {
84+
$configured = (int) config('mcp.cbdb.rate_limit_per_minute', self::MCP_RATE_LIMIT_DEFAULT);
85+
86+
if ($configured < 1) {
87+
$configured = self::MCP_RATE_LIMIT_DEFAULT;
88+
}
89+
90+
return min($configured, 600);
4991
}
5092

5193
/**

routes/ai.php

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,43 @@ class_exists(Mcp::class)
99
&& class_exists(CbdbReadOnlyServer::class)
1010
&& config('mcp.cbdb.enabled', true)
1111
) {
12-
$rateLimit = (int) config('mcp.cbdb.rate_limit_per_minute', 120);
1312
$requiredAbility = (string) config('mcp.cbdb.required_ability', 'mcp:read');
1413

15-
Mcp::web('/api/mcp', CbdbReadOnlyServer::class)
16-
->middleware([
17-
'auth:sanctum',
18-
"mcp.ability:{$requiredAbility}",
19-
"throttle:{$rateLimit},1",
20-
])
21-
->name('mcp.cbdb');
14+
/*
15+
* #1249:限流必須用**群組**套上,不能只掛在 Mcp::web() 回傳的那條路由上。
16+
*
17+
* Mcp::web() 會註冊兩條路由:一條 GET(MCP 規範要求的 SSE 佔位,恆回 405)與一條
18+
* POST(真正的伺服器);它只回傳後者,所以 `->middleware()` 也只套到 POST。GET 因此
19+
* 原本沒有任何路由層 middleware,僅靠 api 群組寬鬆的 600/分把關——同一個 URI 一半有閘
20+
* 一半沒有,成了便宜的請求放大面。改用群組後兩條都涵蓋,且不必依賴「後註冊的同 URI
21+
* 路由會逐出前者」這種實作細節。
22+
*
23+
* throttle 用具名 limiter `mcp`(定義見 RouteServiceProvider::configureRateLimiting()):
24+
* 數值型 `throttle:120,1` 的 key 只由 IP 決定,會與 api 群組的 600 桶相撞,導致實際上限
25+
* 腰斬成約 60/分、且與該 IP 的其他 /api 流量互相排擠。
26+
*
27+
* 另以 withoutMiddleware() 拿掉 api 群組繼承來的 `throttle:600,1`——只換成具名 limiter
28+
* 還不夠:那條 600 仍會套用,於是同 IP 的其他 /api 流量把 600 桶打滿時,MCP 自己的 120
29+
* 還有額度卻照樣 429。拿掉之後 MCP 才真的有一份獨立預算(120 本來就比 600 嚴格,不會放寬
30+
* 任何東西)。字串必須與 app/Http/Kernel.php 的 api 群組完全一致,這個耦合由
31+
* McpEndpointMiddlewareTest 斷言 resolved middleware 守住。
32+
*
33+
* GET 刻意**不掛 auth:sanctum**:回應是固定的 405、不碰資料庫、不含任何資料,加認證沒有
34+
* 保護到東西,卻會讓未認證的 MCP 客戶端在探測階段拿到 401 而非規範所期待的「本伺服器
35+
* 不支援 SSE」405。
36+
*
37+
* 已知且不在本次範圍:未認證的 POST 會在 401 時完全繞過限流——框架的
38+
* $middlewarePriority 把 AuthenticatesRequests 排在 ThrottleRequests 之前,這是全站
39+
* auth 路由的共同行為,不是此處的設定問題。
40+
*/
41+
Route::middleware('throttle:mcp')->withoutMiddleware('throttle:600,1')->group(function () use ($requiredAbility) {
42+
Mcp::web('/api/mcp', CbdbReadOnlyServer::class)
43+
->middleware([
44+
'auth:sanctum',
45+
"mcp.ability:{$requiredAbility}",
46+
])
47+
->name('mcp.cbdb');
48+
});
2249
} else {
2350
Route::post('/api/mcp', static function () {
2451
return response()->json([
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Providers\RouteServiceProvider;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Cache;
8+
use Illuminate\Support\Facades\RateLimiter;
9+
use Illuminate\Support\Facades\Route;
10+
use PHPUnit\Framework\Attributes\Test;
11+
use Tests\TestCase;
12+
13+
/**
14+
* #1249:`GET /api/mcp` 必須與 POST 一樣受 MCP 專屬限流保護。
15+
*
16+
* 背景:`Mcp::web()` 註冊兩條路由(GET 是 MCP 規範要求的 SSE 佔位,恆回 405;POST 是真正的
17+
* 伺服器),但它只回傳 POST,所以 `routes/ai.php` 原本的 `->middleware([...])` 只套到 POST。
18+
* GET 因此沒有任何路由層 middleware,僅靠 `api` 群組寬鬆的 600/分把關——同一個 URI 一半有閘
19+
* 一半沒有。修法是改用群組套限流,並改用具名 limiter(數值型 throttle 的 key 只由 IP 決定,
20+
* 會與 api 群組的 600 桶相撞,使上限腰斬並與其他 /api 流量互相排擠)。
21+
*
22+
* 這裡釘住四件事:
23+
* 1. GET 與 POST 都掛上具名 limiter `mcp`;
24+
* 2. 限流真的會擋(不只是「有掛」),且上限取自 config;
25+
* 3. GET 仍回 405(規範行為不能因為補閘而改變);
26+
* 4. POST 的完整閘門(auth:sanctum + ability)沒有被這次改動弄掉。
27+
*/
28+
class McpEndpointMiddlewareTest extends TestCase {
29+
protected function setUp(): void {
30+
parent::setUp();
31+
32+
// 具名 limiter 的計數器存在 cache store。phpunit.xml 已把 CACHE_DRIVER 設為 array
33+
// (每個測試 process 一份、互不殘留),這裡再顯式清一次 store,讓「上限調小後打過頭」
34+
// 那條測試不受同 process 內其他測試留下的計數影響。
35+
Cache::store(config('cache.default'))->clear();
36+
}
37+
38+
/** @return array<int,\Illuminate\Routing\Route> */
39+
private function mcpRoutes(string $method): array {
40+
$found = [];
41+
42+
foreach (Route::getRoutes() as $route) {
43+
if ($route->uri() === 'api/mcp' && in_array($method, $route->methods(), true)) {
44+
$found[] = $route;
45+
}
46+
}
47+
48+
return $found;
49+
}
50+
51+
private function requireMcpRoute(string $method): \Illuminate\Routing\Route {
52+
$routes = $this->mcpRoutes($method);
53+
54+
// 不用 markTestSkipped:那會讓「有人誤把 MCP_ENABLED 關掉」變成全綠。
55+
// 端點理應存在(config/mcp.php 預設 enabled=true、laravel/mcp 在 require 而非 require-dev)。
56+
$this->assertCount(
57+
1,
58+
$routes,
59+
"應該恰好有一條 {$method} api/mcp 路由。0 條代表 MCP 端點沒註冊(檢查 mcp.cbdb.enabled);"
60+
.'2 條代表有人用「同 URI 後註冊覆蓋」的寫法而 method set 不一致,留下了殘影路由。'
61+
);
62+
63+
return $routes[0];
64+
}
65+
66+
#[Test]
67+
public function both_verbs_use_the_named_mcp_rate_limiter(): void {
68+
foreach (['GET', 'POST'] as $method) {
69+
$this->assertContains(
70+
'throttle:mcp',
71+
$this->requireMcpRoute($method)->gatherMiddleware(),
72+
"{$method} api/mcp 必須使用具名 limiter `mcp`。若這裡失敗,通常是有人把 routes/ai.php 的"
73+
.' throttle 群組拆掉,或改回了數值型 throttle:120,1(那會與 api 群組的 600 桶共用計數器)。'
74+
);
75+
}
76+
}
77+
78+
#[Test]
79+
public function the_mcp_budget_is_isolated_from_the_shared_api_throttle(): void {
80+
// 只換成具名 limiter 還不夠:api 群組繼承來的 throttle:600,1 若仍在,同 IP 的其他
81+
// /api 流量把 600 桶打滿時,MCP 自己的 120 還有額度卻照樣 429。這裡直接檢查
82+
// **解析後**的 middleware 清單,確保那條 600 真的被 withoutMiddleware() 排除掉。
83+
//
84+
// 這條斷言同時守住 routes/ai.php 與 app/Http/Kernel.php 之間的字串耦合:
85+
// 若哪天 api 群組的 throttle 參數改了,排除字串會失配,這裡就會紅。
86+
foreach (['GET', 'POST'] as $method) {
87+
$resolved = app('router')->gatherRouteMiddleware($this->requireMcpRoute($method));
88+
89+
$throttles = array_values(array_filter(
90+
$resolved,
91+
fn ($name) => is_string($name) && str_contains($name, 'ThrottleRequests')
92+
));
93+
94+
$this->assertSame(
95+
['Illuminate\Routing\Middleware\ThrottleRequests:mcp'],
96+
$throttles,
97+
"{$method} api/mcp 應該只剩具名 limiter 一條 throttle。若這裡出現 ThrottleRequests:600,1,"
98+
.'代表 routes/ai.php 的 withoutMiddleware() 字串與 app/Http/Kernel.php 的 api 群組不再一致。'
99+
);
100+
}
101+
}
102+
103+
#[Test]
104+
public function the_limiter_actually_blocks_and_honours_the_configured_limit(): void {
105+
// gatherMiddleware() 只證明「有掛」。這裡把上限調小並真的打過頭,確認會 429,
106+
// 同時證明 limiter 是在請求時才讀 config(而不是註冊路由當下就把數字定死)。
107+
$this->requireMcpRoute('GET');
108+
config(['mcp.cbdb.rate_limit_per_minute' => 3]);
109+
110+
for ($i = 1; $i <= 3; $i++) {
111+
$this->get('/api/mcp')->assertStatus(405);
112+
}
113+
114+
$this->get('/api/mcp')->assertStatus(429);
115+
}
116+
117+
#[Test]
118+
public function the_configured_limit_is_clamped_to_a_sane_range(): void {
119+
// 直接取 limiter callback 算出的 Limit,不必真的打上百次請求。
120+
$limitFor = function (mixed $configured): int {
121+
config(['mcp.cbdb.rate_limit_per_minute' => $configured]);
122+
123+
return RateLimiter::limiter('mcp')(Request::create('/api/mcp'))->maxAttempts;
124+
};
125+
126+
// 正常值照用。
127+
$this->assertSame(50, $limitFor(50));
128+
129+
// 0/負數不是「零額度」——Limit::perMinute(0) 實測會放行第一個請求,等於幾乎不設限,
130+
// 只可能是設定錯誤,退回預設。
131+
$this->assertSame(RouteServiceProvider::MCP_RATE_LIMIT_DEFAULT, $limitFor(0));
132+
$this->assertSame(RouteServiceProvider::MCP_RATE_LIMIT_DEFAULT, $limitFor(-5));
133+
134+
// 不得放寬超過 api 群組原本的 600/分(MCP 群組已把那條排除掉,這裡自己守住承諾)。
135+
$this->assertSame(600, $limitFor(5000));
136+
137+
// 髒設定值(null/非數字字串)經 (int) 轉型為 0,同樣退回預設而不是變成幾乎不設限。
138+
$this->assertSame(RouteServiceProvider::MCP_RATE_LIMIT_DEFAULT, $limitFor(null));
139+
$this->assertSame(RouteServiceProvider::MCP_RATE_LIMIT_DEFAULT, $limitFor('abc'));
140+
}
141+
142+
#[Test]
143+
public function get_still_answers_405_as_the_protocol_expects(): void {
144+
// 補閘不得改變協定層語義:MCP 客戶端探測 GET 時應得到「不支援 SSE」的 405,
145+
// 而不是 401——這是刻意不在 GET 上加 auth:sanctum 的原因。
146+
$this->requireMcpRoute('GET');
147+
148+
$this->get('/api/mcp')->assertStatus(405);
149+
}
150+
151+
#[Test]
152+
public function post_keeps_its_full_authorization_gate(): void {
153+
$middleware = $this->requireMcpRoute('POST')->gatherMiddleware();
154+
155+
$this->assertContains('auth:sanctum', $middleware);
156+
$this->assertContains('mcp.ability:'.config('mcp.cbdb.required_ability', 'mcp:read'), $middleware);
157+
}
158+
159+
#[Test]
160+
public function post_without_a_token_is_rejected(): void {
161+
// 端到端確認 POST 的閘門真的生效(middleware 清單斷言只證明有掛,不證明會擋)。
162+
$this->requireMcpRoute('POST');
163+
164+
$this->postJson('/api/mcp', [])->assertUnauthorized();
165+
}
166+
}

0 commit comments

Comments
 (0)