Skip to content
Merged
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
# Changelog

## feat/mcp-server
## feat/config-package-go126

Persistent configuration via `~/.httpmon/config.json` replaces pure CLI-flag
defaults (#22). A new `internal/config` package handles Load/Save with automatic
default backfill for forward-compatible configs, CLI flag overrides via
`flag.Visit`, and transparent MCP token migration from the legacy `mcp-token`
file. A TUI settings screen (P key) exposes all seven fields — ProxyPort,
MCPEnabled, MCPAddr, BufferSize, ThrottlePreset, ListMode, TreeGroupBy — with
bool/enum toggle and text editing, auto-saving on close. Ten unit tests cover
open/close, navigation, field toggling, text editing with cancel, disk
persistence, view rendering, and menu integration. Go upgraded from 1.25.3 to
1.26.0 alongside golangci-lint v2.9.0, resolving the race detector toolchain
mismatch.

## [feat/mcp-server](https://github.com/kostyay/httpmon/pull/20) - 2026-02-16

An MCP (Model Context Protocol) server lets LLM agents debug HTTP traffic
programmatically alongside the TUI. Fourteen tools span read-only inspection
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ httpmon --throttle 3g # Simulate 3G network (750 kbps)
httpmon --throttle 4g # Simulate 4G network (4 Mbps)
httpmon --latency 100ms # Add 100ms latency to responses
httpmon --maplocal rules.json # Serve local files for matching URLs
httpmon --mcp # Start with MCP server enabled
httpmon --mcp-token # Print MCP bearer token
httpmon --install-ca # Install CA cert into system trust store (needs sudo)
httpmon --version # Print version
```
Expand Down Expand Up @@ -228,6 +230,49 @@ Changes auto-save back to the rules file.

Flows served from local files show a `[L]` indicator in the flow list.

## MCP Server

httpmon includes an MCP (Model Context Protocol) server so LLM agents can programmatically inspect and debug HTTP traffic.

### Start the MCP server

```bash
httpmon --mcp # Start on default addr (127.0.0.1:9551)
httpmon --mcp --mcp-addr :9600 # Custom address
```

### Get the bearer token

```bash
httpmon --mcp-token
```

### Configure Claude Code

```bash
claude mcp add --transport http httpmon http://127.0.0.1:9551/mcp \
--header "Authorization: Bearer $(httpmon --mcp-token)"
```

### Available tools

| Tool | Description |
|------|-------------|
| `list_requests` | List captured HTTP flows with optional filter |
| `get_request` | Get full request/response details |
| `search_requests` | Search flows by substring |
| `get_request_count` | Count flows matching a filter |
| `export_har` | Export flows as HAR 1.2 JSON |
| `set_throttle` | Set bandwidth throttling (3g/4g/wifi presets) |
| `get_throttle` | Get current throttle settings |
| `replay_request` | Replay a captured request or compose a new one |
| `mock_response` | Mock a response for matching URLs |
| `list_scripts` | List scripting hooks |
| `create_script` | Create a new script |
| `get_script` | Get script source |
| `toggle_script` | Enable/disable a script |
| `delete_script` | Remove a script |

## Keyboard Shortcuts

Press `?` anywhere for the full help overlay, or `Space` for a context-aware action menu.
Expand Down
79 changes: 46 additions & 33 deletions cmd/httpmon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/kostyay/httpmon/internal/breakpoint"
"github.com/kostyay/httpmon/internal/certutil"
"github.com/kostyay/httpmon/internal/config"
"github.com/kostyay/httpmon/internal/hostfilter"
"github.com/kostyay/httpmon/internal/mcpserver"
"github.com/kostyay/httpmon/internal/procinfo"
Expand All @@ -27,36 +28,57 @@ import (
var version = "dev"

func main() {
port := flag.Int("port", 8080, "proxy listen port")
flag.Int("port", 8080, "proxy listen port")
dataDir := flag.String("data-dir", defaultDataDir(), "data directory for CA certs")
bufSize := flag.Int("buffer-size", 10000, "max flows in memory")
flag.Int("buffer-size", 10000, "max flows in memory")
blockHosts := flag.String("block", "", "comma-separated host patterns to block (wildcards: *.ads.com)")
allowHosts := flag.String("allow", "", "comma-separated host patterns to allow (only these intercepted)")
showVersion := flag.Bool("version", false, "print version and exit")
installCA := flag.Bool("install-ca", false, "install CA cert into system trust store and exit")
throttleFlag := flag.String("throttle", "", "throttle preset: 3g, 4g, wifi")
flag.String("throttle", "", "throttle preset: 3g, 4g, wifi")
latencyFlag := flag.Duration("latency", 0, "added latency per response (e.g. 100ms)")
mcpFlag := flag.Bool("mcp", false, "start MCP server on default addr (127.0.0.1:9551)")
mcpAddrFlag := flag.String("mcp-addr", "", "MCP server listen address (implies --mcp)")
flag.Bool("mcp", false, "start MCP server on default addr (127.0.0.1:9551)")
flag.String("mcp-addr", "", "MCP server listen address (implies --mcp)")
mcpTokenFlag := flag.Bool("mcp-token", false, "print MCP bearer token and exit")
flag.Parse()

if *showVersion {
fmt.Println(version)
return
}

if *port < 1 || *port > 65535 {
fatal("invalid port: %d", *port)
cfg, cfgErr := config.Load(*dataDir)
if cfgErr != nil {
fatal("config: %v", cfgErr)
}
if *bufSize < 1 {
config.ApplyFlags(cfg, flag.Visit)

// --mcp-addr implies --mcp.
flag.Visit(func(f *flag.Flag) {
if f.Name == "mcp-addr" {
cfg.MCPEnabled = true
}
})

if *mcpTokenFlag {
if err := config.LoadOrCreateToken(cfg, *dataDir); err != nil {
fatal("mcp token: %v", err)
}
fmt.Println(cfg.MCPToken)
return
}

if cfg.ProxyPort < 1 || cfg.ProxyPort > 65535 {
fatal("invalid port: %d", cfg.ProxyPort)
}
if cfg.BufferSize < 1 {
fatal("buffer-size must be > 0")
}

s := store.New(*bufSize)
s := store.New(cfg.BufferSize)
p := proxy.New(s, *dataDir)
p.Resolver = procinfo.New(s)

// Init scripting engine and breakpoint controller.
scriptsDir := filepath.Join(*dataDir, "scripts")
engine := scripting.New()
engine.LoadFromDir(scriptsDir)
Expand All @@ -70,19 +92,18 @@ func main() {
p.HostFilter = hostfilter.New(block, allow)
}

// Throttle configuration.
if *throttleFlag != "" {
bps := throttle.PresetBandwidth(*throttleFlag)
if cfg.ThrottlePreset != "" {
bps := throttle.PresetBandwidth(cfg.ThrottlePreset)
if bps == 0 {
fatal("unknown throttle preset: %q (use 3g, 4g, or wifi)", *throttleFlag)
fatal("unknown throttle preset: %q (use 3g, 4g, or wifi)", cfg.ThrottlePreset)
}
p.ThrottleBPS = bps
}
if *latencyFlag > 0 {
p.ThrottleLatency = *latencyFlag
}

addr := fmt.Sprintf(":%d", *port)
addr := fmt.Sprintf(":%d", cfg.ProxyPort)
if err := p.Init(addr); err != nil {
fatal("proxy init: %v", err)
}
Expand All @@ -106,46 +127,38 @@ func main() {
caTrusted := certutil.IsInstalled(p.CACertPath())
mgr := scripting.NewManager(engine, scriptsDir)

// MCP server (optional).
if *mcpAddrFlag != "" {
*mcpFlag = true
}
var mcpSrv *mcpserver.Server
if *mcpFlag {
mcpAddr := *mcpAddrFlag
if mcpAddr == "" {
mcpAddr = mcpserver.DefaultAddr
}
token, tokenErr := mcpserver.LoadOrCreateToken(*dataDir)
if tokenErr != nil {
fatal("mcp token: %v", tokenErr)
if cfg.MCPEnabled {
if err := config.LoadOrCreateToken(cfg, *dataDir); err != nil {
fatal("mcp token: %v", err)
}
mcpSrv = mcpserver.New(mcpserver.Config{
Store: s,
Proxy: p,
Scripts: mgr,
Throttle: p,
Addr: mcpAddr,
Token: token,
Addr: cfg.MCPAddr,
Token: cfg.MCPToken,
})
if err := mcpSrv.Start(ctx); err != nil {
fatal("mcp server: %v", err)
}
fmt.Fprintf(os.Stderr, "MCP server listening on %s (token: %s)\n", mcpSrv.Addr(), token)
fmt.Fprintf(os.Stderr, "MCP server listening on %s (token: %s)\n", mcpSrv.Addr(), cfg.MCPToken)
}

cfg := tui.AppConfig{
tuiCfg := tui.AppConfig{
Store: s,
Proxy: p,
CATrusted: caTrusted,
Scripts: mgr,
Throttle: p,
Breakpoints: bpCtrl,
DataDir: *dataDir,
}
if mcpSrv != nil {
cfg.MCP = mcpSrv
tuiCfg.MCP = mcpSrv
}
app := tui.NewApp(cfg)
app := tui.NewApp(tuiCfg)
prog := tea.NewProgram(app)
if _, err := prog.Run(); err != nil {
fatal("TUI error: %v", err)
Expand Down
4 changes: 1 addition & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
module github.com/kostyay/httpmon

go 1.25.3

toolchain go1.25.7
go 1.26.0

require (
charm.land/bubbles/v2 v2.0.0-rc.1
Expand Down
143 changes: 143 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package config

import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

const (
configFile = "config.json"
oldTokenFile = "mcp-token"
)

// Config holds persistent httpmon settings stored in ~/.httpmon/config.json.
type Config struct {
ProxyPort int `json:"proxy_port"`
MCPEnabled bool `json:"mcp_enabled"`
MCPAddr string `json:"mcp_addr"`
MCPToken string `json:"mcp_token"`
BufferSize int `json:"buffer_size"`
ThrottlePreset string `json:"throttle_preset"`
ListMode string `json:"list_mode"`
TreeGroupBy string `json:"tree_group_by"`
}

// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() Config {
return Config{
ProxyPort: 8080,
MCPAddr: "127.0.0.1:9551",
BufferSize: 10000,
}
}

// Load reads config.json from dataDir. Returns defaults if the file doesn't exist.
func Load(dataDir string) (*Config, error) {
path := filepath.Join(dataDir, configFile)
data, err := os.ReadFile(path) // #nosec G304 -- dataDir is user-provided config dir
if err != nil {
if errors.Is(err, os.ErrNotExist) {
cfg := DefaultConfig()
return &cfg, nil
}
return nil, fmt.Errorf("read config: %w", err)
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
applyDefaults(&cfg)
return &cfg, nil
}

// Save writes the config as indented JSON to dataDir/config.json.
func (c *Config) Save(dataDir string) error {
if err := os.MkdirAll(dataDir, 0o750); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
path := filepath.Join(dataDir, configFile)
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}

// LoadOrCreateToken ensures cfg.MCPToken is set. It migrates from the legacy
// mcp-token file if present, otherwise generates a new 32-byte hex token.
// The config is saved after the token is set.
func LoadOrCreateToken(cfg *Config, dataDir string) error {
if cfg.MCPToken != "" {
return nil
}

// Try migrating from legacy file.
oldPath := filepath.Join(dataDir, oldTokenFile)
if data, err := os.ReadFile(oldPath); err == nil { // #nosec G304
tok := strings.TrimSpace(string(data))
if tok != "" {
cfg.MCPToken = tok
_ = os.Remove(oldPath)
return cfg.Save(dataDir)
}
}

// Generate new token.
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
return fmt.Errorf("generate token: %w", err)
}
cfg.MCPToken = hex.EncodeToString(buf[:])
return cfg.Save(dataDir)
}

// ApplyFlags overrides config fields with explicitly-set CLI flags.
// Only flags the user actually passed on the command line are applied.
func ApplyFlags(cfg *Config, visit func(fn func(*flag.Flag))) {
visit(func(f *flag.Flag) {
switch f.Name {
case "port":
cfg.ProxyPort = mustInt(f.Value.String())
case "buffer-size":
cfg.BufferSize = mustInt(f.Value.String())
case "mcp":
cfg.MCPEnabled = f.Value.String() == "true"
case "mcp-addr":
cfg.MCPAddr = f.Value.String()
case "throttle":
cfg.ThrottlePreset = f.Value.String()
}
})
}

// applyDefaults fills zero-value fields with defaults so that older config
// files missing new fields still behave correctly.
func applyDefaults(cfg *Config) {
d := DefaultConfig()
if cfg.ProxyPort == 0 {
cfg.ProxyPort = d.ProxyPort
}
if cfg.MCPAddr == "" {
cfg.MCPAddr = d.MCPAddr
}
if cfg.BufferSize == 0 {
cfg.BufferSize = d.BufferSize
}
}

func mustInt(s string) int {
v, _ := strconv.Atoi(s)
return v
}
Loading
Loading