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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ All notable changes to light-tokenmeter are listed here. The format follows
keychain, `-o json|csv`, exit codes by error class and completion for bash, zsh and fish.
Released as archives with a signed `checksums.txt`, the `coyl/tokenmeter-cli` image and a
Homebrew formula (published once the tap token is set).
- `tokenmeter mcp`: a read-only MCP server for AI assistants over stdio or streamable HTTP
(`--listen`, bearer token) with `usage_summary`, `top_spenders`, `cost_per_action`,
`usage_by_meta`, `health` and a `usage_report` prompt. It refuses a token that can write.
- `pkg/client`, a Go client for the gRPC API.
- `/healthz` reports the service's `version`.

Expand Down
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ tokenmeter token new --name ktms --scope write # the token, t
tokenmeter event send --project p1 --user u1 --action check --vendor openai --model gpt-4o-mini
tokenmeter health
tokenmeter completion zsh > "${fpath[1]}/_tokenmeter" # also bash and fish
tokenmeter mcp # reports for AI assistants
```

- Windows are `--last` (`24h`, `7d`, `4w`; 7 days by default) or `--from`/`--to` in RFC 3339 or
Expand All @@ -255,6 +256,37 @@ tokenmeter completion zsh > "${fpath[1]}/_tokenmeter" # also bash an
- [`pkg/client`](pkg/client) is the Go client underneath: `Record` in batches of 500, `Summary`
with the window splitting, `ByMeta`.

### MCP server for AI assistants

`tokenmeter mcp` lets Claude Code, Claude Desktop, Cursor and other MCP clients answer "what did
team X spend this week", "which actions are expensive per request" or "what did job-42 cost" from
the data here. Every tool only reads:

- `usage_summary` (the summary's filters and groupings, totals or `daily`), `top_spenders` (by cost,
tokens or requests, with shares), `cost_per_action` (cost and tokens per request, latency and the
failed share per `action_id`), `usage_by_meta` (a job tagged in `meta`), `health`;
- amounts as exact `cost_usd` and as `cost_nanousd`, windows in UTC, and a `truncated` result marked
as not a total; capped windows are fetched in parts first, as in the CLI;
- a prompt, `usage_report`.

It needs a token with the `read` scope and refuses one that can also write, so an assistant's
server never records usage. Claude Code (`.mcp.json`), Claude Desktop and Cursor take:

```json
{
"mcpServers": {
"tokenmeter": { "command": "tokenmeter", "args": ["mcp"],
"env": { "TOKENMETER_ADDR": "tm.example.com:443", "TOKENMETER_TLS": "true",
"TOKENMETER_CONTEXT": "prod" } }
}
}
```

Together with Keystone's `ktms mcp` (its `get_spend`), an assistant can say what was spent and how
much of a cap is left. Over the network, `tokenmeter mcp --listen 127.0.0.1:8766` serves
streamable HTTP at `/mcp` with `Authorization: Bearer <token>` from `TOKENMETER_MCP_TOKEN` (16+
characters); it speaks plain HTTP, so keep it on localhost or behind a TLS proxy.

## API reference

### HTTP (`:8080`)
Expand Down Expand Up @@ -307,8 +339,8 @@ releases fix bugs.
Not public, and free to change in any release: log message text (other than the prefix above),
the `internal/` Go packages, the outbox's lease and retry timings, and resource numbers.

The `tokenmeter` CLI (commands, flags, exit codes, `-o json|csv` output) and `pkg/client` are new
in 1.1 and Experimental until 1.2: they may still change in a minor release, with a changelog
The `tokenmeter` CLI (commands, flags, exit codes, `-o json|csv` output), its MCP tools and
`pkg/client` are new in 1.1 and Experimental until 1.2: they may still change in a minor release, with a changelog
note.

### Compatibility with Keystone. The Model Switchboard. (KTMS) and ktms-admin
Expand Down
17 changes: 13 additions & 4 deletions cmd/tokenmeter/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"

"github.com/ai-process/light-tokenmeter/pkg/client"
)
Expand All @@ -29,6 +31,8 @@ type target struct {
envToken string // TOKENMETER_TOKEN, unless a context was asked for by name
warn io.Writer
resolved bool
httpOnce sync.Once
hc *http.Client
}

// resolve fills addr, tls and http from the environment and the context, on
Expand Down Expand Up @@ -137,11 +141,16 @@ func (t *target) httpBase() (string, error) {
return t.http, nil
}

// httpClient is shared: tokenmeter mcp probes health for as long as it runs.
func (t *target) httpClient() *http.Client {
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}}
t.httpOnce.Do(func() {
t.hc = &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
IdleConnTimeout: 90 * time.Second,
}}
})
return t.hc
}

func loopback(addr string) bool {
Expand Down
7 changes: 7 additions & 0 deletions cmd/tokenmeter/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const exitCodesHelp = `Exit codes: 0 done, 1 failed, 2 bad usage,

type usageError struct{ error }

// scopeError is a token without the scopes a command needs, or with more.
type scopeError struct{ error }

func exitCode(err error) int {
if err == nil {
return exitOK
Expand All @@ -35,6 +38,10 @@ func exitCode(err error) int {
if errors.As(err, &u) {
return exitUsage
}
var se scopeError
if errors.As(err, &se) {
return exitAuth
}
var netErr net.Error
if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) {
return exitRetry
Expand Down
68 changes: 39 additions & 29 deletions cmd/tokenmeter/http_cmd.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
Expand All @@ -10,6 +11,41 @@ import (
"github.com/spf13/cobra"
)

type probe struct {
Path string `json:"path"`
Status int `json:"status"`
Body map[string]any `json:"body,omitempty"`
Problem string `json:"error,omitempty"`
}

// probes is shared by tokenmeter health and the MCP health tool.
func (a *app) probes(ctx context.Context) (base string, probes []probe, ready bool, err error) {
base, err = a.t.httpBase()
if err != nil {
return "", nil, false, err
}
ready = true
for _, path := range []string{"/healthz", "/readyz"} {
pr := probe{Path: path}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
if err != nil {
return "", nil, false, err
}
res, err := a.t.httpClient().Do(req)
if err != nil {
pr.Problem, ready = err.Error(), false
} else {
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<16))
res.Body.Close()
pr.Status = res.StatusCode
_ = json.Unmarshal(raw, &pr.Body)
ready = ready && res.StatusCode == http.StatusOK
}
probes = append(probes, pr)
}
return base, probes, ready, nil
}

func (a *app) healthCmd() *cobra.Command {
var format string
cmd := &cobra.Command{
Expand All @@ -21,37 +57,11 @@ func (a *app) healthCmd() *cobra.Command {
if err != nil {
return err
}
base, err := a.t.httpBase()
if err != nil {
return err
}
ctx, cancel := a.ctx()
defer cancel()
type probe struct {
Path string `json:"path"`
Status int `json:"status"`
Body map[string]any `json:"body,omitempty"`
Problem string `json:"error,omitempty"`
}
var probes []probe
ready := true
for _, path := range []string{"/healthz", "/readyz"} {
pr := probe{Path: path}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
if err != nil {
return err
}
res, err := a.t.httpClient().Do(req)
if err != nil {
pr.Problem, ready = err.Error(), false
} else {
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<16))
res.Body.Close()
pr.Status = res.StatusCode
_ = json.Unmarshal(raw, &pr.Body)
ready = ready && res.StatusCode == http.StatusOK
}
probes = append(probes, pr)
base, probes, ready, err := a.probes(ctx)
if err != nil {
return err
}
if p.json() {
if err := p.data(map[string]any{"http": base, "probes": probes}); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/tokenmeter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ events. Without one the call goes out unauthenticated.
pf.StringVar(&a.t.tokenFile, "token-file", "", "read the token from this file instead of TOKENMETER_TOKEN")
pf.DurationVar(&a.timeout, "timeout", time.Minute, "time limit for each command")

root.AddCommand(a.usageCmd(), a.tokenCmd(), a.eventCmd(), a.healthCmd(), a.contextCmd(), &cobra.Command{
root.AddCommand(a.usageCmd(), a.tokenCmd(), a.eventCmd(), a.healthCmd(), a.mcpCmd(), a.contextCmd(), &cobra.Command{
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
fmt.Fprintln(a.stdout, version)
Expand Down
2 changes: 1 addition & 1 deletion cmd/tokenmeter/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func TestContexts(t *testing.T) {
t.Fatal(err)
}
if tg.addr != "tm.example.com:443" || !tg.tls || tg.http != "https://tm.example.com:8080" {
t.Fatalf("resolved %+v", tg)
t.Fatalf("resolved %+v", &tg)
}
if tok, _ := tg.token(); tok != "tok-env" {
t.Fatalf("token = %q", tok)
Expand Down
Loading
Loading