adventure-table

M04 — 開發設計方針

Phase:M04 — Web Chat MCP Integration & AI Join Kit 本文件是 M04-A~M04-C 的具體實作契約:module、endpoint、schema、文字模板、資料流與接線。「完成後什麼必須為真」在 實作規格.md;驗收方式在 測試指南.md。 Subphase 名稱與順序與另外兩份文件一字不差。

最後更新:2026-09-11


1. 設計目標

  1. M04-A 是量測,不是產品。 測試 server 獨立於 app,目的只有一個:把網頁版 chat 平台(ChatGPT Plus 優先,次選 Claude chat 個人方案)的真實行為變成可引用的記錄與目標平台判定。M04-B 的每個設計決定都指到 M04-A_PREFLIGHT.md 的某一項。
  2. OAuth 是找到 grant 的第二把鑰匙,不是第二套授權。 access token 驗證完只做一件事:得到 grant_id;之後呼叫 P3-D 既有的 resolve_current_scopeTableActorContext 建構,與 Bearer AI Join Token 路徑收斂到同一個函式。
  3. Seat 在 OAuth 之外。 OAuth module 對 Seat/Session/Participant 只讀不寫;有 static 測試與 DB 寫入計數測試雙重把關。一個 authorization 一個 token family 一張 grantclient_id 只是 OAuth client 身分,不是 Seat 授權識別。
  4. Refresh 不是免檢通道。 每次 refresh 都重新走 P3-D resolve_current_scope;refresh token 未過期不構成授權。
  5. 指引與 tool catalog 同源。 GET /mcp/guide、tool description、briefing 都從 _TOOL_DEFINITIONS 與一份指引文字模組產生。
  6. Standalone 邊界不動。 OAuth route 與 guide route 都掛在 app.mcp.router 或其子 router;standalone 不 include。

2. Module / package layout

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

3. M04-A — Web Chat MCP Preflight

3.0 平台階梯

  1. ChatGPT Plus 個人帳號:完整跑 3.3 程序。write tool 若因方案被拒,記錄平台訊息,判定「方案限制不可行」。
  2. Claude chat 個人方案:只在第 1 階不可行時跑同一套程序。
  3. 兩者都不可行 → M04-A_PREFLIGHT.md 結論標 Platform Blocked / Deferred,PROJECT_BRIEF 把 M04-B/M04-C 標延後,下一步進 P4。

不使用 Business/Enterprise/Edu 工作區;官方文件對方案限制的描述與日期記進 PREFLIGHT.md 開頭。

3.1 測試 server 契約

server.py(公開 listener)+ admin.py(loopback listener),依賴只允許 fastapistarletteuvicorn、標準函式庫;不用 mcp SDK,好讓 wire 上的每個 byte 都是我們自己寫的、可記錄的。

公開 endpoint(經 tunnel):

Path 用途
GET /.well-known/oauth-protected-resource RFC 9728;resource 指向 /mcpauthorization_servers 指向自己
GET /.well-known/oauth-authorization-server RFC 8414;列 authorization_endpointtoken_endpointregistration_endpointcode_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 下拉(dmplayer)+ 固定密碼欄 + Submit;記錄 query(client_idredirect_uricode_challengescopestate
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:tsmethodpathqueryheadersbodystatusresponse_bodyrequest 與 response 走同一個 redact()authorizationcookie header;body 與 query 內任何 key 名稱含 tokencodesecretverifierchallenge 的值(含 access_tokenrefresh_tokencodeclient_secret)一律替換為 [redacted:<len>]Location header 內的 code= 參數同樣遮罩。遮罩函式有一條 pytest(tools/m04a-webchat-preflight/test_redact.py)證明 token endpoint 的 response body 記錄不含明文。

3.2 /mcp 行為

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

3.3 Preflight 程序

寫在 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。

3.4 架構結論格式

M04-A_PREFLIGHT.md 最後一節固定六個小標,每個一句結論加證據行號:Target platform(ChatGPT Plus/Claude chat 個人方案/Platform Blocked)、OAuth endpoints requiredProtocol version & methodsCatalog presentation(對照實作規格 2.3 表格)、wait_for_event timeout capBlockers


4. M04-B — Adventure Table Web Chat Integration

本節的細節以 M04-A 結論與目標平台判定為準;標「依 preflight」者在 M04-B 開工時依記錄填定。M04-A 判定 Platform Blocked 時本節延後。

4.1 Schema(web migration 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

4.2 Endpoint

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 部署段。

4.3 Authorize 頁

4.4 Token 驗證路徑(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

4.5 失效聯動

在 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 仍會擋下,聯動只是讓失效立即生效。

4.6 Protocol 相容層(依 preflight)

若 M04-A 記錄顯示目標平台送非 2026-07-28

4.7 Catalog 呈現(依 preflight,對照實作規格 2.3)

4.8 wait_for_event timeout(依 preflight)

WaitEventsInput.timeoutle 上限對 MCP 入口改為 min(60, 實測值);Human UI 的 long-poll endpoint 不動。

2026-09-12 修訂(M04-C 交付):上限改為 120(M04-A 實測值,不超出證據)。app/domain/rooms/ai_tools.pyWaitEventsInput.timeout le=60.0le=120.0default 維持 30;app/api/rooms/table_events.py 的 Human UI Query(le=60.0) 不動。test_m04b_wait_timeout_cap.py 改鎖 120。「連續最多 5 次」是指引層規則(5.1 第 5 節與 5.2 briefing),server 不計數、不強制。

4.9 錯誤碼

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(errorerror_description);authorize 頁的錯誤是雙語 HTML。


5. M04-C — AI Join Kit, Server-hosted Guide & Other Client Compatibility

5.1 app/mcp/guide.py

GUIDE_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 處置

5.2 render_briefing(2026-09-12 修訂:強制流程 + 機器可讀提示)

5.2a stage_unset / next_required_action

5.3 Tool description 加厚

_desc(en, zh) 格式不變;rich_description()_WHEN_TO_USE(每個 tool 手寫雙語「何時用」)、schema 推導的參數/enum/range/default、_availability()(何時被接受)、roles 組成。

5.3a 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]

5.4 get_session_contextbriefing

pre_session 與 active dict 各加 "briefing": render_briefing(role, mode);active dict 另加 stage_unsetnext_required_action(5.2a);pre_session 另加 temporary_instruction。P3-E 對 pre_session 的精確 dict 比對改為含 briefingtemporary_instruction 的精確集合,active 的斷言用 subset,斷言數不減。

5.5 GET /mcp/guide

@router.get("/mcp/guide", response_class=PlainTextResponse)
def mcp_guide(locale: str = "en") -> PlainTextResponse

5.6 server/discover.instructions

Adventure 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。

5.7 AIJoinKit.tsx

export 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,取得前不渲染

5.7a GET /api/mcp/public-origin

@router.get("/api/mcp/public-origin")
async def mcp_public_origin() -> JSONResponse   # {"public_origin": "<origin>" | null}

5.8 Kit 模板(兩 locale)

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 可用。}

5.9 面板接線

LobbyAIDMGrantPanelPlayerAIControlPanelissued !== 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-oncedata-ai-token-oncecopyToken 不動。

5.10 UI copy

lobbyCopy.tssessionCopy.ts 各加 aiJoinKitTitleaiJoinKitHintaiJoinKitCopyaiJoinKitCopiedaiJoinKitDownload;兩 locale 同步。

5.11 其他 client 相容記錄

對非目標平台的網頁版 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」表。


6. 跨 Phase 相容檢查