From 77c356204538b35ce5d40e0acd15d180d8ec1e5a Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Sun, 3 May 2026 06:01:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(ai-engine,frontend,core):=20ADR-0011?= =?UTF-8?q?=20PR-E4=20=E2=80=94=20chat-runner=20OAuth=20=E5=89=8A=E9=99=A4?= =?UTF-8?q?=20+=20token=20=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0011 PR-E4: chat-runner から OAuth フロー関連を全削除し、buildMcpServers が FileSystemOAuthStore から token を読んで MCP HTTP transport の Authorization header に注入する形に切替える。AuthRequestCard を chat 文脈から切り離して project settings の MCP server 行に配置し、UI 起点の Connect ボタンとして再構成する。 core (schema): - McpServerConfig.oauth を required 化 (PR-E1 で optional 導入、本 PR で必須化) - ChatBlockSchema から auth_request 型を削除 ai-engine: - chat-runner.ts: ~250 行削除 (runOAuthCallback / handleAuthToolResult / findLatestPendingAuthRequest / parseAuthToolName 経路 / TurnState.stashedAuthUses) - auth-detector.ts: ファイル削除 - server.ts: oauth_callback WS message 経路と ChatOAuthCallbackSchema を削除 - stream.ts: chat_auth_request event 型を削除 - mcp/build-mcp-servers.ts: oauthStore deps 追加 + async 化、token を Authorization header に注入。expiresAt が過去なら無視 (codex Major 対応) - agent-runner.ts: deps に oauthStore 追加 frontend: - lib/store.ts: chat_auth_request handler / sendOAuthCallback action を削除 - lib/ws.ts: sendOAuthCallback を削除 - components/chat/chat-message.tsx: auth_request branch / AuthRequestCard import を削除 - components/{chat → mcp}/auth-request-card.tsx: prop signature を `block: AuthRequestBlock` から `{ mcpServerId, mcpServerLabel }` に refactor、 ディレクトリも mcp/ に移動 (chat 文脈非依存) - components/dialog/project-settings-dialog.tsx: oauth.clientId 入力欄を追加し、 保存済み + 編集なし + clientId 入力済 のとき AuthRequestCard を埋め込み表示 codex セカンドオピニオン Major 対応: 1. 期限切れトークンの扱い: buildMcpServers で expiresAt を確認し過去なら null 扱い 2. isOAuthConnectable: 全フィールド比較 (JSON.stringify) で options 編集も検知 3. retrograde guard: 外部 MCP tool_use テストに `chat_auth_request` event が emit されないことを assert (旧経路の誤再導入を CI で検知) テスト: - core: 94 / storage: 97 / ai-engine: 235 / frontend: 280 すべて pass - 削除: chat-runner.test.ts の auth_request 変換 describe (~270 行)、auth-detector.test.ts - 追加: build-mcp-servers expiry / token-type 注入 / クロス config テスト --- packages/ai-engine/src/agent-runner.test.ts | 12 +- packages/ai-engine/src/agent-runner.ts | 10 +- packages/ai-engine/src/auth-detector.test.ts | 87 ----- packages/ai-engine/src/auth-detector.ts | 37 -- packages/ai-engine/src/chat-runner.test.ts | 310 +++-------------- packages/ai-engine/src/chat-runner.ts | 316 ++---------------- .../src/mcp/build-mcp-servers.test.ts | 160 +++++++-- .../ai-engine/src/mcp/build-mcp-servers.ts | 31 +- packages/ai-engine/src/server.ts | 71 +--- packages/ai-engine/src/stream.ts | 25 +- packages/core/src/schema.test.ts | 99 ++---- packages/core/src/schema.ts | 42 +-- .../mcp/[mcpServerId]/oauth/route.test.ts | 28 +- .../[id]/mcp/[mcpServerId]/oauth/route.ts | 10 +- .../src/app/api/projects/[id]/route.test.ts | 4 +- .../src/components/chat/chat-message.tsx | 4 - .../dialog/project-settings-dialog.test.tsx | 10 +- .../dialog/project-settings-dialog.tsx | 62 +++- .../{chat => mcp}/auth-request-card.test.tsx | 31 +- .../{chat => mcp}/auth-request-card.tsx | 38 +-- packages/frontend/src/lib/store.ts | 105 +----- packages/frontend/src/lib/ws.ts | 6 - 22 files changed, 402 insertions(+), 1096 deletions(-) delete mode 100644 packages/ai-engine/src/auth-detector.test.ts delete mode 100644 packages/ai-engine/src/auth-detector.ts rename packages/frontend/src/components/{chat => mcp}/auth-request-card.test.tsx (85%) rename packages/frontend/src/components/{chat => mcp}/auth-request-card.tsx (86%) diff --git a/packages/ai-engine/src/agent-runner.test.ts b/packages/ai-engine/src/agent-runner.test.ts index 9926a9e..49448a4 100644 --- a/packages/ai-engine/src/agent-runner.test.ts +++ b/packages/ai-engine/src/agent-runner.test.ts @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import type { UseCaseNode } from '@tally/core'; -import { FileSystemProjectStore, type ProjectStore } from '@tally/storage'; +import { FileSystemOAuthStore, FileSystemProjectStore, type ProjectStore } from '@tally/storage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runAgent, type SdkLike } from './agent-runner'; @@ -75,6 +75,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk: mockSdk as never, store, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, req: { type: 'start', @@ -116,6 +117,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk: mockSdk as never, store, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, req: { type: 'start', @@ -148,6 +150,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk: mockSdk as never, store, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, req: { type: 'start', @@ -173,6 +176,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk: mockSdk as never, store, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, req: { type: 'start', @@ -202,6 +206,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk: mockSdk as never, store, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, req: { type: 'start', @@ -257,6 +262,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk: mockSdk as never, store, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, req: { type: 'start', @@ -317,6 +323,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk: mockSdk as never, store, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, req: { type: 'start', @@ -411,6 +418,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk, store, + oauthStore: new FileSystemOAuthStore(projectDir), projectDir, req: { type: 'start', @@ -474,6 +482,7 @@ describe('runAgent', () => { for await (const e of runAgent({ sdk, store, + oauthStore: new FileSystemOAuthStore('/ws'), projectDir: '/ws', req: { type: 'start', @@ -546,6 +555,7 @@ describe('runAgent', () => { for await (const _ of runAgent({ sdk, store, + oauthStore: new FileSystemOAuthStore('/ws'), projectDir: '/ws', req: { type: 'start', diff --git a/packages/ai-engine/src/agent-runner.ts b/packages/ai-engine/src/agent-runner.ts index 45031c7..4ce474e 100644 --- a/packages/ai-engine/src/agent-runner.ts +++ b/packages/ai-engine/src/agent-runner.ts @@ -1,5 +1,5 @@ import type { AgentName } from '@tally/core'; -import type { ProjectStore } from '@tally/storage'; +import type { OAuthStore, ProjectStore } from '@tally/storage'; import { AGENT_REGISTRY } from './agents/registry'; import { buildMcpServers } from './mcp/build-mcp-servers'; @@ -67,6 +67,9 @@ export interface SdkLike { export interface RunAgentDeps { sdk: SdkLike; store: ProjectStore; + // ADR-0011 PR-E4: 外部 MCP の Authorization header 注入用に、buildMcpServers が + // FileSystemOAuthStore.read を叩く。agent-runner は per-request に store を渡される。 + oauthStore: OAuthStore; projectDir: string; req: StartRequest; } @@ -77,7 +80,7 @@ export interface RunAgentDeps { // SDK 呼び出し中に MCP ツールハンドラが emit した side events (node_created など) は // 次の SDK メッセージを受け取るタイミングで合流して flush する。 export async function* runAgent(deps: RunAgentDeps): AsyncGenerator { - const { sdk, store, projectDir, req } = deps; + const { sdk, store, oauthStore, projectDir, req } = deps; yield { type: 'start', agent: req.agent, input: req.input }; const def = AGENT_REGISTRY[req.agent]; @@ -128,9 +131,10 @@ export async function* runAgent(deps: RunAgentDeps): AsyncGenerator // env 未設定時は throw → catch で error event に流す。 const projectMeta = await store.getProjectMeta(); const externalConfigs = projectMeta?.mcpServers ?? []; - const { mcpServers, allowedTools: externalAllowed } = buildMcpServers({ + const { mcpServers, allowedTools: externalAllowed } = await buildMcpServers({ tallyMcp: mcp, configs: externalConfigs, + oauthStore, }); // built-in ツールは mcp__ プレフィックスを持たないもの (Read / Glob / Grep など)。 diff --git a/packages/ai-engine/src/auth-detector.test.ts b/packages/ai-engine/src/auth-detector.test.ts deleted file mode 100644 index ccdfed6..0000000 --- a/packages/ai-engine/src/auth-detector.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { extractAuthUrl, parseAuthToolName } from './auth-detector'; - -describe('parseAuthToolName', () => { - it('mcp__atlassian__authenticate を分解', () => { - expect(parseAuthToolName('mcp__atlassian__authenticate')).toEqual({ - mcpServerId: 'atlassian', - kind: 'authenticate', - }); - }); - - it('mcp__atlassian__complete_authentication を分解', () => { - expect(parseAuthToolName('mcp__atlassian__complete_authentication')).toEqual({ - mcpServerId: 'atlassian', - kind: 'complete_authentication', - }); - }); - - it('別 id でも動く (jira-cloud 等のハイフン許容)', () => { - expect(parseAuthToolName('mcp__jira-cloud__authenticate')).toEqual({ - mcpServerId: 'jira-cloud', - kind: 'authenticate', - }); - }); - - it('Tally 内部 MCP は match しない', () => { - expect(parseAuthToolName('mcp__tally__create_node')).toBeNull(); - }); - - it('別ツール名 (read_issue など) は match しない', () => { - expect(parseAuthToolName('mcp__atlassian__read_issue')).toBeNull(); - }); - - it('id が大文字を含むと reject', () => { - expect(parseAuthToolName('mcp__Atlassian__authenticate')).toBeNull(); - }); -}); - -describe('extractAuthUrl', () => { - it('SDK 標準 output 形式から URL を抽出', () => { - const out = `Ask the user to open this URL in their browser to authorize the atlassian MCP server: - -https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz - -Once they complete the flow, the server's tools will become available automatically.`; - expect(extractAuthUrl(out)).toBe( - 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz', - ); - }); - - it('折り返し (`\\\\\\n` + 空白) も復元してから抽出', () => { - const out = - 'Ask the user: https://mcp.atlassian.com/v1/authorize?response_type=code&cli\\\n ent_id=abc&state=xyz_done'; - expect(extractAuthUrl(out)).toBe( - 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz_done', - ); - }); - - it('クエリ文字列なしの URL は無視 (説明用 https://example.com 等)', () => { - expect(extractAuthUrl('See https://example.com for more info')).toBeNull(); - }); - - it('URL が無ければ null', () => { - expect(extractAuthUrl('no url here')).toBeNull(); - }); - - // 自然文末尾の句読点・括弧が URL に紛れて認可リンクが壊れていた。末尾を剥がす。 - it('文末の句読点を URL に含めない (period)', () => { - const out = '認証は https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz.'; - expect(extractAuthUrl(out)).toBe( - 'https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz', - ); - }); - - it('文末の閉じ括弧を URL に含めない', () => { - const out = '(認証は https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz)'; - expect(extractAuthUrl(out)).toBe( - 'https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz', - ); - }); - - it('複数の末尾句読点も剥がす', () => { - const out = '行ってください: https://mcp.atlassian.com/v1/authorize?state=xyz!?'; - expect(extractAuthUrl(out)).toBe('https://mcp.atlassian.com/v1/authorize?state=xyz'); - }); -}); diff --git a/packages/ai-engine/src/auth-detector.ts b/packages/ai-engine/src/auth-detector.ts deleted file mode 100644 index 2aae558..0000000 --- a/packages/ai-engine/src/auth-detector.ts +++ /dev/null @@ -1,37 +0,0 @@ -// 外部 MCP の OAuth 2.1 認証フローを検出するヘルパ。 -// chat-runner が SDK から流れてくる tool_use / tool_result を walk しながら -// authenticate / complete_authentication をパターンで識別し、 -// auth_request ブロックに変換する判断材料を提供する。 - -const AUTH_TOOL_NAME_RE = /^mcp__([a-z][a-z0-9-]{0,31})__(authenticate|complete_authentication)$/; - -export interface AuthToolNameMatch { - mcpServerId: string; - kind: 'authenticate' | 'complete_authentication'; -} - -// `mcp____authenticate` / `mcp____complete_authentication` を分解する。 -// id 部は McpServerIdRegex (core schema 側) と整合: 先頭英小文字 + 英小文字/数字/ハイフン、32 字以内。 -export function parseAuthToolName(name: string): AuthToolNameMatch | null { - const m = name.match(AUTH_TOOL_NAME_RE); - if (!m) return null; - return { mcpServerId: m[1] ?? '', kind: m[2] as 'authenticate' | 'complete_authentication' }; -} - -// authenticate tool_result.output に含まれる OAuth 認可エンドポイントの URL を抽出する。 -// SDK の典型的な出力例: -// "Ask the user to open this URL ... https://mcp.atlassian.com/v1/authorize?..." -// URL は折り返されている (`\<改行>` でエスケープされていることもある) ので -// 復元してから正規表現を当てる。 -export function extractAuthUrl(output: string): string | null { - // SDK が 80 桁折返しで `\<改行 + 連続空白>` を入れる場合がある。これを潰す。 - const unfolded = output.replace(/\\\n\s*/g, ''); - // 最初に見つかった https://...?...&... を採用。query string を含むものに限定して - // 単なる説明用の URL (https://example.com 等) を引かないようにする。 - const urlRe = /https:\/\/[^\s)"'<>]+\?[^\s)"'<>]+/; - const m = unfolded.match(urlRe); - if (!m) return null; - // 自然文末尾の句読点 / 閉じ括弧が URL に紛れるのを除く。 - // 例: "...state=xyz." / "...state=xyz)" → 末尾の `.` `)` を落とす。 - return m[0].replace(/[).,;:!?]+$/u, ''); -} diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index bbee2df..526d9f3 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import type { ChatMessage, Node } from '@tally/core'; import { newChatMessageId } from '@tally/core'; -import { FileSystemChatStore, FileSystemProjectStore } from '@tally/storage'; +import { FileSystemChatStore, FileSystemOAuthStore, FileSystemProjectStore } from '@tally/storage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SdkLike } from './agent-runner'; @@ -73,6 +73,7 @@ describe('ChatRunner', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -119,6 +120,7 @@ describe('ChatRunner', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -169,6 +171,7 @@ describe('ChatRunner', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -233,6 +236,7 @@ describe('ChatRunner', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -308,6 +312,7 @@ describe('ChatRunner', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -361,6 +366,7 @@ describe('ChatRunner', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -393,6 +399,7 @@ describe('ChatRunner', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -520,6 +527,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { name: 'T', kind: 'atlassian', url: 'https://t.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -540,6 +548,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -590,6 +599,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -606,7 +616,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { rmSync(root, { recursive: true, force: true }); }); - it('OAuth 採用後: SDK 設定に Authorization header は付かない (auth は MCP/SDK 任せ)', async () => { + it('PR-E4: token store に保存された access_token を Authorization header に注入する', async () => { const root = mkdtempSync(path.join(tmpdir(), 'tally-task11c-')); const ps = new FileSystemProjectStore(root); await ps.saveProjectMeta({ @@ -619,12 +629,22 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { name: 'A', kind: 'atlassian', url: 'https://api.atlassian.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); + // 事前に oauth store にトークンを書く (= 認証済み状態)。 + const oauthStore = new FileSystemOAuthStore(root); + await oauthStore.write({ + mcpServerId: 'atlassian', + accessToken: 'a-tok', + acquiredAt: new Date().toISOString(), + tokenType: 'Bearer', + }); + const chatStore = new FileSystemChatStore(root); const projectStore = new FileSystemProjectStore(root); const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); @@ -638,6 +658,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { sdk, chatStore, projectStore, + oauthStore, projectDir: root, threadId: thread.id, }); @@ -650,8 +671,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { }; const atlassian = callArg.options?.mcpServers?.atlassian; expect(atlassian?.url).toBe('https://api.atlassian.test/mcp'); - // OAuth 2.1 採用: Tally は Authorization header を組み立てない - expect(atlassian?.headers).toBeUndefined(); + expect(atlassian?.headers).toEqual({ Authorization: 'Bearer a-tok' }); rmSync(root, { recursive: true, force: true }); }); @@ -676,6 +696,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -722,6 +743,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -734,6 +756,11 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( expect(useEvent.toolUseId).toBe('atlassian-tu-1'); expect(useEvent.name).toBe('mcp__atlassian__jira_get_issue'); } + + // PR-E4 retrograde guard: 旧 chat_auth_request 経路 (mcp__*__authenticate / + // complete_authentication 検出 → auth_request block 変換 + chat_auth_request emit) は + // 削除した。外部 MCP 呼び出し時に誤って再導入されたら、ここで CI が落ちる。 + expect(events.some((e) => (e.type as string) === 'chat_auth_request')).toBe(false); const resultEvent = events.find((e) => e.type === 'chat_tool_external_result'); expect(resultEvent).toBeDefined(); if (resultEvent && resultEvent.type === 'chat_tool_external_result') { @@ -799,6 +826,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -823,6 +851,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -869,6 +898,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -908,6 +938,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -954,6 +985,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -984,6 +1016,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -1029,6 +1062,7 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( sdk, chatStore, projectStore, + oauthStore: new FileSystemOAuthStore(root), projectDir: root, threadId: thread.id, }); @@ -1176,271 +1210,3 @@ describe('buildChatPrompt — tool_use/tool_result replay (Task 14, T4 fix)', () expect(prompt).toContain('初回'); }); }); - -// 外部 MCP の OAuth 2.1 フロー: authenticate / complete_authentication tool_use を -// 検出して auth_request ブロックに変換する経路の検証。 -describe('ChatRunner — auth_request 変換 (OAuth 2.1)', () => { - async function setup() { - const root = mkdtempSync(path.join(tmpdir(), 'tally-chat-auth-')); - const ps = new FileSystemProjectStore(root); - await ps.saveProjectMeta({ - id: 'proj-1', - name: 'P', - codebases: [], - mcpServers: [ - { - id: 'atlassian', - name: 'My Atlassian', - kind: 'atlassian', - url: 'https://t.test/mcp', - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - const chatStore = new FileSystemChatStore(root); - const projectStore = new FileSystemProjectStore(root); - const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - return { root, chatStore, projectStore, thread }; - } - - function makeAuthSdk(authUrl: string): SdkLike { - return { - query: () => - (async function* () { - yield { - type: 'assistant', - message: { - content: [ - { type: 'text', text: '認証フローを開始します' }, - { - type: 'tool_use', - id: 'auth-tu-1', - name: 'mcp__atlassian__authenticate', - input: {}, - }, - ], - }, - } as unknown as SdkMessageLike; - yield { - type: 'user', - message: { - content: [ - { - type: 'tool_result', - tool_use_id: 'auth-tu-1', - content: [{ type: 'text', text: `Open: ${authUrl}` }], - }, - ], - }, - } as unknown as SdkMessageLike; - yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; - })(), - }; - } - - it('authenticate: tool_use/tool_result を消化し、auth_request{pending} + chat_auth_request event を出す', async () => { - const { root, chatStore, projectStore, thread } = await setup(); - try { - const authUrl = - 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; - const runner = new ChatRunner({ - sdk: makeAuthSdk(authUrl), - chatStore, - projectStore, - projectDir: root, - threadId: thread.id, - }); - const events: ChatEvent[] = []; - for await (const e of runner.runUserTurn('jira を読んで')) events.push(e); - - expect(events.find((e) => e.type === 'chat_tool_external_use')).toBeUndefined(); - expect(events.find((e) => e.type === 'chat_tool_external_result')).toBeUndefined(); - - const authEvt = events.find((e) => e.type === 'chat_auth_request'); - expect(authEvt).toBeDefined(); - if (authEvt && authEvt.type === 'chat_auth_request') { - expect(authEvt.mcpServerId).toBe('atlassian'); - expect(authEvt.mcpServerLabel).toBe('My Atlassian'); - expect(authEvt.authUrl).toBe(authUrl); - expect(authEvt.status).toBe('pending'); - } - - const reloaded = await chatStore.getChat(thread.id); - const assistant = reloaded?.messages.find((m) => m.role === 'assistant'); - const blocks = assistant?.blocks ?? []; - const hasRawToolUse = blocks.some( - (b) => b.type === 'tool_use' && b.name.includes('authenticate'), - ); - expect(hasRawToolUse).toBe(false); - const authBlock = blocks.find((b) => b.type === 'auth_request'); - expect(authBlock).toBeDefined(); - if (authBlock && authBlock.type === 'auth_request') { - expect(authBlock.status).toBe('pending'); - expect(authBlock.authUrl).toBe(authUrl); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - it('complete_authentication 成功: 同 thread の最新 pending が completed に更新される', async () => { - const { root, chatStore, projectStore, thread } = await setup(); - try { - const authUrl = - 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; - const runner1 = new ChatRunner({ - sdk: makeAuthSdk(authUrl), - chatStore, - projectStore, - projectDir: root, - threadId: thread.id, - }); - for await (const _ of runner1.runUserTurn('jira を読んで')) { - void _; - } - - const sdk2: SdkLike = { - query: () => - (async function* () { - yield { - type: 'assistant', - message: { - content: [ - { - type: 'tool_use', - id: 'auth-tu-2', - name: 'mcp__atlassian__complete_authentication', - input: { url: 'http://localhost:54801/callback?code=xxx&state=xyz' }, - }, - ], - }, - } as unknown as SdkMessageLike; - yield { - type: 'user', - message: { - content: [ - { - type: 'tool_result', - tool_use_id: 'auth-tu-2', - content: [{ type: 'text', text: 'authenticated' }], - }, - ], - }, - } as unknown as SdkMessageLike; - yield { - type: 'result', - subtype: 'success', - result: 'done', - } as unknown as SdkMessageLike; - })(), - }; - const runner2 = new ChatRunner({ - sdk: sdk2, - chatStore, - projectStore, - projectDir: root, - threadId: thread.id, - }); - const events: ChatEvent[] = []; - for await (const e of runner2.runUserTurn( - '[OAuth callback] http://localhost:54801/callback?code=xxx&state=xyz', - )) - events.push(e); - - const authEvt = events.find((e) => e.type === 'chat_auth_request'); - expect(authEvt).toBeDefined(); - if (authEvt && authEvt.type === 'chat_auth_request') { - expect(authEvt.status).toBe('completed'); - } - - const reloaded = await chatStore.getChat(thread.id); - const allAuthBlocks = (reloaded?.messages ?? []).flatMap((m) => - m.blocks.filter((b) => b.type === 'auth_request'), - ); - expect(allAuthBlocks).toHaveLength(1); - const ab = allAuthBlocks[0]; - if (ab && ab.type === 'auth_request') { - expect(ab.status).toBe('completed'); - expect(ab.authUrl).toBe(authUrl); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - it('complete_authentication 失敗 (ok=false): 最新 pending が failed + failureMessage 付きで更新', async () => { - const { root, chatStore, projectStore, thread } = await setup(); - try { - const authUrl = - 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; - const runner1 = new ChatRunner({ - sdk: makeAuthSdk(authUrl), - chatStore, - projectStore, - projectDir: root, - threadId: thread.id, - }); - for await (const _ of runner1.runUserTurn('jira を読んで')) { - void _; - } - - const sdk2: SdkLike = { - query: () => - (async function* () { - yield { - type: 'assistant', - message: { - content: [ - { - type: 'tool_use', - id: 'auth-tu-2', - name: 'mcp__atlassian__complete_authentication', - input: { url: 'http://localhost:54801/callback?code=bad' }, - }, - ], - }, - } as unknown as SdkMessageLike; - yield { - type: 'user', - message: { - content: [ - { - type: 'tool_result', - tool_use_id: 'auth-tu-2', - content: [{ type: 'text', text: 'invalid_grant: state mismatch' }], - is_error: true, - }, - ], - }, - } as unknown as SdkMessageLike; - yield { - type: 'result', - subtype: 'success', - result: 'done', - } as unknown as SdkMessageLike; - })(), - }; - const runner2 = new ChatRunner({ - sdk: sdk2, - chatStore, - projectStore, - projectDir: root, - threadId: thread.id, - }); - const events: ChatEvent[] = []; - for await (const e of runner2.runUserTurn('callback URL: ...')) events.push(e); - - const authEvt = events.find((e) => e.type === 'chat_auth_request'); - expect(authEvt).toBeDefined(); - if (authEvt?.type === 'chat_auth_request' && authEvt.status === 'failed') { - expect(authEvt.failureMessage).toContain('invalid_grant'); - } else { - throw new Error('expected failed auth_request event'); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index caa95cf..39feee8 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -6,11 +6,10 @@ import { newChatMessageId, newToolUseId, } from '@tally/core'; -import type { ChatStore, ProjectStore } from '@tally/storage'; +import type { ChatStore, OAuthStore, ProjectStore } from '@tally/storage'; import type { SdkLike, SdkQueryHandle, SdkUserMessageLike } from './agent-runner'; import { AsyncIterableInput } from './async-input'; -import { type AuthToolNameMatch, extractAuthUrl, parseAuthToolName } from './auth-detector'; import { buildMcpServers } from './mcp/build-mcp-servers'; import type { ChatEvent, SdkMessageLike } from './stream'; import { CreateEdgeInputSchema, createEdgeHandler } from './tools/create-edge'; @@ -24,6 +23,9 @@ export interface ChatRunnerDeps { sdk: SdkLike; chatStore: ChatStore; projectStore: ProjectStore; + // ADR-0011 PR-E4: 各 turn の startQueryInternal で外部 MCP の Authorization + // header を組み立てるために OAuthStore を読む。 + oauthStore: OAuthStore; projectDir: string; threadId: string; } @@ -40,36 +42,6 @@ function truncateForPersistence(output: string): string { return `${head}\n... (truncated, ${output.length} chars total)`; } -// 最新の pending auth_request ブロックを探す (同一 mcpServerId 限定)。 -// thread.messages を末尾から走査し、最初に見つかった pending を返す。 -// 同一 server に対する直近の認証フローのみを更新対象にして、過去に completed/failed で -// 終わったブロックには触らない方針。 -function findLatestPendingAuthRequest( - messages: ChatMessage[], - mcpServerId: string, -): { - messageId: string; - blockIndex: number; - block: Extract; -} | null { - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]; - if (!m) continue; - for (let j = m.blocks.length - 1; j >= 0; j--) { - const b = m.blocks[j]; - if ( - b && - b.type === 'auth_request' && - b.mcpServerId === mcpServerId && - b.status === 'pending' - ) { - return { messageId: m.id, blockIndex: j, block: b }; - } - } - } - return null; -} - // SDK の assistant / user message から抽出する block の単純化形。 // Tally MCP の tool_use は MCP intercept 経路で処理されるので拾わない。 // 外部 MCP (mcp__tally__ 以外) の tool_use / tool_result は永続化と UI 通知のためここで拾う (Task 12)。 @@ -94,8 +66,6 @@ interface TurnState { assistantMsgId: string; queue: EventQueue; textBuffer: string[]; - // OAuth 認証フロー検出用 stash (tool_use 受信 → tool_result 到達時に auth_request に変換)。 - stashedAuthUses: Map; // 外部 MCP の id → name の即引き map (label 表示用、turn 中は不変)。 externalConfigById: Map; // 同 turn 中に観測した外部 MCP の tool_use の id 集合。tool_result が来た時、 @@ -245,7 +215,6 @@ export class ChatRunner { assistantMsgId, queue, textBuffer: [], - stashedAuthUses: new Map(), externalConfigById: this.cachedExternalConfigById ?? new Map(), externalToolUseIds: new Set(), }; @@ -336,7 +305,11 @@ export class ChatRunner { // currentTurn から動的解決する。Builder には turn 越境した値を渡さず、 // currentTurn 経由で参照させる。 const mcp = this.buildMcpServer(tools); - const built = buildMcpServers({ tallyMcp: mcp, configs: externalConfigs }); + const built = await buildMcpServers({ + tallyMcp: mcp, + configs: externalConfigs, + oauthStore: this.deps.oauthStore, + }); const input = new AsyncIterableInput(); this.input = input; @@ -414,12 +387,7 @@ export class ChatRunner { const turn = this.currentTurn; if (!turn) return; const { chatStore, threadId } = this.deps; - const { assistantMsgId, queue, textBuffer, stashedAuthUses } = turn; - // ensureQuery 完了直後に SDK が即 yield する test mock のような race ケースで - // turn.externalConfigById が初期値 (空 Map) のまま読まれることがあるので、 - // 最新の cachedExternalConfigById を優先する (load 後に turn は再代入されるが - // dispatch のタイミング差で古い参照を保持する可能性がある)。 - const externalConfigById = this.cachedExternalConfigById ?? turn.externalConfigById; + const { assistantMsgId, queue, textBuffer } = turn; // result message: turn 終了 const m = msg as unknown as { type?: string; subtype?: string }; @@ -436,13 +404,13 @@ export class ChatRunner { ]); } } else { - // text 出力なし。complete_authentication 専用 turn 等で blocks が 0 件の - // まま残ると UI に空アシスタント bubble が蓄積するため、プレースホルダを置く。 + // text 出力なし。tool 専用 turn 等で blocks が 0 件のまま残ると UI に + // 空アシスタント bubble が蓄積するため、プレースホルダを置く。 const current = await chatStore.getChat(threadId); const target = current?.messages.find((m2) => m2.id === assistantMsgId); if (target && target.blocks.length === 0) { await chatStore.replaceMessageBlocks(threadId, assistantMsgId, [ - { type: 'text', text: '(認証処理を完了しました)' }, + { type: 'text', text: '(処理を完了しました)' }, ]); } } @@ -457,12 +425,9 @@ export class ChatRunner { textBuffer.push(b.text); queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); } else if (b.type === 'tool_use') { - const authMatch = parseAuthToolName(b.name); - if (authMatch) { - const label = externalConfigById.get(authMatch.mcpServerId) ?? authMatch.mcpServerId; - stashedAuthUses.set(b.toolUseId, { match: authMatch, mcpServerLabel: label }); - continue; - } + // ADR-0011 PR-E4: 旧 `mcp____authenticate` 検出 → auth_request block 変換は削除。 + // OAuth 認証は Tally の Route Handler が完結させ、token は buildMcpServers が + // header 注入する。chat-runner は外部 MCP の tool_use をそのまま記録するだけ。 // 外部 MCP の tool_use: source='external' で永続化、承認 UI なし (Task 12)。 turn.externalToolUseIds.add(b.toolUseId); await chatStore.appendBlockToMessage(threadId, assistantMsgId, { @@ -480,18 +445,6 @@ export class ChatRunner { input: b.input, }); } else if (b.type === 'tool_result') { - const stash = stashedAuthUses.get(b.toolUseId); - if (stash) { - stashedAuthUses.delete(b.toolUseId); - await this.handleAuthToolResult({ - match: stash.match, - mcpServerLabel: stash.mcpServerLabel, - result: { ok: b.ok, output: b.output }, - assistantMsgId, - emit: (e) => queue.push(e), - }); - continue; - } // 同 turn 中に観測した外部 tool_use の id のみ external として扱う (CR 指摘 #19 2 周目)。 if (!turn.externalToolUseIds.has(b.toolUseId)) continue; // Task 13: 大規模 epic で tool_result が 500KB+ になり得るので、 @@ -559,238 +512,11 @@ export class ChatRunner { } } - // 外部 MCP の OAuth コールバック URL を受け取り、対応 server の complete_authentication - // のみを ephemeral に実行する。UI から構造化送信された mcpServerId を prompt と - // allowedTools の両方に固定することで、(1) callback URL の code/state を chat 履歴に - // 永続化しない、(2) 別 server の complete_authentication を呼ばせない、(3) 他の - // ツール実行 (create_node 等) を許さない、の 3 点を同時に満たす (PR-B CR Major)。 - // - // 通常 turn (runUserTurn) を再利用しないのは、user message の永続化と通常の - // assistant message ループ全体を回避するため。auth_request ブロックの更新は - // handleAuthToolResult が過去 message の最新 pending を探して書き換える経路で行う。 - // - // SDK 制約上、complete_authentication tool 自体は agent loop 経由でしか呼べないため、 - // sdk.query は呼ぶが allowedTools = [対象 tool 1 件] で他を遮断する。 - async *runOAuthCallback(mcpServerId: string, callbackUrl: string): AsyncGenerator { - // turn 並走禁止 (long-lived runUserTurn と同じガード)。 - if (this.currentTurn) { - yield { - type: 'error', - code: 'turn_in_progress', - message: '前のターンがまだ完了していません', - }; - return; - } - - const { chatStore, projectStore, threadId } = this.deps; - const projectMeta = await projectStore.getProjectMeta(); - const targetConfig = projectMeta?.mcpServers?.find((s) => s.id === mcpServerId); - if (!targetConfig) { - yield { - type: 'error', - code: 'mcp_server_not_found', - message: `MCP server "${mcpServerId}" not found in project config`, - }; - return; - } - - const assistantMsgId = newChatMessageId(); - const queue = new EventQueue(); - const turnState: TurnState = { - assistantMsgId, - queue, - textBuffer: [], - stashedAuthUses: new Map(), - externalConfigById: - this.cachedExternalConfigById ?? new Map([[mcpServerId, targetConfig.name]]), - externalToolUseIds: new Set(), - }; - this.currentTurn = turnState; - - // currentTurn を立てた直後から全体を try/finally で囲み、appendMessage 等の - // 中間ステップで throw しても currentTurn が解放されることを保証する (CR Major)。 - // 解放しないと以後 turn_in_progress で次の turn を受け付けられない。 - try { - // long-lived query 上で動かす (OAuth state を turn 跨ぎで保持するため)。 - try { - await this.ensureQuery(); - } catch (err) { - yield { - type: 'error', - code: 'mcp_config_invalid', - message: err instanceof Error ? err.message : String(err), - }; - return; - } - turnState.externalConfigById = this.cachedExternalConfigById ?? new Map(); - - // ephemeral: user message は chatStore に append しない (callback URL の code/state - // を chat 履歴に残さない)。空 assistant message だけは tool_use/tool_result の親 - // として必要なので append し、turn 末で「(認証処理を完了しました)」プレースホルダで - // 埋める (dispatchSdkMessage の result 処理が自動で実行する)。 - await chatStore.appendMessage(threadId, { - id: assistantMsgId, - role: 'assistant', - blocks: [], - createdAt: new Date().toISOString(), - }); - yield { type: 'chat_assistant_message_started', messageId: assistantMsgId }; - - if (!this.input) { - throw new Error('invariant: ensureQuery succeeded but input is null'); - } - - // 構造化 prompt: AI に必ず指定 server の complete_authentication を呼ばせる。 - // long-lived query では allowedTools が固定 (ensureQuery 起動時に決まる) なので - // 単一 tool への制約はかけられないが、prompt 指示で実用上はモデルが従う。 - const prompt = [ - 'OAuth コールバック URL を受信しました。', - `mcp__${mcpServerId}__complete_authentication ツールを呼び、`, - '以下の callback URL で認証を完了してください:', - callbackUrl, - '', - '他の MCP server の complete_authentication ツールや、', - '別の作業ツール (create_node 等) は呼ばないでください。', - ].join('\n'); - - this.input.push({ - type: 'user', - message: { role: 'user', content: prompt }, - parent_tool_use_id: null, - }); - - while (true) { - const evt = await queue.next(); - if (evt === null) break; - yield evt; - if (evt.type === 'chat_turn_ended') break; - } - } finally { - this.currentTurn = null; - } - } - - // OAuth 認証系 tool_use/tool_result ペアを auth_request ブロックに変換する。 - // - authenticate: tool_result.output から auth URL を抽出して新規 pending ブロックを append - // - complete_authentication: 同 mcpServerId の最新 pending ブロックを completed/failed に更新 - // どちらの場合も chat_auth_request イベントを emit する (UI が card を再描画するための合図)。 - // tool_result の ok=false や URL 抽出失敗時は failed として扱い、UI に message を出す。 - private async handleAuthToolResult(opts: { - match: AuthToolNameMatch; - mcpServerLabel: string; - result: { ok: boolean; output: string }; - assistantMsgId: string; - emit: (e: ChatEvent) => void; - }): Promise { - const { match, mcpServerLabel, result, assistantMsgId, emit } = opts; - const { chatStore, threadId } = this.deps; - - if (match.kind === 'authenticate') { - const authUrl = result.ok ? extractAuthUrl(result.output) : null; - if (!authUrl) { - const failureMessage = result.ok - ? 'authenticate tool_result から URL を抽出できませんでした' - : result.output.slice(0, 256); - const placeholderUrl = 'https://invalid.invalid/?auth_url_unavailable'; - const block: ChatBlock = { - type: 'auth_request', - mcpServerId: match.mcpServerId, - mcpServerLabel, - authUrl: placeholderUrl, - status: 'failed', - failureMessage, - }; - await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); - emit({ - type: 'chat_auth_request', - messageId: assistantMsgId, - mcpServerId: match.mcpServerId, - mcpServerLabel, - authUrl: placeholderUrl, - status: 'failed', - failureMessage, - }); - return; - } - const block: ChatBlock = { - type: 'auth_request', - mcpServerId: match.mcpServerId, - mcpServerLabel, - authUrl, - status: 'pending', - }; - await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); - emit({ - type: 'chat_auth_request', - messageId: assistantMsgId, - mcpServerId: match.mcpServerId, - mcpServerLabel, - authUrl, - status: 'pending', - }); - return; - } - - // complete_authentication: 最新 pending ブロックを更新する。 - const thread = await chatStore.getChat(threadId); - if (!thread) return; - const found = findLatestPendingAuthRequest(thread.messages, match.mcpServerId); - if (!found) { - // 対応する pending が無い (履歴外で auth 済 / 別 thread で auth 済 / 重複呼び出し)。 - // 失敗時は新規 failed ブロックで残す。成功時はサイレント (ノイズ防止)。 - if (!result.ok) { - const failureMessage = result.output.slice(0, 256); - const placeholderUrl = 'https://invalid.invalid/?orphan_complete_failed'; - const block: ChatBlock = { - type: 'auth_request', - mcpServerId: match.mcpServerId, - mcpServerLabel, - authUrl: placeholderUrl, - status: 'failed', - failureMessage, - }; - await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); - emit({ - type: 'chat_auth_request', - messageId: assistantMsgId, - mcpServerId: match.mcpServerId, - mcpServerLabel, - authUrl: placeholderUrl, - status: 'failed', - failureMessage, - }); - } - return; - } - const updated: ChatBlock = result.ok - ? { ...found.block, status: 'completed' } - : { - ...found.block, - status: 'failed', - failureMessage: result.output.slice(0, 256), - }; - await chatStore.updateMessageBlock(threadId, found.messageId, found.blockIndex, updated); - if (updated.status === 'failed' && updated.failureMessage) { - emit({ - type: 'chat_auth_request', - messageId: found.messageId, - mcpServerId: match.mcpServerId, - mcpServerLabel, - authUrl: found.block.authUrl, - status: 'failed', - failureMessage: updated.failureMessage, - }); - } else { - emit({ - type: 'chat_auth_request', - messageId: found.messageId, - mcpServerId: match.mcpServerId, - mcpServerLabel, - authUrl: found.block.authUrl, - status: 'completed', - }); - } - } + // ADR-0011 PR-E4: 旧 runOAuthCallback / handleAuthToolResult / findLatestPendingAuthRequest / + // TurnState.stashedAuthUses / ChatBlockSchema.auth_request / ChatEvent.chat_auth_request を + // すべて削除した。OAuth 認証フローは Tally プロセス内の OAuthFlowOrchestrator + Route + // Handler (POST/GET/DELETE /api/projects//mcp//oauth) が完結させ、token は + // buildMcpServers が FileSystemOAuthStore から読んで Authorization header に注入する。 // 承認 intercept + 実ツール呼び出し。 // 非同期進行を 2 段階で公開する: diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts index 59b262d..ffe5b6a 100644 --- a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts +++ b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts @@ -1,26 +1,53 @@ +import type { McpOAuthToken } from '@tally/core'; +import type { OAuthStore } from '@tally/storage'; import { describe, expect, it } from 'vitest'; import { buildMcpServers } from './build-mcp-servers'; +// PR-E4 の token 注入を検証するためのテスト用 OAuthStore。 +// `read(id)` が指定 map から token を返す簡易実装。 +function makeOAuthStore(map: Record): OAuthStore { + return { + async read(id: string) { + return map[id] ?? null; + }, + async write(_token) { + // テストでは write は使わない。 + }, + async delete(_id: string) { + // 同上。 + }, + async list() { + return Object.keys(map); + }, + }; +} + +const baseAtlassianConfig = { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/v1/mcp', + oauth: { clientId: 'cid' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, +}; + describe('buildMcpServers', () => { - it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', () => { - const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, configs: [] }); + it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', async () => { + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [], + oauthStore: makeOAuthStore({}), + }); expect(Object.keys(result.mcpServers)).toEqual(['tally']); expect(result.allowedTools).toEqual(['mcp__tally__*']); }); - it('atlassian 1 個 → HTTP config (url のみ、Authorization header なし) + allowedTools', () => { - const result = buildMcpServers({ + it('token 未登録 → headers なし HTTP config (= MCP 側 401 で UI が認証フローへ)', async () => { + const result = await buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, - configs: [ - { - id: 'atlassian', - name: 'Atlassian', - kind: 'atlassian', - url: 'https://mcp.atlassian.example/v1/mcp', - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], + configs: [baseAtlassianConfig], + oauthStore: makeOAuthStore({}), }); const atlassian = result.mcpServers.atlassian as { type: string; @@ -28,15 +55,94 @@ describe('buildMcpServers', () => { headers?: unknown; }; expect(atlassian.type).toBe('http'); - expect(atlassian.url).toBe('https://mcp.atlassian.example/v1/mcp'); - // OAuth 2.1 採用: Tally は Authorization header を組み立てない + expect(atlassian.url).toBe(baseAtlassianConfig.url); expect(atlassian.headers).toBeUndefined(); - expect(result.allowedTools).toContain('mcp__tally__*'); expect(result.allowedTools).toContain('mcp__atlassian__*'); }); - it('複数の config を合成 → 各々が独立に build される', () => { - const result = buildMcpServers({ + it('token あり → Authorization: を headers に注入 (PR-E4)', async () => { + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'a-tok', + acquiredAt: '2026-05-02T00:00:00Z', + tokenType: 'Bearer', + }, + }), + }); + const atlassian = result.mcpServers.atlassian as { + type: string; + url: string; + headers?: Record; + }; + expect(atlassian.headers).toEqual({ Authorization: 'Bearer a-tok' }); + }); + + it('tokenType が DPoP のような非 Bearer でもそのまま注入する (RFC 9449 互換)', async () => { + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'd-tok', + acquiredAt: '2026-05-02T00:00:00Z', + tokenType: 'DPoP', + }, + }), + }); + const atlassian = result.mcpServers.atlassian as { headers?: Record }; + expect(atlassian.headers).toEqual({ Authorization: 'DPoP d-tok' }); + }); + + it('expiresAt が過去のトークンは無視 (= headers 無し、MCP 側 401 → UI が再認証)', async () => { + // codex Major 対応の検証: 期限切れトークンを盲目的に注入すると 401 が AI ツール失敗 + // として埋もれ、UI 側でユーザーに認証必要と通知できない。expiresAt < now なら null 扱い。 + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'a-tok-old', + acquiredAt: '2020-01-01T00:00:00Z', + expiresAt: '2020-01-01T01:00:00Z', // 過去 + tokenType: 'Bearer', + }, + }), + }); + const atlassian = result.mcpServers.atlassian as { + type: string; + url: string; + headers?: unknown; + }; + expect(atlassian.headers).toBeUndefined(); + }); + + it('expiresAt が未来のトークンは正常に注入', async () => { + const future = new Date(Date.now() + 3600_000).toISOString(); + const result = await buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [baseAtlassianConfig], + oauthStore: makeOAuthStore({ + atlassian: { + mcpServerId: 'atlassian', + accessToken: 'a-tok-fresh', + acquiredAt: new Date().toISOString(), + expiresAt: future, + tokenType: 'Bearer', + }, + }), + }); + const atlassian = result.mcpServers.atlassian as { headers?: Record }; + expect(atlassian.headers).toEqual({ Authorization: 'Bearer a-tok-fresh' }); + }); + + it('複数 config: 一部にだけ token がある → 該当だけ headers が付く', async () => { + const result = await buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, configs: [ { @@ -44,6 +150,7 @@ describe('buildMcpServers', () => { name: 'F', kind: 'atlassian', url: 'https://a.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, { @@ -51,16 +158,23 @@ describe('buildMcpServers', () => { name: 'S', kind: 'atlassian', url: 'https://b.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], + oauthStore: makeOAuthStore({ + first: { + mcpServerId: 'first', + accessToken: 'tok-1', + acquiredAt: '2026-05-02T00:00:00Z', + tokenType: 'Bearer', + }, + }), }); expect(Object.keys(result.mcpServers)).toEqual(['tally', 'first', 'second']); - const first = result.mcpServers.first as { url: string; headers?: unknown }; - const second = result.mcpServers.second as { url: string; headers?: unknown }; - expect(first.url).toBe('https://a.test/mcp'); - expect(second.url).toBe('https://b.test/mcp'); - expect(first.headers).toBeUndefined(); + const first = result.mcpServers.first as { headers?: Record }; + const second = result.mcpServers.second as { headers?: Record }; + expect(first.headers).toEqual({ Authorization: 'Bearer tok-1' }); expect(second.headers).toBeUndefined(); expect(result.allowedTools).toEqual(['mcp__tally__*', 'mcp__first__*', 'mcp__second__*']); }); diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.ts b/packages/ai-engine/src/mcp/build-mcp-servers.ts index 035167c..c3bb83f 100644 --- a/packages/ai-engine/src/mcp/build-mcp-servers.ts +++ b/packages/ai-engine/src/mcp/build-mcp-servers.ts @@ -1,15 +1,14 @@ import type { McpServerConfig } from '@tally/core'; +import type { OAuthStore } from '@tally/storage'; // SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 // chat-runner / agent-runner が共通で使える shape にする。 // -// 認証方針 (Premise 9 撤回後): -// MCP プロトコルの OAuth 2.1 を採用し、Tally は credentials を一切扱わない。 -// - 401 を受けたら Claude Agent SDK が WWW-Authenticate から OAuth metadata を取り、 -// ブラウザ経由 (or device flow) で auth、token 管理は SDK 側で完結する。 -// - ここでは Authorization header を組み立てない。url のみを SDK に渡す。 -// - PAT 認証の MCP server (sooperset 等) を使う場合は、その server 自身が起動時 env で -// credentials を持つ前提 (Tally は header passthrough しない)。 +// ADR-0011 PR-E4: OAuth 2.1 token は Tally プロセスが管理する。各外部 MCP server に対し +// FileSystemOAuthStore.read(mcpServerId) で token を取得し、SDK の mcpServers config の +// `headers: { Authorization: 'Bearer ' }` として注入する。token が無い (未認証) +// 場合は header 無しで construct する → MCP server 側が 401 を返し、UI 側は AuthRequestCard +// (project settings) 経由で認証フローを走らせる想定。 // // allowedTools は wildcard `mcp____*` (Spike 0b 確認済、Claude Code 2.1.117+ サポート)。 export interface BuildMcpServersInput { @@ -17,6 +16,8 @@ export interface BuildMcpServersInput { tallyMcp: unknown; // プロジェクト設定 project.mcpServers[]。 configs: McpServerConfig[]; + // 各 mcpServerId に対し read(id) で token を引いて header に注入する。 + oauthStore: OAuthStore; } export interface BuildMcpServersResult { @@ -25,17 +26,27 @@ export interface BuildMcpServersResult { } // SDK 設定と allowedTools を組み立てる。 -// 認証は MCP 側 (SDK の OAuth 2.1 / MCP server 自身) に委譲しており、Tally は touch しない。 -export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersResult { - const { tallyMcp, configs } = input; +export async function buildMcpServers(input: BuildMcpServersInput): Promise { + const { tallyMcp, configs, oauthStore } = input; const mcpServers: Record = { tally: tallyMcp }; const allowedTools: string[] = ['mcp__tally__*']; + const now = Date.now(); for (const cfg of configs) { + const token = await oauthStore.read(cfg.id); + // codex Major 対応: expiresAt が過去なら token は null 扱い。期限切れを注入すると + // MCP サーバーが 401 を返し、AI ループの tool_result に紛れて UI には認証問題が + // 通知されないため。期限切れ検知時は header 無しで構築 → MCP 側 401 → UI 側は + // project settings の AuthRequestCard で再認証を促す (PR-E5 で refresh 自動化予定)。 + const expired = token?.expiresAt !== undefined && Date.parse(token.expiresAt) <= now; + const usable = token && !expired ? token : null; + const headers: Record = {}; + if (usable) headers.Authorization = `${usable.tokenType} ${usable.accessToken}`; mcpServers[cfg.id] = { type: 'http' as const, url: cfg.url, + ...(Object.keys(headers).length > 0 ? { headers } : {}), }; allowedTools.push(`mcp__${cfg.id}__*`); } diff --git a/packages/ai-engine/src/server.ts b/packages/ai-engine/src/server.ts index e1bcd8e..4593104 100644 --- a/packages/ai-engine/src/server.ts +++ b/packages/ai-engine/src/server.ts @@ -1,6 +1,11 @@ import type { AgentName } from '@tally/core'; import { AGENT_NAMES } from '@tally/core'; -import { FileSystemChatStore, FileSystemProjectStore, listProjects } from '@tally/storage'; +import { + FileSystemChatStore, + FileSystemOAuthStore, + FileSystemProjectStore, + listProjects, +} from '@tally/storage'; import { type WebSocket, WebSocketServer } from 'ws'; import { z } from 'zod'; import type { SdkLike } from './agent-runner'; @@ -43,38 +48,9 @@ const ChatApproveToolSchema = z.object({ approved: z.boolean(), }); -// 外部 MCP の OAuth コールバック URL を構造化されたメッセージで送る (PR-B CR Major)。 -// 自然文 user_message でモデルに mcpServerId を解釈させると、複数 server があるときに -// 別 server の complete_authentication を呼ぶリスクがあったため、UI からは mcpServerId -// を構造化フィールドとして固定して送り、chat-runner 側で id 入りプロンプトを生成する。 -// mcpServerId は McpServerConfigSchema.id と同じ charset 制約 (英小文字 + 数字 + ハイフン)。 -// -// callbackUrl は UI の isLikelyCallbackUrl と同じ条件で validate する。 -// WS には UI 経由でない外部クライアントからも到達しうるので、サーバ側でも -// loopback / no-credentials / code+state 必須を確認する (CR Major)。 -const isValidCallbackUrl = (s: string): boolean => { - try { - const u = new URL(s); - if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; - if (u.username || u.password) return false; - const host = u.hostname; - if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]') { - return false; - } - return u.searchParams.has('code') && u.searchParams.has('state'); - } catch { - return false; - } -}; - -const ChatOAuthCallbackSchema = z.object({ - type: z.literal('oauth_callback'), - mcpServerId: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/u), - callbackUrl: z.string().url().refine(isValidCallbackUrl, { - message: - 'callback URL は loopback (localhost / 127.0.0.1 / ::1) で credential なし、code と state クエリ必須', - }), -}); +// ADR-0011 PR-E4: 旧 ChatOAuthCallbackSchema / isValidCallbackUrl は削除した。 +// OAuth フローは Tally の Route Handler が完結させるため WS 経由で callback URL を +// 受け取る必要が無くなった。 // registry からプロジェクト ID に対応するディレクトリパスを返す。 // 見つからなければ null。 @@ -146,6 +122,7 @@ function handleAgentConnection(ws: WebSocket, sdk: SdkLike): void { return; } const store = new FileSystemProjectStore(dir); + const oauthStore = new FileSystemOAuthStore(dir); try { // z.unknown() は undefined を許容するため parsed.input は unknown | undefined。 // StartRequest.input は unknown (必須) なので ?? {} で埋める。agent-runner 内で @@ -153,6 +130,7 @@ function handleAgentConnection(ws: WebSocket, sdk: SdkLike): void { for await (const evt of runAgent({ sdk, store, + oauthStore, projectDir: dir, req: { type: parsed.type, @@ -220,10 +198,12 @@ function handleChatConnection(ws: WebSocket, sdk: SdkLike): void { return; } const projectStore = new FileSystemProjectStore(dir); + const oauthStore = new FileSystemOAuthStore(dir); runner = new ChatRunner({ sdk, chatStore, projectStore, + oauthStore, projectDir: dir, threadId: result.data.threadId, }); @@ -275,28 +255,9 @@ function handleChatConnection(ws: WebSocket, sdk: SdkLike): void { return; } - if (obj.type === 'oauth_callback') { - const result = ChatOAuthCallbackSchema.safeParse(parsed); - if (!result.success) { - send({ - type: 'error', - code: 'bad_request', - message: `invalid oauth_callback: ${result.error.message}`, - }); - return; - } - try { - for await (const evt of runner.runOAuthCallback( - result.data.mcpServerId, - result.data.callbackUrl, - )) { - send(evt); - } - } catch (err) { - send({ type: 'error', code: 'agent_failed', message: String(err) }); - } - return; - } + // ADR-0011 PR-E4: 旧 'oauth_callback' message type は削除した。OAuth フローは + // Tally の Route Handler (POST/GET/DELETE /api/projects//mcp//oauth) + // が完結させるため、WS 経由で callback URL を受け取る経路は不要。 send({ type: 'error', diff --git a/packages/ai-engine/src/stream.ts b/packages/ai-engine/src/stream.ts index 9d0a125..e7c357c 100644 --- a/packages/ai-engine/src/stream.ts +++ b/packages/ai-engine/src/stream.ts @@ -57,28 +57,9 @@ export type ChatEvent = ok: boolean; output: string; } - // 外部 MCP の OAuth 2.1 認証要求。SDK の authenticate tool_use を検出して - // tool_use/tool_result の代わりに UI に流す。pending → completed/failed の遷移は - // 同 thread 内の complete_authentication tool_use 検出時に追って emit する。 - // status='failed' のときのみ failureMessage を持つ discriminated union 化により、 - // schema.ts (AuthRequestBlockSchema) の superRefine と型レベルで整合させる。 - | { - type: 'chat_auth_request'; - messageId: string; - mcpServerId: string; - mcpServerLabel: string; - authUrl: string; - status: 'pending' | 'completed'; - } - | { - type: 'chat_auth_request'; - messageId: string; - mcpServerId: string; - mcpServerLabel: string; - authUrl: string; - status: 'failed'; - failureMessage: string; - } + // ADR-0011 PR-E4: 旧 chat_auth_request event は削除した。OAuth 認証は Tally の + // OAuthFlowOrchestrator + Route Handler が完結させ、UI は Route Handler を polling + // するため WS 経由の event 通知は不要になった。 | { type: 'error'; code: string; message: string }; // SDK の厳密な型に依存せず、実行時に触る最小限のプロパティだけで型付けする。 diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index dd5b238..e0b3b9c 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -269,68 +269,16 @@ describe('ChatBlockSchema', () => { }).success, ).toBe(true); }); - it('auth_request ブロック (pending)', () => { + // ADR-0011 PR-E4: 旧 auth_request ブロックは削除した (OAuth は Tally の Route Handler + // が完結させ、UI は project settings の AuthRequestCard に独立)。 + it('auth_request type は schema から削除されたので reject される', () => { const r = ChatBlockSchema.safeParse({ type: 'auth_request', mcpServerId: 'atlassian', mcpServerLabel: 'Atlassian', - authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + authUrl: 'https://x.example/auth', status: 'pending', }); - expect(r.success).toBe(true); - }); - it('auth_request ブロック (failed + failureMessage)', () => { - const r = ChatBlockSchema.safeParse({ - type: 'auth_request', - mcpServerId: 'atlassian', - mcpServerLabel: 'Atlassian', - authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', - status: 'failed', - failureMessage: 'invalid_grant', - }); - expect(r.success).toBe(true); - }); - it('auth_request の authUrl が URL でないと reject', () => { - const r = ChatBlockSchema.safeParse({ - type: 'auth_request', - mcpServerId: 'atlassian', - mcpServerLabel: 'Atlassian', - authUrl: 'not-a-url', - status: 'pending', - }); - expect(r.success).toBe(false); - }); - // status と failureMessage の整合を schema で固定。 - it('auth_request: failed に failureMessage 無しは reject', () => { - const r = ChatBlockSchema.safeParse({ - type: 'auth_request', - mcpServerId: 'atlassian', - mcpServerLabel: 'Atlassian', - authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', - status: 'failed', - }); - expect(r.success).toBe(false); - }); - it('auth_request: pending に failureMessage 付きは reject', () => { - const r = ChatBlockSchema.safeParse({ - type: 'auth_request', - mcpServerId: 'atlassian', - mcpServerLabel: 'Atlassian', - authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', - status: 'pending', - failureMessage: 'should not be here', - }); - expect(r.success).toBe(false); - }); - it('auth_request: completed に failureMessage 付きは reject', () => { - const r = ChatBlockSchema.safeParse({ - type: 'auth_request', - mcpServerId: 'atlassian', - mcpServerLabel: 'Atlassian', - authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', - status: 'completed', - failureMessage: 'should not be here', - }); expect(r.success).toBe(false); }); it('不正な type は reject', () => { @@ -386,14 +334,15 @@ describe('ChatThreadSchema / ChatThreadMetaSchema', () => { }); describe('McpServerConfigSchema', () => { - // OAuth 2.1 採用後、Tally は url のみ持ち auth credentials は MCP/SDK に委譲する。 - // よって round-trip の最小形は id/name/kind/url/options のみ。 - it('atlassian round-trip (auth credentials は MCP/SDK 任せ、Tally は url のみ)', () => { + // ADR-0011 PR-E4: OAuth 2.1 を Tally プロセスが管理するため、oauth.clientId が required。 + // 過渡期 (PR-E1〜E3b) は optional だったが PR-E4 で required 化。 + it('atlassian round-trip (oauth.clientId 必須、url のみで MCP に到達)', () => { const raw = { id: 'atlassian-cloud', name: 'Atlassian Cloud', kind: 'atlassian' as const, url: 'https://mcp.atlassian.example/v1/mcp', + oauth: { clientId: 'cid-abc' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }; const parsed = McpServerConfigSchema.parse(raw); @@ -406,11 +355,23 @@ describe('McpServerConfigSchema', () => { name: 'A', kind: 'atlassian', url: 'https://x.test/mcp', + oauth: { clientId: 'cid' }, }); expect(parsed.options.maxChildIssues).toBe(30); expect(parsed.options.maxCommentsPerIssue).toBe(5); }); + it('oauth 未指定は fail (PR-E4 で required 化)', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + }), + ).toThrow(); + }); + it('url が URL でないと fail', () => { expect(() => McpServerConfigSchema.parse({ @@ -418,11 +379,12 @@ describe('McpServerConfigSchema', () => { name: 'A', kind: 'atlassian', url: 'not a url', + oauth: { clientId: 'cid' }, }), ).toThrow(); }); - it('oauth 設定 (clientId + scopes) を持って parse できる (ADR-0011 で導入、PR-E1 では optional)', () => { + it('oauth 設定 (clientId + scopes) を持って parse できる', () => { const raw = { id: 'atlassian', name: 'Atlassian', @@ -445,28 +407,17 @@ describe('McpServerConfigSchema', () => { }), ).toThrow(); }); - - it('auth フィールドが付いていても strict ではないので無視される (passthrough)', () => { - // schema 上は auth キーを持たない。zod は default で strict ではないため余計なキーは drop。 - // OAuth 移行前の YAML が混入しても parse 自体は通すが、auth 情報は使われない。 - const parsed = McpServerConfigSchema.parse({ - id: 'a', - name: 'A', - kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, // 余計なキー - } as Record); - expect((parsed as unknown as { auth?: unknown }).auth).toBeUndefined(); - }); }); describe('McpServerConfigSchema hardening', () => { // hardening test の共通 valid base。テスト対象のフィールドだけを上書きする。 + // PR-E4 で oauth が required 化されたので base に含める。 const validBase = { id: 'atlassian', name: 'Atlassian', kind: 'atlassian' as const, url: 'https://mcp.atlassian.example/v1/mcp', + oauth: { clientId: 'cid' }, }; describe('url: https 強制 + loopback 例外', () => { @@ -567,6 +518,7 @@ describe('ProjectSchema.mcpServers', () => { name: 'A', kind: 'atlassian' as const, url: 'https://x.test/mcp', + oauth: { clientId: 'cid' }, }, ], }; @@ -591,6 +543,7 @@ describe('ProjectMetaSchema.mcpServers', () => { name: 'A', kind: 'atlassian' as const, url: 'https://x.test/mcp', + oauth: { clientId: 'cid' }, }, ], }); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index fb65172..6c5ed50 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -291,9 +291,10 @@ export const McpServerConfigSchema = z.object({ 'url は https で始まる必要があります (loopback の http は例外的に許容)。URL 内資格情報 (user:pass@) は禁止', }, ), - // ADR-0011: Tally 側で OAuth 2.1 フローを管理する。MVP は段階導入のため optional - // で開始し、PR-E4 で旧 auth_request 経路を削除する際に required 化する。 - oauth: McpOAuthConfigSchema.optional(), + // ADR-0011: Tally 側で OAuth 2.1 フローを管理する。PR-E1 で optional 導入、 + // PR-E4 (旧 auth_request 経路の削除) で required 化。clientId が無いと + // POST /api/projects//mcp//oauth が 400 で詰まるので必須。 + oauth: McpOAuthConfigSchema, options: McpServerOptionsSchema, }); @@ -403,38 +404,9 @@ export const ChatBlockSchema = z.discriminatedUnion('type', [ ok: z.boolean(), output: z.string(), }), - // 外部 MCP (Atlassian 等) の OAuth 2.1 認証フローを 1 等地で扱うブロック。 - // SDK の `mcp____authenticate` tool_use を生のまま並べると UX が破綻する - // (URL がプレーンテキスト + redirect 先 localhost:XXXXX が即死) ため、検出して - // この auth_request に置き換える。status は同 thread 内の complete_authentication で更新。 - // failureMessage は status='failed' のときだけ持つ。superRefine で永続化フォーマット - // としての不正状態 (failed なのに message 無し / pending・completed に message が付く) - // を弾く。 - z - .object({ - type: z.literal('auth_request'), - mcpServerId: z.string().min(1), - mcpServerLabel: z.string().min(1), - authUrl: z.string().url(), - status: z.enum(['pending', 'completed', 'failed']), - failureMessage: z.string().optional(), - }) - .superRefine((b, ctx) => { - if (b.status === 'failed' && !b.failureMessage) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'failed auth_request には failureMessage が必要', - path: ['failureMessage'], - }); - } - if (b.status !== 'failed' && b.failureMessage !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'failureMessage は failed のときだけ設定できます', - path: ['failureMessage'], - }); - } - }), + // ADR-0011 PR-E4: 旧 auth_request block は削除した。OAuth 認証フローは Tally + // プロセス内の OAuthFlowOrchestrator + Route Handler が完結させ、UI は project + // settings の AuthRequestCard (button) から起動する。chat 文脈には残らない。 ]); export const ChatMessageSchema = z.object({ diff --git a/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.test.ts b/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.test.ts index dd7b64f..ec4291e 100644 --- a/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.test.ts @@ -34,7 +34,8 @@ afterEach(async () => { }); // project meta に Atlassian の mcpServer (oauth 設定付き) を 1 件追加する helper。 -async function addAtlassianServer(opts: { withOAuth: boolean }): Promise { +// ADR-0011 PR-E4: oauth は schema 上 required になったので、テストは常に clientId 込みで投入する。 +async function addAtlassianServer(): Promise { const store = new FileSystemProjectStore(projectDir); const meta = await store.getProjectMeta(); if (!meta) throw new Error('meta missing'); @@ -46,7 +47,7 @@ async function addAtlassianServer(opts: { withOAuth: boolean }): Promise { name: 'Atlassian', kind: 'atlassian', url: 'https://api.atlassian.com/mcp', - ...(opts.withOAuth ? { oauth: { clientId: 'cid-xyz' } } : {}), + oauth: { clientId: 'cid-xyz' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -56,7 +57,7 @@ async function addAtlassianServer(opts: { withOAuth: boolean }): Promise { describe('POST /api/projects/:id/mcp/:mcpServerId/oauth', () => { it('start で authorizationUrl を返し、orchestrator が pending になる', async () => { - await addAtlassianServer({ withOAuth: true }); + await addAtlassianServer(); const res = await POST(new Request('http://localhost', { method: 'POST' }), { params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), }); @@ -76,25 +77,20 @@ describe('POST /api/projects/:id/mcp/:mcpServerId/oauth', () => { }); it('未知 mcpServerId は 404', async () => { - await addAtlassianServer({ withOAuth: true }); + await addAtlassianServer(); const res = await POST(new Request('http://localhost', { method: 'POST' }), { params: Promise.resolve({ id: projectId, mcpServerId: 'no-such' }), }); expect(res.status).toBe(404); }); - it('oauth 未設定 mcpServer は 400 (clientId が無いと start できない)', async () => { - await addAtlassianServer({ withOAuth: false }); - const res = await POST(new Request('http://localhost', { method: 'POST' }), { - params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(/no oauth config/); - }); + // ADR-0011 PR-E4: oauth は schema 上 required になったため、`oauth 未設定` の case は + // YAML の手動編集等で起きうる「壊れた状態」だが、route.ts の事前チェックは保ったまま + // (server.oauth が undefined になるパス) で残す。type 上は到達不能だがコンパイル制約の + // 緩和で fall-through するので、test では再現しない。 it('既に pending の状態で再 start すると 409 Conflict (UI 漏洩しない固定文言)', async () => { - await addAtlassianServer({ withOAuth: true }); + await addAtlassianServer(); // 1 回目 start const r1 = await POST(new Request('http://localhost', { method: 'POST' }), { params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), @@ -120,7 +116,7 @@ describe('GET /api/projects/:id/mcp/:mcpServerId/oauth', () => { }); it('start 後は pending 状態を返す', async () => { - await addAtlassianServer({ withOAuth: true }); + await addAtlassianServer(); await POST(new Request('http://localhost', { method: 'POST' }), { params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), }); @@ -136,7 +132,7 @@ describe('GET /api/projects/:id/mcp/:mcpServerId/oauth', () => { describe('DELETE /api/projects/:id/mcp/:mcpServerId/oauth', () => { it('進行中フローを clear し、再 start が可能になる', async () => { - await addAtlassianServer({ withOAuth: true }); + await addAtlassianServer(); await POST(new Request('http://localhost', { method: 'POST' }), { params: Promise.resolve({ id: projectId, mcpServerId: 'atlassian' }), }); diff --git a/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.ts b/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.ts index f02e7d7..6aa1347 100644 --- a/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.ts +++ b/packages/frontend/src/app/api/projects/[id]/mcp/[mcpServerId]/oauth/route.ts @@ -54,13 +54,9 @@ async function resolveTarget( error: `mcp server not found: ${mcpServerId}`, }; } - if (!server.oauth) { - return { - ok: false, - status: 400, - error: `mcp server "${mcpServerId}" has no oauth config (set oauth.clientId in project.yaml)`, - }; - } + // ADR-0011 PR-E4: oauth は schema 上 required なので server.oauth は必ず存在する。 + // YAML 不整合 (手動編集等) は getProjectMeta() の zod parse が落として meta=null になり、 + // この経路に到達する前に上の 404 で弾かれる。 // kind は schema 上 'atlassian' literal だが registry lookup は将来の kind 追加に備えて // 共通の経路を残す。registry に無ければ 400。 const kind = server.kind as OAuthKind; diff --git a/packages/frontend/src/app/api/projects/[id]/route.test.ts b/packages/frontend/src/app/api/projects/[id]/route.test.ts index f6cec37..e4bf4b6 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.test.ts @@ -82,7 +82,7 @@ describe('PATCH /api/projects/:id', () => { name: 'Legacy', kind: 'atlassian', url: 'https://old.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'OLD' }, + oauth: { clientId: 'old-cid' }, options: { maxChildIssues: 1, maxCommentsPerIssue: 1 }, }, ], @@ -102,6 +102,7 @@ describe('PATCH /api/projects/:id', () => { name: 'Atlassian', kind: 'atlassian', url: 'https://x.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -151,6 +152,7 @@ describe('PATCH /api/projects/:id', () => { name: 'A', kind: 'atlassian', url: 'https://x.test/mcp', + oauth: { clientId: 'cid' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], diff --git a/packages/frontend/src/components/chat/chat-message.tsx b/packages/frontend/src/components/chat/chat-message.tsx index 0f78456..303e0aa 100644 --- a/packages/frontend/src/components/chat/chat-message.tsx +++ b/packages/frontend/src/components/chat/chat-message.tsx @@ -2,7 +2,6 @@ import type { ChatBlock, ChatMessage as ChatMessageType } from '@tally/core'; -import { AuthRequestCard } from './auth-request-card'; import { ToolApprovalCard } from './tool-approval-card'; interface Props { @@ -56,9 +55,6 @@ function renderBlock(block: ChatBlock, idx: number) { ); } - if (block.type === 'auth_request') { - return ; - } return null; } diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index 6b9c85e..b2a40c4 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -164,11 +164,13 @@ describe('ProjectSettingsDialog', () => { expect(ids).toContain('atlassian-2'); }); - it('auth / secret 関連の入力欄は無い (OAuth 2.1 で MCP/SDK 任せ)', async () => { + it('PR-E4: oauth.clientId 入力欄を持ち、PAT / API key 等の secret 入力欄は無い', async () => { render( {}} />); // 実 MCP 行に対する検証にするため先に行を 1 つ追加する (空 list だと退行検知が効かない)。 await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); - // auth scheme dropdown / envVar 入力欄 / secret 値入力欄、いずれも無い + // OAuth Client ID は表示される (PR-E4 で UI 追加) + expect(screen.getByLabelText('mcp-0-clientId')).toBeInTheDocument(); + // 旧 PAT / シークレット系 入力欄は存在しない expect(screen.queryByLabelText('mcp-0-scheme')).toBeNull(); expect(screen.queryByLabelText('mcp-0-emailEnvVar')).toBeNull(); expect(screen.queryByLabelText('mcp-0-tokenEnvVar')).toBeNull(); @@ -176,8 +178,8 @@ describe('ProjectSettingsDialog', () => { expect(screen.queryByLabelText(/シークレット/i)).toBeNull(); expect(screen.queryByLabelText(/api_token$/i)).toBeNull(); expect(screen.queryByLabelText(/password/i)).toBeNull(); - // OAuth/MCP 任せの説明文言 - expect(screen.getByText(/MCP プロトコル/)).toBeInTheDocument(); + // PR-E4 の説明文言: Tally プロセスが OAuth を直接管理する + expect(screen.getByText(/OAuth 2\.1 フローは Tally プロセス/)).toBeInTheDocument(); }); it('id 重複時は保存 disabled', async () => { diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.tsx index 50516fc..4119e22 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -3,17 +3,20 @@ import type { Codebase, McpServerConfig } from '@tally/core'; import { useEffect, useMemo, useState } from 'react'; +import { AuthRequestCard } from '@/components/mcp/auth-request-card'; import { TextInput } from '@/components/ui/text-input'; import { useCanvasStore } from '@/lib/store'; import { FolderBrowserDialog } from './folder-browser-dialog'; -// MCP サーバー新規追加時のデフォルト config (url 空、認証は MCP/SDK 任せ)。 +// MCP サーバー新規追加時のデフォルト config。oauth.clientId は PR-E4 で required 化 +// されたので空文字でも作るが、OAuth 認証は clientId 確定後でないと走れない。 function makeDefaultMcpServer(seq: number): McpServerConfig { return { id: `atlassian-${seq}`, name: 'Atlassian', kind: 'atlassian', url: '', + oauth: { clientId: '' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }; } @@ -32,6 +35,25 @@ function makeUid(): string { return `mcp-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; } +// AuthRequestCard を描画してよいかの判定。Route Handler は YAML 上の保存済み設定を +// 読むので、未保存の編集 (id / url / clientId / options など全フィールド) を含む状態で +// Connect させると、UI とサーバーの設定が乖離する。判定は「永続化済み projectMeta の +// 同 id の server と、入力中の row が完全一致するか」を JSON.stringify で全フィールド +// 比較する (codex Major 対応: 個別フィールドだけ比較すると options 編集を素通しする)。 +// clientId が空のときも当然 false。 +function isOAuthConnectable( + meta: { mcpServers?: McpServerConfig[] } | null, + entry: McpServerConfig, +): boolean { + if (!meta?.mcpServers) return false; + if (!entry.oauth.clientId) return false; + const saved = meta.mcpServers.find((s) => s.id === entry.id); + if (!saved) return false; + // 完全一致比較: McpServerConfig のフィールドはプリミティブ + 配列 + 浅い object のみで + // 順序も同じはずなので JSON.stringify で十分。将来 ネストが深くなったら deep-equal に置換。 + return JSON.stringify(saved) === JSON.stringify(entry); +} + export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const projectMeta = useCanvasStore((s) => s.projectMeta); const patchProjectMeta = useCanvasStore((s) => s.patchProjectMeta); @@ -192,8 +214,9 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
- 認証 (OAuth 2.1 / API token 等) は MCP プロトコルに任せます。Tally では URL - の登録のみ行い、 初回利用時に MCP サーバーから案内される認証フローに従ってください。 + ADR-0011: OAuth 2.1 フローは Tally プロセスが直接管理します。各 server の 「OAuth Client + ID」を入力して保存した後、下の「認証する」ボタンで認証フローを 起動してください + (別タブの認可画面 → 自動で完了)。
{mcpServers.length === 0 &&
MCP サーバー未設定
}
    @@ -241,6 +264,36 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos placeholder="https://mcp.atlassian.example/v1/mcp" /> +
    + OAuth Client ID + + updateMcpServer(i, { + ...s, + oauth: { ...s.oauth, clientId: e.target.value }, + }) + } + disabled={busy} + aria-label={`mcp-${i}-clientId`} + style={{ ...INPUT, flex: 1 }} + placeholder="OAuth client ID (provider 側で発行)" + /> +
    + {/* Connect ボタン: Atlassian の認証フローを起動する。 + オリジナル設定 (= 保存済み + 編集なし) かつ clientId 入力済みのときだけ + 描画する。未保存の編集を含む状態で Connect すると、Route Handler が + YAML 上の古い設定で動いてしまうため。 */} + {isOAuthConnectable(projectMeta, s) ? ( +
    + +
    + ) : ( +
    + Connect ボタンは「設定を保存 + Client ID を入力」後に表示されます。 +
    + )} ))}
@@ -357,6 +410,9 @@ const MCP_ROW = { gap: 6, flexWrap: 'wrap' as const, }; +const MCP_AUTH_ROW = { marginTop: 4 }; +const MCP_AUTH_HINT = { fontSize: 11, color: '#8b949e', marginTop: 4 }; +const INPUT_LABEL = { fontSize: 11, color: '#8b949e', minWidth: 100 }; const CB_PATH = { flex: 1, fontSize: 11, diff --git a/packages/frontend/src/components/chat/auth-request-card.test.tsx b/packages/frontend/src/components/mcp/auth-request-card.test.tsx similarity index 85% rename from packages/frontend/src/components/chat/auth-request-card.test.tsx rename to packages/frontend/src/components/mcp/auth-request-card.test.tsx index 68cf5dd..214512b 100644 --- a/packages/frontend/src/components/chat/auth-request-card.test.tsx +++ b/packages/frontend/src/components/mcp/auth-request-card.test.tsx @@ -5,19 +5,10 @@ import { useCanvasStore } from '@/lib/store'; import { AuthRequestCard } from './auth-request-card'; -// ADR-0011 PR-E3b: AuthRequestCard は Tally Route Handler 駆動になり、UI は内部 -// cardState でドライブされる。block.status / block.authUrl は使わない (PR-E4 まで残る -// 過渡期の field なので prop として渡るが見ない)。block.mcpServerId / mcpServerLabel -// だけが意味を持つ。 - -const pendingBlock = { - type: 'auth_request' as const, - mcpServerId: 'atlassian', - mcpServerLabel: 'My Atlassian', - // 旧 SDK 由来 URL。新カードは見ないが prop の shape を満たすため渡す。 - authUrl: 'https://legacy/sdk-loopback', - status: 'pending' as const, -}; +// ADR-0011 PR-E4: AuthRequestCard は project settings から呼ばれるスタンドアロン +// component。prop は `{ mcpServerId, mcpServerLabel }` のみ。 + +const cardProps = { mcpServerId: 'atlassian', mcpServerLabel: 'My Atlassian' }; const PROJECT_ID = 'proj-1'; const EXPECTED_BASE_URL = `/api/projects/${PROJECT_ID}/mcp/atlassian/oauth`; @@ -34,7 +25,7 @@ describe('AuthRequestCard (PR-E3b 新 API 駆動)', () => { }); it('idle: 認証ボタンを表示し、paste 入力欄は出ない', () => { - render(); + render(); expect(screen.getByText(/My Atlassian 認証/)).toBeInTheDocument(); expect(screen.getByText('未認証')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /My Atlassian で認証/ })).toBeInTheDocument(); @@ -55,7 +46,7 @@ describe('AuthRequestCard (PR-E3b 新 API 駆動)', () => { vi.stubGlobal('fetch', fetchMock); const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: /My Atlassian で認証/ })); await waitFor(() => @@ -80,7 +71,7 @@ describe('AuthRequestCard (PR-E3b 新 API 駆動)', () => { }), ), ); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: /My Atlassian で認証/ })); await waitFor(() => expect(screen.getByText('失敗')).toBeInTheDocument()); expect(screen.getByText(/oauth flow already in progress/)).toBeInTheDocument(); @@ -101,7 +92,7 @@ describe('AuthRequestCard (PR-E3b 新 API 駆動)', () => { }); vi.stubGlobal('fetch', fetchMock); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: /My Atlassian で認証/ })); await waitFor(() => expect(screen.getByText('失敗')).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: 'やり直す' })); @@ -128,7 +119,7 @@ describe('AuthRequestCard (PR-E3b 新 API 駆動)', () => { throw new Error(`unexpected: ${init?.method ?? 'GET'} ${url}`); }), ); - render(); + render(); await waitFor(() => expect(screen.getByText('認証済')).toBeInTheDocument()); }); @@ -137,14 +128,14 @@ describe('AuthRequestCard (PR-E3b 新 API 駆動)', () => { 'fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'not started' }), { status: 404 })), ); - render(); + render(); // 認証ボタンが残っている (idle 状態) expect(screen.getByRole('button', { name: /My Atlassian で認証/ })).toBeInTheDocument(); }); it('projectId 未設定なら認証ボタンが disabled (誤発火を防ぐ)', () => { useCanvasStore.setState({ projectId: null } as never); - render(); + render(); const btn = screen.getByRole('button', { name: /My Atlassian で認証/ }) as HTMLButtonElement; expect(btn.disabled).toBe(true); expect(screen.getByText(/プロジェクトが開かれていません/)).toBeInTheDocument(); diff --git a/packages/frontend/src/components/chat/auth-request-card.tsx b/packages/frontend/src/components/mcp/auth-request-card.tsx similarity index 86% rename from packages/frontend/src/components/chat/auth-request-card.tsx rename to packages/frontend/src/components/mcp/auth-request-card.tsx index 5c10ec2..8c16798 100644 --- a/packages/frontend/src/components/chat/auth-request-card.tsx +++ b/packages/frontend/src/components/mcp/auth-request-card.tsx @@ -1,25 +1,20 @@ 'use client'; -import type { ChatBlock } from '@tally/core'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useCanvasStore } from '@/lib/store'; -type AuthRequestBlock = Extract; - -// ADR-0011 PR-E3b: 外部 MCP の OAuth 2.1 認証要求カード。 -// 旧実装は SDK の loopback URL (block.authUrl) を開いて、ユーザーが callback URL を -// アドレスバーから paste する 2 ステップ UX だった。新実装では Tally プロセス内の -// OAuthFlowOrchestrator が loopback callback を直接受けるため、ユーザー操作は -// 「認証ボタンを押す → 別タブで承認 → 自動で完了」の 1 ステップに簡略化する。 +// ADR-0011 PR-E4: 外部 MCP の OAuth 2.1 認証要求カード。 +// PR-E3b で paste UX を廃して Route Handler 駆動の 1 ステップ化、PR-E4 で chat 文脈を +// 完全に外して project settings の MCP server 行に再配置した。prop は +// `{ mcpServerId, mcpServerLabel }` だけを受け取り、ChatBlock 型には依存しない。 // // 状態は orchestrator の Route Handler (POST/GET/DELETE /api/projects//mcp//oauth) -// から polling で取得する。block.status / block.authUrl は使わない (PR-E4 で chat-runner -// 側の auth_request 発行が削除されると block 自体が消えるため、過渡期の表示は API 側に -// 単一ソース化する)。 +// から polling で取得する。マウント時に GET で rehydrate するので、別の場所で start した +// flow の続きを表示することもできる (codex Major 対応の rehydrate effect)。 // // 状態遷移: -// idle ボタン未押下。block 来訪直後の初期状態。 +// idle ボタン未押下。 // starting POST 中 (短い)。 // pending authorize URL を別タブで開いた後、polling 中。 // completed 成功。Atlassian tools が利用可能。 @@ -42,13 +37,18 @@ type CardState = | { kind: 'completed' } | { kind: 'failed'; message: string }; -export function AuthRequestCard({ block }: { block: AuthRequestBlock }) { +export interface AuthRequestCardProps { + mcpServerId: string; + mcpServerLabel: string; +} + +export function AuthRequestCard({ mcpServerId, mcpServerLabel }: AuthRequestCardProps) { const projectId = useCanvasStore((s) => s.projectId); const [cardState, setCardState] = useState({ kind: 'idle' }); const pollTimerRef = useRef | null>(null); const baseUrl = projectId - ? `/api/projects/${encodeURIComponent(projectId)}/mcp/${encodeURIComponent(block.mcpServerId)}/oauth` + ? `/api/projects/${encodeURIComponent(projectId)}/mcp/${encodeURIComponent(mcpServerId)}/oauth` : null; // codex Major 対応: マウント時に orchestrator の現状を取りに行き、cardState を @@ -180,14 +180,14 @@ export function AuthRequestCard({ block }: { block: AuthRequestBlock }) { return (
- 🔐 {block.mcpServerLabel} 認証 + 🔐 {mcpServerLabel} 認証 {statusLabel(cardState)}
{showStartButton && ( <>
- 下のボタンで {block.mcpServerLabel} の認証ページを別タブで開いて承認してください。 + 下のボタンで {mcpServerLabel} の認証ページを別タブで開いて承認してください。
承認が完了すると自動で連携が有効になります (paste 不要)。
@@ -197,9 +197,7 @@ export function AuthRequestCard({ block }: { block: AuthRequestBlock }) { disabled={cardState.kind === 'starting' || !projectId} style={AUTH_BUTTON_STYLE} > - {cardState.kind === 'starting' - ? '開始中...' - : `🔓 ${block.mcpServerLabel} で認証 (新規タブ)`} + {cardState.kind === 'starting' ? '開始中...' : `🔓 ${mcpServerLabel} で認証 (新規タブ)`} {!projectId &&
プロジェクトが開かれていません。
} @@ -222,7 +220,7 @@ export function AuthRequestCard({ block }: { block: AuthRequestBlock }) { {isCompleted && (
- ✅ 認証完了。{block.mcpServerLabel} のツールが利用可能になりました。 + ✅ 認証完了。{mcpServerLabel} のツールが利用可能になりました。
)} diff --git a/packages/frontend/src/lib/store.ts b/packages/frontend/src/lib/store.ts index 3c97585..3edcc11 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -132,11 +132,6 @@ interface CanvasState { closeChatThread: () => void; sendChatMessage: (text: string) => Promise; approveChatTool: (toolUseId: string, approved: boolean) => void; - // 外部 MCP の OAuth コールバック URL を構造化送信する (PR-B CR Major)。 - // 自然文の user_message に mcpServerId を埋め込んで AI に解釈させると、複数 server - // 同時運用時に別 server の complete_authentication を呼ぶ事故が起きうる。UI が知って - // いる server id を構造化フィールドで渡し、サーバ側で確定的に prompt 化する。 - sendOAuthCallback: (mcpServerId: string, callbackUrl: string) => void; // issue #11: チャットに「@メンション」のように添付するノード ID 群。 // 順序保持 + 重複排除のため配列で持つ。スレッド切替・close で自動クリア @@ -387,94 +382,9 @@ export const useCanvasStore = create((set, get) => { }); return; } - // OAuth 2.1 認証要求/状態更新。pending は新規 auth_request ブロックを append、 - // completed/failed は同 mcpServerId の最新 pending ブロックを in-place で更新する。 - // これは chat-runner が永続化側で行う処理と整合させるための鏡映ロジック。 - if (evt.type === 'chat_auth_request') { - const messages = get().chatThreadMessages; - - // pending: 該当 messageId に新規 append - if (evt.status === 'pending') { - set({ - chatThreadMessages: messages.map((m) => { - if (m.id !== evt.messageId) return m; - return { - ...m, - blocks: [ - ...m.blocks, - { - type: 'auth_request', - mcpServerId: evt.mcpServerId, - mcpServerLabel: evt.mcpServerLabel, - authUrl: evt.authUrl, - status: 'pending', - }, - ], - }; - }), - }); - return; - } - - // completed/failed: 同 mcpServerId の最新 pending ブロックを更新 (どのメッセージに属していても)。 - // 再認証で複数 pending が並ぶ可能性があるため、末尾から走査して直近 1 件のみ書き換える - // (先頭から走査すると古いカードを更新してしまい、新しい pending カードが残る問題)。 - let updated = false; - const updatedMessages = [...messages]; - for (let mi = updatedMessages.length - 1; mi >= 0 && !updated; mi -= 1) { - const message = updatedMessages[mi]; - if (!message) continue; - const blocks = [...message.blocks]; - for (let bi = blocks.length - 1; bi >= 0; bi -= 1) { - const block = blocks[bi]; - if ( - block?.type === 'auth_request' && - block.mcpServerId === evt.mcpServerId && - block.status === 'pending' - ) { - blocks[bi] = { - ...block, - status: evt.status, - ...(evt.status === 'failed' ? { failureMessage: evt.failureMessage } : {}), - }; - updatedMessages[mi] = { ...message, blocks }; - updated = true; - break; - } - } - } - - if (updated) { - set({ chatThreadMessages: updatedMessages }); - return; - } - - // pending 不在: failed なら 該当 messageId に新規 append (URL 抽出失敗等で - // 認証 UI 表示前に failed が確定するケースを取り逃さない)。 - // completed で pending 不在は異常系なので無視。 - if (evt.status === 'failed') { - set({ - chatThreadMessages: messages.map((m) => { - if (m.id !== evt.messageId) return m; - return { - ...m, - blocks: [ - ...m.blocks, - { - type: 'auth_request', - mcpServerId: evt.mcpServerId, - mcpServerLabel: evt.mcpServerLabel, - authUrl: evt.authUrl, - status: 'failed', - ...(evt.failureMessage ? { failureMessage: evt.failureMessage } : {}), - }, - ], - }; - }), - }); - } - return; - } + // ADR-0011 PR-E4: 旧 chat_auth_request event handler は削除した。 + // OAuth 認証は project settings の AuthRequestCard が Route Handler 経由で完結させる。 + // chat 側に auth_request block を append する経路は完全に消えた。 if (evt.type === 'chat_assistant_message_completed') { return; } @@ -962,15 +872,6 @@ export const useCanvasStore = create((set, get) => { chatHandle.approveTool(toolUseId, approved); }, - sendOAuthCallback: (mcpServerId, callbackUrl) => { - if (!chatHandle) throw new Error('chat thread is not opened'); - // user message としては積まない (UI 上は AuthRequestCard の状態遷移で表現する)。 - // streaming フラグだけ立てて、サーバから流れてくる chat_auth_request / 完了通知を - // 通常の chat イベント経路で受ける。 - set({ chatThreadStreaming: true }); - chatHandle.sendOAuthCallback(mcpServerId, callbackUrl); - }, - deleteChatThread: async (threadId) => { const pid = get().projectId; if (!pid) throw new Error('projectId is not set'); diff --git a/packages/frontend/src/lib/ws.ts b/packages/frontend/src/lib/ws.ts index 49f663b..c5dc556 100644 --- a/packages/frontend/src/lib/ws.ts +++ b/packages/frontend/src/lib/ws.ts @@ -93,10 +93,6 @@ export interface ChatHandle { events: AsyncIterable; sendUserMessage: (text: string, contextNodeIds?: string[]) => void; approveTool: (toolUseId: string, approved: boolean) => void; - // 外部 MCP の OAuth コールバック URL を構造化送信する (PR-B CR Major)。 - // 自然文 user_message に mcpServerId を埋め込むのを避け、サーバ側で AI に - // 「指定 server の complete_authentication を呼べ」と決定論的に prompt 化させる。 - sendOAuthCallback: (mcpServerId: string, callbackUrl: string) => void; close: () => void; } @@ -193,8 +189,6 @@ export function openChat(opts: OpenChatOptions): ChatHandle { }), approveTool: (toolUseId, approved) => sendWhenReady({ type: 'approve_tool', toolUseId, approved }), - sendOAuthCallback: (mcpServerId, callbackUrl) => - sendWhenReady({ type: 'oauth_callback', mcpServerId, callbackUrl }), close: () => ws.close(), }; } From 2d0de6e37e3dd219f8543179fa574e4836062174 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Sun, 3 May 2026 06:10:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(frontend):=20isOAuthConnectable=20?= =?UTF-8?q?=E3=81=A7=20=5Fuid=20=E3=82=92=E5=89=A5=E3=81=8C=E3=81=97?= =?UTF-8?q?=E3=81=A6=E6=AF=94=E8=BC=83=20(CR=20Major)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR Major 対応: McpServerEntry は UI ローカルの `_uid` を持つが、saved 側 (永続化済み McpServerConfig[]) には _uid が無い。JSON.stringify による全フィールド比較が _uid の 差分で常に false になり、Connect ボタンが一度も出ないバグだった。 entry から _uid を destructure で剥がしてから比較するよう修正。 回帰テスト 2 件追加: - 保存済み + clientId 入力済 → Connect ボタンが描画される - name を編集すると Connect ボタンが消えてヒント文に切り替わる --- .../dialog/project-settings-dialog.test.tsx | 58 +++++++++++++++++++ .../dialog/project-settings-dialog.tsx | 7 ++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index b2a40c4..77ce1c5 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -182,6 +182,64 @@ describe('ProjectSettingsDialog', () => { expect(screen.getByText(/OAuth 2\.1 フローは Tally プロセス/)).toBeInTheDocument(); }); + it('PR-E4 retrograde: 保存済み + clientId 入力済の MCP server に Connect ボタンが描画される', async () => { + // CR Major 対応の検証: 旧実装は entry に UI ローカルの `_uid` が含まれているため + // JSON.stringify(saved) ≠ JSON.stringify(entry) で常に false → Connect ボタンが + // 一度も出ない。`_uid` を剥がして比較する修正後はこのテストが pass する。 + useCanvasStore.setState({ + projectMeta: { + ...meta, + mcpServers: [ + { + id: 'atlassian', + name: 'My Atlassian', + kind: 'atlassian', + url: 'https://mcp.atlassian.example/v1/mcp', + oauth: { clientId: 'cid-xyz' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }, + patchProjectMeta, + } as Partial>); + + render( {}} />); + // AuthRequestCard 内の認証ボタンが現れるはず (ヒント文ではなく) + await waitFor(() => + expect(screen.getByRole('button', { name: /My Atlassian で認証/ })).toBeInTheDocument(), + ); + expect(screen.queryByText(/Connect ボタンは「設定を保存/)).toBeNull(); + }); + + it('PR-E4 retrograde: name を編集すると Connect ボタンが消える', async () => { + useCanvasStore.setState({ + projectMeta: { + ...meta, + mcpServers: [ + { + id: 'atlassian', + name: 'My Atlassian', + kind: 'atlassian', + url: 'https://mcp.atlassian.example/v1/mcp', + oauth: { clientId: 'cid-xyz' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }, + patchProjectMeta, + } as Partial>); + + render( {}} />); + await screen.findByRole('button', { name: /My Atlassian で認証/ }); + const nameInput = screen.getByLabelText('mcp-0-name'); + await userEvent.type(nameInput, 'X'); + // Connect ボタンが消えてヒント文に切り替わる + await waitFor(() => + expect(screen.queryByRole('button', { name: /My Atlassian で認証/ })).toBeNull(), + ); + expect(screen.getByText(/Connect ボタンは「設定を保存/)).toBeInTheDocument(); + }); + it('id 重複時は保存 disabled', async () => { render( {}} />); // まず 2 件目を追加 diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.tsx index 4119e22..27a3a8f 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -43,7 +43,7 @@ function makeUid(): string { // clientId が空のときも当然 false。 function isOAuthConnectable( meta: { mcpServers?: McpServerConfig[] } | null, - entry: McpServerConfig, + entry: McpServerEntry, ): boolean { if (!meta?.mcpServers) return false; if (!entry.oauth.clientId) return false; @@ -51,7 +51,10 @@ function isOAuthConnectable( if (!saved) return false; // 完全一致比較: McpServerConfig のフィールドはプリミティブ + 配列 + 浅い object のみで // 順序も同じはずなので JSON.stringify で十分。将来 ネストが深くなったら deep-equal に置換。 - return JSON.stringify(saved) === JSON.stringify(entry); + // CR Major 対応: McpServerEntry は UI ローカルの `_uid` を持つが saved 側には無いので、 + // _uid を剥がしてから比較する (剥がさないと常に false)。 + const { _uid: _ignored, ...normalizedEntry } = entry; + return JSON.stringify(saved) === JSON.stringify(normalizedEntry); } export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {