diff --git a/src/components/message/activity/activity-rows.test.tsx b/src/components/message/activity/activity-rows.test.tsx
index 70c01405fd9..42929203035 100644
--- a/src/components/message/activity/activity-rows.test.tsx
+++ b/src/components/message/activity/activity-rows.test.tsx
@@ -1418,3 +1418,126 @@ describe("a row's figure", () => {
expect(command.querySelector(".activity-figure")).toBeEmptyDOMElement()
})
})
+
+describe("Kimi MCP OAuth entry", () => {
+ const url =
+ "https://auth.example.test/authorize?state=a%2Bb&code_challenge=x_y&redirect_uri=http%3A%2F%2F127.0.0.1%3A43127%2Fcallback"
+ function oauthParts(phase = "waiting", done = false) {
+ return parts(
+ turn([
+ toolUse(
+ "oauth",
+ "mcp__notion__authenticate",
+ {},
+ {
+ "codeg.kimiMcpOAuth": {
+ toolName: "mcp__notion__authenticate",
+ server: "notion",
+ phase,
+ url: phase === "waiting" ? url : null,
+ },
+ }
+ ),
+ ...(done ? [toolResult("oauth", "cancelled", true)] : []),
+ ]),
+ !done,
+ done ? undefined : ["oauth"]
+ )
+ }
+
+ it("offers the full URL before completion and opens only on an explicit click", () => {
+ const open = vi.spyOn(window, "open").mockReturnValue(null)
+ try {
+ const view = renderUi()
+ const link = screen.getByRole("link", {
+ name: "Open authorization in system browser",
+ })
+ expect(link).toHaveAttribute("href", url)
+ expect(open).not.toHaveBeenCalled()
+ fireEvent.click(link)
+ expect(open).toHaveBeenCalledExactlyOnceWith(url, "_blank", "noreferrer")
+ view.rerender(
+ withProviders(
+
+ )
+ )
+ expect(
+ screen.queryByRole("link", {
+ name: "Open authorization in system browser",
+ })
+ ).not.toBeInTheDocument()
+ expect(screen.getByText(/Reconnecting notion/)).toBeInTheDocument()
+ expect(
+ screen.getByText(/verify access with a real MCP tool call/)
+ ).toBeInTheDocument()
+ expect(open).toHaveBeenCalledTimes(1)
+ view.rerender(
+ withProviders()
+ )
+ expect(
+ screen.queryByText(/Waiting for notion authorization/)
+ ).not.toBeInTheDocument()
+ } finally {
+ open.mockRestore()
+ }
+ })
+
+ it("reports native opener failure without leaking its error and allows retry", async () => {
+ const platform = await import("@/lib/platform")
+ const opener = vi
+ .spyOn(platform, "openUrl")
+ .mockRejectedValueOnce(new Error("private authorization parameters"))
+ .mockResolvedValueOnce()
+ vi.stubGlobal("__TAURI_INTERNALS__", {})
+ try {
+ renderUi()
+ const link = screen.getByRole("link", {
+ name: "Open authorization in system browser",
+ })
+ expect(opener).not.toHaveBeenCalled()
+ await act(async () => {
+ fireEvent.click(link)
+ })
+ expect(opener).toHaveBeenLastCalledWith(url)
+ expect(screen.getByText(/Could not open the browser/)).toBeInTheDocument()
+ expect(
+ screen.queryByText(/private authorization parameters/)
+ ).not.toBeInTheDocument()
+ expect(link).toHaveAttribute("href", url)
+ await act(async () => {
+ fireEvent.click(link)
+ })
+ expect(opener).toHaveBeenCalledTimes(2)
+ expect(
+ screen.queryByText(/Could not open the browser/)
+ ).not.toBeInTheDocument()
+ expect(
+ screen.getByText(/Waiting for notion authorization/)
+ ).toBeInTheDocument()
+ } finally {
+ opener.mockRestore()
+ vi.unstubAllGlobals()
+ }
+ })
+
+ it("never exposes a link from ordinary MCP output", () => {
+ renderUi(
+
+ )
+ expect(
+ screen.queryByRole("link", {
+ name: "Open authorization in system browser",
+ })
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/message/activity/rows/mcp-oauth-notice.tsx b/src/components/message/activity/rows/mcp-oauth-notice.tsx
new file mode 100644
index 00000000000..8ebc791b1ed
--- /dev/null
+++ b/src/components/message/activity/rows/mcp-oauth-notice.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import { useState } from "react"
+import { useTranslations } from "next-intl"
+
+import { BrowserLink } from "@/components/ui/browser-link"
+import type { KimiMcpOAuth } from "@/lib/kimi-mcp-oauth"
+import { openInSystemBrowser } from "@/lib/link-open"
+
+/** Opening a browser is only a user action, never proof of authentication. */
+export function McpOAuthNotice({ progress }: { progress: KimiMcpOAuth }) {
+ const t = useTranslations("Folder.chat.activity.oauth")
+ const [failed, setFailed] = useState(false)
+ if (progress.phase === "pending") return null
+ return (
+
diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx
index ad6bdcedb2c..99bb096ec14 100644
--- a/src/contexts/acp-connections-context.test.tsx
+++ b/src/contexts/acp-connections-context.test.tsx
@@ -2520,6 +2520,141 @@ describe("AcpConnectionsProvider permission request details", () => {
}
})
+ it("exposes Kimi OAuth status before the tool settles and retires its link", async () => {
+ const frames: FrameRequestCallback[] = []
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
+ frames.push(cb)
+ return frames.length
+ })
+ vi.stubGlobal("cancelAnimationFrame", () => {})
+ try {
+ await mountProvider()
+ await act(async () => {
+ await h.actions!.connect(TAB, "kimi_code", "/tmp/x", "sess-1")
+ })
+ const handlers = latestAttachHandlers()
+ let seq = 0
+ const emit = (event: Omit) => {
+ emitAcpEvent(handlers, {
+ ...event,
+ seq: ++seq,
+ connection_id: "spawned-conn",
+ } as EventEnvelope)
+ act(() => {
+ while (frames.length) frames.shift()!(0)
+ })
+ }
+ const call = () => {
+ const block = h
+ .store!.getConnection(TAB)!
+ .liveMessage?.content.find(
+ (b) => b.type === "tool_call" && b.info.tool_call_id === "auth-1"
+ )
+ return block?.type === "tool_call" ? block.info : null
+ }
+ emit({ type: "status_changed", status: "prompting" } as EventEnvelope)
+ emit({
+ type: "tool_call",
+ tool_call_id: "auth-1",
+ title: "mcp__notion__authenticate",
+ kind: "other",
+ status: "pending",
+ content: null,
+ raw_input: null,
+ raw_output: null,
+ } as EventEnvelope)
+ emit({
+ type: "tool_call_update",
+ tool_call_id: "auth-1",
+ title: "Authenticating notion",
+ status: "inprogress",
+ content: null,
+ raw_input: "{}",
+ raw_output: null,
+ } as EventEnvelope)
+ const url =
+ "https://auth.example.test/authorize?state=a%2Bb&code_challenge=x_y&redirect_uri=http%3A%2F%2F127.0.0.1%3A43127%2Fcallback"
+ const progress = `Open this URL in your browser to authorize "notion":\n\n${url}\n\nWaiting for the OAuth callback (timeout 15 min). If you cancel, call this tool again to restart the flow.`
+ const update = (title: string | null, status: string | null = null) =>
+ emit({
+ type: "tool_call_update",
+ tool_call_id: "auth-1",
+ title,
+ status,
+ content: null,
+ raw_input: null,
+ raw_output: null,
+ } as EventEnvelope)
+ update(progress)
+ expect(call()?.status).toBe("inprogress")
+ expect(call()?.title).toBe("mcp__notion__authenticate")
+ expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toMatchObject({
+ phase: "waiting",
+ url,
+ })
+ const { buildStreamingTurnsFromLiveMessage } =
+ await import("@/stores/conversation-runtime-store")
+ const { adaptMessageTurn } =
+ await import("@/lib/adapters/ai-elements-adapter")
+ const { MOCKUP_ADAPTER_TEXT } =
+ await import("@/lib/activity/__fixtures__/mockup-turn")
+ const built = buildStreamingTurnsFromLiveMessage(
+ 42,
+ h.store!.getConnection(TAB)!.liveMessage!
+ )
+ const adapted = adaptMessageTurn(
+ built.turns[0]!,
+ MOCKUP_ADAPTER_TEXT,
+ true,
+ new Set(["auth-1"])
+ )
+ expect(adapted.content.find((p) => p.type === "tool-call")).toMatchObject(
+ {
+ toolName: "mcp__notion__authenticate",
+ meta: { "codeg.kimiMcpOAuth": { phase: "waiting", url } },
+ }
+ )
+ update("Authorized — reconnecting notion…")
+ expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toMatchObject({
+ phase: "reconnecting",
+ url: null,
+ })
+ update(null, "failed")
+ expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toBeNull()
+ update(progress)
+ expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toBeNull()
+ emit({
+ type: "tool_call_update",
+ tool_call_id: "late-auth",
+ title: "mcp__notion__authenticate",
+ status: "inprogress",
+ content: null,
+ raw_input: "{}",
+ raw_output: null,
+ meta: {
+ "codeg.kimiMcpOAuth": {
+ toolName: "mcp__notion__authenticate",
+ server: "notion",
+ phase: "waiting",
+ url,
+ },
+ },
+ } as EventEnvelope)
+ const late = h
+ .store!.getConnection(TAB)!
+ .liveMessage?.content.find(
+ (b) => b.type === "tool_call" && b.info.tool_call_id === "late-auth"
+ )
+ expect(
+ late?.type === "tool_call"
+ ? late.info.meta?.["codeg.kimiMcpOAuth"]
+ : undefined
+ ).toBeNull()
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ })
+
it("keeps a Monitor a monitor through the frames that announce it", async () => {
// claude-agent-acp 0.98.0, recorded: the call is announced by its tool
// name with no input, and the input follows in an update that names no
diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx
index ebe1aa6b3a6..d5d3d93d6f5 100644
--- a/src/contexts/acp-connections-context.tsx
+++ b/src/contexts/acp-connections-context.tsx
@@ -1,5 +1,12 @@
"use client"
+import {
+ readKimiMcpOAuth,
+ startKimiMcpOAuth,
+ stripKimiMcpOAuth,
+ updateKimiMcpOAuth,
+} from "@/lib/kimi-mcp-oauth"
+
import {
createContext,
useCallback,
@@ -1881,15 +1888,16 @@ function mergeToolCallUpdate(
newTotalBytes = update.raw_output.length
}
+ const oauth = readKimiMcpOAuth(info.meta)
return {
...info,
- title: update.title ?? info.title,
+ title: oauth?.toolName ?? update.title ?? info.title,
status: update.status ?? info.status,
content: update.content ?? info.content,
raw_input: update.raw_input ?? info.raw_input,
raw_output_chunks: newChunks,
locations: update.locations ?? info.locations,
- meta: update.meta ?? info.meta,
+ meta: updateKimiMcpOAuth(info.meta, update, info.status),
raw_output_total_bytes: newTotalBytes,
images: update.images !== null ? update.images : info.images,
}
@@ -2441,6 +2449,7 @@ function connectionsReducer(
case "TOOL_CALL": {
const conn = state.get(action.contextKey)
if (!conn) return state
+ const meta = startKimiMcpOAuth(conn.agentType, action.title, action.meta)
// Out-of-turn wire tool activity stays OUT of `liveMessage` (the
// transcript overlay renders that content — grafting it here recreated
// the garbled-timeline bug), but its context is still recorded so a
@@ -2457,7 +2466,7 @@ function connectionsReducer(
action.raw_output !== null ? [action.raw_output] : [],
raw_output_total_bytes: action.raw_output?.length ?? 0,
locations: action.locations,
- meta: action.meta,
+ meta,
images: action.images ?? [],
}
const next = new Map(state)
@@ -2510,7 +2519,7 @@ function connectionsReducer(
// The announcement is what names the tool
// (`_meta.claudeCode.toolName`), so its `_meta` must land.
locations: action.locations ?? block.info.locations,
- meta: action.meta ?? block.info.meta,
+ meta: meta ?? block.info.meta,
images:
action.images !== null ? action.images : block.info.images,
},
@@ -2536,7 +2545,7 @@ function connectionsReducer(
action.raw_output !== null ? [action.raw_output] : [],
raw_output_total_bytes: action.raw_output?.length ?? 0,
locations: action.locations ?? null,
- meta: action.meta ?? null,
+ meta,
images: action.images ?? [],
},
},
@@ -5256,7 +5265,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
raw_input: e.raw_input,
raw_output: e.raw_output,
locations: e.locations ?? null,
- meta: (e.meta as ToolCallMeta) ?? null,
+ meta: stripKimiMcpOAuth((e.meta as ToolCallMeta) ?? null),
images: e.images ?? null,
})
break
@@ -5274,7 +5283,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
raw_output: e.raw_output,
raw_output_append: e.raw_output_append,
locations: e.locations ?? null,
- meta: (e.meta as ToolCallMeta) ?? null,
+ meta: stripKimiMcpOAuth((e.meta as ToolCallMeta) ?? null),
images: e.images ?? null,
})
scheduleToolCallUpdateFlush()
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 092624ad9cc..294ad0ead4e 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "إخفاء المعاينة"
},
"activity": {
+ "oauth": {
+ "waiting": "بانتظار تفويض {server}",
+ "reconnecting": "جارٍ إعادة الاتصال بـ {server}…",
+ "open": "فتح التفويض في متصفح النظام",
+ "verify": "بعد إعادة الاتصال، تحقق من الوصول باستدعاء فعلي لأداة MCP. فتح المتصفح لا يؤكد نجاح التفويض.",
+ "openFailed": "تعذر فتح المتصفح. استخدم رابط التفويض للمحاولة مجددًا."
+ },
"header": {
"label": "سير التنفيذ",
"working": "قيد التنفيذ",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 717bf1ec451..ab049ded82d 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "Vorschau ausblenden"
},
"activity": {
+ "oauth": {
+ "waiting": "Warten auf Autorisierung für {server}",
+ "reconnecting": "Verbindung zu {server} wird wiederhergestellt…",
+ "open": "Autorisierung im Systembrowser öffnen",
+ "verify": "Prüfe den Zugriff nach dem Neuverbinden mit einem echten MCP-Werkzeugaufruf. Das Öffnen des Browsers bestätigt keine Autorisierung.",
+ "openFailed": "Der Browser konnte nicht geöffnet werden. Versuche es über den Autorisierungslink erneut."
+ },
"header": {
"label": "Ablauf",
"working": "Läuft",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 95c2505440a..a9102ceede4 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "Hide preview"
},
"activity": {
+ "oauth": {
+ "waiting": "Waiting for {server} authorization",
+ "reconnecting": "Reconnecting {server}…",
+ "open": "Open authorization in system browser",
+ "verify": "After reconnecting, verify access with a real MCP tool call. Opening the browser does not confirm authorization.",
+ "openFailed": "Could not open the browser. Use the authorization link to try again."
+ },
"header": {
"label": "Activity",
"working": "Working",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 8410c0165b1..2664d616da4 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "Ocultar vista previa"
},
"activity": {
+ "oauth": {
+ "waiting": "Esperando autorización de {server}",
+ "reconnecting": "Reconectando con {server}…",
+ "open": "Abrir autorización en el navegador del sistema",
+ "verify": "Tras reconectar, verifica el acceso con una llamada real a una herramienta MCP. Abrir el navegador no confirma la autorización.",
+ "openFailed": "No se pudo abrir el navegador. Usa el enlace de autorización para reintentar."
+ },
"header": {
"label": "Actividad",
"working": "En curso",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 1e03d8719c5..cb4e8f329ac 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "Masquer l’aperçu"
},
"activity": {
+ "oauth": {
+ "waiting": "En attente d’autorisation pour {server}",
+ "reconnecting": "Reconnexion à {server}…",
+ "open": "Ouvrir l’autorisation dans le navigateur système",
+ "verify": "Après la reconnexion, vérifiez l’accès avec un véritable appel d’outil MCP. Ouvrir le navigateur ne confirme pas l’autorisation.",
+ "openFailed": "Impossible d’ouvrir le navigateur. Réessayez avec le lien d’autorisation."
+ },
"header": {
"label": "Activité",
"working": "En cours",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index c4695f5207a..9d833005828 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "プレビューを閉じる"
},
"activity": {
+ "oauth": {
+ "waiting": "{server} の認可を待機中",
+ "reconnecting": "{server} に再接続中…",
+ "open": "システムブラウザーで認可を開く",
+ "verify": "再接続後、実際の MCP ツール呼び出しでアクセスを確認してください。ブラウザーを開くだけでは認可は確認されません。",
+ "openFailed": "ブラウザーを開けませんでした。認可リンクから再試行してください。"
+ },
"header": {
"label": "実行過程",
"working": "実行中",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 412f820533b..eddae8bcc95 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "미리보기 닫기"
},
"activity": {
+ "oauth": {
+ "waiting": "{server} 승인 대기 중",
+ "reconnecting": "{server}에 다시 연결 중…",
+ "open": "시스템 브라우저에서 승인 열기",
+ "verify": "다시 연결한 후 실제 MCP 도구 호출로 접근을 확인하세요. 브라우저를 여는 것만으로 승인이 확인되지는 않습니다.",
+ "openFailed": "브라우저를 열지 못했습니다. 승인 링크로 다시 시도하세요."
+ },
"header": {
"label": "실행 과정",
"working": "진행 중",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 503cba05049..e59ad4051e6 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "Ocultar pré-visualização"
},
"activity": {
+ "oauth": {
+ "waiting": "Aguardando autorização de {server}",
+ "reconnecting": "Reconectando a {server}…",
+ "open": "Abrir autorização no navegador do sistema",
+ "verify": "Após reconectar, verifique o acesso com uma chamada real de ferramenta MCP. Abrir o navegador não confirma a autorização.",
+ "openFailed": "Não foi possível abrir o navegador. Use o link de autorização para tentar novamente."
+ },
"header": {
"label": "Atividade",
"working": "Em andamento",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index b3bfccb921b..492f63a54f6 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "收起预览"
},
"activity": {
+ "oauth": {
+ "waiting": "等待 {server} 授权",
+ "reconnecting": "正在重新连接 {server}…",
+ "open": "在系统浏览器中打开授权",
+ "verify": "重新连接后,请用真实 MCP 工具调用验证访问。打开浏览器不代表授权成功。",
+ "openFailed": "无法打开浏览器,请通过授权链接重试。"
+ },
"header": {
"label": "执行过程",
"working": "进行中",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 5bc94c985c0..a5c2bf3e7d1 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -3946,6 +3946,13 @@
"htmlPreviewHide": "收起預覽"
},
"activity": {
+ "oauth": {
+ "waiting": "等待 {server} 授權",
+ "reconnecting": "正在重新連線 {server}…",
+ "open": "在系統瀏覽器中開啟授權",
+ "verify": "重新連線後,請用實際 MCP 工具呼叫驗證存取。開啟瀏覽器不代表授權成功。",
+ "openFailed": "無法開啟瀏覽器,請透過授權連結重試。"
+ },
"header": {
"label": "執行過程",
"working": "進行中",
diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts
index 3b4a16ec1ef..e61f71fcc13 100644
--- a/src/lib/adapters/ai-elements-adapter.ts
+++ b/src/lib/adapters/ai-elements-adapter.ts
@@ -21,6 +21,7 @@ import {
import { isGrepNoMatchCommandResult } from "@/lib/grep-no-match"
import { isBackgroundTaskToolCall } from "@/lib/background-task"
import { isContextCompactionMeta } from "@/lib/context-compaction"
+import { readKimiMcpOAuth } from "@/lib/kimi-mcp-oauth"
import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle"
import { feedbackCheckHasContent } from "@/lib/feedback-check"
import {
@@ -1737,7 +1738,10 @@ export function dropEmptyInFlightToolCalls(
isBackgroundTaskToolCall(part) ||
// A compaction runs with no arguments until it completes; dropping it
// would hide the "compacting" divider for as long as it works.
- isContextCompactionMeta(part.meta)
+ isContextCompactionMeta(part.meta) ||
+ // Kimi's synthetic authenticate tool takes {} and has no result while
+ // awaiting OAuth. Its scoped progress is the content the user needs.
+ readKimiMcpOAuth(part.meta)?.toolName === part.toolName
) {
return true
}
diff --git a/src/lib/kimi-mcp-oauth.test.ts b/src/lib/kimi-mcp-oauth.test.ts
new file mode 100644
index 00000000000..a44e960ad16
--- /dev/null
+++ b/src/lib/kimi-mcp-oauth.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it } from "vitest"
+
+import {
+ KIMI_MCP_OAUTH_META,
+ readKimiMcpOAuth,
+ startKimiMcpOAuth,
+ updateKimiMcpOAuth,
+} from "./kimi-mcp-oauth"
+
+const URL =
+ "https://auth.example.test/authorize?state=a%2Bb&code_challenge=x_y&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A43127%2Fcallback"
+const statusText = (url = URL, server = "notion") =>
+ `Open this URL in your browser to authorize "${server}":\n\n${url}\n\nWaiting for the OAuth callback (timeout 15 min). If you cancel, call this tool again to restart the flow.`
+const opening = () =>
+ startKimiMcpOAuth("kimi_code", "mcp__notion__authenticate", { keep: 1 })
+const update = (title: string | null, status: string | null = null) => ({
+ title,
+ status,
+ content: null,
+ raw_input: null,
+ raw_output: null,
+ meta: null,
+})
+const waiting = () =>
+ updateKimiMcpOAuth(opening(), update(statusText()), "in_progress")
+
+describe("Kimi ACP OAuth status compatibility", () => {
+ it("keeps the original HTTPS URL bytes, and independent metadata, while waiting", () => {
+ expect(readKimiMcpOAuth(waiting())).toEqual({
+ toolName: "mcp__notion__authenticate",
+ server: "notion",
+ phase: "waiting",
+ url: URL,
+ })
+ expect(waiting()?.keep).toBe(1)
+ })
+
+ it.each([
+ ["kimi_code", "mcp__notion__fetch"],
+ ["kimi_code", "authenticate"],
+ ["codex", "mcp__notion__authenticate"],
+ ["claude_code", "mcp__notion__authenticate"],
+ ])("does not establish OAuth context for %s / %s", (agent, tool) => {
+ const meta = startKimiMcpOAuth(agent, tool, null)
+ expect(
+ readKimiMcpOAuth(
+ updateKimiMcpOAuth(meta, update(statusText()), "in_progress")
+ )
+ ).toBeNull()
+ })
+
+ it.each([
+ "http://auth.example.test/authorize?state=x",
+ "javascript:alert(1)",
+ "file:///tmp/oauth",
+ "https://user:password@auth.example.test/authorize",
+ "https://auth.example.test/authorize\nhttps://evil.example.test",
+ "https://auth.example.test/authorize oauth",
+ "https://",
+ "https://auth.example.test/authorize\u0000",
+ "https://auth.example.test/authorize\u007f",
+ ])("rejects an unsafe or malformed URL: %s", (url) => {
+ expect(
+ readKimiMcpOAuth(
+ updateKimiMcpOAuth(opening(), update(statusText(url)), "in_progress")
+ )?.url
+ ).toBeNull()
+ })
+
+ it("never finds authorization URLs in a tool result, input or ordinary title", () => {
+ for (const patch of [
+ { title: "result", raw_output: statusText() },
+ { title: "result", content: statusText() },
+ { title: "result", raw_input: statusText() },
+ { title: `Please open ${URL}` },
+ { title: statusText(), status: "in_progress" },
+ { title: statusText(), raw_output: "unrelated result" },
+ { title: statusText(URL, "other-server") },
+ ]) {
+ const meta = updateKimiMcpOAuth(
+ opening(),
+ { ...update(null), ...patch },
+ "in_progress"
+ )
+ expect(readKimiMcpOAuth(meta)?.url).toBeNull()
+ }
+ })
+
+ it.each(["completed", "failed", "cancelled", "error"])(
+ "retires URLs on %s and ignores late progress",
+ (status) => {
+ const ended = updateKimiMcpOAuth(
+ waiting(),
+ update(null, status),
+ "in_progress"
+ )
+ expect(ended?.[KIMI_MCP_OAUTH_META]).toBeNull()
+ expect(
+ readKimiMcpOAuth(
+ updateKimiMcpOAuth(ended, update(statusText()), status)
+ )
+ ).toBeNull()
+ }
+ )
+
+ it("removes the link while reconnecting without declaring verified access", () => {
+ const meta = updateKimiMcpOAuth(
+ waiting(),
+ update("Authorized — reconnecting notion…"),
+ "in_progress"
+ )
+ expect(readKimiMcpOAuth(meta)).toMatchObject({
+ phase: "reconnecting",
+ url: null,
+ })
+ expect(
+ readKimiMcpOAuth(
+ updateKimiMcpOAuth(
+ meta,
+ update("Discovering OAuth metadata for notion…"),
+ "in_progress"
+ )
+ )
+ ).toMatchObject({ phase: "pending", url: null })
+ })
+
+ it("fails closed after context loss, and never imports a foreign binding from update metadata", () => {
+ expect(
+ readKimiMcpOAuth(
+ updateKimiMcpOAuth(null, update(statusText()), "in_progress")
+ )
+ ).toBeNull()
+ expect(
+ readKimiMcpOAuth(
+ updateKimiMcpOAuth(
+ null,
+ { ...update(statusText()), meta: waiting() },
+ "in_progress"
+ )
+ )
+ ).toBeNull()
+ expect(
+ readKimiMcpOAuth(
+ startKimiMcpOAuth("codex", "mcp__notion__authenticate", waiting())
+ )
+ ).toBeNull()
+ })
+
+ it("does not borrow the URL of another parallel authenticate call", () => {
+ const other = startKimiMcpOAuth(
+ "kimi_code",
+ "mcp__other__authenticate",
+ null
+ )
+ expect(
+ readKimiMcpOAuth(
+ updateKimiMcpOAuth(other, update(statusText()), "in_progress")
+ )?.url
+ ).toBeNull()
+ expect(readKimiMcpOAuth(waiting())?.url).toBe(URL)
+ })
+})
+
+it("accepts the Rust ACP bridge's inprogress status spelling", () => {
+ const running = updateKimiMcpOAuth(
+ opening(),
+ update("Authenticating notion", "inprogress"),
+ "pending"
+ )
+ const meta = updateKimiMcpOAuth(running, update(statusText()), "inprogress")
+ expect(readKimiMcpOAuth(meta)?.url).toBe(URL)
+})
+
+it("matches Kimi-sanitized tool names to raw server labels without borrowing another server", () => {
+ const meta = startKimiMcpOAuth(
+ "kimi_code",
+ "mcp__my_notion__authenticate",
+ null
+ )
+ const progress = updateKimiMcpOAuth(
+ meta,
+ update(statusText(URL, "my.notion")),
+ "inprogress"
+ )
+ expect(readKimiMcpOAuth(progress)).toMatchObject({
+ phase: "waiting",
+ server: "my.notion",
+ url: URL,
+ })
+})
diff --git a/src/lib/kimi-mcp-oauth.ts b/src/lib/kimi-mcp-oauth.ts
new file mode 100644
index 00000000000..da46997ae7a
--- /dev/null
+++ b/src/lib/kimi-mcp-oauth.ts
@@ -0,0 +1,138 @@
+/** Kimi 2.5.3 drops custom OAuth events at its ACP boundary, but forwards
+ * this exact status template as a title-only tool_call_update. Never scan
+ * tool inputs, results, assistant prose or pages for an authorization URL. */
+export const KIMI_MCP_OAUTH_META = "codeg.kimiMcpOAuth"
+
+type Meta = Record | null
+
+// Kimi's tool-naming.ts; status text uses the original server label.
+function qualifiedAuthTool(server: string): string {
+ const name = server.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_")
+ return `mcp__${name}__authenticate`
+}
+
+export interface KimiMcpOAuth {
+ toolName: string
+ server: string
+ phase: "pending" | "waiting" | "reconnecting"
+ url: string | null
+}
+
+/** This binding is derived locally, never accepted from agent metadata. */
+export function stripKimiMcpOAuth(meta: Meta): Meta {
+ return meta && KIMI_MCP_OAUTH_META in meta
+ ? { ...meta, [KIMI_MCP_OAUTH_META]: null }
+ : meta
+}
+
+export function isOAuthHttpsUrl(value: string): boolean {
+ if (!value.startsWith("https://") || /[\s\u0000-\u001f\u007f]/.test(value)) {
+ return false
+ }
+ try {
+ const url = new URL(value)
+ return !!url.hostname && !url.username && !url.password
+ } catch {
+ return false
+ }
+}
+
+export function readKimiMcpOAuth(meta: Meta | undefined): KimiMcpOAuth | null {
+ const value = meta?.[KIMI_MCP_OAUTH_META]
+ if (!value || typeof value !== "object") return null
+ const p = value as KimiMcpOAuth
+ if (
+ typeof p.server !== "string" ||
+ !p.server ||
+ /["\r\n]/.test(p.server) ||
+ p.toolName !== qualifiedAuthTool(p.server) ||
+ !["pending", "waiting", "reconnecting"].includes(p.phase) ||
+ (p.phase === "waiting"
+ ? typeof p.url !== "string" || !isOAuthHttpsUrl(p.url)
+ : p.url !== null)
+ ) {
+ return null
+ }
+ return p
+}
+
+/** Only the opening call can establish provenance. Snapshots/late updates
+ * that missed that identity fail closed until a new authenticate call. */
+export function startKimiMcpOAuth(
+ agentType: string,
+ title: string,
+ meta: Meta
+): Meta {
+ const server =
+ agentType === "kimi_code"
+ ? /^mcp__([\w-]+)__authenticate$/.exec(title)?.[1]
+ : null
+ if (!server) return stripKimiMcpOAuth(meta)
+ return {
+ ...meta,
+ [KIMI_MCP_OAUTH_META]: server
+ ? { toolName: title, server, phase: "pending", url: null }
+ : null,
+ }
+}
+
+const WAIT_SUFFIX =
+ "\n\nWaiting for the OAuth callback (timeout 15 min). If you cancel, call this tool again to restart the flow."
+
+export function updateKimiMcpOAuth(
+ previous: Meta,
+ update: {
+ title: string | null
+ status: string | null
+ content: string | null
+ raw_input: string | null
+ raw_output: string | null
+ meta: Meta
+ },
+ currentStatus: string
+): Meta {
+ const next = update.meta ?? previous
+ const progress = readKimiMcpOAuth(previous)
+ if (!progress && !next?.[KIMI_MCP_OAUTH_META]) return next
+ const status = (update.status ?? currentStatus)
+ .toLowerCase()
+ .replace(/_/g, "")
+ if (!progress || !["pending", "inprogress"].includes(status)) {
+ return { ...next, [KIMI_MCP_OAUTH_META]: null }
+ }
+ let value = progress
+ if (
+ update.title !== null &&
+ update.status === null &&
+ update.content === null &&
+ update.raw_input === null &&
+ update.raw_output === null
+ ) {
+ // A new status retires the old URL, including reconnect and retry.
+ value = { ...progress, phase: "pending", url: null }
+ const prefix =
+ /^Open this URL in your browser to authorize "([^"\r\n]+)":\n\n/.exec(
+ update.title
+ )
+ const server = prefix?.[1]
+ if (
+ prefix &&
+ server &&
+ qualifiedAuthTool(server) === progress.toolName &&
+ update.title.endsWith(WAIT_SUFFIX)
+ ) {
+ const url = update.title.slice(prefix[0].length, -WAIT_SUFFIX.length)
+ if (isOAuthHttpsUrl(url))
+ value = { ...value, server, phase: "waiting", url }
+ } else {
+ const reconnect =
+ /^(?:Authorized — reconnecting |Already authorized; reconnecting )([^"\r\n]+)…$/.exec(
+ update.title
+ )?.[1]
+ if (reconnect && qualifiedAuthTool(reconnect) === progress.toolName) {
+ value = { ...value, server: reconnect, phase: "reconnecting" }
+ }
+ }
+ }
+ return { ...next, [KIMI_MCP_OAUTH_META]: value }
+}
diff --git a/src/lib/snapshot-denormalize.test.ts b/src/lib/snapshot-denormalize.test.ts
index 7ae5d6983ea..a57489461f4 100644
--- a/src/lib/snapshot-denormalize.test.ts
+++ b/src/lib/snapshot-denormalize.test.ts
@@ -278,3 +278,43 @@ describe("denormalizeSnapshot — last_error", () => {
expect(patch.status).toBe("connected")
})
})
+
+it("does not treat agent-supplied snapshot metadata as trusted OAuth progress", () => {
+ const patch = denormalizeSnapshot(
+ baseSnapshot({
+ status: "prompting",
+ live_message: {
+ id: "lm",
+ role: "assistant",
+ started_at: "2026-11-11T09:00:00Z",
+ content: [{ kind: "tool_call_ref", tool_call_id: "auth" }],
+ },
+ active_tool_calls: [
+ {
+ id: "auth",
+ label: "mcp__notion__authenticate",
+ kind: "other",
+ status: "in_progress",
+ input: {},
+ output: null,
+ content: null,
+ locations: null,
+ meta: {
+ keep: true,
+ "codeg.kimiMcpOAuth": {
+ toolName: "mcp__notion__authenticate",
+ server: "notion",
+ phase: "waiting",
+ url: "https://untrusted.example.test/authorize",
+ },
+ },
+ },
+ ],
+ })
+ )
+ const block = patch.liveMessage?.content[0]
+ expect(block?.type).toBe("tool_call")
+ if (block?.type !== "tool_call") throw new Error("tool call missing")
+ expect(block.info.meta?.["codeg.kimiMcpOAuth"]).toBeNull()
+ expect(block.info.meta?.keep).toBe(true)
+})
diff --git a/src/lib/snapshot-denormalize.ts b/src/lib/snapshot-denormalize.ts
index 3853cb27146..ed9d30d8044 100644
--- a/src/lib/snapshot-denormalize.ts
+++ b/src/lib/snapshot-denormalize.ts
@@ -1,3 +1,4 @@
+import { stripKimiMcpOAuth } from "@/lib/kimi-mcp-oauth"
import type {
ActiveDelegationState,
AsyncTaskRecord,
@@ -323,7 +324,7 @@ function toolStateToInfo(tc: ToolCallState): ToolCallInfo {
raw_output_chunks: outputChunks,
raw_output_total_bytes: outputBytes,
locations: tc.locations ?? null,
- meta: tc.meta ?? null,
+ meta: stripKimiMcpOAuth(tc.meta ?? null),
images: tc.images ?? [],
}
}