From 29d1da6fd8b76660158bb5689a3d1958bbf45308 Mon Sep 17 00:00:00 2001 From: San Lee <295248956+sanlee-ys@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:21:37 -0400 Subject: [PATCH] mcp: an agent reads the fleet from its own client, out of the same document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `telltale mcp` serves the snapshot document (design.md §7.22) over the Model Context Protocol on stdio. An MCP client starts the process and calls one tool, `fleet_snapshot`, which takes an optional `vendor` argument, runs one scan and returns the document. One flag: `--timeout`, per call. The document is not re-made here. The tool result is `snapshot.Encode` over the document `snapshot.Build` produced from the scan `telltale snapshot` runs, so zero-vs-absent, the explicit nulls, `estimated`, `unsupported` and `self_reported` carry through as the same bytes the CLI prints. `structuredContent` is that same document again, marshalled by the same package. The tool description carries the value rules, because the calling model reads that text before it decides what a null means. It is a reader: stdio only, so it binds no port, calls no network and writes nothing at all. `internal/mcpserver` speaks hand-written JSON-RPC over `encoding/json`, which keeps the module's no-new-dependency position. Verified live against the built binary on Windows 11 with a scripted stdio client: seven messages in, six responses out, exit 0, empty stderr, and the tool's document validated against `docs/snapshot.schema.json`. `~/.telltale` was byte-identical before and after. CI drives the same sequence on every run and validates both spellings of the document. No third-party MCP client has connected to this server yet. STATE.md and design.md §7.25 carry that debt. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 105 ++++++ CLAUDE.md | 4 +- README.md | 23 ++ STATE.md | 12 + cmd/telltale/main.go | 150 +++++++-- cmd/telltale/main_test.go | 99 ++++++ docs/design.md | 141 ++++++++ internal/eventview/boundary_test.go | 8 +- internal/mcpserver/boundary_test.go | 133 ++++++++ internal/mcpserver/mcpserver.go | 483 +++++++++++++++++++++++++++ internal/mcpserver/mcpserver_test.go | 359 ++++++++++++++++++++ 11 files changed, 1490 insertions(+), 27 deletions(-) create mode 100644 internal/mcpserver/boundary_test.go create mode 100644 internal/mcpserver/mcpserver.go create mode 100644 internal/mcpserver/mcpserver_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f66b8b3..090997e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -226,6 +226,111 @@ jobs: } Remove-Item snapshot-live.json + # The same document, in front of the other kind of machine reader + # (design.md §7.25). This step drives the BUILT binary as an MCP stdio + # server with a scripted client, and then feeds the tool's own document + # back through the validator the step above used. That reuse is the + # point: the MCP surface is a fourth READER of the snapshot document, so + # the way to check it is the contract the document already publishes, not + # a second reading of the same rules. + # + # It runs after the schema gate so the runner's relayed quota is already + # written — this document carries the same populated `quota` array that + # one asserts on, rather than the empty half a bare machine emits. + - name: Gate (the MCP server through the real binary) + shell: pwsh + run: | + function Tree { + $dir = "$env:USERPROFILE\.telltale" + if (-not (Test-Path $dir)) { return '' } + (Get-ChildItem $dir -Recurse -File | Sort-Object FullName | + ForEach-Object { "$($_.FullName)|$($_.Length)|$($_.LastWriteTimeUtc.Ticks)" }) -join "`n" + } + $before = Tree + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = (Resolve-Path ./telltale.exe).Path + $psi.Arguments = 'mcp' + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $p = [System.Diagnostics.Process]::Start($psi) + $errTask = $p.StandardError.ReadToEndAsync() + + # One request per line, answers read one line at a time — the stdio + # transport's own framing. A notification carries no id and MUST NOT + # be answered, so nothing is read after it; if the server ever + # answered one, every read below would return the wrong frame and the + # assertions would fail together. + $requests = @( + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' + '{"jsonrpc":"2.0","method":"notifications/initialized"}' + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fleet_snapshot","arguments":{}}}' + '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"fleet_snapshot","arguments":{"vendor":"chatgpt"}}}' + ) + $frames = @() + foreach ($r in $requests) { + $p.StandardInput.WriteLine($r) + $p.StandardInput.Flush() + if ($r -match '"id"') { $frames += $p.StandardOutput.ReadLine() } + } + $p.StandardInput.Close() + if (-not $p.WaitForExit(60000)) { + $p.Kill($true) + throw "the MCP server did not exit after its client closed stdin; EOF is the clean end of an stdio session" + } + $err = $errTask.GetAwaiter().GetResult() + if ($p.ExitCode -ne 0) { throw "telltale mcp exited $($p.ExitCode): $err" } + if ($err) { throw "the MCP server wrote to stderr: $err" } + if ((Tree) -ne $before) { throw "an MCP session changed something under ~/.telltale; this mode writes nothing at all (§7.25)" } + # A closed stream reads as $null, and every assertion below would then + # fail on a missing property rather than on the thing that broke. + if ($frames.Count -ne 4 -or ($frames | Where-Object { -not $_ })) { + throw "the server stopped answering: got $($frames.Count) frames, one of them empty. $err" + } + + $init = $frames[0] | ConvertFrom-Json + if ($init.result.protocolVersion -ne '2025-06-18') { + throw "the handshake answered $($init.result.protocolVersion) to a client asking for a supported revision" + } + if (-not $init.result.serverInfo.name) { throw "the handshake named no server: $($frames[0])" } + + $list = $frames[1] | ConvertFrom-Json + if ($list.result.tools[0].name -ne 'fleet_snapshot') { throw "tools/list did not name the tool: $($frames[1])" } + # The calling MODEL reads this description and nothing else before it + # decides what a value means, so the honesty rules have to be in it. + foreach ($word in 'null', 'estimated', 'unsupported', 'self_reported') { + if ($list.result.tools[0].description -notmatch $word) { + throw "the tool description never mentions $word; a caller that does not know the rules reads a null as a zero" + } + } + + $call = $frames[2] | ConvertFrom-Json + if ($call.result.isError) { throw "the tool call failed: $($frames[2])" } + # The text content and structuredContent are one document marshalled + # twice. Validating the TEXT is the stronger of the two, because that + # is the byte string a client shows a model. + $call.result.content[0].text | Set-Content -Path mcp-doc.json -Encoding utf8 + python tools/validate-snapshot.py mcp-doc.json + if ($LASTEXITCODE -ne 0) { throw "the document served over MCP does not match docs/snapshot.schema.json" } + # A gate that only reads the text half would miss a structuredContent + # that had drifted, and a client may read either one. + $call.result.structuredContent | ConvertTo-Json -Depth 12 | Set-Content -Path mcp-structured.json -Encoding utf8 + python tools/validate-snapshot.py mcp-structured.json + if ($LASTEXITCODE -ne 0) { throw "structuredContent does not match docs/snapshot.schema.json; the two spellings have drifted" } + Remove-Item mcp-doc.json, mcp-structured.json + + # A bad argument is a RESULT the model can read, never a transport + # error it cannot — and it carries no document, because there is no + # measurement behind it. + $bad = $frames[3] | ConvertFrom-Json + if ($bad.error) { throw "a bad vendor came back as a JSON-RPC error: $($frames[3])" } + if (-not $bad.result.isError) { throw "a bad vendor was reported as a successful call: $($frames[3])" } + if ($bad.result.structuredContent) { throw "a failed call carried a document: $($frames[3])" } + if ($bad.result.content[0].text -notmatch 'chatgpt') { throw "the refusal does not name what was wrong: $($frames[3])" } + - name: Smoke (cursor token relay through the real binary) shell: pwsh run: | diff --git a/CLAUDE.md b/CLAUDE.md index 30a68ac..2552f07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,7 +180,9 @@ and `telltale hud` read vendor files, make no network calls, read no credentials and no keybinding mutates vendor state. `telltale snapshot` (design.md §7.22) is a third reader of the same scan and holds the contract with one item spare — it writes nothing at all, not even the quota relay, because it renders no quota of -its own to relay. **Three** deliberate, bounded exceptions +its own to relay. `telltale mcp` (design.md §7.25) is a fourth reader of that +same document and holds the same contract: stdio only, so it binds no port +either. **Three** deliberate, bounded exceptions exist, all under `~/.telltale/` and all numbers-and-keys only, never content: - `telltale council` — spawns vendor CLIs; writes `council/room.json` (session diff --git a/README.md b/README.md index 2e5c208..7248b36 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,12 @@ Its payload has no vendor name. This statusline is interactive only Then start an interactive `cursor-agent` session and run `telltale hud`. +Wire the MCP server into a client once, so an agent can read the fleet: + +``` +claude mcp add telltale -- C:\path\to\telltale.exe mcp +``` +

@@ -117,6 +123,7 @@ HUD flags: `--vendor all|claude|codex|gemini|agy|cursor|grok`, - **`telltale hud`:** a watch TUI over Claude Code, Codex, Gemini CLI, Antigravity CLI, Cursor (Composer), Grok CLI, and Pi. - **`telltale snapshot`:** the same scan as JSON, for a program. +- **`telltale mcp`:** the same document over MCP on stdio, for an agent. - **`telltale doctor`:** which vendor binaries this machine has. - **`telltale events`** / **`telltale events view`:** a loopback hook sink and its reader. @@ -176,6 +183,22 @@ or reply text. Schema: [docs/design.md §7.22](docs/design.md#s7-22) and Get-TelltaleFleetLine ``` +## `telltale mcp` + +``` +claude mcp add telltale -- \telltale.exe mcp +``` + +The same document, served to an agent over the Model Context Protocol on +stdio. You do not type this command: an MCP client starts it. One tool, +`fleet_snapshot`, takes an optional `vendor` argument and returns the +document above — the same bytes, so every rule in that table holds here. +One flag: `--timeout ` (default 10s), per call. + +It speaks stdio only. It binds no port, calls no network, and writes +nothing. [docs/design.md §7.25](docs/design.md#s7-25) states the surface +and what is not verified. + ## `telltale events` ``` diff --git a/STATE.md b/STATE.md index 4054ab8..916e451 100644 --- a/STATE.md +++ b/STATE.md @@ -331,6 +331,18 @@ Nothing open. The last one here was the 44 seconds, and it was measured ## Known gaps, not yet owned +- **No third-party MCP client has connected to `telltale mcp`** (2026-08-18, + design.md §7.25). The mode is verified against the built binary by a scripted + stdio client — six requests, six responses, exit 0, the tool's document + validated against `docs/snapshot.schema.json`, and `~/.telltale` byte-identical + before and after — and CI drives the same sequence on every run. What that + proves is a correct server. It says nothing about how a shipped client + negotiates a version, orders its requests, or renders the result, because + wiring one up writes an entry into the operator's own client configuration and + that entry is his to make. One `claude mcp add telltale -- \telltale.exe + mcp` followed by one tool call pays this in a minute. The command's shape is + read off `claude mcp add --help` at Claude Code 2.1.233, not assumed. + - **A live ordinary-turn give-up is owed on the reference box before 2026-09-30.** `x` on an ordinary turn shipped 2026-08-17 with offline tests only. Whether a real vendor's interrupt lands mid-turn, and whether the diff --git a/cmd/telltale/main.go b/cmd/telltale/main.go index bfccc21..77e9fdb 100644 --- a/cmd/telltale/main.go +++ b/cmd/telltale/main.go @@ -1,6 +1,6 @@ // telltale — an honest gauge for your coding agents. // -// One binary, eight modes (decisions/002, decisions/008): +// One binary, nine modes (decisions/002, decisions/008): // // telltale statusline read a vendor statusline JSON payload on stdin, print one // line (Claude Code, or Antigravity CLI via its documented @@ -25,6 +25,9 @@ // sink's own socket (design.md §7.21, 2026-08-17) // telltale snapshot the fleet's current gauge state as one JSON document, // for a reader that is a program (design.md §7.22) +// telltale mcp the same document over the Model Context Protocol on +// stdio, for a reader that is an AGENT: one tool, one +// scan per call, stdio only (design.md §7.25) // telltale doctor launch-time preflight: which vendor binaries are here, // what version each reports, and what was never checked // @@ -82,6 +85,7 @@ import ( "github.com/sanlee-ys/telltale/internal/gatehook" "github.com/sanlee-ys/telltale/internal/grokotel" "github.com/sanlee-ys/telltale/internal/hud" + "github.com/sanlee-ys/telltale/internal/mcpserver" "github.com/sanlee-ys/telltale/internal/model" "github.com/sanlee-ys/telltale/internal/quotacache" "github.com/sanlee-ys/telltale/internal/snapshot" @@ -139,6 +143,11 @@ func main() { fmt.Fprintln(os.Stderr, "telltale snapshot:", err) os.Exit(1) } + case "mcp": + if err := runMCP(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "telltale mcp:", err) + os.Exit(1) + } case "doctor": if err := runDoctor(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "telltale doctor:", err) @@ -640,29 +649,60 @@ func runSnapshot(args []string) error { return errors.New("--timeout wants a positive duration") } - filter, err := parseFilter(*vendor) + adapters, err := snapshotAdapters(*vendor) if err != nil { return err } - adapters := allAdapters() - if v, ok := filter.VendorID(); ok { - adapters = nil - for _, a := range allAdapters() { - if a.Vendor() == v { - adapters = append(adapters, a) - } - } - // A filter naming a vendor no adapter serves would print an empty - // document that looks like an idle fleet. The seat roster and the - // adapter roster are allowed to differ (model.VendorGrok was a seat - // before it was an adapter), so this is a real case and not a typo. - if len(adapters) == 0 { - return errors.New("--vendor " + string(v) + " has no HUD adapter, so there is nothing to snapshot") - } - } ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() + out, err := snapshot.Encode(scanDocument(ctx, adapters), *compact) + if err != nil { + return err + } + _, err = os.Stdout.Write(out) + return err +} + +// snapshotAdapters resolves a --vendor word to the adapters that answer it. +// +// It is shared by `snapshot` and `mcp` so the two modes cannot come to disagree +// about what a vendor word means. A word one accepts and the other refuses +// would be the worst kind of disagreement here, because both surfaces are read +// by programs: the caller would get a corrective error from one and a +// well-formed document answering a different question from the other. +func snapshotAdapters(vendor string) ([]model.Adapter, error) { + filter, err := parseFilter(vendor) + if err != nil { + return nil, err + } + v, ok := filter.VendorID() + if !ok { + return allAdapters(), nil + } + var adapters []model.Adapter + for _, a := range allAdapters() { + if a.Vendor() == v { + adapters = append(adapters, a) + } + } + // A filter naming a vendor no adapter serves would print an empty + // document that looks like an idle fleet. The seat roster and the + // adapter roster are allowed to differ (model.VendorGrok was a seat + // before it was an adapter), so this is a real case and not a typo. + if len(adapters) == 0 { + return nil, errors.New("--vendor " + string(v) + " has no HUD adapter, so there is nothing to snapshot") + } + return adapters, nil +} + +// scanDocument runs one scan and reshapes it into the document. +// +// One function, two modes, one scan path — which is the same reason +// allAdapters() exists above. `telltale snapshot` and `telltale mcp` serve the +// identical document to two different kinds of reader, and a second scan here +// would be a place for them to drift into two answers. +func scanDocument(ctx context.Context, adapters []model.Adapter) snapshot.Document { snap := hud.Scan(ctx, adapters, time.Now()) // The account relay rides the scan here exactly as it does in the HUD // (design.md §7.15): read after the scan, off any render path, and a @@ -670,12 +710,58 @@ func runSnapshot(args []string) error { if dir, err := quotacache.Dir(); err == nil { snap.Account = quotacache.ReadAll(dir, snap.At) } - out, err := snapshot.Encode(snapshot.Build(snap, model.DefaultLivenessThresholds), *compact) - if err != nil { + return snapshot.Build(snap, model.DefaultLivenessThresholds) +} + +// runMCP serves the snapshot document over the Model Context Protocol on stdio +// (design.md §7.25): the same scan `snapshot` prints, in front of a reader that +// is an agent rather than a script. +// +// A separate mode rather than a flag on `snapshot`, for the reason `doctor` and +// `events view` are their own modes: what it prints goes somewhere else. +// `snapshot` prints one document and returns; this holds the pipe open, answers +// requests until its client closes stdin, and its stdout carries protocol +// frames no human reads. +// +// Nobody types this. An MCP client is configured with the command and starts +// the process itself, which is why the mode takes one flag and no arguments. +// +// It keeps the gauges' contract with one item spare, exactly as `snapshot` +// does: it reads vendor stores and the quota relay, calls no network, binds no +// port, reads no credential and writes nothing at all. +func runMCP(args []string) error { + fs := flag.NewFlagSet("telltale mcp", flag.ContinueOnError) + // One deadline per TOOL CALL rather than one for the process: this mode + // serves for as long as its client keeps it, so a budget for the run would + // be a server that stops answering after ten seconds of uptime. + timeout := fs.Duration("timeout", 10*time.Second, "how long each scan gets before it reports what it has") + if err := fs.Parse(args); err != nil { return err } - _, err = os.Stdout.Write(out) - return err + // The same loud refusal `snapshot` gives, for the same reason and one step + // worse: this mode's operator is a config file nobody re-reads, so an + // argument that is silently ignored would stay wrong for as long as the + // client is wired up. + if fs.NArg() > 0 { + return errors.New("unexpected argument " + fs.Arg(0) + " (this mode takes flags only: --timeout)") + } + if *timeout <= 0 { + return errors.New("--timeout wants a positive duration") + } + + return mcpserver.Serve(context.Background(), os.Stdin, os.Stdout, mcpserver.Options{ + Name: "telltale", + Version: version, + Fleet: func(ctx context.Context, vendor string) (snapshot.Document, error) { + adapters, err := snapshotAdapters(vendor) + if err != nil { + return snapshot.Document{}, err + } + ctx, cancel := context.WithTimeout(ctx, *timeout) + defer cancel() + return scanDocument(ctx, adapters), nil + }, + }) } func runHUD(args []string) error { @@ -890,10 +976,12 @@ Three modes need no configuration at all. Run one of them: telltale council the dispatch room: one brief, several agents, side by side. This is the mode the project is for. -One mode is wired in rather than run: point Claude Code's — or Antigravity +Two modes are wired in rather than run. Point Claude Code's — or Antigravity CLI's — statusLine.command at ` + "`telltale statusline`" + `. Cursor CLI's wants ` + "`telltale statusline --vendor cursor`" + `, because its payload carries no marker -to route on. The README's Install section carries the settings block to paste. +to route on. And an agent reads the fleet itself through ` + "`telltale mcp`" + `, an MCP +server on stdio you add to a client once. The README's Install section carries +the settings block to paste. telltale help every mode and every flag telltale version the tag this binary was built from` @@ -958,6 +1046,14 @@ usage: claimed rather than telltale measured carries "self_reported": true. Numbers and keys only — no session name, workspace, transcript or reply text + telltale mcp serve that same document over the Model Context + Protocol on stdio, so an agent reads fleet state with + the mechanism it already has. Nobody types it: wire it + into an MCP client as the command, e.g. + claude mcp add telltale -- telltale mcp. One tool, + fleet_snapshot, taking an optional vendor argument; + one scan per call; stdio only, so it binds no port and + writes nothing telltale doctor launch-time preflight: which vendor binaries are on this machine, where each was found, what version it reports — and, said out loud rather than left blank, @@ -989,6 +1085,12 @@ telltale snapshot flags: has (default 10s). A scan that runs out says so in scan_error rather than hanging or lying +telltale mcp flags: + --timeout how long each tool call's scan gets before it + reports what it has (default 10s). One deadline + per CALL, not one for the process: this mode + serves for as long as its client keeps it + telltale doctor flags: --timeout how long each vendor gets to answer --version (default 15s). One deadline per seat, not one for diff --git a/cmd/telltale/main_test.go b/cmd/telltale/main_test.go index 9b4172c..9d0e9c8 100644 --- a/cmd/telltale/main_test.go +++ b/cmd/telltale/main_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "io" "net" "os" @@ -9,6 +10,7 @@ import ( "github.com/sanlee-ys/telltale/internal/council" "github.com/sanlee-ys/telltale/internal/gatehook" + "github.com/sanlee-ys/telltale/internal/mcpserver" ) // TestUsageNamesEverySeat stops the long help from describing a smaller room @@ -247,6 +249,18 @@ func TestUsageNamesTheSnapshotMode(t *testing.T) { } } +// TestUsageNamesTheMCPMode, and names the two things a reader cannot guess: the +// tool's name, and the fact that this mode is wired into a client rather than +// typed. A mode nobody can see in the help is a mode nobody runs, and this one +// has no interactive surface at all to stumble into. +func TestUsageNamesTheMCPMode(t *testing.T) { + for _, want := range []string{"telltale mcp", mcpserver.ToolName, "mcp add"} { + if !strings.Contains(usageText, want) { + t.Errorf("usage text never mentions %q", want) + } + } +} + // TestUsageDescribesCouncilSeatsNotHudFilter guards the neighbouring trap. // // Two different flags spell themselves `--vendor`: council's seat roster and @@ -319,3 +333,88 @@ func TestHookGateWritesTheDecisionAndNothingElse(t *testing.T) { t.Errorf("stdout = %q, want exactly %q", got, gatehook.Decision()) } } + +// TestMCPFailsLoudOnWhatItCannotDo is `snapshot`'s flag contract, for the mode +// whose reader is an agent — and the argument for it is one step stronger here. +// Nobody types this command: it is written once into an MCP client's config and +// then never read again, so a flag that is silently ignored stays wrong for as +// long as the client is wired up. +func TestMCPFailsLoudOnWhatItCannotDo(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"unknown flag", []string{"--vendor", "claude"}, "not defined"}, + {"positional argument", []string{"serve"}, "unexpected argument"}, + {"zero timeout", []string{"--timeout", "0"}, "positive duration"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runMCP(tc.args) + if err == nil { + t.Fatalf("runMCP(%v) started serving instead of refusing", tc.args) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error %q does not carry the correction %q", err, tc.want) + } + }) + } +} + +// TestMCPAnswersTheHandshakeThroughTheWiring drives the mode the way its client +// does — lines of JSON-RPC down a real stdin, lines of JSON-RPC back up a real +// stdout — and asserts the parts internal/mcpserver's own tests cannot see: that +// `mcp` reaches the server at all, and that this path prints nothing but +// protocol frames. One stray banner on this stdout is not noise, it is a frame +// the client cannot parse, the same way one stray line breaks `hook gate` above. +// +// It stops at tools/list on purpose. A tools/call here would run a real scan of +// whatever machine the suite is on, and the call's document is already pinned +// against a fixture in internal/mcpserver and against the built binary in CI. +func TestMCPAnswersTheHandshakeThroughTheWiring(t *testing.T) { + stdin, stdout := os.Stdin, os.Stdout + defer func() { os.Stdin, os.Stdout = stdin, stdout }() + + in, inw, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + go func() { + io.WriteString(inw, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`+"\n") + io.WriteString(inw, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`+"\n") + inw.Close() + }() + out, outw, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdin, os.Stdout = in, outw + + done := make(chan error, 1) + go func() { + done <- runMCP(nil) + outw.Close() + }() + got, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatalf("runMCP returned %v; a closed stdin is a clean end of session", err) + } + + lines := strings.Split(strings.TrimRight(string(got), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("two requests produced %d lines of stdout; this path carries protocol frames and nothing else:\n%s", len(lines), got) + } + for _, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("stdout carried a line no client could parse: %q (%v)", line, err) + } + } + if !strings.Contains(lines[1], mcpserver.ToolName) { + t.Errorf("tools/list did not name %s through the wiring: %s", mcpserver.ToolName, lines[1]) + } +} diff --git a/docs/design.md b/docs/design.md index 9ec178f..81c3ae7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -6640,6 +6640,147 @@ applies before extending the claim. Nothing here defends against a local program not meant to. The gauges' no-network rule and the loopback-only bind are untouched and remain absolute: this change only narrows who may talk to a socket that was already loopback. + + +### 7.25 `telltale mcp` — the same document, in front of an agent (2026-08-18) + +**What it is.** `telltale mcp` serves [§7.22](#s7-22)'s snapshot document over the Model +Context Protocol on stdio. An MCP client starts the process, speaks JSON-RPC down its stdin +and reads frames off its stdout, and calls one tool — `fleet_snapshot` — which runs one scan +and returns the document. One flag: `--timeout` (default 10s), which is the deadline for each +CALL rather than for the process. Nobody types this command; it goes in a client's config +once, e.g. `claude mcp add telltale -- \telltale.exe mcp`. That spelling is the CLI's +own — read off `claude mcp add --help` at Claude Code 2.1.233 on 2026-08-18, where +`claude mcp add my-server -- my-command --some-flag arg1` is the documented stdio form — and +not a shape assumed from memory. Running it is the operator's to do; see the closing paragraph. + +**Why it exists, given that §7.22 already shipped.** The snapshot mode answered "a reader that +is a program" and it answered it well: CI validates it, and `tools/fleet-prompt.ps1` renders +it. Both of those readers are SCRIPTS somebody wrote on purpose. The reader this repo actually +has most of is an agent, and the honest difference is not that an agent cannot shell out — +several can — it is that a command nothing told it about is a command it does not run. A tool +its client LISTS is the version it reaches without being asked, with the argument names and +the value rules in front of it. That is the same gap §7.22 described one layer up: the data +was honest and reachable, and nothing was reaching it. + +**It is a fourth READER, not a second document.** `internal/mcpserver` calls +`snapshot.Encode` on the document `snapshot.Build` produced from the scan `cmd/telltale` runs +for `telltale snapshot` — one scan path (`scanDocument`), one adapter roster (`allAdapters`), +one vendor vocabulary (`snapshotAdapters`), one serializer. The tool result's text content is +byte-for-byte what the CLI prints. That is the whole design, and +`TestTheToolResultIsTheSnapshotDocumentUnchanged` is what holds it: every honesty property +this surface claims is a property of those bytes — a measured zero is `0`, an absent reading +is `null`, no optional key is omitted, `estimated` names what an adapter computed, +`unsupported` names what a vendor can never source, `self_reported` names an entry whose +writer claimed it. A second serializer here would be a second statement of that contract, and +two statements of one contract drift — which is §7.22's own argument for a published schema +over hand-written assertions, applied to itself. + +`structuredContent` carries the same document as JSON beside the text, for a client that +reads it that way. It is the identical value marshalled twice by one package, so the two can +never disagree. No `outputSchema` is declared beside it, deliberately: the document's schema +is published at `docs/snapshot.schema.json` and CI validates the shipped binary against that +file, and embedding a copy in the binary would be exactly the second statement just refused. + +**One tool, not a family.** Every fleet question — what is close to its window, what has been +spent, which vendor stopped reading, how old is the quota reading — is already one parse of +one document. A second tool answering a subset would have to re-serialize part of it, and that +is where the zero-vs-absent rules get restated ([§4a.1](#s4a-1)). + +**The tool DESCRIPTION carries the honesty rules, because the model reads it.** A caller that +does not know them reads a `null` as a zero and an estimate as a measurement — the collapse +this repo exists to prevent, moved one process outward into the agent. So the description says, +in the text the model sees before it decides to call: null is absent and never 0, `estimated` +means telltale computed it, `unsupported` means the vendor can never report it, and +`self_reported` means its writer claimed it. `TestTheToolListNamesTheOneTool` pins all four +words. + +**Stdio only, and that is what makes [§7.24](#s7-24) not apply.** telltale's two other +machine-facing surfaces listen on loopback, and §7.24 exists because a loopback bind is not +containment on its own — a measured headless Chrome planted a usage row and read the whole +event store. This mode binds nothing. The client owns both pipes and starts the process, so +there is no third party to refuse and no `Origin` to check. `TestTheServerOpensNoSocket` +asserts the direct imports rather than the transitive graph, and says so: the graph already +reaches `net` through `internal/hud`'s TUI framework, and linking that code is not calling it +([ADR-002](#adr-002)'s distinction). + +**It writes nothing**, with the gauges' contract one item spare for §7.22's reason: it renders +no quota of its own to relay. `TestTheServerWritesNothing` drives a whole session with the +home directory redirected and compares the tree before and after. + +**The protocol surface is four methods and stops there.** `initialize`, `tools/list`, +`tools/call`, `ping`, plus the notifications a client sends and expects no answer to. Absent: +resources, prompts, completion, sampling, subscriptions, and any server-initiated request. +Their absence is *stated* in the capabilities object rather than discovered by a client that +tried one. Two shapes are refused with the reason rather than half-answered: a JSON-RPC batch +array (removed from MCP in 2025-06-18, and this server answers revisions on both sides of +that), and a message with an id and no method, which is a response to a request this server +never sent and would be a protocol error to answer. + +**Version negotiation echoes what the client asked for, within a list.** +`supportedVersions` is `2024-11-05`, `2025-03-26`, `2025-06-18`, `2025-11-25`; the narrow +surface above is spelled identically across all four, so echoing is a true statement rather +than a compatibility guess. An unknown request gets `2025-11-25`, the latest supported, which +is the lifecycle's own rule. **`2026-07-28` is deliberately not on the list, and it is the +newest revision, so the omission is the interesting one**: that revision requires a server to +implement `server/discover`, and this one does not. Claiming the version would be claiming a +method a client is entitled to call — ADR-001's failure in protocol form, a capability +asserted rather than built. The same revision's versioning section says a client may invoke +methods inline instead, which is the path that works here. + +**Two error channels, and the split is about who can fix it.** A bad `vendor` argument and a +scan that failed come back as a tool RESULT with `isError` set, because the model asked and +the model can correct. A tool name this server never listed, an unknown method and a +malformed line come back as JSON-RPC errors, because those are the client's plumbing. A failed +call carries no `structuredContent` at all — there is no measurement behind it, and an empty +document would be a fleet with nothing in it, which is a different claim. + +**Hand-written JSON-RPC over `encoding/json`, not the official MCP Go SDK.** This follows the +module's standing position rather than inventing one: `go.mod` carries no direct dependency +outside the TUI stack, and this document records the same refusal for the SQLite reader +([§3.2](#s3-2)), the zstd reader, the OTLP listener ([§7.16a](#s7-16a)) and the event emitter +([§7.21](#s7-21)). Four methods and one tool is a smaller surface than the SDK's own API. + +**Verified live, 2026-08-18, Windows 11, against the built binary** (branch `mcp-server` off +main `4b58258`, `go build -o telltale.exe ./cmd/telltale`), driven by a scripted stdio client +that writes request lines and reads response lines. Seven messages in, six responses out — the +seventh was the notification, which is answered by silence — exit 0, empty stderr, ~2.3s for +the whole session including one scan of the real stores: + +- `initialize` at `protocolVersion: "2025-06-18"` echoed that version and returned + `capabilities: {"tools":{}}` with `serverInfo`; `notifications/initialized` produced no + response at all, which is the half a client would hang on. +- `tools/list` returned the one tool. +- `tools/call fleet_snapshot` returned a document of 8 vendors and 1,523 sessions, and + `structuredContent` parsed EQUAL to the text content. **The zero-vs-absent pair appeared in + it unstaged**: `agy`'s `3p-weekly` window carried `used_pct` as the integer `0` — a measured + zero — beside `gemini-weekly` at `0.2`, while `cost_usd_total` was `null` fleet-wide and + five vendors carried `quota_read_at: null`. `codex` carried `estimated: ["context_pct"]` + against `cursor`'s `[]`, and the drop-file entry carried `self_reported: true`. +- **That document was written to a file and validated against `docs/snapshot.schema.json` + with `tools/validate-snapshot.py`: `ok`.** The published contract holds on the new surface, + checked with the gate the CLI's own document is checked with rather than with a second + reading of it. +- `--vendor chatgpt` came back as a tool result with `isError: true` naming the accepted + words; `fleet_quota` came back as JSON-RPC `-32602` naming the tool that does exist; + `resources/list` came back as `-32601` naming the methods that do. +- **Writes nothing, measured on the real store**: `~/.telltale` held the identical 35 files, + sizes and modification times before and after a full session. + +CI drives the same sequence against the built binary on every run, and pipes BOTH spellings of +the tool's document — the text content and `structuredContent` — through the same validator +(`.github/workflows/ci.yml`). **That gate was proved non-vacuous the way §7.22's schema gate +was**: one sentence was removed from the tool description, the binary rebuilt, and the gate +failed naming the missing word; the sentence was restored and it passed again. + +**What is NOT verified, stated so nobody reads the run above as more than it is.** **No +third-party MCP client has connected to this server.** The drive was telltale's own scripted +client, which proves the framing, the document and the error channels, and says nothing about +how a shipped client negotiates a version, orders its requests, or renders a tool result. +Wiring one up writes an entry into the operator's own client configuration, which is his to +make; until he does, this section claims a correct server and not a working integration. +`STATE.md` carries the debt. + ## 8. Roadmap (decided 2026-08-01; adoption track added 2026-08-02, ADR-005) diff --git a/internal/eventview/boundary_test.go b/internal/eventview/boundary_test.go index 73a083a..ee5dbe8 100644 --- a/internal/eventview/boundary_test.go +++ b/internal/eventview/boundary_test.go @@ -22,13 +22,17 @@ const ( viewPkg = "github.com/sanlee-ys/telltale/internal/eventview" ) -// gaugePkgs are the three read surfaces the boundary names. The statusline and +// gaugePkgs are the four read surfaces the boundary names. The statusline and // the HUD are the gauges; snapshot is the third reader of the same scan and -// holds the same contract with one item spare (CLAUDE.md). +// holds the same contract with one item spare (CLAUDE.md), and mcpserver is the +// fourth — it serves snapshot's own document to an agent (design.md §7.25), so +// an import of the event store there would put verbatim hook content in front +// of a model. var gaugePkgs = []string{ "github.com/sanlee-ys/telltale/internal/hud", "github.com/sanlee-ys/telltale/internal/statusline", "github.com/sanlee-ys/telltale/internal/snapshot", + "github.com/sanlee-ys/telltale/internal/mcpserver", } // TestNoGaugeReadsTheEventStore is the property that lets a viewer exist at diff --git a/internal/mcpserver/boundary_test.go b/internal/mcpserver/boundary_test.go new file mode 100644 index 0000000..c2d5419 --- /dev/null +++ b/internal/mcpserver/boundary_test.go @@ -0,0 +1,133 @@ +package mcpserver + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// The gates in this file turn this mode's two boundary claims — stdio only, and +// writes nothing — from sentences in the package doc into something the build +// runs. +// +// The import gate shells out to `go list` for the reason internal/eventview's +// boundary test does: it is the toolchain's own answer to what a package +// imports, honouring build tags, the module graph and the current GOOS, none of +// which a hand-rolled walker gets right. A missing `go` is a broken +// environment, not a reason to skip, and a skipped gate reads as a pass. + +const serverPkg = "github.com/sanlee-ys/telltale/internal/mcpserver" + +// TestTheServerOpensNoSocket pins the claim that makes §7.24 irrelevant to this +// mode. +// +// Both of telltale's other machine-facing surfaces listen on loopback, and +// §7.24 exists because a loopback bind is not containment on its own — a web +// page the operator merely visits reaches 127.0.0.1 too, which was measured +// planting a forged usage row and reading the event store. This mode has no +// such question to answer, and the reason is structural rather than careful: +// the client starts this process and owns both pipes, so there is nothing for a +// third party to connect to. +// +// The failure this guards is the convenient one. An MCP server that grew an +// HTTP transport "as well" would be a listener nobody wrote §7.24's argument +// for, and it would compile without a word of complaint. +// +// It reads the DIRECT imports of this package, which is the exact claim, and +// eventview's TestTheViewerOpensNoSocket is the precedent for saying why. A +// transitive assertion would be the wrong test and would fail today for the +// right reason: this package imports internal/snapshot for the document, that +// package imports internal/hud for the scan it reshapes, and internal/hud +// imports the Bubble Tea TUI framework — which reaches net. Linking that code +// is not calling it, the same distinction ADR-002's fast-path gate rests on. +// +// What it therefore does not cover, said out loud rather than implied: nothing +// stops a future caller reaching a socket through a package this one already +// imports. TestTheServerWritesNothing below is the behavioural half, and it +// measures a whole session rather than an import list. +func TestTheServerOpensNoSocket(t *testing.T) { + out, err := exec.Command("go", "list", "-f", "{{join .Imports \"\\n\"}}", serverPkg).Output() + if err != nil { + t.Fatalf("go list %s: %v", serverPkg, err) + } + imports := strings.Fields(string(out)) + if len(imports) == 0 { + t.Fatalf("go list returned no imports for %s; the gate would pass vacuously", serverPkg) + } + for _, imp := range imports { + if imp == "net" || strings.HasPrefix(imp, "net/") { + t.Errorf("the MCP server imports %s. This mode speaks stdio only: the client owns both\n"+ + "pipes, which is why design.md §7.24's question of who may reach a listener does not\n"+ + "arise here. A transport that binds a port needs that argument written first.", imp) + } + } +} + +// TestTheServerWritesNothing is the read/write boundary, measured rather than +// asserted. +// +// It runs a whole session — handshake, list, call — with the home directory +// redirected, and compares the tree before and after. `telltale snapshot` holds +// the gauges' contract with one item spare (it does not even write the quota +// relay, because it renders no quota of its own to relay), and this mode is a +// reader of that same document, so it has one item spare too. +// +// The redirect is what makes the assertion real on a developer's box: without +// it the test would be reading the operator's own ~/.telltale, where any other +// process could move a byte mid-run and redden this for the wrong reason. +func TestTheServerWritesNothing(t *testing.T) { + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + + before := tree(t, home) + in := strings.NewReader(strings.Join([]string{ + initLine, + `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, + callLine, + }, "\n") + "\n") + var out strings.Builder + if err := Serve(context.Background(), in, &out, options()); err != nil { + t.Fatal(err) + } + if out.Len() == 0 { + t.Fatal("the session produced no output; the gate below would pass vacuously") + } + if got := tree(t, home); got != before { + t.Errorf("an MCP session changed something under the home directory:\nbefore %q\n after %q\n"+ + "This mode writes nothing at all — not even the quota relay, because it renders no quota\n"+ + "of its own to relay (design.md §7.22, §7.25).", before, got) + } +} + +// tree is a before/after fingerprint of everything under dir, by path and size. +// It reports the whole subtree rather than a Test-Path on one directory, +// because the write this guards against would be a new file in a directory +// that already exists. +func tree(t *testing.T, dir string) string { + t.Helper() + var lines []string + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + lines = append(lines, path+"|"+info.ModTime().UTC().String()+"|"+strconv.FormatInt(info.Size(), 10)) + return nil + }) + if err != nil { + t.Fatal(err) + } + return strings.Join(lines, "\n") +} diff --git a/internal/mcpserver/mcpserver.go b/internal/mcpserver/mcpserver.go new file mode 100644 index 0000000..4013009 --- /dev/null +++ b/internal/mcpserver/mcpserver.go @@ -0,0 +1,483 @@ +// Package mcpserver serves the snapshot document over the Model Context +// Protocol on stdio, so a reader that is an AGENT can ask for fleet state with +// the mechanism it already has (docs/design.md §7.25). +// +// It is a FOURTH reader of the same scan, beside the statusline, the HUD and +// `telltale snapshot`, and it adds no reading of its own. The tool result IS +// `internal/snapshot`'s document — the same Build, the same Encode, byte for +// byte — because every honesty property that document carries is a property of +// its bytes: zero is the number 0, absent is `null`, no optional key is +// omitted, `estimated` names what an adapter computed, `unsupported` names what +// a vendor can never source, and `self_reported` names an entry whose writer +// claimed it. A second serializer here would be a second statement of that +// contract, and two statements of one contract drift (§7.22's own argument for +// a published schema over hand-written Go assertions). +// +// Why a mode rather than a flag on `snapshot`: what it prints goes somewhere +// else, which is the reason `doctor` and `events view` are their own modes too. +// `snapshot` prints one document and exits; this one holds the pipe open and +// answers requests until its client closes stdin, and its stdout carries +// protocol frames that no human reads. +// +// # It is a gauge, and it keeps the gauges' contract +// +// This mode writes nothing, anywhere — not even the quota relay, for §7.22's +// reason: it renders no quota of its own to relay. It calls no network and it +// binds no port. **stdio only**: the client starts this process and owns both +// pipes, so §7.24's question of who may push to a listener does not arise here +// — there is nothing to push to. `TestTheServerOpensNoSocket` and +// `TestTheServerWritesNothing` are what keep both halves true. +// +// A tool call runs one scan and answers. Nothing here starts a vendor, spends +// quota, reads a credential, or sends anything to a running agent. +// +// # The surface is deliberately four methods wide +// +// `initialize`, `tools/list`, `tools/call`, plus `ping` and the notifications a +// client sends and expects no answer to. That is what a client needs to reach +// one tool. Resources, prompts, completion, sampling, subscriptions and +// server-initiated requests are all absent, and their absence is stated in the +// capabilities object rather than discovered by a client that tried. +// +// This is hand-written JSON-RPC over stdlib `encoding/json` rather than the +// official MCP Go SDK, and that follows the module's standing position rather +// than inventing one: `go.mod` carries no direct dependency outside the TUI +// stack, and design.md records the same refusal for the SQLite reader (§3.2), +// the zstd reader, the OTLP listener (§7.16a) and the event emitter (§7.21). +// The surface above is small enough that a dependency would carry far more +// protocol than this mode serves. +package mcpserver + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + + "github.com/sanlee-ys/telltale/internal/snapshot" +) + +// PinnedProtocolVersion is the revision this server answers with when the +// client asks for one it does not know. +// +// The lifecycle rule is that a server which does not support the requested +// version responds with one it does support, and the client disconnects if it +// cannot meet that. So this is the LATEST revision in supportedVersions below, +// not the oldest — an old client that cannot read this answer disconnects, +// which is the protocol working. +const PinnedProtocolVersion = "2025-11-25" + +// supportedVersions are the revisions this server will echo back verbatim. +// +// The narrow surface above is spelled identically across all four: `initialize` +// negotiates a version, `tools/list` returns `tools`, and `tools/call` returns +// `content` with an optional `isError`. Nothing this server sends changes shape +// between them, so echoing the client's own revision is a true statement rather +// than a compatibility guess. +// +// **2026-07-28 is deliberately NOT here**, and it is the newest revision, so +// the omission is the interesting one. That revision requires a server to +// implement `server/discover`, and this one does not implement it. Claiming the +// version would be claiming a method a client is entitled to call — which is +// the ADR-001 failure in protocol form: a capability asserted rather than +// built. A client that speaks it and calls `server/discover` first gets +// "method not found"; the same revision's own versioning section says a client +// may instead invoke methods inline and handle a version error, which is the +// path that works here. +var supportedVersions = []string{ + "2024-11-05", + "2025-03-26", + "2025-06-18", + PinnedProtocolVersion, +} + +// ToolName is the one tool this server exposes. +// +// One tool, not a family. Every question an agent asks about the fleet — how +// much context is anything holding, what has been spent, which vendor stopped +// reading, how stale is the quota reading — is already one parse of this one +// document, and a second tool answering a subset would have to re-serialize +// part of it. That is where the zero-vs-absent rules would get restated, and a +// restated rule is a rule that drifts (§4a.1). +const ToolName = "fleet_snapshot" + +// toolDescription is what the calling MODEL reads before it decides to call. +// +// It states the document's provenance rules rather than describing the +// fields, because a reader that does not know them will read `null` as zero and +// an estimate as a measurement — which is the exact collapse this repo exists +// to prevent, moved one process outward into the agent. +const toolDescription = "Read the current state of this machine's coding-agent fleet: one scan, " + + "one JSON document, one entry per vendor plus a pre-computed fleet rollup. " + + "Three rules govern the values and a reader that ignores them will be wrong: " + + "a measured zero is the number 0 and an absent reading is null, never 0; " + + "a field named in a vendor's \"estimated\" list was computed by telltale rather than read from the vendor; " + + "a field named in \"unsupported\" is one that vendor can never report, so a null there is a capability statement rather than this moment's reading. " + + "An entry with \"self_reported\": true carries numbers its own writer claimed. " + + "The document holds numbers and keys only: no session name, workspace path, transcript or reply text." + +// FleetFunc runs one scan and returns the document for it. +// +// It is injected rather than called directly so this package depends on no +// adapter, no store and no clock: the scan lives in cmd/telltale beside +// `snapshot`'s own, which is what keeps the two modes from drifting into two +// scans. A test drives this server with a fixture document and never touches +// the machine it runs on. +// +// vendor is the `--vendor` vocabulary, "all" by default. An unknown value is an +// error here, and the caller turns it into a tool result the model can read +// rather than a transport error it cannot. +type FleetFunc func(ctx context.Context, vendor string) (snapshot.Document, error) + +// Options configures one Serve run. +type Options struct { + // Name and Version are what `initialize` reports as serverInfo. Version is + // the binary's own version string, so a client's logs name the build that + // answered. + Name string + Version string + Fleet FleetFunc +} + +// Serve reads newline-delimited JSON-RPC from in and writes it to out until in +// reaches EOF, then returns nil. +// +// EOF is the normal end of an MCP stdio session: the client closes the pipe +// when it is done with the server, and a server that treated that as an error +// would put a red line in the client's log for a clean shutdown. +// +// Requests are handled ONE at a time, in arrival order. The protocol permits +// concurrent responses, and this server declines the permission: a scan is the +// only cost here, interleaving would need a writer lock for no measured gain, +// and sequential replies make the ordering of this mode's stdout trivially +// correct rather than argued. +func Serve(ctx context.Context, in io.Reader, out io.Writer, opt Options) error { + if opt.Fleet == nil { + return errors.New("mcpserver: no Fleet function; the server would advertise a tool it cannot answer") + } + r := bufio.NewReader(in) + // One encoder for the whole run. Compact by construction (Encode writes no + // indentation and one trailing newline), which is the framing the stdio + // transport requires: one message per line, no embedded line breaks. + // encoding/json escapes a newline inside any string it writes, so a vendor's + // OS error message cannot break a frame. + enc := json.NewEncoder(out) + // No HTML escaping, for §7.22's reason: an OS error message can carry & or + // <, and the escaped spelling is noise in every reader of this document. + enc.SetEscapeHTML(false) + + for { + line, err := r.ReadString('\n') + if len(strings.TrimSpace(line)) > 0 { + if resp, ok := handle(ctx, []byte(line), opt); ok { + if werr := enc.Encode(resp); werr != nil { + return werr + } + } + } + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + if ctx.Err() != nil { + return nil + } + } +} + +// handle turns one incoming line into at most one response. +// +// The second return value is false when there is nothing to send. That case is +// not an edge: a NOTIFICATION carries no id and must never be answered, and +// `notifications/initialized` is one every client sends. A server that replied +// to it would be sending a response to a message that has no id to correlate +// it with. +func handle(ctx context.Context, line []byte, opt Options) (response, bool) { + trimmed := bytes.TrimSpace(line) + if trimmed[0] == '[' { + // JSON-RPC batching was removed from MCP in 2025-06-18 and this server + // supports revisions on both sides of that. Refusing it with the reason + // beats answering half of it. + return errorResponse(nil, codeInvalidRequest, "this server takes one JSON-RPC message per line; batch arrays are not supported (removed from MCP in 2025-06-18)"), true + } + + var req request + if err := json.Unmarshal(trimmed, &req); err != nil { + return errorResponse(nil, codeParseError, "not JSON-RPC: "+err.Error()), true + } + if req.Method == "" { + // A message with an id and no method is a RESPONSE to a request this + // server never sent. Answering it would itself be a protocol error, so + // it is dropped in silence. + return response{}, false + } + if len(req.ID) == 0 || string(req.ID) == "null" { + // A notification. Nothing this server does is triggered by one, and + // none of them is an error: an unknown notification is explicitly a + // thing a peer may ignore. + return response{}, false + } + if req.JSONRPC != "2.0" { + return errorResponse(req.ID, codeInvalidRequest, `"jsonrpc" must be "2.0"`), true + } + + switch req.Method { + case "initialize": + return okResponse(req.ID, initializeResult(req.Params, opt)), true + case "ping": + // An empty result object is the whole of a pong. + return okResponse(req.ID, struct{}{}), true + case "tools/list": + return okResponse(req.ID, toolsListResult{Tools: []tool{describeTool()}}), true + case "tools/call": + return okResponse(req.ID, callTool(ctx, req.Params, opt)), true + default: + return errorResponse(req.ID, codeMethodNotFound, "unknown method "+req.Method+ + "; this server implements initialize, tools/list, tools/call and ping"), true + } +} + +// initializeResult negotiates the version and states what this server can do. +// +// The version rule is the lifecycle's: echo the client's own revision when it +// is one this server supports, and otherwise answer with the latest it does. A +// client that cannot meet the answer disconnects, which is the negotiation +// working rather than failing. +// +// A malformed or missing params object is NOT an error here. The one field this +// server reads is protocolVersion, and the fallback for "the client did not say" +// is identical to the fallback for "the client said something unknown". Failing +// the handshake over a field with a working default would refuse a session this +// server can serve. +func initializeResult(params json.RawMessage, opt Options) initResult { + negotiated := PinnedProtocolVersion + var p initParams + if len(params) > 0 && json.Unmarshal(params, &p) == nil { + for _, v := range supportedVersions { + if p.ProtocolVersion == v { + negotiated = v + break + } + } + } + return initResult{ + ProtocolVersion: negotiated, + // Tools is the only capability, and it carries no `listChanged`: this + // server's tool list is a constant, so it can never change and a + // subscription to its changes would be an offer of nothing. Every other + // capability is absent because it is unimplemented, which is the same + // distinction the document itself draws between absent and zero. + Capabilities: capabilities{Tools: struct{}{}}, + ServerInfo: implementation{Name: opt.Name, Version: opt.Version}, + Instructions: "Call " + ToolName + " to read this machine's coding-agent fleet. " + + "It reports what telltale measured and marks what it did not: null is an absent reading and never a zero, " + + "and a field listed in a vendor's \"estimated\" or \"unsupported\" array carries a provenance statement about itself.", + } +} + +// callTool runs the one tool. +// +// A failure comes back as a RESULT with isError set rather than as a JSON-RPC +// error, and the split is about who can fix it. A bad `vendor` argument and a +// scan that failed are things the MODEL asked for and the model can correct, so +// they belong where the model reads — in the content of a result. The one +// exception is a tool NAME this server does not have: that is the client's +// wiring rather than the model's request, and it is answered as invalid params. +func callTool(ctx context.Context, params json.RawMessage, opt Options) any { + var p callParams + if len(params) > 0 { + if err := json.Unmarshal(params, &p); err != nil { + return toolFailure("could not read the call parameters: " + err.Error()) + } + } + if p.Name != ToolName { + return rpcError{Code: codeInvalidParams, Message: "unknown tool " + p.Name + "; this server has one: " + ToolName} + } + vendor := strings.TrimSpace(p.Arguments.Vendor) + if vendor == "" { + vendor = "all" + } + + doc, err := opt.Fleet(ctx, vendor) + if err != nil { + return toolFailure(err.Error()) + } + // The document is encoded by the package that owns it, in the indented form + // `telltale snapshot` prints by default. Nothing here re-serializes it: the + // bytes a client reads are the bytes the CLI prints, which is what makes + // this a fourth READER rather than a second format. + body, err := snapshot.Encode(doc, false) + if err != nil { + return toolFailure("could not encode the fleet document: " + err.Error()) + } + return callResult{ + Content: []content{{Type: "text", Text: string(body)}}, + // StructuredContent is the same document again, as JSON rather than as + // text, for a client that reads it that way. It is the identical value + // — one Build, one document, marshalled twice by one package — so the + // two can never disagree. + // + // No `outputSchema` is declared beside it, on purpose. The document's + // schema is published at docs/snapshot.schema.json and CI validates the + // shipped binary's output against that file; embedding a copy in this + // binary would be the second statement of one contract that §7.22 + // refused when it chose a schema file over hand-written assertions. + StructuredContent: &doc, + IsError: false, + } +} + +// toolFailure is an error the calling model should read and act on. +func toolFailure(msg string) callResult { + return callResult{ + Content: []content{{Type: "text", Text: msg}}, + IsError: true, + } +} + +// describeTool is the tool's advertised shape. +// +// `additionalProperties: false` on the input, deliberately, and it is the +// opposite choice from the document's own schema (§7.22 sets it true there). +// The two are different directions of trust: the document is this server's +// output and a reader must tolerate a field added later, while these arguments +// are input and an unrecognized one means the caller believes in a control this +// tool does not have. Refusing it beats scanning the whole fleet for a request +// that asked for something narrower. +func describeTool() tool { + return tool{ + Name: ToolName, + Title: "Fleet snapshot", + Description: toolDescription, + InputSchema: inputSchema{ + Type: "object", + Properties: map[string]schemaProperty{ + "vendor": { + Type: "string", + Description: "Report one vendor only. Accepts the same words as `telltale snapshot --vendor`: " + + "all (default), claude, codex, gemini, agy, cursor, grok, pi, self-reported.", + }, + }, + AdditionalProperties: false, + }, + } +} + +// The JSON-RPC envelope. +// +// `omitempty` appears here on Result and Error, and once more on a failed +// call's StructuredContent. Neither is the `omitempty` §7.22 forbids. That rule +// is about a MEASUREMENT going missing — a key that vanishes when its value is +// absent makes "no reading" and "the schema moved" one observation. These two +// are the transport's own contract instead: a response carries exactly one of +// result and error, and a call that measured nothing carries no document. +// Nothing inside the fleet document is touched by either. +type request struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// The JSON-RPC codes this server uses, spelled out rather than inlined so a +// reader can see the whole vocabulary at once. +const ( + codeParseError = -32700 + codeInvalidRequest = -32600 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 +) + +// okResponse wraps a result. A handler that returns an rpcError value gets it +// carried as a protocol error instead — which is how tools/call reports an +// unknown tool name without a second return value on every path. +func okResponse(id json.RawMessage, result any) response { + if e, isErr := result.(rpcError); isErr { + return response{JSONRPC: "2.0", ID: id, Error: &e} + } + return response{JSONRPC: "2.0", ID: id, Result: result} +} + +func errorResponse(id json.RawMessage, code int, msg string) response { + return response{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}} +} + +type initParams struct { + ProtocolVersion string `json:"protocolVersion"` +} + +type initResult struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities capabilities `json:"capabilities"` + ServerInfo implementation `json:"serverInfo"` + Instructions string `json:"instructions"` +} + +type capabilities struct { + Tools struct{} `json:"tools"` +} + +type implementation struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type toolsListResult struct { + Tools []tool `json:"tools"` +} + +type tool struct { + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + InputSchema inputSchema `json:"inputSchema"` +} + +type inputSchema struct { + Type string `json:"type"` + Properties map[string]schemaProperty `json:"properties"` + AdditionalProperties bool `json:"additionalProperties"` +} + +type schemaProperty struct { + Type string `json:"type"` + Description string `json:"description"` +} + +type callParams struct { + Name string `json:"name"` + Arguments struct { + Vendor string `json:"vendor"` + } `json:"arguments"` +} + +type callResult struct { + Content []content `json:"content"` + // StructuredContent is a pointer so a failed call omits it entirely. An + // empty document here would be a fleet with nothing in it, which is a + // measurement — and this is the absence of one. + StructuredContent *snapshot.Document `json:"structuredContent,omitempty"` + IsError bool `json:"isError"` +} + +type content struct { + Type string `json:"type"` + Text string `json:"text"` +} diff --git a/internal/mcpserver/mcpserver_test.go b/internal/mcpserver/mcpserver_test.go new file mode 100644 index 0000000..578ef07 --- /dev/null +++ b/internal/mcpserver/mcpserver_test.go @@ -0,0 +1,359 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/sanlee-ys/telltale/internal/model" + "github.com/sanlee-ys/telltale/internal/snapshot" +) + +// Every test here drives the server the way a client does: lines of JSON in, +// lines of JSON out. Nothing calls a handler directly, because the framing is +// half of what this package is — a handler that answered correctly into a +// stream nobody could parse would pass a unit test and fail every client. + +// fixture is a document with both halves of the zero-vs-absent pair in it, and +// no scan behind it. The server must carry it through untouched; it is not this +// package's business to produce one. +func fixture() snapshot.Document { + at := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + zero := 0.0 + ctx := 61.3 + return snapshot.Document{ + SchemaVersion: snapshot.SchemaVersion, + GeneratedAt: at, + Fleet: snapshot.Fleet{ + Sessions: 2, + Live: 1, + VendorsWatching: 2, + ContextPctMax: &ctx, + // CostUSDTotal stays nil: no session anywhere reported a cost. That + // is the absent half, and it must never print as 0. + }, + Vendors: []snapshot.Vendor{ + { + Vendor: model.VendorClaude, Status: "watching", Sessions: 2, Live: 1, + ContextPctMax: &ctx, + Quota: []snapshot.QuotaWindow{ + // A measured zero, beside a fleet cost that is absent. + {ID: "seven_day", Label: "7d", UsedPct: &zero}, + }, + QuotaReadAt: &at, + Estimated: []string{model.FieldContextPercent.String()}, + Unsupported: []string{}, + }, + { + Vendor: model.VendorCodex, Status: "not detected", Sessions: 0, + Quota: []snapshot.QuotaWindow{}, Estimated: []string{}, + Unsupported: []string{model.FieldCost.String()}, + }, + }, + } +} + +func options() Options { + return Options{ + Name: "telltale", + Version: "test", + Fleet: func(_ context.Context, vendor string) (snapshot.Document, error) { + if vendor != "all" && vendor != "claude" { + return snapshot.Document{}, errUnknownVendor(vendor) + } + return fixture(), nil + }, + } +} + +type vendorError string + +func (e vendorError) Error() string { return string(e) } + +func errUnknownVendor(v string) error { + return vendorError("unknown --vendor " + v + " (want all, claude, codex, gemini, agy, cursor, grok, pi, self-reported)") +} + +// drive runs the server over the given request lines and returns the response +// lines it wrote. +func drive(t *testing.T, opt Options, lines ...string) []map[string]json.RawMessage { + t.Helper() + in := strings.NewReader(strings.Join(lines, "\n") + "\n") + var out strings.Builder + if err := Serve(context.Background(), in, &out, opt); err != nil { + t.Fatalf("Serve returned %v; a closed stdin is a clean end of session", err) + } + var got []map[string]json.RawMessage + for _, line := range strings.Split(strings.TrimRight(out.String(), "\n"), "\n") { + if line == "" { + continue + } + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("the server wrote a line no client could parse: %q (%v)", line, err) + } + got = append(got, m) + } + return got +} + +const initLine = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}` +const callLine = `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fleet_snapshot","arguments":{}}}` + +// TestTheHandshakeAnswersAndTheNotificationDoesNot pins the two halves of the +// opening exchange every client performs. The second half is the one worth a +// test: `notifications/initialized` carries no id, and a response to it would +// be a message the client has nothing to correlate with. +func TestTheHandshakeAnswersAndTheNotificationDoesNot(t *testing.T) { + got := drive(t, options(), initLine, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) + if len(got) != 1 { + t.Fatalf("want one response to two messages, got %d: a notification must never be answered", len(got)) + } + var res initResult + mustResult(t, got[0], &res) + if res.ProtocolVersion != "2025-06-18" { + t.Errorf("the server answered %q to a client asking for a version it supports; the handshake echoes a supported revision", res.ProtocolVersion) + } + if res.ServerInfo.Name != "telltale" || res.ServerInfo.Version != "test" { + t.Errorf("serverInfo is %+v; a client's log must name the build that answered", res.ServerInfo) + } +} + +// TestAnUnknownProtocolVersionGetsThePinnedOne: the lifecycle rule is that a +// server which cannot meet the requested revision answers with one it supports. +// Answering with the client's own unknown string would be a claim to speak a +// revision nobody here has read. +func TestAnUnknownProtocolVersionGetsThePinnedOne(t *testing.T) { + got := drive(t, options(), `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}`) + var res initResult + mustResult(t, got[0], &res) + if res.ProtocolVersion != PinnedProtocolVersion { + t.Errorf("got %q, want the pinned %q", res.ProtocolVersion, PinnedProtocolVersion) + } +} + +// TestTheToolListNamesTheOneTool. A client that cannot see the tool never calls +// it, which is the same failure shape as a mode missing from the usage text. +func TestTheToolListNamesTheOneTool(t *testing.T) { + got := drive(t, options(), `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + var res toolsListResult + mustResult(t, got[0], &res) + if len(res.Tools) != 1 || res.Tools[0].Name != ToolName { + t.Fatalf("tools/list returned %+v, want the one tool %s", res.Tools, ToolName) + } + // The description carries the document's honesty rules, and a model that + // does not read them will read a null as a zero. That is the collapse §4a.1 + // forbids, moved one process outward into the agent. + for _, want := range []string{"null", "estimated", "unsupported", "self_reported"} { + if !strings.Contains(res.Tools[0].Description, want) { + t.Errorf("the tool description never mentions %q; the calling model reads this and nothing else before it decides what a value means", want) + } + } +} + +// TestTheToolResultIsTheSnapshotDocumentUnchanged is the whole point of this +// package. +// +// The bytes a client reads must be the bytes `telltale snapshot` prints, from +// the same Encode. If this ever fails, some second serializer has appeared — +// and a second serializer is where zero-vs-absent, the explicit nulls and the +// estimated/unsupported arrays would drift apart from the CLI's document. +func TestTheToolResultIsTheSnapshotDocumentUnchanged(t *testing.T) { + got := drive(t, options(), callLine) + var res callResult + mustResult(t, got[0], &res) + if res.IsError { + t.Fatalf("a healthy call reported an error: %+v", res.Content) + } + want, err := snapshot.Encode(fixture(), false) + if err != nil { + t.Fatal(err) + } + if len(res.Content) != 1 || res.Content[0].Text != string(want) { + t.Errorf("the text content is not snapshot.Encode's output:\n got %q\nwant %q", res.Content[0].Text, want) + } + // structuredContent is the same document again. Encoding it back must give + // the identical bytes, or the two spellings a client may read disagree. + again, err := snapshot.Encode(*res.StructuredContent, false) + if err != nil { + t.Fatal(err) + } + if string(again) != string(want) { + t.Errorf("structuredContent and the text content are different documents:\n got %q\nwant %q", again, want) + } +} + +// TestZeroAndAbsentSurviveTheToolCall asserts the property this repo exists to +// protect, on the new surface, in JSON TYPES rather than in bytes. +// +// The snapshot package already pins it for the document; this pins that nothing +// between Build and the client's parser collapsed it — a re-marshal through a +// map, an `omitempty` on the envelope reaching inside, or a text rendering that +// printed a null as 0. +func TestZeroAndAbsentSurviveTheToolCall(t *testing.T) { + got := drive(t, options(), callLine) + var res callResult + mustResult(t, got[0], &res) + + var doc map[string]any + if err := json.Unmarshal([]byte(res.Content[0].Text), &doc); err != nil { + t.Fatal(err) + } + fleet := doc["fleet"].(map[string]any) + if _, present := fleet["cost_usd_total"]; !present { + t.Fatal("cost_usd_total vanished; an absent reading is an explicit null, never a missing key") + } + if fleet["cost_usd_total"] != nil { + t.Errorf("an absent fleet cost arrived as %v, want null", fleet["cost_usd_total"]) + } + vendors := doc["vendors"].([]any) + window := vendors[0].(map[string]any)["quota"].([]any)[0].(map[string]any) + used, isNumber := window["used_pct"].(float64) + if !isNumber || used != 0 { + t.Errorf("a measured zero arrived as %#v; it must be the number 0", window["used_pct"]) + } +} + +// TestABadVendorIsAResultTheModelCanRead, not a transport error it cannot. +// +// The distinction is the one `telltale snapshot` draws in its own flag +// handling: an input this surface cannot honour comes back with the correction +// in it. Here the corrector is the model, so the correction has to land where +// the model reads — in content, with isError set. +func TestABadVendorIsAResultTheModelCanRead(t *testing.T) { + got := drive(t, options(), `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"fleet_snapshot","arguments":{"vendor":"chatgpt"}}}`) + if _, isErr := got[0]["error"]; isErr { + t.Fatalf("a bad argument came back as a JSON-RPC error: %s", got[0]["error"]) + } + var res callResult + mustResult(t, got[0], &res) + if !res.IsError { + t.Fatal("a bad vendor was reported as a successful call") + } + if res.StructuredContent != nil { + t.Error("a failed call carried a document; there is no measurement behind it") + } + if !strings.Contains(res.Content[0].Text, "chatgpt") { + t.Errorf("the refusal does not name what was wrong: %q", res.Content[0].Text) + } +} + +// TestAnUnknownToolIsAProtocolError, because it is the CLIENT's mistake rather +// than the model's: a client that calls a tool this server never listed is +// wired wrong, and the correction belongs in its plumbing. +func TestAnUnknownToolIsAProtocolError(t *testing.T) { + got := drive(t, options(), `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"fleet_quota"}}`) + var e rpcError + mustError(t, got[0], &e) + if e.Code != codeInvalidParams { + t.Errorf("code %d, want %d", e.Code, codeInvalidParams) + } + if !strings.Contains(e.Message, ToolName) { + t.Errorf("the refusal does not name the tool that does exist: %q", e.Message) + } +} + +// TestMalformedInputAnswersAndKeepsServing. A client that sends one bad line +// must not lose its session: the next request has to be answered. +func TestMalformedInputAnswersAndKeepsServing(t *testing.T) { + got := drive(t, options(), `{"jsonrpc":"2.0",`, `{"jsonrpc":"2.0","id":9,"method":"ping"}`) + if len(got) != 2 { + t.Fatalf("want two responses, got %d: a parse error must not end the session", len(got)) + } + var e rpcError + mustError(t, got[0], &e) + if e.Code != codeParseError { + t.Errorf("code %d, want %d", e.Code, codeParseError) + } + if string(got[0]["id"]) != "null" { + t.Errorf("id %s on a parse error, want null: there is no id to echo", got[0]["id"]) + } + if string(got[1]["id"]) != "9" { + t.Errorf("the second request was answered with id %s", got[1]["id"]) + } +} + +// TestAnUnknownMethodNamesWhatThisServerHas. The surface is three methods wide +// on purpose, so the refusal has to say which three rather than leaving a +// client to probe. +func TestAnUnknownMethodNamesWhatThisServerHas(t *testing.T) { + got := drive(t, options(), `{"jsonrpc":"2.0","id":7,"method":"resources/list"}`) + var e rpcError + mustError(t, got[0], &e) + if e.Code != codeMethodNotFound { + t.Errorf("code %d, want %d", e.Code, codeMethodNotFound) + } + for _, want := range []string{"initialize", "tools/list", "tools/call"} { + if !strings.Contains(e.Message, want) { + t.Errorf("the refusal never names %q: %q", want, e.Message) + } + } +} + +// TestEveryFrameIsOneLine is the transport's own rule, and it is the one that +// breaks a client silently: a document with an embedded newline would be read +// as two frames, the first of them truncated JSON. +func TestEveryFrameIsOneLine(t *testing.T) { + opt := options() + opt.Fleet = func(context.Context, string) (snapshot.Document, error) { + doc := fixture() + msg := "the store refused:\nAccess is denied.\r\n" + doc.ScanError = &msg + return doc, nil + } + in := strings.NewReader(callLine + "\n") + var out strings.Builder + if err := Serve(context.Background(), in, &out, opt); err != nil { + t.Fatal(err) + } + body := out.String() + if strings.Count(body, "\n") != 1 || !strings.HasSuffix(body, "\n") { + t.Fatalf("a scan error with newlines in it broke the framing: %q", body) + } +} + +// TestAResponseFromTheClientIsIgnored. A message with an id and no method is a +// response to a request this server never sent. Answering it would itself be a +// protocol error, so the server must write nothing at all. +func TestAResponseFromTheClientIsIgnored(t *testing.T) { + in := strings.NewReader(`{"jsonrpc":"2.0","id":42,"result":{}}` + "\n") + var out strings.Builder + if err := Serve(context.Background(), in, &out, options()); err != nil { + t.Fatal(err) + } + if out.String() != "" { + t.Errorf("the server answered a response: %q", out.String()) + } +} + +// TestServeRefusesWithoutAScan: a server with no Fleet function would advertise +// a tool it cannot answer, which is a capability claim with nothing behind it. +func TestServeRefusesWithoutAScan(t *testing.T) { + if err := Serve(context.Background(), strings.NewReader(""), &strings.Builder{}, Options{}); err == nil { + t.Fatal("Serve accepted a server that can answer nothing") + } +} + +func mustResult(t *testing.T, msg map[string]json.RawMessage, into any) { + t.Helper() + raw, ok := msg["result"] + if !ok { + t.Fatalf("no result in %v", msg) + } + if err := json.Unmarshal(raw, into); err != nil { + t.Fatalf("result %s: %v", raw, err) + } +} + +func mustError(t *testing.T, msg map[string]json.RawMessage, into *rpcError) { + t.Helper() + raw, ok := msg["error"] + if !ok { + t.Fatalf("no error in %v", msg) + } + if err := json.Unmarshal(raw, into); err != nil { + t.Fatalf("error %s: %v", raw, err) + } +}