diff --git a/hld/rpc/approval_handlers.go b/hld/rpc/approval_handlers.go index 07a35b1..2f2e8c1 100644 --- a/hld/rpc/approval_handlers.go +++ b/hld/rpc/approval_handlers.go @@ -29,6 +29,7 @@ type CreateApprovalRequest struct { RunID string `json:"run_id"` ToolName string `json:"tool_name"` ToolInput json.RawMessage `json:"tool_input"` + ToolUseID string `json:"tool_use_id,omitempty"` } // CreateApprovalResponse is the response for creating a local approval @@ -54,10 +55,22 @@ func (h *ApprovalHandlers) HandleCreateApproval(ctx context.Context, params json return nil, fmt.Errorf("tool_input is required") } - // Create the approval - approvalID, err := h.approvals.CreateApproval(ctx, req.RunID, req.ToolName, req.ToolInput) - if err != nil { - return nil, fmt.Errorf("failed to create approval: %w", err) + // Create approval with or without tool use ID + var approvalID string + if req.ToolUseID != "" { + // Use the new method that accepts tool use ID + approval, err := h.approvals.CreateApprovalWithToolUseID(ctx, req.RunID, req.ToolName, req.ToolInput, req.ToolUseID) + if err != nil { + return nil, fmt.Errorf("failed to create approval with tool use ID: %w", err) + } + approvalID = approval.ID + } else { + // Fall back to legacy method for backward compatibility + var err error + approvalID, err = h.approvals.CreateApproval(ctx, req.RunID, req.ToolName, req.ToolInput) + if err != nil { + return nil, fmt.Errorf("failed to create approval: %w", err) + } } return &CreateApprovalResponse{ diff --git a/hld/session/continue_inheritance_test.go b/hld/session/continue_inheritance_test.go index 7d862bd..e2644d0 100644 --- a/hld/session/continue_inheritance_test.go +++ b/hld/session/continue_inheritance_test.go @@ -344,18 +344,73 @@ func TestContinueSessionInheritance(t *testing.T) { t.Fatalf("Failed to get child MCP servers: %v", err) } - // Should have inherited the MCP servers - if len(childMCPServers) != len(mcpServers) { - t.Errorf("MCP servers not inherited: got %d, want %d", len(childMCPServers), len(mcpServers)) + // Should have inherited the MCP servers plus injected codelayer + expectedCount := len(mcpServers) + 1 // +1 for injected codelayer + if len(childMCPServers) != expectedCount { + t.Errorf("MCP servers count mismatch: got %d, want %d", len(childMCPServers), expectedCount) } - // Verify server details (accounting for HUMANLAYER_RUN_ID being added) + // Find and verify the injected codelayer server + var foundCodelayer bool + var codelayerIdx int for i, server := range childMCPServers { - if server.Name != mcpServers[i].Name { - t.Errorf("MCP server %d name mismatch: got %s, want %s", i, server.Name, mcpServers[i].Name) + if server.Name == "codelayer" { + foundCodelayer = true + codelayerIdx = i + + // Verify codelayer configuration + if server.Command != "hlyr" { + t.Errorf("Codelayer command mismatch: got %s, want hlyr", server.Command) + } + + var args []string + if err := json.Unmarshal([]byte(server.ArgsJSON), &args); err != nil { + t.Fatalf("Failed to unmarshal codelayer args: %v", err) + } + expectedArgs := []string{"mcp", "claude_approvals"} + if len(args) != len(expectedArgs) { + t.Errorf("Codelayer args length mismatch: got %d, want %d", len(args), len(expectedArgs)) + } else { + for j, arg := range args { + if arg != expectedArgs[j] { + t.Errorf("Codelayer arg[%d] mismatch: got %s, want %s", j, arg, expectedArgs[j]) + } + } + } + + var env map[string]string + if err := json.Unmarshal([]byte(server.EnvJSON), &env); err != nil { + t.Fatalf("Failed to unmarshal codelayer env: %v", err) + } + if env["HUMANLAYER_SESSION_ID"] != childSession.ID { + t.Errorf("Codelayer env HUMANLAYER_SESSION_ID mismatch: got %s, want %s", env["HUMANLAYER_SESSION_ID"], childSession.ID) + } + break } - if server.Command != mcpServers[i].Command { - t.Errorf("MCP server %d command mismatch: got %s, want %s", i, server.Command, mcpServers[i].Command) + } + + if !foundCodelayer { + t.Error("Injected codelayer MCP server not found") + } + + // Verify inherited servers (excluding codelayer) + parentIdx := 0 + for i, server := range childMCPServers { + // Skip the codelayer server + if i == codelayerIdx { + continue + } + + if parentIdx >= len(mcpServers) { + t.Errorf("Extra unexpected MCP server found: %s", server.Name) + continue + } + + if server.Name != mcpServers[parentIdx].Name { + t.Errorf("MCP server %d name mismatch: got %s, want %s", parentIdx, server.Name, mcpServers[parentIdx].Name) + } + if server.Command != mcpServers[parentIdx].Command { + t.Errorf("MCP server %d command mismatch: got %s, want %s", parentIdx, server.Command, mcpServers[parentIdx].Command) } // Compare args (deserialize to compare content) @@ -363,15 +418,15 @@ func TestContinueSessionInheritance(t *testing.T) { if err := json.Unmarshal([]byte(server.ArgsJSON), &childArgs); err != nil { t.Fatalf("Failed to unmarshal child args: %v", err) } - if err := json.Unmarshal([]byte(mcpServers[i].ArgsJSON), &parentArgs); err != nil { + if err := json.Unmarshal([]byte(mcpServers[parentIdx].ArgsJSON), &parentArgs); err != nil { t.Fatalf("Failed to unmarshal parent args: %v", err) } if len(childArgs) != len(parentArgs) { - t.Errorf("MCP server %d args length mismatch", i) + t.Errorf("MCP server %d args length mismatch", parentIdx) } else { for j, arg := range childArgs { if arg != parentArgs[j] { - t.Errorf("MCP server %d arg[%d] mismatch: got %s, want %s", i, j, arg, parentArgs[j]) + t.Errorf("MCP server %d arg[%d] mismatch: got %s, want %s", parentIdx, j, arg, parentArgs[j]) } } } @@ -381,21 +436,23 @@ func TestContinueSessionInheritance(t *testing.T) { if err := json.Unmarshal([]byte(server.EnvJSON), &childEnv); err != nil { t.Fatalf("Failed to unmarshal child env: %v", err) } - if err := json.Unmarshal([]byte(mcpServers[i].EnvJSON), &parentEnv); err != nil { + if err := json.Unmarshal([]byte(mcpServers[parentIdx].EnvJSON), &parentEnv); err != nil { t.Fatalf("Failed to unmarshal parent env: %v", err) } // Child should have all parent env vars plus HUMANLAYER_RUN_ID for key, val := range parentEnv { if childEnv[key] != val { - t.Errorf("MCP server %d env[%s] mismatch: got %s, want %s", i, key, childEnv[key], val) + t.Errorf("MCP server %d env[%s] mismatch: got %s, want %s", parentIdx, key, childEnv[key], val) } } // Should have HUMANLAYER_RUN_ID added if _, ok := childEnv["HUMANLAYER_RUN_ID"]; !ok { - t.Errorf("MCP server %d missing HUMANLAYER_RUN_ID in env", i) + t.Errorf("MCP server %d missing HUMANLAYER_RUN_ID in env", parentIdx) } + + parentIdx++ } }) @@ -576,17 +633,26 @@ func TestContinueSessionInheritance(t *testing.T) { t.Fatalf("Failed to get child MCP servers: %v", err) } - // Should have the override server, not the original - if len(childMCPServers) != 1 { - t.Fatalf("Expected 1 MCP server, got %d", len(childMCPServers)) + // Should have the override server plus injected codelayer + if len(childMCPServers) != 2 { + t.Fatalf("Expected 2 MCP servers (override + codelayer), got %d", len(childMCPServers)) } - server := childMCPServers[0] - if server.Name != "override-server" { - t.Errorf("MCP server name not overridden: got %s", server.Name) + // Find the override server (not codelayer) + var overrideServer *store.MCPServer + for _, s := range childMCPServers { + if s.Name == "override-server" { + overrideServer = &s + break + } } - if server.Command != "override-cmd" { - t.Errorf("MCP server command not overridden: got %s", server.Command) + + if overrideServer == nil { + t.Fatal("Override server not found") + } + + if overrideServer.Command != "override-cmd" { + t.Errorf("MCP server command not overridden: got %s", overrideServer.Command) } }) @@ -853,17 +919,25 @@ func TestContinueSessionInheritance(t *testing.T) { t.Fatalf("Failed to get child MCP servers: %v", err) } - // Should have inherited the MCP server - if len(childMCPServers) != 1 { - t.Fatalf("Expected 1 MCP server, got %d", len(childMCPServers)) + // Should have inherited the MCP server plus injected codelayer + if len(childMCPServers) != 2 { + t.Fatalf("Expected 2 MCP servers (http + codelayer), got %d", len(childMCPServers)) } - childMCPServer := childMCPServers[0] + // Find the http test server (not codelayer) + var childMCPServer *store.MCPServer + for _, s := range childMCPServers { + if s.Name == "http-test-server" { + childMCPServer = &s + break + } + } + + if childMCPServer == nil { + t.Fatal("HTTP test server not found") + } // Verify basic inheritance - if childMCPServer.Name != "http-test-server" { - t.Errorf("MCP server name not inherited: got %s, want http-test-server", childMCPServer.Name) - } if childMCPServer.Command != "http" { t.Errorf("MCP server type not inherited: got %s, want http", childMCPServer.Command) } diff --git a/hld/session/manager.go b/hld/session/manager.go index 556f698..afda57e 100644 --- a/hld/session/manager.go +++ b/hld/session/manager.go @@ -68,6 +68,26 @@ func (m *Manager) LaunchSession(ctx context.Context, config LaunchSessionConfig) // Extract the Claude config (without daemon-level settings) claudeConfig := config.SessionConfig + // Inject daemon's CodeLayer MCP server configuration + if claudeConfig.MCPConfig == nil { + claudeConfig.MCPConfig = &claudecode.MCPConfig{ + MCPServers: make(map[string]claudecode.MCPServer), + } + } + + // Always inject codelayer MCP server (overwrite if exists) + claudeConfig.MCPConfig.MCPServers["codelayer"] = claudecode.MCPServer{ + Command: "hlyr", + Args: []string{"mcp", "claude_approvals"}, + Env: map[string]string{ + "HUMANLAYER_SESSION_ID": sessionID, + "HUMANLAYER_DAEMON_SOCKET": m.socketPath, + }, + } + slog.Debug("injected codelayer MCP server", + "session_id", sessionID, + "socket_path", m.socketPath) + // Add HUMANLAYER_RUN_ID and HUMANLAYER_DAEMON_SOCKET to MCP server environment // For HTTP servers, inject session ID header if claudeConfig.MCPConfig != nil { @@ -1300,8 +1320,33 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig // Add run_id and daemon socket to MCP server environments // For HTTP servers, inject session ID header + // Ensure MCP config exists for injection + if config.MCPConfig == nil { + config.MCPConfig = &claudecode.MCPConfig{ + MCPServers: make(map[string]claudecode.MCPServer), + } + } + + // Always update codelayer MCP server with child session ID + config.MCPConfig.MCPServers["codelayer"] = claudecode.MCPServer{ + Command: "hlyr", + Args: []string{"mcp", "claude_approvals"}, + Env: map[string]string{ + "HUMANLAYER_SESSION_ID": sessionID, // Use child session ID + "HUMANLAYER_DAEMON_SOCKET": m.socketPath, + }, + } + slog.Debug("updated codelayer MCP server for child session", + "session_id", sessionID, + "parent_session_id", req.ParentSessionID, + "socket_path", m.socketPath) + if config.MCPConfig != nil { for name, server := range config.MCPConfig.MCPServers { + // Skip codelayer as we already configured it above + if name == "codelayer" { + continue + } // Check if this is an HTTP MCP server if server.Type == "http" { // For HTTP servers, always set session ID header to child session ID diff --git a/hld/session/manager_test.go b/hld/session/manager_test.go index ba344df..f6a07e0 100644 --- a/hld/session/manager_test.go +++ b/hld/session/manager_test.go @@ -337,11 +337,25 @@ func TestLaunchSession_SetsMCPEnvironment(t *testing.T) { } // Verify MCP servers have the correct environment variables - if len(capturedMCPServers) != 1 { - t.Fatalf("Expected 1 MCP server, got %d", len(capturedMCPServers)) + // Should have the test server plus injected codelayer + if len(capturedMCPServers) != 2 { + t.Fatalf("Expected 2 MCP servers (test + codelayer), got %d", len(capturedMCPServers)) } - server := capturedMCPServers[0] + // Find the test server (not codelayer) + var server store.MCPServer + var found bool + for _, s := range capturedMCPServers { + if s.Name == "test-server" { + server = s + found = true + break + } + } + + if !found { + t.Fatal("Test server not found in MCP servers") + } // Parse the environment JSON var env map[string]string diff --git a/hlyr/src/commands/launch.ts b/hlyr/src/commands/launch.ts index 73ed7e6..d67183d 100644 --- a/hlyr/src/commands/launch.ts +++ b/hlyr/src/commands/launch.ts @@ -45,22 +45,6 @@ export const launchCommand = async (query: string, options: LaunchOptions = {}) const client = await connectWithRetry(socketPath, 3, 1000) try { - // Build MCP config (approvals enabled by default unless explicitly disabled) - // Phase 6: Using HTTP MCP endpoint instead of stdio - const daemonPort = process.env.HUMANLAYER_DAEMON_HTTP_PORT || '7777' - const mcpConfig = - options.approvals !== false - ? { - mcpServers: { - codelayer: { - type: 'http', - url: `http://localhost:${daemonPort}/api/v1/mcp`, - // Session ID will be added as header by Claude Code - }, - }, - } - : undefined - // Launch the session const result = await client.launchSession({ query: query, @@ -68,8 +52,9 @@ export const launchCommand = async (query: string, options: LaunchOptions = {}) model: options.model, working_dir: options.workingDir || process.cwd(), max_turns: options.maxTurns, - mcp_config: mcpConfig, - permission_prompt_tool: mcpConfig ? 'mcp__codelayer__request_approval' : undefined, + // MCP config is now injected by daemon + permission_prompt_tool: + options.approvals !== false ? 'mcp__codelayer__request_permission' : undefined, dangerously_skip_permissions: options.dangerouslySkipPermissions, dangerously_skip_permissions_timeout: options.dangerouslySkipPermissionsTimeout ? parseInt(options.dangerouslySkipPermissionsTimeout) * 60 * 1000 diff --git a/hlyr/src/daemonClient.ts b/hlyr/src/daemonClient.ts index 40c3ccf..92403e0 100644 --- a/hlyr/src/daemonClient.ts +++ b/hlyr/src/daemonClient.ts @@ -353,11 +353,13 @@ export class DaemonClient extends EventEmitter { runId: string, toolName: string, toolInput: unknown, + toolUseId?: string, ): Promise<{ approval_id: string }> { return this.call<{ approval_id: string }>('createApproval', { run_id: runId, tool_name: toolName, tool_input: toolInput, + ...(toolUseId && { tool_use_id: toolUseId }), }) } diff --git a/hlyr/src/index.ts b/hlyr/src/index.ts index 0aea40c..da28804 100644 --- a/hlyr/src/index.ts +++ b/hlyr/src/index.ts @@ -10,6 +10,7 @@ import { launchCommand } from './commands/launch.js' import { alertCommand } from './commands/alert.js' import { thoughtsCommand } from './commands/thoughts.js' import { joinWaitlistCommand } from './commands/joinWaitlist.js' +import { startClaudeApprovalsMCPServer } from './mcp.js' import { getDefaultConfigPath, resolveFullConfig, @@ -66,7 +67,7 @@ async function authenticate(printSelectedProject: boolean = false) { program.name('humanlayer').description('HumanLayer, but on your command-line.').version(VERSION) -const UNPROTECTED_COMMANDS = ['config', 'login', 'thoughts', 'join-waitlist', 'launch'] +const UNPROTECTED_COMMANDS = ['config', 'login', 'thoughts', 'join-waitlist', 'launch', 'mcp'] program.hook('preAction', async (thisCmd, actionCmd) => { // Get the full command path by traversing up the command hierarchy @@ -96,6 +97,13 @@ program .option('--config-file ', 'Path to config file') .action(loginCommand) +const mcpCommand = program.command('mcp').description('MCP server functionality') + +mcpCommand + .command('claude_approvals') + .description('Start the Claude approvals MCP server for permission requests') + .action(startClaudeApprovalsMCPServer) + program .command('launch ') .description('Launch a new Claude Code session via the daemon') diff --git a/hlyr/src/mcp.ts b/hlyr/src/mcp.ts new file mode 100644 index 0000000..d2f29e7 --- /dev/null +++ b/hlyr/src/mcp.ts @@ -0,0 +1,192 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ErrorCode, + ListToolsRequestSchema, + McpError, +} from '@modelcontextprotocol/sdk/types.js' +import { resolveFullConfig } from './config.js' +import { DaemonClient } from './daemonClient.js' +import { logger } from './mcpLogger.js' + +/** + * Start the Claude approvals MCP server that provides request_permission functionality + * Returns responses in the format required by Claude Code SDK + * + * This uses local approvals through the daemon instead of HumanLayer API + */ +export async function startClaudeApprovalsMCPServer() { + // No auth validation needed - uses local daemon + logger.info('Starting Claude approvals MCP server') + logger.info('Environment variables', { + HUMANLAYER_DAEMON_SOCKET: process.env.HUMANLAYER_DAEMON_SOCKET, + HUMANLAYER_SESSION_ID: process.env.HUMANLAYER_SESSION_ID, + }) + + const server = new Server( + { + name: 'humanlayer-claude-local-approvals', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + }, + ) + + // Create daemon client with socket path from environment or config + // The daemon sets HUMANLAYER_DAEMON_SOCKET for MCP servers it launches + const resolvedConfig = resolveFullConfig({}) + const socketPath = process.env.HUMANLAYER_DAEMON_SOCKET || resolvedConfig.daemon_socket + logger.info('Creating daemon client', { socketPath }) + const daemonClient = new DaemonClient(socketPath) + + server.setRequestHandler(ListToolsRequestSchema, async () => { + logger.info('ListTools request received') + const tools = [ + { + name: 'request_permission', + description: 'Request permission to perform an action', + inputSchema: { + type: 'object', + properties: { + tool_name: { type: 'string' }, + input: { type: 'object' }, + tool_use_id: { type: 'string' }, // Added for Phase 2 + }, + required: ['tool_name', 'input', 'tool_use_id'], + }, + }, + ] + logger.info('Returning tools', { tools }) + return { tools } + }) + + server.setRequestHandler(CallToolRequestSchema, async request => { + logger.debug('Received tool call request', { name: request.params.name }) + + if (request.params.name === 'request_permission') { + const toolName: string | undefined = request.params.arguments?.tool_name + const toolUseId: string | undefined = request.params.arguments?.tool_use_id // Phase 2 + + if (!toolName) { + logger.error('Invalid tool name in request_permission', request.params.arguments) + throw new McpError(ErrorCode.InvalidRequest, 'Invalid tool name requesting permissions') + } + + const input: Record = request.params.arguments?.input || {} + + // Get session ID from environment (set by daemon) + const sessionId = process.env.HUMANLAYER_SESSION_ID + if (!sessionId) { + logger.error('HUMANLAYER_SESSION_ID not set in environment') + throw new McpError(ErrorCode.InternalError, 'HUMANLAYER_SESSION_ID not set') + } + + logger.info('Processing approval request', { sessionId, toolName, toolUseId }) + + try { + // Connect to daemon + logger.debug('Connecting to daemon...') + await daemonClient.connect() + logger.debug('Connected to daemon') + + // Create approval request with tool use ID (Phase 2) + logger.debug('Creating approval request...', { sessionId, toolName, toolUseId }) + const createResponse = await daemonClient.createApproval(sessionId, toolName, input, toolUseId) + const approvalId = createResponse.approval_id + logger.info('Created approval', { approvalId }) + + // Poll for approval status + let approved = false + let comment = '' + let polling = true + + while (polling) { + try { + // Get the specific approval by ID + logger.debug('Fetching approval status...', { approvalId }) + const approval = (await daemonClient.getApproval(approvalId)) as { + id: string + status: string + comment?: string + } + + logger.debug('Approval status', { status: approval.status }) + + if (approval.status !== 'pending') { + // Approval has been resolved + approved = approval.status === 'approved' + comment = approval.comment || '' + polling = false + logger.info('Approval resolved', { + approvalId, + status: approval.status, + approved, + }) + } else { + // Still pending, wait and poll again + logger.debug('Approval still pending, polling again...') + await new Promise(resolve => setTimeout(resolve, 1000)) + } + } catch (error) { + logger.error('Failed to get approval status', { error, approvalId }) + // Re-throw the error since this is a critical failure + throw new McpError(ErrorCode.InternalError, 'Failed to get approval status') + } + } + + if (!approved) { + logger.info('Approval denied', { approvalId, comment }) + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + behavior: 'deny', + message: comment || 'Request denied by human reviewer', + }), + }, + ], + } + } + + logger.info('Approval granted', { approvalId }) + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + behavior: 'allow', + updatedInput: input, + }), + }, + ], + } + } catch (error) { + logger.error('Failed to process approval', error) + throw new McpError( + ErrorCode.InternalError, + `Failed to process approval: ${error instanceof Error ? error.message : String(error)}`, + ) + } finally { + logger.debug('Closing daemon connection') + daemonClient.close() + } + } + + throw new McpError(ErrorCode.InvalidRequest, 'Invalid tool name') + }) + + const transport = new StdioServerTransport() + + try { + await server.connect(transport) + logger.info('MCP server connected and ready') + } catch (error) { + logger.error('Failed to start MCP server', error) + throw error + } +} diff --git a/humanlayer-wui/src/hooks/useSessionLauncher.ts b/humanlayer-wui/src/hooks/useSessionLauncher.ts index f980139..424ad64 100644 --- a/humanlayer-wui/src/hooks/useSessionLauncher.ts +++ b/humanlayer-wui/src/hooks/useSessionLauncher.ts @@ -1,7 +1,6 @@ import { create } from 'zustand' import { daemonClient } from '@/lib/daemon' import type { LaunchSessionRequest } from '@/lib/daemon/types' -import { getDaemonUrl } from '@/lib/daemon/http-config' import { useHotkeysContext } from 'react-hotkeys-hook' import { SessionTableHotkeysScope } from '@/components/internal/SessionTable' import { exists } from '@tauri-apps/plugin-fs' @@ -141,17 +140,7 @@ export const useSessionLauncher = create((set, get) => ({ try { set({ isLaunching: true, error: undefined }) - // Build MCP config (approvals enabled by default) - // Use HTTP-based MCP server built into the daemon - const daemonUrl = await getDaemonUrl() - const mcpConfig = { - mcpServers: { - approvals: { - type: 'http', - url: `${daemonUrl}/api/v1/mcp`, - }, - }, - } + // MCP config is now injected by daemon const request: LaunchSessionRequest = { query: query.trim(), @@ -159,8 +148,8 @@ export const useSessionLauncher = create((set, get) => ({ working_dir: config.workingDir || undefined, model: config.model || undefined, max_turns: config.maxTurns || undefined, - mcp_config: mcpConfig, - permission_prompt_tool: 'mcp__approvals__request_approval', + // MCP config is now injected by daemon + permission_prompt_tool: 'mcp__codelayer__request_permission', } const response = await daemonClient.launchSession(request)