The problem
The owner runs many long coding-agent sessions across several machines and
repos. Every day he hand-writes handoff files so the next session knows what
the last one did. A better dashboard does not fix this — a human still has
to look at a screen, read it, and retype a summary. The fix is for a session
to ask directly: "what was I doing in spacepilot yesterday," "which sessions
touched api.ts," "what did I decide about the outcome enum." Today nothing
can answer that without a human in the loop. This spec gives a session a way
to ask the local index those questions itself, over MCP.
AgentWorth already has the data. crates/storage/src/lib.rs indexes it,
apps/cli/src/server/routes.rs exposes eight of nine handlers as JSON over
HTTP (/api/stats, /api/traces, /api/traces/:id, /api/matrix,
/api/blame, /api/usage, /api/pacing, /api/archaeology, plus
POST /api/scan and POST /api/export/:id). What's missing is a surface an
agent can call mid-session without a human opening a browser tab first.
One thing this spec is not: crates/adapters/src/mcp.rs. That file
normalizes MCP tool names found inside recorded traces (mcp__server__tool
→ mcp:server:tool) so the scanner can tell which MCP servers a past session
called. It has nothing to do with AgentWorth exposing itself as an MCP
server. No server exists in this repo today.
Transport
stdio, and only stdio for v1.
MCP defines two transports: stdio and Streamable HTTP (the successor to the
now-deprecated HTTP+SSE transport, standardized since March 2025). stdio is
the one every coding agent already speaks fluently — Claude Code, Codex, and
Cursor all spawn stdio MCP servers as subprocesses routinely — and it has no
listening socket at all, which fits "local-only, forever" more literally
than binding 127.0.0.1 does. agentworth serve already binds a port for
the dashboard; stdio doesn't need one.
Should agentworth serve also expose an MCP-over-Streamable-HTTP
endpoint? Only as a later fast-follow, not in v1. What it would buy: a
client that already has agentworth serve running for other reasons (the
dashboard, a forwarded port in a dev container) could reach the same tools
without spawning a second process. What it costs: Streamable HTTP needs
session and origin handling stdio doesn't, and it would be a second
transport carrying the same tool set that has to stay in sync with the
first. Since the actual consumer here — a coding agent's own MCP client —
already defaults to spawning a stdio subprocess, there's no real user this
unblocks in v1. Build it only if someone asks for it after stdio ships.
Where the server lives
Follow the precedent already in this repo: apps/cli/src/server/routes.rs
is not its own crate — the HTTP surface lives directly inside apps/cli,
composing agentworth-storage, agentworth-outcomes, agentworth-scoring,
and agentworth-redaction. Do the same for MCP: a new apps/cli/src/mcp/
module, not a new workspace crate. It shares the same Storage and
Scanner instances the HTTP server already builds (AppState in
routes.rs), so a running agentworth serve and a running agentworth mcp
read the identical SQLite index with no duplicated wiring.
This needs one new dependency: rmcp, the official Rust MCP SDK
(modelcontextprotocol/rust-sdk, 4.7M+ downloads on crates.io, current
release line 3.x, implements the 2026-07-28 spec, supports both stdio and
HTTP transports with a macro-driven tool API). Verified via its GitHub repo
and docs.rs listing; not currently a dependency of this workspace.
New CLI surface: a Commands::Mcp subcommand (agentworth mcp /
agwt mcp), alongside the existing Scan, Serve, Search, etc. in
apps/cli/src/main.rs. It opens the default (or --db-path-specified)
Storage, builds an rmcp stdio server, registers the tools below, and
blocks on stdin/stdout until the parent process closes the pipe — the same
lifecycle every other stdio MCP server has.
The tool surface
Every tool here is grounded in a query crates/storage/src/lib.rs or
apps/cli/src/server/routes.rs can already answer. Nothing below invents a
new column, endpoint, or crate beyond what each tool's "New work" line
states plainly.
sessions_find
Wraps Storage::list_sessions_filtered(&SessionFilter)
(crates/storage/src/lib.rs:683).
| Param | Type | Maps to |
|---|---|---|
repo |
string, optional | not a SessionFilter field — see below |
adapter |
string, optional | SessionFilter.adapter (exact match) |
model |
string, optional | SessionFilter.model (substring, LIKE %model% on models_used) |
outcome |
string, optional | SessionFilter.outcome — see the encoding warning below |
search |
string, optional | SessionFilter.search (substring across session_id, source_path, models_used, adapter) |
start_date, end_date |
RFC 3339 string, optional | SessionFilter.start_date / end_date |
min_tokens |
integer, optional | SessionFilter.min_tokens |
order_by |
enum, optional | SessionFilter.order_by — one of started_at_desc (default), started_at_asc, tokens_desc, tokens_asc, events_desc, events_asc, duration_desc, score_desc, score_asc |
limit |
integer, required, no silent default | SessionFilter.limit |
offset |
integer, optional | SessionFilter.offset |
include_stubs |
boolean, optional, default false | SessionFilter.include_stubs |
repo is not a stored column. There is no repo field on the
sessions table — only source_path. extract_repository_or_workspace()
(crates/storage/src/lib.rs:1094) derives a repo/workspace name from
source_path at read time, and it's already used this way for
get_top_repositories(). The tool implementation fetches a
filter-matched, ordered slice and post-filters in Rust by
extract_repository_or_workspace(&s.source_path) == repo, the same
client-side-filter-after-a-bounded-fetch pattern fleet-view.md already
uses for mtime. Cheap, no schema change, but means repo can't be combined
with limit as a hard cap — the tool must over-fetch (say, limit * 4,
capped) before repo-filtering, and say so if it truncates.
The limit default trap. /api/traces defaults to 50 and silently
excludes stub sessions when no limit is given
(AGENTS.md, "Things you cannot learn from the code," item 3) — a client
that forgets to ask for more gets a partial answer that looks complete.
Don't repeat that here: make limit a required parameter on this tool, with
a hard ceiling (say 200) rather than a silent default, so a remote model
calling this tool is forced to state how much it's asking for.
The outcome encoding gap. docs/specs/README.md's "build this first"
item is that primary_outcome is stored PascalCase ("CommitObserved") in
SQLite while OutcomeKind's serde form and the rest of the API contract are
snake_case. Confirmed still true in this snapshot:
crates/storage/src/lib.rs:713 does primary_outcome = ? as a raw string
compare, and crates/core/src/lib.rs:172 writes it via
outcome_kind_name(), not the serde encoding. Until that fix lands, this
tool's outcome parameter has to accept the actual on-disk PascalCase
values (DoneClaimed, ArtifactChanged, TestOrBuildPassed,
CommitObserved, CiOrDeploymentVerified) to work at all, which is an ugly
parameter contract to ship. Land the backend encoding fix first, as
docs/specs/README.md already says, then this tool's outcome param can
use the same snake_case values every other consumer expects.
Return shape: an array of session summaries (session_id, adapter,
source_path, started_at, duration_seconds, total_tokens,
total_events, tool_calls_count, models_used, primary_outcome,
composite_score — the SessionSummary fields, crates/storage/src/lib.rs:70)
plus a truncated: bool flag (true when repo post-filtering or the hard
limit ceiling cut real results). No total-match count — list_sessions_filtered
doesn't compute one, and adding a COUNT(*) variant is unscoped new work,
noted under Open questions instead of assumed away.
source_path is redacted per the policy below regardless of this tool's own
redaction setting, since a list of raw absolute paths is exactly the kind
of thing this tool exists to hand to a possibly-remote model.
session_get
Wraps Scanner::load_trace(&id) plus TraceScorer::score(),
OutcomeDetector::detect_outcomes(), RecoveryDetector::detect_recoveries()
— the same four calls get_trace_by_id_handler makes
(apps/cli/src/server/routes.rs:312), returning the same shape
/api/traces/:id does: { trace, score, outcomes, recoveries }
(TraceDetailResponse, routes.rs:113).
| Param | Type | Notes |
|---|---|---|
session_id |
string, required | |
include_raw |
boolean, optional, default false | see Redaction below — this is the one parameter this whole spec turns on |
events_offset |
integer, optional, default 0 | zero-based offset into trace.events |
events_limit |
integer, optional, default 500 | max events returned; must be > 0 (0 is rejected as invalid params) |
Implementation note (added when pagination shipped): trace.events on
a real session can run to tens of thousands of entries — tens of MB of
JSON — and a remote model asking for "the session" with no further
qualification used to get that in full. events_limit now defaults to
500 rather than unbounded, so a call without these params can never
return a session's whole event list by accident; pass a larger
events_limit explicitly to see more. The response carries events_total
(the session's real event count, independent of how many events this
particular call returned) and events_offset (the offset actually
applied), so a caller can tell "sliced" from "this session genuinely has
few events" and knows how far there is left to page. Score, outcomes, and
recoveries are always computed from the full, unsliced trace first —
detection accuracy shouldn't depend on which page of events was
requested — and only trace.events itself is sliced afterward (then
redacted, unless include_raw is set). The same slicing helper
(paginate_events, apps/cli/src/server/routes.rs) backs GET /api/traces/:id's own offset/limit query params, so the two surfaces
share one pagination contract.
score is the five-component TraceScore (crates/scoring/src/scorer.rs:38:
outcome_score, verifiability_score, complexity_score, recovery_score,
provenance_score, composite_score, plus human-readable explanations).
outcomes is Vec<OutcomeEvidence> (kind, summary, confidence).
recoveries is Vec<RecoverySignal> (failure_sequence, failure_summary,
recovery_sequence, recovery_summary, steps_to_recover).
Done, ahead of tool implementation (backend session, 2026-09-01):
Redactor::redact_outcome_evidence(&[OutcomeEvidence]) -> Vec<OutcomeEvidence>
and Redactor::redact_recovery_signal(&[RecoverySignal]) -> Vec<RecoverySignal>
now exist in crates/redaction/src/redactor.rs, redacting summary /
failure_summary / recovery_summary / correlated_files the same way
redact_trace already redacts trace.events. When session_get gets
built: compute score/outcomes/recoveries from the raw trace first
(detection accuracy shouldn't depend on redacted text), then run
outcomes/recoveries through these two functions on the same redactor
instance used for the trace — see the repository-identity note right below,
which is exactly why "same instance" matters now.
blame_find
Wraps Storage::find_sessions_for_blame(&file_path_pattern)
(crates/storage/src/lib.rs:966), the same call /api/blame makes.
| Param | Type |
|---|---|
file_path |
string, required — substring pattern, matches /api/blame's file/path query param |
Returns Vec<BlameMatch> (session_id, adapter, source_path,
started_at, models_used, total_tokens, tool_calls_count,
file_path, action, modified_at, model) — redacted per policy.
usage_summary
Wraps Storage::get_daily_usage / get_weekly_usage / get_monthly_usage
(crates/storage/src/lib.rs:797-807), same as /api/usage.
| Param | Type |
|---|---|
period |
enum, required — day, week, or month |
limit |
integer, optional, default matches the route's own defaults (30 / 20 / 12) |
Returns { rows, cost_basis, subscription_tier }. rows is
Vec<UsagePeriodSummary> (period, adapter, session_count, token
breakdown, total_duration_seconds, estimated_cost_usd,
cache_hit_ratio). cost_basis is always "api_list_price_equivalent"
— every estimated_cost_usd here is computed from the public API price
list, not what the account actually paid. subscription_tier is present
when ~/.claude.json names one (e.g. a Claude subscriber's plan); its
presence means the cost figures above are not the account's real bill.
agentworth usage's own richer rollup (--period day|week|month|year|all,
--by adapter|model|repo, --since, honest period-count --limit) isn't
exposed over MCP yet — this tool still mirrors the older adapter-grouped
/api/usage shape.
pacing_window
Wraps Storage::get_pacing_window(hours) (crates/storage/src/lib.rs:864),
same as /api/pacing. This is the tool that answers "what am I burning
right now" — fleet-view.md's addendum's second daily question.
| Param | Type |
|---|---|
hours |
integer, optional, default 5 (matches the route default) |
Returns PacingSummary (burn_rate_tokens_per_hour, active_adapters,
active_models, token breakdown, estimated_cost_usd, cache_hit_ratio).
coverage_stats
Wraps Storage::get_aggregate_stats() (crates/storage/src/lib.rs:516,
same as /api/stats) and, optionally, the adapter matrix computation
/api/matrix already does (compute_adapter_matrix,
apps/cli/src/server/routes.rs:451).
No parameters. Returns AggregateStats (total_sessions, total_events,
token_usage, sessions_by_adapter, models_usage_count,
tools_usage_count, verified_outcomes_count, first_session_at,
last_session_at) plus, if the caller passes include_matrix: true,
AdapterMatrixResponse (total_adapters, detected_adapters, per-adapter
detection/format/capability rows). This is the tool that answers "what does
this machine even have" without a human opening the dashboard's Overview
tab first.
What's deliberately not in v1
- No
scan_triggertool. A tool that runsScanner::run_scanwould let a remote model kick off a filesystem scan across the whole machine on its own initiative, with no human watching. That's a bigger blast radius than every read-only tool above combined, for a benefit (auto-refreshing a stale index) a human can get just as well by runningagentworth scanthemselves before asking questions. Keep the v1 surface 100% read-only; documentagentworth scanas a prerequisite, not a tool call. Revisit only if staleness turns out to be a real problem in practice — see Open questions. - No semantic-search tool.
docs/specs/local-search.mdargues MCP should ship before embeddings, and that most questions are exact-match SQL, not similarity search. Don't pre-empt that by wiringagwt search's vector store into a tool here. If embeddings prove worth shipping, add the tool then. - No
/api/exportor/api/archaeologyequivalents. Export already has its own explicit, opt-in redaction flow through the CLI/HTTP surface; duplicating it as an MCP tool doesn't add anything an agent mid-session needs. Archaeology is presentation logic for the dashboard's forensics view (compute_archaeology_highlights), not a new query — skip it unless something asks for it specifically.
What it must not expose
Session logs carry prompts, shell commands, tool output, file diffs, and whatever secrets happened to be in scope when the agent ran. This server hands that content to whatever process holds the other end of the pipe — which, for a coding agent's own MCP client, may itself be backed by a remote model. This is the sharpest privacy question in the whole design, and here is the direct answer, not a hedge:
Redacted is the default for every tool that returns event or file content. Raw is opt-in, per call, never global.
sessions_findandblame_findnever return full event content in the first place — only summary rows (SessionSummary,BlameMatch) — but theirsource_pathfields go through the redaction engine's home-path rules regardless, since even a bare list of absolute paths and repo names is more than a remote model needs to answer "which sessions touched this file."session_getdefaultsinclude_rawto false. Redacted, it runs the trace, its outcome summaries, and its recovery summaries throughRedactorbefore returning (see the New work note above — this redaction ofoutcomes/recoveriesdoesn't exist yet and has to be built as part of this tool, not assumed from existing code).include_raw: truereturns the unredacted trace. There is no server-side global setting that flips the default; every call chooses.
Fixed, not just flagged (backend session, 2026-09-01): the named-rule
gap above was real — the redaction engine's rules cover API keys, JWTs, env
vars, credential URLs, emails, private IPs, PEM keys, and home-directory
usernames, but nothing matched a repository or project name, so even
"redacted" output leaked it via source_path. crates/redaction/src/rules.rs
now has repository_identity_rule(repo_or_workspace: &str) -> Option<RedactionRule>,
which builds a literal-match rule for one session's own identity (derived
via agentworth_schema::extract_repository_or_workspace, moved there from
agentworth-storage — still re-exported from storage so existing callers
don't change — specifically so agentworth-redaction could reach it
without taking on storage's SQLite dependency). Redactor::for_trace(&trace) -> Self
returns a redactor augmented with this trace's own identity rule;
redact_trace calls it internally now, so every existing caller (export --redact, and this tool once built) gets repository-name protection for
free. session_get's include_raw: false path should build its redactor
via Redactor::new().for_trace(&trace), then use that same instance for
redact_trace, redact_outcome_evidence, and redact_recovery_signal —
that's what makes the repository-identity rule apply to all three instead
of just the trace object. Real tests in
crates/redaction/tests/redactor_test.rs cover the rule builder, the
end-to-end trace case, and the composition across trace/outcomes/recoveries
via one for_trace-augmented instance. Verified on lenovo — see this
repo's docs/DECISION-INBOX.md for the real build/test output.
Nothing here changes what's on disk. Every tool reads through
Storage/Scanner the same way the HTTP routes already do. No tool writes
to the original session logs; Redactor::redact_trace already only ever
produces a sanitized copy (crates/redaction/src/redactor.rs), same
guarantee AGENTS.md's "Never modify original histories" already states.
Registration
Wire it into Claude Code with the standard local-stdio-server flow (verified against Claude Code's current MCP docs, September 2026):
claude mcp add agentworth --scope user -- agentworth mcp
--scope user matters here more than it does for a typical MCP server:
the whole point is a session in any repo being able to ask about any
other repo's history ("what was I doing in spacepilot yesterday," asked
from inside a totally different checkout). A project-scoped .mcp.json
entry would only be live in one repo at a time, which defeats that.
Equivalent hand-written entry, in ~/.claude.json under the top-level
mcpServers key (or .mcp.json at project scope, if a team ever wants
that instead):
{
"mcpServers": {
"agentworth": {
"type": "stdio",
"command": "agentworth",
"args": ["mcp"]
}
}
}
No environment variables, no auth — stdio servers run as the local user's
own process, same trust boundary as running agentworth from a terminal
already has.
Why this beats a better dashboard
A dashboard needs a human to open it, read it, and retype what mattered into a handoff file for the next session. That human is the actual bottleneck the owner is trying to remove — a nicer chart doesn't remove a person from the loop, it just gives them a nicer screen to transcribe from.
An MCP tool lets the next session ask directly, in the same turn it
needs the answer, and get back structured data it can act on — not prose
it has to re-read and re-interpret. "Which sessions touched api.ts" as an
MCP tool call returns a list the calling agent can iterate over
programmatically; the same question against a dashboard returns a screen a
human has to look at and then explain. The dashboard isn't going away —
fleet-view.md and trajectory-scrubber.md are still worth building for
the times a human genuinely wants to look — but it was never going to solve
the specific pain this spec targets, because a screen still requires a
person.
Decisions made here
- stdio only for v1; Streamable HTTP deferred, not ruled out.
- The MCP surface lives in
apps/cli/src/mcp/, not a new crate, mirroring where the HTTP routes already live. rmcpis the SDK to build on — official, actively maintained, supports both transports for when HTTP is revisited.- Seven read tools (
sessions_find,session_get,blame_find,usage_summary,pacing_window,coverage_stats, withinclude_matrixfolded intocoverage_statsrather than a separate tool) cover every question named in the brief. No write tool ships in v1. - Redacted is the default output for anything carrying event or file content; raw is an explicit per-call opt-in, never a server-wide switch.
limitis a required parameter onsessions_find, with a hard ceiling, specifically to not repeat the/api/traces50-default trap.
Open questions
- Both redaction questions that used to be here — whether
outcomes/recoveriesredaction should be a sharedagentworth-redactionfunction, and whether the repository-name gap gets fixed before this ships or documented as a caveat — are resolved. See the "New work"/"A real gap" notes above: both landed as realagentworth-redactionfunctions (redact_outcome_evidence,redact_recovery_signal,for_trace), not a documented caveat./api/traces/:id?redact=true(mentioned as a possible second consumer) doesn't exist yet — whoever adds it should reuse these same functions rather than one-off logic, but that route itself is still unbuilt and out of scope here. - Is a
scan_triggertool ever wanted, gated behind an explicit confirmation round-trip the way the redaction opt-in is, rather than omitted entirely? Left out of v1 above, but "never" wasn't argued for — only "not without more thought." - Does
sessions_find's missing total-match count matter enough to add aCOUNT(*)variant tolist_sessions_filtered, or istruncated: boolsufficient for how a model is likely to use pagination? - Once the
docs/specs/README.mdoutcome-encoding fix lands, doessessions_find'soutcomeparameter get validated against the known snake_case enum values at the tool layer (reject an invalid value with a clear error) or passed through raw the waySessionFilter.outcomealready does?
Implementation notes (resolved during the build)
- The outcome-encoding fix has landed on main (confirmed against
crates/outcomes/src/outcome.rsandcrates/storage/src/lib.rs'sget_aggregate_statsquery, both snake_case).sessions_find'soutcomeparameter is passed through raw toSessionFilter.outcome, unvalidated — same choiceSessionFilteritself already makes, so the tool layer isn't inventing a new contract. Revisit if a client turns out to routinely pass a stale PascalCase value. limitout of range is rejected, not clamped. Bothlimit == 0andlimit > 200return aninvalid_paramsMCP error naming the valid range, rather than silently clamping — clamping would repeat exactly the "looks complete but isn't" shape this tool exists to avoid.coverage_stats'sinclude_matrixreusescompute_adapter_matrixdirectly (widened from private topub(crate)inapps/cli/src/server/routes.rs) rather than re-deriving the 20-adapter capability table — one definition, shared by/api/matrixand this tool.- Tracing goes to stderr for the
mcpsubcommand specifically. The CLI's existing global tracing setup defaults to stdout, which would corrupt the stdio JSON-RPC stream for any log line emitted while a client is attached.apps/cli/src/main.rsnow branches onCommands::Mcpbefore initializing the subscriber. This wasn't called out above; it's the kind of thing that only surfaces once you actually try to run an MCP server on top of a CLI that already logs.