@modelstat/mcp 0.1.2 → 0.2.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 +11 -3
  2. package/dist/index.js +12 -4
  3. package/package.json +2 -6
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Ask any MCP-compatible AI tool — Claude Desktop, Claude Code, Cursor, Cline, Continue, Zed — about your token spend directly in the chat. The same numbers you see on the [modelstat dashboard](https://modelstat.ai/dashboard), answered inline:
4
4
 
5
5
  - "How much did I spend on Cursor this week?"
6
- - "Total $ I spent debugging the chainvisor project."
6
+ - "Total $ I spent debugging the acme project."
7
7
  - "Show me recent sessions over $5."
8
8
  - "What's this session costing so far?"
9
9
 
@@ -39,9 +39,17 @@ modelstat-mcp
39
39
 
40
40
  ## Wire it up
41
41
 
42
+ **One command wires every tool you have** — Claude Code, Claude Desktop, Cursor, VS Code (+ Insiders / VSCodium), Windsurf, Zed, Codex, Gemini CLI:
43
+
44
+ ```bash
45
+ npx -y @modelstat/mcp wire
46
+ ```
47
+
48
+ It writes the `modelstat` server into each detected tool's config — non-destructive (your other servers are untouched) and safe to re-run. Restart any open tool afterwards. (The [modelstat installer](https://modelstat.ai/install) runs this for you.) Prefer to configure one tool by hand? The recipes are below.
49
+
42
50
  ### Claude Desktop
43
51
 
44
- Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows, unsupported at the moment):
52
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), `~/.config/Claude/claude_desktop_config.json` (Linux), or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
45
53
 
46
54
  ```jsonc
47
55
  {
@@ -110,7 +118,7 @@ The pattern: **resolve names → ids with the `find_*` tools, then pass the ids
110
118
 
111
119
  `range` accepts: `today`, `7d`, `30d`, `90d`, `mtd`, `ytd` — or pass explicit RFC3339 `from`/`to`. Omit both for all-time.
112
120
 
113
- 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.
121
+ Prefer remote? The same tools are served over streamable HTTP at `https://mcp.modelstat.ai` — 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.
114
122
 
115
123
  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.
116
124
 
package/dist/index.js CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env node
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}
2
+ import{Server as Te}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as xe}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as Re,ListToolsRequestSchema as Ce}from"@modelcontextprotocol/sdk/types.js";var l=class extends Error{constructor(r,n,s){super(r);this.status=n;this.body=s;this.name="ApiError"}status;body};function K(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 C(e,t,r,n={}){K(e);let s=new URL(r.startsWith("/")?r:`/${r}`,e.apiUrl),o=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(s,{method:t,headers:c,body:n.body!==void 0?JSON.stringify(n.body):void 0,signal:o});if(!i.ok){let u=await i.text().catch(()=>"");throw new l(`${i.status} ${i.statusText} for ${s.pathname}`,i.status,u)}return await i.json()}var b={listTools(e,t={}){return C(e,"GET","/v1/mcp/tools",{timeoutMs:t.timeoutMs??1500})},callTool(e,t,r){return C(e,"POST","/v1/mcp/call",{body:{name:t,arguments:r}})}};import{spawn as te}from"child_process";import{hostname as re,platform as J,release as ne}from"os";import{execFileSync as O}from"child_process";import{mkdirSync as A,readFileSync as P,renameSync as M,unlinkSync as E,writeFileSync as U}from"fs";import{homedir as Y}from"os";import{dirname as k,join as y}from"path";var m="https://modelstat.ai",X="http://localhost:3010";function D(){let e=process.env.MODELSTAT_HOME?.trim();return e&&e.length>0?e:y(Y(),".modelstat")}function _(){return y(D(),"mcp-auth.json")}function Z(){try{let e=O("modelstat",["paths","--json"],{stdio:["ignore","pipe","ignore"],timeout:2e3,encoding:"utf8"});return JSON.parse(e)}catch{return null}}function Q(){try{let e=O("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 I(e){try{let t=JSON.parse(P(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 ee(){return I(_())}function h(){let e=process.env.MODELSTAT_API_URL??process.env.DAEMON_API_URL,t=process.env.MODELSTAT_TOKEN,r=_(),n=f=>f&&f.length>0&&f!==X?f:void 0;if(t)return{bearer:t,apiUrl:n(e)??m,authPath:r,source:"env"};let s=Q();if(s)return{bearer:s.token,apiUrl:n(e)??n(s.api)??m,authPath:r,source:"daemon-token"};let o=Z(),c=process.env.MODELSTAT_STATE_FILE??o?.identity??y(D(),"identity.json"),i=I(c);if(i.bearer)return{bearer:i.bearer,deviceId:i.deviceId,deviceUuid:i.deviceUuid,apiUrl:n(e)??n(o?.api)??m,authPath:r,source:"daemon-identity"};let u=ee();return u.bearer?{bearer:u.bearer,deviceId:u.deviceId,deviceUuid:u.deviceUuid,apiUrl:n(e)??m,authPath:r,source:"mcp-auth"}:{apiUrl:n(e)??m,authPath:r,source:"none"}}function $(e){let t=_(),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{A(k(t),{recursive:!0}),U(r,JSON.stringify(n),{encoding:"utf8",mode:384}),M(r,t)}catch{try{E(r)}catch{}}}function j(e){return y(k(e.authPath),"mcp-tools-cache.json")}function N(e){try{let t=P(j(e),"utf8"),r=JSON.parse(t);if(Array.isArray(r.tools))return{tools:r.tools}}catch{}return null}function L(e,t){let r=j(e),n=`${r}.tmp-${process.pid}`;try{A(k(r),{recursive:!0}),U(n,JSON.stringify(t),{encoding:"utf8",mode:384}),M(n,r)}catch{try{E(n)}catch{}}}function d(e){process.stderr.write(`modelstat-mcp: ${e}
3
+ `)}function se(){return globalThis.crypto.randomUUID()}function oe(e){let t=J(),r=t==="darwin"?"open":t==="win32"?"cmd":"xdg-open",n=t==="win32"?["/c","start","",e]:[e];try{return te(r,n,{stdio:"ignore",detached:!0}).unref(),!0}catch{return!1}}function ie(e){return new Promise(t=>setTimeout(t,e))}async function W(e){if(!de())return d("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 ue(e.apiUrl)}catch(n){return d(`device self-register failed: ${n.message}`),e}d(""),d("modelstat needs to connect this MCP to your account (one-time)."),d(`Opening: ${t.claim_url}`),d("If your browser didn't open, paste that URL and sign in to claim the device."),d(""),oe(t.claim_url);let r=Date.now()+ce;for(;Date.now()<r;){await ie(ae);let n=null;try{n=await pe(e.apiUrl,t.device_secret)}catch{}if(n&&(n.status==="claimed"||n.user_id&&n.user_id.length>0))return $({bearer:t.device_secret,deviceId:t.device_id,deviceUuid:t.device_uuid}),d("\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 d("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 ae=2500,ce=3*6e4;function de(){return process.env.MODELSTAT_MCP_BROWSER_AUTH==="0"?!1:process.env.MODELSTAT_MCP_BROWSER_AUTH==="1"?!0:process.stderr.isTTY===!0}async function ue(e){let t=await fetch(new URL("/v1/devices/self-register",e),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({device_uuid:se(),fingerprint:{source:"mcp",hostname:re(),platform:J(),release:ne()}})});if(!t.ok)throw new Error(`${t.status} ${t.statusText}: ${(await t.text().catch(()=>"")).slice(0,200)}`);return await t.json()}async function pe(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 le(){return Number(process.env.MODELSTAT_LOCAL_INGEST_PORT)||4319}async function F(e,t={}){if(e.length===0)return{kind:"no_daemon"};let r=t.wait!==!1,n=new AbortController,s=setTimeout(()=>n.abort(),t.timeoutMs??15e3);try{let o=await fetch(`http://127.0.0.1:${le()}/v1/control/scan`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({session_ids:e,wait:r}),signal:n.signal});return o.ok?(await o.json().catch(()=>({})),{kind:"scanned"}):{kind:"error",message:`control scan ${o.status}`}}catch(o){let c=o.cause?.code;return c==="ECONNREFUSED"||c==="ECONNRESET"?{kind:"no_daemon"}:{kind:"error",message:o.message}}finally{clearTimeout(s)}}import{execFileSync as q}from"child_process";import{existsSync as S,mkdirSync as me,readFileSync as z,writeFileSync as B}from"fs";import{homedir as ge,platform as fe}from"os";import{dirname as ye,join as a}from"path";var T="npx",x=["-y","@modelstat/mcp"];function v(e,t,r){return t==="win32"?a(process.env.APPDATA||a(e,"AppData","Roaming"),r):t==="darwin"?a(e,"Library","Application Support",r):a(e,".config",r)}function he(e,t){let r=v(e,t,"Claude"),n=v(e,t,"Code"),s=v(e,t,"Code - Insiders"),o=v(e,t,"VSCodium");return[{name:"Claude Desktop",file:a(r,"claude_desktop_config.json"),key:"mcpServers",detect:r,shape:"command"},{name:"Cursor",file:a(e,".cursor","mcp.json"),key:"mcpServers",detect:a(e,".cursor"),shape:"command"},{name:"VS Code",file:a(n,"User","mcp.json"),key:"servers",detect:n,shape:"command"},{name:"VS Code Insiders",file:a(s,"User","mcp.json"),key:"servers",detect:s,shape:"command"},{name:"VSCodium",file:a(o,"User","mcp.json"),key:"servers",detect:o,shape:"command"},{name:"Windsurf",file:a(e,".codeium","windsurf","mcp_config.json"),key:"mcpServers",detect:a(e,".codeium","windsurf"),shape:"command"},{name:"Gemini CLI",file:a(e,".gemini","settings.json"),key:"mcpServers",detect:a(e,".gemini"),shape:"command"},{name:"Zed",file:a(e,".config","zed","settings.json"),key:"context_servers",detect:a(e,".config","zed"),shape:"zed"}]}function ve(e){return e==="zed"?{source:"custom",command:T,args:x,env:{}}:{command:T,args:x}}function Se(e){if(!S(e.detect))return"absent";let t={};if(S(e.file))try{let o=JSON.parse(z(e.file,"utf8"));o&&typeof o=="object"&&(t=o)}catch{}let r=t[e.key],n=r&&typeof r=="object"?r:{},s=ve(e.shape);if(JSON.stringify(n.modelstat)===JSON.stringify(s))return"already";n.modelstat=s,t[e.key]=n;try{return me(ye(e.file),{recursive:!0}),B(e.file,`${JSON.stringify(t,null,2)}
4
+ `),"configured"}catch{return"skipped"}}function we(e){let t=a(e,".codex");if(!S(t))return"absent";let r=a(t,"config.toml"),n="";if(S(r))try{n=z(r,"utf8")}catch{n=""}if(/\[mcp_servers\.modelstat\]/.test(n))return"already";let s=`[mcp_servers.modelstat]
5
+ command = "npx"
6
+ args = ["-y", "@modelstat/mcp"]
7
+ `,o=n.trim().length>0?`${n.replace(/\s*$/,"")}
8
+
9
+ ${s}`:s;try{return B(r,o),"configured"}catch{return"skipped"}}function be(){try{q("claude",["--version"],{stdio:"ignore"})}catch{return"absent"}try{return q("claude",["mcp","add","modelstat","-s","user","--",T,...x],{stdio:"ignore"}),"configured"}catch{return"already"}}var ke={configured:"+",already:"\xB7",absent:"-",skipped:"!"},_e={configured:"configured",already:"already configured",absent:"not detected, skipped",skipped:"could not write, skipped"};async function H(e={}){let t=e.home??ge(),r=e.platform??fe(),n=e.includeClaudeCode??!0,s=e.log??(i=>process.stdout.write(`${i}
10
+ `)),o=[];n&&o.push({name:"Claude Code",status:be()});for(let i of he(t,r))o.push({name:i.name,status:Se(i)});o.push({name:"Codex",status:we(t)}),s("modelstat MCP \u2014 wiring your AI tools:");for(let i of o)s(` ${ke[i.status]} ${i.name} \u2014 ${_e[i.status]}`);let c=o.filter(i=>i.status==="configured").length;return s(c>0?`Configured ${c} tool${c===1?"":"s"}. Restart any open tool to load the modelstat MCP.`:"Nothing new to configure (already set up, or no supported tools detected)."),0}var Oe=["today","7d","30d","90d","mtd","ytd"],G=["provider","model","tool","day","hour","device","identity","session","taxonomy"],Ae=["cost","list","tokens","events","sessions","tokens_input","tokens_output","tokens_cache_read","tokens_cache_creation","tokens_reasoning"],w={range:{type:"string",enum:[...Oe],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`)"}},V=[{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:{...w}}},{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 acme 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:[...G],default:"day"},stack_by:{type:"string",enum:[...G],description:"Optional second dimension; each cell carries `stack`."},metric:{type:"string",enum:[...Ae],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."},...w}}},{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"},...w}}},{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},...w}}},{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"}}}}],R=new Te({name:"modelstat",version:"0.0.3"},{capabilities:{tools:{}}});R.setRequestHandler(Ce,async()=>{let e=h();try{let t=await b.listTools(e,{timeoutMs:1500});return L(e,t),p(`tools=remote count=${t.tools.length}`),{tools:t.tools}}catch(t){let r=t.message,n=N(e);return n?(p(`tools=cached count=${n.tools.length} (remote=${r})`),{tools:n.tools}):(p(`tools=static count=${V.length} (remote=${r})`),{tools:V})}});R.setRequestHandler(Re,async e=>{let t=h(),r=e.params.name,n=e.params.arguments??{};if(t.source==="none"&&(t=await W(t),t.source==="none"))return g("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 b.callTool(t,r,n)}catch(s){if(s instanceof l){if(s.status===401)return g("modelstat API returned 401. Your token may have expired \u2014 run `npx modelstat@latest` to re-pair, or retry to re-claim.");if(s.status===404)return g(`Tool \`${r}\` is no longer available \u2014 your MCP catalog may be out of date. Restart your MCP client to refresh.`);let o=s.body?`: ${s.body.slice(0,400)}`:"";return g(`modelstat API error (${s.status})${o}`)}return g(s.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 F(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 g(e){return{isError:!0,content:[{type:"text",text:e}]}}function p(e){process.stderr.write(`modelstat-mcp: ${e}
11
+ `)}async function Me(){let e=new xe;await R.connect(e);let t=h();process.stderr.write(`modelstat-mcp: ready (auth=${t.source})
12
+ `)}process.argv[2]==="wire"?H().then(e=>process.exit(e)).catch(e=>{process.stderr.write(`modelstat-mcp: wire failed: ${e.message}
13
+ `),process.exit(1)}):Me().catch(e=>{process.stderr.write(`modelstat-mcp: fatal: ${e.message}
6
14
  `),process.exit(1)});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstat/mcp",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for modelstat — ask any MCP-compatible AI tool about your token spend.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -10,7 +10,7 @@
10
10
  "observability",
11
11
  "token-tracking"
12
12
  ],
13
- "homepage": "https://modelstat.ai/mcp",
13
+ "homepage": "https://mcp.modelstat.ai",
14
14
  "repository": {
15
15
  "type": "git",
16
16
  "url": "git+https://github.com/modelstat/modelstat.git",
@@ -30,10 +30,6 @@
30
30
  "engines": {
31
31
  "node": ">=20"
32
32
  },
33
- "os": [
34
- "darwin",
35
- "linux"
36
- ],
37
33
  "dependencies": {
38
34
  "@modelcontextprotocol/sdk": "^1.0.0"
39
35
  },