From f1511b26944966eb0fcf2037b14fa7fbcc1d5da3 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Fri, 24 Apr 2026 16:01:23 +0900 Subject: [PATCH 01/33] =?UTF-8?q?feat(core):=20McpServerConfigSchema=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(Atlassian=20MCP=20=E9=80=A3?= =?UTF-8?q?=E6=90=BA=E3=81=AE=E5=9F=BA=E7=9B=A4=E3=80=81Basic/Bearer=20?= =?UTF-8?q?=E4=B8=A1=E5=AF=BE=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/schema.test.ts | 74 ++++++++++++++++++++++++++++++++ packages/core/src/schema.ts | 41 ++++++++++++++++++ packages/core/src/types.ts | 2 +- 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index b2199ed..a5b330c 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -8,6 +8,7 @@ import { CodebaseSchema, CodeRefNodeSchema, EdgeSchema, + McpServerConfigSchema, NodeSchema, ProjectMetaSchema, ProposalNodeSchema, @@ -317,3 +318,76 @@ 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(); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 849e3c9..9583b75 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -188,6 +188,47 @@ export const ProjectMetaSchema = z }) .superRefine((meta, ctx) => checkUniqueCodebaseIds(meta.codebases, ctx)); +// --------------------------------------------------------------------------- +// MCP サーバー設定スキーマ (Atlassian MCP 連携) +// --------------------------------------------------------------------------- + +// 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: z.string().min(1), // 例 "ATLASSIAN_EMAIL" + tokenEnvVar: z.string().min(1), // 例 "ATLASSIAN_API_TOKEN" + }), + z.object({ + type: z.literal('pat'), + scheme: z.literal('bearer'), + tokenEnvVar: z.string().min(1), // 例 "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 })); + +export const McpServerConfigSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + kind: z.literal('atlassian'), + url: z.string().url(), + auth: McpAuthSchema, + options: McpServerOptionsSchema, +}); + +export type McpServerConfig = z.infer; + // 実行時に Project 全体を扱う際の合成スキーマ (メモリ上表現)。 export const ProjectSchema = z .object({ 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]; From 098983cf58a47d10a1c8756903caa18566fcc274 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 13:58:23 +0900 Subject: [PATCH 02/33] =?UTF-8?q?feat(core):=20McpServerConfigSchema=20?= =?UTF-8?q?=E3=81=AB=20hardening=203=20=E4=BB=B6=20(https=20=E5=BC=B7?= =?UTF-8?q?=E5=88=B6=20/=20id=20charset=20/=20envVar=20=E5=90=8D=20regex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/schema.test.ts | 177 +++++++++++++++++++++++++++++++ packages/core/src/schema.ts | 48 ++++++++- 2 files changed, 220 insertions(+), 5 deletions(-) diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index a5b330c..765d500 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -391,3 +391,180 @@ describe('McpServerConfigSchema', () => { ).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(); + }); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 9583b75..eb57127 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -192,19 +192,26 @@ export const ProjectMetaSchema = z // MCP サーバー設定スキーマ (Atlassian MCP 連携) // --------------------------------------------------------------------------- +// 環境変数名の 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: z.string().min(1), // 例 "ATLASSIAN_EMAIL" - tokenEnvVar: z.string().min(1), // 例 "ATLASSIAN_API_TOKEN" + emailEnvVar: envVarName, // 例 "ATLASSIAN_EMAIL" + tokenEnvVar: envVarName, // 例 "ATLASSIAN_API_TOKEN" }), z.object({ type: z.literal('pat'), scheme: z.literal('bearer'), - tokenEnvVar: z.string().min(1), // 例 "JIRA_PAT" + tokenEnvVar: envVarName, // 例 "JIRA_PAT" }), ]); @@ -218,11 +225,42 @@ const McpServerOptionsSchema = z }) .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().min(1), + id: z.string().regex(McpServerIdRegex, { + message: 'mcp server id は先頭英小文字 + 英小文字/数字/ハイフン、32 字以内', + }), name: z.string().min(1), kind: z.literal('atlassian'), - url: z.string().url(), + // PAT を Authorization header で送る transport なので cleartext を許さない。 + // 開発・テスト用の loopback (localhost / 127.0.0.1 / ::1) のみ http: を例外的に許容。 + url: z + .string() + .url() + .refine( + (u) => { + try { + const parsed = new URL(u); + 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 は例外的に許容)' }, + ), auth: McpAuthSchema, options: McpServerOptionsSchema, }); From 42a5f473cf2eb474139fc39e58e85baaa6c19cfe Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:08:40 +0900 Subject: [PATCH 03/33] =?UTF-8?q?feat(core):=20ProjectSchema/ProjectMetaSc?= =?UTF-8?q?hema=20=E3=81=AB=20mcpServers[]=20=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20(default=20[])?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlassian MCP 連携の Project レベル設定を保持するため、 ProjectSchema と ProjectMetaSchema に mcpServers: McpServerConfig[] を追加。 default は [] とし、mcpServers フィールドを持たない既存 YAML も後方互換で読み込める。 宣言順の都合で MCP セクションを ProjectMetaSchema より前に移動。 ProjectMeta / Project 型は z.input 由来に切り替え、書き込み側で mcpServers 省略可とした (output は parse 時に default [] が解決される)。 --- packages/core/src/schema.test.ts | 74 ++++++++++++++++++++++++++++++++ packages/core/src/schema.ts | 34 +++++++++------ packages/core/src/types.ts | 11 +++-- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 765d500..6cd12c9 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -11,6 +11,7 @@ import { McpServerConfigSchema, NodeSchema, ProjectMetaSchema, + ProjectSchema, ProposalNodeSchema, QuestionNodeSchema, RequirementNodeSchema, @@ -568,3 +569,76 @@ describe('McpServerConfigSchema hardening', () => { }); }); }); + +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([]); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index eb57127..b950ddd 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -174,23 +174,11 @@ function checkUniqueCodebaseIds( } } -// .tally/project.yaml に対応する meta のみのスキーマ。 -// ノード・エッジはファイル分割で永続化するため、ここには含めない。 -export const ProjectMetaSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - // 0 件以上。code ノードが存在するときは最低 1 件必要(整合性は storage 層で検証)。 - codebases: z.array(CodebaseSchema), - createdAt: z.string(), - updatedAt: z.string(), - }) - .superRefine((meta, ctx) => checkUniqueCodebaseIds(meta.codebases, ctx)); - // --------------------------------------------------------------------------- // MCP サーバー設定スキーマ (Atlassian MCP 連携) // --------------------------------------------------------------------------- +// 注: ProjectMetaSchema / ProjectSchema が McpServerConfigSchema を参照するため、 +// 宣言順序として MCP セクションを Project 系より前に置く。 // 環境変数名の shape (POSIX 準拠: 大文字英 + 数字 + アンダースコア、先頭は大文字英)。 // 実値 (例 "foo@bar.com") の混入を防ぐ。空文字は regex で自動的に reject される。 @@ -267,6 +255,22 @@ export const McpServerConfigSchema = z.object({ export type McpServerConfig = z.infer; +// .tally/project.yaml に対応する meta のみのスキーマ。 +// ノード・エッジはファイル分割で永続化するため、ここには含めない。 +export const ProjectMetaSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + 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)); + // 実行時に Project 全体を扱う際の合成スキーマ (メモリ上表現)。 export const ProjectSchema = z .object({ @@ -274,6 +278,8 @@ 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), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0bc7a08..523dedb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,9 +39,14 @@ export type ProposalNode = z.infer; export type Node = z.infer; export type Edge = z.infer; -export type ProjectMeta = z.infer; -export type ProjectMetaPatch = z.infer; -export type Project = z.infer; +// ProjectMeta / Project は z.input 由来にする。 +// 理由: ProjectMetaSchema.mcpServers は default [] を持つので +// output 型では required、input 型では optional になる。 +// 既存の YAML や呼び出しが mcpServers を持たないケースを許容するため input 側を採用。 +// 読み取り時は z.parse で必ず default が解決され実値は McpServerConfig[]。 +export type ProjectMeta = z.input; +export type ProjectMetaPatch = z.input; +export type Project = z.input; // UserStoryNode の補助型。 export type AcceptanceCriterion = NonNullable[number]; From fb930a98f6ed65aeab51720a95d3256cc1d978a0 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:18:02 +0900 Subject: [PATCH 04/33] =?UTF-8?q?refactor(core):=20Project=20=E5=9E=8B?= =?UTF-8?q?=E3=82=92=20z.infer=20=E5=BE=A9=E5=B8=B0=E3=80=81=E6=97=A2?= =?UTF-8?q?=E5=AD=98=20fixture=20=E3=81=AB=20mcpServers:=20[]=20=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3987780 で導入した z.input 由来の Project / ProjectMeta / ProjectMetaPatch を z.infer (output 型) に戻す。foundation schema は parse 後の値を表すべきで、 input 緩和は downstream で ?? [] ガードを書く debt を生むため。 これに伴い必要となる既存 fixture 17 ファイルに mcpServers: [] を機械的に追加: - packages/storage/src/init-project.ts (1) - packages/storage/src/project-store.test.ts (5) - packages/ai-engine の test 4 ファイル (9) - packages/frontend の test 11 ファイル (32) 新規 test の追加は無し。既存 546 tests は同数のまま全 pass。 --- packages/ai-engine/src/agent-runner.test.ts | 2 ++ .../src/agents/find-related-code.test.ts | 4 ++++ packages/ai-engine/src/chat-runner.test.ts | 1 + packages/ai-engine/src/server.test.ts | 2 ++ packages/core/src/types.ts | 11 +++-------- .../api/projects/[id]/chats/chats-route.test.ts | 1 + .../api/projects/[id]/edges/edges-route.test.ts | 2 ++ .../nodes/[nodeId]/adopt/adopt-route.test.ts | 1 + .../api/projects/[id]/nodes/nodes-route.test.ts | 3 +++ .../ai-actions/analyze-impact-button.test.tsx | 1 + .../ai-actions/codebase-agent-button.test.tsx | 1 + .../ai-actions/extract-questions-button.test.tsx | 1 + .../ai-actions/find-related-code-button.test.tsx | 1 + .../components/details/usecase-detail.test.tsx | 2 ++ .../dialog/project-settings-dialog.test.tsx | 1 + packages/frontend/src/lib/store.test.ts | 16 ++++++++++++++++ packages/storage/src/init-project.ts | 1 + packages/storage/src/project-store.test.ts | 5 +++++ 18 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/ai-engine/src/agent-runner.test.ts b/packages/ai-engine/src/agent-runner.test.ts index 94adc43..deb37d7 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', }); 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..5208d63 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -21,6 +21,7 @@ describe('ChatRunner', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); 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/core/src/types.ts b/packages/core/src/types.ts index 523dedb..0bc7a08 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,14 +39,9 @@ export type ProposalNode = z.infer; export type Node = z.infer; export type Edge = z.infer; -// ProjectMeta / Project は z.input 由来にする。 -// 理由: ProjectMetaSchema.mcpServers は default [] を持つので -// output 型では required、input 型では optional になる。 -// 既存の YAML や呼び出しが mcpServers を持たないケースを許容するため input 側を採用。 -// 読み取り時は z.parse で必ず default が解決され実値は McpServerConfig[]。 -export type ProjectMeta = z.input; -export type ProjectMetaPatch = z.input; -export type Project = z.input; +export type ProjectMeta = z.infer; +export type ProjectMetaPatch = z.infer; +export type Project = z.infer; // UserStoryNode の補助型。 export type AcceptanceCriterion = NonNullable[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/components/ai-actions/analyze-impact-button.test.tsx b/packages/frontend/src/components/ai-actions/analyze-impact-button.test.tsx index 3593a53..9355776 100644 --- a/packages/frontend/src/components/ai-actions/analyze-impact-button.test.tsx +++ b/packages/frontend/src/components/ai-actions/analyze-impact-button.test.tsx @@ -11,6 +11,7 @@ const baseMeta = { id: 'proj-1', name: 'P', codebases: [] as { id: string; label: string; path: string }[], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [node], diff --git a/packages/frontend/src/components/ai-actions/codebase-agent-button.test.tsx b/packages/frontend/src/components/ai-actions/codebase-agent-button.test.tsx index e43f56f..39414a9 100644 --- a/packages/frontend/src/components/ai-actions/codebase-agent-button.test.tsx +++ b/packages/frontend/src/components/ai-actions/codebase-agent-button.test.tsx @@ -11,6 +11,7 @@ const baseMeta = { id: 'proj-1', name: 'P', codebases: [] as { id: string; label: string; path: string }[], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [anchor], diff --git a/packages/frontend/src/components/ai-actions/extract-questions-button.test.tsx b/packages/frontend/src/components/ai-actions/extract-questions-button.test.tsx index e292f03..64b2ec3 100644 --- a/packages/frontend/src/components/ai-actions/extract-questions-button.test.tsx +++ b/packages/frontend/src/components/ai-actions/extract-questions-button.test.tsx @@ -18,6 +18,7 @@ const baseMeta = { id: 'proj-1', name: 'P', codebases: [] as { id: string; label: string; path: string }[], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [anchor], diff --git a/packages/frontend/src/components/ai-actions/find-related-code-button.test.tsx b/packages/frontend/src/components/ai-actions/find-related-code-button.test.tsx index fb058bf..de58f32 100644 --- a/packages/frontend/src/components/ai-actions/find-related-code-button.test.tsx +++ b/packages/frontend/src/components/ai-actions/find-related-code-button.test.tsx @@ -11,6 +11,7 @@ const baseMeta = { id: 'proj-1', name: 'P', codebases: [] as { id: string; label: string; path: string }[], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [node], 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..d2e6b9a 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', }; 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/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', }); From f0af78768546f4f5769036095331ca127fe5682f Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:25:59 +0900 Subject: [PATCH 05/33] =?UTF-8?q?feat(core):=20ChatBlock.tool=5Fuse=20?= =?UTF-8?q?=E3=81=AB=20source=20=E8=BF=BD=E5=8A=A0=E3=80=81RequirementNode?= =?UTF-8?q?=20=E3=81=AB=20sourceUrl=20=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ai-engine/src/chat-runner.ts | 2 + packages/core/src/schema.test.ts | 136 ++++++++++++++++++ packages/core/src/schema.ts | 23 ++- .../chat/tool-approval-card.test.tsx | 3 + packages/frontend/src/lib/store.ts | 1 + packages/storage/src/chat-store.test.ts | 7 + 6 files changed, 165 insertions(+), 7 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index dba1055..6949430 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -234,6 +234,7 @@ export class ChatRunner { toolUseId: uiId, name: entry.name, input, + source: 'internal', approval: 'approved', }); await chatStore.appendBlockToMessage(threadId, assistantMsgId, { @@ -262,6 +263,7 @@ export class ChatRunner { toolUseId: uiToolUseId, name: entry.name, input, + source: 'internal', approval: 'pending', }); emit({ diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 6cd12c9..f2cae68 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -642,3 +642,139 @@ describe('ProjectMetaSchema.mcpServers', () => { 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(); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index b950ddd..6035d63 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -53,6 +53,8 @@ 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 から開けるようにする予定。 + sourceUrl: z.string().url().optional(), }); export const UseCaseNodeSchema = z.object({ @@ -303,13 +305,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/frontend/src/components/chat/tool-approval-card.test.tsx b/packages/frontend/src/components/chat/tool-approval-card.test.tsx index 653f286..8341cf7 100644 --- a/packages/frontend/src/components/chat/tool-approval-card.test.tsx +++ b/packages/frontend/src/components/chat/tool-approval-card.test.tsx @@ -20,6 +20,7 @@ describe('ToolApprovalCard', () => { 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,6 +57,7 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: {}, + source: 'internal', approval: 'pending', }} />, diff --git a/packages/frontend/src/lib/store.ts b/packages/frontend/src/lib/store.ts index a72af75..a47ff68 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -277,6 +277,7 @@ export const useCanvasStore = create((set, get) => { toolUseId: evt.toolUseId, name: evt.name, input: evt.input, + source: 'internal', approval: 'pending', }, ], 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', }, ], From ef984162189e5eede15810af112cbe8aea31589a Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:31:32 +0900 Subject: [PATCH 06/33] =?UTF-8?q?feat(core):=20RequirementNode.sourceUrl?= =?UTF-8?q?=20=E3=82=92=20https-only=20=E3=81=AB=20hardening=20(Task=201?= =?UTF-8?q?=20url=20=E3=81=A8=E3=81=AE=E6=95=B4=E5=90=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/schema.test.ts | 41 ++++++++++++++++++++++++++++++++ packages/core/src/schema.ts | 17 ++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index f2cae68..f510daf 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -777,4 +777,45 @@ describe('RequirementNodeSchema.sourceUrl', () => { }), ).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 6035d63..0bd2ffd 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -54,7 +54,22 @@ export const RequirementNodeSchema = z.object({ qualityCategory: z.enum(QUALITY_CATEGORIES).optional(), priority: z.enum(REQUIREMENT_PRIORITIES).optional(), // 外部 MCP (Atlassian 等) から取り込んだ場合の元情報 URL。Phase 6+ で UI から開けるようにする予定。 - sourceUrl: z.string().url().optional(), + // 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({ From 07a6aff90a6d249b199c2aa1bb9d9df1f272c10e Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:34:46 +0900 Subject: [PATCH 07/33] =?UTF-8?q?feat(ai-engine):=20redactMcpSecrets=20uti?= =?UTF-8?q?lity=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(Authorization=20header=20?= =?UTF-8?q?=E3=81=AE=20log=20=E6=BC=8F=E6=B4=A9=E4=BA=88=E9=98=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ai-engine/src/mcp/redact.test.ts | 99 +++++++++++++++++++++++ packages/ai-engine/src/mcp/redact.ts | 39 +++++++++ 2 files changed, 138 insertions(+) create mode 100644 packages/ai-engine/src/mcp/redact.test.ts create mode 100644 packages/ai-engine/src/mcp/redact.ts 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..fcef1dc --- /dev/null +++ b/packages/ai-engine/src/mcp/redact.test.ts @@ -0,0 +1,99 @@ +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]); + }); +}); diff --git a/packages/ai-engine/src/mcp/redact.ts b/packages/ai-engine/src/mcp/redact.ts new file mode 100644 index 0000000..fcd8fb9 --- /dev/null +++ b/packages/ai-engine/src/mcp/redact.ts @@ -0,0 +1,39 @@ +// SDK に渡す mcpServers 設定 (Authorization header) をログに出す前の安全な形に変換する。 +// プロセスメモリには PAT が残るが、ログ出力経路では "***" にする。 +// 元オブジェクトは破壊せず、shallow copy で返す (mcpServers と該当 server / headers のみ複製)。 +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 }; +} From 752bb7f217877578ff0a2e26039f3d55d946737a Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:38:05 +0900 Subject: [PATCH 08/33] =?UTF-8?q?feat(ai-engine):=20redactMcpSecrets=20?= =?UTF-8?q?=E3=81=AB=20case-sensitive=20=E6=B3=A8=E8=A8=98=E3=81=A8?= =?UTF-8?q?=E9=85=8D=E5=88=97=E5=80=A4=20test=20=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ai-engine/src/mcp/redact.test.ts | 18 ++++++++++++++++++ packages/ai-engine/src/mcp/redact.ts | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/packages/ai-engine/src/mcp/redact.test.ts b/packages/ai-engine/src/mcp/redact.test.ts index fcef1dc..30877f8 100644 --- a/packages/ai-engine/src/mcp/redact.test.ts +++ b/packages/ai-engine/src/mcp/redact.test.ts @@ -96,4 +96,22 @@ describe('redactMcpSecrets', () => { 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 index fcd8fb9..bcc1289 100644 --- a/packages/ai-engine/src/mcp/redact.ts +++ b/packages/ai-engine/src/mcp/redact.ts @@ -1,6 +1,13 @@ // 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; From 90155bcbd82de266bc45c0f3f2d9bd6e59686dc9 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:40:50 +0900 Subject: [PATCH 09/33] =?UTF-8?q?feat(ai-engine):=20buildMcpServers=20util?= =?UTF-8?q?ity=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(Basic/Bearer=20auth=20+=20?= =?UTF-8?q?allowedTools=20wildcard=20=E5=90=88=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/mcp/build-mcp-servers.test.ts | 191 ++++++++++++++++++ .../ai-engine/src/mcp/build-mcp-servers.ts | 63 ++++++ 2 files changed, 254 insertions(+) create mode 100644 packages/ai-engine/src/mcp/build-mcp-servers.test.ts create mode 100644 packages/ai-engine/src/mcp/build-mcp-servers.ts 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 }; +} From 9121e5d6bed18b4506780a1406b559982675cc9b Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:43:39 +0900 Subject: [PATCH 10/33] =?UTF-8?q?feat(ai-engine):=20duplicate-guards=20?= =?UTF-8?q?=E9=AA=A8=E6=A0=BC=20(interface=20+=20strategy=20dispatcher)=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/duplicate-guards/index.test.ts | 122 ++++++++++++++++++ .../ai-engine/src/duplicate-guards/index.ts | 73 +++++++++++ 2 files changed, 195 insertions(+) create mode 100644 packages/ai-engine/src/duplicate-guards/index.test.ts create mode 100644 packages/ai-engine/src/duplicate-guards/index.ts 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..53af068 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -0,0 +1,73 @@ +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(); +} From 6c5c88abd55f16a1cda4f47c7dfa4808b904a7b4 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:47:32 +0900 Subject: [PATCH 11/33] =?UTF-8?q?feat(ai-engine):=20coderef=20=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E3=82=AC=E3=83=BC=E3=83=89=E3=82=92=20duplicate-guard?= =?UTF-8?q?s/coderef.ts=20=E3=81=AB=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/duplicate-guards/coderef.test.ts | 147 ++++++++++++++++++ .../ai-engine/src/duplicate-guards/coderef.ts | 57 +++++++ .../ai-engine/src/duplicate-guards/index.ts | 6 + 3 files changed, 210 insertions(+) create mode 100644 packages/ai-engine/src/duplicate-guards/coderef.test.ts create mode 100644 packages/ai-engine/src/duplicate-guards/coderef.ts 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.ts b/packages/ai-engine/src/duplicate-guards/index.ts index 53af068..73fcf38 100644 --- a/packages/ai-engine/src/duplicate-guards/index.ts +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -71,3 +71,9 @@ export function notifyCreated( export function __resetGuardsForTest(): void { REGISTRY.clear(); } + +import { coderefGuard } from './coderef'; + +// 個別 guard を register する (module load 時の副作用)。 +// テストは __resetGuardsForTest でクリアした後、必要な guard を再登録すること。 +registerGuard(coderefGuard); From 79539a0d76ada2b79801d8073ca68ba1fd001dec Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:50:33 +0900 Subject: [PATCH 12/33] =?UTF-8?q?feat(ai-engine):=20question=20=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E3=82=AC=E3=83=BC=E3=83=89=E3=82=92=20duplicate-guard?= =?UTF-8?q?s/question.ts=20=E3=81=AB=E5=88=86=E9=9B=A2=20(anchorId=20?= =?UTF-8?q?=E7=A9=BA=E3=81=AF=20skip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-engine/src/duplicate-guards/index.ts | 2 + .../src/duplicate-guards/question.test.ts | 110 ++++++++++++++++++ .../src/duplicate-guards/question.ts | 48 ++++++++ 3 files changed, 160 insertions(+) create mode 100644 packages/ai-engine/src/duplicate-guards/question.test.ts create mode 100644 packages/ai-engine/src/duplicate-guards/question.ts diff --git a/packages/ai-engine/src/duplicate-guards/index.ts b/packages/ai-engine/src/duplicate-guards/index.ts index 73fcf38..617371c 100644 --- a/packages/ai-engine/src/duplicate-guards/index.ts +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -73,7 +73,9 @@ export function __resetGuardsForTest(): void { } import { coderefGuard } from './coderef'; +import { questionGuard } from './question'; // 個別 guard を register する (module load 時の副作用)。 // テストは __resetGuardsForTest でクリアした後、必要な guard を再登録すること。 registerGuard(coderefGuard); +registerGuard(questionGuard); 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}`); + }, +}; From 2fd94ab164f34125344bbd90a1bf18adaed7db11 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:02:41 +0900 Subject: [PATCH 13/33] =?UTF-8?q?feat(ai-engine):=20sourceUrl=20=E3=83=99?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E9=87=8D=E8=A4=87=E3=82=AC=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(T1=20fix:=20chat=20anchor=20?= =?UTF-8?q?=E7=84=A1=E3=81=97=E3=81=A7=E3=82=82=E5=8B=95=E3=81=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-engine/src/duplicate-guards/index.ts | 2 + .../src/duplicate-guards/source-url.test.ts | 129 ++++++++++++++++++ .../src/duplicate-guards/source-url.ts | 54 ++++++++ 3 files changed, 185 insertions(+) create mode 100644 packages/ai-engine/src/duplicate-guards/source-url.test.ts create mode 100644 packages/ai-engine/src/duplicate-guards/source-url.ts diff --git a/packages/ai-engine/src/duplicate-guards/index.ts b/packages/ai-engine/src/duplicate-guards/index.ts index 617371c..90ee894 100644 --- a/packages/ai-engine/src/duplicate-guards/index.ts +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -74,8 +74,10 @@ export function __resetGuardsForTest(): void { 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/source-url.test.ts b/packages/ai-engine/src/duplicate-guards/source-url.test.ts new file mode 100644 index 0000000..08022f6 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/source-url.test.ts @@ -0,0 +1,129 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { __resetGuardsForTest, 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', () => { + beforeEach(() => __resetGuardsForTest()); + + 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}`); + } + }, +}; From 3c81d19e8592bfd3faa66842fafb12617e50ff29 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:07:28 +0900 Subject: [PATCH 14/33] =?UTF-8?q?refactor(ai-engine):=20create-node=20?= =?UTF-8?q?=E3=82=92=20duplicate-guards=20=E3=81=AB=E5=A7=94=E8=AD=B2?= =?UTF-8?q?=E3=80=81sourceUrl=20guard=20=E3=82=92=E6=9C=89=E5=8A=B9?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-engine/src/tools/create-node.test.ts | 156 ++++++++++++++++++ packages/ai-engine/src/tools/create-node.ts | 142 ++++++---------- 2 files changed, 203 insertions(+), 95 deletions(-) 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)}` }; From 868ebdb030da9c4bf03d2defd913de1ac104e9c0 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:13:08 +0900 Subject: [PATCH 15/33] =?UTF-8?q?feat(ai-engine):=20ChatRunner=20=E3=81=8C?= =?UTF-8?q?=20buildMcpServers=20=E3=81=A7=E5=A4=96=E9=83=A8=20MCP=20?= =?UTF-8?q?=E3=82=92=E5=90=88=E6=88=90=20(Task=2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ai-engine/src/chat-runner.test.ts | 210 +++++++++++++++++++++ packages/ai-engine/src/chat-runner.ts | 37 +++- 2 files changed, 239 insertions(+), 8 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index 5208d63..c0ed5d1 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -475,3 +475,213 @@ 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 }); + }); +}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 6949430..f421a35 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'; @@ -133,6 +135,27 @@ 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; + } + const textBuffer: string[] = []; // 5. SDK query をバックグラウンドで走らせ、queue にイベントを push する。 @@ -143,14 +166,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,7 +179,10 @@ 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') { From 1240a452b70568ed92d41aa1658aad4c40db2798 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:34:21 +0900 Subject: [PATCH 16/33] =?UTF-8?q?feat(ai-engine):=20=E5=A4=96=E9=83=A8=20M?= =?UTF-8?q?CP=20=E3=81=AE=20tool=5Fuse/tool=5Fresult=20=E3=82=92=20source?= =?UTF-8?q?=3Dexternal=20=E3=81=A7=E6=B0=B8=E7=B6=9A=E5=8C=96=20(Task=2012?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatEvent に chat_tool_external_use / chat_tool_external_result を追加 - extractAssistantBlocks を拡張: text + 外部 MCP tool_use + tool_result を返す - mcp__tally__* は intercept 経路で処理されるため拾わない (重複回避) - chatStore.appendBlockToMessage で source: 'external' で永続化、approval 省略 - console.log は redactMcpSecrets でラップ済み (Task 11 で導入済) --- packages/ai-engine/src/chat-runner.test.ts | 232 +++++++++++++++++++++ packages/ai-engine/src/chat-runner.ts | 90 +++++++- packages/ai-engine/src/stream.ts | 17 ++ 3 files changed, 332 insertions(+), 7 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index c0ed5d1..f1ad729 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -685,3 +685,235 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { 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 が 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 }); + }); +}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index f421a35..4b9564b 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -27,9 +27,13 @@ export interface ChatRunnerDeps { threadId: string; } -// SDK の assistant message から抽出する block の単純化形。 -// MCP 経路に一本化したため tool_use は拾わないが、将来のデバッグ用に型定義は保持。 -type ExtractedBlock = { type: 'text'; text: string }; +// 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 (読み取り)。 @@ -188,6 +192,36 @@ export class ChatRunner { 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)。 + 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') { + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_result', + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); + queue.push({ + type: 'chat_tool_external_result', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); } } } @@ -613,20 +647,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/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 の厳密な型に依存せず、実行時に触る最小限のプロパティだけで型付けする。 From 66d5497405dbd531ded2eeb78f20abd87cc20b36 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:35:54 +0900 Subject: [PATCH 17/33] =?UTF-8?q?feat(ai-engine):=20tool=5Fresult=20output?= =?UTF-8?q?=20=E3=82=92=E6=B0=B8=E7=B6=9A=E5=8C=96=E6=99=82=204KB=20?= =?UTF-8?q?=E3=81=AB=20truncate=20(Task=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大規模 epic 取り込みで 1 ターン 500KB+ になり得る tool_result の YAML 永続化を 4KB + "(truncated, N chars total)" マーカーに切り詰める。event はフルを流すので UI セッション内では全文展開可、リロード後は truncated 版のみ。 --- packages/ai-engine/src/chat-runner.test.ts | 137 +++++++++++++++++++++ packages/ai-engine/src/chat-runner.ts | 16 ++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index f1ad729..6a53439 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -841,6 +841,143 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( 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* () { + 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* () { + 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-')); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 4b9564b..6be8f16 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -27,6 +27,18 @@ export interface ChatRunnerDeps { threadId: 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)。 @@ -209,11 +221,13 @@ export class ChatRunner { input: b.input, }); } else if (b.type === 'tool_result') { + // 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: b.output, + output: truncateForPersistence(b.output), }); queue.push({ type: 'chat_tool_external_result', From 312c88d85ff7e56a90e055edecf950a29297bf3f Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:38:36 +0900 Subject: [PATCH 18/33] =?UTF-8?q?feat(ai-engine):=20buildChatPrompt=20?= =?UTF-8?q?=E3=81=8C=20tool=5Fuse/tool=5Fresult=20=E3=82=82=20replay=20(Ta?= =?UTF-8?q?sk=2014,=20T4=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit multi-turn 対話で AI が前ターンの外部 MCP 出力を覚えるように buildChatPrompt を拡張。 旧版は text block のみ replay していたため、AI が 1 ターン目で @JIRA EPIC-1 を読んで proposal を生成しても、2 ターン目「続けて子チケット STORY-2 も見て」では Jira の内容を 覚えていない (= AI が summarize した assistant text しか残らない) 問題があった。 各 block を順に replay: - text → そのまま (assistant / user の自然言語) - tool_use → ...input... - tool_result → ...output... ChatRunner.runUserTurn の prompt 構築タイミングは現状維持 (user message append → contextNodes load → buildChatPrompt → 空 assistant append)。 --- packages/ai-engine/src/chat-runner.test.ts | 133 ++++++++++++++++++++- packages/ai-engine/src/chat-runner.ts | 36 ++++-- 2 files changed, 158 insertions(+), 11 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index 6a53439..6dcd91a 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'; @@ -1054,3 +1054,134 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( 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 6be8f16..0c71a65 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -613,12 +613,18 @@ export function formatNodeForContext(node: Node): string { } // チャット履歴を単一 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]; @@ -627,14 +633,24 @@ 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') { + lines.push(b.text); + } else if (b.type === 'tool_use') { + // source は default 'internal'。external も含めて全部 replay する + // (AI に「外部 source を読んだ」事実を context として伝えるため) + const sourceAttr = b.source === 'external' ? ' source="external"' : ''; + lines.push( + `${JSON.stringify(b.input)}`, + ); + } else if (b.type === 'tool_result') { + lines.push(`${b.output}`); + } } + lines.push(''); } lines.push(''); } From a4a9201b1cc9c344e5fdbe6acaf0f8ddd4a89675 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:42:02 +0900 Subject: [PATCH 19/33] =?UTF-8?q?feat(ai-engine):=20agent-runner=20refacto?= =?UTF-8?q?r=20+=20buildMcpServers=20=E5=85=B1=E6=9C=89=20(Task=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-runner.ts:114 の hardcoded mcpServers / allowedTools を chat-runner と 共通の buildMcpServers utility 経由に変更。プロジェクト設定 mcpServers[] が agent 側でも有効化され、Task 16+ で実装する ingest-jira-epic agent がそのまま 動く土台が整う。 - 既存 5 agent (decompose-to-stories / extract-questions / find-related-code / analyze-impact / ingest-document) の動作不変を 193 既存 test で regression 担保 - 新規 2 test: 外部 MCP 合成 (Bearer auth) / env 未設定で error event - agent 固有 allowedTools (mcp__tally__find_related 等) と外部 MCP wildcard (mcp____*) を合流、tally の wildcard は dedup - env 未設定 throw は既存の catch (err) で agent_failed error event に流れる --- packages/ai-engine/src/agent-runner.test.ts | 150 +++++++++++++++++++- packages/ai-engine/src/agent-runner.ts | 24 +++- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/packages/ai-engine/src/agent-runner.test.ts b/packages/ai-engine/src/agent-runner.test.ts index deb37d7..4982fae 100644 --- a/packages/ai-engine/src/agent-runner.test.ts +++ b/packages/ai-engine/src/agent-runner.test.ts @@ -438,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([]), @@ -491,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 デフォルト。 From 86886f3d01b8fe48d361e1e6bc888fd15480f283 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:43:57 +0900 Subject: [PATCH 20/33] =?UTF-8?q?feat(core,frontend):=20Project=20API=20?= =?UTF-8?q?=E3=81=A7=20mcpServers[]=20=E3=81=AE=20round-trip=20=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=20(Task=2016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core: ProjectMetaPatchSchema に mcpServers: McpServerConfig[] optional を追加 (.strict() なので明示追加が必要) - frontend: PATCH /api/projects/:id で parsed.data.mcpServers を next に流す - 新規 test 3 件: round-trip / hardening (http://example.com で 400) / 空配列で全消去 --- packages/core/src/schema.ts | 3 +- .../src/app/api/projects/[id]/route.test.ts | 80 +++++++++++++++++++ .../src/app/api/projects/[id]/route.ts | 1 + 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 0bd2ffd..2880814 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -304,12 +304,13 @@ export const ProjectSchema = z }) .superRefine((p, ctx) => checkUniqueCodebaseIds(p.codebases, 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)); 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..83368c9 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,86 @@ describe('PATCH /api/projects/:id', () => { expect(body.codebases).toEqual([{ id: 'web', label: 'Web', path: '/w' }]); }); + it('mcpServers[] を全置換 (Task 16)', async () => { + 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'); + }); + + 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 () => { + // 事前に登録 + 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 }) }, + ); + // 空配列で全消去 + 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 Date: Mon, 27 Apr 2026 15:48:34 +0900 Subject: [PATCH 21/33] =?UTF-8?q?feat(frontend):=20=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E8=A8=AD=E5=AE=9A=20dialog?= =?UTF-8?q?=20=E3=81=AB=20MCP=20=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC=20CRU?= =?UTF-8?q?D=20UI=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(Task=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcpServers[] の追加 / 削除 / 編集 (id / name / url / scheme / envVar) - Bearer (Server/DC) と Basic (Cloud) 切替で emailEnvVar 入力欄が出現 - secret 値はフォームに無し → caption で .env への誘導 - store の patchProjectMeta type に mcpServers? を追加 - 新規 test 4 件: 追加/削除/Basic 切替/secret 欄が無いこと --- .../dialog/project-settings-dialog.test.tsx | 70 +++++++ .../dialog/project-settings-dialog.tsx | 180 +++++++++++++++++- packages/frontend/src/lib/store.ts | 2 + 3 files changed, 249 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index d2e6b9a..49c78cc 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -93,6 +93,76 @@ 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(); + }); + + it('secret 値の入力欄は無い (envVar 名のみ。caption に .env への誘導)', () => { + render( {}} />); + // 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..b58da2f 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -1,23 +1,40 @@ '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 }, + }; +} + 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 ?? []); + } }, [open, projectMeta]); const duplicateIds = useMemo(() => { @@ -58,7 +75,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos setBusy(true); setError(null); try { - await patchProjectMeta({ codebases }); + await patchProjectMeta({ codebases, mcpServers }); onClose(); } catch (e) { setError(String((e as Error).message ?? e)); @@ -66,6 +83,20 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos } }; + const addMcpServer = () => { + setMcpServers([...mcpServers, makeDefaultMcpServer(mcpServers.length + 1)]); + }; + + const updateMcpServer = (index: number, next: McpServerConfig) => { + const list = [...mcpServers]; + list[index] = next; + setMcpServers(list); + }; + + const removeMcpServer = (index: number) => { + setMcpServers(mcpServers.filter((_, i) => i !== index)); + }; + return (
@@ -131,6 +162,134 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
+
+
+ MCP サーバー (Atlassian 等の外部連携) ({mcpServers.length}) + +
+
+ シークレット (PAT 等) はこのフォームでは入力しません。サーバーの .env に + ATLASSIAN_PAT=... のように置き、ここでは環境変数名のみ指定します。 +
+ {mcpServers.length === 0 &&
MCP サーバー未設定
} +
    + {mcpServers.map((s, i) => ( +
  • +
    + 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 +386,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.ts b/packages/frontend/src/lib/store.ts index a47ff68..90678e5 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: チャットスレッド管理。 From f6fa0de05fa7d039dbe98a139bb542f0afa0530b Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:50:43 +0900 Subject: [PATCH 22/33] =?UTF-8?q?feat(frontend):=20Chat=20UI=20=E3=81=A7?= =?UTF-8?q?=E5=A4=96=E9=83=A8=20MCP=20=E3=81=AE=20tool=5Fuse=20=E3=82=92?= =?UTF-8?q?=E6=8A=98=E3=82=8A=E7=95=B3=E3=81=BF=E8=A1=A8=E7=A4=BA=20(Task?= =?UTF-8?q?=2018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.ts: chat_tool_external_use / chat_tool_external_result の event handler を追加 - 内部 tool_use とは別 handler で source='external' の block を append - external は承認概念なし (approval は付与しない) - tool-approval-card.tsx: source 分岐 - source='external' は
折り畳み + 「外部ソース」ラベル + 承認 / 却下なし - source='internal' は既存通り承認 / 却下 button (退行なし) - 新規 test 2 件: external で承認 button 非表示 + details 折り畳み --- .../chat/tool-approval-card.test.tsx | 38 ++++++++++++++++ .../components/chat/tool-approval-card.tsx | 36 ++++++++++++++- packages/frontend/src/lib/store.ts | 44 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/chat/tool-approval-card.test.tsx b/packages/frontend/src/components/chat/tool-approval-card.test.tsx index 8341cf7..5f13f1e 100644 --- a/packages/frontend/src/components/chat/tool-approval-card.test.tsx +++ b/packages/frontend/src/components/chat/tool-approval-card.test.tsx @@ -64,4 +64,42 @@ describe('ToolApprovalCard', () => { ); 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( + , + ); + //
要素が存在 + const details = container.querySelector('details'); + expect(details).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/lib/store.ts b/packages/frontend/src/lib/store.ts index 90678e5..fa18bb5 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -338,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; } From 51b879ba434145f511531b6f43c8bd1b50ca0495 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Tue, 28 Apr 2026 18:10:07 +0900 Subject: [PATCH 23/33] =?UTF-8?q?fix(frontend):=20MCP=20=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=90=E3=83=BC=20addMcpServer=20=E3=81=AE=20id=20=E6=8E=A1?= =?UTF-8?q?=E7=95=AA=E8=A1=9D=E7=AA=81=E3=82=92=E5=9B=9E=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧実装は \`mcpServers.length + 1\` で id を採番していたため、 \`atlassian-1\` / \`atlassian-2\` の状態で 1 件削除して追加すると、 再び \`atlassian-2\` が生成されて key 衝突 → 1 件を黙って上書きしていた。 未使用 suffix を探索する方式に変更。 副次効果として React key も id だけで一意になるため、配列 index を key に使う必要が無くなり biome の \`noArrayIndexKey\` lint error を解消。 - addMcpServer: usedIds set を作り、最小未使用 suffix を採番 - map の key を \`s.id\` のみに変更 (index 削除) - regression test 追加: 削除→追加で id 衝突しない --- .../dialog/project-settings-dialog.test.tsx | 26 +++++++++++++++++++ .../dialog/project-settings-dialog.tsx | 11 ++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index 49c78cc..e0950ae 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -152,6 +152,32 @@ describe('ProjectSettingsDialog', () => { 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 への誘導)', () => { render( {}} />); // secret / token / pat / api_token / password 系の入力欄が無いこと diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.tsx index b58da2f..27ea21a 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -84,7 +84,13 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos }; const addMcpServer = () => { - setMcpServers([...mcpServers, makeDefaultMcpServer(mcpServers.length + 1)]); + // 削除→追加で `mcpServers.length + 1` を使うと既存 ID と衝突する + // (例: atlassian-1, atlassian-2 で 1 件削除して追加すると再び atlassian-2)。 + // mcpServers の id は下流で key として使われるため、未使用 suffix を探す。 + const usedIds = new Set(mcpServers.map((s) => s.id)); + let seq = 1; + while (usedIds.has(`atlassian-${seq}`)) seq += 1; + setMcpServers([...mcpServers, makeDefaultMcpServer(seq)]); }; const updateMcpServer = (index: number, next: McpServerConfig) => { @@ -176,7 +182,8 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos {mcpServers.length === 0 &&
MCP サーバー未設定
}
    {mcpServers.map((s, i) => ( -
  • + // addMcpServer で未使用 suffix を採番するため id は一意。React key として安全。 +
  • Date: Tue, 28 Apr 2026 18:13:49 +0900 Subject: [PATCH 24/33] =?UTF-8?q?test(ai-engine):=20source-url.test.ts=20?= =?UTF-8?q?=E3=81=AE=E4=B8=8D=E8=A6=81=E3=81=AA=20=5F=5FresetGuardsForTest?= =?UTF-8?q?=20=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 指摘 (PR #18 2nd review): このテストは sourceUrlGuard を 直接呼んでおり、dispatchDuplicateGuard 経由のグローバル registry は 触らないため、beforeEach の reset 呼び出しは不要 + テスト順依存を 作るリスクがあった。 --- packages/ai-engine/src/duplicate-guards/source-url.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ai-engine/src/duplicate-guards/source-url.test.ts b/packages/ai-engine/src/duplicate-guards/source-url.test.ts index 08022f6..834d85a 100644 --- a/packages/ai-engine/src/duplicate-guards/source-url.test.ts +++ b/packages/ai-engine/src/duplicate-guards/source-url.test.ts @@ -1,7 +1,7 @@ import type { ProjectStore } from '@tally/storage'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; +import type { DuplicateGuardContext } from './index'; import { sourceUrlGuard } from './source-url'; function makeCtx( @@ -20,7 +20,8 @@ function makeCtx( } describe('sourceUrlGuard', () => { - beforeEach(() => __resetGuardsForTest()); + // 注: このテストは sourceUrlGuard を直接呼んでおり、`dispatchDuplicateGuard` + // 経由のグローバル registry は触らない。__resetGuardsForTest は不要 (CR 指摘)。 it('adoptAs は "requirement"', () => { expect(sourceUrlGuard.adoptAs).toBe('requirement'); From 33aa1c6189698a224d11d76d328713dfd5a3c534 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Thu, 30 Apr 2026 07:52:22 +0900 Subject: [PATCH 25/33] =?UTF-8?q?fix(core):=20mcpServers[]=20=E3=81=AE=20i?= =?UTF-8?q?d=20=E9=87=8D=E8=A4=87=E3=82=92=20superRefine=20=E3=81=A7?= =?UTF-8?q?=E6=A4=9C=E5=87=BA=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildMcpServers が Record にマップするため、重複 id を 登録すると後勝ちで silent override されつつ allowedTools には 両方残るため整合性が崩れる。codebases[] と対称に checkUniqueMcpServerIds を ProjectMetaSchema / ProjectSchema / ProjectMetaPatchSchema の superRefine に組み込む。 codex セカンドオピニオン (PR #19) Major 1 指摘対応。 --- packages/core/src/schema.ts | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 2880814..f202292 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -191,6 +191,28 @@ 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 連携) // --------------------------------------------------------------------------- @@ -286,7 +308,10 @@ export const ProjectMetaSchema = z 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 @@ -302,7 +327,10 @@ export const ProjectSchema = z 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 / mcpServers は全置換のみ (部分更新はしない)。 export const ProjectMetaPatchSchema = z @@ -313,7 +341,10 @@ export const ProjectMetaPatchSchema = z 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) From e4da705e6c7bb40f008b52116bac7096ef06c265 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Thu, 30 Apr 2026 07:53:20 +0900 Subject: [PATCH 26/33] =?UTF-8?q?fix(ai-engine):=20=E7=A9=BA=20assistant?= =?UTF-8?q?=20message=20=E3=81=AE=E6=B0=B8=E7=B6=9A=E5=8C=96=E3=82=92=20MC?= =?UTF-8?q?P=20=E6=A7=8B=E7=AF=89=E6=88=90=E5=8A=9F=E5=BE=8C=E3=81=AB?= =?UTF-8?q?=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env 未設定で buildMcpServers が throw した場合、これまでは空 blocks の assistant message が YAML に永続化された後に error event を出して return していた。リロード時には buildChatPrompt が空 message を スキップするため AI への影響はないが、chat 履歴 UI には空のアシスタント バブルが蓄積する問題があった。 assistantMsgId の生成は handler が参照するため先に行い、永続化のみを MCP 構築成功後に遅延させる。 codex セカンドオピニオン (PR #19) Major 2 指摘対応。 --- packages/ai-engine/src/chat-runner.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 0c71a65..8f11c53 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -132,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 経由に分離する。 @@ -172,6 +167,16 @@ export class ChatRunner { 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[] = []; // 5. SDK query をバックグラウンドで走らせ、queue にイベントを push する。 From cbe718dc44a79a021d35698f16490f14f5c24b12 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Thu, 30 Apr 2026 07:53:52 +0900 Subject: [PATCH 27/33] =?UTF-8?q?fix(ai-engine):=20buildChatPrompt=20?= =?UTF-8?q?=E3=81=AE=20tool=5Fresult.output=20=E3=82=92=20XML=20=E3=82=A8?= =?UTF-8?q?=E3=82=B9=E3=82=B1=E3=83=BC=E3=83=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_result の output は外部 MCP の生出力 (Jira 本文・epic 説明等) で や < を含む可能性があり、prompt の XML 構造を 壊して AI が前ターンの context を誤読する恐れがあった。 escapeXmlText で & < > の最低限のみエスケープ。tool_use.input 側は JSON.stringify が < を \\u003c にエスケープするため対象外。 codex セカンドオピニオン (PR #19) Major 3 指摘対応。 --- packages/ai-engine/src/chat-runner.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 8f11c53..707789f 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -617,6 +617,14 @@ export function formatNodeForContext(node: Node): string { return lines.join('\n'); } +// XML 特殊文字をエスケープ。tool_result.output は外部 MCP の生出力 (Jira 本文・ +// epic 説明等) で `` や `<` を含む可能性があり、これが prompt の XML +// 構造を壊して AI が context を誤読するのを防ぐ。`<` `>` `&` の最低限のみ。 +// (tool_use.input 側は JSON.stringify が `<` を `<` にエスケープするため安全) +function escapeXmlText(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + // チャット履歴を単一 prompt にエンコードする。 // 各 block を順に replay する: // - text: そのまま (assistant / user の自然言語) @@ -652,7 +660,9 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = `${JSON.stringify(b.input)}`, ); } else if (b.type === 'tool_result') { - lines.push(`${b.output}`); + lines.push( + `${escapeXmlText(b.output)}`, + ); } } lines.push(''); From dc10dcc9cfcdca56f0639b5642af099260543b76 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Thu, 30 Apr 2026 09:38:42 +0900 Subject: [PATCH 28/33] =?UTF-8?q?fix(ai-engine):=20tool=5Fuse/=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E3=82=82=20XML=20=E3=82=A8=E3=82=B9=E3=82=B1=E3=83=BC?= =?UTF-8?q?=E3=83=97=E3=81=97=E3=80=81=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=AE=E8=AA=A4=E3=82=8A=E3=82=92=E8=A8=82=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前 commit で追加した escapeXmlText は tool_result.output のみを対象として おり「JSON.stringify が < を \\u003c にエスケープするので tool_use 側は 安全」とコメントしていたが、これは事実誤認だった。JSON.stringify が エスケープするのは " \\ と control chars のみで、< > & は素通しする。 このため input オブジェクトに や < が含まれると prompt の XML 構造が壊れて AI が前ターンの context を誤読する恐れがあった。 - escapeXmlAttr (& " < > エスケープ) を追加 - tool_use の input は escapeXmlText(JSON.stringify(...)) でエスケープ - 属性値 (toolUseId / name / role) は escapeXmlAttr でラップ - コメントの誤りを訂正 CodeRabbit (PR #19) Major 1 指摘対応。 --- packages/ai-engine/src/chat-runner.ts | 29 ++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 707789f..7c5745f 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -617,14 +617,27 @@ export function formatNodeForContext(node: Node): string { return lines.join('\n'); } -// XML 特殊文字をエスケープ。tool_result.output は外部 MCP の生出力 (Jira 本文・ -// epic 説明等) で `` や `<` を含む可能性があり、これが prompt の XML -// 構造を壊して AI が context を誤読するのを防ぐ。`<` `>` `&` の最低限のみ。 -// (tool_use.input 側は JSON.stringify が `<` を `<` にエスケープするため安全) +// 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 にエンコードする。 // 各 block を順に replay する: // - text: そのまま (assistant / user の自然言語) @@ -648,7 +661,7 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = for (const m of past) { // block が 1 つも無い空 message は省く (空 assistant の preliminary append 等) if (m.blocks.length === 0) continue; - lines.push(``); + lines.push(``); for (const b of m.blocks) { if (b.type === 'text') { lines.push(b.text); @@ -656,12 +669,14 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = // source は default 'internal'。external も含めて全部 replay する // (AI に「外部 source を読んだ」事実を context として伝えるため) const sourceAttr = b.source === 'external' ? ' source="external"' : ''; + // input は JSON.stringify 後に XML エスケープ。`<` `>` `&` は JSON 文字列内では + // 生のまま残るので、XML タグへの埋め込みでは構造を壊しうる (codex 指摘の前提誤り)。 lines.push( - `${JSON.stringify(b.input)}`, + `${escapeXmlText(JSON.stringify(b.input))}`, ); } else if (b.type === 'tool_result') { lines.push( - `${escapeXmlText(b.output)}`, + `${escapeXmlText(b.output)}`, ); } } From 40dfc009a6860ef678a3ae74cd54d1deb8f05b6d Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Thu, 30 Apr 2026 09:38:49 +0900 Subject: [PATCH 29/33] =?UTF-8?q?fix(core):=20MCP=20server=20URL=20?= =?UTF-8?q?=E3=81=AE=20userinfo=20(user:pass@)=20=E3=82=92=E6=8B=92?= =?UTF-8?q?=E5=90=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit これまでの url validator は \`https://user:pass@host/mcp\` を許容していた。 URL 内資格情報はログ・プロキシ・ブラウザ履歴に漏洩しやすく、本実装が Authorization header 経由で PAT を送る設計とも不整合。 parsed.username / parsed.password が非空なら reject する。 CodeRabbit (PR #19) Major 2 指摘対応 (Quick win)。 --- packages/core/src/schema.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index f202292..7526184 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -264,6 +264,8 @@ export const McpServerConfigSchema = z.object({ 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() @@ -271,6 +273,7 @@ export const McpServerConfigSchema = z.object({ (u) => { try { const parsed = new URL(u); + if (parsed.username || parsed.password) return false; if (parsed.protocol === 'https:') return true; if ( parsed.protocol === 'http:' && @@ -286,7 +289,10 @@ export const McpServerConfigSchema = z.object({ return false; } }, - { message: 'url は https で始まる必要があります (loopback の http は例外的に許容)' }, + { + message: + 'url は https で始まる必要があります (loopback の http は例外的に許容)。URL 内資格情報 (user:pass@) は禁止', + }, ), auth: McpAuthSchema, options: McpServerOptionsSchema, From 8ae27e9cb56985900742a5b4328d44b142160d1f Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Thu, 30 Apr 2026 09:38:55 +0900 Subject: [PATCH 30/33] =?UTF-8?q?fix(frontend):=20MCP=20=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=90=E3=83=BC=E4=B8=80=E8=A6=A7=E3=81=AE=20React=20key=20?= =?UTF-8?q?=E3=82=92=E4=B8=8D=E5=A4=89=20=5Fuid=20=E3=81=AB=E5=88=86?= =?UTF-8?q?=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React key として s.id を使っていたが、id はフォームから編集可能で あり、編集中に key が変わると React が要素を再マウントしてしまい 入力フォーカスや state がリセットされるリスクがあった。重複入力時 には reconciliation の混乱で間違った DOM ノードが再利用される 可能性もある。 UI ローカルの不変 ID として _uid をマウント時に 1 度だけ割り当て、 React key には _uid を使う。永続化前 (onSave) に _uid を剥がす。 CodeRabbit (PR #19) Major 3 指摘対応。 --- .../dialog/project-settings-dialog.tsx | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.tsx index 27ea21a..a932bed 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -20,12 +20,26 @@ function makeDefaultMcpServer(seq: number): McpServerConfig { }; } +// 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 [mcpServers, setMcpServers] = useState([]); const [pickerOpen, setPickerOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -33,7 +47,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos useEffect(() => { if (open && projectMeta) { setCodebases(projectMeta.codebases); - setMcpServers(projectMeta.mcpServers ?? []); + setMcpServers((projectMeta.mcpServers ?? []).map((s) => ({ ...s, _uid: makeUid() }))); } }, [open, projectMeta]); @@ -75,7 +89,9 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos setBusy(true); setError(null); try { - await patchProjectMeta({ codebases, mcpServers }); + // _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)); @@ -84,18 +100,20 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos }; const addMcpServer = () => { - // 削除→追加で `mcpServers.length + 1` を使うと既存 ID と衝突する - // (例: atlassian-1, atlassian-2 で 1 件削除して追加すると再び atlassian-2)。 - // mcpServers の id は下流で key として使われるため、未使用 suffix を探す。 + // 採番 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)]); + setMcpServers([...mcpServers, { ...makeDefaultMcpServer(seq), _uid: makeUid() }]); }; const updateMcpServer = (index: number, next: McpServerConfig) => { const list = [...mcpServers]; - list[index] = next; + const prev = list[index]; + if (!prev) return; + // 既存エントリの _uid は保持 (フォーム編集で React key が変わるのを防ぐ)。 + list[index] = { ...next, _uid: prev._uid }; setMcpServers(list); }; @@ -182,8 +200,10 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos {mcpServers.length === 0 &&
    MCP サーバー未設定
    }
      {mcpServers.map((s, i) => ( - // addMcpServer で未使用 suffix を採番するため id は一意。React key として安全。 -
    • + // _uid は UI ローカルの不変 ID。s.id はフォームで編集可能なため key に使うと + // 編集中の再マウント (focus / state リセット) や重複入力時の reconciliation + // 衝突を起こすため避ける (CR 指摘 #19)。 +
    • Date: Thu, 30 Apr 2026 09:39:05 +0900 Subject: [PATCH 31/33] =?UTF-8?q?test(frontend):=20CR=20=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E7=B2=BE=E5=BA=A6=E5=90=91?= =?UTF-8?q?=E4=B8=8A=20(mcp=20=E7=BD=AE=E6=8F=9B=20/=20details=20=E4=B8=AD?= =?UTF-8?q?=E8=BA=AB=20/=20secret=20UI=20=E5=89=8D=E6=8F=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (PR #19) Minor 3 件 (Quick win) 対応: - route.test.ts: mcpServers 全置換テストに事前データを投入し、 「マージ追記」では通らないようにする。 - tool-approval-card.test.tsx:
      の存在だけでなく、その内側に input preview の
       が含まれていることまで検証する。
      - project-settings-dialog.test.tsx: secret UI 検証を実 MCP 行に対する
        ものに変更 (空 list だと退行検知が効かないため、+ MCP サーバーを追加
        をクリックしてから tokenEnvVar 存在 + secret 入力欄非存在を確認)。
      ---
       .../src/app/api/projects/[id]/route.test.ts   | 23 +++++++++++++++++++
       .../chat/tool-approval-card.test.tsx          |  5 +++-
       .../dialog/project-settings-dialog.test.tsx   |  6 ++++-
       3 files changed, 32 insertions(+), 2 deletions(-)
      
      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 83368c9..3362e40 100644
      --- a/packages/frontend/src/app/api/projects/[id]/route.test.ts
      +++ b/packages/frontend/src/app/api/projects/[id]/route.test.ts
      @@ -71,6 +71,27 @@ describe('PATCH /api/projects/:id', () => {
         });
       
         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',
      @@ -93,6 +114,8 @@ describe('PATCH /api/projects/:id', () => {
           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 () => {
      diff --git a/packages/frontend/src/components/chat/tool-approval-card.test.tsx b/packages/frontend/src/components/chat/tool-approval-card.test.tsx
      index 5f13f1e..a529d2f 100644
      --- a/packages/frontend/src/components/chat/tool-approval-card.test.tsx
      +++ b/packages/frontend/src/components/chat/tool-approval-card.test.tsx
      @@ -98,8 +98,11 @@ describe('ToolApprovalCard', () => {
               }}
             />,
           );
      -    // 
      要素が存在 + //
      要素が存在し、その内側に 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/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx
      index e0950ae..fbac328 100644
      --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx
      +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx
      @@ -178,8 +178,12 @@ describe('ProjectSettingsDialog', () => {
           expect(ids).toContain('atlassian-2');
         });
       
      -  it('secret 値の入力欄は無い (envVar 名のみ。caption に .env への誘導)', () => {
      +  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();
      
      From 13da60cfcd5869171682b0d775cc4adb757adac0 Mon Sep 17 00:00:00 2001
      From: Shoma Nishitateno 
      Date: Thu, 30 Apr 2026 09:49:56 +0900
      Subject: [PATCH 32/33] =?UTF-8?q?fix(ai-engine):=20tool=5Fresult=20?=
       =?UTF-8?q?=E3=81=AE=20external=20=E8=AA=A4=E5=88=86=E9=A1=9E=E9=98=B2?=
       =?UTF-8?q?=E6=AD=A2=20+=20text=20=E6=9C=AC=E6=96=87=E3=82=82=20XML=20?=
       =?UTF-8?q?=E3=82=A8=E3=82=B9=E3=82=B1=E3=83=BC=E3=83=97?=
      MIME-Version: 1.0
      Content-Type: text/plain; charset=UTF-8
      Content-Transfer-Encoding: 8bit
      
      CodeRabbit (PR #19) 2 周目 Major 2 件対応:
      
      [Major 1] tool_result を無条件に external として永続化していたため、SDK 仕様
      変更や edge case で内部 (mcp__tally__*) の tool_result が SDK ストリームに
      流れた場合に誤分類されるリスクがあった。同 turn 内で観測した外部 tool_use の
      toolUseId を Set に保持し、対応する tool_result のみ external として扱う。
      集合に無い toolUseId (内部 / intercept 経路 / 想定外) は無視する。
      
      [Major 2] buildChatPrompt で  内の text 本文と 
      の本文が未エスケープだった。前 commit で tool_use/tool_result はエスケープ
      したが、自由入力の text block 自体は / や < & が混入し
      うる。escapeXmlText でラップ。
      
      test: 既存の Task 13 テスト 2 件 (4KB 超 truncate / 4KB 以下 not truncate)
      は tool_result のみ yield していたが、Major 1 ガード後は tool_use 先行が
      必須なので mock を整備 (tool_use → tool_result の順)。
      ---
       packages/ai-engine/src/chat-runner.test.ts | 28 ++++++++++++++++++++++
       packages/ai-engine/src/chat-runner.ts      | 15 ++++++++++--
       2 files changed, 41 insertions(+), 2 deletions(-)
      
      diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts
      index 6dcd91a..b46d867 100644
      --- a/packages/ai-engine/src/chat-runner.test.ts
      +++ b/packages/ai-engine/src/chat-runner.test.ts
      @@ -869,6 +869,20 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', (
           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: {
      @@ -942,6 +956,20 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', (
           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: {
      diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts
      index 7c5745f..44516e4 100644
      --- a/packages/ai-engine/src/chat-runner.ts
      +++ b/packages/ai-engine/src/chat-runner.ts
      @@ -178,6 +178,11 @@ export class ChatRunner {
           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 するだけ。
      @@ -211,6 +216,7 @@ export class ChatRunner {
                     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,
      @@ -226,6 +232,9 @@ export class ChatRunner {
                       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, {
      @@ -664,7 +673,9 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] =
             lines.push(``);
             for (const b of m.blocks) {
               if (b.type === 'text') {
      -          lines.push(b.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 として伝えるため)
      @@ -698,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('');
      
      From 43e0d72703046aa12dde83e6c12d4629c44056bd Mon Sep 17 00:00:00 2001
      From: Shoma Nishitateno 
      Date: Thu, 30 Apr 2026 09:50:00 +0900
      Subject: [PATCH 33/33] =?UTF-8?q?test(frontend):=20=E3=80=8CmcpServers=20?=
       =?UTF-8?q?=E5=85=A8=E6=B6=88=E5=8E=BB=E3=80=8D=E3=83=86=E3=82=B9=E3=83=88?=
       =?UTF-8?q?=E3=81=AE=20seed=20PATCH=20=E3=81=AB=20status=20assert?=
      MIME-Version: 1.0
      Content-Type: text/plain; charset=UTF-8
      Content-Transfer-Encoding: 8bit
      
      CodeRabbit (PR #19) 2 周目 Minor 1 件対応 (Quick win)。
      事前登録ステップが silent fail すると後段の「空配列で消す」前提が崩れて
      偽の成功で通る恐れがあったため、seed PATCH の status を 200 で assert する。
      ---
       packages/frontend/src/app/api/projects/[id]/route.test.ts | 5 +++--
       1 file changed, 3 insertions(+), 2 deletions(-)
      
      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 3362e40..f3e70bb 100644
      --- a/packages/frontend/src/app/api/projects/[id]/route.test.ts
      +++ b/packages/frontend/src/app/api/projects/[id]/route.test.ts
      @@ -141,8 +141,8 @@ describe('PATCH /api/projects/:id', () => {
         });
       
         it('mcpServers を空配列で全消去できる', async () => {
      -    // 事前に登録
      -    await PATCH(
      +    // 事前に登録 (seed PATCH の成功も assert: 前提崩れを取り逃さないため)
      +    const seedRes = await PATCH(
             new Request('http://localhost', {
               method: 'PATCH',
               body: JSON.stringify({
      @@ -160,6 +160,7 @@ describe('PATCH /api/projects/:id', () => {
             }),
             { params: Promise.resolve({ id: projectId }) },
           );
      +    expect(seedRes.status).toBe(200);
           // 空配列で全消去
           const res = await PATCH(
             new Request('http://localhost', {