"""HTTP transport for host-terminal-mcp.
Wraps the existing HostTerminalServer logic in a FastAPI app,
allowing external services (e.g. Annie's MCP server running in Docker)
to call host-terminal-mcp via HTTP instead of stdio.
"""
from fastapi import FastAPI
from pydantic import BaseModel
from .config import Config
from .executor import CommandExecutor
class ExecuteRequest(BaseModel):
"""Request body for /execute endpoint."""
command: str
working_directory: str | None = None
class CdRequest(BaseModel):
"""Request body for /cd endpoint."""
path: str
def create_app(config: Config) -> FastAPI:
"""Create a FastAPI app that delegates to existing command execution logic.
Args:
config: The loaded host-terminal-mcp Config.
Returns:
A FastAPI application instance.
"""
app = FastAPI(title="host-terminal-mcp", version="0.1.0")
executor = CommandExecutor(config)
@app.get("/health")
async def health() -> dict:
return {
"status": "ok",
"service": "host-terminal-mcp",
"permission_mode": config.permission_mode.value,
}
@app.post("/execute")
async def execute(req: ExecuteRequest) -> dict:
command = req.command.strip()
if not command:
return {"status": "error", "error": "No command provided"}
# Permission check (reuses existing config logic)
is_allowed, reason = config.is_command_allowed(command)
if reason == "NEEDS_APPROVAL":
return {
"status": "needs_approval",
"command": command,
"message": (
f"Command '{command}' is not in the allow list. "
"Approve it via the MCP stdio interface or add it to config."
),
}
if not is_allowed:
return {
"status": "error",
"error": f"Command not allowed: {reason}",
}
result = await executor.execute(command, req.working_directory)
return {
"status": "success",
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": result.return_code,
"return_code": result.return_code,
"timed_out": result.timed_out,
"truncated": result.truncated,
"working_directory": result.working_directory,
}
@app.post("/cd")
async def change_directory(req: CdRequest) -> dict:
path = req.path.strip()
if not path:
return {"status": "error", "error": "No path provided"}
success, message = executor.change_directory(path)
return {
"status": "success" if success else "error",
"message": message,
"current_directory": executor.current_directory,
}
@app.get("/cwd")
async def get_current_directory() -> dict:
return {
"status": "success",
"current_directory": executor.current_directory,
}
@app.get("/permissions")
async def get_permissions() -> dict:
return {
"status": "success",
"permission_mode": config.permission_mode.value,
"num_allowed_patterns": len(config.allowed_commands),
"num_blocked_patterns": len(config.blocked_commands),
"allowed_directories": config.allowed_directories,
"timeout_seconds": config.timeout_seconds,
}
return app