refactor(hlyr): Migrate from stdio to HTTP MCP transport

- Extend claudecode-go types to support HTTP MCP servers with headers
- Update launch command to use HTTP MCP endpoint at /api/v1/mcp
- Remove stdio MCP server implementation from hlyr
- Clean up index.ts to remove MCP server initialization
This commit is contained in:
dexhorthy
2025-08-13 23:03:07 -07:00
parent 3642ae7be2
commit 2c28211138
4 changed files with 16 additions and 311 deletions

View File

@@ -27,10 +27,17 @@ const (
)
// MCPServer represents a single MCP server configuration
// It can be either a stdio-based server (with command/args/env) or an HTTP server (with type/url/headers)
type MCPServer struct {
Command string `json:"command"`
// For stdio-based servers
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
// For HTTP servers
Type string `json:"type,omitempty"` // "http" for HTTP servers
URL string `json:"url,omitempty"` // The HTTP endpoint URL
Headers map[string]string `json:"headers,omitempty"` // HTTP headers to include
}
// MCPConfig represents the MCP configuration structure

View File

@@ -44,13 +44,16 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
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: {
approvals: {
command: 'npx',
args: ['humanlayer', 'mcp', 'claude_approvals'],
codelayer: {
type: 'http',
url: `http://localhost:${daemonPort}/api/v1/mcp`,
// Session ID will be added as header by Claude Code
},
},
}
@@ -63,7 +66,7 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
working_dir: options.workingDir || process.cwd(),
max_turns: options.maxTurns,
mcp_config: mcpConfig,
permission_prompt_tool: mcpConfig ? 'mcp__approvals__request_permission' : undefined,
permission_prompt_tool: mcpConfig ? 'mcp__codelayer__request_approval' : undefined,
dangerously_skip_permissions: options.dangerouslySkipPermissions,
dangerously_skip_permissions_timeout: options.dangerouslySkipPermissionsTimeout
? parseInt(options.dangerouslySkipPermissionsTimeout) * 60 * 1000

View File

@@ -10,7 +10,6 @@ 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 { startDefaultMCPServer, startClaudeApprovalsMCPServer } from './mcp.js'
import {
getDefaultConfigPath,
resolveFullConfig,
@@ -67,7 +66,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', 'mcp']
const UNPROTECTED_COMMANDS = ['config', 'login', 'thoughts', 'join-waitlist', 'launch']
program.hook('preAction', async (thisCmd, actionCmd) => {
// Get the full command path by traversing up the command hierarchy
@@ -171,36 +170,6 @@ program
.option('--daemon-socket <path>', 'Path to daemon socket')
.action(alertCommand)
const mcpCommand = program.command('mcp').description('MCP server functionality')
mcpCommand
.command('serve')
.description('Start the default MCP server for contact_human functionality')
.action(startDefaultMCPServer)
mcpCommand
.command('claude_approvals')
.description('Start the Claude approvals MCP server for permission requests')
.action(startClaudeApprovalsMCPServer)
mcpCommand
.command('wrapper')
.description('Wrap an existing MCP server with human approval functionality (not implemented yet)')
.action(() => {
console.log('MCP wrapper functionality is not implemented yet.')
console.log('This will allow wrapping any existing MCP server with human approval.')
process.exit(1)
})
mcpCommand
.command('inspector')
.description('Run MCP inspector for debugging MCP servers')
.argument('[command]', 'MCP server command to inspect', 'serve')
.action(command => {
const args = ['@modelcontextprotocol/inspector', 'node', 'dist/index.js', 'mcp', command]
spawn('npx', args, { stdio: 'inherit', cwd: process.cwd() })
})
// Add thoughts command
thoughtsCommand(program)

View File

@@ -1,274 +0,0 @@
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 { humanlayer } from '@humanlayer/sdk'
import { resolveFullConfig } from './config.js'
import { DaemonClient } from './daemonClient.js'
import { logger } from './mcpLogger.js'
function validateAuth(): void {
const config = resolveFullConfig({})
if (!config.api_key) {
console.error('Error: No HumanLayer API token found.')
console.error('Please set HUMANLAYER_API_KEY environment variable or run `humanlayer login`')
process.exit(1)
}
}
/**
* Start the default MCP server that provides contact_human functionality
* Uses web UI by default when no contact channel is configured
*/
export async function startDefaultMCPServer() {
validateAuth()
const server = new Server(
{
name: 'humanlayer-standalone',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
},
)
const resolvedConfig = resolveFullConfig({})
const hl = humanlayer({
apiKey: resolvedConfig.api_key,
...(resolvedConfig.api_base_url && { apiBaseUrl: resolvedConfig.api_base_url }),
...(resolvedConfig.run_id && { runId: resolvedConfig.run_id }),
...(Object.keys(resolvedConfig.contact_channel).length > 0 && {
contactChannel: resolvedConfig.contact_channel,
}),
})
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'contact_human',
description: 'Contact a human for assistance',
inputSchema: {
type: 'object',
properties: {
message: { type: 'string' },
},
required: ['message'],
},
},
],
}
})
server.setRequestHandler(CallToolRequestSchema, async request => {
if (request.params.name === 'contact_human') {
const response = await hl.fetchHumanResponse({
spec: {
msg: request.params.arguments?.message,
},
})
return {
content: [
{
type: 'text',
text: response,
},
],
}
}
throw new McpError(ErrorCode.InvalidRequest, 'Invalid tool name')
})
const transport = new StdioServerTransport()
await server.connect(transport)
}
/**
* Start the Claude approvals MCP server that provides request_permission functionality
* Returns responses in the format required by Claude Code SDK
*
* This now 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_RUN_ID: process.env.HUMANLAYER_RUN_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' },
},
required: ['tool_name', 'input'],
},
},
]
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
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 run ID from environment (set by Claude Code)
const runId = process.env.HUMANLAYER_RUN_ID
if (!runId) {
logger.error('HUMANLAYER_RUN_ID not set in environment')
throw new McpError(ErrorCode.InternalError, 'HUMANLAYER_RUN_ID not set')
}
logger.info('Processing approval request', { runId, toolName })
try {
// Connect to daemon
logger.debug('Connecting to daemon...')
await daemonClient.connect()
logger.debug('Connected to daemon')
// Create approval request
logger.debug('Creating approval request...', { runId, toolName })
const createResponse = await daemonClient.createApproval(runId, toolName, input)
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
}
}