From 4cf120f3780a71f962f4915555ae55e2f832c18b Mon Sep 17 00:00:00 2001 From: liplus-lin-lay Date: Mon, 20 Apr 2026 22:46:51 +0900 Subject: [PATCH] feat(oauth): auto-open browser + auth-required tool response for device flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.11.0 で導入した device authorization grant の初回認証 UX を修復する。 stderr へのログ出力のみでは Claude Code / Desktop のチャット UI にユーザが 気付けず、ツール呼び出しが 600 秒沈黙したまま expired_token で終わる状態に なっていた。 主な変更: - performOAuthFlow() を phase 1(device code 取得)と phase 2(polling)に 分割し、polling をバックグラウンドに逃がした。 - tool handler は新設した getAccessTokenForToolCall() を経由し、device flow 未完了時は AuthRequiredError 経由で isError=true + user_code / verification URL を含む構造化応答を即座に返す。2 回目以降の同一ツール呼び出しも同じ メッセージを返し(polling は 1 本を共有)、完了すれば次の呼び出しで通常 処理に戻る。 - device authorization 応答受信直後に child_process.spawn で platform 既定の ブラウザを起動する(Windows: cmd /c start、macOS: open、Linux: xdg-open)。 失敗は non-fatal。 - mcp-server/server/index.js と local-mcp/src/index.ts を同じ設計で揃えた。 - manifest.json / package.json / server.json を 0.11.1 に version bump。 - docs/0-requirements.{md,ja.md} に F7.9(自動オープン)/ F7.10(auth-required 応答)を追加。docs/installation.{md,ja.md} の初回認証節を書き換えて v0.11.1 の体験を説明。 - mcp-server/test/auth-required.test.mjs で応答フォーマットの契約テストを 追加(verification_uri_complete 優先 / user_code 必須 / isError=true / retry 指示 / expires_at の有無)。 Refs #207 --- docs/0-requirements.ja.md | 2 + docs/0-requirements.md | 2 + docs/installation.ja.md | 27 +- docs/installation.md | 27 +- local-mcp/src/index.ts | 325 +++++++++++++++++++++--- mcp-server/manifest.json | 2 +- mcp-server/package.json | 2 +- mcp-server/server.json | 4 +- mcp-server/server/index.js | 330 ++++++++++++++++++++++--- mcp-server/test/auth-required.test.mjs | 146 +++++++++++ 10 files changed, 779 insertions(+), 88 deletions(-) create mode 100644 mcp-server/test/auth-required.test.mjs diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index a29177e..3c81f02 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -158,6 +158,8 @@ Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudf | F7.6 | 旧 `GET /oauth/authorize` および `GET /oauth/callback` は **HTTP 410 Gone** を返す(localhost callback flow は v0.11.0 で廃止) | | F7.7 | 保護対象 API ルート(`/mcp`, `/events`)は `Authorization: Bearer ` ヘッダによる独自 token 検証 middleware で認可する | | F7.8 | KV schema は自前設計: `client:{client_id}` / `device:{device_code}` / `user_code:{user_code}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}` | +| F7.9 | ローカルブリッジは device authorization 応答受信直後に `verification_uri_complete`(なければ `verification_uri`)を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) | +| F7.10 | ローカルブリッジは初回ツール呼び出しで device flow が完了していない場合、polling をバックグラウンドに維持したまま、`user_code` / `verification_uri_complete` / `verification_uri` / 残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | **GitHub App 前提条件:** diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 92e461d..04a82ef 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -158,6 +158,8 @@ Worker は OAuth 2.1 Device Authorization Grant を自前実装する(`@cloudf | F7.6 | 旧 `GET /oauth/authorize` および `GET /oauth/callback` は **HTTP 410 Gone** を返す(localhost callback flow は v0.11.0 で廃止) | | F7.7 | 保護対象 API ルート(`/mcp`, `/events`)は `Authorization: Bearer ` ヘッダによる独自 token 検証 middleware で認可する | | F7.8 | KV schema は自前設計: `client:{client_id}` / `device:{device_code}` / `user_code:{user_code}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}` | +| F7.9 | ローカルブリッジは device authorization 応答受信直後に `verification_uri_complete`(なければ `verification_uri`)を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) | +| F7.10 | ローカルブリッジは初回ツール呼び出しで device flow が完了していない場合、polling をバックグラウンドに維持したまま、`user_code` / `verification_uri_complete` / `verification_uri` / 残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | **GitHub App 前提条件:** diff --git a/docs/installation.ja.md b/docs/installation.ja.md index 4207bb4..67f45df 100644 --- a/docs/installation.ja.md +++ b/docs/installation.ja.md @@ -17,17 +17,40 @@ ### 初回認証(OAuth Device Flow) -v0.11.0 以降、MCP クライアントは **OAuth 2.1 Device Authorization Grant (RFC 8628)** で認証します。初回接続時に以下のメッセージが Claude Code の stderr ログに出力されます: +v0.11.0 以降、MCP クライアントは **OAuth 2.1 Device Authorization Grant (RFC 8628)** で認証します。v0.11.1 以降は初回のツール呼び出し時に以下が同時に起きます: + +1. **ブラウザが自動で開きます**(`verification_uri_complete`、コード事前入力済み URL) +2. **ツール呼び出しの応答として `user_code` と URL が即座に返ります**(600 秒待たされません) + +Claude Code / Claude Desktop のチャット上には、おおよそ次のような応答が表示されます: + +``` +OAuth device authorization required. + +Open (code pre-filled): https://github.com/login/device?user_code=WDJB-MJHT + +Or visit https://github.com/login/device and enter the code: + WDJB-MJHT + +Code expires in about 10 minutes. +A browser window should have opened automatically. Retry the same tool call +after approving — subsequent calls will succeed once authorization completes. +``` + +ブラウザで承認するとバックグラウンドのポーリングが完了し、`~/.github-webhook-mcp/oauth-tokens.json` にトークンが保存されます。その後同じツールを呼び直すと通常どおり結果が返ります。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 + +並行して、stderr ログにも同じ情報が出力されます(ログを見たい場合のフォールバック): ``` [github-webhook-mcp] OAuth device authorization required. [github-webhook-mcp] Visit: https://github.com/login/device [github-webhook-mcp] Enter code: WDJB-MJHT [github-webhook-mcp] Or open directly: https://github.com/login/device?user_code=WDJB-MJHT +[github-webhook-mcp] Opening browser for authentication... [github-webhook-mcp] Waiting for approval (expires in 600s)... ``` -ブラウザで `https://github.com/login/device` を開き、表示された 8 文字の `user_code` を入力してください。承認後、自動的にトークンが発行され、`~/.github-webhook-mcp/oauth-tokens.json` に保存されます。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 +> **ブラウザ自動オープンが失敗した場合:** 応答と stderr ログに URL がそのまま残るので、手動でコピーしてブラウザに貼り付けてください。Windows では `start`、macOS では `open`、Linux では `xdg-open` を使用します。 > **旧バージョンからの移行:** v0.10.x 以前の localhost callback flow を使っていた場合、初回起動時に旧トークンファイルが自動削除され、migration 通知が stderr に出力されます。表示される device code を入力して一度だけ再認証してください。 diff --git a/docs/installation.md b/docs/installation.md index 3a1c501..b33d5b3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -17,17 +17,40 @@ ### 初回認証(OAuth Device Flow) -v0.11.0 以降、MCP クライアントは **OAuth 2.1 Device Authorization Grant (RFC 8628)** で認証します。初回接続時に以下のメッセージが Claude Code の stderr ログに出力されます: +v0.11.0 以降、MCP クライアントは **OAuth 2.1 Device Authorization Grant (RFC 8628)** で認証します。v0.11.1 以降は初回のツール呼び出し時に以下が同時に起きます: + +1. **ブラウザが自動で開きます**(`verification_uri_complete`、コード事前入力済み URL) +2. **ツール呼び出しの応答として `user_code` と URL が即座に返ります**(600 秒待たされません) + +Claude Code / Claude Desktop のチャット上には、おおよそ次のような応答が表示されます: + +``` +OAuth device authorization required. + +Open (code pre-filled): https://github.com/login/device?user_code=WDJB-MJHT + +Or visit https://github.com/login/device and enter the code: + WDJB-MJHT + +Code expires in about 10 minutes. +A browser window should have opened automatically. Retry the same tool call +after approving — subsequent calls will succeed once authorization completes. +``` + +ブラウザで承認するとバックグラウンドのポーリングが完了し、`~/.github-webhook-mcp/oauth-tokens.json` にトークンが保存されます。その後同じツールを呼び直すと通常どおり結果が返ります。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 + +並行して、stderr ログにも同じ情報が出力されます(ログを見たい場合のフォールバック): ``` [github-webhook-mcp] OAuth device authorization required. [github-webhook-mcp] Visit: https://github.com/login/device [github-webhook-mcp] Enter code: WDJB-MJHT [github-webhook-mcp] Or open directly: https://github.com/login/device?user_code=WDJB-MJHT +[github-webhook-mcp] Opening browser for authentication... [github-webhook-mcp] Waiting for approval (expires in 600s)... ``` -ブラウザで `https://github.com/login/device` を開き、表示された 8 文字の `user_code` を入力してください。承認後、自動的にトークンが発行され、`~/.github-webhook-mcp/oauth-tokens.json` に保存されます。以降の起動では保存済みトークンが再利用され、期限切れ前に自動でリフレッシュされます。 +> **ブラウザ自動オープンが失敗した場合:** 応答と stderr ログに URL がそのまま残るので、手動でコピーしてブラウザに貼り付けてください。Windows では `start`、macOS では `open`、Linux では `xdg-open` を使用します。 > **旧バージョンからの移行:** v0.10.x 以前の localhost callback flow を使っていた場合、初回起動時に旧トークンファイルが自動削除され、migration 通知が stderr に出力されます。表示される device code を入力して一度だけ再認証してください。 diff --git a/local-mcp/src/index.ts b/local-mcp/src/index.ts index 5a6f457..4f6ed62 100644 --- a/local-mcp/src/index.ts +++ b/local-mcp/src/index.ts @@ -19,8 +19,9 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import WebSocket from "ws"; import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; -import { homedir } from "node:os"; +import { homedir, platform as osPlatform } from "node:os"; import { join } from "node:path"; +import { spawn } from "node:child_process"; const WORKER_URL = process.env.WEBHOOK_WORKER_URL || "https://github-webhook.smgjp.com"; const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0"; @@ -73,6 +74,21 @@ let _refreshLock: Promise | null = null; let _deviceFlowLock: Promise | null = null; let _legacyMigrationNotified = false; +/** + * Tracks the in-flight device authorization so tool calls can return an + * auth-required response immediately (instead of blocking for ~600s) while + * polling continues in the background. Cleared on success or failure so the + * next tool call after expiry starts a fresh device code. + */ +interface PendingDeviceAuth { + user_code: string; + verification_uri: string; + verification_uri_complete: string | null; + expires_at: number | undefined; +} +let _pendingDeviceAuth: PendingDeviceAuth | null = null; +let _pendingDeviceAuthError: string | null = null; + // ── OAuth Discovery & Registration ─────────────────────────────────────────── interface OAuthMetadata { @@ -216,6 +232,49 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Best-effort platform-native browser launcher for the device-flow + * verification URL. Failures are non-fatal: we still surface the URL on + * stderr / in the tool response so the user can open it manually. + */ +function openBrowser(url: string): void { + if (!url || typeof url !== "string") return; + try { + const plat = osPlatform(); + let command: string; + let args: string[]; + const options = { detached: true, stdio: "ignore" as const }; + + if (plat === "win32") { + // `start` is a cmd.exe builtin. The empty "" argument is the window + // title placeholder expected when the URL is quoted. + command = "cmd.exe"; + args = ["/c", "start", "", url]; + } else if (plat === "darwin") { + command = "open"; + args = [url]; + } else { + command = "xdg-open"; + args = [url]; + } + + const child = spawn(command, args, options); + child.on("error", (err: Error) => { + process.stderr.write( + `[github-webhook-mcp] Failed to auto-open browser (${err.message || err}). ` + + `Open this URL manually: ${url}\n`, + ); + }); + if (typeof child.unref === "function") child.unref(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write( + `[github-webhook-mcp] Failed to auto-open browser (${msg}). ` + + `Open this URL manually: ${url}\n`, + ); + } +} + async function requestDeviceAuthorization( metadata: OAuthMetadata, client: ClientRegistration, @@ -320,24 +379,44 @@ async function pollForDeviceToken( ); } +/** + * Start a device authorization flow: obtain device_code/user_code, surface the + * verification URL (stderr + auto-open browser), and kick off a background + * poll. Callers that await the returned promise will block until the user + * approves — this is what the WebSocket bootstrap does. Callers that only + * need the deviceAuth metadata (for a non-blocking tool response) can read + * `_pendingDeviceAuth` as soon as this function resolves past the phase-1 + * await; see `getAccessTokenForToolCall()`. + * + * Serialization: _deviceFlowLock ensures that concurrent callers (WebSocket + * bootstrap racing the first tool call) share a single device code rather + * than each launching their own approval prompt. + */ async function performOAuthFlow(): Promise { - // Serialize concurrent device-flow starts (e.g. WebSocket boot racing the - // first tool call). Whichever caller wins runs the flow; others await. if (_deviceFlowLock) { return await _deviceFlowLock; } - _deviceFlowLock = (async (): Promise => { + // Phase 1: obtain the device code. This is fast (one HTTP round-trip) and + // we surface the verification URL as soon as it returns. + const startPromise: Promise<{ + metadata: OAuthMetadata; + client: ClientRegistration; + deviceAuth: DeviceAuthorizationResponse; + }> = (async () => { await checkLegacyTokensMigration(); const metadata = await discoverOAuthMetadata(); const client = await ensureClientRegistration(metadata); - const deviceAuth = await requestDeviceAuthorization(metadata, client); - // stdio MCP clients have no UI surface, so we publish the user_code and - // verification URI on stderr where Claude Code surfaces the log. const complete = deviceAuth.verification_uri_complete; + const browserUrl = complete || deviceAuth.verification_uri; + + // stdio MCP clients have no UI surface of their own, so we publish the + // user_code and verification URI on stderr where the host logs land. The + // auth-required tool response (below) is the primary channel for Claude + // Code / Desktop; stderr is the fallback surface. const lines: string[] = [ "", "[github-webhook-mcp] OAuth device authorization required.", @@ -348,38 +427,78 @@ async function performOAuthFlow(): Promise { lines.push(`[github-webhook-mcp] Or open directly: ${complete}`); } lines.push( + `[github-webhook-mcp] Opening browser for authentication...`, `[github-webhook-mcp] Waiting for approval (expires in ${deviceAuth.expires_in ?? "?"}s)...`, "", ); process.stderr.write(lines.join("\n")); - const tokenData = await pollForDeviceToken(metadata, client, deviceAuth); + // Best-effort browser auto-open. Failures are logged to stderr but do + // not abort the flow — the URL is still available in the tool response. + openBrowser(browserUrl); - if (!tokenData.access_token) { - throw new Error( - `Token response missing access_token: ${JSON.stringify(tokenData).slice(0, 200)}`, - ); - } + const expiresAt = deviceAuth.expires_in + ? Date.now() + Number(deviceAuth.expires_in) * 1000 + : undefined; - const tokens: TokenData = { - flow: TOKENS_FLOW_MARKER, - access_token: tokenData.access_token, - refresh_token: tokenData.refresh_token, - expires_at: tokenData.expires_in - ? Date.now() + tokenData.expires_in * 1000 - : undefined, + _pendingDeviceAuth = { + user_code: deviceAuth.user_code, + verification_uri: deviceAuth.verification_uri, + verification_uri_complete: complete ?? null, + expires_at: expiresAt, }; + _pendingDeviceAuthError = null; - await saveTokens(tokens); - process.stderr.write("[github-webhook-mcp] OAuth device authorization complete.\n"); - return tokens; + return { metadata, client, deviceAuth }; })(); - try { - return await _deviceFlowLock; - } finally { - _deviceFlowLock = null; - } + // Phase 2: poll in the background (still inside the same lock promise so + // that simultaneous callers await one shared flow). Errors are recorded + // and re-thrown so awaiting callers see them. + _deviceFlowLock = (async (): Promise => { + try { + const { metadata, client, deviceAuth } = await startPromise; + const tokenData = await pollForDeviceToken(metadata, client, deviceAuth); + + if (!tokenData.access_token) { + throw new Error( + `Token response missing access_token: ${JSON.stringify(tokenData).slice(0, 200)}`, + ); + } + + const tokens: TokenData = { + flow: TOKENS_FLOW_MARKER, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: tokenData.expires_in + ? Date.now() + tokenData.expires_in * 1000 + : undefined, + }; + + await saveTokens(tokens); + _cachedTokens = tokens; + _pendingDeviceAuth = null; + _pendingDeviceAuthError = null; + process.stderr.write("[github-webhook-mcp] OAuth device authorization complete.\n"); + return tokens; + } catch (err) { + _pendingDeviceAuth = null; + _pendingDeviceAuthError = err instanceof Error ? err.message : String(err); + throw err; + } + })(); + + const lockPromise = _deviceFlowLock; + lockPromise.finally(() => { + if (_deviceFlowLock === lockPromise) { + _deviceFlowLock = null; + } + }); + + // Wait for phase 1 so the caller sees _pendingDeviceAuth populated (or the + // startPromise's error) before we return the outer promise. + await startPromise; + return lockPromise; } /** @@ -477,10 +596,94 @@ async function getAccessToken(): Promise { return _cachedTokens.access_token; } -/** Build common headers with OAuth Bearer auth */ -async function authHeaders(extra?: Record): Promise> { +/** + * Sentinel thrown by `getAccessTokenForToolCall()` when the device flow is + * still pending approval. The tool handler catches this and returns a + * structured auth-required response to the MCP client without blocking on + * the poll loop. + */ +class AuthRequiredError extends Error { + pending: PendingDeviceAuth; + constructor(pending: PendingDeviceAuth, note?: string) { + super(note || "OAuth device authorization required"); + this.name = "AuthRequiredError"; + this.pending = pending; + } +} + +/** + * Like getAccessToken(), but never blocks on the device-flow poll. If no + * tokens are available, it starts the flow (if not already running) and + * throws an AuthRequiredError carrying the current device-code details so + * the caller can surface them in the tool response immediately. + */ +async function getAccessTokenForToolCall(): Promise { + if (!_cachedTokens) { + _cachedTokens = await loadTokens(); + } + + if (_cachedTokens) { + const REFRESH_BUFFER_MS = 5 * 60_000; + if (!_cachedTokens.expires_at || _cachedTokens.expires_at > Date.now() + REFRESH_BUFFER_MS) { + return _cachedTokens.access_token; + } + + if (_cachedTokens.refresh_token) { + // Refresh is a single round-trip; blocking a tool call here is fine. + if (!_refreshLock) { + _refreshLock = refreshAccessToken(_cachedTokens.refresh_token); + } + try { + _cachedTokens = await _refreshLock; + return _cachedTokens.access_token; + } catch (err) { + console.error( + "[oauth] refresh failed, starting device flow in background:", + (err as Error).message || err, + ); + } finally { + _refreshLock = null; + } + } + } + + // No usable tokens. Start the device flow if it isn't already running; + // either way, hand the caller the current pending device_code details. + if (!_pendingDeviceAuth && !_deviceFlowLock) { + // Swallow the outer promise; the background poll settles via + // _deviceFlowLock. Errors during phase 1 (e.g. network failure to + // /oauth/device_authorization) propagate via _pendingDeviceAuthError. + void performOAuthFlow().catch((err) => { + console.error( + "[oauth] device flow background poll ended with error:", + (err as Error).message || err, + ); + }); + } + + // Give phase 1 a little time to finish. Device authorization is one HTTP + // round-trip; normally sub-second, so 15s is a generous ceiling. + const phase1Deadline = Date.now() + 15_000; + while (!_pendingDeviceAuth && !_pendingDeviceAuthError && Date.now() < phase1Deadline) { + await sleep(100); + } + + if (_pendingDeviceAuthError && !_pendingDeviceAuth) { + throw new Error(`OAuth device flow failed: ${_pendingDeviceAuthError}`); + } + + if (_pendingDeviceAuth) { + throw new AuthRequiredError(_pendingDeviceAuth); + } + + throw new Error("OAuth device flow did not produce a verification URL in time."); +} + +async function buildAuthHeaders( + token: string | undefined, + extra?: Record, +): Promise> { const h: Record = { ...extra }; - const token = await getAccessToken(); if (token) h["Authorization"] = `Bearer ${token}`; return h; } @@ -489,12 +692,12 @@ async function authHeaders(extra?: Record): Promise { +async function getSessionIdWithToken(token: string): Promise { if (_sessionId) return _sessionId; const res = await fetch(`${WORKER_URL}/mcp`, { method: "POST", - headers: await authHeaders({ + headers: await buildAuthHeaders(token, { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", }), @@ -514,12 +717,17 @@ async function getSessionId(): Promise { return _sessionId; } -async function callRemoteTool(name: string, args: Record, _retried = false): Promise<{ content: Array<{ type: string; text: string }> }> { - const sessionId = await getSessionId(); +async function callRemoteToolWithToken( + name: string, + args: Record, + token: string, + _retried = false, +): Promise<{ content: Array<{ type: string; text: string }> }> { + const sessionId = await getSessionIdWithToken(token); const res = await fetch(`${WORKER_URL}/mcp`, { method: "POST", - headers: await authHeaders({ + headers: await buildAuthHeaders(token, { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "mcp-session-id": sessionId, @@ -532,14 +740,16 @@ async function callRemoteTool(name: string, args: Record, _retr }), }); - // 401 = token expired or revoked, re-authenticate and retry once + // 401 = token expired or revoked. Clear session + token cache and retry + // once with a freshly acquired token (refresh or full flow). if (res.status === 401) { if (_retried) { return { content: [{ type: "text", text: "Authentication failed after retry. Please re-authenticate." }] }; } _cachedTokens = null; _sessionId = null; - return callRemoteTool(name, args, true); + const freshToken = await getAccessTokenForToolCall(); + return callRemoteToolWithToken(name, args, freshToken, true); } const text = await res.text(); @@ -552,7 +762,7 @@ async function callRemoteTool(name: string, args: Record, _retr // Session expired — retry once with a fresh session if ((json.error.code === -32600 || json.error.code === -32001) && !_retried) { _sessionId = null; - return callRemoteTool(name, args, true); + return callRemoteToolWithToken(name, args, token, true); } return { content: [{ type: "text", text: JSON.stringify(json.error) }] }; } @@ -635,14 +845,49 @@ const TOOLS = [ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); +function formatAuthRequiredResponse(pending: PendingDeviceAuth): string { + const parts: string[] = []; + parts.push("OAuth device authorization required."); + parts.push(""); + if (pending.verification_uri_complete) { + parts.push(`Open (code pre-filled): ${pending.verification_uri_complete}`); + parts.push(""); + parts.push(`Or visit ${pending.verification_uri} and enter the code:`); + } else { + parts.push(`Visit: ${pending.verification_uri}`); + parts.push("Enter the code:"); + } + parts.push(` ${pending.user_code}`); + parts.push(""); + if (pending.expires_at) { + const remainingMs = pending.expires_at - Date.now(); + if (remainingMs > 0) { + const mins = Math.max(1, Math.round(remainingMs / 60_000)); + parts.push(`Code expires in about ${mins} minute${mins === 1 ? "" : "s"}.`); + } + } + parts.push( + "A browser window should have opened automatically. " + + "Retry the same tool call after approving — subsequent calls will succeed once authorization completes.", + ); + return parts.join("\n"); +} + mcp.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { - const result = await callRemoteTool(name, args ?? {}); + const token = await getAccessTokenForToolCall(); + const result = await callRemoteToolWithToken(name, args ?? {}, token); // First successful tool call confirms OAuth is working markOAuthEstablished(); return result; } catch (err) { + if (err instanceof AuthRequiredError) { + return { + content: [{ type: "text", text: formatAuthRequiredResponse(err.pending) }], + isError: true, + }; + } return { content: [{ type: "text", text: `Failed to reach worker: ${err}` }], isError: true, diff --git a/mcp-server/manifest.json b/mcp-server/manifest.json index b752438..6cd8ee6 100644 --- a/mcp-server/manifest.json +++ b/mcp-server/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "github-webhook-mcp", "display_name": "GitHub Webhook MCP", - "version": "1.0.0", + "version": "0.11.1", "description": "Real-time GitHub webhook notifications via Cloudflare Worker + Durable Object.", "long_description": "GitHub Webhook MCP bridges GitHub webhook events to Claude via a Cloudflare Worker backend. Events are stored in a Durable Object with SQLite, queried through MCP tools, and optionally pushed in real-time via SSE channel notifications. No local webhook receiver needed — just point your GitHub webhook at the Worker URL.", "author": { diff --git a/mcp-server/package.json b/mcp-server/package.json index faf89d3..d7f0872 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "github-webhook-mcp", - "version": "0.8.2", + "version": "0.11.1", "description": "MCP server bridging GitHub webhooks via Cloudflare Worker", "type": "module", "bin": { diff --git a/mcp-server/server.json b/mcp-server/server.json index fb6fcaa..90efea6 100644 --- a/mcp-server/server.json +++ b/mcp-server/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.Liplus-Project/github-webhook-mcp", "description": "MCP server bridging GitHub webhooks via Cloudflare Worker for real-time event streaming", - "version": "0.8.2", + "version": "0.11.1", "repository": { "url": "https://github.com/Liplus-Project/github-webhook-mcp", "source": "github", @@ -13,7 +13,7 @@ { "registryType": "npm", "identifier": "github-webhook-mcp", - "version": "0.8.2", + "version": "0.11.1", "runtimeHint": "npx", "transport": { "type": "stdio" diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index f060f61..bf659ee 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -19,9 +19,10 @@ import { CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; -import { homedir } from "node:os"; +import { homedir, platform as osPlatform } from "node:os"; import { join } from "node:path"; import { createRequire } from "node:module"; +import { spawn } from "node:child_process"; import WebSocketClient from "ws"; const require = createRequire(import.meta.url); @@ -72,6 +73,15 @@ let _refreshLock = null; let _deviceFlowLock = null; let _legacyMigrationNotified = false; +/** + * Tracks the in-flight device authorization so tool calls can return an + * auth-required response immediately (instead of blocking for ~600s) while + * polling continues in the background. Cleared on success or failure so the + * next tool call after expiry starts a fresh device code. + */ +let _pendingDeviceAuth = null; +let _pendingDeviceAuthError = null; + // ── OAuth Discovery & Registration ─────────────────────────────────────────── async function discoverOAuthMetadata() { @@ -182,6 +192,49 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Best-effort platform-native browser launcher for the device-flow + * verification URL. Failures are non-fatal: we still surface the URL on + * stderr / in the tool response so the user can open it manually. + */ +function openBrowser(url) { + if (!url || typeof url !== "string") return; + try { + const plat = osPlatform(); + let command; + let args; + let options = { detached: true, stdio: "ignore" }; + + if (plat === "win32") { + // `start` is a cmd.exe builtin, not a standalone executable. + // The empty "" argument is the window title placeholder expected by + // `start` when the URL is quoted. + command = "cmd.exe"; + args = ["/c", "start", "", url]; + } else if (plat === "darwin") { + command = "open"; + args = [url]; + } else { + command = "xdg-open"; + args = [url]; + } + + const child = spawn(command, args, options); + child.on("error", (err) => { + process.stderr.write( + `[github-webhook-mcp] Failed to auto-open browser (${err.message || err}). ` + + `Open this URL manually: ${url}\n`, + ); + }); + if (typeof child.unref === "function") child.unref(); + } catch (err) { + process.stderr.write( + `[github-webhook-mcp] Failed to auto-open browser (${err && err.message ? err.message : err}). ` + + `Open this URL manually: ${url}\n`, + ); + } +} + async function requestDeviceAuthorization(metadata, client) { const endpoint = metadata.device_authorization_endpoint || @@ -279,24 +332,40 @@ async function pollForDeviceToken(metadata, client, deviceAuth) { ); } +/** + * Start a device authorization flow: obtain device_code/user_code, surface the + * verification URL (stderr + auto-open browser), and kick off a background + * poll. Callers that await the returned promise will block until the user + * approves — this is what the WebSocket bootstrap does. Callers that only + * need the deviceAuth metadata (for a non-blocking tool response) can read + * `_pendingDeviceAuth` as soon as this function resolves past the await + * below; see `getAccessTokenOrPendingAuth()`. + * + * Serialization: _deviceFlowLock ensures that concurrent callers (WebSocket + * bootstrap racing the first tool call) share a single device code rather + * than each launching their own approval prompt. + */ async function performOAuthFlow() { - // Serialize concurrent device-flow starts (e.g. WebSocket boot racing the - // first tool call). Whichever caller wins runs the flow; others await. if (_deviceFlowLock) { return await _deviceFlowLock; } - _deviceFlowLock = (async () => { + // Phase 1: obtain the device code. This is fast (one HTTP round-trip) and + // we surface the verification URL as soon as it returns. + const startPromise = (async () => { await checkLegacyTokensMigration(); const metadata = await discoverOAuthMetadata(); const client = await ensureClientRegistration(metadata); - const deviceAuth = await requestDeviceAuthorization(metadata, client); - // stdio MCP clients have no UI surface, so we publish the user_code and - // verification URI on stderr where Claude Code surfaces the log. const complete = deviceAuth.verification_uri_complete; + const browserUrl = complete || deviceAuth.verification_uri; + + // stdio MCP clients have no UI surface of their own, so we publish the + // user_code and verification URI on stderr where the host logs land. The + // auth-required tool response (below) is the primary channel for Claude + // Code / Desktop; stderr is the fallback surface. const lines = [ "", "[github-webhook-mcp] OAuth device authorization required.", @@ -307,38 +376,81 @@ async function performOAuthFlow() { lines.push(`[github-webhook-mcp] Or open directly: ${complete}`); } lines.push( + `[github-webhook-mcp] Opening browser for authentication...`, `[github-webhook-mcp] Waiting for approval (expires in ${deviceAuth.expires_in || "?"}s)...`, "", ); process.stderr.write(lines.join("\n")); - const tokenData = await pollForDeviceToken(metadata, client, deviceAuth); + // Best-effort browser auto-open. Failures are logged to stderr but do + // not abort the flow — the URL is still available in the tool response. + openBrowser(browserUrl); - if (!tokenData.access_token) { - throw new Error( - `Token response missing access_token: ${JSON.stringify(tokenData).slice(0, 200)}`, - ); - } + const expiresAt = deviceAuth.expires_in + ? Date.now() + Number(deviceAuth.expires_in) * 1000 + : undefined; - const tokens = { - flow: TOKENS_FLOW_MARKER, - access_token: tokenData.access_token, - refresh_token: tokenData.refresh_token, - expires_at: tokenData.expires_in - ? Date.now() + tokenData.expires_in * 1000 - : undefined, + _pendingDeviceAuth = { + user_code: deviceAuth.user_code, + verification_uri: deviceAuth.verification_uri, + verification_uri_complete: complete || null, + expires_at: expiresAt, }; + _pendingDeviceAuthError = null; - await saveTokens(tokens); - process.stderr.write("[github-webhook-mcp] OAuth device authorization complete.\n"); - return tokens; + return { metadata, client, deviceAuth }; })(); - try { - return await _deviceFlowLock; - } finally { - _deviceFlowLock = null; - } + // Phase 2: poll in the background (still inside the same lock promise so + // that simultaneous callers await one shared flow). Errors are recorded + // and re-thrown so awaiting callers see them. + _deviceFlowLock = (async () => { + try { + const { metadata, client, deviceAuth } = await startPromise; + const tokenData = await pollForDeviceToken(metadata, client, deviceAuth); + + if (!tokenData.access_token) { + throw new Error( + `Token response missing access_token: ${JSON.stringify(tokenData).slice(0, 200)}`, + ); + } + + const tokens = { + flow: TOKENS_FLOW_MARKER, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: tokenData.expires_in + ? Date.now() + tokenData.expires_in * 1000 + : undefined, + }; + + await saveTokens(tokens); + _cachedTokens = tokens; + _pendingDeviceAuth = null; + _pendingDeviceAuthError = null; + process.stderr.write("[github-webhook-mcp] OAuth device authorization complete.\n"); + return tokens; + } catch (err) { + _pendingDeviceAuth = null; + _pendingDeviceAuthError = err && err.message ? err.message : String(err); + throw err; + } + })(); + + // Make sure the caller-visible promise settles deterministically when the + // background poll finishes (or errors). Don't await inside the lock itself; + // other callers may want to see _pendingDeviceAuth without blocking. + const lockPromise = _deviceFlowLock; + lockPromise.finally(() => { + if (_deviceFlowLock === lockPromise) { + _deviceFlowLock = null; + } + }); + + // Wait for phase 1 so the caller sees _pendingDeviceAuth populated (or the + // startPromise's error) before we return the outer promise. + await startPromise; + return lockPromise; } async function refreshAccessToken(refreshToken) { @@ -425,10 +537,111 @@ async function getAccessToken() { return _cachedTokens.access_token; } -/** Build common headers with OAuth Bearer auth */ -async function authHeaders(extra) { +/** + * Sentinel thrown by `getAccessTokenForToolCall()` when the device flow is + * still pending approval. The tool handler catches this and returns a + * structured auth-required response to the MCP client without blocking on + * the poll loop. + */ +class AuthRequiredError extends Error { + constructor(pending, note) { + super(note || "OAuth device authorization required"); + this.name = "AuthRequiredError"; + this.pending = pending; + } +} + +/** + * Like getAccessToken(), but never blocks on the device-flow poll. If no + * tokens are available, it starts the flow (if not already running) and + * throws an AuthRequiredError carrying the current device-code details so + * the caller can surface them in the tool response immediately. + */ +async function getAccessTokenForToolCall() { + if (!_cachedTokens) { + _cachedTokens = await loadTokens(); + } + + if (_cachedTokens) { + const REFRESH_BUFFER_MS = 5 * 60_000; + if (!_cachedTokens.expires_at || _cachedTokens.expires_at > Date.now() + REFRESH_BUFFER_MS) { + return _cachedTokens.access_token; + } + + if (_cachedTokens.refresh_token) { + // Refresh synchronously — refresh calls are one round-trip, unlike + // the full device flow, so blocking a tool call here is fine. + if (!_refreshLock) { + _refreshLock = refreshAccessToken(_cachedTokens.refresh_token); + } + try { + _cachedTokens = await _refreshLock; + return _cachedTokens.access_token; + } catch (err) { + console.error( + "[oauth] refresh failed, starting device flow in background:", + err.message || err, + ); + } finally { + _refreshLock = null; + } + } + } + + // No usable tokens. Start the device flow if it isn't already running; + // either way, hand the caller the current pending device_code details. + if (!_pendingDeviceAuth && !_deviceFlowLock) { + // Swallow the outer promise; the background poll settles via + // _deviceFlowLock. Errors during phase 1 (e.g. network failure to + // /oauth/device_authorization) propagate synchronously below. + performOAuthFlow().catch((err) => { + // Already captured into _pendingDeviceAuthError; log for operators. + console.error("[oauth] device flow background poll ended with error:", err.message || err); + }); + + // Wait briefly (phase 1 is a single HTTP round-trip) so _pendingDeviceAuth + // is populated before we throw — but never block for the full poll. + // performOAuthFlow() only returns once phase 1 has resolved, so we can + // await the lock promise safely. The lock is created synchronously inside + // performOAuthFlow(), so it's guaranteed to be non-null here. + if (_deviceFlowLock) { + try { + // Awaiting the lock resolves when polling finishes. We instead await + // via a tick to give startPromise time to populate _pendingDeviceAuth. + await Promise.race([ + // Let phase 1 populate _pendingDeviceAuth. + new Promise((resolve) => setImmediate(resolve)), + // If phase 1 fails fast, the lock rejects and we re-throw. + _deviceFlowLock.then(() => undefined, (err) => { throw err; }), + ]); + } catch (err) { + throw err; + } + } + } + + // Give phase 1 a little more time to finish (device_authorization request + // is a single round-trip — normally sub-second). Poll the state briefly + // so we return a populated auth-required response instead of an empty one. + const phase1Deadline = Date.now() + 15_000; + while (!_pendingDeviceAuth && !_pendingDeviceAuthError && Date.now() < phase1Deadline) { + await sleep(100); + } + + if (_pendingDeviceAuthError && !_pendingDeviceAuth) { + throw new Error(`OAuth device flow failed: ${_pendingDeviceAuthError}`); + } + + if (_pendingDeviceAuth) { + throw new AuthRequiredError(_pendingDeviceAuth); + } + + // Phase 1 timed out — surface as a regular error. + throw new Error("OAuth device flow did not produce a verification URL in time."); +} + +async function buildAuthHeaders(token, extra) { const h = { ...extra }; - const token = await getAccessToken(); if (token) h["Authorization"] = `Bearer ${token}`; return h; } @@ -437,12 +650,12 @@ async function authHeaders(extra) { let _sessionId = null; -async function getSessionId() { +async function getSessionIdWithToken(token) { if (_sessionId) return _sessionId; const res = await fetch(`${WORKER_URL}/mcp`, { method: "POST", - headers: await authHeaders({ + headers: await buildAuthHeaders(token, { "Content-Type": "application/json", Accept: "application/json, text/event-stream", }), @@ -462,12 +675,12 @@ async function getSessionId() { return _sessionId; } -async function callRemoteTool(name, args, _retried = false) { - const sessionId = await getSessionId(); +async function callRemoteToolWithToken(name, args, token, _retried = false) { + const sessionId = await getSessionIdWithToken(token); const res = await fetch(`${WORKER_URL}/mcp`, { method: "POST", - headers: await authHeaders({ + headers: await buildAuthHeaders(token, { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "mcp-session-id": sessionId, @@ -480,14 +693,16 @@ async function callRemoteTool(name, args, _retried = false) { }), }); - // 401 = token expired or revoked, re-authenticate and retry once + // 401 = token expired or revoked. Clear session + token cache and retry + // once with a freshly acquired token (refresh or full flow). if (res.status === 401) { if (_retried) { return { content: [{ type: "text", text: "Authentication failed after retry. Please re-authenticate." }] }; } _cachedTokens = null; _sessionId = null; - return callRemoteTool(name, args, true); + const freshToken = await getAccessTokenForToolCall(); + return callRemoteToolWithToken(name, args, freshToken, true); } const text = await res.text(); @@ -500,7 +715,7 @@ async function callRemoteTool(name, args, _retried = false) { // Session expired — retry once with a fresh session if ((json.error.code === -32600 || json.error.code === -32001) && !_retried) { _sessionId = null; - return callRemoteTool(name, args, true); + return callRemoteToolWithToken(name, args, token, true); } return { content: [{ type: "text", text: JSON.stringify(json.error) }] }; } @@ -610,14 +825,49 @@ const TOOLS = [ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); +function formatAuthRequiredResponse(pending) { + const parts = []; + parts.push("OAuth device authorization required."); + parts.push(""); + if (pending.verification_uri_complete) { + parts.push(`Open (code pre-filled): ${pending.verification_uri_complete}`); + parts.push(""); + parts.push(`Or visit ${pending.verification_uri} and enter the code:`); + } else { + parts.push(`Visit: ${pending.verification_uri}`); + parts.push("Enter the code:"); + } + parts.push(` ${pending.user_code}`); + parts.push(""); + if (pending.expires_at) { + const remainingMs = pending.expires_at - Date.now(); + if (remainingMs > 0) { + const mins = Math.max(1, Math.round(remainingMs / 60_000)); + parts.push(`Code expires in about ${mins} minute${mins === 1 ? "" : "s"}.`); + } + } + parts.push( + "A browser window should have opened automatically. " + + "Retry the same tool call after approving — subsequent calls will succeed once authorization completes.", + ); + return parts.join("\n"); +} + server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args } = req.params; try { - const result = await callRemoteTool(name, args ?? {}); + const token = await getAccessTokenForToolCall(); + const result = await callRemoteToolWithToken(name, args ?? {}, token); // First successful tool call confirms OAuth is working markOAuthEstablished(); return result; } catch (err) { + if (err instanceof AuthRequiredError) { + return { + content: [{ type: "text", text: formatAuthRequiredResponse(err.pending) }], + isError: true, + }; + } return { content: [{ type: "text", text: `Failed to reach worker: ${err}` }], isError: true, diff --git a/mcp-server/test/auth-required.test.mjs b/mcp-server/test/auth-required.test.mjs new file mode 100644 index 0000000..e95a452 --- /dev/null +++ b/mcp-server/test/auth-required.test.mjs @@ -0,0 +1,146 @@ +/** + * Black-box contract tests for the v0.11.1 device-flow UX response shape. + * + * The behaviour exercised here is the "auth-required" tool result that the + * MCP client returns when a tool call arrives before the user has approved + * the device code (see mcp-server/server/index.js :: formatAuthRequiredResponse + * and its TypeScript twin in local-mcp/src/index.ts). + * + * We re-implement the user-visible contract inline because the module cannot + * be imported without starting an MCP server (top-level await on mcp.connect). + * The assertions cover the invariants that the Claude Code / Desktop UX + * depends on: + * - the response surfaces a clickable URL (verification_uri_complete + * preferred over verification_uri) + * - the user_code is always included, even when the complete URL is present + * - the response is marked isError=true so hosts render it as a failed tool + * call the user can retry + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +/** Mirrors formatAuthRequiredResponse() in server/index.js. */ +function formatAuthRequiredResponse(pending) { + const parts = []; + parts.push("OAuth device authorization required."); + parts.push(""); + if (pending.verification_uri_complete) { + parts.push(`Open (code pre-filled): ${pending.verification_uri_complete}`); + parts.push(""); + parts.push(`Or visit ${pending.verification_uri} and enter the code:`); + } else { + parts.push(`Visit: ${pending.verification_uri}`); + parts.push("Enter the code:"); + } + parts.push(` ${pending.user_code}`); + parts.push(""); + if (pending.expires_at) { + const remainingMs = pending.expires_at - Date.now(); + if (remainingMs > 0) { + const mins = Math.max(1, Math.round(remainingMs / 60_000)); + parts.push(`Code expires in about ${mins} minute${mins === 1 ? "" : "s"}.`); + } + } + parts.push( + "A browser window should have opened automatically. " + + "Retry the same tool call after approving — subsequent calls will succeed once authorization completes.", + ); + return parts.join("\n"); +} + +/** Shape the tool handler returns when the device flow is pending. */ +function buildAuthRequiredToolResult(pending) { + return { + content: [{ type: "text", text: formatAuthRequiredResponse(pending) }], + isError: true, + }; +} + +test("auth-required response prefers verification_uri_complete and still includes the raw code", () => { + const pending = { + user_code: "WDJB-MJHT", + verification_uri: "https://github.com/login/device", + verification_uri_complete: + "https://github.com/login/device?user_code=WDJB-MJHT", + expires_at: Date.now() + 600_000, + }; + const result = buildAuthRequiredToolResult(pending); + + assert.equal(result.isError, true); + assert.equal(result.content.length, 1); + const text = result.content[0].text; + + // Both surfaces must be present: the preferred pre-filled URL AND the raw + // code (some hosts strip hyperlinks or trim long URLs). + assert.ok( + text.includes(pending.verification_uri_complete), + "expected response to include verification_uri_complete", + ); + assert.ok(text.includes(pending.user_code), "expected response to include user_code"); + assert.ok( + text.includes("OAuth device authorization required"), + "expected response to start with the OAuth required header", + ); +}); + +test("auth-required response falls back to verification_uri when complete URL is absent", () => { + const pending = { + user_code: "WDJB-MJHT", + verification_uri: "https://github.com/login/device", + verification_uri_complete: null, + expires_at: Date.now() + 600_000, + }; + const result = buildAuthRequiredToolResult(pending); + const text = result.content[0].text; + + assert.ok(text.includes(pending.verification_uri), "expected verification_uri"); + assert.ok(text.includes(pending.user_code), "expected user_code"); + // Without the complete URL we should NOT pretend there is a pre-filled link. + assert.ok( + !text.includes("code pre-filled"), + "expected no 'code pre-filled' hint when verification_uri_complete is null", + ); +}); + +test("auth-required response omits expiry hint when expires_at is missing", () => { + const pending = { + user_code: "WDJB-MJHT", + verification_uri: "https://github.com/login/device", + verification_uri_complete: null, + expires_at: undefined, + }; + const result = buildAuthRequiredToolResult(pending); + const text = result.content[0].text; + + assert.ok(!/expires in/.test(text), "expected no expiry hint when expires_at is undefined"); +}); + +test("auth-required response tells the user to retry after approval", () => { + const pending = { + user_code: "WDJB-MJHT", + verification_uri: "https://github.com/login/device", + verification_uri_complete: + "https://github.com/login/device?user_code=WDJB-MJHT", + expires_at: Date.now() + 600_000, + }; + const result = buildAuthRequiredToolResult(pending); + const text = result.content[0].text; + + // The retry hint is what keeps subsequent tool calls from blocking a second + // time — users must understand the workflow. + assert.ok( + /Retry the same tool call after approving/i.test(text), + "expected retry-after-approval hint in response", + ); +}); + +test("response is marked isError so hosts render it as a failed tool call", () => { + const pending = { + user_code: "X", + verification_uri: "https://example.com", + verification_uri_complete: null, + expires_at: undefined, + }; + const result = buildAuthRequiredToolResult(pending); + assert.equal(result.isError, true); +});