Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える
| F7.8 | KV schema は自前設計: `client:{client_id}` / `web_auth_state:{state}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}`。device flow 時代の `device:` / `user_code:` キーは撤去 |
| F7.9 | ローカルブリッジは authorize URL を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) |
| F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) |
| F7.11 | ローカルブリッジは refresh 時に `invalid_grant` を受けた場合、直ちに全面 re-auth に遷移せず tokens file を再読み込みする。別プロセスが既に rotation を完了していれば、その最新 refresh_token を採用して再試行する(RC1: refresh desync の最小 fix。file lock は導入しない) |
| F7.11 | ローカルブリッジは refresh の `invalid_grant` 後、共有 tokens file を直ちに再読込し、最大 1 秒の待機予算内で 50 ms 間隔に再読込する。この予算はファイル更新待ちの期限であり、既存の refresh 通信の所要時間上限は変更しない。新しい refresh_token の世代を見つけたら、有効な access token(期限なし、または期限まで 5 分超)があれば採用し、それ以外は refresh を最大 2 回試す。同じ世代は再試行しない。最初の refresh と回復処理は両 access-token 取得経路で単一のプロセス内 promise を共有し、成功・失敗の双方で解放する。更新なし・欠損・不正なファイル・本物の失効は有界に既存の全面認証/背景認証へ戻り、別種の refresh エラーでは再読込待機をしない。正常 refresh と有効なキャッシュには待機を追加しない。プロセス間 file lock とブラウザ認証共有化は対象外(#258。#242 の過去原因の確定を意味しない) |

**Dynamic Client Registration の位置づけ(#249 で確認、撤去は本 issue の範囲外):**

Expand Down
2 changes: 1 addition & 1 deletion docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える
| F7.8 | KV schema は自前設計: `client:{client_id}` / `web_auth_state:{state}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}`。device flow 時代の `device:` / `user_code:` キーは撤去 |
| F7.9 | ローカルブリッジは authorize URL を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) |
| F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) |
| F7.11 | ローカルブリッジは refresh 時に `invalid_grant` を受けた場合、直ちに全面 re-auth に遷移せず tokens file を再読み込みする。別プロセスが既に rotation を完了していれば、その最新 refresh_token を採用して再試行する(RC1: refresh desync の最小 fix。file lock は導入しない) |
| F7.11 | ローカルブリッジは refresh の `invalid_grant` 後、共有 tokens file を直ちに再読込し、最大 1 秒の待機予算内で 50 ms 間隔に再読込する。この予算はファイル更新待ちの期限であり、既存の refresh 通信の所要時間上限は変更しない。新しい refresh_token の世代を見つけたら、有効な access token(期限なし、または期限まで 5 分超)があれば採用し、それ以外は refresh を最大 2 回試す。同じ世代は再試行しない。最初の refresh と回復処理は両 access-token 取得経路で単一のプロセス内 promise を共有し、成功・失敗の双方で解放する。更新なし・欠損・不正なファイル・本物の失効は有界に既存の全面認証/背景認証へ戻り、別種の refresh エラーでは再読込待機をしない。正常 refresh と有効なキャッシュには待機を追加しない。プロセス間 file lock とブラウザ認証共有化は対象外(#258。#242 の過去原因の確定を意味しない) |

**Dynamic Client Registration の位置づけ(#249 で確認、撤去は本 issue の範囲外):**

Expand Down
105 changes: 57 additions & 48 deletions local-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,30 +503,65 @@ function isInvalidGrantError(err: unknown): boolean {
return body.includes("invalid_grant");
}

/**
* RC1 fix: before giving up on a stale refresh_token, re-read the tokens file
* to see whether a concurrent process already rotated it. If the on-disk
* refresh_token differs from the one that just failed, adopt it and retry.
*/
// Only invalid_grant recovery pays this bounded disk-publication wait.
const REFRESH_RECOVERY_WAIT_MS = 1_000;
const REFRESH_RECOVERY_INTERVAL_MS = 50;
const REFRESH_RECOVERY_MAX_ATTEMPTS = 2;

async function tryRefreshViaDiskReread(previousRefreshToken: string): Promise<TokenData | null> {
const fresh = await loadTokens();
if (!fresh || !fresh.refresh_token) return null;
if (fresh.refresh_token === previousRefreshToken) return null;
try {
const tokens = await refreshAccessToken(fresh.refresh_token);
return tokens;
} catch (err) {
console.error(
"[oauth] disk-reread refresh also failed:",
err instanceof Error ? err.message : err,
);
return null;
const deadline = Date.now() + REFRESH_RECOVERY_WAIT_MS;
const attempted = new Set([previousRefreshToken]);
let attempts = 0;
while (true) {
const fresh = await loadTokens();
if (fresh && fresh.refresh_token && !attempted.has(fresh.refresh_token)) {
attempted.add(fresh.refresh_token);
if (fresh.access_token &&
(!fresh.expires_at || fresh.expires_at > Date.now() + 5 * 60_000)) {
return fresh;
}
attempts++;
try {
return await refreshAccessToken(fresh.refresh_token);
} catch (err) {
console.error("[oauth] disk-reread refresh also failed:",
err instanceof Error ? err.message : err);
if (!isInvalidGrantError(err) || attempts >= REFRESH_RECOVERY_MAX_ATTEMPTS) {
return null;
}
}
}
const remaining = deadline - Date.now();
if (remaining <= 0) return null;
await sleep(Math.min(REFRESH_RECOVERY_INTERVAL_MS, remaining));
}
}

/**
* Get a valid access token, refreshing or re-authenticating as needed.
*/
function refreshWithRecovery(refreshToken: string): Promise<TokenData> {
if (!_refreshLock) {
// Capture the failed generation before any caller can replace the cache.
_refreshLock = (async () => {
try {
return await refreshAccessToken(refreshToken);
} catch (err) {
if (isInvalidGrantError(err)) {
const recovered = await tryRefreshViaDiskReread(refreshToken);
if (recovered) return recovered;
}
throw err;
}
})().then((tokens) => {
_cachedTokens = tokens;
return tokens;
}).finally(() => {
// Only the shared operation owns lock release, not its individual waiters.
_refreshLock = null;
});
}
return _refreshLock;
}

/** Get a valid access token, refreshing or re-authenticating as needed. */
async function getAccessToken(): Promise<string> {
if (!_cachedTokens) {
_cachedTokens = await loadTokens();
Expand All @@ -540,24 +575,11 @@ async function getAccessToken(): Promise<string> {
}

if (_cachedTokens.refresh_token) {
const previousRefresh = _cachedTokens.refresh_token;
if (!_refreshLock) {
_refreshLock = refreshAccessToken(previousRefresh);
}
try {
_cachedTokens = await _refreshLock;
_cachedTokens = await refreshWithRecovery(_cachedTokens.refresh_token);
return _cachedTokens.access_token;
} catch (err) {
if (isInvalidGrantError(err)) {
const reread = await tryRefreshViaDiskReread(previousRefresh);
if (reread) {
_cachedTokens = reread;
return _cachedTokens.access_token;
}
}
console.error("[oauth] refresh failed, falling back to full OAuth flow:", (err as Error).message || err);
} finally {
_refreshLock = null;
}
} else {
console.error("[oauth] no refresh_token available, requiring full OAuth flow");
Expand Down Expand Up @@ -601,27 +623,14 @@ async function getAccessTokenForToolCall(): Promise<string> {
}

if (_cachedTokens.refresh_token) {
const previousRefresh = _cachedTokens.refresh_token;
if (!_refreshLock) {
_refreshLock = refreshAccessToken(previousRefresh);
}
try {
_cachedTokens = await _refreshLock;
_cachedTokens = await refreshWithRecovery(_cachedTokens.refresh_token);
return _cachedTokens.access_token;
} catch (err) {
if (isInvalidGrantError(err)) {
const reread = await tryRefreshViaDiskReread(previousRefresh);
if (reread) {
_cachedTokens = reread;
return _cachedTokens.access_token;
}
}
console.error(
"[oauth] refresh failed, starting web flow in background:",
(err as Error).message || err,
);
} finally {
_refreshLock = null;
}
}
}
Expand Down
17 changes: 16 additions & 1 deletion mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"ws": "^8.18.0"
},
"devDependencies": {
"@anthropic-ai/mcpb": "^2.1.2"
"@anthropic-ai/mcpb": "^2.1.2",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=18.0.0"
Expand Down
101 changes: 56 additions & 45 deletions mcp-server/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -454,27 +454,64 @@ function isInvalidGrantError(err) {
return body.includes("invalid_grant");
}

/**
* RC1 fix: before giving up on a stale refresh_token, re-read the tokens file
* to see whether a concurrent process already rotated it. If the on-disk
* refresh_token differs from the one that just failed, adopt it and retry.
*/
// Only invalid_grant recovery pays this bounded disk-publication wait.
const REFRESH_RECOVERY_WAIT_MS = 1_000;
const REFRESH_RECOVERY_INTERVAL_MS = 50;
const REFRESH_RECOVERY_MAX_ATTEMPTS = 2;

async function tryRefreshViaDiskReread(previousRefreshToken) {
const fresh = await loadTokens();
if (!fresh || !fresh.refresh_token) return null;
if (fresh.refresh_token === previousRefreshToken) return null;
try {
const tokens = await refreshAccessToken(fresh.refresh_token);
return tokens;
} catch (err) {
console.error(
"[oauth] disk-reread refresh also failed:",
err && err.message ? err.message : err,
);
return null;
const deadline = Date.now() + REFRESH_RECOVERY_WAIT_MS;
const attempted = new Set([previousRefreshToken]);
let attempts = 0;
while (true) {
const fresh = await loadTokens();
if (fresh && fresh.refresh_token && !attempted.has(fresh.refresh_token)) {
attempted.add(fresh.refresh_token);
if (fresh.access_token &&
(!fresh.expires_at || fresh.expires_at > Date.now() + 5 * 60_000)) {
return fresh;
}
attempts++;
try {
return await refreshAccessToken(fresh.refresh_token);
} catch (err) {
console.error("[oauth] disk-reread refresh also failed:",
err && err.message ? err.message : err);
if (!isInvalidGrantError(err) || attempts >= REFRESH_RECOVERY_MAX_ATTEMPTS) {
return null;
}
}
}
const remaining = deadline - Date.now();
if (remaining <= 0) return null;
await sleep(Math.min(REFRESH_RECOVERY_INTERVAL_MS, remaining));
}
}

function refreshWithRecovery(refreshToken) {
if (!_refreshLock) {
// Capture the failed generation before any caller can replace the cache.
_refreshLock = (async () => {
try {
return await refreshAccessToken(refreshToken);
} catch (err) {
if (isInvalidGrantError(err)) {
const recovered = await tryRefreshViaDiskReread(refreshToken);
if (recovered) return recovered;
}
throw err;
}
})().then((tokens) => {
_cachedTokens = tokens;
return tokens;
}).finally(() => {
// Only the shared operation owns lock release, not its individual waiters.
_refreshLock = null;
});
}
return _refreshLock;
}

async function getAccessToken() {
if (!_cachedTokens) {
_cachedTokens = await loadTokens();
Expand All @@ -488,25 +525,11 @@ async function getAccessToken() {
}

if (_cachedTokens.refresh_token) {
if (!_refreshLock) {
_refreshLock = refreshAccessToken(_cachedTokens.refresh_token);
}
try {
_cachedTokens = await _refreshLock;
_cachedTokens = await refreshWithRecovery(_cachedTokens.refresh_token);
return _cachedTokens.access_token;
} catch (err) {
// RC1: maybe a sibling process already rotated. Re-read and retry once
// before falling through to a full web-flow restart.
if (isInvalidGrantError(err)) {
const reread = await tryRefreshViaDiskReread(_cachedTokens.refresh_token);
if (reread) {
_cachedTokens = reread;
return _cachedTokens.access_token;
}
}
console.error("[oauth] refresh failed, falling back to full OAuth flow:", err.message || err);
} finally {
_refreshLock = null;
}
} else {
console.error("[oauth] no refresh_token available, requiring full OAuth flow");
Expand Down Expand Up @@ -548,26 +571,14 @@ async function getAccessTokenForToolCall() {
}

if (_cachedTokens.refresh_token) {
if (!_refreshLock) {
_refreshLock = refreshAccessToken(_cachedTokens.refresh_token);
}
try {
_cachedTokens = await _refreshLock;
_cachedTokens = await refreshWithRecovery(_cachedTokens.refresh_token);
return _cachedTokens.access_token;
} catch (err) {
if (isInvalidGrantError(err)) {
const reread = await tryRefreshViaDiskReread(_cachedTokens.refresh_token);
if (reread) {
_cachedTokens = reread;
return _cachedTokens.access_token;
}
}
console.error(
"[oauth] refresh failed, starting web flow in background:",
err.message || err,
);
} finally {
_refreshLock = null;
}
}
}
Expand Down
Loading
Loading