From ffc151cd8de962ed50c97852c35540f1ec0e989b Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Sun, 8 Feb 2026 08:11:05 +0900 Subject: [PATCH] takt: github-issue-125-claude-agent (#133) --- src/__tests__/it-piece-loader.test.ts | 125 +++++++++++ src/__tests__/models.test.ts | 205 ++++++++++++++++++ src/__tests__/permission-mode.test.ts | 53 ++++- src/agents/runner.ts | 1 + src/agents/types.ts | 4 +- src/core/models/index.ts | 1 + src/core/models/mcp-schemas.ts | 40 ++++ src/core/models/piece-types.ts | 27 +++ src/core/models/schemas.ts | 5 + src/core/models/types.ts | 1 + src/core/piece/engine/OptionsBuilder.ts | 1 + src/infra/claude/client.ts | 45 +--- src/infra/claude/options-builder.ts | 1 + src/infra/claude/types.ts | 6 +- src/infra/claude/utils.ts | 47 ++++ src/infra/config/loaders/pieceParser.ts | 112 +--------- src/infra/config/loaders/resource-resolver.ts | 109 ++++++++++ src/infra/providers/claude.ts | 1 + src/infra/providers/types.ts | 4 +- 19 files changed, 641 insertions(+), 147 deletions(-) create mode 100644 src/core/models/mcp-schemas.ts create mode 100644 src/infra/claude/utils.ts create mode 100644 src/infra/config/loaders/resource-resolver.ts diff --git a/src/__tests__/it-piece-loader.test.ts b/src/__tests__/it-piece-loader.test.ts index 1ff037e..53b7efa 100644 --- a/src/__tests__/it-piece-loader.test.ts +++ b/src/__tests__/it-piece-loader.test.ts @@ -447,6 +447,131 @@ movements: }); }); +describe('Piece Loader IT: mcp_servers parsing', () => { + let testDir: string; + + beforeEach(() => { + testDir = createTestDir(); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should parse mcp_servers from YAML to PieceMovement.mcpServers', () => { + const piecesDir = join(testDir, '.takt', 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + + writeFileSync(join(piecesDir, 'with-mcp.yaml'), ` +name: with-mcp +description: Piece with MCP servers +max_iterations: 5 +initial_movement: e2e-test + +movements: + - name: e2e-test + persona: coder + mcp_servers: + playwright: + command: npx + args: ["-y", "@anthropic-ai/mcp-server-playwright"] + allowed_tools: + - Read + - Bash + - mcp__playwright__* + rules: + - condition: Done + next: COMPLETE + instruction: "Run E2E tests" +`); + + const config = loadPiece('with-mcp', testDir); + + expect(config).not.toBeNull(); + const e2eStep = config!.movements.find((s) => s.name === 'e2e-test'); + expect(e2eStep).toBeDefined(); + expect(e2eStep!.mcpServers).toEqual({ + playwright: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-server-playwright'], + }, + }); + }); + + it('should allow movement without mcp_servers', () => { + const piecesDir = join(testDir, '.takt', 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + + writeFileSync(join(piecesDir, 'no-mcp.yaml'), ` +name: no-mcp +description: Piece without MCP servers +max_iterations: 5 +initial_movement: implement + +movements: + - name: implement + persona: coder + rules: + - condition: Done + next: COMPLETE + instruction: "Implement the feature" +`); + + const config = loadPiece('no-mcp', testDir); + + expect(config).not.toBeNull(); + const implementStep = config!.movements.find((s) => s.name === 'implement'); + expect(implementStep).toBeDefined(); + expect(implementStep!.mcpServers).toBeUndefined(); + }); + + it('should parse mcp_servers with multiple servers and transports', () => { + const piecesDir = join(testDir, '.takt', 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + + writeFileSync(join(piecesDir, 'multi-mcp.yaml'), ` +name: multi-mcp +description: Piece with multiple MCP servers +max_iterations: 5 +initial_movement: test + +movements: + - name: test + persona: coder + mcp_servers: + playwright: + command: npx + args: ["-y", "@anthropic-ai/mcp-server-playwright"] + remote-api: + type: http + url: http://localhost:3000/mcp + headers: + Authorization: "Bearer token123" + rules: + - condition: Done + next: COMPLETE + instruction: "Run tests" +`); + + const config = loadPiece('multi-mcp', testDir); + + expect(config).not.toBeNull(); + const testStep = config!.movements.find((s) => s.name === 'test'); + expect(testStep).toBeDefined(); + expect(testStep!.mcpServers).toEqual({ + playwright: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-server-playwright'], + }, + 'remote-api': { + type: 'http', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer token123' }, + }, + }); + }); +}); + describe('Piece Loader IT: invalid YAML handling', () => { let testDir: string; diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index 52fbc8b..7b9e179 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -8,6 +8,7 @@ import { StatusSchema, PermissionModeSchema, PieceConfigRawSchema, + McpServerConfigSchema, CustomAgentConfigSchema, GlobalConfigSchema, } from '../core/models/index.js'; @@ -143,6 +144,210 @@ describe('PieceConfigRawSchema', () => { expect(() => PieceConfigRawSchema.parse(config)).toThrow(); }); + + it('should parse movement with stdio mcp_servers', () => { + const config = { + name: 'test-piece', + movements: [ + { + name: 'e2e-test', + persona: 'coder', + mcp_servers: { + playwright: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-server-playwright'], + }, + }, + allowed_tools: ['mcp__playwright__*'], + instruction: '{task}', + }, + ], + }; + + const result = PieceConfigRawSchema.parse(config); + expect(result.movements![0]?.mcp_servers).toEqual({ + playwright: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-server-playwright'], + }, + }); + }); + + it('should parse movement with sse mcp_servers', () => { + const config = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + mcp_servers: { + remote: { + type: 'sse', + url: 'http://localhost:8080/sse', + headers: { Authorization: 'Bearer token' }, + }, + }, + instruction: '{task}', + }, + ], + }; + + const result = PieceConfigRawSchema.parse(config); + expect(result.movements![0]?.mcp_servers).toEqual({ + remote: { + type: 'sse', + url: 'http://localhost:8080/sse', + headers: { Authorization: 'Bearer token' }, + }, + }); + }); + + it('should parse movement with http mcp_servers', () => { + const config = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + mcp_servers: { + api: { + type: 'http', + url: 'http://localhost:3000/mcp', + }, + }, + instruction: '{task}', + }, + ], + }; + + const result = PieceConfigRawSchema.parse(config); + expect(result.movements![0]?.mcp_servers).toEqual({ + api: { + type: 'http', + url: 'http://localhost:3000/mcp', + }, + }); + }); + + it('should allow omitting mcp_servers', () => { + const config = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + instruction: '{task}', + }, + ], + }; + + const result = PieceConfigRawSchema.parse(config); + expect(result.movements![0]?.mcp_servers).toBeUndefined(); + }); + + it('should reject invalid mcp_servers (missing command for stdio)', () => { + const config = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + mcp_servers: { + broken: { args: ['--flag'] }, + }, + instruction: '{task}', + }, + ], + }; + + expect(() => PieceConfigRawSchema.parse(config)).toThrow(); + }); + + it('should reject invalid mcp_servers (missing url for sse)', () => { + const config = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + mcp_servers: { + broken: { type: 'sse' }, + }, + instruction: '{task}', + }, + ], + }; + + expect(() => PieceConfigRawSchema.parse(config)).toThrow(); + }); +}); + +describe('McpServerConfigSchema', () => { + it('should parse stdio config', () => { + const config = { command: 'npx', args: ['-y', 'some-server'], env: { NODE_ENV: 'test' } }; + const result = McpServerConfigSchema.parse(config); + expect(result).toEqual(config); + }); + + it('should parse stdio config with command only', () => { + const config = { command: 'mcp-server' }; + const result = McpServerConfigSchema.parse(config); + expect(result).toEqual(config); + }); + + it('should parse stdio config with explicit type', () => { + const config = { type: 'stdio' as const, command: 'npx', args: ['-y', 'some-server'] }; + const result = McpServerConfigSchema.parse(config); + expect(result).toEqual(config); + }); + + it('should parse sse config', () => { + const config = { type: 'sse' as const, url: 'http://localhost:8080/sse' }; + const result = McpServerConfigSchema.parse(config); + expect(result).toEqual(config); + }); + + it('should parse sse config with headers', () => { + const config = { type: 'sse' as const, url: 'http://example.com', headers: { 'X-Key': 'val' } }; + const result = McpServerConfigSchema.parse(config); + expect(result).toEqual(config); + }); + + it('should parse http config', () => { + const config = { type: 'http' as const, url: 'http://localhost:3000/mcp' }; + const result = McpServerConfigSchema.parse(config); + expect(result).toEqual(config); + }); + + it('should parse http config with headers', () => { + const config = { type: 'http' as const, url: 'http://example.com', headers: { Authorization: 'Bearer x' } }; + const result = McpServerConfigSchema.parse(config); + expect(result).toEqual(config); + }); + + it('should reject empty command for stdio', () => { + expect(() => McpServerConfigSchema.parse({ command: '' })).toThrow(); + }); + + it('should reject missing url for sse', () => { + expect(() => McpServerConfigSchema.parse({ type: 'sse' })).toThrow(); + }); + + it('should reject missing url for http', () => { + expect(() => McpServerConfigSchema.parse({ type: 'http' })).toThrow(); + }); + + it('should reject empty url for sse', () => { + expect(() => McpServerConfigSchema.parse({ type: 'sse', url: '' })).toThrow(); + }); + + it('should reject unknown type', () => { + expect(() => McpServerConfigSchema.parse({ type: 'websocket', url: 'ws://localhost' })).toThrow(); + }); + + it('should reject empty object', () => { + expect(() => McpServerConfigSchema.parse({})).toThrow(); + }); }); describe('CustomAgentConfigSchema', () => { diff --git a/src/__tests__/permission-mode.test.ts b/src/__tests__/permission-mode.test.ts index 44440df..6599232 100644 --- a/src/__tests__/permission-mode.test.ts +++ b/src/__tests__/permission-mode.test.ts @@ -3,9 +3,10 @@ */ import { describe, it, expect } from 'vitest'; -import { SdkOptionsBuilder } from '../infra/claude/options-builder.js'; +import { SdkOptionsBuilder, buildSdkOptions } from '../infra/claude/options-builder.js'; import { mapToCodexSandboxMode } from '../infra/codex/types.js'; import type { PermissionMode } from '../core/models/index.js'; +import type { ClaudeSpawnOptions } from '../infra/claude/types.js'; describe('SdkOptionsBuilder.mapToSdkPermissionMode', () => { it('should map readonly to SDK default', () => { @@ -52,3 +53,53 @@ describe('mapToCodexSandboxMode', () => { } }); }); + +describe('SdkOptionsBuilder.build() — mcpServers', () => { + it('should include mcpServers in SDK options when provided', () => { + const spawnOptions: ClaudeSpawnOptions = { + cwd: '/tmp/test', + mcpServers: { + playwright: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-server-playwright'], + }, + }, + }; + + const sdkOptions = buildSdkOptions(spawnOptions); + expect(sdkOptions.mcpServers).toEqual({ + playwright: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-server-playwright'], + }, + }); + }); + + it('should not include mcpServers in SDK options when not provided', () => { + const spawnOptions: ClaudeSpawnOptions = { + cwd: '/tmp/test', + }; + + const sdkOptions = buildSdkOptions(spawnOptions); + expect(sdkOptions).not.toHaveProperty('mcpServers'); + }); + + it('should include mcpServers alongside other options', () => { + const spawnOptions: ClaudeSpawnOptions = { + cwd: '/tmp/test', + allowedTools: ['Read', 'mcp__playwright__*'], + mcpServers: { + playwright: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-server-playwright'], + }, + }, + permissionMode: 'edit', + }; + + const sdkOptions = buildSdkOptions(spawnOptions); + expect(sdkOptions.mcpServers).toBeDefined(); + expect(sdkOptions.allowedTools).toEqual(['Read', 'mcp__playwright__*']); + expect(sdkOptions.permissionMode).toBe('acceptEdits'); + }); +}); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 277f6f2..92cf323 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -102,6 +102,7 @@ export class AgentRunner { cwd: options.cwd, sessionId: options.sessionId, allowedTools: options.allowedTools ?? agentConfig?.allowedTools, + mcpServers: options.mcpServers, maxTurns: options.maxTurns, model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig), permissionMode: options.permissionMode, diff --git a/src/agents/types.ts b/src/agents/types.ts index 3d134e2..e739c84 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -3,7 +3,7 @@ */ import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/index.js'; -import type { PermissionMode, Language } from '../core/models/index.js'; +import type { PermissionMode, Language, McpServerConfig } from '../core/models/index.js'; export type { StreamCallback }; @@ -17,6 +17,8 @@ export interface RunAgentOptions { personaPath?: string; /** Allowed tools for this agent run */ allowedTools?: string[]; + /** MCP servers for this agent run */ + mcpServers?: Record; /** Maximum number of agentic turns */ maxTurns?: number; /** Permission mode for tool execution (from piece step) */ diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 083bc58..17abed1 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -7,6 +7,7 @@ export type { OutputContractLabelPath, OutputContractItem, OutputContractEntry, + McpServerConfig, AgentResponse, SessionState, PieceRule, diff --git a/src/core/models/mcp-schemas.ts b/src/core/models/mcp-schemas.ts new file mode 100644 index 0000000..c97d61c --- /dev/null +++ b/src/core/models/mcp-schemas.ts @@ -0,0 +1,40 @@ +/** + * Zod schemas for MCP (Model Context Protocol) server configuration. + * + * Supports three transports: stdio, SSE, and HTTP. + * Note: Uses zod v4 syntax for SDK compatibility. + */ + +import { z } from 'zod/v4'; + +/** MCP server configuration for stdio transport */ +const McpStdioServerSchema = z.object({ + type: z.literal('stdio').optional(), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), +}); + +/** MCP server configuration for SSE transport */ +const McpSseServerSchema = z.object({ + type: z.literal('sse'), + url: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), +}); + +/** MCP server configuration for HTTP transport */ +const McpHttpServerSchema = z.object({ + type: z.literal('http'), + url: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), +}); + +/** MCP server configuration (union of all YAML-configurable transports) */ +export const McpServerConfigSchema = z.union([ + McpStdioServerSchema, + McpSseServerSchema, + McpHttpServerSchema, +]); + +/** MCP servers map: server name → config */ +export const McpServersSchema = z.record(z.string(), McpServerConfigSchema).optional(); diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 3e95eff..9fb954e 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -53,6 +53,31 @@ export interface OutputContractItem { /** Union type for output contract entries */ export type OutputContractEntry = OutputContractLabelPath | OutputContractItem; +/** MCP server configuration for stdio transport */ +export interface McpStdioServerConfig { + type?: 'stdio'; + command: string; + args?: string[]; + env?: Record; +} + +/** MCP server configuration for SSE transport */ +export interface McpSseServerConfig { + type: 'sse'; + url: string; + headers?: Record; +} + +/** MCP server configuration for HTTP transport */ +export interface McpHttpServerConfig { + type: 'http'; + url: string; + headers?: Record; +} + +/** MCP server configuration (union of all YAML-configurable transports) */ +export type McpServerConfig = McpStdioServerConfig | McpSseServerConfig | McpHttpServerConfig; + /** Single movement in a piece */ export interface PieceMovement { name: string; @@ -66,6 +91,8 @@ export interface PieceMovement { personaDisplayName: string; /** Allowed tools for this movement (optional, passed to agent execution) */ allowedTools?: string[]; + /** MCP servers configuration for this movement */ + mcpServers?: Record; /** Resolved absolute path to persona prompt file (set by loader) */ personaPath?: string; /** Provider override for this movement */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 006a512..6669d69 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -6,6 +6,9 @@ import { z } from 'zod/v4'; import { DEFAULT_LANGUAGE } from '../../shared/constants.js'; +import { McpServersSchema } from './mcp-schemas.js'; + +export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js'; /** Agent model schema (opus, sonnet, haiku) */ export const AgentModelSchema = z.enum(['opus', 'sonnet', 'haiku']).default('sonnet'); @@ -137,6 +140,7 @@ export const ParallelSubMovementRawSchema = z.object({ /** Knowledge reference(s) — key name(s) from piece-level knowledge map */ knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), + mcp_servers: McpServersSchema, provider: z.enum(['claude', 'codex', 'mock']).optional(), model: z.string().optional(), permission_mode: PermissionModeSchema.optional(), @@ -166,6 +170,7 @@ export const PieceMovementRawSchema = z.object({ /** Knowledge reference(s) — key name(s) from piece-level knowledge map */ knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), + mcp_servers: McpServersSchema, provider: z.enum(['claude', 'codex', 'mock']).optional(), model: z.string().optional(), /** Permission mode for tool execution in this movement */ diff --git a/src/core/models/types.ts b/src/core/models/types.ts index f07c911..b13a24a 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -29,6 +29,7 @@ export type { OutputContractLabelPath, OutputContractItem, OutputContractEntry, + McpServerConfig, PieceMovement, LoopDetectionConfig, LoopMonitorConfig, diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index a6dab74..8faee3b 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -68,6 +68,7 @@ export class OptionsBuilder { ...this.buildBaseOptions(step), sessionId: shouldResumeSession ? this.getSessionId(step.persona ?? step.name) : undefined, allowedTools, + mcpServers: step.mcpServers, }; } diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index 6766644..9ef9729 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -11,51 +11,10 @@ import { createLogger } from '../../shared/utils/index.js'; import { loadTemplate } from '../../shared/prompts/index.js'; export type { ClaudeCallOptions } from './types.js'; +export { detectRuleIndex, isRegexSafe } from './utils.js'; const log = createLogger('client'); -/** - * Detect rule index from numbered tag pattern [STEP_NAME:N]. - * Returns 0-based rule index, or -1 if no match. - * - * Example: detectRuleIndex("... [PLAN:2] ...", "plan") → 1 - */ -export function detectRuleIndex(content: string, movementName: string): number { - const tag = movementName.toUpperCase(); - const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi'); - const matches = [...content.matchAll(regex)]; - const match = matches.at(-1); - if (match?.[1]) { - const index = Number.parseInt(match[1], 10) - 1; - return index >= 0 ? index : -1; - } - return -1; -} - -/** Validate regex pattern for ReDoS safety */ -export function isRegexSafe(pattern: string): boolean { - if (pattern.length > 200) { - return false; - } - - const dangerousPatterns = [ - /\(\.\*\)\+/, // (.*)+ - /\(\.\+\)\*/, // (.+)* - /\(\.\*\)\*/, // (.*)* - /\(\.\+\)\+/, // (.+)+ - /\([^)]*\|[^)]*\)\+/, // (a|b)+ - /\([^)]*\|[^)]*\)\*/, // (a|b)* - ]; - - for (const dangerous of dangerousPatterns) { - if (dangerous.test(pattern)) { - return false; - } - } - - return true; -} - /** * High-level Claude client for calling Claude with various configurations. * @@ -81,6 +40,7 @@ export class ClaudeClient { cwd: options.cwd, sessionId: options.sessionId, allowedTools: options.allowedTools, + mcpServers: options.mcpServers, model: options.model, maxTurns: options.maxTurns, systemPrompt: options.systemPrompt, @@ -167,6 +127,7 @@ export class ClaudeClient { cwd: options.cwd, sessionId: options.sessionId, allowedTools: options.allowedTools, + mcpServers: options.mcpServers, model: options.model, maxTurns: options.maxTurns, permissionMode: options.permissionMode, diff --git a/src/infra/claude/options-builder.ts b/src/infra/claude/options-builder.ts index 8b540af..32bbdf8 100644 --- a/src/infra/claude/options-builder.ts +++ b/src/infra/claude/options-builder.ts @@ -63,6 +63,7 @@ export class SdkOptionsBuilder { if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns; if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools; if (this.options.agents) sdkOptions.agents = this.options.agents; + if (this.options.mcpServers) sdkOptions.mcpServers = this.options.mcpServers; if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt; if (canUseTool) sdkOptions.canUseTool = canUseTool; if (hooks) sdkOptions.hooks = hooks; diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index 00eb588..782e9f2 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -6,7 +6,7 @@ */ import type { PermissionUpdate, AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; -import type { PermissionMode } from '../../core/models/index.js'; +import type { PermissionMode, McpServerConfig } from '../../core/models/index.js'; import type { PermissionResult } from '../../core/piece/index.js'; // Re-export PermissionResult for convenience @@ -121,6 +121,8 @@ export interface ClaudeCallOptions { cwd: string; sessionId?: string; allowedTools?: string[]; + /** MCP servers configuration */ + mcpServers?: Record; model?: string; maxTurns?: number; systemPrompt?: string; @@ -145,6 +147,8 @@ export interface ClaudeSpawnOptions { cwd: string; sessionId?: string; allowedTools?: string[]; + /** MCP servers configuration */ + mcpServers?: Record; model?: string; maxTurns?: number; systemPrompt?: string; diff --git a/src/infra/claude/utils.ts b/src/infra/claude/utils.ts new file mode 100644 index 0000000..7255a24 --- /dev/null +++ b/src/infra/claude/utils.ts @@ -0,0 +1,47 @@ +/** + * Utility functions for Claude client operations. + * + * Stateless helpers for rule detection and regex safety validation. + */ + +/** + * Detect rule index from numbered tag pattern [STEP_NAME:N]. + * Returns 0-based rule index, or -1 if no match. + * + * Example: detectRuleIndex("... [PLAN:2] ...", "plan") → 1 + */ +export function detectRuleIndex(content: string, movementName: string): number { + const tag = movementName.toUpperCase(); + const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi'); + const matches = [...content.matchAll(regex)]; + const match = matches.at(-1); + if (match?.[1]) { + const index = Number.parseInt(match[1], 10) - 1; + return index >= 0 ? index : -1; + } + return -1; +} + +/** Validate regex pattern for ReDoS safety */ +export function isRegexSafe(pattern: string): boolean { + if (pattern.length > 200) { + return false; + } + + const dangerousPatterns = [ + /\(\.\*\)\+/, // (.*)+ + /\(\.\+\)\*/, // (.+)* + /\(\.\*\)\*/, // (.*)* + /\(\.\+\)\+/, // (.+)+ + /\([^)]*\|[^)]*\)\+/, // (a|b)+ + /\([^)]*\|[^)]*\)\*/, // (a|b)* + ]; + + for (const dangerous of dangerousPatterns) { + if (dangerous.test(pattern)) { + return false; + } + } + + return true; +} diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 02da69f..a1e124d 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -6,114 +6,23 @@ */ import { readFileSync, existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join, dirname, basename } from 'node:path'; +import { dirname } from 'node:path'; import { parse as parseYaml } from 'yaml'; import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js'; +import { + type PieceSections, + resolveResourceContent, + resolveRefToContent, + resolveRefList, + resolveSectionMap, + extractPersonaDisplayName, + resolvePersona, +} from './resource-resolver.js'; type RawStep = z.output; -/** Resolve a resource spec to an absolute file path. */ -function resolveResourcePath(spec: string, pieceDir: string): string { - if (spec.startsWith('./')) return join(pieceDir, spec.slice(2)); - if (spec.startsWith('~')) return join(homedir(), spec.slice(1)); - if (spec.startsWith('/')) return spec; - return join(pieceDir, spec); -} - -/** - * Resolve a resource spec to its file content. - * If the spec ends with .md and the file exists, returns file content. - * Otherwise returns the spec as-is (treated as inline content). - */ -function resolveResourceContent(spec: string | undefined, pieceDir: string): string | undefined { - if (spec == null) return undefined; - if (spec.endsWith('.md')) { - const resolved = resolveResourcePath(spec, pieceDir); - if (existsSync(resolved)) return readFileSync(resolved, 'utf-8'); - } - return spec; -} - -/** - * Resolve a section reference to content. - * Looks up ref in resolvedMap first, then falls back to resolveResourceContent. - */ -function resolveRefToContent( - ref: string, - resolvedMap: Record | undefined, - pieceDir: string, -): string | undefined { - const mapped = resolvedMap?.[ref]; - if (mapped) return mapped; - return resolveResourceContent(ref, pieceDir); -} - -/** Resolve multiple references to content strings (for fields that accept string | string[]). */ -function resolveRefList( - refs: string | string[] | undefined, - resolvedMap: Record | undefined, - pieceDir: string, -): string[] | undefined { - if (refs == null) return undefined; - const list = Array.isArray(refs) ? refs : [refs]; - const contents: string[] = []; - for (const ref of list) { - const content = resolveRefToContent(ref, resolvedMap, pieceDir); - if (content) contents.push(content); - } - return contents.length > 0 ? contents : undefined; -} - -/** Resolve a piece-level section map (each value resolved to file content or inline). */ -function resolveSectionMap( - raw: Record | undefined, - pieceDir: string, -): Record | undefined { - if (!raw) return undefined; - const resolved: Record = {}; - for (const [name, value] of Object.entries(raw)) { - const content = resolveResourceContent(value, pieceDir); - if (content) resolved[name] = content; - } - return Object.keys(resolved).length > 0 ? resolved : undefined; -} - -/** Extract display name from persona path (e.g., "coder.md" → "coder"). */ -function extractPersonaDisplayName(personaPath: string): string { - return basename(personaPath, '.md'); -} - -/** Resolve persona from YAML field to spec + absolute path. */ -function resolvePersona( - rawPersona: string | undefined, - sections: PieceSections, - pieceDir: string, -): { personaSpec?: string; personaPath?: string } { - if (!rawPersona) return {}; - const personaSpec = sections.personas?.[rawPersona] ?? rawPersona; - - const resolved = resolveResourcePath(personaSpec, pieceDir); - const personaPath = existsSync(resolved) ? resolved : undefined; - return { personaSpec, personaPath }; -} - -/** Pre-resolved section maps passed to movement normalization. */ -interface PieceSections { - /** Persona name → file path (raw, not content-resolved) */ - personas?: Record; - /** Policy name → resolved content */ - resolvedPolicies?: Record; - /** Knowledge name → resolved content */ - resolvedKnowledge?: Record; - /** Instruction name → resolved content */ - resolvedInstructions?: Record; - /** Report format name → resolved content */ - resolvedReportFormats?: Record; -} - /** Check if a raw output contract item is the object form (has 'name' property). */ function isOutputContractItem(raw: unknown): raw is { name: string; order?: string; format?: string } { return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; @@ -271,6 +180,7 @@ function normalizeStepFromRaw( personaDisplayName: displayName || (personaSpec ? extractPersonaDisplayName(personaSpec) : step.name), personaPath, allowedTools: step.allowed_tools, + mcpServers: step.mcp_servers, provider: step.provider, model: step.model, permissionMode: step.permission_mode, diff --git a/src/infra/config/loaders/resource-resolver.ts b/src/infra/config/loaders/resource-resolver.ts new file mode 100644 index 0000000..d364adf --- /dev/null +++ b/src/infra/config/loaders/resource-resolver.ts @@ -0,0 +1,109 @@ +/** + * Resource resolution helpers for piece YAML parsing. + * + * Resolves file paths, content references, and persona specs + * from piece-level section maps. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, basename } from 'node:path'; + +/** Pre-resolved section maps passed to movement normalization. */ +export interface PieceSections { + /** Persona name → file path (raw, not content-resolved) */ + personas?: Record; + /** Policy name → resolved content */ + resolvedPolicies?: Record; + /** Knowledge name → resolved content */ + resolvedKnowledge?: Record; + /** Instruction name → resolved content */ + resolvedInstructions?: Record; + /** Report format name → resolved content */ + resolvedReportFormats?: Record; +} + +/** Resolve a resource spec to an absolute file path. */ +export function resolveResourcePath(spec: string, pieceDir: string): string { + if (spec.startsWith('./')) return join(pieceDir, spec.slice(2)); + if (spec.startsWith('~')) return join(homedir(), spec.slice(1)); + if (spec.startsWith('/')) return spec; + return join(pieceDir, spec); +} + +/** + * Resolve a resource spec to its file content. + * If the spec ends with .md and the file exists, returns file content. + * Otherwise returns the spec as-is (treated as inline content). + */ +export function resolveResourceContent(spec: string | undefined, pieceDir: string): string | undefined { + if (spec == null) return undefined; + if (spec.endsWith('.md')) { + const resolved = resolveResourcePath(spec, pieceDir); + if (existsSync(resolved)) return readFileSync(resolved, 'utf-8'); + } + return spec; +} + +/** + * Resolve a section reference to content. + * Looks up ref in resolvedMap first, then falls back to resolveResourceContent. + */ +export function resolveRefToContent( + ref: string, + resolvedMap: Record | undefined, + pieceDir: string, +): string | undefined { + const mapped = resolvedMap?.[ref]; + if (mapped) return mapped; + return resolveResourceContent(ref, pieceDir); +} + +/** Resolve multiple references to content strings (for fields that accept string | string[]). */ +export function resolveRefList( + refs: string | string[] | undefined, + resolvedMap: Record | undefined, + pieceDir: string, +): string[] | undefined { + if (refs == null) return undefined; + const list = Array.isArray(refs) ? refs : [refs]; + const contents: string[] = []; + for (const ref of list) { + const content = resolveRefToContent(ref, resolvedMap, pieceDir); + if (content) contents.push(content); + } + return contents.length > 0 ? contents : undefined; +} + +/** Resolve a piece-level section map (each value resolved to file content or inline). */ +export function resolveSectionMap( + raw: Record | undefined, + pieceDir: string, +): Record | undefined { + if (!raw) return undefined; + const resolved: Record = {}; + for (const [name, value] of Object.entries(raw)) { + const content = resolveResourceContent(value, pieceDir); + if (content) resolved[name] = content; + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** Extract display name from persona path (e.g., "coder.md" → "coder"). */ +export function extractPersonaDisplayName(personaPath: string): string { + return basename(personaPath, '.md'); +} + +/** Resolve persona from YAML field to spec + absolute path. */ +export function resolvePersona( + rawPersona: string | undefined, + sections: PieceSections, + pieceDir: string, +): { personaSpec?: string; personaPath?: string } { + if (!rawPersona) return {}; + const personaSpec = sections.personas?.[rawPersona] ?? rawPersona; + + const resolved = resolveResourcePath(personaSpec, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec, personaPath }; +} diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index 02a8cf8..2ce14a2 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -12,6 +12,7 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { cwd: options.cwd, sessionId: options.sessionId, allowedTools: options.allowedTools, + mcpServers: options.mcpServers, model: options.model, maxTurns: options.maxTurns, permissionMode: options.permissionMode, diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index 1764583..f0af97b 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -3,7 +3,7 @@ */ import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js'; -import type { AgentResponse, PermissionMode } from '../../core/models/index.js'; +import type { AgentResponse, PermissionMode, McpServerConfig } from '../../core/models/index.js'; /** Agent setup configuration — determines HOW the provider invokes the agent */ export interface AgentSetup { @@ -23,6 +23,8 @@ export interface ProviderCallOptions { sessionId?: string; model?: string; allowedTools?: string[]; + /** MCP servers configuration */ + mcpServers?: Record; /** Maximum number of agentic turns */ maxTurns?: number; /** Permission mode for tool execution (from piece step) */