Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A lightweight, embeddable development dashboard for Go applications. Monitor log
- **HTTP Server**: Track incoming HTTP requests to your application
- **SQL Queries**: Monitor database queries with timing and arguments
- **On-Demand Capture**: Start/stop capturing through the dashboard UI with session or global modes
- **Agent Access (MCP)**: Expose captured events to AI coding agents over the Model Context Protocol (opt-in)
- **Multi-User Isolation**: Each user gets their own event storage with independent clearing
- **Low Overhead**: Designed to be lightweight; no events captured until you start a session
- **Easy to Integrate**: Embeds into your application with minimal configuration
Expand Down Expand Up @@ -299,6 +300,133 @@ dlog := devlog.NewWithOptions(devlog.Options{
})
```

## Agent Access (MCP)

devlog can expose its captured events to AI coding agents (Claude Code, Cursor, …) over the
[Model Context Protocol](https://modelcontextprotocol.io). An agent can then list requests,
inspect SQL queries and logs, fetch bodies, and control capture — giving it the same context a
developer reads in the dashboard.

This consists of two parts:

1. A read-only **Agent JSON API** mounted on the dashboard handler (under `/api/agent/v1/`).
2. The **`devlog` CLI** (`./cli` module), which exposes that API to an agent over MCP.

> The browser-relayed variant for **deployed** (stage/production) instances is a separate, planned
> phase. What is described here is the local-development path. See
> [`docs/design/agent-access.md`](docs/design/agent-access.md) and
> [`docs/design/agent-access-relay.md`](docs/design/agent-access-relay.md) for the full design.

### Enabling the API

The Agent JSON API is **off by default** — unlike the dashboard UI (which shows data to a human on
screen), the agent API is consumed by tools that may forward data to third-party LLM providers, so
it must be enabled deliberately. Enable it with `WithAgentAPI()`:

```go
mux.Handle("/_devlog/", http.StripPrefix("/_devlog", dlog.DashboardHandler("/_devlog",
dashboard.WithAgentAPI(),
)))
```

It inherits whatever authentication middleware you have placed in front of the dashboard.

#### Redaction

Sensitive header values are masked by default (`Authorization`, `Cookie`, `Set-Cookie`,
`WWW-Authenticate`, `Proxy-Authenticate`, `Proxy-Authorization`), with the key preserved so an agent
can tell a header is redacted rather than absent. Request/response bodies are never inlined in list
or detail responses — only metadata is — and are served (capped) by dedicated endpoints.

```go
dlog.DashboardHandler("/_devlog",
dashboard.WithAgentAPI(),
dashboard.WithAgentRedactedHeaders("X-Api-Key"), // mask additional headers
dashboard.WithAgentMaxBodyBytes(64*1024), // cap body bytes served (default: 64 KiB)
dashboard.WithAgentRedactor(func(d *dashboard.EventDetail) *dashboard.EventDetail {
// Mutate the detail (e.g. clear d.RequestBody.Available to suppress a body),
// or return nil to drop the event from the agent API entirely.
return d
}),
// dashboard.WithAgentInsecureHeaders(), // disable header masking (local dev only)
)
```

#### Endpoints

All responses are JSON; errors use `{"error": "..."}`. `{sid}` is the capture session id.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/agent/v1/s/{sid}/events` | Event summaries, newest first. Filters: `type`, `since`/`until` (RFC 3339), `limit`, `status` (e.g. `5xx`), `path` |
| GET | `/api/agent/v1/s/{sid}/events/{id}` | Full event detail incl. child events and body metadata |
| GET | `/api/agent/v1/s/{sid}/events/{id}/request-body` | Raw request body (redaction- and cap-aware) |
| GET | `/api/agent/v1/s/{sid}/events/{id}/response-body` | Raw response body (redaction- and cap-aware) |
| GET | `/api/agent/v1/s/{sid}/capture/status` | `{active, mode, eventCount}` (read-only; capture is started/stopped by the user in the dashboard) |
| GET | `/api/agent/v1/sessions` | List active capture sessions (for the relay to attach to) |
| GET | `/api/agent/v1/stats` | Memory and event statistics |

### The `devlog` CLI

The CLI lives in the `./cli` module and bridges the JSON API to an agent over MCP. In `--direct`
mode it talks to a local devlog instance over plain HTTP — no browser tunnel involved.

The relay does **not** create its own capture session — it **attaches to an existing one** so the
agent and your dashboard see the same events. Open the dashboard and start a capture first, then:

```bash
# from the repository root (workspace) or the ./cli module
go run ./cli relay --direct http://localhost:8080/_devlog --mcp-port 4319

# or build a binary
go build -C cli -o devlog .
./cli/devlog relay --direct http://localhost:8080/_devlog --mcp-port 4319
```

On startup the relay lists active sessions: if exactly one is active it attaches automatically; if
several are, it prompts you to choose; if none exist yet it waits until one appears. Use
`--session <sid>` (the id from the dashboard URL) to attach non-interactively. Because the dashboard
tab owns the session, no agent-side lifetime management is needed — when you close the dashboard the
session ends normally.

The relay binds to `127.0.0.1` only and serves an MCP endpoint at `http://127.0.0.1:<port>/mcp`. The
endpoint validates the `Host` header is a loopback literal to block DNS-rebinding from a browser.

MCP tools exposed (all read-only): `list_events`, `get_event`, `get_request_body`,
`get_response_body`, `capture_status`, `get_stats`, `list_environments`. The agent cannot start or
stop capture — you manage capture from the dashboard; the agent only piggy-backs on your session.

### Integrating with an agent

For an MCP client that supports streamable-HTTP servers (e.g. Claude Code), point it at the relay's
MCP URL. With Claude Code, add a project-scoped `.mcp.json`:

```json
{
"mcpServers": {
"devlog": {
"type": "http",
"url": "http://127.0.0.1:4319/mcp"
}
}
}
```

Keep the relay running while the agent is connected. A typical flow: you open the dashboard and start
capturing, the relay attaches to that session, you exercise the application, then the agent calls
`list_events` / `get_event` to inspect what happened. Capture is forward-looking — events are
captured from the moment capture starts in the dashboard.

### Security notes

- **Opt-in and auth-inherited.** The API is disabled unless `WithAgentAPI()` is set and is protected
by your existing dashboard authentication middleware.
- **Egress awareness.** Data returned to an agent may be sent to an LLM provider. Default header
redaction and the body size cap limit exposure; add a `WithAgentRedactor` for application-specific
scrubbing, and prefer leaving the API off where regulated data flows through.
- **Loopback only.** The CLI binds to `127.0.0.1` and rejects non-loopback `Host` headers on the MCP
endpoint.

## Development

### Running Acceptance Tests
Expand Down
199 changes: 199 additions & 0 deletions cli/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)

// directClient talks to a local devlog Agent JSON API over plain HTTP. It is
// attached to an existing capture session (owned by a dashboard browser tab); it
// never creates sessions of its own.
type directClient struct {
agentBase string // <base-url>/api/agent/v1
httpc *http.Client
sid string // attached session id
}

func newDirectClient(baseURL, sid string) *directClient {
return &directClient{
agentBase: agentBaseURL(baseURL),
httpc: defaultHTTPClient(),
sid: sid,
}
}

func agentBaseURL(baseURL string) string {
return strings.TrimRight(baseURL, "/") + "/api/agent/v1"
}

func defaultHTTPClient() *http.Client {
return &http.Client{Timeout: 30 * time.Second}
}

func (c *directClient) sessionURL(suffix string) string {
return c.agentBase + "/s/" + c.sid + suffix
}

func (c *directClient) listEvents(ctx context.Context, in listEventsInput) (json.RawMessage, error) {
q := url.Values{}
if in.Type != "" {
q.Set("type", in.Type)
}
if in.Since != "" {
q.Set("since", in.Since)
}
if in.Until != "" {
q.Set("until", in.Until)
}
if in.Status != "" {
q.Set("status", in.Status)
}
if in.Path != "" {
q.Set("path", in.Path)
}
if in.Limit > 0 {
q.Set("limit", strconv.Itoa(in.Limit))
}
u := c.sessionURL("/events")
if len(q) > 0 {
u += "?" + q.Encode()
}
return c.expectOK(c.getRaw(ctx, u))
}

func (c *directClient) getEvent(ctx context.Context, eventID string) (json.RawMessage, error) {
u := c.sessionURL("/events/" + url.PathEscape(eventID))
return c.expectOK(c.getRaw(ctx, u))
}

func (c *directClient) getStats(ctx context.Context) (json.RawMessage, error) {
return c.expectOK(c.getRaw(ctx, c.agentBase+"/stats"))
}

func (c *directClient) captureStatus(ctx context.Context) (json.RawMessage, error) {
return c.expectOK(c.getRaw(ctx, c.sessionURL("/capture/status")))
}

// bodyResult is a fetched request/response body plus its content type and whether
// it was truncated by the server's size cap.
type bodyResult struct {
data []byte
contentType string
truncated bool
}

// getBody fetches the raw request or response body (side is "request" or
// "response"). Non-200 responses (404 missing, 410 redacted, 400 wrong type)
// become errors carrying the server's message.
func (c *directClient) getBody(ctx context.Context, eventID, side string) (*bodyResult, error) {
u := c.sessionURL("/events/" + url.PathEscape(eventID) + "/" + side + "-body")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := c.httpc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, apiError(resp.StatusCode, data)
}
return &bodyResult{
data: data,
contentType: resp.Header.Get("Content-Type"),
truncated: resp.Header.Get("X-Devlog-Truncated") == "true",
}, nil
}

func (c *directClient) getRaw(ctx context.Context, u string) (json.RawMessage, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, 0, err
}
resp, err := c.httpc.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return json.RawMessage(data), resp.StatusCode, nil
}

func (c *directClient) expectOK(raw json.RawMessage, status int, err error) (json.RawMessage, error) {
if err != nil {
return nil, err
}
if status == http.StatusNotFound && isNoSessionError(raw) {
return nil, fmt.Errorf("the devlog session %s is no longer active (dashboard closed?); reconnect the relay", c.sid)
}
if status != http.StatusOK {
return nil, apiError(status, raw)
}
return raw, nil
}

func isNoSessionError(raw json.RawMessage) bool {
var e struct {
Error string `json:"error"`
}
return json.Unmarshal(raw, &e) == nil && strings.Contains(e.Error, "no capture session")
}

func apiError(status int, raw json.RawMessage) error {
var e struct {
Error string `json:"error"`
}
if json.Unmarshal(raw, &e) == nil && e.Error != "" {
return fmt.Errorf("agent API error (%d): %s", status, e.Error)
}
return fmt.Errorf("agent API error: status %d", status)
}

// sessionInfo mirrors one entry of GET /api/agent/v1/sessions.
type sessionInfo struct {
SessionID string `json:"sessionId"`
Mode string `json:"mode"`
Capturing bool `json:"capturing"`
EventCount int `json:"eventCount"`
LastActiveMsAgo int64 `json:"lastActiveMsAgo"`
}

// fetchSessions lists the active capture sessions of the devlog instance at baseURL.
func fetchSessions(ctx context.Context, baseURL string, httpc *http.Client) ([]sessionInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, agentBaseURL(baseURL)+"/sessions", nil)
if err != nil {
return nil, err
}
resp, err := httpc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, apiError(resp.StatusCode, data)
}
var sessions []sessionInfo
if err := json.Unmarshal(data, &sessions); err != nil {
return nil, fmt.Errorf("decoding sessions: %w", err)
}
return sessions, nil
}
Loading
Loading