mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
274
hlyr/src/mcp.ts
274
hlyr/src/mcp.ts
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user