Phase:M04 — Web Chat MCP Integration & AI Join Kit 本文件是 M04-A~M04-C 的具體實作契約:module、endpoint、schema、文字模板、資料流與接線。「完成後什麼必須為真」在
實作規格.md;驗收方式在測試指南.md。 Subphase 名稱與順序與另外兩份文件一字不差。
最後更新:2026-09-11
M04-A_PREFLIGHT.md 的某一項。grant_id;之後呼叫 P3-D 既有的 resolve_current_scope 與 TableActorContext 建構,與 Bearer AI Join Token 路徑收斂到同一個函式。client_id 只是 OAuth client 身分,不是 Seat 授權識別。resolve_current_scope;refresh token 未過期不構成授權。GET /mcp/guide、tool description、briefing 都從 _TOOL_DEFINITIONS 與一份指引文字模組產生。app.mcp.router 或其子 router;standalone 不 include。tools/m04a-webchat-preflight/ # M04-A:獨立測試 server,不 import app.*
README.md
requirements.txt
server.py # Starlette/FastAPI 單檔;OAuth + MCP + JSONL logger(公開 listener)
admin.py # 管理 endpoint(只監聽 127.0.0.1 的第二個 listener)
authorize.html # 最小授權表單
logs/ # JSONL 記錄(gitignore 內容,保留目錄)
apps/server/app/mcp/
oauth/ # M04-B:新 package
__init__.py
metadata.py # /.well-known/* 回應
routes.py # /mcp/oauth/authorize、/token、/register
service.py # code / token 發放、hash、與 grant 的對應
pages.py # authorize 頁 HTML(bilingual)
auth.py # 改:Bearer 值先試 OAuth access token,再試 AI Join Token
protocol.py # 若 M04-A 證明需要:legacy 相容層
guide.py # M04-C:指引產生器
tools.py # M04-C:description 加厚、briefing
server.py # M04-C:GET /mcp/guide、discover.instructions
apps/server/app/persistence/rooms/
ai_oauth.py # M04-B:ORM + repository
apps/server/alembic/versions/
0021_m04b_ai_oauth.py # web track
apps/web/src/features/rooms/
aiJoinKit.ts / aiJoinKit.test.ts # M04-C
LobbyAIDMGrantPanel.tsx # M04-C:加 kit 區塊
PlayerAIControlPanel.tsx # M04-C:加 kit 區塊
lobbyCopy.ts / sessionCopy.ts # M04-C:新 copy
M04-A_PREFLIGHT.md 結論標 Platform Blocked / Deferred,PROJECT_BRIEF 把 M04-B/M04-C 標延後,下一步進 P4。不使用 Business/Enterprise/Edu 工作區;官方文件對方案限制的描述與日期記進 PREFLIGHT.md 開頭。
server.py(公開 listener)+ admin.py(loopback listener),依賴只允許 fastapi/starlette、uvicorn、標準函式庫;不用 mcp SDK,好讓 wire 上的每個 byte 都是我們自己寫的、可記錄的。
公開 endpoint(經 tunnel):
| Path | 用途 |
|---|---|
GET /.well-known/oauth-protected-resource |
RFC 9728;resource 指向 /mcp,authorization_servers 指向自己 |
GET /.well-known/oauth-authorization-server |
RFC 8414;列 authorization_endpoint、token_endpoint、registration_endpoint、code_challenge_methods_supported: ["S256"]、grant_types_supported: ["authorization_code","refresh_token"] |
POST /register |
RFC 7591 dynamic client registration;接受任何 redirect_uris,回隨機 client_id,記錄平台送來的整個 body |
GET /authorize |
回 authorize.html:role 下拉(dm/player)+ 固定密碼欄 + Submit;記錄 query(client_id、redirect_uri、code_challenge、scope、state) |
POST /authorize |
驗密碼 → 產生 code(記 role 與 code_challenge)→ 302 到 redirect_uri?code=&state= |
POST /token |
authorization_code(驗 PKCE)→ access token(TTL 可用 env 設,預設 5 分鐘以逼出 refresh 行為)+ refresh token;refresh_token → 新 access token |
POST /mcp |
見 3.2 |
GET /mcp |
回 200 純文字「MCP endpoint」,記錄平台有沒有先 GET 探測 |
管理 endpoint(admin.py,只 bind 127.0.0.1:<admin_port>,tunnel 不轉發;經公開入口打 /admin/* 一律 404):
| Path | 用途 |
|---|---|
POST /admin/revoke-all |
讓所有 token 失效,測 A.5 第 4 步 |
POST /admin/add-tool |
動態多一個 tool,測 A.5 第 2 步 |
server.py 啟動時斷言 admin listener 只 bind loopback;README 的 tunnel 設定只轉發公開 port。
記錄: 每個 request 一行 JSONL:ts、method、path、query、headers、body、status、response_body。request 與 response 走同一個 redact():authorization、cookie header;body 與 query 內任何 key 名稱含 token/code/secret/verifier/challenge 的值(含 access_token、refresh_token、code、client_secret)一律替換為 [redacted:<len>];Location header 內的 code= 參數同樣遮罩。遮罩函式有一條 pytest(tools/m04a-webchat-preflight/test_redact.py)證明 token endpoint 的 response body 記錄不含明文。
/mcp 行為MCP-Protocol-Version header 值;沒有也接受。family_id → {role, access_hash, refresh_hash, revoked});/token 的 refresh 路徑先檢查 family 未 revoked 才發新 access token(模擬 M04-B 的重驗)。initialize(回 protocolVersion = client 送的值或 2025-06-18、capabilities.tools、serverInfo、instructions)、notifications/initialized(202)、server/discover、tools/list、tools/call、ping。其他回 -32601 並記錄。Mcp-Session-Id 就原樣回;若 initialize 沒帶則回一個隨機值;server 不依賴它。Accept 含 text/event-stream 時仍回 JSON(先量平台是否接受);若記錄顯示它拒絕,加一個 env 切換改回 SSE 單事件,再量一次。| tool | scope | 行為 |
|---|---|---|
get_context |
全部 | 回 {role, note_count, server_time} |
post_note |
全部 | {text} → 存記憶體,回 {seq} |
dm_only_ping |
dm |
回 {pong: true} |
wait_seconds |
全部 | {seconds ≤ 120} → asyncio.sleep 後回 {waited} |
late_tool |
全部 | 只有 POST /admin/add-tool 之後才出現在 tools/list |
寫在 tools/m04a-webchat-preflight/README.md,步驟對應實作規格 A.2~A.6,依 3.0 階梯對每個平台各跑一輪;每一步結束後把 JSONL 的相關行號記進 docs/M04/M04-A_PREFLIGHT.md。記錄檔本身不進 repo(含平台送來的 redirect URI、client metadata 等),只把遮罩後的關鍵樣本貼進 PREFLIGHT.md。
M04-A_PREFLIGHT.md 最後一節固定六個小標,每個一句結論加證據行號:Target platform(ChatGPT Plus/Claude chat 個人方案/Platform Blocked)、OAuth endpoints required、Protocol version & methods、Catalog presentation(對照實作規格 2.3 表格)、wait_for_event timeout cap、Blockers。
本節的細節以 M04-A 結論與目標平台判定為準;標「依 preflight」者在 M04-B 開工時依記錄填定。M04-A 判定 Platform Blocked 時本節延後。
0021_m04b_ai_oauth)ai_oauth_clients
id UUID PK
client_id TEXT UNIQUE
client_secret_hash TEXT NULL -- public client 時 NULL
redirect_uris JSONB
client_name TEXT NULL
created_at
ai_oauth_authorizations -- 一次 authorize = 一個 token family
id UUID PK
client_id TEXT FK ai_oauth_clients.client_id
grant_id UUID FK ai_controller_grants.id ON DELETE CASCADE
grant_generation INTEGER -- mint 當下的 grant.generation snapshot(audit 用)
created_at
revoked_at NULL
ai_oauth_authorization_codes
code_hash TEXT PK
authorization_id UUID FK ai_oauth_authorizations.id
code_challenge TEXT
redirect_uri TEXT
expires_at -- 5 分鐘
consumed_at NULL
ai_oauth_tokens
id UUID PK
token_hash TEXT UNIQUE
kind TEXT CHECK IN ('access','refresh')
authorization_id UUID FK ai_oauth_authorizations.id ON DELETE CASCADE
expires_at
revoked_at NULL
last_used_at NULL
authorization_id,不是 client_id。 token → authorization → grant;revoke 的單位是 authorization(整個 family),同 client_id 下其他 authorization 不受影響。ai_oauth_authorizations (grant_id) WHERE revoked_at IS NULL——一張 grant 同時只有一個 active family;對同一 grant 重新 authorize 時,先把舊 authorization revoked_at = now() 再 insert。test_m03d_schema_parity.py 的 forbidden 清單加這四張表。| Path | 說明 |
|---|---|
GET /.well-known/oauth-protected-resource |
依 preflight 是否必要 |
GET /.well-known/oauth-authorization-server |
同上 |
POST /mcp/oauth/register |
DCR;依 preflight 是否必要 |
GET /mcp/oauth/authorize |
雙語 HTML:一個 textarea「貼上 AI Join Token」+ 送出;?locale= 選語言,預設依 Accept-Language |
POST /mcp/oauth/authorize |
以 AIControllerService.authenticate(token) 驗 token → 得 grant_id → revoke 該 grant 舊 authorization → insert 新 authorization → 發 code → 302 |
POST /mcp/oauth/token |
authorization_code:code → access(+ refresh 依 preflight)。refresh_token:先重驗 P3-D authority——以 authorization 的 grant_id 呼叫 resolve_current_scope(grant 有效、Seat current binding 為此 grant、controller_epoch == generation、TTL/Session 有效),通過才 rotate 出新 access(+ 新 refresh、舊 refresh 作廢);不通過回 oauth_invalid_grant 並把整個 authorization revoked_at |
POST /mcp/oauth/revoke |
RFC 7009;依 preflight 是否被 ChatGPT 使用 |
全部掛在 app.mcp.router;standalone 不 include。.well-known 路徑若 reverse proxy 只轉發 /mcp*,需同時轉發 /.well-known/oauth-*,寫進 README 部署段。
revoked_at(實作規格 B.3「一張 grant 一個 active connection」)。同 client_id 下綁其他 grant 的 authorization 不動。auth.py)Authorization: Bearer <value>
→ if value startswith "at_ai_": P3-D AI Join Token 路徑(不變)
→ elif value startswith "at_oa_": OAuth access token
hash → ai_oauth_tokens(kind=access, revoked_at IS NULL, expires_at > now)
→ authorization(revoked_at IS NULL)→ grant_id
→ AIControllerService.authenticate_grant(grant_id, touch=True)
(新方法:跳過 secret 比對,其餘與 authenticate() 相同:
resolve_current_scope → epoch/generation → AIControllerAuthView)
→ else 401 ai_token_required
authenticate_grant 與 authenticate 共用 _auth_view 與 resolve_current_scope;差別只在有沒有 secret 比對。ai_oauth_authorizations.grant_generation 只是 snapshot 供 audit;current authority 仍是 Seat controller_epoch(P3-D 契約不變)。authenticate_grant;差別只在 refresh 成功後多 mint 一組 token。at_oa_ 與 P3-D 的 at_ai_ 區分,parse_ai_controller_token 不動。在 P3-D 既有的 grant revoke/rotate/Session End/Abandon/Seat epoch 前進的 transaction 內,加一句 UPDATE ai_oauth_authorizations SET revoked_at = now() WHERE grant_id = :id AND revoked_at IS NULL(token 以 authorization 為準判斷,不需逐筆更新)。不新增獨立 job。即使漏掉某條聯動,refresh 的重驗(4.2)與 access 的 authenticate_grant 仍會擋下,聯動只是讓失效立即生效。
若 M04-A 記錄顯示目標平台送非 2026-07-28:
protocol.py 新增 LEGACY_PROTOCOL_VERSIONS(只放記錄中實際出現、且為官方已發布的版本)。validate_request 依 header 分流;legacy 路徑允許 initialize/notifications/initialized/ping/tools/list/tools/call。initialize 回靜態 capabilities + serverInfo + instructions;Mcp-Session-Id 若需要則回隨機 opaque 值,server 不保存。tool_catalog(auth) 不動。tool_catalog 對 OAuth 來源回全部 tool,description 加 [DM only]/[Player only] 標記;call_tool 的 role 檢查不變(已存在,回 scope_denied)。/mcp/dm、/mcp/player 兩個 connector URL,catalog 依 URL 固定;token 的 role 與 URL 不符時 tools/call 一律拒絕。wait_for_event timeout(依 preflight)WaitEventsInput.timeout 的 le 上限對 MCP 入口改為 min(60, 實測值);Human UI 的 long-poll endpoint 不動。
2026-09-12 修訂(M04-C 交付):上限改為 120(M04-A 實測值,不超出證據)。
app/domain/rooms/ai_tools.py的WaitEventsInput.timeoutle=60.0→le=120.0,default維持 30;app/api/rooms/table_events.py的 Human UIQuery(le=60.0)不動。test_m04b_wait_timeout_cap.py改鎖 120。「連續最多 5 次」是指引層規則(5.1 第 5 節與 5.2 briefing),server 不計數、不強制。
| code | 觸發 |
|---|---|
oauth_invalid_client |
client_id 不存在或 redirect_uri 不符 |
oauth_invalid_grant |
code 無效/過期/已用/PKCE 不符;refresh 無效 |
oauth_join_token_rejected |
authorize 頁貼的 AI Join Token 驗證失敗 |
ai_token_unauthorized |
既有;access token 失效時沿用 |
OAuth endpoint 的錯誤格式依 RFC 6749(error/error_description);authorize 頁的錯誤是雙語 HTML。
app/mcp/guide.pyGUIDE_LOCALES = ("zh-TW", "en")
def render_guide(locale: str) -> str
def render_briefing(role: str, mode: str) -> str
def tool_reference_rows(role: str | None) -> list[ToolReferenceRow]
指引分節固定:
1. 你是誰(token 決定 role;第一步呼叫 get_session_context)
2. 三種接入方式
2a. 目標平台網頁版 chat:connector URL、OAuth 時貼上 token、(依 M04-A)換 role 後要 Refresh
2b. 有 MCP client:HTTP transport + Authorization: Bearer
2c. 有 shell 或可對外連網 code execution:見第 8、9 節
3. 回應格式(result.structuredContent.data / isError / error)
4. 工具表(由 tool_reference_rows 產生)
5. 進場流程與 cursor 規則(含等待規則:timeout 120、逾時直接再等、連續最多 5 次後停下告知人類、收到事件後計數歸零)
6. DM 守則
7. Player 守則
8. HTTP 契約(header、_meta、不 initialize、不 Mcp-Session-Id;若有 legacy 層另註)
9. 最小 client 範例(開頭固定適用性聲明:只給有 shell 或可對外連網 code execution 的 AI;
網頁版 chat 的 sandbox 沒有對外網路,不要嘗試,改走 2a)
10. 401 處置
_TOOL_DEFINITIONS 迭代產生:name、roles、pre_session、description、model_json_schema()["required"] 與 enum 值。guide.py 內不得手寫工具名稱字面值。urllib.request、json),約 40 行,call(name, args) 與 wait(after_seq);URL 與 token 用 <SERVER_URL>/<AI_JOIN_TOKEN> 佔位。post_action 描述意圖,等 DM request_check(Player 沒有這個工具),再用 roll_pending 或 submit_physical_roll 完成;quick_roll 只是便利骰,不完成任何正式請求;HP/狀態變更用 update_character_state;私下問 DM 用 whisper_dm。visibility: dm_only;狀態一律用工具寫回;每個寫入帶 idempotency_key;代理 Player 時帶 subject_seat_id。render_briefing(2026-09-12 修訂:強制流程 + 機器可讀提示)app/domain/rooms/ai_guidance.py;輸入 role、mode;輸出 en / zh-TW 併列、≤ BRIEFING_MAX_CHARS(= 2,400;原 1,200 不夠容納逐步流程)。_dm_loop / _player_loop 產生(見實作規格 C.4)。DM loop 第一步就是 stage_unset 為真時先 set_stage_text。等待規則(timeout 120、連續 5 次)沿用 WAIT_TIMEOUT_SECONDS / WAIT_RETRY_COUNT 常數。set_stage_text。_INVOCATION_RULE_EN / _INVOCATION_RULE_ZH 常數接在 active loop 後;get_session_context 的 _WHEN_TO_USE 同步加同義句。_brief_role_rule / _brief_wait_rule 描述式助手移除;guide 用的完整 role_rule / wait_rule 保留。stage_unset / next_required_actionAIToolApplicationService._stage_hint(stage, role) 回 (stage_unset, next_required_action):DM 且 stage.text 空→(True, "set_stage_text"),否則→"wait_for_event";Player 一律 "wait_for_event"。get_session_context(active)由真實 Stage 推導後放入 dict;start_session 因新場 Stage 必為空,直接給 (True, "set_stage_text"),不多做 Stage 讀取(該路徑的測試 facade 只接 session_service)。call_tool 不因 stage_unset 擋任何工具。_desc(en, zh) 格式不變;rich_description() 由 _WHEN_TO_USE(每個 tool 手寫雙語「何時用」)、schema 推導的參數/enum/range/default、_availability()(何時被接受)、roles 組成。
tool_catalog 固定集合(2026-09-12 修訂)def tool_catalog(auth: AIControllerAuthView) -> list[dict[str, Any]]:
return [d.wire() for d in _TOOL_DEFINITIONS if auth.role in d.roles]
auth.session_id;DM 清單固定 13 個(含 start_session),Player 固定 12 個。call_tool:active_session_required(未綁 Session 呼叫 gameplay 工具)、pre_session_only(已綁 Session 呼叫 start_session),既有 P3-E 錯誤碼不變。_availability() 依 pre_session 旗標與 start_session 名稱產生一句雙語可用時機,接在「何時用」之後。test_p3e_mcp_tools.py::test_pre_session_dm_catalog_matches_active_dm_catalog(原 _is_minimal)、test_p3e_pre_session_ai_dm.py 改斷言 start 前後清單相同、test_m04c_tool_descriptions.py::test_role_scoped_catalog_is_fixed_per_role。start_session 後 Refresh」提醒移除。get_session_context 加 briefingpre_session 與 active dict 各加 "briefing": render_briefing(role, mode);active dict 另加 stage_unset、next_required_action(5.2a);pre_session 另加 temporary_instruction。P3-E 對 pre_session 的精確 dict 比對改為含 briefing/temporary_instruction 的精確集合,active 的斷言用 subset,斷言數不減。
GET /mcp/guide@router.get("/mcp/guide", response_class=PlainTextResponse)
def mcp_guide(locale: str = "en") -> PlainTextResponse
locale 以 require_content_locale 驗證;非法回 400 mcp_guide_locale_unsupported。Content-Type: text/plain; charset=utf-8、Cache-Control: public, max-age=300。validate_request。server/discover.instructionsAdventure Table external AI transport. Read the full guide at GET {origin}/mcp/guide
before acting. Your first call must be get_session_context; it returns a briefing
for your Seat role. Use only the scoped Seat capabilities exposed by this server.
{origin} 取 request.base_url;反向代理限制記在 closeout。
AIJoinKit.tsxexport function buildAIJoinKit(input: {
origin: string // 瀏覽器 origin → 本機 URL
remoteOrigin: string | null // GET /api/mcp/public-origin → 公網 URL;null 表示未設定
token: string
role: 'dm' | 'player'
locale: Locale
expiresAt?: string | null
}): string
export function aiJoinKitFilename(role: 'dm' | 'player', date?: Date): string
export function AIJoinKit(props): JSX.Element // 面板共用元件;掛載時 fetch public origin,取得前不渲染
buildAIJoinKit 純函式;下載沿用 CharacterSheetHtmlExport 的 Blob 模式。adventure-table-ai-<role>-<YYYYMMDD>.txt。GET /api/mcp/public-origin 提供(5.7a);fetch 失敗視同未設定。GET /api/mcp/public-origin@router.get("/api/mcp/public-origin")
async def mcp_public_origin() -> JSONResponse # {"public_origin": "<origin>" | null}
app/mcp/server.py,只讀 Settings.mcp_public_origin(M04-B 既有),去尾斜線;未設定回 null,不退回 request origin(loopback 對遠端 AI 無用)。/mcp 的 proxy 與 /api 相同,讓 kit 的本機 URL 在 dev 也連得到。Adventure Table — AI Join Kit
==============================
URL(本機): {origin}/mcp
URL(公網): {public_origin}/mcp ← 未設定公網 origin 時整行不出現
Token: {token}
Role: {DM | Player}(由 token 決定,不可更改)
Expires:{expires_at | 直到 Session 結束或被撤銷}
Guide(本機): {origin}/mcp/guide?locale={locale}
Guide(公網): {public_origin}/mcp/guide?locale={locale} ← 同上
AI 請直接照做:你已透過 MCP 連上這張桌,不需要也不能自己設定 connector。第一個動作就是實際呼叫 get_session_context(真的 tool call),再依 briefing 進行。除非某次實際 tool call 回傳錯誤,否則不得宣稱連線失敗;還沒呼叫過就說「尚未測試」。
接入設定(給架設者;通常已幫你設好,AI 不用做這段):
1. {目標平台}網頁版 chat:由使用者在 ChatGPT 新增 connector,URL 填「公網」URL,OAuth 頁貼上 Token。
2. MCP client(例如 Claude Code):HTTP transport,header Authorization: Bearer {token}。
3. shell/可對外連網的 code execution:先讀 Guide,再依 POST /mcp 契約呼叫工具(網頁版 chat 沒有對外網路,請走 connector)。
在這台機器上跑的 AI 用「本機」URL;網頁版 chat 與機器外的 AI 用「公網」URL。
{依 M04-A:Session 開始或換 Role/Seat 後工具仍是舊清單就 Refresh;換 Role/Seat 必須重新授權。}
安全:此 token 只顯示這一次;勿轉傳;用完請 Owner/DM 在網站撤銷。
{未設定公網 origin 時:尚未設定公網入口(ADVENTURE_TABLE_MCP_PUBLIC_ORIGIN);網頁版 chat 與機器外的 AI 目前連不進來,只有「本機」URL 可用。}
URL: 行加 loopback 提醒改為本機/公網雙 URL(見實作規格 C.6);loopback 判斷與提醒移除。AIJoinKit.tsx 的 templateCopy(kit 內容,非 UI copy);按鈕/標題等 UI copy 住 lobbyCopy.ts/sessionCopy.ts(5.10)。hardcodedUiCopy.test.ts 掃描範圍涵蓋 AIJoinKit.tsx。LobbyAIDMGrantPanel 與 PlayerAIControlPanel 在 issued !== null 區塊、token textarea 下方掛共用元件 <AIJoinKit origin={window.location.origin} token role locale expiresAt uiCopy />,渲染為 <div data-ai-join-kit="dm|player">:標題、提示、<pre> kit 文字、「複製 kit」、「下載」。公網 origin 由元件自己 fetch(5.7a),面板不傳。既有 data-ai-dm-token-once/data-ai-token-once 與 copyToken 不動。
lobbyCopy.ts、sessionCopy.ts 各加 aiJoinKitTitle、aiJoinKitHint、aiJoinKitCopy、aiJoinKitCopied、aiJoinKitDownload;兩 locale 同步。
對非目標平台的網頁版 chat、Codex CLI、Codex Desktop 各以 M04-B 入口嘗試一次;記錄平台、版本、日期、能否 OAuth、能否 Scan、卡點。寫入 M04-C_CLOSEOUT.md「Compatibility」段。不為它們改 server。
2026-09-12 修訂(使用者拍板):本節與實作規格 C.8 的 Bearer/純 HTTP 路徑一併延後;M04-C 只以 ChatGPT Web Plus 憑 kit 進桌作為真實 AI gate,closeout 不含「Compatibility」表。
test_p3e_mcp_protocol.py 對 2026-07-28 的每條斷言原樣通過;相容層只加測試。test_p3d_*.py 全綠;OAuth 路徑對 epoch/revoke/TTL 的測試是新增,不改舊。test_m03_import_boundary.py forbidden regex 涵蓋 app.mcp.oauth;test_m03d_schema_parity.py forbidden 表加四張 OAuth 表;test_p3e_standalone_mcp.py 加 OAuth route 與 guide route 404。/mcp* 與 /.well-known/oauth-*,不把 Room UI 露出公網。tools/m04a-webchat-preflight/:不在任何 app 打包、CI lint 或 boundary 測試範圍內,但要在 .github/workflows 的 path filter 排除,避免 preflight 改動觸發 app CI。