@modelstat/mcp 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -15
  2. package/dist/index.js +4 -3
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  Ask any MCP-compatible AI tool — Claude Desktop, Claude Code, Cursor, Cline, Continue, Zed — about your token spend directly in the chat.
4
4
 
5
5
  - "How much did I spend on Cursor this week?"
6
- - "Which project is driving my Claude Code cost?"
6
+ - "Total $ I spent debugging the chainvisor project."
7
7
  - "Show me recent sessions over $5."
8
- - "Is my modelstat agent healthy?"
8
+ - "What's this session costing so far?"
9
9
 
10
- Uses the bearer token [`npx modelstat@latest`](https://modelstat.ai/install) already wrote to `~/.config/modelstat/state.json` no separate auth.
10
+ **No daemon required.** If the [modelstat daemon](https://modelstat.ai/install) is installed, the MCP reuses its device token automatically (fast path). If not, the first tool call opens a browser tab to connect this MCP to your account (one-time), then remembers it in `~/.modelstat/mcp-auth.json`.
11
11
 
12
12
  ## Install
13
13
 
@@ -78,34 +78,37 @@ mcpServers:
78
78
 
79
79
  ## Tools
80
80
 
81
- All tools are **read-only** except `assign_session`.
81
+ The pattern: **resolve names → ids with the `find_*` tools, then pass the ids as filters to `explore`/`sessions`.** All tools are **read-only** except `assign_session`.
82
82
 
83
83
  | Tool | Purpose |
84
84
  |---|---|
85
- | `usage_overview` | Spend/usage headline: cost, list price, savings, tokens, sessions. |
86
- | `usage_explore` | The charting workhorse — group/stack by `day`, `hour`, `model`, `tool`, `provider`, `session`; metrics from cost to per-class tokens (`tokens_input`, `tokens_cache_read`, …); filter by providers/models/tools/session_ids. |
87
- | `list_sessions` | Recent sessions with cost + tokens (cursor-paginated). |
88
- | `session_detail` | One session's token breakdown + segments (redacted abstracts, tags). |
89
- | `sessions_usage` | Combined usage for an explicit session-id set — e.g. one Claude Code conversation across all its compactions/resumes. |
85
+ | `overview` | Spend/usage headline: effective cost, list price, savings, tokens, sessions, taxonomy roots. |
86
+ | `explore` | The analytics workhorse — group/stack by `day`, `hour`, `model`, `tool`, `provider`, `session`, `identity`, `taxonomy`; metrics from cost to per-class tokens; filter by providers/models/tools/identities/session_ids and a `taxonomy` AND-of-OR filter (`[[projId],[debugId]]` = tagged BOTH). |
87
+ | `sessions` | Search/list sessions filter by taxonomy groups, identity, device, free-text `q`, range (cursor-paginated). |
88
+ | `session_insights` | Live per-session insights for the CURRENT session: tokens, effective $, taxonomy detected, + a `ready`/`analyzing`/`not_ingested` status. `eager:true` force-scans the session locally first; then re-poll while `analyzing`. |
89
+ | `find_taxonomy` | Resolve taxonomy node names ids (optional `root_key`). |
90
+ | `find_projects` | List/search projects (the `workstreams` root) → node ids, with spend. |
91
+ | `find_people` | Search identities → ids for the `identities` filter. |
90
92
  | `assign_session` | MUTATING: reassign a session's owner. |
91
93
 
92
94
  `range` accepts: `today`, `7d`, `30d`, `90d`, `mtd`, `ytd` — or pass explicit RFC3339 `from`/`to`. Omit both for all-time.
93
95
 
94
96
  Prefer remote? The same tools are served over streamable HTTP at `https://modelstat.ai/mcp` — auth with `Authorization: Bearer $(npx -y modelstat@latest token)`. Claude Code users: the [modelstat plugin](https://modelstat.ai/dashboard/mcp) bundles this server and adds the `/stat` charts command.
95
97
 
96
- Your MCP client may see additional tools beyond the ones listed above — the live catalog comes from the modelstat backend, and we add new query tools server-side. Ask your client to list available tools to see what's actually exposed for your account.
98
+ Your MCP client may see additional tools beyond the ones listed above — the live catalog comes from the modelstat backend, and the bridge forwards it verbatim. Ask your client to list available tools to see what's actually exposed for your account.
97
99
 
98
100
  ## Auth & privacy
99
101
 
100
- The MCP server reads the bearer token that `npx modelstat@latest` stored locally. It never transmits that token anywhere except directly to the modelstat API (default `https://modelstat.ai`). Prompts, responses, and file contents never touch this process.
102
+ Auth resolution, most-trusted first: (1) `MODELSTAT_TOKEN` env, (2) a running daemon's token (`modelstat token`), (3) the daemon identity at `~/.modelstat/identity.json`, (4) our own `~/.modelstat/mcp-auth.json` written by the browser claim. With none of those, the first tool call runs the browser claim. The bearer is sent only to the modelstat API (default `https://modelstat.ai`); prompts, responses, and file contents never touch this process.
101
103
 
102
- Override the API endpoint with `MODELSTAT_API_URL` (for self-hosted / dev). Override the state dir with `MODELSTAT_STATE_DIR`.
104
+ Override the API endpoint with `MODELSTAT_API_URL` (self-hosted / dev), the bearer with `MODELSTAT_TOKEN`, or the daemon home with `MODELSTAT_HOME`. Set `MODELSTAT_MCP_BROWSER_AUTH=0` to disable the browser claim (or `=1` to force it in a non-TTY context).
103
105
 
104
106
  ## Troubleshooting
105
107
 
106
- - **`modelstat is not paired on this machine`** run `npx modelstat@latest` first.
107
- - **401 responses** — the bearer expired. Re-run `npx modelstat@latest`.
108
- - **No data yet** — the agent uploads within a few seconds of your first AI-tool session. Check `npx modelstat@latest status`.
108
+ - **`modelstat isn't connected yet`** claim it via the browser tab the first tool call opens, or install the daemon with `npx modelstat@latest`.
109
+ - **401 responses** — the token expired. Re-run `npx modelstat@latest`, or just retry to re-claim.
110
+ - **No data yet** — usage uploads within a few seconds of your first AI-tool session. Check `npx modelstat@latest status`.
111
+ - **No browser opened in a headless host** — set `MODELSTAT_TOKEN=$(modelstat token)` (or `MODELSTAT_MCP_BROWSER_AUTH=1` to force the prompt).
109
112
 
110
113
  ## License
111
114
 
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import{Server as $}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as j}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as D,ListToolsRequestSchema as N}from"@modelcontextprotocol/sdk/types.js";var a=class extends Error{constructor(s,o,n){super(s);this.status=o;this.body=n;this.name="ApiError"}status;body};function x(e){if(!e.bearer)throw new Error("modelstat is not paired on this machine. Run `npx modelstat@latest` to pair, or install the CLI first: https://modelstat.ai/install")}async function y(e,t,s,o={}){x(e);let n=new URL(s.startsWith("/")?s:`/${s}`,e.apiUrl),r=o.timeoutMs!=null?AbortSignal.timeout(o.timeoutMs):void 0,c={Authorization:`Bearer ${e.bearer}`,Accept:"application/json"};t==="POST"&&(c["content-type"]="application/json");let i=await fetch(n,{method:t,headers:c,body:o.body!==void 0?JSON.stringify(o.body):void 0,signal:r});if(!i.ok){let k=await i.text().catch(()=>"");throw new a(`${i.status} ${i.statusText} for ${n.pathname}`,i.status,k)}return await i.json()}var d={listTools(e,t={}){return y(e,"GET","/v1/mcp/tools",{timeoutMs:t.timeoutMs??1500})},callTool(e,t,s){return y(e,"POST","/v1/mcp/call",{body:{name:t,arguments:s}})}};import{execFileSync as P}from"child_process";import{mkdirSync as M,readFileSync as h,renameSync as E,unlinkSync as C,writeFileSync as I}from"fs";import{homedir as R,platform as A}from"os";import{dirname as f,join as l}from"path";function U(){let e="modelstat-agent-dev-nodejs",t=R();if(A()==="darwin")return l(t,"Library","Preferences",e,"config.json");let s=process.env.XDG_CONFIG_HOME,o=s&&s.length>0?s:l(t,".config");return l(o,e,"config.json")}function O(){try{let e=P("modelstat",["paths","--json"],{stdio:["ignore","pipe","ignore"],timeout:2e3,encoding:"utf8"}),t=JSON.parse(e);if(t.state)return{statePath:t.state,apiUrl:t.api}}catch{}return null}function u(){let e=process.env.MODELSTAT_API_URL??process.env.AGENT_API_URL,t=O(),o=process.env.MODELSTAT_STATE_FILE??t?.statePath??U(),n={};try{let c=h(o,"utf8");n=JSON.parse(c)}catch{}let r=n;return{bearer:typeof r.bearerToken=="string"?r.bearerToken:void 0,deviceId:typeof r.deviceId=="string"?r.deviceId:void 0,deviceUuid:typeof r.deviceUuid=="string"?r.deviceUuid:void 0,userEmail:typeof r.userEmail=="string"?r.userEmail:void 0,apiUrl:e??t?.apiUrl??(typeof r.apiUrl=="string"&&r.apiUrl&&r.apiUrl!=="http://localhost:3010"?r.apiUrl:"https://modelstat.ai"),statePath:o}}function v(e){return l(f(e.statePath),"mcp-tools-cache.json")}function S(e){try{let t=h(v(e),"utf8"),s=JSON.parse(t);if(Array.isArray(s.tools))return{tools:s.tools}}catch{}return null}function b(e,t){let s=v(e),o=`${s}.tmp-${process.pid}`;try{M(f(s),{recursive:!0}),I(o,JSON.stringify(t),{encoding:"utf8",mode:384}),E(o,s)}catch{try{C(o)}catch{}}}var L=["today","7d","30d","90d","mtd","ytd"],T=["provider","model","tool","day","hour","device","identity","session"],F=["cost","list","tokens","events","sessions","tokens_input","tokens_output","tokens_cache_read","tokens_cache_creation","tokens_reasoning"],_={range:{type:"string",enum:[...L],description:"Named time window (ignored when from/to given). Omit range AND from/to for all-time."},from:{type:"string",description:"RFC3339 inclusive lower bound (overrides `range`)"},to:{type:"string",description:"RFC3339 exclusive upper bound (overrides `range`)"}},w=[{name:"usage_overview",description:"Spend/usage headline for the account: effective cost, list-price cost, savings, total tokens, event count, distinct sessions. Start here for 'how much did I spend?'. Costs are exact decimal strings in USD.",inputSchema:{type:"object",properties:{..._}}},{name:"usage_explore",description:"The charting workhorse: group-by (and optionally stack-by) any dimension, pick a metric, filter, and get back cells + whole-set totals. Time series: group_by=day or hour (cells come back chronologically \u2014 ideal for line/bar charts); stacked series: add stack_by=model|tool|provider. Leaderboards: group_by=model|tool|session etc. (sorted by value). Token-class metrics (tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning) split the raw token volume \u2014 e.g. cache-hit-rate = tokens_cache_read vs tokens. Filters (providers/models/tools/session_ids) are exact-match lists; session_ids scopes everything to those sessions (pass a whole compaction chain for one logical conversation). Cost values are exact decimal USD strings.",inputSchema:{type:"object",properties:{group_by:{type:"string",enum:[...T],default:"day"},stack_by:{type:"string",enum:[...T],description:"Optional second dimension; each cell carries `stack`."},metric:{type:"string",enum:[...F],default:"cost"},providers:{type:"array",items:{type:"string"},description:'e.g. ["anthropic"]'},models:{type:"array",items:{type:"string"}},tools:{type:"array",items:{type:"string"},description:'e.g. ["claude_code", "cursor"]'},session_ids:{type:"array",items:{type:"string"}},limit:{type:"integer",minimum:1,maximum:500,default:50,description:"Top-N cap on returned groups."},..._}}},{name:"list_sessions",description:"The account's sessions, most recent activity first (cursor-paginated). Each row: session_id, tool, total tokens, effective cost. Use session_detail or sessions_usage to drill in.",inputSchema:{type:"object",properties:{limit:{type:"integer",minimum:1,maximum:500},cursor:{type:"string"}}}},{name:"session_detail",description:"One session with its full token breakdown and its segments (time-bounded slices with redacted abstracts + tags). The segment abstracts tell you WHAT the session was about.",inputSchema:{type:"object",required:["session_id"],properties:{session_id:{type:"string"}}}},{name:"sessions_usage",description:"Aggregate usage over an EXPLICIT set of session ids: per-session rows (tool, time bounds, per-class tokens, cost) plus a combined roll-up. Built for 'current session' analysis in Claude Code: one logical conversation spans several session ids across compactions/resumes \u2014 pass every sessionId found in the transcript chain and read the combined block. Ids not (yet) ingested are listed in missing_session_ids.",inputSchema:{type:"object",required:["session_ids"],properties:{session_ids:{type:"array",items:{type:"string"},minItems:1,maxItems:200}}}},{name:"assign_session",description:"MUTATING: reassign a session's owner/identity.",inputSchema:{type:"object",required:["session_id","target"],properties:{session_id:{type:"string"},target:{type:"string",description:"identity/owner to assign"}}}}],g=new $({name:"modelstat",version:"0.0.2"},{capabilities:{tools:{}}});g.setRequestHandler(N,async()=>{let e=u();try{let t=await d.listTools(e,{timeoutMs:1500});return b(e,t),m(`tools=remote count=${t.tools.length}`),{tools:t.tools}}catch(t){let s=t.message,o=S(e);return o?(m(`tools=cached count=${o.tools.length} (remote=${s})`),{tools:o.tools}):(m(`tools=static count=${w.length} (remote=${s})`),{tools:w})}});g.setRequestHandler(D,async e=>{let t=u(),s=e.params.name,o=e.params.arguments??{};try{return await d.callTool(t,s,o)}catch(n){if(n instanceof a){if(n.status===401)return p("modelstat API returned 401. Your bearer token may have expired \u2014 run `npx modelstat@latest` to re-pair.");if(n.status===404)return p(`Tool \`${s}\` is no longer available \u2014 your MCP catalog may be out of date. Restart your MCP client to refresh.`);let r=n.body?`: ${n.body.slice(0,400)}`:"";return p(`modelstat API error (${n.status})${r}`)}return p(n.message)}});function p(e){return{isError:!0,content:[{type:"text",text:e}]}}function m(e){process.stderr.write(`modelstat-mcp: ${e}
3
- `)}async function G(){let e=new j;await g.connect(e),process.stderr.write(`modelstat-mcp: ready
4
- `)}G().catch(e=>{process.stderr.write(`modelstat-mcp: fatal: ${e.message}
2
+ import{Server as oe}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as se}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as ie,ListToolsRequestSchema as ae}from"@modelcontextprotocol/sdk/types.js";var u=class extends Error{constructor(r,n,o){super(r);this.status=n;this.body=o;this.name="ApiError"}status;body};function q(e){if(!e.bearer)throw new Error("modelstat is not paired on this machine. Run `npx modelstat@latest` to pair, or install the CLI first: https://modelstat.ai/install")}async function T(e,t,r,n={}){q(e);let o=new URL(r.startsWith("/")?r:`/${r}`,e.apiUrl),s=n.timeoutMs!=null?AbortSignal.timeout(n.timeoutMs):void 0,c={Authorization:`Bearer ${e.bearer}`,Accept:"application/json"};t==="POST"&&(c["content-type"]="application/json");let i=await fetch(o,{method:t,headers:c,body:n.body!==void 0?JSON.stringify(n.body):void 0,signal:s});if(!i.ok){let d=await i.text().catch(()=>"");throw new u(`${i.status} ${i.statusText} for ${o.pathname}`,i.status,d)}return await i.json()}var v={listTools(e,t={}){return T(e,"GET","/v1/mcp/tools",{timeoutMs:t.timeoutMs??1500})},callTool(e,t,r){return T(e,"POST","/v1/mcp/call",{body:{name:t,arguments:r}})}};import{spawn as z}from"child_process";import{hostname as W,platform as I,release as K}from"os";import{execFileSync as b}from"child_process";import{mkdirSync as R,readFileSync as k,renameSync as x,unlinkSync as O,writeFileSync as A}from"fs";import{homedir as F}from"os";import{dirname as w,join as y}from"path";var l="https://modelstat.ai",H="http://localhost:3010";function M(){let e=process.env.MODELSTAT_HOME?.trim();return e&&e.length>0?e:y(F(),".modelstat")}function S(){return y(M(),"mcp-auth.json")}function J(){try{let e=b("modelstat",["paths","--json"],{stdio:["ignore","pipe","ignore"],timeout:2e3,encoding:"utf8"});return JSON.parse(e)}catch{return null}}function B(){try{let e=b("modelstat",["token","--json"],{stdio:["ignore","pipe","ignore"],timeout:2e3,encoding:"utf8"}),t=JSON.parse(e);if(t.token)return{token:t.token,api:t.api}}catch{}return null}function E(e){try{let t=JSON.parse(k(e,"utf8"));return{bearer:typeof t.bearerToken=="string"?t.bearerToken:void 0,deviceId:typeof t.deviceId=="string"?t.deviceId:void 0,deviceUuid:typeof t.deviceUuid=="string"?t.deviceUuid:void 0}}catch{return{}}}function G(){return E(S())}function f(){let e=process.env.MODELSTAT_API_URL??process.env.DAEMON_API_URL,t=process.env.MODELSTAT_TOKEN,r=S(),n=g=>g&&g.length>0&&g!==H?g:void 0;if(t)return{bearer:t,apiUrl:n(e)??l,authPath:r,source:"env"};let o=B();if(o)return{bearer:o.token,apiUrl:n(e)??n(o.api)??l,authPath:r,source:"daemon-token"};let s=J(),c=process.env.MODELSTAT_STATE_FILE??s?.identity??y(M(),"identity.json"),i=E(c);if(i.bearer)return{bearer:i.bearer,deviceId:i.deviceId,deviceUuid:i.deviceUuid,apiUrl:n(e)??n(s?.api)??l,authPath:r,source:"daemon-identity"};let d=G();return d.bearer?{bearer:d.bearer,deviceId:d.deviceId,deviceUuid:d.deviceUuid,apiUrl:n(e)??l,authPath:r,source:"mcp-auth"}:{apiUrl:n(e)??l,authPath:r,source:"none"}}function P(e){let t=S(),r=`${t}.tmp-${process.pid}`,n={bearerToken:e.bearer,deviceId:e.deviceId??null,deviceUuid:e.deviceUuid??null,createdAt:new Date().toISOString(),source:"mcp-browser-claim"};try{R(w(t),{recursive:!0}),A(r,JSON.stringify(n),{encoding:"utf8",mode:384}),x(r,t)}catch{try{O(r)}catch{}}}function C(e){return y(w(e.authPath),"mcp-tools-cache.json")}function U(e){try{let t=k(C(e),"utf8"),r=JSON.parse(t);if(Array.isArray(r.tools))return{tools:r.tools}}catch{}return null}function D(e,t){let r=C(e),n=`${r}.tmp-${process.pid}`;try{R(w(r),{recursive:!0}),A(n,JSON.stringify(t),{encoding:"utf8",mode:384}),x(n,r)}catch{try{O(n)}catch{}}}function a(e){process.stderr.write(`modelstat-mcp: ${e}
3
+ `)}function Y(){return globalThis.crypto.randomUUID()}function V(e){let t=I(),r=t==="darwin"?"open":t==="win32"?"cmd":"xdg-open",n=t==="win32"?["/c","start","",e]:[e];try{return z(r,n,{stdio:"ignore",detached:!0}).unref(),!0}catch{return!1}}function Q(e){return new Promise(t=>setTimeout(t,e))}async function L(e){if(!ee())return a("not paired and no interactive terminal \u2014 set MODELSTAT_MCP_BROWSER_AUTH=1 to claim via browser, or install the daemon (npx modelstat@latest)."),e;let t;try{t=await te(e.apiUrl)}catch(n){return a(`device self-register failed: ${n.message}`),e}a(""),a("modelstat needs to connect this MCP to your account (one-time)."),a(`Opening: ${t.claim_url}`),a("If your browser didn't open, paste that URL and sign in to claim the device."),a(""),V(t.claim_url);let r=Date.now()+Z;for(;Date.now()<r;){await Q(X);let n=null;try{n=await re(e.apiUrl,t.device_secret)}catch{}if(n&&(n.status==="claimed"||n.user_id&&n.user_id.length>0))return P({bearer:t.device_secret,deviceId:t.device_id,deviceUuid:t.device_uuid}),a("\u2713 connected \u2014 this MCP is now linked to your modelstat account."),{...e,bearer:t.device_secret,deviceId:t.device_id,deviceUuid:t.device_uuid,source:"mcp-auth"}}return a("timed out waiting for the device to be claimed. Re-run your request after signing in, or install the daemon: npx modelstat@latest."),e}var X=2500,Z=3*6e4;function ee(){return process.env.MODELSTAT_MCP_BROWSER_AUTH==="0"?!1:process.env.MODELSTAT_MCP_BROWSER_AUTH==="1"?!0:process.stderr.isTTY===!0}async function te(e){let t=await fetch(new URL("/v1/devices/self-register",e),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({device_uuid:Y(),fingerprint:{source:"mcp",hostname:W(),platform:I(),release:K()}})});if(!t.ok)throw new Error(`${t.status} ${t.statusText}: ${(await t.text().catch(()=>"")).slice(0,200)}`);return await t.json()}async function re(e,t){let r=await fetch(new URL("/v1/devices/me",e),{headers:{authorization:`Bearer ${t}`}});if(!r.ok)throw new Error(`devices/me ${r.status}`);return await r.json()}function ne(){return Number(process.env.MODELSTAT_LOCAL_INGEST_PORT)||4319}async function N(e,t={}){if(e.length===0)return{kind:"no_daemon"};let r=t.wait!==!1,n=new AbortController,o=setTimeout(()=>n.abort(),t.timeoutMs??15e3);try{let s=await fetch(`http://127.0.0.1:${ne()}/v1/control/scan`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({session_ids:e,wait:r}),signal:n.signal});return s.ok?(await s.json().catch(()=>({})),{kind:"scanned"}):{kind:"error",message:`control scan ${s.status}`}}catch(s){let c=s.cause?.code;return c==="ECONNREFUSED"||c==="ECONNRESET"?{kind:"no_daemon"}:{kind:"error",message:s.message}}finally{clearTimeout(o)}}var ce=["today","7d","30d","90d","mtd","ytd"],$=["provider","model","tool","day","hour","device","identity","session","taxonomy"],de=["cost","list","tokens","events","sessions","tokens_input","tokens_output","tokens_cache_read","tokens_cache_creation","tokens_reasoning"],h={range:{type:"string",enum:[...ce],description:"Named time window (ignored when from/to given). Omit range AND from/to for all-time."},from:{type:"string",description:"RFC3339 inclusive lower bound (overrides `range`)"},to:{type:"string",description:"RFC3339 exclusive upper bound (overrides `range`)"}},j=[{name:"overview",description:"Headline spend/usage for the account: effective cost, list-price cost, savings, total tokens, event count, distinct sessions, ROI (repos/PRs), and taxonomy roots. Start here for 'how much did I spend?'. Costs are exact decimal USD strings.",inputSchema:{type:"object",properties:{...h}}},{name:"explore",description:"The analytics workhorse (event/segment grain): group-by (and optionally stack-by) any dimension, pick a metric, filter, get back cells + whole-set totals. Time series: group_by=day|hour. Leaderboards: group_by=model|tool|session|identity. The `taxonomy` filter takes AND-of-OR groups [[idA,idB],[idC]] (AND across groups, OR within; a flat array is one OR-group) \u2014 resolve ids first with find_taxonomy / find_projects. This is how you answer cross-cutting questions like 'total $ debugging the chainvisor project': find both ids, then explore with taxonomy:[[proj],[debug]]. Costs are exact decimal USD strings.",inputSchema:{type:"object",properties:{group_by:{type:"string",enum:[...$],default:"day"},stack_by:{type:"string",enum:[...$],description:"Optional second dimension; each cell carries `stack`."},metric:{type:"string",enum:[...de],default:"cost"},taxonomy:{type:"array",description:"AND-of-OR taxonomy node-id groups, e.g. [[projId],[debugId]] = tagged BOTH. A flat array [a,b] is one OR-group. Auto-expanded to subtrees server-side.",items:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]}},providers:{type:"array",items:{type:"string"},description:'e.g. ["anthropic"]'},models:{type:"array",items:{type:"string"}},tools:{type:"array",items:{type:"string"},description:'e.g. ["claude_code"]'},identities:{type:"array",items:{type:"string"},description:"identity ids"},session_ids:{type:"array",items:{type:"string"}},limit:{type:"integer",minimum:1,maximum:500,default:50,description:"Top-N cap on returned groups."},...h}}},{name:"sessions",description:"Search/list sessions, most recent first (cursor-paginated). Filter by taxonomy AND-of-OR groups, project, device, identity, free-text `q`, and range. Each row: session_id, tool, total tokens, effective cost. Resolve names to ids with find_* first.",inputSchema:{type:"object",properties:{q:{type:"string",description:"free-text match over session abstracts/metadata"},taxonomy:{type:"array",description:"AND-of-OR taxonomy node-id groups (see explore.taxonomy).",items:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]}},identity_id:{type:"string"},device_id:{type:"string"},limit:{type:"integer",minimum:1,maximum:500},cursor:{type:"string"},...h}}},{name:"session_insights",description:'Live per-session insights for the CURRENT session: total tokens, effective $ assigned, and the taxonomy nodes detected, plus a status (ready | analyzing | not_ingested). Pass every session id in the transcript chain (compactions/resumes are one logical conversation). Set eager:true to force-scan the session locally first (via a running daemon) and prioritise server enrichment \u2014 then re-call with eager:false every ~2.5s while status=="analyzing" (up to ~20s). Returns a formatted text summary + structuredContent for rendering.',inputSchema:{type:"object",required:["session_ids"],properties:{session_ids:{type:"array",items:{type:"string"},minItems:1,maxItems:200,description:"The session-id chain for one logical conversation."},eager:{type:"boolean",default:!1,description:"Force-scan the session on the local daemon first + prioritise server enrichment. Use once, then poll with eager:false."}}}},{name:"find_taxonomy",description:"Resolve taxonomy node names \u2192 ids. Search by name, optionally scoped to a root_key (e.g. work_type). Returns [{id,name,path,root_key,\u2026}]. Feed the ids into explore/sessions `taxonomy` filters.",inputSchema:{type:"object",properties:{q:{type:"string",description:'name search, e.g. "debugging"'},root_key:{type:"string",description:"restrict to one taxonomy root"},limit:{type:"integer",minimum:1,maximum:100}}}},{name:"find_projects",description:"List/search projects (the `workstreams` taxonomy root) \u2192 node ids, with spend. Returns [{id,name,slug,cost_usd,sessions}] where `id` is a taxonomy node id usable directly as a filter in explore/sessions.",inputSchema:{type:"object",properties:{q:{type:"string",description:"project name search"},limit:{type:"integer",minimum:1,maximum:100},...h}}},{name:"find_people",description:"Search provider-account identities \u2192 ids for the `identities` filter. Returns matching identities you can then pass to explore/sessions.",inputSchema:{type:"object",properties:{q:{type:"string",description:"name/email/handle search"},limit:{type:"integer",minimum:1,maximum:100}}}},{name:"assign_session",description:"MUTATING: reassign a session's owner/identity.",inputSchema:{type:"object",required:["session_id","target"],properties:{session_id:{type:"string"},target:{type:"string",description:"identity/owner to assign"}}}}],_=new oe({name:"modelstat",version:"0.0.3"},{capabilities:{tools:{}}});_.setRequestHandler(ae,async()=>{let e=f();try{let t=await v.listTools(e,{timeoutMs:1500});return D(e,t),p(`tools=remote count=${t.tools.length}`),{tools:t.tools}}catch(t){let r=t.message,n=U(e);return n?(p(`tools=cached count=${n.tools.length} (remote=${r})`),{tools:n.tools}):(p(`tools=static count=${j.length} (remote=${r})`),{tools:j})}});_.setRequestHandler(ie,async e=>{let t=f(),r=e.params.name,n=e.params.arguments??{};if(t.source==="none"&&(t=await L(t),t.source==="none"))return m("modelstat isn't connected yet. Sign in via the browser tab we opened (or run `npx modelstat@latest`), then retry.");r==="session_insights"&&n.eager===!0&&await pe(n);try{return await v.callTool(t,r,n)}catch(o){if(o instanceof u){if(o.status===401)return m("modelstat API returned 401. Your token may have expired \u2014 run `npx modelstat@latest` to re-pair, or retry to re-claim.");if(o.status===404)return m(`Tool \`${r}\` is no longer available \u2014 your MCP catalog may be out of date. Restart your MCP client to refresh.`);let s=o.body?`: ${o.body.slice(0,400)}`:"";return m(`modelstat API error (${o.status})${s}`)}return m(o.message)}});async function pe(e){let t=Array.isArray(e.session_ids)?e.session_ids.filter(n=>typeof n=="string"&&n.length>0):[];if(t.length===0)return;let r=await N(t,{wait:!0});r.kind==="no_daemon"?p("eager: no local daemon on the control port \u2014 proceeding (server will report status)"):r.kind==="error"?p(`eager: daemon scan skipped (${r.message}) \u2014 proceeding`):p("eager: daemon force-scanned the session")}function m(e){return{isError:!0,content:[{type:"text",text:e}]}}function p(e){process.stderr.write(`modelstat-mcp: ${e}
4
+ `)}async function ue(){let e=new se;await _.connect(e);let t=f();process.stderr.write(`modelstat-mcp: ready (auth=${t.source})
5
+ `)}ue().catch(e=>{process.stderr.write(`modelstat-mcp: fatal: ${e.message}
5
6
  `),process.exit(1)});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstat/mcp",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "MCP server for modelstat — ask any MCP-compatible AI tool about your token spend.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -45,6 +45,7 @@
45
45
  "scripts": {
46
46
  "build": "tsup src/index.ts --format esm --out-dir dist --clean --no-splitting --minify",
47
47
  "dev": "tsx src/index.ts",
48
- "typecheck": "tsc --noEmit"
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "node --import tsx --test src/**/*.test.ts"
49
50
  }
50
51
  }