@modelstat/mcp 0.1.3 → 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.
- package/README.md +9 -1
- package/dist/index.js +12 -4
- package/package.json +1 -5
package/README.md
CHANGED
|
@@ -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
|
|
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
|
{
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{Server as
|
|
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 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:[...$],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
|
-
`)}
|
|
5
|
-
|
|
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.
|
|
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",
|
|
@@ -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
|
},
|