@modelstat/mcp 0.1.2 → 0.1.3
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 +2 -2
- package/dist/index.js +1 -1
- package/package.json +2 -2
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
|
|
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
|
|
|
@@ -110,7 +110,7 @@ The pattern: **resolve names → ids with the `find_*` tools, then pass the ids
|
|
|
110
110
|
|
|
111
111
|
`range` accepts: `today`, `7d`, `30d`, `90d`, `mtd`, `ytd` — or pass explicit RFC3339 `from`/`to`. Omit both for all-time.
|
|
112
112
|
|
|
113
|
-
Prefer remote? The same tools are served over streamable HTTP at `https://modelstat.ai
|
|
113
|
+
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
114
|
|
|
115
115
|
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
116
|
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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}
|
|
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
4
|
`)}async function ue(){let e=new se;await _.connect(e);let t=f();process.stderr.write(`modelstat-mcp: ready (auth=${t.source})
|
|
5
5
|
`)}ue().catch(e=>{process.stderr.write(`modelstat-mcp: fatal: ${e.message}
|
|
6
6
|
`),process.exit(1)});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstat/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
|
13
|
+
"homepage": "https://mcp.modelstat.ai",
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
16
16
|
"url": "git+https://github.com/modelstat/modelstat.git",
|