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")
+ }
+}