From 4ff9f14d77064415ee6cf78c726d751b20eabcdf Mon Sep 17 00:00:00 2001 From: Nils Jannasch Date: Sat, 9 May 2026 22:37:58 +0200 Subject: [PATCH] feat: MCP-over-HTTP + --bind/--token for headless/LAN mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a vibecockpit instance run on a server (e.g. via --autostart on a Linux box) and serve as a central memory hub for multiple machines. Web server: - New --bind flag (default 127.0.0.1). Pass 0.0.0.0 or a specific IP to expose to the LAN. - New --token / VIBECOCKPIT_TOKEN. Required when --bind is non-loopback; Start() refuses to listen otherwise — better to fail loud than ship an unauthenticated dashboard onto a network. - bearerAuthMiddleware: loopback requests bypass entirely (so SSH-tunnel + the auto-launched browser keep working). Non-loopback requests must carry Authorization: Bearer , with a ?token= query fallback for download flows. Constant-time compare. - Auto-open-browser is now skipped on non-loopback bind (no browser on a headless server). MCP transport: - Same internal/mcp.Server now exposes a HandleHTTPRequest(body []byte) []byte method backed by the same dispatcher the stdio transport uses. - /mcp endpoint on the web server speaks the MCP "Streamable HTTP" transport: each POST is one JSON-RPC request; response is the encoded reply, or 204 No Content for notifications. - /mcp is registered behind the same auth middleware as everything else, and gated by enable_mcp in config.yaml just like the stdio path. - Audit log is shared, so both transports' tool calls land in the same /api/mcp-audit feed. Tests: - isLoopbackBind table test for IPv4/IPv6 + localhost. - bearerAuthMiddleware: loopback bypass, missing/wrong/correct bearer, query-string fallback, defensive 401 when token unset on remote. - /mcp end-to-end: tools/list returns the full toolset, notifications return 204, MCP-disabled returns 503. Docs: README has a Remote/Headless Mode section with Claude Desktop and Cursor MCP-over-HTTP config snippets. CLAUDE.md notes the new flags and that the MCP toolset is identical across both transports. Verified live: --bind 0.0.0.0 without --token refuses to start; with --token, remote requests need the bearer (401 without, 200 with); loopback requests stay token-free; tools/list and search_memory round-trip correctly over HTTP. --- CLAUDE.md | 11 +++- README.md | 54 +++++++++++++++++ internal/mcp/server.go | 27 +++++++++ internal/web/auth.go | 79 ++++++++++++++++++++++++ internal/web/auth_test.go | 97 +++++++++++++++++++++++++++++ internal/web/mcp_http_test.go | 111 ++++++++++++++++++++++++++++++++++ internal/web/server.go | 84 ++++++++++++++++++++++--- main.go | 13 +++- 8 files changed, 464 insertions(+), 12 deletions(-) create mode 100644 internal/web/auth.go create mode 100644 internal/web/auth_test.go create mode 100644 internal/web/mcp_http_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1970902..5d3c315 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,11 @@ make run # build + launch ## Development ```bash -./vibecockpit --web --port 3456 & # API backend +./vibecockpit --web --port 3456 & # API backend (loopback only by default) cd frontend && npm run dev # Svelte with hot-reload (proxies /api to :3456) + +# Headless / LAN mode — requires a token +VIBECOCKPIT_TOKEN=$(openssl rand -hex 32) ./vibecockpit --web --bind 0.0.0.0 --port 3456 ``` ## Quality @@ -78,10 +81,12 @@ frontend/src/ - Agent tracker with PID, elapsed time, log tailing, kill support ### MCP server (`internal/mcp/`) -- 13+ tools: sessions, boards, costs, stats, inventory, rescan +- 14+ tools: sessions, boards, costs, stats, inventory, rescan, search_memory, get_session_context - Board tools: list_boards, list_tasks, get_task, update_task, create_task - Inventory filtering by type and query -- Run via `vibecockpit --mcp` +- Two transports, same Server, same toolset: + - Stdio: `vibecockpit --mcp` (one process per client, JSON-RPC over stdin/stdout) + - HTTP: `POST /mcp` on the web server (Streamable HTTP transport; reuses `Server.HandleHTTPRequest`) ### Scan cache (`internal/web/scancache.go`) - Directory mtime-based invalidation diff --git a/README.md b/README.md index 777ac83..0cd5782 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,58 @@ VibeCockpit extracts token usage from your session files and estimates costs usi | Antigravity | Conversations encrypted, no token data | N/A | | Remote (SSH) | Not yet supported | — | +## Remote / Headless Mode + +VibeCockpit can run headless on a server (e.g. a home lab or VPS) and act as a central memory hub for multiple laptops. + +```bash +# On the server — bind to LAN, require a token +export VIBECOCKPIT_TOKEN=$(openssl rand -hex 32) +vibecockpit --web --bind 0.0.0.0 --port 3456 --autostart + +# On each laptop — pull memory periodically and ship it to the server +vibecockpit memory export ~/.cache/vc.db +curl -X POST -H "Authorization: Bearer $VIBECOCKPIT_TOKEN" \ + -F "file=@$HOME/.cache/vc.db" \ + http://server.local:3456/api/memory/import +``` + +By default the web UI binds to `127.0.0.1` only. `--bind 0.0.0.0` (or any non-loopback address) requires `--token` (or `VIBECOCKPIT_TOKEN`); VibeCockpit refuses to start without one — there's no other auth on the API. + +For sensitive networks, terminate TLS in front of VibeCockpit (Caddy, nginx, Traefik, Tailscale Funnel) rather than exposing plain HTTP. Loopback requests (including SSH-tunneled ones) still bypass the token, so `ssh -L 3456:localhost:3456 server` keeps working without any client config. + +### MCP over HTTP + +When `enable_mcp: true` is set in `config.yaml`, the same MCP toolset that `vibecockpit --mcp` exposes over stdio is also served at `POST /mcp` (the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport). Agents on remote machines can hit this endpoint to query the server's memory index without each laptop having to maintain its own copy. + +Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "vibecockpit-remote": { + "url": "http://server.local:3456/mcp", + "headers": { "Authorization": "Bearer YOUR_TOKEN" } + } + } +} +``` + +Cursor's `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "vibecockpit-remote": { + "url": "http://server.local:3456/mcp", + "headers": { "Authorization": "Bearer YOUR_TOKEN" } + } + } +} +``` + +Once configured, `search_memory` and `get_session_context` queries from any client hit the central index — every machine sees the same cross-tool memory. + ## Contributing VibeCockpit is built with **Go** (backend + TUI) and **Svelte 5** (web UI). @@ -259,6 +311,8 @@ vibecockpit [flags] --web Start the web UI (opens in browser) --port int Port for the web UI (default: 3456) + --bind string Address to bind the web UI to (default: 127.0.0.1; use 0.0.0.0 for LAN) + --token string Bearer token required for non-loopback requests (or set VIBECOCKPIT_TOKEN) --list List all sessions (table format) --list --json List all sessions (JSON format) --version Print version diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 631ca36..078410a 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -2,6 +2,7 @@ package mcp import ( "bufio" + "bytes" "context" "encoding/json" "fmt" @@ -51,6 +52,32 @@ func NewServer(providers []provider.Provider, version, workspaceDir string, cfg return s } +// HandleHTTPRequest is the entry point for MCP-over-HTTP transport. +// It parses one JSON-RPC request, dispatches it through the same +// handle() the stdio path uses, and returns the JSON-encoded response +// (with the trailing newline from writeResult/writeError stripped). +// +// Returns nil bytes when the request is a JSON-RPC notification (no +// id field, or a notifications/* method) so the HTTP caller can reply +// with 204 No Content. Always returns nil error — protocol-level +// failures are encoded as JSON-RPC error responses. +func (s *Server) HandleHTTPRequest(body []byte) []byte { + var req jsonRPCRequest + if err := json.Unmarshal(body, &req); err != nil { + var buf bytes.Buffer + writeError(&buf, nil, -32700, "Parse error") + return bytes.TrimRight(buf.Bytes(), "\n") + } + var buf bytes.Buffer + s.handle(&buf, &req) + out := bytes.TrimRight(buf.Bytes(), "\n") + if len(out) == 0 { + // notification — no response per JSON-RPC 2.0 + return nil + } + return out +} + // Run starts the MCP server, reading JSON-RPC from stdin and writing to stdout. func (s *Server) Run() error { reader := bufio.NewReader(os.Stdin) diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..dcf79f9 --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,79 @@ +package web + +import ( + "crypto/subtle" + "net" + "net/http" + "strings" +) + +// isLoopbackBind reports whether the bind address points at the local +// machine. Used to decide whether --token is mandatory at startup. +// IPv6 ::1 and the wildcard form ::1/128 are normalized via net.ParseIP. +func isLoopbackBind(addr string) bool { + if addr == "" || addr == "localhost" { + return true + } + ip := net.ParseIP(addr) + if ip == nil { + return false + } + return ip.IsLoopback() +} + +// bearerAuthMiddleware enforces token auth for non-loopback requests. +// +// Loopback requests (127.0.0.1, ::1) bypass the check entirely so local +// dev, the auto-launched browser, and SSH tunnels keep working without +// having to pass the token around. When a request arrives over a real +// network and a token is configured, we require an exact-match +// Authorization header (constant-time compared) or a ?token= query +// parameter for browser-driven download flows like /api/memory/export. +// +// If token is empty AND the request isn't loopback we 401 — the +// startup check in Start() refuses to bind to a non-loopback address +// without a token, so this is defensive. +func bearerAuthMiddleware(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isLoopbackRequest(r) { + next.ServeHTTP(w, r) + return + } + if token == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if !validBearer(r, token) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func isLoopbackRequest(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return false + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func validBearer(r *http.Request, token string) bool { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + got := strings.TrimPrefix(h, "Bearer ") + if subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1 { + return true + } + } + // Query-string fallback for download flows (e.g. ). + // Browsers can't easily send custom Authorization headers from a + // plain link click, so this gives a workable escape hatch. + if got := r.URL.Query().Get("token"); got != "" { + if subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1 { + return true + } + } + return false +} diff --git a/internal/web/auth_test.go b/internal/web/auth_test.go new file mode 100644 index 0000000..a8e6b9d --- /dev/null +++ b/internal/web/auth_test.go @@ -0,0 +1,97 @@ +package web + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsLoopbackBind(t *testing.T) { + cases := map[string]bool{ + "": true, + "localhost": true, + "127.0.0.1": true, + "127.0.0.42": true, + "::1": true, + "0.0.0.0": false, + "192.168.1.5": false, + "10.0.0.1": false, + } + for in, want := range cases { + if got := isLoopbackBind(in); got != want { + t.Errorf("isLoopbackBind(%q) = %v; want %v", in, got, want) + } + } +} + +func newAuthRequest(remote string, header string, query string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/sessions"+query, nil) + r.RemoteAddr = remote + if header != "" { + r.Header.Set("Authorization", header) + } + return r +} + +func TestBearerMiddleware_LoopbackBypass(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(200) + }) + h := bearerAuthMiddleware("the-secret", next) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newAuthRequest("127.0.0.1:54321", "", "")) + if rec.Code != 200 || !called { + t.Errorf("loopback should bypass token; got code=%d called=%v", rec.Code, called) + } +} + +func TestBearerMiddleware_RemoteRequiresToken(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + h := bearerAuthMiddleware("the-secret", next) + + cases := []struct { + name string + header string + query string + want int + }{ + {"no header", "", "", 401}, + {"wrong scheme", "Basic abc", "", 401}, + {"wrong token", "Bearer wrong", "", 401}, + {"correct token", "Bearer the-secret", "", 200}, + {"query token correct", "", "?token=the-secret", 200}, + {"query token wrong", "", "?token=nope", 401}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newAuthRequest("10.0.0.1:54321", c.header, c.query)) + if rec.Code != c.want { + body, _ := io.ReadAll(rec.Body) + t.Errorf("got code=%d body=%q; want %d", rec.Code, string(body), c.want) + } + }) + } +} + +func TestBearerMiddleware_NoTokenConfigured_Remote401(t *testing.T) { + // Defensive case: even if Start() fails to refuse a non-loopback bind + // without a token, the middleware itself must still 401 remote + // requests rather than silently allowing them through. + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + h := bearerAuthMiddleware("", next) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newAuthRequest("10.0.0.1:54321", "", "")) + if rec.Code != 401 { + t.Errorf("expected 401 when no token configured + remote request; got %d", rec.Code) + } +} diff --git a/internal/web/mcp_http_test.go b/internal/web/mcp_http_test.go new file mode 100644 index 0000000..074563a --- /dev/null +++ b/internal/web/mcp_http_test.go @@ -0,0 +1,111 @@ +package web + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "vibecockpit/internal/config" + mcpserver "vibecockpit/internal/mcp" +) + +// TestMCPHTTP_ToolsListRoundTrip wires up just enough of the web server +// to exercise /mcp end-to-end: mux + handler + the real mcp.Server. We +// don't go through Start() because the full Start() sets up scheduler, +// chat, and a memory index — none of which the MCP transport actually +// needs. +func TestMCPHTTP_ToolsListRoundTrip(t *testing.T) { + cfg := &config.Config{EnableMCP: true} + mcp := mcpserver.NewServer(nil, "test", "/tmp", cfg, nil) + + s := &server{mcpServer: mcp} + mux := http.NewServeMux() + mux.HandleFunc("POST /mcp", s.handleMCPHTTP) + ts := httptest.NewServer(mux) + defer ts.Close() + + req := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) + resp, err := http.Post(ts.URL+"/mcp", "application/json", bytes.NewReader(req)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status=%d body=%s", resp.StatusCode, string(body)) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Result struct { + Tools []map[string]any `json:"tools"` + } `json:"result"` + ID int `json:"id"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("unmarshal: %v\n body: %s", err, string(body)) + } + if parsed.ID != 1 { + t.Errorf("response id=%d; want 1 (must echo request id)", parsed.ID) + } + if len(parsed.Result.Tools) == 0 { + t.Fatal("expected at least one tool in tools/list response") + } + // Sanity: the search_memory tool we added in v0.14.0 must be in the list. + var found bool + for _, tool := range parsed.Result.Tools { + if name, _ := tool["name"].(string); name == "search_memory" { + found = true + break + } + } + if !found { + t.Error("search_memory not in tools/list — toolset wiring is broken") + } +} + +func TestMCPHTTP_DisabledReturns503(t *testing.T) { + s := &server{mcpServer: nil} // MCP disabled in config + mux := http.NewServeMux() + mux.HandleFunc("POST /mcp", s.handleMCPHTTP) + ts := httptest.NewServer(mux) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/mcp", "application/json", bytes.NewReader([]byte(`{}`))) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 503 { + t.Errorf("status=%d; want 503 when mcpServer is nil", resp.StatusCode) + } +} + +func TestMCPHTTP_NotificationReturns204(t *testing.T) { + cfg := &config.Config{EnableMCP: true} + mcp := mcpserver.NewServer(nil, "test", "/tmp", cfg, nil) + + s := &server{mcpServer: mcp} + mux := http.NewServeMux() + mux.HandleFunc("POST /mcp", s.handleMCPHTTP) + ts := httptest.NewServer(mux) + defer ts.Close() + + // notifications/initialized has no id → MCP server writes nothing → + // HTTP layer should reply 204. + req := []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) + resp, err := http.Post(ts.URL+"/mcp", "application/json", bytes.NewReader(req)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 204 { + body, _ := io.ReadAll(resp.Body) + t.Errorf("status=%d body=%q; want 204", resp.StatusCode, string(body)) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 8598272..94d3f31 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -27,6 +27,7 @@ import ( "vibecockpit/internal/costs" "vibecockpit/internal/inventory" "vibecockpit/internal/launcher" + mcpserver "vibecockpit/internal/mcp" "vibecockpit/internal/memory" "vibecockpit/internal/provider" "vibecockpit/internal/scanner" @@ -60,6 +61,11 @@ type server struct { memIndex *memory.Index // nil if init failed; search endpoints will return empty memIndexer *memory.Indexer + mcpServer *mcpserver.Server // exposes MCP-over-HTTP at /mcp; nil when MCP is disabled in config + + bindAddr string // "127.0.0.1" or whatever the user passed via --bind + authToken string // bearer token; required for any non-loopback request + versionMu sync.Mutex latestVersion string latestFetched time.Time @@ -110,14 +116,34 @@ type providerInfo struct { Name string `json:"name"` } -func Start(cfg *config.Config, providers []provider.Provider, port int, version string) error { +// StartOpts is the configuration for the web server. Bundled into a +// struct so the call site doesn't keep growing positional args. +type StartOpts struct { + Port int + Bind string // "127.0.0.1" (default), "0.0.0.0", or a specific IP + Token string // bearer token; required when Bind is non-loopback + Version string +} + +func Start(cfg *config.Config, providers []provider.Provider, opts StartOpts) error { + if opts.Bind == "" { + opts.Bind = "127.0.0.1" + } + // Refuse to expose the UI on a non-loopback address without a token. + // Better to fail loud at startup than to ship an unauthenticated + // dashboard onto a LAN. + if !isLoopbackBind(opts.Bind) && opts.Token == "" { + return fmt.Errorf("--bind=%s requires --token (or VIBECOCKPIT_TOKEN env) — refusing to expose the web UI without auth", opts.Bind) + } isDemo := len(providers) == 1 && providers[0].Name() == "demo" sched := scheduler.New(cfg) s := &server{ - cfg: cfg, providers: providers, version: version, + cfg: cfg, providers: providers, version: opts.Version, cacheTTL: 10 * time.Second, inventoryTTL: 60 * time.Second, demoMode: isDemo, + bindAddr: opts.Bind, + authToken: opts.Token, auditLog: audit.NewLogger(), sessionCache: newScanCache(providers), scheduler: sched, @@ -155,6 +181,14 @@ func Start(cfg *config.Config, providers []provider.Provider, port int, version } } + // MCP-over-HTTP: when MCP is enabled in config, build a Server here + // and expose it at /mcp behind the auth middleware. The same Server + // implementation backs the stdio transport (vibecockpit --mcp), so + // the toolset is identical across transports. + if cfg.EnableMCP && !isDemo { + s.mcpServer = mcpserver.NewServer(providers, opts.Version, cfg.NewProjectDir, cfg, s.memIndex) + } + if cfg.Terminal == "default" || cfg.Terminal == "" { cfg.Terminal = detectTerminal() _ = cfg.Save() @@ -182,6 +216,7 @@ func Start(cfg *config.Config, providers []provider.Provider, port int, version mux.HandleFunc("GET /api/memory/tombstones", s.handleMemoryTombstones) mux.HandleFunc("POST /api/memory/untombstone", s.handleMemoryUntombstone) mux.HandleFunc("GET /api/mcp-audit", s.handleMCPAudit) + mux.HandleFunc("POST /mcp", s.handleMCPHTTP) mux.HandleFunc("GET /api/boards", s.handleGetBoards) mux.HandleFunc("GET /api/boards/{name}", s.handleGetBoard) mux.HandleFunc("POST /api/boards", s.handleCreateBoard) @@ -224,16 +259,16 @@ func Start(cfg *config.Config, providers []provider.Provider, port int, version sub, _ := fs.Sub(staticFiles, "static") mux.Handle("/", spaHandler(http.FS(sub))) - addr := fmt.Sprintf("127.0.0.1:%d", port) + addr := fmt.Sprintf("%s:%d", opts.Bind, opts.Port) listener, err := net.Listen("tcp", addr) if err != nil { if isAddrInUse(err) { - killed, killErr := killStaleVibecockpit(port) + killed, killErr := killStaleVibecockpit(opts.Port) if killErr != nil { - return fmt.Errorf("port %d in use and could not free it: %w", port, killErr) + return fmt.Errorf("port %d in use and could not free it: %w", opts.Port, killErr) } if killed { - fmt.Printf("Stopped previous vibecockpit instance on port %d\n", port) + fmt.Printf("Stopped previous vibecockpit instance on port %d\n", opts.Port) } listener, err = net.Listen("tcp", addr) if err != nil { @@ -247,10 +282,18 @@ func Start(cfg *config.Config, providers []provider.Provider, port int, version sched.Start() fmt.Printf("VibeCockpit web UI: http://%s\n", addr) + if opts.Token != "" { + fmt.Printf("Bearer token required for non-loopback requests (set in --token / VIBECOCKPIT_TOKEN)\n") + } fmt.Println("Press Ctrl+C to stop") - openBrowser(fmt.Sprintf("http://%s", addr)) + // Only auto-open the browser when bound to loopback — on a headless + // server that's bound to 0.0.0.0 there is no browser to open. + if isLoopbackBind(opts.Bind) { + openBrowser(fmt.Sprintf("http://%s", addr)) + } - return http.Serve(listener, mux) + handler := bearerAuthMiddleware(opts.Token, mux) + return http.Serve(listener, handler) } func isAddrInUse(err error) bool { @@ -1153,6 +1196,31 @@ func (s *server) handleMemoryUntombstone(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(map[string]any{"restored": id}) } +// handleMCPHTTP terminates the MCP "Streamable HTTP" transport. Each +// POST is one JSON-RPC request; the response body is the JSON-RPC +// response, or 204 No Content when the request is a notification. +// +// Tool dispatch goes through the same Server the stdio transport uses, +// so the toolset and audit log are identical across transports. +func (s *server) handleMCPHTTP(w http.ResponseWriter, r *http.Request) { + if s.mcpServer == nil { + http.Error(w, "MCP is disabled — set enable_mcp: true in config.yaml", http.StatusServiceUnavailable) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + resp := s.mcpServer.HandleHTTPRequest(body) + if len(resp) == 0 { + w.WriteHeader(http.StatusNoContent) // JSON-RPC notification + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(resp) +} + func (s *server) handleMCPAudit(w http.ResponseWriter, r *http.Request) { limit := 50 if l := r.URL.Query().Get("limit"); l != "" { diff --git a/main.go b/main.go index c66c95c..b1af112 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,8 @@ func main() { webFlag := flag.Bool("web", false, "start the web UI (opens in browser)") mcpFlag := flag.Bool("mcp", false, "start as MCP server (JSON-RPC over stdio)") portFlag := flag.Int("port", 3456, "port for the web UI") + bindFlag := flag.String("bind", "127.0.0.1", "address to bind the web UI to (use 0.0.0.0 for LAN, requires --token)") + tokenFlag := flag.String("token", "", "bearer token required for non-loopback requests (or set VIBECOCKPIT_TOKEN)") installFlag := flag.Bool("install", false, "install binary to ~/.local/bin and create desktop entry") uninstallFlag := flag.Bool("uninstall", false, "remove the installed binary, app launcher, and autostart service") autostartFlag := flag.Bool("autostart", false, "register as a login service (systemd/launchd)") @@ -115,7 +117,16 @@ func main() { } if *webFlag { - if err := web.Start(cfg, providers, *portFlag, version); err != nil { + token := *tokenFlag + if token == "" { + token = os.Getenv("VIBECOCKPIT_TOKEN") + } + if err := web.Start(cfg, providers, web.StartOpts{ + Port: *portFlag, + Bind: *bindFlag, + Token: token, + Version: version, + }); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) }