@modelstat/mcp 0.3.0 → 0.4.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 +1 -1
- package/dist/index.js +8 -8
- package/dist/session-insights.html +349 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ The pattern: **resolve names → ids with the `find_*` tools, then pass the ids
|
|
|
110
110
|
| `overview` | Spend/usage headline: effective cost, list price, savings, tokens, sessions, taxonomy roots. |
|
|
111
111
|
| `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). |
|
|
112
112
|
| `sessions` | Search/list sessions — filter by taxonomy groups, identity, device, free-text `q`, range (cursor-paginated). |
|
|
113
|
-
| `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`. |
|
|
113
|
+
| `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`. In [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) hosts (Claude Desktop/web) this renders as an inline card; elsewhere it's text + structured data. |
|
|
114
114
|
| `find_taxonomy` | Resolve taxonomy node names → ids (optional `root_key`). |
|
|
115
115
|
| `find_projects` | List/search projects (the `workstreams` root) → node ids, with spend. |
|
|
116
116
|
| `find_people` | Search identities → ids for the `identities` filter. |
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{Server as
|
|
3
|
-
`)}function
|
|
4
|
-
`),"configured"}catch{return"skipped"}}function
|
|
2
|
+
import{Server as $e}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as je}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as Le,ListResourcesRequestSchema as Ne,ListToolsRequestSchema as We,ReadResourceRequestSchema as Je}from"@modelcontextprotocol/sdk/types.js";var m=class extends Error{constructor(r,n,s){super(r);this.status=n;this.body=s;this.name="ApiError"}status;body};function se(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 A(e,t,r,n={}){se(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 m(`${i.status} ${i.statusText} for ${s.pathname}`,i.status,u)}return await i.json()}var k={listTools(e,t={}){return A(e,"GET","/v1/mcp/tools",{timeoutMs:t.timeoutMs??1500})},callTool(e,t,r){return A(e,"POST","/v1/mcp/call",{body:{name:t,arguments:r}})}};import{spawn as ue}from"child_process";import{hostname as pe,platform as q,release as le}from"os";import{execFileSync as E}from"child_process";import{mkdirSync as P,readFileSync as U,renameSync as D,unlinkSync as I,writeFileSync as $}from"fs";import{homedir as oe}from"os";import{dirname as b,join as v}from"path";var g="https://modelstat.ai",ie="http://localhost:3010";function j(){let e=process.env.MODELSTAT_HOME?.trim();return e&&e.length>0?e:v(oe(),".modelstat")}function x(){return v(j(),"mcp-auth.json")}function ae(){try{let e=E("modelstat",["paths","--json"],{stdio:["ignore","pipe","ignore"],timeout:2e3,encoding:"utf8"});return JSON.parse(e)}catch{return null}}function ce(){try{let e=E("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 L(e){try{let t=JSON.parse(U(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 de(){return L(x())}function w(){let e=process.env.MODELSTAT_API_URL??process.env.DAEMON_API_URL,t=process.env.MODELSTAT_TOKEN,r=x(),n=h=>h&&h.length>0&&h!==ie?h:void 0;if(t)return{bearer:t,apiUrl:n(e)??g,authPath:r,source:"env"};let s=ce();if(s)return{bearer:s.token,apiUrl:n(e)??n(s.api)??g,authPath:r,source:"daemon-token"};let o=ae(),c=process.env.MODELSTAT_STATE_FILE??o?.identity??v(j(),"identity.json"),i=L(c);if(i.bearer)return{bearer:i.bearer,deviceId:i.deviceId,deviceUuid:i.deviceUuid,apiUrl:n(e)??n(o?.api)??g,authPath:r,source:"daemon-identity"};let u=de();return u.bearer?{bearer:u.bearer,deviceId:u.deviceId,deviceUuid:u.deviceUuid,apiUrl:n(e)??g,authPath:r,source:"mcp-auth"}:{apiUrl:n(e)??g,authPath:r,source:"none"}}function N(e){let t=x(),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{P(b(t),{recursive:!0}),$(r,JSON.stringify(n),{encoding:"utf8",mode:384}),D(r,t)}catch{try{I(r)}catch{}}}function W(e){return v(b(e.authPath),"mcp-tools-cache.json")}function J(e){try{let t=U(W(e),"utf8"),r=JSON.parse(t);if(Array.isArray(r.tools))return{tools:r.tools}}catch{}return null}function F(e,t){let r=W(e),n=`${r}.tmp-${process.pid}`;try{P(b(r),{recursive:!0}),$(n,JSON.stringify(t),{encoding:"utf8",mode:384}),D(n,r)}catch{try{I(n)}catch{}}}function d(e){process.stderr.write(`modelstat-mcp: ${e}
|
|
3
|
+
`)}function me(){return globalThis.crypto.randomUUID()}function ge(e){let t=q(),r=t==="darwin"?"open":t==="win32"?"cmd":"xdg-open",n=t==="win32"?["/c","start","",e]:[e];try{return ue(r,n,{stdio:"ignore",detached:!0}).unref(),!0}catch{return!1}}function fe(e){return new Promise(t=>setTimeout(t,e))}async function G(e){if(!ve())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 we(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(""),ge(t.claim_url);let r=Date.now()+he;for(;Date.now()<r;){await fe(ye);let n=null;try{n=await Se(e.apiUrl,t.device_secret)}catch{}if(n&&(n.status==="claimed"||n.user_id&&n.user_id.length>0))return N({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 ye=2500,he=3*6e4;function ve(){return process.env.MODELSTAT_MCP_BROWSER_AUTH==="0"?!1:process.env.MODELSTAT_MCP_BROWSER_AUTH==="1"?!0:process.stderr.isTTY===!0}async function we(e){let t=await fetch(new URL("/v1/devices/self-register",e),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({device_uuid:me(),fingerprint:{source:"mcp",hostname:pe(),platform:q(),release:le()}})});if(!t.ok)throw new Error(`${t.status} ${t.statusText}: ${(await t.text().catch(()=>"")).slice(0,200)}`);return await t.json()}async function Se(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 _e(){return Number(process.env.MODELSTAT_LOCAL_INGEST_PORT)||4319}async function H(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:${_e()}/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{readFileSync as Te}from"fs";import{fileURLToPath as Re}from"url";var M="session_insights",p="ui://modelstat/session-insights",B="text/html;profile=mcp-app",z={ui:{resourceUri:p},"ui/resourceUri":p};function V(e){return e.map(t=>t.name===M?{...t,_meta:{...t._meta,...z}}:t)}function K(e){return{...e,_meta:{...e._meta,...z}}}function Y(){return{uri:p,name:"Session insights",description:"Live card for the current conversation: tokens, $ assigned, and detected work types.",mimeType:B,_meta:{ui:{prefersBorder:!1}}}}var S=null;function ke(){if(S!=null)return S;let e=Re(new URL("./session-insights.html",import.meta.url));return S=Te(e,"utf8"),S}function X(){return{contents:[{uri:p,mimeType:B,text:ke(),_meta:{ui:{prefersBorder:!1}}}]}}import{execFileSync as Z}from"child_process";import{existsSync as T,mkdirSync as be,readFileSync as Q,writeFileSync as ee}from"fs";import{homedir as xe,platform as Me}from"os";import{dirname as Ce,join as a}from"path";var C="npx",O=["-y","@modelstat/mcp"];function _(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 Oe(e,t){let r=_(e,t,"Claude"),n=_(e,t,"Code"),s=_(e,t,"Code - Insiders"),o=_(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 Ae(e){return e==="zed"?{source:"custom",command:C,args:O,env:{}}:{command:C,args:O}}function Ee(e){if(!T(e.detect))return"absent";let t={};if(T(e.file))try{let o=JSON.parse(Q(e.file,"utf8"));o&&typeof o=="object"&&(t=o)}catch{}let r=t[e.key],n=r&&typeof r=="object"?r:{},s=Ae(e.shape);if(JSON.stringify(n.modelstat)===JSON.stringify(s))return"already";n.modelstat=s,t[e.key]=n;try{return be(Ce(e.file),{recursive:!0}),ee(e.file,`${JSON.stringify(t,null,2)}
|
|
4
|
+
`),"configured"}catch{return"skipped"}}function Pe(e){let t=a(e,".codex");if(!T(t))return"absent";let r=a(t,"config.toml"),n="";if(T(r))try{n=Q(r,"utf8")}catch{n=""}if(/\[mcp_servers\.modelstat\]/.test(n))return"already";let s=`[mcp_servers.modelstat]
|
|
5
5
|
command = "npx"
|
|
6
6
|
args = ["-y", "@modelstat/mcp"]
|
|
7
7
|
`,o=n.trim().length>0?`${n.replace(/\s*$/,"")}
|
|
8
8
|
|
|
9
|
-
${s}`:s;try{return
|
|
10
|
-
`)),o=[];n&&o.push({name:"Claude Code",status:
|
|
11
|
-
`)}async function
|
|
12
|
-
`)}process.argv[2]==="wire"?
|
|
13
|
-
`),process.exit(1)}):
|
|
9
|
+
${s}`:s;try{return ee(r,o),"configured"}catch{return"skipped"}}function Ue(){try{Z("claude",["--version"],{stdio:"ignore"})}catch{return"absent"}try{return Z("claude",["mcp","add","modelstat","-s","user","--",C,...O],{stdio:"ignore"}),"configured"}catch{return"already"}}var De={configured:"+",already:"\xB7",absent:"-",skipped:"!"},Ie={configured:"configured",already:"already configured",absent:"not detected, skipped",skipped:"could not write, skipped"};async function te(e={}){let t=e.home??xe(),r=e.platform??Me(),n=e.includeClaudeCode??!0,s=e.log??(i=>process.stdout.write(`${i}
|
|
10
|
+
`)),o=[];n&&o.push({name:"Claude Code",status:Ue()});for(let i of Oe(t,r))o.push({name:i.name,status:Ee(i)});o.push({name:"Codex",status:Pe(t)}),s("modelstat MCP \u2014 wiring your AI tools:");for(let i of o)s(` ${De[i.status]} ${i.name} \u2014 ${Ie[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 Fe=["today","7d","30d","90d","mtd","ytd"],re=["provider","model","tool","day","hour","device","identity","session","taxonomy"],qe=["cost","list","tokens","events","sessions","tokens_input","tokens_output","tokens_cache_read","tokens_cache_creation","tokens_reasoning"],R={range:{type:"string",enum:[...Fe],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`)"}},ne=[{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:{...R}}},{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:[...re],default:"day"},stack_by:{type:"string",enum:[...re],description:"Optional second dimension; each cell carries `stack`."},metric:{type:"string",enum:[...qe],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."},...R}}},{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"},...R}}},{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},...R}}},{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"}}}}],y=new $e({name:"modelstat",version:"0.0.3"},{capabilities:{tools:{},resources:{}}});y.setRequestHandler(We,async()=>{let e=w(),t;try{let r=await k.listTools(e,{timeoutMs:1500});F(e,r),l(`tools=remote count=${r.tools.length}`),t=r.tools}catch(r){let n=r.message,s=J(e);s?(l(`tools=cached count=${s.tools.length} (remote=${n})`),t=s.tools):(l(`tools=static count=${ne.length} (remote=${n})`),t=ne)}return{tools:V(t)}});y.setRequestHandler(Ne,async()=>({resources:[Y()]}));y.setRequestHandler(Je,async e=>{if(e.params.uri!==p)throw new Error(`Unknown resource: ${e.params.uri}`);return X()});y.setRequestHandler(Le,async e=>{let t=w(),r=e.params.name,n=e.params.arguments??{};if(t.source==="none"&&(t=await G(t),t.source==="none"))return f("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 Ge(n);try{let s=await k.callTool(t,r,n);return r===M?K(s):s}catch(s){if(s instanceof m){if(s.status===401)return f("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 f(`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 f(`modelstat API error (${s.status})${o}`)}return f(s.message)}});async function Ge(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 H(t,{wait:!0});r.kind==="no_daemon"?l("eager: no local daemon on the control port \u2014 proceeding (server will report status)"):r.kind==="error"?l(`eager: daemon scan skipped (${r.message}) \u2014 proceeding`):l("eager: daemon force-scanned the session")}function f(e){return{isError:!0,content:[{type:"text",text:e}]}}function l(e){process.stderr.write(`modelstat-mcp: ${e}
|
|
11
|
+
`)}async function He(){let e=new je;await y.connect(e);let t=w();process.stderr.write(`modelstat-mcp: ready (auth=${t.source})
|
|
12
|
+
`)}process.argv[2]==="wire"?te().then(e=>process.exit(e)).catch(e=>{process.stderr.write(`modelstat-mcp: wire failed: ${e.message}
|
|
13
|
+
`),process.exit(1)}):He().catch(e=>{process.stderr.write(`modelstat-mcp: fatal: ${e.message}
|
|
14
14
|
`),process.exit(1)});
|