Phase 1: Restore MCP stdio server in hlyr

- Create mcp.ts with claude_approvals command for local daemon integration
- Register MCP command in index.ts
- Uses daemon RPC instead of HumanLayer API for approval management
This commit is contained in:
dexhorthy
2025-08-18 18:44:52 -07:00
parent fe197f71af
commit cb8b360192
9 changed files with 391 additions and 69 deletions

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }),
})
}

View File

@@ -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>', '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 <query>')
.description('Launch a new Claude Code session via the daemon')

192
hlyr/src/mcp.ts Normal file
View File

@@ -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<string, unknown> = 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
}
}

View File

@@ -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<LauncherState>((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<LauncherState>((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)