diff --git a/packages/ai-engine/src/agent-runner.test.ts b/packages/ai-engine/src/agent-runner.test.ts index 94adc43..4982fae 100644 --- a/packages/ai-engine/src/agent-runner.test.ts +++ b/packages/ai-engine/src/agent-runner.test.ts @@ -228,6 +228,7 @@ describe('runAgent', () => { id: 'proj-test', name: 'FRC integration', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -288,6 +289,7 @@ describe('runAgent', () => { id: 'proj-test', name: 'P', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', }); @@ -436,7 +438,9 @@ describe('runAgent', () => { it('ingest-document: anchor 無しで起動し、tool_use を素通しする', async () => { const store = { getNode: vi.fn(), - getProjectMeta: vi.fn(), + // Task 15: agent-runner は mcpServers[] を取得するため毎ターン getProjectMeta を呼ぶ。 + // 空 (mcpServers なし) を返して既存挙動と同等にする。 + getProjectMeta: vi.fn().mockResolvedValue(null), addNode: vi.fn(), listNodes: vi.fn().mockResolvedValue([]), findRelatedNodes: vi.fn().mockResolvedValue([]), @@ -489,8 +493,150 @@ describe('runAgent', () => { expect(events.some((e) => e.type === 'error')).toBe(false); const toolUseEvents = events.filter((e) => e.type === 'tool_use'); expect(toolUseEvents.length).toBeGreaterThan(0); - // anchor 無しなので store.getNode / getProjectMeta は呼ばれない + // anchor 無しなので store.getNode は呼ばれない expect(store.getNode).not.toHaveBeenCalled(); - expect(store.getProjectMeta).not.toHaveBeenCalled(); + // Task 15: getProjectMeta は mcpServers[] を取るため必ず呼ばれる + expect(store.getProjectMeta).toHaveBeenCalled(); + }); + + describe('Task 15: agent-runner で buildMcpServers を共有', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('プロジェクト mcpServers[] を sdk.query に動的に渡す (chat-runner と同じ utility 共有)', async () => { + process.env.TEST_PAT = 'secret'; + const store = { + getNode: vi.fn().mockResolvedValue({ + id: 'uc-1', + type: 'usecase', + x: 0, + y: 0, + title: 'UC', + body: '', + }), + getProjectMeta: vi.fn().mockResolvedValue({ + id: 'p', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }), + addNode: vi.fn(), + listNodes: vi.fn().mockResolvedValue([]), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addEdge: vi.fn(), + } as unknown as ProjectStore; + + const querySpy = vi.fn(() => + (async function* () { + yield { + type: 'result', + subtype: 'success', + result: 'ok', + } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + + for await (const _ of runAgent({ + sdk, + store, + projectDir: '/ws', + req: { + type: 'start', + agent: 'extract-questions', + projectId: 'p', + input: { nodeId: 'uc-1' }, + }, + })) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { + mcpServers?: Record }>; + allowedTools?: string[]; + }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual( + expect.arrayContaining(['tally', 'atlassian']), + ); + const atlassian = callArg.options?.mcpServers?.atlassian; + expect(atlassian?.headers?.Authorization).toBe('Bearer secret'); + // agent 固有の allowedTools (mcp__tally__find_related 等) + 外部 MCP wildcard + expect(callArg.options?.allowedTools).toContain('mcp__atlassian__*'); + }); + + it('env 未設定なら error event を emit、sdk.query は呼ばない', async () => { + delete process.env.MISSING_PAT; + const store = { + getNode: vi.fn().mockResolvedValue({ + id: 'uc-1', + type: 'usecase', + x: 0, + y: 0, + title: 'UC', + body: '', + }), + getProjectMeta: vi.fn().mockResolvedValue({ + id: 'p', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'MISSING_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }), + addNode: vi.fn(), + listNodes: vi.fn().mockResolvedValue([]), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addEdge: vi.fn(), + } as unknown as ProjectStore; + + const querySpy = vi.fn(); + const sdk: SdkLike = { query: querySpy }; + const events: AgentEvent[] = []; + for await (const e of runAgent({ + sdk, + store, + projectDir: '/ws', + req: { + type: 'start', + agent: 'extract-questions', + projectId: 'p', + input: { nodeId: 'uc-1' }, + }, + })) { + events.push(e); + } + + // buildMcpServers の throw が catch (err) で error event に流れる + expect(querySpy).not.toHaveBeenCalled(); + const errorEvent = events.find((e) => e.type === 'error'); + expect(errorEvent).toBeDefined(); + if (errorEvent && errorEvent.type === 'error') { + expect(errorEvent.message).toMatch(/MISSING_PAT/); + } + }); }); }); diff --git a/packages/ai-engine/src/agent-runner.ts b/packages/ai-engine/src/agent-runner.ts index 7dc10e9..fd94972 100644 --- a/packages/ai-engine/src/agent-runner.ts +++ b/packages/ai-engine/src/agent-runner.ts @@ -2,6 +2,7 @@ import type { AgentName } from '@tally/core'; import type { ProjectStore } from '@tally/storage'; import { AGENT_REGISTRY } from './agents/registry'; +import { buildMcpServers } from './mcp/build-mcp-servers'; import type { AgentEvent, SdkMessageLike } from './stream'; import { sdkMessageToAgentEvent } from './stream'; import { buildTallyMcpServer } from './tools'; @@ -104,19 +105,36 @@ export async function* runAgent(deps: RunAgentDeps): AsyncGenerator input: parsed.data, }); try { + // Task 15: プロジェクト設定の mcpServers[] を毎ターン読み込み、buildMcpServers で + // Tally MCP と外部 MCP (Atlassian 等) を合成する。chat-runner と同じ utility を共有。 + // env 未設定時は throw → catch で error event に流す。 + const projectMeta = await store.getProjectMeta(); + const externalConfigs = projectMeta?.mcpServers ?? []; + const { mcpServers, allowedTools: externalAllowed } = buildMcpServers({ + tallyMcp: mcp, + configs: externalConfigs, + }); + // built-in ツールは mcp__ プレフィックスを持たないもの (Read / Glob / Grep など)。 // options.tools = 実質的な built-in 使用可能リスト。[] を渡せば Bash/Edit/Write 等すべてオフ。 const builtInTools = def.allowedTools.filter((t) => !t.startsWith('mcp__')); + // agent 固有の allowedTools (Tally MCP の具体 tool 名 + built-in) に、外部 MCP の wildcard + // (mcp____*) を合流。tally の wildcard は agent 側に既に具体名で並んでいるので除外して dedup。 + const finalAllowedTools = [ + ...def.allowedTools, + ...externalAllowed.filter((t) => t !== 'mcp__tally__*'), + ]; + const iter = sdk.query({ prompt: prompt.userPrompt, options: { systemPrompt: prompt.systemPrompt, - mcpServers: { tally: mcp as unknown as Record }, + mcpServers, // built-in ツールは registry で宣言した範囲のみ許可。 // これで find-related-code に Bash / Edit / Write 等が使われなくなる。 tools: builtInTools, - // MCP ツール (mcp__tally__*) も含めて自動承認する。 - allowedTools: def.allowedTools, + // MCP ツール (mcp__tally__* + 外部 MCP wildcard) を自動承認する。 + allowedTools: finalAllowedTools, // 承認リスト外は拒否。built-in 側は tools で絞っているので二重ガード。 permissionMode: 'dontAsk', // cwd は find-related-code のコード探索スコープ。未指定エージェントは SDK デフォルト。 diff --git a/packages/ai-engine/src/agents/find-related-code.test.ts b/packages/ai-engine/src/agents/find-related-code.test.ts index c87341a..df915f4 100644 --- a/packages/ai-engine/src/agents/find-related-code.test.ts +++ b/packages/ai-engine/src/agents/find-related-code.test.ts @@ -52,6 +52,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -106,6 +107,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -122,6 +124,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: filePath }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -136,6 +139,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: '../nonexistent-xyz' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index 0af071e..b46d867 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import type { Node } from '@tally/core'; +import type { ChatMessage, Node } from '@tally/core'; import { newChatMessageId } from '@tally/core'; import { FileSystemChatStore, FileSystemProjectStore } from '@tally/storage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -21,6 +21,7 @@ describe('ChatRunner', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -474,3 +475,741 @@ describe('formatNodeForContext / buildChatPrompt', () => { expect(out.slice(curIdx)).not.toContain('過去質問'); }); }); + +describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('プロジェクト設定の mcpServers[] を sdk.query に動的に渡す (Bearer)', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'test-mcp', + name: 'T', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + 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' }); + + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + expect(querySpy).toHaveBeenCalled(); + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { + mcpServers?: Record }>; + allowedTools?: string[]; + }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual( + expect.arrayContaining(['tally', 'test-mcp']), + ); + const testMcp = callArg.options?.mcpServers?.['test-mcp']; + expect(testMcp?.headers?.Authorization).toBe('Bearer secret'); + expect(callArg.options?.allowedTools).toContain('mcp__tally__*'); + expect(callArg.options?.allowedTools).toContain('mcp__test-mcp__*'); + + rmSync(root, { recursive: true, force: true }); + }); + + it('mcpServers[] が空配列なら tally のみ (退行なし)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [], + 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' }); + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { mcpServers?: Record; allowedTools?: string[] }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual(['tally']); + expect(callArg.options?.allowedTools).toEqual(['mcp__tally__*']); + + rmSync(root, { recursive: true, force: true }); + }); + + it('env 未設定なら error event を emit、sdk.query は呼ばない', async () => { + delete process.env.MISSING_PAT; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11c-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'MISSING_PAT' }, + 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' }); + const querySpy = vi.fn(); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('hi')) events.push(e); + + expect(querySpy).not.toHaveBeenCalled(); + const errorEvent = events.find((e) => e.type === 'error'); + expect(errorEvent).toBeDefined(); + if (errorEvent && errorEvent.type === 'error') { + expect(errorEvent.message).toMatch(/MISSING_PAT/); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('Basic auth (Cloud) でも正しく Authorization header が組まれる', async () => { + process.env.ATLASSIAN_EMAIL = 'user@example.com'; + process.env.ATLASSIAN_API_TOKEN = 'token-xyz'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11d-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'cloud', + name: 'C', + kind: 'atlassian', + url: 'https://api.atlassian.test/mcp', + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + 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' }); + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { mcpServers?: Record }> }; + }; + const expected = Buffer.from('user@example.com:token-xyz').toString('base64'); + expect(callArg.options?.mcpServers?.cloud?.headers?.Authorization).toBe(`Basic ${expected}`); + + rmSync(root, { recursive: true, force: true }); + }); +}); + +describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('外部 MCP の tool_use を source=external で永続化、chat_tool_external_use event を emit', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12a-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + 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' }); + + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Jira を読みます' }, + { + type: 'tool_use', + id: 'atlassian-tu-1', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'EPIC-1' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'atlassian-tu-1', + content: [{ type: 'text', text: '{"summary":"Epic title"}' }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('@JIRA EPIC-1')) events.push(e); + + const useEvent = events.find((e) => e.type === 'chat_tool_external_use'); + expect(useEvent).toBeDefined(); + if (useEvent && useEvent.type === 'chat_tool_external_use') { + expect(useEvent.toolUseId).toBe('atlassian-tu-1'); + expect(useEvent.name).toBe('mcp__atlassian__jira_get_issue'); + } + const resultEvent = events.find((e) => e.type === 'chat_tool_external_result'); + expect(resultEvent).toBeDefined(); + if (resultEvent && resultEvent.type === 'chat_tool_external_result') { + expect(resultEvent.toolUseId).toBe('atlassian-tu-1'); + expect(resultEvent.ok).toBe(true); + expect(resultEvent.output).toContain('Epic title'); + } + + const reloaded = await chatStore.getChat(thread.id); + const asstMsg = reloaded?.messages.find((m) => m.role === 'assistant'); + const toolUse = asstMsg?.blocks.find((b) => b.type === 'tool_use'); + expect(toolUse).toBeDefined(); + if (toolUse?.type === 'tool_use') { + expect(toolUse.source).toBe('external'); + expect(toolUse.name).toBe('mcp__atlassian__jira_get_issue'); + expect(toolUse.approval).toBeUndefined(); + } + const toolResult = asstMsg?.blocks.find((b) => b.type === 'tool_result'); + expect(toolResult).toBeDefined(); + if (toolResult?.type === 'tool_result') { + expect(toolResult.ok).toBe(true); + expect(toolResult.output).toContain('Epic title'); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('mcp__tally__ で始まる tool_use は無視 (intercept 経路で処理されるため)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [], + 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' }); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: '作ります' }, + { + type: 'tool_use', + id: 'tally-tu', + name: 'mcp__tally__create_node', + input: {}, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('hi')) events.push(e); + + expect(events.find((e) => e.type === 'chat_tool_external_use')).toBeUndefined(); + + rmSync(root, { recursive: true, force: true }); + }); + + it('tool_result output が 4KB 超えると永続化時に truncate、event は full (Task 13)', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task13-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + 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' }); + const bigOutput = 'X'.repeat(10_000); + const sdk: SdkLike = { + query: () => + (async function* () { + // 外部 tool_use を先行させる (externalToolUseIds に登録するため)。 + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'big-1', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'BIG-1' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'big-1', + content: [{ type: 'text', text: bigOutput }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('q')) events.push(e); + + // event はフル + const evt = events.find((e) => e.type === 'chat_tool_external_result'); + expect(evt).toBeDefined(); + if (evt && evt.type === 'chat_tool_external_result') { + expect(evt.output.length).toBe(10_000); + } + + // YAML 永続化は truncate + const reloaded = await chatStore.getChat(thread.id); + const tr = reloaded?.messages.flatMap((m) => m.blocks).find((b) => b.type === 'tool_result'); + expect(tr).toBeDefined(); + if (tr?.type === 'tool_result') { + expect(tr.output.length).toBeLessThanOrEqual(4200); + expect(tr.output).toContain('(truncated'); + expect(tr.output).toContain('10000'); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('tool_result output が 4KB 以下なら truncate しない', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task13b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + 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' }); + const smallOutput = 'small result'; + const sdk: SdkLike = { + query: () => + (async function* () { + // 外部 tool_use を先行させる (externalToolUseIds に登録するため)。 + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'small-1', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'SMALL-1' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'small-1', + content: [{ type: 'text', text: smallOutput }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('q')) { + /* drain */ + } + + const reloaded = await chatStore.getChat(thread.id); + const tr = reloaded?.messages.flatMap((m) => m.blocks).find((b) => b.type === 'tool_result'); + if (tr?.type === 'tool_result') { + expect(tr.output).toBe(smallOutput); + expect(tr.output).not.toContain('truncated'); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('外部 tool_result が is_error=true なら ok=false で記録', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12c-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + 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' }); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'err-tu', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'BOGUS' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'err-tu', + content: [{ type: 'text', text: '404 not found' }], + is_error: true, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('q')) events.push(e); + + const evt = events.find((e) => e.type === 'chat_tool_external_result'); + expect(evt).toBeDefined(); + if (evt && evt.type === 'chat_tool_external_result') { + expect(evt.ok).toBe(false); + expect(evt.output).toContain('404'); + } + + rmSync(root, { recursive: true, force: true }); + }); +}); + +describe('buildChatPrompt — tool_use/tool_result replay (Task 14, T4 fix)', () => { + it('過去 turn の text + tool_use + tool_result が conversation_history に含まれる', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '@JIRA EPIC-1 を読んで' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a1', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Jira を読みます' }, + { + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__atlassian__jira_get_issue', + input: { key: 'EPIC-1' }, + source: 'external', + }, + { type: 'tool_result', toolUseId: 'tu-1', ok: true, output: '{"summary":"Epic X"}' }, + { type: 'text', text: '読みました。Epic X です' }, + ], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: '続けて子チケット STORY-42 を読んで' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + + const prompt = buildChatPrompt(messages); + + // 過去 turn の Jira 内容が prompt に含まれる (T4 fix の核) + expect(prompt).toContain('Epic X'); + expect(prompt).toContain('mcp__atlassian__jira_get_issue'); + expect(prompt).toContain('source="external"'); + // 直近 user message は current_user_message として独立 + expect(prompt).toContain(''); + expect(prompt).toContain('STORY-42'); + // tool_use / tool_result タグが正しく出る + expect(prompt).toContain(' { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '作って' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a1', + role: 'assistant', + blocks: [ + { + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__tally__create_node', + input: {}, + source: 'internal', + approval: 'approved', + }, + ], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: 'next' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + + const prompt = buildChatPrompt(messages); + expect(prompt).toContain('mcp__tally__create_node'); + expect(prompt).not.toContain('source="external"'); + expect(prompt).not.toContain('source="internal"'); + }); + + it('blocks が空の message は省く (履歴前段の空 assistant 想定)', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a-empty', + role: 'assistant', + blocks: [], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: 'continue' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + const prompt = buildChatPrompt(messages); + // 空 assistant は省かれる + const messageOpens = prompt.match(//g) ?? []; + expect(messageOpens.length).toBe(0); + // user の "hello" は履歴に残る + expect(prompt).toContain('hello'); + expect(prompt).toContain('continue'); + }); + + it('過去 turn が無く current user のみのケース (初回 turn)', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '初回' }], + createdAt: '2026-04-24T00:00:00Z', + }, + ]; + const prompt = buildChatPrompt(messages); + expect(prompt).not.toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain('初回'); + }); +}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index dba1055..44516e4 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -9,6 +9,8 @@ import { import type { ChatStore, ProjectStore } from '@tally/storage'; import type { SdkLike } from './agent-runner'; +import { buildMcpServers } from './mcp/build-mcp-servers'; +import { redactMcpSecrets } from './mcp/redact'; import type { ChatEvent, SdkMessageLike } from './stream'; import { CreateEdgeInputSchema, createEdgeHandler } from './tools/create-edge'; import { CreateNodeInputSchema, createNodeHandler } from './tools/create-node'; @@ -25,9 +27,25 @@ export interface ChatRunnerDeps { threadId: string; } -// SDK の assistant message から抽出する block の単純化形。 -// MCP 経路に一本化したため tool_use は拾わないが、将来のデバッグ用に型定義は保持。 -type ExtractedBlock = { type: 'text'; text: string }; +// 外部 MCP の tool_result output を YAML に永続化するときの上限 (Task 13)。 +// 大規模 epic 取り込み等で 1 ターンに 500KB+ 来うるので、永続化は 4KB に切る。 +// メモリ内 (event) は full を流すので、UI セッション内では全文展開可能。 +// リロード後は truncated 版だけ見える (dogfooding には十分)。 +const TOOL_RESULT_PERSIST_LIMIT = 4096; + +function truncateForPersistence(output: string): string { + if (output.length <= TOOL_RESULT_PERSIST_LIMIT) return output; + const head = output.slice(0, TOOL_RESULT_PERSIST_LIMIT); + return `${head}\n... (truncated, ${output.length} chars total)`; +} + +// SDK の assistant / user message から抽出する block の単純化形。 +// Tally MCP の tool_use は MCP intercept 経路で処理されるので拾わない。 +// 外部 MCP (mcp__tally__ 以外) の tool_use / tool_result は永続化と UI 通知のためここで拾う (Task 12)。 +type ExtractedBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; toolUseId: string; name: string; input: unknown } + | { type: 'tool_result'; toolUseId: string; ok: boolean; output: string }; // MCP ツール名と、そのハンドラ (承認必要かどうか) を束ねるエントリ。 // 承認必須のツールは create_* 系 (書き込み)、承認不要は find_related / list_by_type (読み取り)。 @@ -114,16 +132,11 @@ export class ChatRunner { const prompt = buildChatPrompt(threadWithUser?.messages ?? [], contextNodes); const systemPrompt = buildChatSystemPrompt(); - // 3. 空の assistant message を append (後続の tool_use 即時永続化の親として必要) - // prompt スナップショット後に行うことで、上記 buildChatPrompt の前提が崩れないようにする。 + // 3. assistantMsgId を先に生成 (buildMcpServer の handler が emit 先として参照)。 + // 永続化は MCP 構築成功後に行う (途中で throw した場合に空 assistant message が + // YAML に残らないようにする。ロード時は がスキップされるが、 + // chat 履歴 UI には空バブルが蓄積するのを防ぐため)。 const assistantMsgId = newChatMessageId(); - await chatStore.appendMessage(threadId, { - id: assistantMsgId, - role: 'assistant', - blocks: [], - createdAt: new Date().toISOString(), - }); - yield { type: 'chat_assistant_message_started', messageId: assistantMsgId }; // 4. MCP 経由で呼ばれる tool ハンドラ内で invokeInterceptedTool を回す。 // MCP handler は SDK query を block するので、イベント emit は AsyncQueue 経由に分離する。 @@ -133,7 +146,43 @@ export class ChatRunner { const emit = (e: ChatEvent) => queue.push(e); const mcp = this.buildMcpServer(tools, emit, assistantMsgId); + // 4b. プロジェクト設定の mcpServers[] を Tally MCP と合成する (Task 11)。 + // 毎ターン読むことで env / 設定変更がホットリロードされる。 + // env 未設定 (PAT 等) は buildMcpServers が throw するので、ここで補足し + // error event を emit して early return する (sdk.query は呼ばない)。 + const projectMeta = await projectStore.getProjectMeta(); + const externalConfigs = projectMeta?.mcpServers ?? []; + let mcpServers: Record; + let allowedTools: string[]; + try { + const built = buildMcpServers({ tallyMcp: mcp, configs: externalConfigs }); + mcpServers = built.mcpServers; + allowedTools = built.allowedTools; + } catch (err) { + yield { + type: 'error', + code: 'mcp_config_invalid', + message: err instanceof Error ? err.message : String(err), + }; + return; + } + + // 5. MCP 構築が成功した時点で空 assistant message を永続化 (後続の tool_use 即時 + // 永続化の親として必要)。buildChatPrompt スナップショット後・sdk.query 前に行う。 + await chatStore.appendMessage(threadId, { + id: assistantMsgId, + role: 'assistant', + blocks: [], + createdAt: new Date().toISOString(), + }); + yield { type: 'chat_assistant_message_started', messageId: assistantMsgId }; + const textBuffer: string[] = []; + // 外部 MCP の tool_use を観測した toolUseId のみ集合に保持し、対応する tool_result + // のみ external として永続化する。Tally 内部 MCP (mcp__tally__*) は本来 intercept + // 経路で SDK ストリームに現れない前提だが、SDK 仕様変更や edge case で内部 result + // が流れた場合に external 誤分類するのを防ぐためのガード (CR 指摘 #19 2 周目)。 + const externalToolUseIds = new Set(); // 5. SDK query をバックグラウンドで走らせ、queue にイベントを push する。 // generator 側は queue をドレインして yield するだけ。 @@ -143,14 +192,9 @@ export class ChatRunner { prompt, options: { systemPrompt, - mcpServers: { tally: mcp as unknown as Record }, + mcpServers, tools: [], - allowedTools: [ - 'mcp__tally__create_node', - 'mcp__tally__create_edge', - 'mcp__tally__find_related', - 'mcp__tally__list_by_type', - ], + allowedTools, permissionMode: 'dontAsk', settingSources: [], cwd: projectDir, @@ -161,12 +205,51 @@ export class ChatRunner { }); for await (const msg of iter) { - console.log('[chat-runner] sdk msg:', JSON.stringify(msg).slice(0, 200)); + console.log( + '[chat-runner] sdk msg:', + JSON.stringify(redactMcpSecrets(msg)).slice(0, 200), + ); const blocks = extractAssistantBlocks(msg); for (const b of blocks) { if (b.type === 'text') { textBuffer.push(b.text); queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); + } else if (b.type === 'tool_use') { + // 外部 MCP の tool_use: source='external' で永続化、承認 UI なし (Task 12)。 + externalToolUseIds.add(b.toolUseId); + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_use', + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + source: 'external', + }); + queue.push({ + type: 'chat_tool_external_use', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + }); + } else if (b.type === 'tool_result') { + // 同 turn 中に観測した外部 tool_use の id のみ external として扱う。 + // 集合に無い toolUseId (= 内部 / intercept 経路 / 想定外) は無視する。 + if (!externalToolUseIds.has(b.toolUseId)) continue; + // Task 13: 大規模 epic で tool_result が 500KB+ になり得るので、 + // YAML 永続化は 4KB に切り詰める。event はフル (UI はメモリ内で全文展開可)。 + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_result', + toolUseId: b.toolUseId, + ok: b.ok, + output: truncateForPersistence(b.output), + }); + queue.push({ + type: 'chat_tool_external_result', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); } } } @@ -234,6 +317,7 @@ export class ChatRunner { toolUseId: uiId, name: entry.name, input, + source: 'internal', approval: 'approved', }); await chatStore.appendBlockToMessage(threadId, assistantMsgId, { @@ -262,6 +346,7 @@ export class ChatRunner { toolUseId: uiToolUseId, name: entry.name, input, + source: 'internal', approval: 'pending', }); emit({ @@ -541,13 +626,40 @@ export function formatNodeForContext(node: Node): string { return lines.join('\n'); } +// XML element 内テキスト用のエスケープ。`<` `>` `&` の最低限のみ。 +// tool_result.output (外部 MCP の生出力) や tool_use.input の JSON 文字列を +// XML 要素本体に埋め込む際に使う。 +// +// 注: JSON.stringify は `<` `>` `&` をエスケープしない (escape するのは `"` `\` +// と control chars のみ)。input オブジェクトに `` 等が含まれる +// ケースに備え、JSON 文字列にも本関数を適用する必要がある。 +function escapeXmlText(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +// XML 属性値用のエスケープ。`"` も含めてエスケープする (属性は二重引用符で囲むため)。 +// toolUseId / name / role などの動的値を attr に埋め込むときに使う。 +function escapeXmlAttr(s: string): string { + return s + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + // チャット履歴を単一 prompt にエンコードする。 -// tool_use / tool_result は冗長なので省き、text block だけを role 付きで並べる。 -// 最後の user message は current として別タグに出し、モデルの「今答えるべきもの」を明示する。 +// 各 block を順に replay する: +// - text: そのまま (assistant / user の自然言語) +// - tool_use: ... +// - tool_result: ... +// +// T4 fix (Task 14): 旧版は text block だけ replay していたが、これだと AI が +// 2 ターン目以降で前ターンの外部 MCP tool_result (= Jira 等の読み取り内容) を忘れてしまい、 +// multi-turn 対話が成立しなかった。tool_use / tool_result も replay することで +// 「@JIRA EPIC-1 を読んで → 続けて子チケット STORY-2 も見て」が動く。 // // contextNodes: 今ターンで参照するコンテキストノード (issue #11)。 // 履歴より下、current_user_message より上に として埋め込む。 -// 履歴に積まないのは「ターンごとに添付し直しできる軽量な参照」という UX 設計のため。 export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = []): string { const lines: string[] = []; const last = messages[messages.length - 1]; @@ -556,14 +668,30 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = if (past.length > 0) { lines.push(''); for (const m of past) { - const texts = m.blocks - .filter((b): b is Extract => b.type === 'text') - .map((b) => b.text); - if (texts.length > 0) { - lines.push(``); - lines.push(texts.join('\n')); - lines.push(''); + // block が 1 つも無い空 message は省く (空 assistant の preliminary append 等) + if (m.blocks.length === 0) continue; + lines.push(``); + for (const b of m.blocks) { + if (b.type === 'text') { + // text 本文も `<` `>` `&` を escape する。assistant / user 自由入力なので + // `` 等の文字列が混入しうる (CR 指摘 #19 2 周目)。 + lines.push(escapeXmlText(b.text)); + } else if (b.type === 'tool_use') { + // source は default 'internal'。external も含めて全部 replay する + // (AI に「外部 source を読んだ」事実を context として伝えるため) + const sourceAttr = b.source === 'external' ? ' source="external"' : ''; + // input は JSON.stringify 後に XML エスケープ。`<` `>` `&` は JSON 文字列内では + // 生のまま残るので、XML タグへの埋め込みでは構造を壊しうる (codex 指摘の前提誤り)。 + lines.push( + `${escapeXmlText(JSON.stringify(b.input))}`, + ); + } else if (b.type === 'tool_result') { + lines.push( + `${escapeXmlText(b.output)}`, + ); + } } + lines.push(''); } lines.push(''); } @@ -581,7 +709,7 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = if (last && last.role === 'user') { const texts = last.blocks .filter((b): b is Extract => b.type === 'text') - .map((b) => b.text); + .map((b) => escapeXmlText(b.text)); lines.push(''); lines.push(texts.join('\n')); lines.push(''); @@ -590,20 +718,62 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = return lines.join('\n'); } -// SDK から流れてくる assistant message の content 配列から text を取り出す。 -// tool_use は MCP 経路が処理するのでここでは無視する (重複処理を避ける)。 +// SDK から流れてくる assistant message + user message (tool_result を含む) から block 抽出。 +// 拾うもの: +// - assistant.text (existing 動作維持) +// - tool_use で name が mcp__tally__ で始まらないもの (= 外部 MCP、Task 12) +// - tool_result 全部 (外部 MCP の応答、user message に含まれる) +// +// Tally MCP (mcp__tally__*) の tool_use は createSdkMcpServer の intercept 経路で +// invokeInterceptedTool が処理するので、ここで拾うと二重処理になる。よって除外。 // 実行時 duck typing (agent-runner.ts の sdkMessageToAgentEvent と同じパターン)。 function extractAssistantBlocks(msg: SdkMessageLike): ExtractedBlock[] { const m = msg as unknown as { type?: string; message?: { content?: unknown[] } }; - if (m.type !== 'assistant' || !m.message?.content) return []; + if ((m.type !== 'assistant' && m.type !== 'user') || !m.message?.content) return []; const out: ExtractedBlock[] = []; for (const block of m.message.content) { const b = block as { type?: string; text?: string; + id?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: unknown; + is_error?: boolean; }; - if (b.type === 'text' && typeof b.text === 'string') { + if (b.type === 'text' && typeof b.text === 'string' && m.type === 'assistant') { out.push({ type: 'text', text: b.text }); + } else if ( + b.type === 'tool_use' && + typeof b.id === 'string' && + typeof b.name === 'string' && + !b.name.startsWith('mcp__tally__') + ) { + out.push({ + type: 'tool_use', + toolUseId: b.id, + name: b.name, + input: b.input, + }); + } else if (b.type === 'tool_result' && typeof b.tool_use_id === 'string') { + // content は string or [{type:'text', text:'...'}] で来る (SDK 仕様)。string 化する。 + let outputText = ''; + if (typeof b.content === 'string') { + outputText = b.content; + } else if (Array.isArray(b.content)) { + outputText = b.content + .map((c: { type?: string; text?: string }) => + c.type === 'text' && typeof c.text === 'string' ? c.text : '', + ) + .join(''); + } + out.push({ + type: 'tool_result', + toolUseId: b.tool_use_id, + ok: b.is_error !== true, + output: outputText, + }); } } return out; diff --git a/packages/ai-engine/src/duplicate-guards/coderef.test.ts b/packages/ai-engine/src/duplicate-guards/coderef.test.ts new file mode 100644 index 0000000..a23ccc1 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/coderef.test.ts @@ -0,0 +1,147 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { coderefGuard } from './coderef'; +import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; + +function makeCtx( + nodes: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + // 注: exactOptionalPropertyTypes のため codebaseId は明示 undefined にせず、 + // override で指定された場合のみ広げる。 + return { + store: { + listNodes: async () => nodes as never, + findRelatedNodes: async () => [], + } as unknown as ProjectStore, + anchorId: '', + sessionMemo: new Set(), + ...override, + }; +} + +describe('coderefGuard', () => { + beforeEach(() => __resetGuardsForTest()); + + it('adoptAs は "coderef"', () => { + expect(coderefGuard.adoptAs).toBe('coderef'); + }); + + it('同一 filePath + 近接 startLine (±10) で重複検知', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 105, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + expect(res?.reason).toContain('n1'); + }); + + it('11 行以上離れていれば重複ではない', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 112, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('codebaseId が異なれば別物扱い (重複ではない)', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb2' }, + }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('input の codebaseId が無くても ctx.codebaseId が使われる', async () => { + const ctx = makeCtx( + [{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }], + { codebaseId: 'cb1' }, + ); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('既存 codebaseId が undefined でも横断的に重複扱い (legacy migration 対応)', async () => { + const ctx = makeCtx([ + { id: 'n_legacy', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('filePath が "./" 付きでも正規化して判定', async () => { + const ctx = makeCtx([{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: './src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('proposal (adoptAs="coderef") も重複検知の対象', async () => { + const ctx = makeCtx([ + { + id: 'p1', + type: 'proposal', + adoptAs: 'coderef', + filePath: 'src/a.ts', + startLine: 100, + }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('filePath / startLine が input に無ければ skip (null)', async () => { + const ctx = makeCtx([{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }]); + const res = await coderefGuard.check({ title: 'T', body: '', additional: undefined }, ctx); + expect(res).toBeNull(); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/coderef.ts b/packages/ai-engine/src/duplicate-guards/coderef.ts new file mode 100644 index 0000000..edd3f6d --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/coderef.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; + +import type { DuplicateGuard } from './index'; + +// 既存 create-node.ts の findDuplicateCoderef ロジックを移行 (動作不変)。 +// `find-related-code` / `analyze-impact` はスキャン位置がブレやすいので、 +// 同一 filePath で ±10 行以内の近接 coderef を重複扱いする。 +const CODEREF_LINE_TOLERANCE = 10; + +// "./src/a.ts" や "src//a.ts" を "src/a.ts" に正規化する。 +function normalizeFilePath(fp: string): string { + const stripped = fp.startsWith('./') ? fp.slice(2) : fp; + return path.posix.normalize(stripped); +} + +export const coderefGuard: DuplicateGuard = { + adoptAs: 'coderef', + async check(input, ctx) { + const additional = input.additional ?? {}; + const fp = additional.filePath; + const sl = additional.startLine; + if (typeof fp !== 'string' || typeof sl !== 'number') return null; + + const normalized = normalizeFilePath(fp); + // input 側の codebaseId 優先、無ければ ctx の codebaseId を使う + const inputCb = + typeof additional.codebaseId === 'string' ? (additional.codebaseId as string) : undefined; + const activeCbId = inputCb ?? ctx.codebaseId; + + const all = await ctx.store.listNodes(); + for (const n of all) { + const rec = n as Record; + const type = rec.type as string | undefined; + const adoptAs = rec.adoptAs as string | undefined; + // 正規 coderef と adoptAs=coderef proposal の両方を対象 + const isCoderef = type === 'coderef' || (type === 'proposal' && adoptAs === 'coderef'); + if (!isCoderef) continue; + const existingFp = rec.filePath as string | undefined; + const existingSl = rec.startLine as number | undefined; + if (!existingFp || typeof existingSl !== 'number') continue; + if (normalizeFilePath(existingFp) !== normalized) continue; + // マルチコードベース: 両方が codebaseId を持ち、かつ異なれば別物扱い。 + // 一方でも undefined なら従来通り全件比較 (legacy migration 対応)。 + const existingCb = rec.codebaseId as string | undefined; + if (activeCbId !== undefined && existingCb !== undefined && existingCb !== activeCbId) { + continue; + } + if (Math.abs(existingSl - sl) <= CODEREF_LINE_TOLERANCE) { + const id = rec.id as string; + return { + reason: `重複: ${id} と近接 (filePath=${normalized}, startLine 差=${Math.abs(existingSl - sl)})`, + }; + } + } + return null; + }, +}; diff --git a/packages/ai-engine/src/duplicate-guards/index.test.ts b/packages/ai-engine/src/duplicate-guards/index.test.ts new file mode 100644 index 0000000..69c51f5 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/index.test.ts @@ -0,0 +1,122 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + __resetGuardsForTest, + type DuplicateGuard, + type DuplicateGuardContext, + dispatchDuplicateGuard, + notifyCreated, + registerGuard, +} from './index'; + +const fakeStore = { + listNodes: async () => [], + findRelatedNodes: async () => [], +} as unknown as ProjectStore; + +const baseCtx: DuplicateGuardContext = { + store: fakeStore, + anchorId: '', + sessionMemo: new Set(), +}; + +beforeEach(() => { + __resetGuardsForTest(); +}); + +describe('dispatchDuplicateGuard', () => { + it('登録された guard が無い adoptAs は null を返す', async () => { + const result = await dispatchDuplicateGuard( + 'requirement', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result).toBeNull(); + }); + + it('guard が DuplicateFound を返したら dispatcher も同じものを返す', async () => { + const stubGuard: DuplicateGuard = { + adoptAs: 'usecase', + check: async () => ({ reason: '重複: stub' }), + }; + registerGuard(stubGuard); + const result = await dispatchDuplicateGuard( + 'usecase', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result?.reason).toBe('重複: stub'); + }); + + it('複数 guard が同じ adoptAs に登録された場合、最初に重複を検知したものが返る', async () => { + const guardA: DuplicateGuard = { + adoptAs: 'userstory', + check: async () => null, + }; + const guardB: DuplicateGuard = { + adoptAs: 'userstory', + check: async () => ({ reason: 'B が検知' }), + }; + registerGuard(guardA); + registerGuard(guardB); + const result = await dispatchDuplicateGuard( + 'userstory', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result?.reason).toBe('B が検知'); + }); + + it('全 guard が null なら null を返す', async () => { + const guardA: DuplicateGuard = { + adoptAs: 'issue', + check: async () => null, + }; + const guardB: DuplicateGuard = { + adoptAs: 'issue', + check: async () => null, + }; + registerGuard(guardA); + registerGuard(guardB); + const result = await dispatchDuplicateGuard( + 'issue', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result).toBeNull(); + }); +}); + +describe('notifyCreated', () => { + it('登録された guard の onCreated が呼ばれる', async () => { + const calls: string[] = []; + const guard: DuplicateGuard = { + adoptAs: 'coderef', + check: async () => null, + onCreated: (input) => { + calls.push(input.title); + }, + }; + registerGuard(guard); + notifyCreated('coderef', { title: 'T1', body: '', additional: undefined }, baseCtx); + expect(calls).toEqual(['T1']); + }); + + it('onCreated が無い guard では何も起きない (例外も出ない)', async () => { + const guard: DuplicateGuard = { + adoptAs: 'question', + check: async () => null, + }; + registerGuard(guard); + expect(() => + notifyCreated('question', { title: 'T2', body: '', additional: undefined }, baseCtx), + ).not.toThrow(); + }); + + it('登録 guard が無い adoptAs では何も起きない', () => { + expect(() => + notifyCreated('coderef', { title: 'T3', body: '', additional: undefined }, baseCtx), + ).not.toThrow(); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/index.ts b/packages/ai-engine/src/duplicate-guards/index.ts new file mode 100644 index 0000000..90ee894 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -0,0 +1,83 @@ +import type { AdoptableType } from '@tally/core'; +import type { ProjectStore } from '@tally/storage'; + +// create-node 入力のうち guard に必要な最小 shape。 +export interface GuardInput { + title: string; + body: string; + additional: Record | undefined; +} + +// guard が共有するランタイム文脈。 +export interface DuplicateGuardContext { + store: ProjectStore; + // anchor 無し (chat) のときは空文字。anchor 依存 guard は空文字を skip せよ。 + anchorId: string; + // セッション内で生成済みノードの重複記録。キーは guard 実装が決める。 + sessionMemo: Set; + // マルチコードベース対応のために流すコードベース ID (optional)。 + codebaseId?: string; +} + +export interface DuplicateFound { + reason: string; // ユーザー向けメッセージ (既存 node id などを含む) +} + +export interface DuplicateGuard { + // 対象 adoptAs。複数対応は同 guard を複数 adoptAs で登録する。 + adoptAs: AdoptableType; + // 重複があれば DuplicateFound、無ければ null。 + check(input: GuardInput, ctx: DuplicateGuardContext): Promise; + // 生成成功後に呼ばれる (sessionMemo 更新など)。任意。 + onCreated?(input: GuardInput, ctx: DuplicateGuardContext): void; +} + +// adoptAs → Guard[] のレジストリ。Task 7-9 で個別 guard を追加する。 +const REGISTRY = new Map(); + +export function registerGuard(guard: DuplicateGuard): void { + const list = REGISTRY.get(guard.adoptAs) ?? []; + list.push(guard); + REGISTRY.set(guard.adoptAs, list); +} + +// dispatcher: 登録 guard を順に check し、最初に重複を見つけたら返す。 +// 全部 null なら null。Promise を一つずつ await する (並列にしない: 副作用順序を保つ)。 +export async function dispatchDuplicateGuard( + adoptAs: AdoptableType, + input: GuardInput, + ctx: DuplicateGuardContext, +): Promise { + const guards = REGISTRY.get(adoptAs) ?? []; + for (const g of guards) { + const found = await g.check(input, ctx); + if (found) return found; + } + return null; +} + +// 生成成功通知: 登録 guard の onCreated を全部呼ぶ。 +export function notifyCreated( + adoptAs: AdoptableType, + input: GuardInput, + ctx: DuplicateGuardContext, +): void { + const guards = REGISTRY.get(adoptAs) ?? []; + for (const g of guards) g.onCreated?.(input, ctx); +} + +// テスト用: REGISTRY をクリア。プロダクションコードからは呼ばないこと。 +// 命名 prefix で「test-only」を明示し、accidental 使用を防ぐ。 +export function __resetGuardsForTest(): void { + REGISTRY.clear(); +} + +import { coderefGuard } from './coderef'; +import { questionGuard } from './question'; +import { sourceUrlGuard } from './source-url'; + +// 個別 guard を register する (module load 時の副作用)。 +// テストは __resetGuardsForTest でクリアした後、必要な guard を再登録すること。 +registerGuard(coderefGuard); +registerGuard(questionGuard); +registerGuard(sourceUrlGuard); diff --git a/packages/ai-engine/src/duplicate-guards/question.test.ts b/packages/ai-engine/src/duplicate-guards/question.test.ts new file mode 100644 index 0000000..6b43554 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/question.test.ts @@ -0,0 +1,110 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; +import { questionGuard } from './question'; + +function makeCtx( + neighbors: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + const ctx: DuplicateGuardContext = { + store: { + listNodes: async () => [], + findRelatedNodes: async () => neighbors as never, + } as unknown as ProjectStore, + anchorId: 'anchor-1', + sessionMemo: new Set(), + ...override, + }; + return ctx; +} + +describe('questionGuard', () => { + beforeEach(() => __resetGuardsForTest()); + + it('adoptAs は "question"', () => { + expect(questionGuard.adoptAs).toBe('question'); + }); + + it('anchorId が空なら skip (null を返す) — T1 fix の前提', async () => { + const ctx = makeCtx([], { anchorId: '' }); + const res = await questionGuard.check( + { title: '[AI] Q', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('同 anchor に同タイトル正規 question が既にあれば重複', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('q1'); + expect(res?.reason).toContain('anchor-1'); + }); + + it('同 anchor に同タイトル proposal (adoptAs=question) が既にあれば重複', async () => { + const ctx = makeCtx([ + { id: 'p1', type: 'proposal', adoptAs: 'question', title: '[AI] どうするか' }, + ]); + const res = await questionGuard.check( + { title: 'どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('[AI] prefix の有無を吸収して比較する', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: '[AI] どうするか' }]); + const res = await questionGuard.check( + { title: 'どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('q1'); + }); + + it('sessionMemo に同 anchor+title が記録済みなら重複 (同セッション内の連続生成防止)', async () => { + const ctx = makeCtx([], { sessionMemo: new Set(['anchor-1|どうするか']) }); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('同一セッション'); + expect(res?.reason).toContain('anchor-1'); + }); + + it('別タイトルなら重複ではない', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] 別の論点', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('近傍に他 type のノード (例 usecase) は無視', async () => { + const ctx = makeCtx([{ id: 'u1', type: 'usecase', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('onCreated が anchorId+title を sessionMemo に追加', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + questionGuard.onCreated?.({ title: '[AI] 新しい論点', body: '', additional: undefined }, ctx); + expect(memo.has('anchor-1|新しい論点')).toBe(true); + }); + + it('onCreated は anchorId が空なら何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { anchorId: '', sessionMemo: memo }); + questionGuard.onCreated?.({ title: '[AI] X', body: '', additional: undefined }, ctx); + expect(memo.size).toBe(0); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/question.ts b/packages/ai-engine/src/duplicate-guards/question.ts new file mode 100644 index 0000000..20fc189 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/question.ts @@ -0,0 +1,48 @@ +import { stripAiPrefix } from '@tally/core'; + +import type { DuplicateGuard } from './index'; + +// 既存 create-node.ts の question 用重複ガード (sessionQuestionKeys + findRelatedNodes) を移行。 +// +// T1 fix の前提: chat 経由 (anchorId が空) ではこの guard は skip し、 +// Task 9 の sourceUrl guard が代替で重複検知する。 +// +// 比較方針: +// - title は stripAiPrefix で "[AI]" prefix を剥がしてから比較 (AI 提案と人間生成の混在対応) +// - 同セッション内の連続生成は sessionMemo (anchorId|normalizedTitle) で短絡防止 +// - DB 側は anchor の近傍 (findRelatedNodes) を引き、同タイトルの正規 question or +// proposal(adoptAs=question) があれば重複扱い +export const questionGuard: DuplicateGuard = { + adoptAs: 'question', + async check(input, ctx) { + // T1: anchorId が空なら skip (chat 経路で findRelatedNodes('') は空配列) + if (!ctx.anchorId) return null; + + const normalizedTitle = stripAiPrefix(input.title); + const sessionKey = `${ctx.anchorId}|${normalizedTitle}`; + if (ctx.sessionMemo.has(sessionKey)) { + return { + reason: `重複 (同一セッション内): anchor ${ctx.anchorId} に既に同タイトル question を生成済み`, + }; + } + + const neighbors = await ctx.store.findRelatedNodes(ctx.anchorId); + for (const n of neighbors) { + const rec = n as unknown as { id: string; type: string; adoptAs?: string; title: string }; + const isQuestion = + rec.type === 'question' || (rec.type === 'proposal' && rec.adoptAs === 'question'); + if (isQuestion && stripAiPrefix(rec.title) === normalizedTitle) { + return { + reason: `重複: anchor ${ctx.anchorId} に既に同タイトル question 候補 ${rec.id} が存在`, + }; + } + } + return null; + }, + onCreated(input, ctx) { + // anchor 無し (chat) では memo しない (T1 fix の対称) + if (!ctx.anchorId) return; + const normalizedTitle = stripAiPrefix(input.title); + ctx.sessionMemo.add(`${ctx.anchorId}|${normalizedTitle}`); + }, +}; diff --git a/packages/ai-engine/src/duplicate-guards/source-url.test.ts b/packages/ai-engine/src/duplicate-guards/source-url.test.ts new file mode 100644 index 0000000..834d85a --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/source-url.test.ts @@ -0,0 +1,130 @@ +import type { ProjectStore } from '@tally/storage'; +import { describe, expect, it } from 'vitest'; + +import type { DuplicateGuardContext } from './index'; +import { sourceUrlGuard } from './source-url'; + +function makeCtx( + nodes: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + return { + store: { + listNodes: async () => nodes as never, + findRelatedNodes: async () => [], + } as unknown as ProjectStore, + anchorId: '', + sessionMemo: new Set(), + ...override, + }; +} + +describe('sourceUrlGuard', () => { + // 注: このテストは sourceUrlGuard を直接呼んでおり、`dispatchDuplicateGuard` + // 経由のグローバル registry は触らない。__resetGuardsForTest は不要 (CR 指摘)。 + + it('adoptAs は "requirement"', () => { + expect(sourceUrlGuard.adoptAs).toBe('requirement'); + }); + + it('sourceUrl が additional に無ければ skip (null)', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: undefined }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('sourceUrl が空文字なら skip (null)', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: '' } }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('同 sourceUrl の正規 requirement が既にあれば重複', async () => { + const ctx = makeCtx([{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('r1'); + expect(res?.reason).toContain('https://jira.test/EPIC-1'); + }); + + it('同 sourceUrl の proposal(adoptAs=requirement) も重複検知対象', async () => { + const ctx = makeCtx([ + { id: 'p1', type: 'proposal', adoptAs: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }, + ]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('別 sourceUrl なら重複ではない', async () => { + const ctx = makeCtx([{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-2' } }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('他 type のノード (例 usecase) は無視', async () => { + const ctx = makeCtx([{ id: 'u1', type: 'usecase', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('sessionMemo に記録済みなら重複 (連続生成防止)', async () => { + const memo = new Set(['sourceUrl:https://jira.test/EPIC-1']); + const ctx = makeCtx([], { sessionMemo: memo }); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('同一セッション'); + }); + + it('chat 経路 (anchorId="") でも sourceUrl で重複検知 — T1 fix の核', async () => { + const ctx = makeCtx( + [{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }], + { anchorId: '' }, + ); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('r1'); + }); + + it('onCreated で sessionMemo にキーを追加', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(memo.has('sourceUrl:https://jira.test/EPIC-1')).toBe(true); + }); + + it('onCreated は sourceUrl が無いときは何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.({ title: 'R', body: '', additional: undefined }, ctx); + expect(memo.size).toBe(0); + }); + + it('onCreated は sourceUrl が空文字なら何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.({ title: 'R', body: '', additional: { sourceUrl: '' } }, ctx); + expect(memo.size).toBe(0); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/source-url.ts b/packages/ai-engine/src/duplicate-guards/source-url.ts new file mode 100644 index 0000000..5916313 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/source-url.ts @@ -0,0 +1,54 @@ +import type { DuplicateGuard } from './index'; + +// sourceUrl ベースの重複検知。 +// +// T1 fix の核: anchor 不要 → chat (anchorId='') でも動く。 +// 既存 question guard は anchorId に依存して findRelatedNodes 経由で動くが、 +// chat 経路では anchorId が空文字で findRelatedNodes('') が空配列を返すため重複ガードが dead。 +// sourceUrl は anchor 概念がないので、グラフ全件スキャンで重複検知する。 +// +// 対象: +// - 正規 requirement (`type === 'requirement'`) +// - adoptAs=requirement の proposal (`type === 'proposal' && adoptAs === 'requirement'`) +// +// memo キー: `sourceUrl:${url}` (anchor 非依存、グラフ横断で一意) +const SESSION_KEY_PREFIX = 'sourceUrl:'; + +export const sourceUrlGuard: DuplicateGuard = { + adoptAs: 'requirement', + async check(input, ctx) { + const sourceUrl = input.additional?.sourceUrl; + if (typeof sourceUrl !== 'string' || sourceUrl.length === 0) return null; + + const sessionKey = `${SESSION_KEY_PREFIX}${sourceUrl}`; + if (ctx.sessionMemo.has(sessionKey)) { + return { + reason: `重複 (同一セッション内): sourceUrl ${sourceUrl} を既に生成済み`, + }; + } + + const all = await ctx.store.listNodes(); + for (const n of all) { + const rec = n as Record; + const type = rec.type as string | undefined; + const adoptAs = rec.adoptAs as string | undefined; + const isRequirement = + type === 'requirement' || (type === 'proposal' && adoptAs === 'requirement'); + if (!isRequirement) continue; + const existingUrl = rec.sourceUrl as string | undefined; + if (existingUrl === sourceUrl) { + const id = rec.id as string; + return { + reason: `重複: sourceUrl ${sourceUrl} は既に node ${id} が保持`, + }; + } + } + return null; + }, + onCreated(input, ctx) { + const sourceUrl = input.additional?.sourceUrl; + if (typeof sourceUrl === 'string' && sourceUrl.length > 0) { + ctx.sessionMemo.add(`${SESSION_KEY_PREFIX}${sourceUrl}`); + } + }, +}; diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts new file mode 100644 index 0000000..0dc3939 --- /dev/null +++ b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildMcpServers } from './build-mcp-servers'; + +describe('buildMcpServers', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', () => { + const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, configs: [] }); + expect(Object.keys(result.mcpServers)).toEqual(['tally']); + expect(result.allowedTools).toEqual(['mcp__tally__*']); + }); + + it('Bearer (Server/DC) → Authorization: Bearer ', () => { + process.env.JIRA_PAT = 'secret-xyz'; + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'atlassian-dc', + name: 'A', + kind: 'atlassian', + url: 'https://jira.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + const atlassian = result.mcpServers['atlassian-dc'] as { + type: string; + url: string; + headers: Record; + }; + expect(atlassian.type).toBe('http'); + expect(atlassian.url).toBe('https://jira.test/mcp'); + expect(atlassian.headers.Authorization).toBe('Bearer secret-xyz'); + expect(result.allowedTools).toContain('mcp__tally__*'); + expect(result.allowedTools).toContain('mcp__atlassian-dc__*'); + }); + + it('Basic (Cloud) → Authorization: Basic ', () => { + process.env.ATLASSIAN_EMAIL = 'user@example.com'; + process.env.ATLASSIAN_API_TOKEN = 'api-token-xyz'; + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'atlassian-cloud', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + const atlassian = result.mcpServers['atlassian-cloud'] as { + headers: Record; + }; + const expected = Buffer.from('user@example.com:api-token-xyz').toString('base64'); + expect(atlassian.headers.Authorization).toBe(`Basic ${expected}`); + }); + + it('Bearer の tokenEnvVar 未設定 → throw', () => { + delete process.env.JIRA_PAT; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/JIRA_PAT/); + }); + + it('Basic の emailEnvVar 未設定 → throw', () => { + delete process.env.ATLASSIAN_EMAIL; + process.env.ATLASSIAN_API_TOKEN = 'x'; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/ATLASSIAN_EMAIL/); + }); + + it('Basic の tokenEnvVar 未設定 → throw', () => { + process.env.ATLASSIAN_EMAIL = 'user@example.com'; + delete process.env.ATLASSIAN_API_TOKEN; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/ATLASSIAN_API_TOKEN/); + }); + + it('env 値が空文字でも → throw (= 未設定と同じ扱い)', () => { + process.env.JIRA_PAT = ''; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/JIRA_PAT/); + }); + + it('複数の config を合成 → 各々が独立に build される', () => { + process.env.JIRA_PAT = 's1'; + process.env.OTHER_TOKEN = 's2'; + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'first', + name: 'F', + kind: 'atlassian', + url: 'https://a.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + { + id: 'second', + name: 'S', + kind: 'atlassian', + url: 'https://b.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'OTHER_TOKEN' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + expect(Object.keys(result.mcpServers)).toEqual(['tally', 'first', 'second']); + const first = result.mcpServers.first as { headers: Record }; + const second = result.mcpServers.second as { headers: Record }; + expect(first.headers.Authorization).toBe('Bearer s1'); + expect(second.headers.Authorization).toBe('Bearer s2'); + 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 new file mode 100644 index 0000000..e89bc1c --- /dev/null +++ b/packages/ai-engine/src/mcp/build-mcp-servers.ts @@ -0,0 +1,63 @@ +import type { McpServerConfig } from '@tally/core'; + +// SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 +// chat-runner / agent-runner が共通で使える shape にする。 +// +// 認証方式: +// - bearer (Server/DC): Authorization: Bearer +// - basic (Cloud): Authorization: Basic +// +// allowedTools は wildcard `mcp____*` (Spike 0b 確認済、Claude Code 2.1.117+ サポート)。 +export interface BuildMcpServersInput { + // createSdkMcpServer で組み立てた Tally MCP。ここでは opaque。 + tallyMcp: unknown; + // プロジェクト設定 project.mcpServers[]。 + configs: McpServerConfig[]; +} + +export interface BuildMcpServersResult { + mcpServers: Record; + allowedTools: string[]; +} + +function requireEnv(varName: string, contextId: string): string { + const v = process.env[varName]; + if (v === undefined || v === '') { + throw new Error(`MCP 設定 "${contextId}" の env var "${varName}" が未設定または空です`); + } + return v; +} + +function buildAuthHeader(auth: McpServerConfig['auth'], contextId: string): string { + if (auth.scheme === 'bearer') { + const token = requireEnv(auth.tokenEnvVar, contextId); + return `Bearer ${token}`; + } + // basic + const email = requireEnv(auth.emailEnvVar, contextId); + const token = requireEnv(auth.tokenEnvVar, contextId); + const b64 = Buffer.from(`${email}:${token}`).toString('base64'); + return `Basic ${b64}`; +} + +// SDK 設定と allowedTools を組み立てる。env 未設定は throw。 +// 呼び出し元 (chat-runner / agent-runner) は runUserTurn の都度これを呼ぶ +// → env 変更がホットリロードされる。 +export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersResult { + const { tallyMcp, configs } = input; + + const mcpServers: Record = { tally: tallyMcp }; + const allowedTools: string[] = ['mcp__tally__*']; + + for (const cfg of configs) { + const authHeader = buildAuthHeader(cfg.auth, cfg.id); + mcpServers[cfg.id] = { + type: 'http' as const, + url: cfg.url, + headers: { Authorization: authHeader }, + }; + allowedTools.push(`mcp__${cfg.id}__*`); + } + + return { mcpServers, allowedTools }; +} diff --git a/packages/ai-engine/src/mcp/redact.test.ts b/packages/ai-engine/src/mcp/redact.test.ts new file mode 100644 index 0000000..30877f8 --- /dev/null +++ b/packages/ai-engine/src/mcp/redact.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { redactMcpSecrets } from './redact'; + +describe('redactMcpSecrets', () => { + it('Authorization header を "***" に置換', () => { + const input = { + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer abc-123' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { atlassian: { headers: Record } }; + }; + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + // 元オブジェクトは破壊しない (immutable) + expect(input.mcpServers.atlassian.headers.Authorization).toBe('Bearer abc-123'); + }); + + it('Basic auth (Basic xxx) も同じく redact', () => { + const input = { + mcpServers: { + cloud: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Basic dXNlcjpwYXNz' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { cloud: { headers: Record } }; + }; + expect(out.mcpServers.cloud.headers.Authorization).toBe('***'); + }); + + it('他の header は保持', () => { + const out = redactMcpSecrets({ + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer secret', 'X-Other': 'keep' }, + }, + }, + }) as { + mcpServers: { atlassian: { url: string; headers: Record } }; + }; + expect(out.mcpServers.atlassian.url).toBe('https://x.test/mcp'); + expect(out.mcpServers.atlassian.headers['X-Other']).toBe('keep'); + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + }); + + it('mcpServers 不在ならそのまま返す', () => { + const input = { foo: 'bar' }; + expect(redactMcpSecrets(input)).toEqual(input); + }); + + it('mcpServers 内に headers が無いサーバ (SDK type 等) は触らない', () => { + const sdkServer = { type: 'sdk', name: 'tally' }; + const input = { + mcpServers: { tally: sdkServer }, + }; + const out = redactMcpSecrets(input) as { mcpServers: Record }; + expect(out.mcpServers.tally).toEqual(sdkServer); + }); + + it('複数サーバが混在しても各々を独立に処理', () => { + const input = { + mcpServers: { + tally: { type: 'sdk', name: 'tally' }, + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer xyz' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { + tally: { type: string }; + atlassian: { type: string; headers?: Record }; + }; + }; + expect(out.mcpServers.tally.type).toBe('sdk'); + expect(out.mcpServers.atlassian.headers?.Authorization).toBe('***'); + }); + + it('non-object input (primitive / null / array) はそのまま返す', () => { + expect(redactMcpSecrets(null)).toBe(null); + expect(redactMcpSecrets(undefined)).toBe(undefined); + expect(redactMcpSecrets(42)).toBe(42); + expect(redactMcpSecrets('string')).toBe('string'); + expect(redactMcpSecrets([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('Authorization 値が配列等の非 string でも redact される (安全側に倒す)', () => { + const input = { + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { + Authorization: ['Bearer xxx'] as unknown as string, + }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { atlassian: { headers: Record } }; + }; + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + }); +}); diff --git a/packages/ai-engine/src/mcp/redact.ts b/packages/ai-engine/src/mcp/redact.ts new file mode 100644 index 0000000..bcc1289 --- /dev/null +++ b/packages/ai-engine/src/mcp/redact.ts @@ -0,0 +1,46 @@ +// SDK に渡す mcpServers 設定 (Authorization header) をログに出す前の安全な形に変換する。 +// プロセスメモリには PAT が残るが、ログ出力経路では "***" にする。 +// 元オブジェクトは破壊せず、shallow copy で返す (mcpServers と該当 server / headers のみ複製)。 +// +// 注意: +// - Authorization header の検出は **canonical な "Authorization" のみ** (case-sensitive)。 +// Claude Agent SDK は McpHttpServerConfig.headers を canonical 表記で吐くため十分。 +// 将来 SDK 仕様変更で "authorization" 等の表記が混在する場合は本関数を更新する。 +// - 現状は Authorization のみ redact 対象。Cookie / X-API-Key / Proxy-Authorization 等は対応外。 +// MVP の MCP HTTP transport では Authorization 以外の credential header を使わないため。 +export function redactMcpSecrets(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + + const obj = value as Record; + if (!obj.mcpServers || typeof obj.mcpServers !== 'object' || Array.isArray(obj.mcpServers)) { + return value; + } + + const servers = obj.mcpServers as Record; + const redactedServers: Record = {}; + + for (const [name, cfg] of Object.entries(servers)) { + if (cfg && typeof cfg === 'object' && !Array.isArray(cfg) && 'headers' in cfg) { + const src = cfg as { headers?: unknown }; + const headers = src.headers; + if ( + headers && + typeof headers === 'object' && + !Array.isArray(headers) && + 'Authorization' in headers + ) { + redactedServers[name] = { + ...(cfg as Record), + headers: { + ...(headers as Record), + Authorization: '***', + }, + }; + continue; + } + } + redactedServers[name] = cfg; + } + + return { ...obj, mcpServers: redactedServers }; +} diff --git a/packages/ai-engine/src/server.test.ts b/packages/ai-engine/src/server.test.ts index 7d0d50e..e39bffe 100644 --- a/packages/ai-engine/src/server.test.ts +++ b/packages/ai-engine/src/server.test.ts @@ -24,6 +24,7 @@ describe('WS /agent', () => { id: 'proj-ws', name: 'WS', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -158,6 +159,7 @@ describe('WS /chat', () => { id: 'proj-ws', name: 'WS', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/ai-engine/src/stream.ts b/packages/ai-engine/src/stream.ts index 040573c..1b9a916 100644 --- a/packages/ai-engine/src/stream.ts +++ b/packages/ai-engine/src/stream.ts @@ -40,6 +40,23 @@ export type ChatEvent = } | { type: 'chat_assistant_message_completed'; messageId: string } | { type: 'chat_turn_ended' } + // 外部 MCP (mcp__tally__ 以外) の tool_use を承認なしで永続化するときに発火。 + // AI が外部ソースを read したことを UI に見える形で残す (Task 12)。 + | { + type: 'chat_tool_external_use'; + messageId: string; + toolUseId: string; + name: string; + input: unknown; + } + // 外部 MCP の tool_result。AI が読んだ外部ソースの内容を UI に展開可能で表示する (Task 12)。 + | { + type: 'chat_tool_external_result'; + messageId: string; + toolUseId: string; + ok: boolean; + output: string; + } | { type: 'error'; code: string; message: string }; // SDK の厳密な型に依存せず、実行時に触る最小限のプロパティだけで型付けする。 diff --git a/packages/ai-engine/src/tools/create-node.test.ts b/packages/ai-engine/src/tools/create-node.test.ts index a5e4b0a..bdcf089 100644 --- a/packages/ai-engine/src/tools/create-node.test.ts +++ b/packages/ai-engine/src/tools/create-node.test.ts @@ -695,3 +695,159 @@ describe('adoptAs=question の anchor+同タイトル重複ガード', () => { expect(r2.ok).toBe(true); }); }); + +describe('createNodeHandler — sourceUrl 重複ガード (Task 9 連動)', () => { + // duplicate-guards/source-url.ts と組み合わさった統合テスト。 + // Task 10 の主目的: dispatcher 経由で sourceUrl guard が発火することを担保する。 + + it('同 sourceUrl の requirement が既にあれば 2 度目の作成は fail (chat anchor 無し経路)', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: 'Jira Issue', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-1', + }, + ]; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn(), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + // chat 経路: anchorId は空文字 + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira Issue', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-1' }, + }); + expect(res.ok).toBe(false); + expect(res.output).toContain('sourceUrl'); + expect(store.addNode).not.toHaveBeenCalled(); + }); + + it('chat 経路 (anchorId="") でも sourceUrl で重複検知が動く (proposal 既存)', async () => { + const existing = [ + { + id: 'prop-req-0', + type: 'proposal', + adoptAs: 'requirement', + x: 0, + y: 0, + title: '[AI] Jira Issue', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-2', + }, + ]; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn(), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira Issue 再取込', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-2' }, + }); + expect(res.ok).toBe(false); + expect(res.output).toContain('sourceUrl'); + expect(store.addNode).not.toHaveBeenCalled(); + }); + + it('別 sourceUrl なら重複扱いしない (新規 requirement として通る)', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: 'Jira PROJ-1', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-1', + }, + ]; + const added: Array> = []; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn().mockImplementation(async (n: Record) => { + const created = { ...n, id: `n-${added.length + 1}` }; + added.push(created); + return created; + }), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira PROJ-2', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-2' }, + }); + expect(res.ok).toBe(true); + expect(added).toHaveLength(1); + expect(added[0]?.sourceUrl).toBe('https://example.atlassian.net/browse/PROJ-2'); + }); + + it('sourceUrl 無し (legacy / 非外部 ingest) は guard skip → 通常通り作成', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: '内部要求', + body: '', + // sourceUrl 無し + }, + ]; + const added: Array> = []; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn().mockImplementation(async (n: Record) => { + const created = { ...n, id: `n-${added.length + 1}` }; + added.push(created); + return created; + }), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: '別の内部要求', + body: '', + // sourceUrl 無し → guard skip + }); + expect(res.ok).toBe(true); + expect(added).toHaveLength(1); + }); +}); diff --git a/packages/ai-engine/src/tools/create-node.ts b/packages/ai-engine/src/tools/create-node.ts index 6a9886a..75c27dd 100644 --- a/packages/ai-engine/src/tools/create-node.ts +++ b/packages/ai-engine/src/tools/create-node.ts @@ -1,16 +1,26 @@ import path from 'node:path'; import type { AdoptableType, AgentName, ProposalNode } from '@tally/core'; -import { newQuestionOptionId, stripAiPrefix } from '@tally/core'; +import { newQuestionOptionId } from '@tally/core'; import type { ProjectStore } from '@tally/storage'; import { z } from 'zod'; +import { + type DuplicateGuardContext, + dispatchDuplicateGuard, + notifyCreated, +} from '../duplicate-guards/index'; import type { AgentEvent } from '../stream'; // create_node: ツールハンドラ。AI は proposal しか作れない (ADR-0005 前提)。 // adoptAs は「採用されたら何になるか」を宣言。title に [AI] プレフィックスが無ければ自動付与。 // x/y 未指定時は呼び出し元が与える anchor 座標を基準に自動オフセット配置。 -// coderef の場合は filePath を正規化し、近接する既存 coderef があれば重複としてガードする。 +// +// 重複検知は duplicate-guards/ の strategy map に委譲 (Task 6-9 で抽出)。 +// ここでは「保存内容の整合性」だけ責任を持つ: +// - coderef の filePath 正規化と codebaseId 注入 (DB に書く値そのものを揃える) +// - question の options 正規化と min 2 検証 (採用後 decision 不能を防ぐ) +// - dispatcher 呼び出し → addNode → notifyCreated const ADOPTABLE_TYPES = [ 'requirement', @@ -36,7 +46,7 @@ export interface CreateNodeDeps { store: ProjectStore; emit: (e: AgentEvent) => void; anchor: { x: number; y: number }; - // anchor ノードの id。question 重複ガードで近傍を引くために使う。 + // anchor ノードの id。question 重複ガードで近傍を引くために使う。chat 経路は空文字。 anchorId: string; // AI が生成した proposal に sourceAgentId として刻むエージェント名。 // どの agent が作ったかを後から辿れるようにするため required。 @@ -51,63 +61,26 @@ export interface ToolResult { output: string; } -// filePath 近接判定の許容行数。`find-related-code` / `analyze-impact` は -// スキャン位置がブレやすく、同一箇所を複数 proposal として追加しがちなので -// ±10 行以内を重複とみなしてガードする。 -const CODEREF_LINE_TOLERANCE = 10; - -// "./src/a.ts" や "src//a.ts" を "src/a.ts" に正規化する。 -// 比較・保存を揃えるため。 -function normalizeFilePath(fp: string): string { - const stripped = fp.startsWith('./') ? fp.slice(2) : fp; - return path.posix.normalize(stripped); -} - -async function findDuplicateCoderef( - store: ProjectStore, - filePath: string, - startLine: number, - codebaseId: string | undefined, -): Promise<{ id: string; startLine: number } | null> { - const all = await store.listNodes(); - for (const n of all) { - const rec = n as Record; - const type = rec.type as string | undefined; - const adoptAs = rec.adoptAs as string | undefined; - // 正規 coderef と、adoptAs=coderef の proposal の両方を対象にする。 - const isCoderef = type === 'coderef' || (type === 'proposal' && adoptAs === 'coderef'); - if (!isCoderef) continue; - const fp = rec.filePath as string | undefined; - const sl = rec.startLine as number | undefined; - if (!fp || typeof sl !== 'number') continue; - if (normalizeFilePath(fp) !== filePath) continue; - // マルチコードベース対応: 同一 filePath でも codebaseId が異なれば別物として扱う。 - // codebaseId 未指定の旧 proposal (レガシー) や横断エージェントは従来通り全件比較する。 - const existingCb = rec.codebaseId as string | undefined; - if (codebaseId !== undefined && existingCb !== undefined && existingCb !== codebaseId) { - continue; - } - if (Math.abs(sl - startLine) <= CODEREF_LINE_TOLERANCE) { - return { id: rec.id as string, startLine: sl }; - } - } - return null; -} - // adoptAs=question の options として有効な最小数。extract-questions の仕様上 // 「必ず 2〜4 個」とプロンプト指示しているが、AI が守らなかったとき proposal // 採用後に decision を選べない question が出来てしまうのでサーバ側でも弾く。 const QUESTION_MIN_OPTIONS = 2; +// 保存前の filePath 正規化 (guard 内の正規化とは独立、保存内容の整合性のため必須)。 +// "./src/a.ts" や "src//a.ts" を "src/a.ts" に揃えて DB に書く。 +function normalizeFilePathForStorage(fp: string): string { + const stripped = fp.startsWith('./') ? fp.slice(2) : fp; + return path.posix.normalize(stripped); +} + export function createNodeHandler(deps: CreateNodeDeps) { // 複数ノードが同じ anchor で作られたときに重ならないよう、呼び出しごとにオフセットをずらす。 // agent セッション毎に独立させるため handler closure で保持。 let nextOffsetIndex = 0; - // 同一セッション内で作成済みの question を「anchorId|正規化タイトル」の Set で記録する。 - // findRelatedNodes は edge 経由で近傍を引くため、「create_node × 2 → create_edge × 2」の - // 順にモデルが呼んだとき 1 件目の edge 作成前は 2 件目の findRelatedNodes が 1 件目を - // 拾えず重複が素通りする。セッション内 Set と併用して防ぐ。 - const sessionQuestionKeys = new Set(); + // duplicate-guards の sessionMemo (anchorId|title など、guard 実装が定義するキー)。 + // handler closure で持ち、同一エージェントセッション内の重複を短絡防止する。 + const sessionMemo = new Set(); + return async (input: unknown): Promise => { const parsed = CreateNodeInputSchema.safeParse(input); if (!parsed.success) { @@ -115,9 +88,10 @@ export function createNodeHandler(deps: CreateNodeDeps) { } const { adoptAs, title, body, x, y, additional } = parsed.data; - // coderef のとき filePath を正規化して additional に戻し、さらに近接 coderef を探して重複ガード。 - // deps.codebaseId があれば additional に必ず注入し、adopt 時に codebaseId 必須検証が通るようにする。 let normalizedAdditional = additional; + + // coderef: filePath 正規化 + codebaseId 注入。 + // 保存値の正規化なので guard 委譲とは別に必須 (DB に "./" 付きを書かない)。 if (adoptAs === 'coderef') { const base = additional ?? {}; const withCb: Record = @@ -126,29 +100,14 @@ export function createNodeHandler(deps: CreateNodeDeps) { : { ...base }; const fp = withCb.filePath; if (typeof fp === 'string' && fp.length > 0) { - const normalized = normalizeFilePath(fp); - withCb.filePath = normalized; - const sl = withCb.startLine; - if (typeof sl === 'number') { - const activeCbId = - typeof withCb.codebaseId === 'string' ? (withCb.codebaseId as string) : undefined; - const dup = await findDuplicateCoderef(deps.store, normalized, sl, activeCbId); - if (dup) { - return { - ok: false, - output: `重複: ${dup.id} と近接 (filePath=${normalized}, startLine 差=${Math.abs(dup.startLine - sl)})`, - }; - } - } + withCb.filePath = normalizeFilePathForStorage(fp); } normalizedAdditional = withCb; } - // adoptAs=question: options の正規化 + 有効数チェック + anchor 重複ガード。 + // adoptAs=question: options の正規化 + 有効数チェック。 // AI は { text } だけ渡す (仕様)。id / selected 指定があっても上書きする (信頼境界)。 // options < 2 件の proposal は「決定不能な question」になるのでサーバ側で弾く。 - // sessionKey は addNode 成功後に set へ追加する (失敗時の汚染回避)。 - let sessionKey: string | null = null; if (adoptAs === 'question') { const rawOptions = additional?.options; const normalizedOptions = Array.isArray(rawOptions) @@ -173,32 +132,22 @@ export function createNodeHandler(deps: CreateNodeDeps) { options: normalizedOptions, decision: null, }; - - // anchor の近傍に同タイトル question (正規 or proposal) があれば重複として弾く。 - // 比較は [AI] 接頭辞を剥がして揃える。 - const normalizedTitle = stripAiPrefix(title); - sessionKey = `${deps.anchorId}|${normalizedTitle}`; - if (sessionQuestionKeys.has(sessionKey)) { - return { - ok: false, - output: `重複 (同一セッション内): anchor ${deps.anchorId} に既に同タイトル question を生成済み`, - }; - } - const neighbors = await deps.store.findRelatedNodes(deps.anchorId); - const dup = neighbors.find((n) => { - const rec = n as unknown as { type: string; adoptAs?: string; title: string }; - const isQuestion = - rec.type === 'question' || (rec.type === 'proposal' && rec.adoptAs === 'question'); - return isQuestion && stripAiPrefix(rec.title) === normalizedTitle; - }); - if (dup) { - return { - ok: false, - output: `重複: anchor ${deps.anchorId} に既に同タイトル question 候補 ${(dup as { id: string }).id} が存在`, - }; - } } + // 重複ガード: dispatcher に委譲 (coderef / question / source-url の guard が登録済み)。 + // 重複あれば early return、無ければ addNode に進む。 + // codebaseId は exactOptionalPropertyTypes 対応で条件付きで含める。 + const guardCtx: DuplicateGuardContext = { + store: deps.store, + anchorId: deps.anchorId, + sessionMemo, + ...(deps.codebaseId !== undefined ? { codebaseId: deps.codebaseId } : {}), + }; + const guardInput = { title, body, additional: normalizedAdditional }; + const dup = await dispatchDuplicateGuard(adoptAs, guardInput, guardCtx); + if (dup) return { ok: false, output: dup.reason }; + + // 共通: ensureTitle / placement / addNode const ensuredTitle = title.startsWith('[AI]') ? title : `[AI] ${title}`; const idx = nextOffsetIndex++; const placedX = x ?? deps.anchor.x + 260 + idx * 20; @@ -216,7 +165,10 @@ export function createNodeHandler(deps: CreateNodeDeps) { sourceAgentId: deps.agentName, } as Parameters[0])) as ProposalNode; deps.emit({ type: 'node_created', node: created }); - if (sessionKey) sessionQuestionKeys.add(sessionKey); + + // 生成成功後、guard に通知 (sessionMemo の更新など)。失敗時は通知しない (Set 汚染回避)。 + notifyCreated(adoptAs, guardInput, guardCtx); + return { ok: true, output: JSON.stringify(created) }; } catch (err) { return { ok: false, output: `addNode failed: ${String(err)}` }; diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index b2199ed..f510daf 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -8,8 +8,10 @@ import { CodebaseSchema, CodeRefNodeSchema, EdgeSchema, + McpServerConfigSchema, NodeSchema, ProjectMetaSchema, + ProjectSchema, ProposalNodeSchema, QuestionNodeSchema, RequirementNodeSchema, @@ -317,3 +319,503 @@ describe('ChatThreadSchema / ChatThreadMetaSchema', () => { ).toBe(true); }); }); + +describe('McpServerConfigSchema', () => { + it('Cloud (basic) auth の round-trip が通る', () => { + const raw = { + id: 'atlassian-cloud', + name: 'Atlassian Cloud', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/v1/mcp', + auth: { + type: 'pat' as const, + scheme: 'basic' as const, + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; + const parsed = McpServerConfigSchema.parse(raw); + expect(parsed).toEqual(raw); + }); + + it('Server/DC (bearer) auth の round-trip が通る', () => { + const raw = { + id: 'atlassian-onprem', + name: 'Atlassian On-Prem', + kind: 'atlassian' as const, + url: 'https://jira.example.com/mcp', + auth: { + type: 'pat' as const, + scheme: 'bearer' as const, + tokenEnvVar: 'JIRA_PAT', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; + const parsed = McpServerConfigSchema.parse(raw); + expect(parsed).toEqual(raw); + }); + + it('basic で emailEnvVar 無しは fail', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'basic', tokenEnvVar: 'T' }, + }), + ).toThrow(); + }); + + it('options 未指定なら default が入る', () => { + const parsed = McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X_PAT' }, + }); + expect(parsed.options.maxChildIssues).toBe(30); + expect(parsed.options.maxCommentsPerIssue).toBe(5); + }); + + it('url が URL でないと fail', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'not a url', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, + }), + ).toThrow(); + }); +}); + +describe('McpServerConfigSchema hardening', () => { + // hardening test の共通 valid base。テスト対象のフィールドだけを上書きする。 + const validBase = { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/v1/mcp', + auth: { + type: 'pat' as const, + scheme: 'bearer' as const, + tokenEnvVar: 'JIRA_PAT', + }, + }; + + describe('url: https 強制 + loopback 例外', () => { + it('https スキームは pass', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'https://x.test/mcp' }), + ).not.toThrow(); + }); + + it('http://localhost は pass (sooperset セルフホスト想定)', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://localhost:9000/mcp' }), + ).not.toThrow(); + }); + + it('http://127.0.0.1 は pass', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://127.0.0.1:9000/mcp' }), + ).not.toThrow(); + }); + + it('http://example.com は fail (cleartext で credential が漏れる)', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://example.com/mcp' }), + ).toThrow(); + }); + + it('ftp:// は fail', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'ftp://x.test/mcp' }), + ).toThrow(); + }); + }); + + describe('id: charset 制約 (CodebaseSchema.id と同じ regex)', () => { + it("'atlassian' は pass", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'atlassian' })).not.toThrow(); + }); + + it("'atlassian-cloud' は pass", () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, id: 'atlassian-cloud' }), + ).not.toThrow(); + }); + + it("'a' は pass (1 文字)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a' })).not.toThrow(); + }); + + it("'Atlassian' は fail (大文字)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'Atlassian' })).toThrow(); + }); + + it("'1abc' は fail (数字始まり)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: '1abc' })).toThrow(); + }); + + it("'a_b' は fail (アンダースコア含む)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a_b' })).toThrow(); + }); + + it("'a.b' は fail (ドット含む)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a.b' })).toThrow(); + }); + + it('33 文字は fail (上限超過)', () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a'.repeat(33) })).toThrow(); + }); + }); + + describe('emailEnvVar / tokenEnvVar: env var 名 regex', () => { + const baseBasic = { + ...validBase, + auth: { + type: 'pat' as const, + scheme: 'basic' as const, + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + }; + + it("'ATLASSIAN_PAT' は pass", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'ATLASSIAN_PAT' }, + }), + ).not.toThrow(); + }); + + it("'JIRA_PAT_1' は pass (数字含む OK)", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT_1' }, + }), + ).not.toThrow(); + }); + + it("'A' は pass (1 文字大文字)", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'A' }, + }), + ).not.toThrow(); + }); + + it("'lowercase' は fail", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'lowercase' }, + }), + ).toThrow(); + }); + + it("'foo@bar.com' は fail (実値混入を防ぐ)", () => { + expect(() => + McpServerConfigSchema.parse({ + ...baseBasic, + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'foo@bar.com', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + }), + ).toThrow(); + }); + + it("'1ABC' は fail (数字始まり)", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '1ABC' }, + }), + ).toThrow(); + }); + + it("'' (空文字) は fail", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '' }, + }), + ).toThrow(); + }); + + it('basic auth の emailEnvVar も同じ regex を要求', () => { + expect(() => + McpServerConfigSchema.parse({ + ...baseBasic, + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'lowercase', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + }), + ).toThrow(); + }); + }); +}); + +describe('ProjectSchema.mcpServers', () => { + it('mcpServers 未指定なら default の空配列', () => { + const p = ProjectSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + nodes: [], + edges: [], + }); + expect(p.mcpServers).toEqual([]); + }); + + it('mcpServers 指定で round-trip する', () => { + const input = { + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + nodes: [], + edges: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian' as const, + url: 'https://x.test/mcp', + auth: { type: 'pat' as const, scheme: 'bearer' as const, tokenEnvVar: 'JIRA_PAT' }, + }, + ], + }; + const p = ProjectSchema.parse(input); + expect(p.mcpServers).toHaveLength(1); + expect(p.mcpServers[0]?.options.maxChildIssues).toBe(30); + expect(p.mcpServers[0]?.id).toBe('atlassian'); + }); +}); + +describe('ProjectMetaSchema.mcpServers', () => { + it('ProjectMetaSchema にも mcpServers が乗る (project.yaml の meta との整合)', () => { + const meta = ProjectMetaSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian' as const, + url: 'https://x.test/mcp', + auth: { type: 'pat' as const, scheme: 'bearer' as const, tokenEnvVar: 'JIRA_PAT' }, + }, + ], + }); + expect(meta.mcpServers).toHaveLength(1); + }); + + it('既存 YAML (mcpServers 無し) は default [] で読める (後方互換)', () => { + const meta = ProjectMetaSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }); + expect(meta.mcpServers).toEqual([]); + }); +}); + +describe('ChatBlockSchema.tool_use.source', () => { + it('source 未指定の古いデータが "internal" に defaults', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__tally__create_node', + input: { x: 1 }, + approval: 'approved', + }); + expect(b.type).toBe('tool_use'); + if (b.type === 'tool_use') expect(b.source).toBe('internal'); + }); + + it('source = "external" は承認不要 (approval optional)', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-2', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'EPIC-1' }, + source: 'external', + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('external'); + expect(b.approval).toBeUndefined(); + } + }); + + it('source = "external" + approval 指定もできる (任意で記録可)', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-3', + name: 'mcp__atlassian__jira_search', + input: {}, + source: 'external', + approval: 'approved', + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('external'); + expect(b.approval).toBe('approved'); + } + }); + + it('source = "internal" で approval 無しは fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-4', + name: 'mcp__tally__create_node', + input: {}, + source: 'internal', + }), + ).toThrow(); + }); + + it('source 未指定 (= internal default) で approval 無しは fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-5', + name: 'mcp__tally__create_node', + input: {}, + }), + ).toThrow(); + }); + + it('既存の internal + approval=pending/approved/rejected は引き続き valid', () => { + for (const a of ['pending', 'approved', 'rejected'] as const) { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: `tu-${a}`, + name: 'mcp__tally__create_node', + input: {}, + approval: a, + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('internal'); + expect(b.approval).toBe(a); + } + } + }); + + it('source の不正値 (例 "auto") は fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-bad', + name: 'mcp__tally__create_node', + input: {}, + source: 'auto', + approval: 'approved', + }), + ).toThrow(); + }); +}); + +describe('RequirementNodeSchema.sourceUrl', () => { + it('sourceUrl 未指定は optional (既存互換)', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + }); + expect(n.sourceUrl).toBeUndefined(); + }); + + it('sourceUrl 指定で保持', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'https://jira.test/browse/EPIC-1', + }); + expect(n.sourceUrl).toBe('https://jira.test/browse/EPIC-1'); + }); + + it('sourceUrl が URL でないと fail', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'not a url', + }), + ).toThrow(); + }); + + it('sourceUrl が http:// なら fail (https 強制、UI link でも cleartext は禁止)', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'http://jira.test/browse/EPIC-1', + }), + ).toThrow(); + }); + + it('sourceUrl が https:// なら pass', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'https://jira.test/browse/EPIC-1', + }); + expect(n.sourceUrl).toBe('https://jira.test/browse/EPIC-1'); + }); + + it('sourceUrl が ftp:// なら fail', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'ftp://jira.test/EPIC-1', + }), + ).toThrow(); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 849e3c9..7526184 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -53,6 +53,23 @@ export const RequirementNodeSchema = z.object({ kind: z.enum(REQUIREMENT_KINDS).optional(), qualityCategory: z.enum(QUALITY_CATEGORIES).optional(), priority: z.enum(REQUIREMENT_PRIORITIES).optional(), + // 外部 MCP (Atlassian 等) から取り込んだ場合の元情報 URL。Phase 6+ で UI から開けるようにする予定。 + // UI link 経由で credential が漏れる構図を排除するため https-only。 + // McpServerConfig.url と異なり loopback 例外は不要 (Jira issue URL に loopback はあり得ない)。 + sourceUrl: z + .string() + .url() + .refine( + (u) => { + try { + return new URL(u).protocol === 'https:'; + } catch { + return false; + } + }, + { message: 'sourceUrl は https で始まる必要があります' }, + ) + .optional(), }); export const UseCaseNodeSchema = z.object({ @@ -174,6 +191,115 @@ function checkUniqueCodebaseIds( } } +// mcpServers[].id の重複を検出して issue を積む。superRefine の共通ロジック。 +// buildMcpServers が Record にマップするため、重複 id を許容すると +// 後勝ちで silent override されつつ allowedTools には両方残るため整合性が崩れる。 +function checkUniqueMcpServerIds( + mcpServers: { id: string }[] | undefined, + ctx: z.RefinementCtx, +): void { + if (!mcpServers) return; + const seen = new Set(); + for (const s of mcpServers) { + if (seen.has(s.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `mcpServers[].id 重複: ${s.id}`, + path: ['mcpServers'], + }); + return; + } + seen.add(s.id); + } +} + +// --------------------------------------------------------------------------- +// MCP サーバー設定スキーマ (Atlassian MCP 連携) +// --------------------------------------------------------------------------- +// 注: ProjectMetaSchema / ProjectSchema が McpServerConfigSchema を参照するため、 +// 宣言順序として MCP セクションを Project 系より前に置く。 + +// 環境変数名の shape (POSIX 準拠: 大文字英 + 数字 + アンダースコア、先頭は大文字英)。 +// 実値 (例 "foo@bar.com") の混入を防ぐ。空文字は regex で自動的に reject される。 +const ENV_VAR_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/u; +const envVarName = z.string().regex(ENV_VAR_NAME_REGEX, { + message: 'env var 名は ^[A-Z][A-Z0-9_]*$ (大文字英始まり、英数字・_ のみ)', +}); + +// Atlassian Cloud は Basic (base64(email:token))、Server/DC は Bearer (pat) の 2 scheme。 +// どちらも PAT ベースの認証 (OAuth は MVP 非対応、Premise 9)。 +const McpAuthSchema = z.discriminatedUnion('scheme', [ + z.object({ + type: z.literal('pat'), + scheme: z.literal('basic'), + emailEnvVar: envVarName, // 例 "ATLASSIAN_EMAIL" + tokenEnvVar: envVarName, // 例 "ATLASSIAN_API_TOKEN" + }), + z.object({ + type: z.literal('pat'), + scheme: z.literal('bearer'), + tokenEnvVar: envVarName, // 例 "JIRA_PAT" + }), +]); + +// options は未指定時に {} を default として与え、内側で各フィールドの default を発火させる。 +// zod v4 では outer .default(value) が parse 前に value をそのまま流すため、 +// 入力と同じ経路でフィールド default を解決するには .default({}) → inner default の 2 段構え。 +const McpServerOptionsSchema = z + .object({ + maxChildIssues: z.number().int().positive().default(30), + maxCommentsPerIssue: z.number().int().nonnegative().default(5), + }) + .default(() => ({ maxChildIssues: 30, maxCommentsPerIssue: 5 })); + +// MCP サーバー id は SDK の wildcard `mcp____*` の id 部分に embed されるため、 +// tool 名 matching が壊れないよう CodebaseSchema.id と同じ charset 制約を採用。 +const McpServerIdRegex = /^[a-z][a-z0-9-]{0,31}$/u; + +export const McpServerConfigSchema = z.object({ + id: z.string().regex(McpServerIdRegex, { + message: 'mcp server id は先頭英小文字 + 英小文字/数字/ハイフン、32 字以内', + }), + name: z.string().min(1), + kind: z.literal('atlassian'), + // PAT を Authorization header で送る transport なので cleartext を許さない。 + // 開発・テスト用の loopback (localhost / 127.0.0.1 / ::1) のみ http: を例外的に許容。 + // URL 内資格情報 (user:pass@host) はログ・プロキシ漏洩リスクがあり、Authorization header + // 設計とも不整合のため拒否する。 + url: z + .string() + .url() + .refine( + (u) => { + try { + const parsed = new URL(u); + if (parsed.username || parsed.password) return false; + if (parsed.protocol === 'https:') return true; + if ( + parsed.protocol === 'http:' && + (parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '::1' || + parsed.hostname === '[::1]') + ) { + return true; + } + return false; + } catch { + return false; + } + }, + { + message: + 'url は https で始まる必要があります (loopback の http は例外的に許容)。URL 内資格情報 (user:pass@) は禁止', + }, + ), + auth: McpAuthSchema, + options: McpServerOptionsSchema, +}); + +export type McpServerConfig = z.infer; + // .tally/project.yaml に対応する meta のみのスキーマ。 // ノード・エッジはファイル分割で永続化するため、ここには含めない。 export const ProjectMetaSchema = z @@ -183,10 +309,15 @@ export const ProjectMetaSchema = z description: z.string().optional(), // 0 件以上。code ノードが存在するときは最低 1 件必要(整合性は storage 層で検証)。 codebases: z.array(CodebaseSchema), + // Atlassian 等の MCP サーバー設定。既存 YAML (フィールド無し) は default [] で読み込める。 + mcpServers: z.array(McpServerConfigSchema).default([]), createdAt: z.string(), updatedAt: z.string(), }) - .superRefine((meta, ctx) => checkUniqueCodebaseIds(meta.codebases, ctx)); + .superRefine((meta, ctx) => { + checkUniqueCodebaseIds(meta.codebases, ctx); + checkUniqueMcpServerIds(meta.mcpServers, ctx); + }); // 実行時に Project 全体を扱う際の合成スキーマ (メモリ上表現)。 export const ProjectSchema = z @@ -195,22 +326,31 @@ export const ProjectSchema = z name: z.string().min(1), description: z.string().optional(), codebases: z.array(CodebaseSchema), + // Atlassian 等の MCP サーバー設定。ProjectMetaSchema と整合。 + mcpServers: z.array(McpServerConfigSchema).default([]), createdAt: z.string(), updatedAt: z.string(), nodes: z.array(NodeSchema), edges: z.array(EdgeSchema), }) - .superRefine((p, ctx) => checkUniqueCodebaseIds(p.codebases, ctx)); + .superRefine((p, ctx) => { + checkUniqueCodebaseIds(p.codebases, ctx); + checkUniqueMcpServerIds(p.mcpServers, ctx); + }); -// PATCH /api/projects/:id の body スキーマ。codebases 全置換のみ許可(部分更新はしない)。 +// PATCH /api/projects/:id の body スキーマ。codebases / mcpServers は全置換のみ (部分更新はしない)。 export const ProjectMetaPatchSchema = z .object({ name: z.string().min(1).optional(), description: z.string().nullable().optional(), codebases: z.array(CodebaseSchema).optional(), + mcpServers: z.array(McpServerConfigSchema).optional(), }) .strict() - .superRefine((patch, ctx) => checkUniqueCodebaseIds(patch.codebases, ctx)); + .superRefine((patch, ctx) => { + checkUniqueCodebaseIds(patch.codebases, ctx); + checkUniqueMcpServerIds(patch.mcpServers, ctx); + }); // --------------------------------------------------------------------------- // チャットスキーマ (Phase 6) @@ -218,13 +358,20 @@ export const ProjectMetaPatchSchema = z export const ChatBlockSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), text: z.string() }), - z.object({ - type: z.literal('tool_use'), - toolUseId: z.string().min(1), - name: z.string().min(1), - input: z.unknown(), - approval: z.enum(['pending', 'approved', 'rejected']), - }), + z + .object({ + type: z.literal('tool_use'), + toolUseId: z.string().min(1), + name: z.string().min(1), + input: z.unknown(), + // 'internal' = Tally MCP (人間承認が必要)、'external' = Atlassian 等の外部 MCP (承認概念なし)。 + // 既存 YAML (source 無し) は default 'internal' で読めるよう後方互換を保つ。 + source: z.enum(['internal', 'external']).default('internal'), + approval: z.enum(['pending', 'approved', 'rejected']).optional(), + }) + .refine((b) => b.source === 'external' || b.approval !== undefined, { + message: 'internal tool_use には approval が必要', + }), z.object({ type: z.literal('tool_result'), toolUseId: z.string().min(1), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bc9dcdf..0bc7a08 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -20,7 +20,7 @@ import type { UserStoryNodeSchema, } from './schema'; -export type { ChatBlock, ChatMessage, ChatThread, ChatThreadMeta } from './schema'; +export type { ChatBlock, ChatMessage, ChatThread, ChatThreadMeta, McpServerConfig } from './schema'; export type NodeType = (typeof NODE_TYPES)[number]; export type EdgeType = (typeof EDGE_TYPES)[number]; diff --git a/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts b/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts index 4d1e706..694b296 100644 --- a/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts @@ -22,6 +22,7 @@ describe('/api/projects/[id]/chats', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts b/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts index c2e639d..f55715f 100644 --- a/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts @@ -24,6 +24,7 @@ describe('POST /api/projects/[id]/edges', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -98,6 +99,7 @@ describe('PATCH /api/projects/[id]/edges/[edgeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts b/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts index b3b0090..4b78d78 100644 --- a/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts @@ -21,6 +21,7 @@ describe('POST /api/projects/[id]/nodes/[nodeId]/adopt', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts b/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts index 84cfc7c..066c602 100644 --- a/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts @@ -22,6 +22,7 @@ describe('POST /api/projects/[id]/nodes', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -91,6 +92,7 @@ describe('PATCH /api/projects/[id]/nodes/[nodeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -195,6 +197,7 @@ describe('DELETE /api/projects/[id]/nodes/[nodeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); 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 04861b5..f3e70bb 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.test.ts @@ -70,6 +70,110 @@ describe('PATCH /api/projects/:id', () => { expect(body.codebases).toEqual([{ id: 'web', label: 'Web', path: '/w' }]); }); + it('mcpServers[] を全置換 (Task 16)', async () => { + // 事前に既存 mcpServers を投入: 「マージ追記」ではなく「全置換」であることを検証する。 + const seedRes = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'legacy', + name: 'Legacy', + kind: 'atlassian', + url: 'https://old.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'OLD' }, + options: { maxChildIssues: 1, maxCommentsPerIssue: 1 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(seedRes.status).toBe(200); + + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { mcpServers: Array<{ id: string }> }; + expect(body.mcpServers).toHaveLength(1); + expect(body.mcpServers[0]?.id).toBe('atlassian'); + // legacy が残っていないこと (= 全置換) + expect(body.mcpServers.find((s) => s.id === 'legacy')).toBeUndefined(); + }); + + it('mcpServers の url が http (loopback 以外) なら 400 (Task 1 hardening)', async () => { + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'http://example.com/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(400); + }); + + it('mcpServers を空配列で全消去できる', async () => { + // 事前に登録 (seed PATCH の成功も assert: 前提崩れを取り逃さないため) + const seedRes = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(seedRes.status).toBe(200); + // 空配列で全消去 + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ mcpServers: [] }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { mcpServers: unknown[] }; + expect(body.mcpServers).toEqual([]); + }); + it('name を更新', async () => { const res = await PATCH( new Request('http://localhost', { diff --git a/packages/frontend/src/app/api/projects/[id]/route.ts b/packages/frontend/src/app/api/projects/[id]/route.ts index db983ee..4d0c4f3 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.ts @@ -84,6 +84,7 @@ export async function PATCH(req: Request, context: RouteContext): Promise { toolUseId: 'tool-abc', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }} />, @@ -38,6 +39,7 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-xyz', name: 'mcp__tally__create_edge', input: { from: 'a', to: 'b', type: 'derive' }, + source: 'internal', approval: 'pending', }} />, @@ -55,10 +57,52 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: {}, + source: 'internal', approval: 'pending', }} />, ); expect(screen.getByText(/^create_node$/)).toBeDefined(); }); + + it('source=external の tool_use は承認 / 却下ボタンを表示しない (Task 18)', () => { + useCanvasStore.setState({ approveChatTool: vi.fn() } as never); + render( + , + ); + expect(screen.queryByRole('button', { name: /^承認$/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /^却下$/ })).toBeNull(); + // 外部ソース表示は AI が読んだ tool 名を含む + expect(screen.getByText(/外部ソース/)).toBeInTheDocument(); + expect(screen.getByText(/atlassian: jira_get_issue/)).toBeInTheDocument(); + }); + + it('source=external は折り畳み (details) で input を隠す (Task 18)', () => { + useCanvasStore.setState({ approveChatTool: vi.fn() } as never); + const { container } = render( + , + ); + //
要素が存在し、その内側に input preview の
 が含まれていること。
+    // details が存在するだけだと「input が details の外に出ている」誤実装を取り逃すため
+    // 親子関係まで確認する。
+    const details = container.querySelector('details');
+    expect(details).not.toBeNull();
+    expect(details?.querySelector('pre')).not.toBeNull();
+  });
 });
diff --git a/packages/frontend/src/components/chat/tool-approval-card.tsx b/packages/frontend/src/components/chat/tool-approval-card.tsx
index 838b500..93ebeef 100644
--- a/packages/frontend/src/components/chat/tool-approval-card.tsx
+++ b/packages/frontend/src/components/chat/tool-approval-card.tsx
@@ -6,9 +6,28 @@ import { useCanvasStore } from '@/lib/store';
 
 type PendingToolBlock = Extract;
 
-// pending tool_use のカード UI。承認 / 却下ボタン。
+// tool_use のカード UI。
+// - source='internal' (Tally MCP): 承認 / 却下 ボタン (approval=pending のとき)
+// - source='external' (外部 MCP): 承認概念なし、AI が自律で読んだ外部ソースを折り畳み表示
 export function ToolApprovalCard({ block }: { block: PendingToolBlock }) {
   const approveChatTool = useCanvasStore((s) => s.approveChatTool);
+
+  // Task 18: 外部 MCP は source='external' で識別、承認 UI を出さない
+  if (block.source === 'external') {
+    const shortName = block.name.replace(/^mcp__([^_]+)__/, '$1: ');
+    const inputPreview = previewInput(block.input);
+    return (
+      
+
+ + 🔗 外部ソース {shortName} + +
{inputPreview}
+
+
+ ); + } + const shortName = block.name.replace(/^mcp__tally__/, ''); const inputPreview = previewInput(block.input); @@ -113,3 +132,18 @@ const APPROVE_BUTTON_STYLE = { fontSize: 11, cursor: 'pointer', }; +const EXTERNAL_CARD_STYLE = { + background: '#0d1d2a', + border: '1px solid #1f6feb', + borderRadius: 6, + padding: 8, + width: '100%', +}; +const EXTERNAL_HEADER_STYLE = { + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 12, + color: '#79c0ff', + cursor: 'pointer', +}; diff --git a/packages/frontend/src/components/details/usecase-detail.test.tsx b/packages/frontend/src/components/details/usecase-detail.test.tsx index 983e9f4..c4ecf3f 100644 --- a/packages/frontend/src/components/details/usecase-detail.test.tsx +++ b/packages/frontend/src/components/details/usecase-detail.test.tsx @@ -38,6 +38,7 @@ describe('UseCaseDetail', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'backend', label: 'Backend', path: '../backend' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: '', body: '' }], @@ -56,6 +57,7 @@ describe('UseCaseDetail', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'backend', label: 'Backend', path: '../backend' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: '', body: '' }], 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 58cabcc..fbac328 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -10,6 +10,7 @@ const meta: ProjectMeta = { id: 'proj-a', name: 'P', codebases: [{ id: 'web', label: 'Web', path: '/w' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }; @@ -92,6 +93,106 @@ describe('ProjectSettingsDialog', () => { ); }); + it('MCP サーバーを追加できる (Task 17)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // 新規追加された MCP server の id 入力欄が現れる (default は atlassian-1) + const idInput = screen.getByLabelText('mcp-0-id') as HTMLInputElement; + expect(idInput).toBeInTheDocument(); + expect(idInput.value).toBe('atlassian-1'); + // url / tokenEnvVar を入力 + const urlInput = screen.getByLabelText('mcp-0-url'); + await userEvent.type(urlInput, 'https://x.test/mcp'); + const tokenInput = screen.getByLabelText('mcp-0-tokenEnvVar'); + await userEvent.type(tokenInput, 'JIRA_PAT'); + + await userEvent.click(screen.getByRole('button', { name: /保存/ })); + await waitFor(() => + expect(patchProjectMeta).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: [ + expect.objectContaining({ + id: 'atlassian-1', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: expect.objectContaining({ + type: 'pat', + scheme: 'bearer', + tokenEnvVar: 'JIRA_PAT', + }), + }), + ], + }), + ), + ); + }); + + it('Basic auth 切替で emailEnvVar 入力欄が現れる (Task 17)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // 初期は bearer なので emailEnvVar 欄は無し + expect(screen.queryByLabelText('mcp-0-emailEnvVar')).toBeNull(); + // basic に切替 + const schemeSelect = screen.getByLabelText('mcp-0-scheme') as HTMLSelectElement; + await userEvent.selectOptions(schemeSelect, 'basic'); + // emailEnvVar 欄が現れる + expect(screen.getByLabelText('mcp-0-emailEnvVar')).toBeInTheDocument(); + }); + + it('MCP サーバーを削除できる (Task 17)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // 追加直後 1 件 + expect(screen.getByLabelText('mcp-0-id')).toBeInTheDocument(); + // MCP セクションの削除 button (codebase の削除と区別するため scope で取る) + // codebase 削除 + MCP 削除の 2 つ「削除」button があるので getAllByRole で取って最後を click + const removeButtons = screen.getAllByRole('button', { name: /削除/ }); + // 最後の削除 button = MCP のもの (codebase は 1 件目で追加 = 0 番目) + await userEvent.click(removeButtons[removeButtons.length - 1] as HTMLElement); + expect(screen.queryByLabelText('mcp-0-id')).toBeNull(); + }); + + // 旧実装は `mcpServers.length + 1` で id 採番していたため、削除→追加で + // 既存 id と衝突して下流の React key も衝突していた。未使用 suffix を探索する + // ように修正済み (Task 17 fix)。 + it('addMcpServer: 削除→追加で id が既存と衝突しない', async () => { + render( {}} />); + const addBtn = () => screen.getByRole('button', { name: /MCP サーバーを追加/ }); + await userEvent.click(addBtn()); // atlassian-1 + await userEvent.click(addBtn()); // atlassian-2 + expect((screen.getByLabelText('mcp-0-id') as HTMLInputElement).value).toBe('atlassian-1'); + expect((screen.getByLabelText('mcp-1-id') as HTMLInputElement).value).toBe('atlassian-2'); + // 1 件目 (mcp-0) を削除 → 残るのは元 mcp-1 だが index は 0 にスライド + const removeButtons = screen.getAllByRole('button', { name: /削除/ }); + // codebase 削除 (0) + MCP 2 件分 削除 (1, 2) → MCP 削除は最後 2 つ。1 件目 MCP を削除。 + await userEvent.click(removeButtons[removeButtons.length - 2] as HTMLElement); + expect((screen.getByLabelText('mcp-0-id') as HTMLInputElement).value).toBe('atlassian-2'); + // 再度追加 → 旧実装は length+1=2 で `atlassian-2` 衝突。修正後は `atlassian-1`。 + await userEvent.click(addBtn()); + const ids = [ + (screen.getByLabelText('mcp-0-id') as HTMLInputElement).value, + (screen.getByLabelText('mcp-1-id') as HTMLInputElement).value, + ]; + expect(new Set(ids).size).toBe(2); // 衝突なし + expect(ids).toContain('atlassian-1'); + expect(ids).toContain('atlassian-2'); + }); + + it('secret 値の入力欄は無い (envVar 名のみ。caption に .env への誘導)', async () => { + render( {}} />); + // 実 MCP 行に対する検証にするため先に行を 1 つ追加する (空 list だと退行検知が効かない)。 + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // tokenEnvVar (envVar 名) は存在する + expect(screen.getByLabelText('mcp-0-tokenEnvVar')).toBeInTheDocument(); + // secret / token / pat / api_token / password 系の入力欄が無いこと + expect(screen.queryByLabelText(/PAT$/i)).toBeNull(); + expect(screen.queryByLabelText(/シークレット/i)).toBeNull(); + expect(screen.queryByLabelText(/api_token$/i)).toBeNull(); + expect(screen.queryByLabelText(/password/i)).toBeNull(); + // .env への誘導文言 + expect(screen.getByText(/\.env/)).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 b19a8a9..a932bed 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -1,23 +1,54 @@ 'use client'; -import type { Codebase } from '@tally/core'; +import type { Codebase, McpServerConfig } from '@tally/core'; import { useEffect, useMemo, useState } from 'react'; import { TextInput } from '@/components/ui/text-input'; import { useCanvasStore } from '@/lib/store'; import { FolderBrowserDialog } from './folder-browser-dialog'; +// MCP サーバー新規追加時のデフォルト config (Bearer + 空 envVar 名 + 空 url)。 +// scheme/envVar は ユーザーが Cloud (basic) か Server/DC (bearer) かで選択する。 +function makeDefaultMcpServer(seq: number): McpServerConfig { + return { + id: `atlassian-${seq}`, + name: 'Atlassian', + kind: 'atlassian', + url: '', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; +} + +// UI ローカルの不変 ID を持つ MCP server エントリ。 +// React key として用いる `_uid` はマウント時に 1 度だけ割り当て、その後は変更しない。 +// `id` は UI で編集可能なため key にすると編集中の再マウント (focus / state リセット) +// や重複入力時の reconciliation 衝突を起こす。`_uid` は永続化対象外で onSave で剥がす。 +type McpServerEntry = McpServerConfig & { _uid: string }; + +function makeUid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + // crypto.randomUUID 非対応環境向け fallback (jsdom の古い setup 等)。 + return `mcp-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; +} + export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const projectMeta = useCanvasStore((s) => s.projectMeta); const patchProjectMeta = useCanvasStore((s) => s.patchProjectMeta); const [codebases, setCodebases] = useState([]); + const [mcpServers, setMcpServers] = useState([]); const [pickerOpen, setPickerOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { - if (open && projectMeta) setCodebases(projectMeta.codebases); + if (open && projectMeta) { + setCodebases(projectMeta.codebases); + setMcpServers((projectMeta.mcpServers ?? []).map((s) => ({ ...s, _uid: makeUid() }))); + } }, [open, projectMeta]); const duplicateIds = useMemo(() => { @@ -58,7 +89,9 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos setBusy(true); setError(null); try { - await patchProjectMeta({ codebases }); + // _uid は UI ローカルの React key 用なので永続化前に剥がす。 + const cleanedMcpServers = mcpServers.map(({ _uid, ...rest }) => rest); + await patchProjectMeta({ codebases, mcpServers: cleanedMcpServers }); onClose(); } catch (e) { setError(String((e as Error).message ?? e)); @@ -66,6 +99,28 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos } }; + const addMcpServer = () => { + // 採番 id は表示初期値として使うが、React key は別途 _uid を割り当てる + // (id はユーザーが編集できるため key に使うと編集中の再マウントを起こす)。 + const usedIds = new Set(mcpServers.map((s) => s.id)); + let seq = 1; + while (usedIds.has(`atlassian-${seq}`)) seq += 1; + setMcpServers([...mcpServers, { ...makeDefaultMcpServer(seq), _uid: makeUid() }]); + }; + + const updateMcpServer = (index: number, next: McpServerConfig) => { + const list = [...mcpServers]; + const prev = list[index]; + if (!prev) return; + // 既存エントリの _uid は保持 (フォーム編集で React key が変わるのを防ぐ)。 + list[index] = { ...next, _uid: prev._uid }; + setMcpServers(list); + }; + + const removeMcpServer = (index: number) => { + setMcpServers(mcpServers.filter((_, i) => i !== index)); + }; + return (
@@ -131,6 +186,137 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
+
+
+ MCP サーバー (Atlassian 等の外部連携) ({mcpServers.length}) + +
+
+ シークレット (PAT 等) はこのフォームでは入力しません。サーバーの .env に + ATLASSIAN_PAT=... のように置き、ここでは環境変数名のみ指定します。 +
+ {mcpServers.length === 0 &&
MCP サーバー未設定
} +
    + {mcpServers.map((s, i) => ( + // _uid は UI ローカルの不変 ID。s.id はフォームで編集可能なため key に使うと + // 編集中の再マウント (focus / state リセット) や重複入力時の reconciliation + // 衝突を起こすため避ける (CR 指摘 #19)。 +
  • +
    + updateMcpServer(i, { ...s, id: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-id`} + style={{ ...INPUT, width: 160 }} + placeholder="atlassian" + /> + updateMcpServer(i, { ...s, name: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-name`} + style={{ ...INPUT, flex: 1 }} + placeholder="表示名" + /> + +
    +
    + updateMcpServer(i, { ...s, url: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-url`} + style={{ ...INPUT, flex: 1 }} + placeholder="https://mcp.atlassian.example/v1/mcp" + /> + +
    +
    + {s.auth.scheme === 'basic' && ( + + updateMcpServer(i, { + ...s, + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: e.target.value, + tokenEnvVar: s.auth.tokenEnvVar, + }, + }) + } + disabled={busy} + aria-label={`mcp-${i}-emailEnvVar`} + style={{ ...INPUT, flex: 1 }} + placeholder="ATLASSIAN_EMAIL" + /> + )} + + updateMcpServer(i, { + ...s, + auth: { ...s.auth, tokenEnvVar: e.target.value }, + }) + } + disabled={busy} + aria-label={`mcp-${i}-tokenEnvVar`} + style={{ ...INPUT, flex: 1 }} + placeholder={s.auth.scheme === 'basic' ? 'ATLASSIAN_API_TOKEN' : 'JIRA_PAT'} + /> +
    +
  • + ))} +
+
+ {error && (
{error} @@ -227,6 +413,21 @@ const CB_ITEM = { gap: 6, flexWrap: 'wrap' as const, }; +const MCP_ITEM = { + display: 'flex', + flexDirection: 'column' as const, + gap: 4, + padding: 8, + border: '1px solid #30363d', + borderRadius: 6, + background: '#0d1117', +}; +const MCP_ROW = { + display: 'flex', + alignItems: 'center', + gap: 6, + flexWrap: 'wrap' as const, +}; const CB_PATH = { flex: 1, fontSize: 11, diff --git a/packages/frontend/src/lib/store.test.ts b/packages/frontend/src/lib/store.test.ts index f676521..4e59705 100644 --- a/packages/frontend/src/lib/store.test.ts +++ b/packages/frontend/src/lib/store.test.ts @@ -17,6 +17,7 @@ function baseProject(): Project { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: now, updatedAt: now, nodes: [n1], @@ -136,6 +137,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -181,6 +183,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -250,6 +253,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -307,6 +311,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -370,6 +375,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -409,6 +415,7 @@ describe('useCanvasStore', () => { id: 'proj-2', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -432,6 +439,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [], @@ -459,6 +467,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'old', label: 'Old', path: '/old' }], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [], @@ -487,6 +496,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -525,6 +535,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -680,6 +691,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -841,6 +853,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -890,6 +903,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -944,6 +958,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -989,6 +1004,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'req-a', type: 'requirement', x: 0, y: 0, title: 'A', body: '' }], diff --git a/packages/frontend/src/lib/store.ts b/packages/frontend/src/lib/store.ts index a72af75..fa18bb5 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -9,6 +9,7 @@ import type { Codebase, Edge, EdgeType, + McpServerConfig, Node, NodeType, Project, @@ -113,6 +114,7 @@ interface CanvasState { name?: string; description?: string | null; codebases?: Codebase[]; + mcpServers?: McpServerConfig[]; }) => Promise; // Phase 6: チャットスレッド管理。 @@ -277,6 +279,7 @@ export const useCanvasStore = create((set, get) => { toolUseId: evt.toolUseId, name: evt.name, input: evt.input, + source: 'internal', approval: 'pending', }, ], @@ -335,6 +338,50 @@ export const useCanvasStore = create((set, get) => { } return; } + // Task 12/18: 外部 MCP の tool_use を source='external' で append。承認 UI は出さない。 + if (evt.type === 'chat_tool_external_use') { + set({ + chatThreadMessages: get().chatThreadMessages.map((m) => { + if (m.id !== evt.messageId) return m; + return { + ...m, + blocks: [ + ...m.blocks, + { + type: 'tool_use', + toolUseId: evt.toolUseId, + name: evt.name, + input: evt.input, + source: 'external', + }, + ], + }; + }), + }); + return; + } + // Task 12/18: 外部 MCP の tool_result を append。 + // event はフル output (Task 13 の truncate は永続化のみ)。UI セッション内では全文閲覧可。 + if (evt.type === 'chat_tool_external_result') { + set({ + chatThreadMessages: get().chatThreadMessages.map((m) => { + if (m.id !== evt.messageId) return m; + return { + ...m, + blocks: [ + ...m.blocks, + { + type: 'tool_result', + toolUseId: evt.toolUseId, + ok: evt.ok, + output: evt.output, + }, + ], + }; + }), + }); + return; + } if (evt.type === 'chat_assistant_message_completed') { return; } diff --git a/packages/storage/src/chat-store.test.ts b/packages/storage/src/chat-store.test.ts index 744f04b..181fb18 100644 --- a/packages/storage/src/chat-store.test.ts +++ b/packages/storage/src/chat-store.test.ts @@ -64,6 +64,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: { x: 1 }, + source: 'internal', approval: 'pending', }, ], @@ -74,6 +75,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: { x: 1 }, + source: 'internal', approval: 'approved', }); const reloaded = await store.getChat(thread.id); @@ -155,15 +157,18 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-aaa', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }); const reloaded = await store.getChat(thread.id); expect(reloaded?.messages[0]?.blocks).toHaveLength(2); + // source は schema の default で 'internal' が入る (Phase 6+ Atlassian MCP 連携の準備) expect(reloaded?.messages[0]?.blocks[1]).toEqual({ type: 'tool_use', toolUseId: 'tool-aaa', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }); } finally { @@ -229,6 +234,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-a', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'A', body: '' }, + source: 'internal', approval: 'pending', }, { @@ -236,6 +242,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-b', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'B', body: '' }, + source: 'internal', approval: 'pending', }, ], diff --git a/packages/storage/src/init-project.ts b/packages/storage/src/init-project.ts index 92bed9b..9496ccf 100644 --- a/packages/storage/src/init-project.ts +++ b/packages/storage/src/init-project.ts @@ -71,6 +71,7 @@ export async function initProject(input: InitProjectInput): Promise { name: 'テストプロジェクト', description: '説明', codebases: [], + mcpServers: [], createdAt: '2026-04-18T10:00:00Z', updatedAt: '2026-04-18T10:00:00Z', }); @@ -355,6 +356,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -372,6 +374,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases, + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -386,6 +389,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [{ id: 'frontend', label: 'W', path: '/a' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -407,6 +411,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [{ id: 'frontend', label: 'W', path: '/a' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', });