@modelstat/mcp 0.4.1 → 0.5.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/dist/index.js +9 -9
- package/package.json +1 -1
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 Xe}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as Ze}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as Qe,GetPromptRequestSchema as et,ListPromptsRequestSchema as tt,ListResourcesRequestSchema as rt,ListToolsRequestSchema as nt,ReadResourceRequestSchema as st}from"@modelcontextprotocol/sdk/types.js";var f=class extends Error{constructor(r,n,s){super(r);this.status=n;this.body=s;this.name="ApiError"}status;body};function de(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 R(e,t,r,n={}){de(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 m=await i.text().catch(()=>"");throw new f(`${i.status} ${i.statusText} for ${s.pathname}`,i.status,m)}return await i.json()}var y={listTools(e,t={}){return R(e,"GET","/v1/mcp/tools",{timeoutMs:t.timeoutMs??1500})},callTool(e,t,r){return R(e,"POST","/v1/mcp/call",{body:{name:t,arguments:r}})},listPrompts(e,t={}){return R(e,"GET","/v1/mcp/prompts",{timeoutMs:t.timeoutMs??1500})},getPrompt(e,t,r){return R(e,"POST","/v1/mcp/prompt",{body:{name:t,arguments:r}})}};import{spawn as be}from"child_process";import{hostname as Me,platform as z,release as Pe}from"os";import{randomBytes as fe,randomUUID as ye}from"crypto";import{mkdirSync as he,readFileSync as ve,renameSync as Se,unlinkSync as we,writeFileSync as Re}from"fs";import{dirname as _e,join as Te}from"path";import{execFileSync as E}from"child_process";import{mkdirSync as D,readFileSync as U,renameSync as $,unlinkSync as j,writeFileSync as L}from"fs";import{homedir as ue}from"os";import{dirname as P,join as _}from"path";var h="https://modelstat.ai",pe="http://localhost:3010";function T(){let e=process.env.MODELSTAT_HOME?.trim();return e&&e.length>0?e:_(ue(),".modelstat")}function C(){return _(T(),"mcp-auth.json")}function me(){try{let e=E("modelstat",["paths","--json"],{stdio:["ignore","pipe","ignore"],timeout:2e3,encoding:"utf8"});return JSON.parse(e)}catch{return null}}function le(){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 N(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 ge(){return N(C())}function l(){let e=process.env.MODELSTAT_API_URL??process.env.DAEMON_API_URL,t=process.env.MODELSTAT_TOKEN,r=C(),n=w=>w&&w.length>0&&w!==pe?w:void 0;if(t)return{bearer:t,apiUrl:n(e)??h,authPath:r,source:"env"};let s=le();if(s)return{bearer:s.token,apiUrl:n(e)??n(s.api)??h,authPath:r,source:"daemon-token"};let o=me(),c=process.env.MODELSTAT_STATE_FILE??o?.identity??_(T(),"identity.json"),i=N(c);if(i.bearer)return{bearer:i.bearer,deviceId:i.deviceId,deviceUuid:i.deviceUuid,apiUrl:n(e)??n(o?.api)??h,authPath:r,source:"daemon-identity"};let m=ge();return m.bearer?{bearer:m.bearer,deviceId:m.deviceId,deviceUuid:m.deviceUuid,apiUrl:n(e)??h,authPath:r,source:"mcp-auth"}:{apiUrl:n(e)??h,authPath:r,source:"none"}}function W(e){let t=C(),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{D(P(t),{recursive:!0}),L(r,JSON.stringify(n),{encoding:"utf8",mode:384}),$(r,t)}catch{try{j(r)}catch{}}}function q(e){return _(P(e.authPath),"mcp-tools-cache.json")}function J(e){try{let t=U(q(e),"utf8"),r=JSON.parse(t);if(Array.isArray(r.tools))return{tools:r.tools}}catch{}return null}function F(e,t){let r=q(e),n=`${r}.tmp-${process.pid}`;try{D(P(r),{recursive:!0}),L(n,JSON.stringify(t),{encoding:"utf8",mode:384}),$(n,r)}catch{try{j(n)}catch{}}}var v=null;function G(){return Te(T(),"mcp-device.json")}function ke(){try{let e=JSON.parse(ve(G(),"utf8"));if(typeof e.machineId=="string"&&typeof e.deviceUuid=="string")return{machineId:e.machineId,deviceUuid:e.deviceUuid}}catch{}return null}function xe(e){let t=G(),r=`${t}.tmp-${process.pid}`;try{he(_e(t),{recursive:!0}),Re(r,JSON.stringify(e),{encoding:"utf8",mode:384}),Se(r,t)}catch{try{we(r)}catch{}}}function H(){if(v)return v;let e=ke();if(e)return v=e,v;let t={machineId:fe(32).toString("hex"),deviceUuid:ye()};return xe(t),v=t,t}function V(e){return e&&typeof e=="object"&&"data"in e?e.data:e}function d(e){process.stderr.write(`modelstat-mcp: ${e}
|
|
3
|
+
`)}function Ce(e){let t=z(),r=t==="darwin"?"open":t==="win32"?"cmd":"xdg-open",n=t==="win32"?["/c","start","",e]:[e];try{return be(r,n,{stdio:"ignore",detached:!0}).unref(),!0}catch{return!1}}function Oe(e){return new Promise(t=>setTimeout(t,e))}async function K(e){if(!Ee())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 De(e.apiUrl)}catch(n){return d(`device registration failed: ${n.message}`),e}if(t.status==="claimed")return B(e,t,"this MCP is already linked to your modelstat account.");if(!t.claim_url)return d("device registered but the server returned no claim URL \u2014 cannot pair from the MCP. Install the daemon instead: npx modelstat@latest."),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(""),Ce(t.claim_url);let r=Date.now()+Ae;for(;Date.now()<r;){await Oe(Ie);let n=null;try{n=await Ue(e.apiUrl,t.device_secret)}catch{}if(n&&(n.status==="claimed"||n.user_id&&n.user_id.length>0))return B(e,t,"this MCP is now linked to your modelstat account.")}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}function B(e,t,r){return W({bearer:t.device_secret,deviceId:t.device_id,deviceUuid:t.device_uuid}),d(`\u2713 connected \u2014 ${r}`),{...e,bearer:t.device_secret,deviceId:t.device_id,deviceUuid:t.device_uuid,source:"mcp-auth"}}var Ie=2500,Ae=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 De(e){let t=H(),r=await fetch(new URL("/v1/tokens",e),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({device_uuid:t.deviceUuid,fingerprint:{source:"mcp",hostname:Me(),platform:z(),release:Pe(),machine_id:t.machineId}})});if(!r.ok)throw new Error(`${r.status} ${r.statusText}: ${(await r.text().catch(()=>"")).slice(0,200)}`);return V(await r.json())}async function Ue(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 V(await r.json())}function $e(){return Number(process.env.MODELSTAT_LOCAL_INGEST_PORT)||4319}async function Y(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 je}from"fs";import{fileURLToPath as Le}from"url";var O="session_insights",g="ui://modelstat/session-insights",X="text/html;profile=mcp-app",Z={ui:{resourceUri:g},"ui/resourceUri":g};function Q(e){return e.map(t=>t.name===O?{...t,_meta:{...t._meta,...Z}}:t)}function ee(e){return{...e,_meta:{...e._meta,...Z}}}function te(){return{uri:g,name:"Session insights",description:"Live card for the current conversation: tokens, $ assigned, and detected work types.",mimeType:X,_meta:{ui:{prefersBorder:!1}}}}var k=null;function Ne(){if(k!=null)return k;let e=Le(new URL("./session-insights.html",import.meta.url));return k=je(e,"utf8"),k}function re(){return{contents:[{uri:g,mimeType:X,text:Ne(),_meta:{ui:{prefersBorder:!1}}}]}}import{execFileSync as ne}from"child_process";import{existsSync as b,mkdirSync as We,readFileSync as se,writeFileSync as oe}from"fs";import{homedir as qe,platform as Je}from"os";import{dirname as Fe,join as a}from"path";var I="npx",A=["-y","@modelstat/mcp"];function x(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 Ge(e,t){let r=x(e,t,"Claude"),n=x(e,t,"Code"),s=x(e,t,"Code - Insiders"),o=x(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 He(e){return e==="zed"?{source:"custom",command:I,args:A,env:{}}:{command:I,args:A}}function Be(e){if(!b(e.detect))return"absent";let t={};if(b(e.file))try{let o=JSON.parse(se(e.file,"utf8"));o&&typeof o=="object"&&(t=o)}catch{}let r=t[e.key],n=r&&typeof r=="object"?r:{},s=He(e.shape);if(JSON.stringify(n.modelstat)===JSON.stringify(s))return"already";n.modelstat=s,t[e.key]=n;try{return We(Fe(e.file),{recursive:!0}),oe(e.file,`${JSON.stringify(t,null,2)}
|
|
4
|
+
`),"configured"}catch{return"skipped"}}function ze(e){let t=a(e,".codex");if(!b(t))return"absent";let r=a(t,"config.toml"),n="";if(b(r))try{n=se(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
|
-
${
|
|
10
|
-
`)),
|
|
11
|
-
`)}async function
|
|
12
|
-
`)}process.argv[2]==="wire"?
|
|
13
|
-
`),process.exit(1)}):
|
|
9
|
+
${s}`:s;try{return oe(r,o),"configured"}catch{return"skipped"}}function Ve(){try{ne("claude",["--version"],{stdio:"ignore"})}catch{return"absent"}try{return ne("claude",["mcp","add","modelstat","-s","user","--",I,...A],{stdio:"ignore"}),"configured"}catch{return"already"}}var Ke={configured:"+",already:"\xB7",absent:"-",skipped:"!"},Ye={configured:"configured",already:"already configured",absent:"not detected, skipped",skipped:"could not write, skipped"};async function ie(e={}){let t=e.home??qe(),r=e.platform??Je(),n=e.includeClaudeCode??!0,s=e.log??(i=>process.stdout.write(`${i}
|
|
10
|
+
`)),o=[];n&&o.push({name:"Claude Code",status:Ve()});for(let i of Ge(t,r))o.push({name:i.name,status:Be(i)});o.push({name:"Codex",status:ze(t)}),s("modelstat MCP \u2014 wiring your AI tools:");for(let i of o)s(` ${Ke[i.status]} ${i.name} \u2014 ${Ye[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 ot=["today","7d","30d","90d","mtd","ytd"],ae=["provider","model","tool","day","hour","device","identity","session","taxonomy"],it=["cost","list","tokens","events","sessions","tokens_input","tokens_output","tokens_cache_read","tokens_cache_creation","tokens_reasoning"],M={range:{type:"string",enum:[...ot],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`)"}},ce=[{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:{...M}}},{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:[...ae],default:"day"},stack_by:{type:"string",enum:[...ae],description:"Optional second dimension; each cell carries `stack`."},metric:{type:"string",enum:[...it],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."},...M}}},{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"},...M}}},{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},...M}}},{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"}}}}],p=new Xe({name:"modelstat",version:"0.0.3"},{capabilities:{tools:{},resources:{},prompts:{}}});p.setRequestHandler(nt,async()=>{let e=l(),t;try{let r=await y.listTools(e,{timeoutMs:1500});F(e,r),u(`tools=remote count=${r.tools.length}`),t=r.tools}catch(r){let n=r.message,s=J(e);s?(u(`tools=cached count=${s.tools.length} (remote=${n})`),t=s.tools):(u(`tools=static count=${ce.length} (remote=${n})`),t=ce)}return{tools:Q(t)}});p.setRequestHandler(rt,async()=>({resources:[te()]}));p.setRequestHandler(st,async e=>{if(e.params.uri!==g)throw new Error(`Unknown resource: ${e.params.uri}`);return re()});p.setRequestHandler(tt,async()=>{let e=l();try{let t=await y.listPrompts(e,{timeoutMs:1500});return u(`prompts=remote count=${t.prompts.length}`),{prompts:t.prompts}}catch(t){return u(`prompts=none (remote=${t.message})`),{prompts:[]}}});p.setRequestHandler(et,async e=>{let t=l(),r=e.params.name,n=e.params.arguments??{};return y.getPrompt(t,r,n)});p.setRequestHandler(Qe,async e=>{let t=l(),r=e.params.name,n=e.params.arguments??{};if(t.source==="none"&&(t=await K(t),t.source==="none"))return S("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 at(n);try{let s=await y.callTool(t,r,n);return r===O?ee(s):s}catch(s){if(s instanceof f){if(s.status===401)return S("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 S(`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 S(`modelstat API error (${s.status})${o}`)}return S(s.message)}});async function at(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 Y(t,{wait:!0});r.kind==="no_daemon"?u("eager: no local daemon on the control port \u2014 proceeding (server will report status)"):r.kind==="error"?u(`eager: daemon scan skipped (${r.message}) \u2014 proceeding`):u("eager: daemon force-scanned the session")}function S(e){return{isError:!0,content:[{type:"text",text:e}]}}function u(e){process.stderr.write(`modelstat-mcp: ${e}
|
|
11
|
+
`)}async function ct(){let e=new Ze;await p.connect(e);let t=l();process.stderr.write(`modelstat-mcp: ready (auth=${t.source})
|
|
12
|
+
`)}process.argv[2]==="wire"?ie().then(e=>process.exit(e)).catch(e=>{process.stderr.write(`modelstat-mcp: wire failed: ${e.message}
|
|
13
|
+
`),process.exit(1)}):ct().catch(e=>{process.stderr.write(`modelstat-mcp: fatal: ${e.message}
|
|
14
14
|
`),process.exit(1)});
|