diff --git a/README.md b/README.md index 5640eb5..8806a9e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +

showagent

showagent — every AI coding session on your machine, in one TUI.
@@ -85,6 +86,7 @@ showagent resume latest # reopen the most recent session, any agent showagent convert latest --to claude --dry-run # preview exactly what a hand-off would carry/drop showagent info latest # exact resume command + storage location +showagent mcp # serve session history to MCP-capable agents (stdio) showagent update # install the latest GitHub release showagent --help # full CLI help ``` @@ -145,6 +147,41 @@ Exit codes: `0` success, `1` error (including "no sessions found"), `2` usage. When stdout is not a terminal, plain `showagent` prints the `list` table, so pipes just work. +## Use it from inside your agent (MCP) + +`showagent mcp` runs a stdio MCP server, so the agent you are talking to can +search every past coding session on your machine — from **any** agent — and +pull one in as context or convert it to continue right there. Ask Claude Code +"have I solved this rate-limit bug before?" and it can find the Codex session +where you did, read the transcript, and hand you the command to resume it — +or rewrite it as a native Claude Code session and keep going. Your session +history stops being per-tool memory and becomes shared memory. + +```sh +# Claude Code +claude mcp add showagent -- showagent mcp + +# Codex (~/.codex/config.toml) +[mcp_servers.showagent] +command = "showagent" +args = ["mcp"] +``` + +Tools: + +| Tool | What it does | +|---|---| +| `list_sessions` | Search sessions across all agents — filter by provider, workspace substring, or free text over workspace + first/last user message (default 25, max 100 results) | +| `get_transcript` | Read a session's user/assistant turns; `max_turns` keeps the most recent N (default 50) so long sessions don't flood context | +| `branch_session` | Fork a full local copy of a session, same agent; returns the new id, file, and resume command | +| `convert_session` | Rewrite a session into another agent's native format; returns the new id, file, and resume command | +| `resume_command` | The exact shell command (and cwd) that resumes a session — returned as a string, **never executed** | + +The MCP surface is deliberately non-destructive: there is **no delete tool**, +and the server never executes an agent CLI. Deleting sessions stays exclusive +to the TUI, where it takes two key presses with a human watching. Branch and +convert only ever write new files — originals are never modified. + ## How it compares Great tools exist for *running* agents in parallel — showagent is about the diff --git a/cmd/showagent/main.go b/cmd/showagent/main.go index 11c8fcc..e597233 100644 --- a/cmd/showagent/main.go +++ b/cmd/showagent/main.go @@ -1,10 +1,12 @@ package main import ( + "context" "encoding/json" "fmt" "io" "os" + "os/signal" "runtime/debug" "strconv" "strings" @@ -12,6 +14,7 @@ import ( "github.com/charmbracelet/x/term" + "github.com/aytzey/showagent/internal/mcpserver" "github.com/aytzey/showagent/internal/session" "github.com/aytzey/showagent/internal/tui" ) @@ -19,7 +22,7 @@ import ( // version is stamped by the release build via -ldflags "-X main.version=...". var version = "dev" -const usageLine = "usage: showagent [list [--json] | resume [--yolo] | convert --to [--dry-run] | info | update | setup]" +const usageLine = "usage: showagent [list [--json] | resume [--yolo] | convert --to [--dry-run] | info | mcp | update | setup]" func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) @@ -47,6 +50,8 @@ func run(args []string, stdout, stderr io.Writer) int { return runConvert(args[1:], stdout, stderr) case "info": return runInfo(args[1:], stdout, stderr) + case "mcp": + return runMCP(args[1:], stderr) case "update": return runUpdate(args[1:], stdout, stderr) case "setup": @@ -318,6 +323,22 @@ func resolveSession(rows []session.Row, id string) (session.Row, error) { return session.Row{}, fmt.Errorf("session %q not found; run 'showagent list' to see session ids", id) } +// runMCP serves the session store to MCP clients over stdio until the client +// disconnects or the process is interrupted. It deliberately offers no delete +// tool and never executes an agent CLI; see internal/mcpserver. +func runMCP(args []string, stderr io.Writer) int { + if len(args) != 0 { + return usageError(stderr, fmt.Sprintf("mcp takes no arguments, got %q", args[0])) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := mcpserver.Run(ctx, versionString()); err != nil { + _, _ = fmt.Fprintf(stderr, "showagent: %v\n", err) + return 1 + } + return 0 +} + func runSetup(stdout, stderr io.Writer) int { results, err := session.EnsureCompoundEngineeringPlugin() if err != nil { @@ -412,6 +433,8 @@ Usage: preview or write a native session for another agent showagent info [--yolo] print the exact resume command and storage location + showagent mcp serve session history to MCP clients over stdio + (search, transcripts, branch, convert; no delete) showagent update install the latest GitHub release showagent setup install the compound-engineering plugin for supported agents diff --git a/cmd/showagent/main_test.go b/cmd/showagent/main_test.go index 7170909..08139f6 100644 --- a/cmd/showagent/main_test.go +++ b/cmd/showagent/main_test.go @@ -67,7 +67,7 @@ func TestHelpFlag(t *testing.T) { if code != 0 { t.Fatalf("%s exit = %d, want 0", flag, code) } - for _, want := range []string{"Usage:", "list", "resume", "convert", "info", "update", "setup", "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "--yolo", "--json", "--dry-run"} { + for _, want := range []string{"Usage:", "list", "resume", "convert", "info", "mcp", "update", "setup", "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "--yolo", "--json", "--dry-run"} { if !strings.Contains(stdout, want) { t.Fatalf("%s output missing %q:\n%s", flag, want, stdout) } @@ -114,6 +114,16 @@ func TestUnknownArgumentExitsTwo(t *testing.T) { } } +func TestMCPRejectsArguments(t *testing.T) { + code, _, stderr := runCLI(t, "mcp", "--http") + if code != 2 { + t.Fatalf("exit = %d, want 2", code) + } + if !strings.Contains(stderr, "mcp takes no arguments") { + t.Fatalf("stderr missing mcp usage hint:\n%s", stderr) + } +} + func TestUnknownListFlagExitsTwo(t *testing.T) { code, _, stderr := runCLI(t, "list", "--nope") if code != 2 { diff --git a/go.mod b/go.mod index 699237b..1771f26 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.5 github.com/charmbracelet/x/term v0.2.2 + github.com/modelcontextprotocol/go-sdk v1.6.1 golang.org/x/sys v0.47.0 ) @@ -19,11 +20,16 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.3 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index ddba7ba..47c7ea2 100644 --- a/go.sum +++ b/go.sum @@ -26,23 +26,41 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..4bc99c9 --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,350 @@ +// Package mcpserver exposes showagent's session store as an MCP server over +// stdio, so any MCP-capable agent (Claude Code, Codex, ...) can search the +// user's coding-session history across every supported agent and pull a past +// conversation in as context. +// +// The server is read-mostly by design: branch_session and convert_session +// write brand-new session files through the same atomic paths the TUI uses, +// and nothing here ever modifies or deletes an existing session. Deletion is +// deliberately not exposed as a tool — it stays behind the TUI's two-press +// confirmation, where a human is watching. Nothing here executes an agent CLI +// either: resume commands are returned as strings for the caller to run. +package mcpserver + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/aytzey/showagent/internal/session" +) + +const ( + // defaultListLimit bounds list_sessions responses when the caller does not + // pick a limit; maxListLimit bounds them when it does. Session previews are + // short, so 100 summaries stay well under a model's context budget. + defaultListLimit = 25 + maxListLimit = 100 + + // defaultTranscriptTurns bounds get_transcript responses so one old + // session cannot blow the calling agent's context window. Callers that + // want more ask for more explicitly. + defaultTranscriptTurns = 50 +) + +// New builds the showagent MCP server with every tool registered. The version +// string is reported to clients during the MCP handshake. +func New(version string) *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{ + Name: "showagent", + Title: "showagent session history", + Version: version, + }, nil) + + falseHint := false + readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &falseHint} + // branch/convert only ever add new files; originals are never touched. + additive := &mcp.ToolAnnotations{DestructiveHint: &falseHint, OpenWorldHint: &falseHint} + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_sessions", + Description: "List local AI coding-agent sessions (Codex, Claude Code, Gemini CLI, OpenCode, jcode) newest first. " + + "Filter by provider, workspace path substring, or free-text query over workspace and first/last user message. " + + "Use this to find past work on a topic before pulling a session in with get_transcript or convert_session.", + Annotations: readOnly, + }, listSessions) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_transcript", + Description: "Read the user/assistant turns of one session, most recent last. " + + "Returns at most max_turns turns counted from the end (default 50) so a long session cannot flood your context; " + + "total_turns and truncated tell you when older turns were dropped.", + Annotations: readOnly, + }, getTranscript) + + mcp.AddTool(server, &mcp.Tool{ + Name: "branch_session", + Description: "Fork a session: write a full local copy as a new session of the same agent, leaving the original untouched. " + + "Returns the new session id, its file, and the exact shell command that resumes the copy (never executed by this server).", + Annotations: additive, + }, branchSession) + + mcp.AddTool(server, &mcp.Tool{ + Name: "convert_session", + Description: "Rewrite a session into another agent's native format so that agent can resume it — e.g. continue a Codex conversation in Claude Code. " + + "Writes a brand-new session (the original is never modified) and returns its id, file, and resume command. " + + "Nothing is executed; run the returned command yourself to continue there.", + Annotations: additive, + }, convertSession) + + mcp.AddTool(server, &mcp.Tool{ + Name: "resume_command", + Description: "Get the exact shell command (and the directory to run it from) that reopens a session in its own agent CLI. " + + "This tool only returns the command string; it never executes anything.", + Annotations: readOnly, + }, resumeCommand) + + // Deliberately NOT registered: a delete tool. Deleting sessions is + // destructive and irreversible, so it stays exclusive to the TUI's + // two-press confirmation flow (and the provider CLIs themselves). + + return server +} + +// Run serves the showagent MCP server over stdio until the client disconnects +// or ctx is canceled. +func Run(ctx context.Context, version string) error { + if err := New(version).Run(ctx, &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("mcp server: %w", err) + } + return nil +} + +type listSessionsArgs struct { + Provider string `json:"provider,omitempty" jsonschema:"only return sessions of this agent; one of the provider ids reported in results (codex, claude, gemini, opencode, jcode)"` + Workspace string `json:"workspace,omitempty" jsonschema:"case-insensitive substring the session's workspace path must contain"` + Query string `json:"query,omitempty" jsonschema:"case-insensitive free text matched against the workspace path and the first/last user message"` + Limit int `json:"limit,omitempty" jsonschema:"maximum sessions to return, newest first (default 25, max 100)"` +} + +type sessionSummary struct { + ID string `json:"id" jsonschema:"session id; pass it to the other showagent tools"` + Provider string `json:"provider" jsonschema:"agent that owns the session"` + Workspace string `json:"workspace" jsonschema:"directory the session worked in"` + Updated string `json:"updated,omitempty" jsonschema:"RFC3339 time of the last activity"` + FirstMessage string `json:"first_message,omitempty" jsonschema:"first user message preview"` + LastMessage string `json:"last_message,omitempty" jsonschema:"most recent user message preview"` +} + +type listSessionsResult struct { + Sessions []sessionSummary `json:"sessions"` + Total int `json:"total" jsonschema:"sessions that matched the filters before the limit was applied"` +} + +func listSessions(_ context.Context, _ *mcp.CallToolRequest, args listSessionsArgs) (*mcp.CallToolResult, listSessionsResult, error) { + var provider session.Provider + if args.Provider != "" { + parsed, err := session.ParseProvider(args.Provider) + if err != nil { + return nil, listSessionsResult{}, err + } + provider = parsed + } + + limit := args.Limit + switch { + case limit <= 0: + limit = defaultListLimit + case limit > maxListLimit: + limit = maxListLimit + } + + result := listSessionsResult{Sessions: []sessionSummary{}} + for _, row := range session.Discover() { + if !matchesFilters(row, provider, args.Workspace, args.Query) { + continue + } + result.Total++ + if len(result.Sessions) < limit { + result.Sessions = append(result.Sessions, summarize(row)) + } + } + return nil, result, nil +} + +// matchesFilters reports whether row passes the list_sessions filters: +// provider is an exact match, workspace a substring of the workspace path, +// and query a substring of the workspace or the first/last user message. +func matchesFilters(row session.Row, provider session.Provider, workspace, query string) bool { + if provider != "" && row.Provider != provider { + return false + } + if workspace != "" && !containsFold(row.CWD, workspace) { + return false + } + if query != "" && !containsFold(row.CWD, query) && + !containsFold(row.FirstUser, query) && !containsFold(row.LastUser, query) { + return false + } + return true +} + +func containsFold(haystack, needle string) bool { + return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) +} + +func summarize(row session.Row) sessionSummary { + updated := "" + if !row.LastAt.IsZero() { + updated = row.LastAt.Format(time.RFC3339) + } + return sessionSummary{ + ID: row.ID, + Provider: string(row.Provider), + Workspace: row.CWD, + Updated: updated, + FirstMessage: row.FirstUser, + LastMessage: row.LastUser, + } +} + +type getTranscriptArgs struct { + ID string `json:"id" jsonschema:"session id from list_sessions, or 'latest' for the most recent session"` + MaxTurns int `json:"max_turns,omitempty" jsonschema:"return at most this many turns counted from the end, i.e. the most recent ones (default 50)"` +} + +type transcriptTurn struct { + Role string `json:"role" jsonschema:"user or assistant"` + Text string `json:"text"` +} + +type transcriptResult struct { + ID string `json:"id"` + Provider string `json:"provider"` + Workspace string `json:"workspace,omitempty"` + TotalTurns int `json:"total_turns" jsonschema:"turns in the full transcript, before truncation"` + Truncated bool `json:"truncated" jsonschema:"true when older turns were dropped to honor max_turns"` + Turns []transcriptTurn `json:"turns" jsonschema:"user/assistant turns in order, most recent last"` +} + +func getTranscript(_ context.Context, _ *mcp.CallToolRequest, args getTranscriptArgs) (*mcp.CallToolResult, transcriptResult, error) { + row, err := resolveRow(args.ID) + if err != nil { + return nil, transcriptResult{}, err + } + turns, err := session.Transcript(row) + if err != nil { + return nil, transcriptResult{}, fmt.Errorf("read transcript of %s session %s: %w", row.Provider, row.ID, err) + } + + maxTurns := args.MaxTurns + if maxTurns <= 0 { + maxTurns = defaultTranscriptTurns + } + result := transcriptResult{ + ID: row.ID, + Provider: string(row.Provider), + Workspace: row.CWD, + TotalTurns: len(turns), + } + if len(turns) > maxTurns { + turns = turns[len(turns)-maxTurns:] + result.Truncated = true + } + result.Turns = make([]transcriptTurn, 0, len(turns)) + for _, turn := range turns { + result.Turns = append(result.Turns, transcriptTurn{Role: turn.Role, Text: turn.Text}) + } + return nil, result, nil +} + +type sessionIDArgs struct { + ID string `json:"id" jsonschema:"session id from list_sessions, or 'latest' for the most recent session"` +} + +// newSessionResult describes a session file written by branch_session or +// convert_session, together with the command that resumes it. +type newSessionResult struct { + ID string `json:"id" jsonschema:"id of the newly written session"` + Provider string `json:"provider" jsonschema:"agent that can resume the new session"` + File string `json:"file" jsonschema:"where the new session was written (a storage description for database-backed agents)"` + ResumeCommand string `json:"resume_command" jsonschema:"exact shell command that resumes the new session; showagent never runs it for you"` + CWD string `json:"cwd,omitempty" jsonschema:"directory to run the resume command from"` + Note string `json:"note,omitempty"` +} + +func branchSession(_ context.Context, _ *mcp.CallToolRequest, args sessionIDArgs) (*mcp.CallToolResult, newSessionResult, error) { + row, err := resolveRow(args.ID) + if err != nil { + return nil, newSessionResult{}, err + } + branched, err := session.Branch(row) + if err != nil { + return nil, newSessionResult{}, fmt.Errorf("branch %s session %s: %w", row.Provider, row.ID, err) + } + return nil, describeNewSession(branched), nil +} + +type convertSessionArgs struct { + ID string `json:"id" jsonschema:"session id from list_sessions, or 'latest' for the most recent session"` + Target string `json:"target" jsonschema:"provider to convert the session to: codex, claude, gemini, opencode, or jcode"` +} + +func convertSession(_ context.Context, _ *mcp.CallToolRequest, args convertSessionArgs) (*mcp.CallToolResult, newSessionResult, error) { + target, err := session.ParseProvider(args.Target) + if err != nil { + return nil, newSessionResult{}, err + } + row, err := resolveRow(args.ID) + if err != nil { + return nil, newSessionResult{}, err + } + converted, err := session.Convert(row, target, session.HandoffOptions{}) + if err != nil { + return nil, newSessionResult{}, fmt.Errorf("convert %s session %s to %s: %w", row.Provider, row.ID, target, err) + } + return nil, describeNewSession(converted), nil +} + +func describeNewSession(row session.Row) newSessionResult { + recipe := session.RecipeFor(row, session.ResumeOptions{}) + return newSessionResult{ + ID: row.ID, + Provider: string(row.Provider), + File: recipe.StorageLocation, + ResumeCommand: recipe.CommandString, + CWD: recipe.CWD, + Note: recipe.Note, + } +} + +type resumeCommandResult struct { + ID string `json:"id"` + Provider string `json:"provider"` + Command string `json:"command" jsonschema:"exact shell command that resumes the session; run it in a terminal, showagent never executes it"` + CWD string `json:"cwd,omitempty" jsonschema:"directory to run the command from"` + File string `json:"file,omitempty" jsonschema:"where the session is stored"` + Note string `json:"note,omitempty"` +} + +func resumeCommand(_ context.Context, _ *mcp.CallToolRequest, args sessionIDArgs) (*mcp.CallToolResult, resumeCommandResult, error) { + row, err := resolveRow(args.ID) + if err != nil { + return nil, resumeCommandResult{}, err + } + recipe := session.RecipeFor(row, session.ResumeOptions{}) + if recipe.CommandString == "" { + return nil, resumeCommandResult{}, fmt.Errorf("no resume command for provider %q", row.Provider) + } + return nil, resumeCommandResult{ + ID: row.ID, + Provider: string(row.Provider), + Command: recipe.CommandString, + CWD: recipe.CWD, + File: recipe.StorageLocation, + Note: recipe.Note, + }, nil +} + +// resolveRow maps a tool-supplied id (or the literal "latest") to a discovered +// session. Discovery runs fresh on every call so new sessions show up without +// restarting the server. +func resolveRow(id string) (session.Row, error) { + rows := session.Discover() + if len(rows) == 0 { + return session.Row{}, errors.New("no local agent sessions found") + } + if id == "latest" { + return rows[0], nil + } + for _, row := range rows { + if row.ID == id { + return row, nil + } + } + return session.Row{}, fmt.Errorf("session %q not found; call list_sessions to see current ids", id) +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..081e92c --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,434 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/aytzey/showagent/internal/session" +) + +const ( + codexRateLimitID = "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb" + codexDarkModeID = "bbbbbbbb-2222-3333-4444-cccccccccccc" + claudeWebhookID = "cccccccc-1111-2222-3333-dddddddddddd" +) + +// setFixtureHomes points every provider home at hermetic directories holding +// two codex sessions and one claude session with distinct workspaces and +// message texts, so filter tests can tell them apart. +func setFixtureHomes(t *testing.T) (codexHome, claudeHome string) { + t.Helper() + root := t.TempDir() + codexHome = filepath.Join(root, "codex") + claudeHome = filepath.Join(root, "claude") + t.Setenv("CODEX_HOME", codexHome) + t.Setenv("CLAUDE_HOME", claudeHome) + t.Setenv("JCODE_HOME", filepath.Join(root, "empty-jcode")) + t.Setenv("OPENCODE_DATA_HOME", filepath.Join(root, "empty-opencode")) + t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + + writeFile(t, filepath.Join(codexHome, "sessions", "2026", "06", "01", "rollout-2026-06-01T12-00-00-"+codexRateLimitID+".jsonl"), ` +{"timestamp":"2026-06-01T09:00:00Z","type":"session_meta","payload":{"id":"`+codexRateLimitID+`","cwd":"/work/api-server"}} +{"timestamp":"2026-06-01T09:01:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"add rate limiting to the charges endpoint"}]}} +{"timestamp":"2026-06-01T09:02:00Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"added a token bucket"}]}} +{"timestamp":"2026-06-01T09:03:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"return Retry-After on 429"}]}} +{"timestamp":"2026-06-01T09:04:00Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done, header added"}]}} +{"timestamp":"2026-06-01T09:05:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"now mock the clock in the TTL test"}]}} +`) + + writeFile(t, filepath.Join(codexHome, "sessions", "2026", "06", "02", "rollout-2026-06-02T12-00-00-"+codexDarkModeID+".jsonl"), ` +{"timestamp":"2026-06-02T09:00:00Z","type":"session_meta","payload":{"id":"`+codexDarkModeID+`","cwd":"/work/webapp"}} +{"timestamp":"2026-06-02T09:01:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"add dark mode toggle"}]}} +{"timestamp":"2026-06-02T09:02:00Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"toggle shipped"}]}} +`) + + writeFile(t, filepath.Join(claudeHome, "projects", "-work-api-server", claudeWebhookID+".jsonl"), ` +{"type":"user","message":{"role":"user","content":"fix the webhook idempotency bug"},"uuid":"1","timestamp":"2026-06-03T10:00:00Z","cwd":"/work/api-server","sessionId":"`+claudeWebhookID+`"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"keyed on the provider event id"}]},"uuid":"2","timestamp":"2026-06-03T10:01:00Z","cwd":"/work/api-server","sessionId":"`+claudeWebhookID+`"} +{"type":"user","message":{"role":"user","content":"log dropped duplicates"},"uuid":"3","timestamp":"2026-06-03T10:02:00Z","cwd":"/work/api-server","sessionId":"`+claudeWebhookID+`"} +`) + return codexHome, claudeHome +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// newTestSession connects an in-memory MCP client to the showagent server and +// returns the client session, so tests exercise the full tool dispatch path +// (schema validation included) rather than the handlers in isolation. +func newTestSession(t *testing.T) *mcp.ClientSession { + t.Helper() + ctx := context.Background() + clientTransport, serverTransport := mcp.NewInMemoryTransports() + + serverSession, err := New("test").Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("connect server: %v", err) + } + t.Cleanup(func() { _ = serverSession.Wait() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "showagent-test", Version: "test"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("connect client: %v", err) + } + t.Cleanup(func() { _ = clientSession.Close() }) + return clientSession +} + +// callTool invokes name with args and decodes the structured output into T. +func callTool[T any](t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) T { + t.Helper() + result, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("call %s: %v", name, err) + } + if result.IsError { + t.Fatalf("call %s returned tool error: %s", name, resultText(result)) + } + raw, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatalf("marshal %s output: %v", name, err) + } + var out T + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal %s output %s: %v", name, raw, err) + } + return out +} + +// callToolExpectError invokes name with args and returns the tool error text. +func callToolExpectError(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string { + t.Helper() + result, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("call %s: %v", name, err) + } + if !result.IsError { + t.Fatalf("call %s succeeded, want tool error", name) + } + return resultText(result) +} + +func resultText(result *mcp.CallToolResult) string { + var parts []string + for _, content := range result.Content { + if text, ok := content.(*mcp.TextContent); ok { + parts = append(parts, text.Text) + } + } + return strings.Join(parts, "\n") +} + +func sessionIDs(result listSessionsResult) []string { + ids := make([]string, 0, len(result.Sessions)) + for _, s := range result.Sessions { + ids = append(ids, s.ID) + } + return ids +} + +func TestListSessionsReturnsAllNewestFirst(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + result := callTool[listSessionsResult](t, cs, "list_sessions", nil) + if result.Total != 3 || len(result.Sessions) != 3 { + t.Fatalf("total = %d, sessions = %d, want 3/3: %v", result.Total, len(result.Sessions), sessionIDs(result)) + } + want := []string{claudeWebhookID, codexDarkModeID, codexRateLimitID} + if got := sessionIDs(result); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("ids = %v, want newest first %v", got, want) + } + first := result.Sessions[0] + if first.Provider != "claude" || first.Workspace != "/work/api-server" { + t.Fatalf("unexpected first session: %#v", first) + } + if first.Updated != "2026-06-03T10:02:00Z" { + t.Fatalf("updated = %q, want RFC3339 last-activity time", first.Updated) + } + if first.FirstMessage != "fix the webhook idempotency bug" || first.LastMessage != "log dropped duplicates" { + t.Fatalf("unexpected previews: %#v", first) + } +} + +func TestListSessionsFilters(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + byProvider := callTool[listSessionsResult](t, cs, "list_sessions", map[string]any{"provider": "claude"}) + if len(byProvider.Sessions) != 1 || byProvider.Sessions[0].ID != claudeWebhookID { + t.Fatalf("provider filter = %v, want only claude session", sessionIDs(byProvider)) + } + + byWorkspace := callTool[listSessionsResult](t, cs, "list_sessions", map[string]any{"workspace": "API-SERVER"}) + if len(byWorkspace.Sessions) != 2 { + t.Fatalf("workspace filter = %v, want both api-server sessions", sessionIDs(byWorkspace)) + } + + // The free-text query matches first/last user messages, not just paths. + byQuery := callTool[listSessionsResult](t, cs, "list_sessions", map[string]any{"query": "Rate Limiting"}) + if len(byQuery.Sessions) != 1 || byQuery.Sessions[0].ID != codexRateLimitID { + t.Fatalf("query filter = %v, want only the rate-limit session", sessionIDs(byQuery)) + } + byLastMessage := callTool[listSessionsResult](t, cs, "list_sessions", map[string]any{"query": "mock the clock"}) + if len(byLastMessage.Sessions) != 1 || byLastMessage.Sessions[0].ID != codexRateLimitID { + t.Fatalf("last-message query = %v, want only the rate-limit session", sessionIDs(byLastMessage)) + } + + combined := callTool[listSessionsResult](t, cs, "list_sessions", map[string]any{"provider": "codex", "workspace": "api-server"}) + if len(combined.Sessions) != 1 || combined.Sessions[0].ID != codexRateLimitID { + t.Fatalf("combined filter = %v, want only the codex api-server session", sessionIDs(combined)) + } + + none := callTool[listSessionsResult](t, cs, "list_sessions", map[string]any{"query": "no such topic anywhere"}) + if none.Total != 0 || len(none.Sessions) != 0 { + t.Fatalf("no-match query = %v (total %d), want empty", sessionIDs(none), none.Total) + } +} + +func TestListSessionsLimitKeepsTotal(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + result := callTool[listSessionsResult](t, cs, "list_sessions", map[string]any{"limit": 1}) + if len(result.Sessions) != 1 || result.Sessions[0].ID != claudeWebhookID { + t.Fatalf("limited sessions = %v, want just the newest", sessionIDs(result)) + } + if result.Total != 3 { + t.Fatalf("total = %d, want 3 so callers know more matched", result.Total) + } +} + +func TestListSessionsRejectsUnknownProvider(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + text := callToolExpectError(t, cs, "list_sessions", map[string]any{"provider": "cursor"}) + if !strings.Contains(text, "unsupported provider") || !strings.Contains(text, "codex") { + t.Fatalf("error %q should name the supported providers", text) + } +} + +func TestGetTranscriptTruncatesFromTheEnd(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + full := callTool[transcriptResult](t, cs, "get_transcript", map[string]any{"id": codexRateLimitID}) + if full.TotalTurns != 5 || len(full.Turns) != 5 || full.Truncated { + t.Fatalf("full transcript = %d/%d truncated=%v, want 5/5 untruncated", len(full.Turns), full.TotalTurns, full.Truncated) + } + if full.Provider != "codex" || full.Workspace != "/work/api-server" { + t.Fatalf("unexpected transcript metadata: %#v", full) + } + if full.Turns[0].Role != "user" || full.Turns[0].Text != "add rate limiting to the charges endpoint" { + t.Fatalf("unexpected first turn: %#v", full.Turns[0]) + } + + tail := callTool[transcriptResult](t, cs, "get_transcript", map[string]any{"id": codexRateLimitID, "max_turns": 2}) + if tail.TotalTurns != 5 || !tail.Truncated || len(tail.Turns) != 2 { + t.Fatalf("tail transcript = %d/%d truncated=%v, want the last 2 of 5", len(tail.Turns), tail.TotalTurns, tail.Truncated) + } + if tail.Turns[0] != (transcriptTurn{Role: "assistant", Text: "done, header added"}) || + tail.Turns[1] != (transcriptTurn{Role: "user", Text: "now mock the clock in the TTL test"}) { + t.Fatalf("tail must keep the most recent turns in order: %#v", tail.Turns) + } + + latest := callTool[transcriptResult](t, cs, "get_transcript", map[string]any{"id": "latest"}) + if latest.ID != claudeWebhookID { + t.Fatalf("latest transcript id = %q, want newest session %q", latest.ID, claudeWebhookID) + } +} + +func TestGetTranscriptUnknownID(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + text := callToolExpectError(t, cs, "get_transcript", map[string]any{"id": "nope"}) + if !strings.Contains(text, "not found") || !strings.Contains(text, "list_sessions") { + t.Fatalf("error %q should point the caller at list_sessions", text) + } +} + +func TestBranchSessionWritesCopy(t *testing.T) { + codexHome, _ := setFixtureHomes(t) + cs := newTestSession(t) + + branched := callTool[newSessionResult](t, cs, "branch_session", map[string]any{"id": codexRateLimitID}) + if branched.Provider != "codex" { + t.Fatalf("branched provider = %q, want codex", branched.Provider) + } + if branched.ID == codexRateLimitID || branched.ID == "" { + t.Fatalf("branched id = %q, want a fresh id", branched.ID) + } + if !strings.HasPrefix(branched.File, codexHome) { + t.Fatalf("branched file = %q, want under %q", branched.File, codexHome) + } + if _, err := os.Stat(branched.File); err != nil { + t.Fatalf("branched file missing: %v", err) + } + if branched.ResumeCommand != "codex resume "+branched.ID { + t.Fatalf("resume command = %q", branched.ResumeCommand) + } + if branched.CWD != "/work/api-server" { + t.Fatalf("branched cwd = %q, want the source workspace", branched.CWD) + } + + // The copy carries the full transcript and the original is untouched. + copyTranscript := callTool[transcriptResult](t, cs, "get_transcript", map[string]any{"id": branched.ID}) + if copyTranscript.TotalTurns != 5 { + t.Fatalf("branched transcript turns = %d, want 5", copyTranscript.TotalTurns) + } + original := callTool[transcriptResult](t, cs, "get_transcript", map[string]any{"id": codexRateLimitID}) + if original.TotalTurns != 5 { + t.Fatalf("original transcript turns = %d, want 5", original.TotalTurns) + } +} + +func TestConvertSessionWritesTargetFormat(t *testing.T) { + _, claudeHome := setFixtureHomes(t) + cs := newTestSession(t) + + converted := callTool[newSessionResult](t, cs, "convert_session", map[string]any{"id": codexRateLimitID, "target": "claude"}) + if converted.Provider != "claude" { + t.Fatalf("converted provider = %q, want claude", converted.Provider) + } + if converted.ID == codexRateLimitID || converted.ID == "" { + t.Fatalf("converted id = %q, want a fresh id", converted.ID) + } + if !strings.HasPrefix(converted.File, filepath.Join(claudeHome, "projects")) { + t.Fatalf("converted file = %q, want under the claude projects dir", converted.File) + } + if _, err := os.Stat(converted.File); err != nil { + t.Fatalf("converted file missing: %v", err) + } + if converted.ResumeCommand != "claude --resume "+converted.ID { + t.Fatalf("resume command = %q", converted.ResumeCommand) + } + + // The converted session must be readable back through its new provider. + transcript := callTool[transcriptResult](t, cs, "get_transcript", map[string]any{"id": converted.ID}) + if transcript.Provider != "claude" || transcript.TotalTurns != 5 { + t.Fatalf("converted transcript = %s/%d turns, want claude/5", transcript.Provider, transcript.TotalTurns) + } + if transcript.Turns[len(transcript.Turns)-1].Text != "now mock the clock in the TTL test" { + t.Fatalf("converted transcript lost the last turn: %#v", transcript.Turns) + } +} + +func TestConvertSessionRejectsUnknownTarget(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + text := callToolExpectError(t, cs, "convert_session", map[string]any{"id": codexRateLimitID, "target": "cursor"}) + if !strings.Contains(text, "unsupported provider") { + t.Fatalf("error = %q, want unsupported provider", text) + } +} + +func TestResumeCommandReturnsCommandWithoutExecuting(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + result := callTool[resumeCommandResult](t, cs, "resume_command", map[string]any{"id": claudeWebhookID}) + if result.Command != "claude --resume "+claudeWebhookID { + t.Fatalf("command = %q", result.Command) + } + if result.Provider != "claude" || result.CWD != "/work/api-server" { + t.Fatalf("unexpected recipe: %#v", result) + } + if result.File == "" { + t.Fatalf("resume_command should report where the session is stored") + } +} + +// TestNoDeleteToolExposed pins the deliberate decision that the MCP surface +// cannot destroy sessions: deletion stays behind the TUI's two-press confirm. +func TestNoDeleteToolExposed(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + + tools, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "list_sessions": true, + "get_transcript": true, + "branch_session": true, + "convert_session": true, + "resume_command": true, + } + if len(tools.Tools) != len(want) { + t.Fatalf("tool count = %d, want %d", len(tools.Tools), len(want)) + } + for _, tool := range tools.Tools { + if !want[tool.Name] { + t.Fatalf("unexpected tool %q", tool.Name) + } + if strings.Contains(tool.Name, "delete") || strings.Contains(tool.Name, "remove") { + t.Fatalf("destructive tool %q must not be exposed over MCP", tool.Name) + } + } +} + +func TestResolveRowLatestAndMissing(t *testing.T) { + setFixtureHomes(t) + + latest, err := resolveRow("latest") + if err != nil { + t.Fatal(err) + } + if latest.ID != claudeWebhookID { + t.Fatalf("latest = %q, want %q", latest.ID, claudeWebhookID) + } + if _, err := resolveRow("missing-id"); err == nil { + t.Fatal("missing id must error") + } + + t.Setenv("CODEX_HOME", t.TempDir()) + t.Setenv("CLAUDE_HOME", t.TempDir()) + if _, err := resolveRow("latest"); err == nil || !strings.Contains(err.Error(), "no local agent sessions") { + t.Fatalf("empty store err = %v, want 'no local agent sessions'", err) + } +} + +func TestMatchesFiltersWorkspaceAndQuery(t *testing.T) { + row := session.Row{ + Provider: session.ProviderCodex, + CWD: "/home/dev/Api-Server", + FirstUser: "add rate limiting", + LastUser: "mock the clock", + } + if !matchesFilters(row, "", "api-server", "") { + t.Fatal("workspace match must be case-insensitive") + } + if !matchesFilters(row, "", "", "API-SERVER") { + t.Fatal("query must match the workspace path") + } + if !matchesFilters(row, session.ProviderCodex, "api", "clock") { + t.Fatal("all filters together must match") + } + if matchesFilters(row, session.ProviderClaude, "", "") { + t.Fatal("provider mismatch must exclude the row") + } + if matchesFilters(row, "", "webapp", "") { + t.Fatal("workspace mismatch must exclude the row") + } + if matchesFilters(row, "", "", "dark mode") { + t.Fatal("query mismatch must exclude the row") + } +}