@modelstat/mcp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +109 -0
  2. package/dist/index.js +5 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @modelstat/mcp
2
+
3
+ Ask any MCP-compatible AI tool — Claude Desktop, Claude Code, Cursor, Cline, Continue, Zed — about your token spend directly in the chat.
4
+
5
+ - "How much did I spend on Cursor this week?"
6
+ - "Which project is driving my Claude Code cost?"
7
+ - "Show me recent sessions over $5."
8
+ - "Is my modelstat agent healthy?"
9
+
10
+ Uses the bearer token [`modelstat connect`](https://modelstat.ai/install) already wrote to `~/.config/modelstat/state.json` — no separate auth.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ # Works inline — no global install needed.
16
+ npx -y @modelstat/mcp --help
17
+
18
+ # Or pin globally:
19
+ npm install -g @modelstat/mcp
20
+ modelstat-mcp
21
+ ```
22
+
23
+ ## Wire it up
24
+
25
+ ### Claude Desktop
26
+
27
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows, unsupported at the moment):
28
+
29
+ ```jsonc
30
+ {
31
+ "mcpServers": {
32
+ "modelstat": {
33
+ "command": "npx",
34
+ "args": ["-y", "@modelstat/mcp"]
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ Restart Claude Desktop. You'll see a 🔌 for the modelstat tools.
41
+
42
+ ### Claude Code
43
+
44
+ ```bash
45
+ claude mcp add modelstat -- npx -y @modelstat/mcp
46
+ ```
47
+
48
+ ### Cursor
49
+
50
+ Settings → Cursor Settings → MCP → Add new MCP server:
51
+
52
+ - Name: `modelstat`
53
+ - Command: `npx`
54
+ - Args: `-y @modelstat/mcp`
55
+
56
+ ### Cline / Roo
57
+
58
+ Settings → MCP Servers → Edit JSON:
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "modelstat": { "command": "npx", "args": ["-y", "@modelstat/mcp"] }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ### Continue.dev
69
+
70
+ In `~/.continue/config.yaml`:
71
+
72
+ ```yaml
73
+ mcpServers:
74
+ - name: modelstat
75
+ command: npx
76
+ args: ["-y", "@modelstat/mcp"]
77
+ ```
78
+
79
+ ## Tools
80
+
81
+ All tools are **read-only**. The MCP server never issues mutating calls.
82
+
83
+ | Tool | Purpose |
84
+ |---|---|
85
+ | `get_spend_summary` | Total $ and tokens for a range, split by tool + model. |
86
+ | `get_spend_by_project` | Spend grouped by repo / project. |
87
+ | `get_spend_by_tool` | Spend grouped by AI tool. |
88
+ | `list_recent_sessions` | Most recent sessions with cost. |
89
+ | `get_device_status` | Pairing + last-heartbeat status for this machine. |
90
+
91
+ `range` accepts: `today`, `7d`, `30d`, `90d`, `mtd`, `ytd`.
92
+
93
+ Your MCP client may see additional tools beyond the ones listed above — the live catalog comes from the modelstat backend, and we add new query tools server-side. Ask your client to list available tools to see what's actually exposed for your account.
94
+
95
+ ## Auth & privacy
96
+
97
+ The MCP server reads the bearer token that `modelstat connect` stored locally. It never transmits that token anywhere except directly to the modelstat API (default `https://modelstat.ai`). Prompts, responses, and file contents never touch this process.
98
+
99
+ Override the API endpoint with `MODELSTAT_API_URL` (for self-hosted / dev). Override the state dir with `MODELSTAT_STATE_DIR`.
100
+
101
+ ## Troubleshooting
102
+
103
+ - **`modelstat is not paired on this machine`** — run `curl -fsSL https://install.modelstat.ai | sh` first.
104
+ - **401 responses** — the bearer expired. Re-run `modelstat connect`.
105
+ - **No data yet** — the agent uploads within a few seconds of your first AI-tool session. Check `modelstat status`.
106
+
107
+ ## License
108
+
109
+ MIT.
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import{Server as U}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as $}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as q,ListToolsRequestSchema as I}from"@modelcontextprotocol/sdk/types.js";var a=class extends Error{constructor(o,r,n){super(o);this.status=r;this.body=n;this.name="ApiError"}status;body};function k(e){if(!e.bearer)throw new Error("modelstat is not paired on this machine. Run `modelstat connect` to pair, or install the CLI first: https://modelstat.ai/install")}async function y(e,t,o,r={}){k(e);let n=new URL(o.startsWith("/")?o:`/${o}`,e.apiUrl),s=r.timeoutMs!=null?AbortSignal.timeout(r.timeoutMs):void 0,c={Authorization:`Bearer ${e.bearer}`,Accept:"application/json"};t==="POST"&&(c["content-type"]="application/json");let i=await fetch(n,{method:t,headers:c,body:r.body!==void 0?JSON.stringify(r.body):void 0,signal:s});if(!i.ok){let v=await i.text().catch(()=>"");throw new a(`${i.status} ${i.statusText} for ${n.pathname}`,i.status,v)}return await i.json()}var l={listTools(e,t={}){return y(e,"GET","/v1/mcp/tools",{timeoutMs:t.timeoutMs??1500})},callTool(e,t,o){return y(e,"POST","/v1/mcp/call",{body:{name:t,arguments:o}})}};import{execFileSync as x}from"child_process";import{mkdirSync as j,readFileSync as f,renameSync as P,unlinkSync as M,writeFileSync as C}from"fs";import{homedir as R,platform as E}from"os";import{dirname as b,join as p}from"path";function A(){let e="modelstat-agent-dev-nodejs",t=R();if(E()==="darwin")return p(t,"Library","Preferences",e,"config.json");let o=process.env.XDG_CONFIG_HOME,r=o&&o.length>0?o:p(t,".config");return p(r,e,"config.json")}function O(){try{let e=x("modelstat",["paths","--json"],{stdio:["ignore","pipe","ignore"],timeout:2e3,encoding:"utf8"}),t=JSON.parse(e);if(t.state)return{statePath:t.state,apiUrl:t.api}}catch{}return null}function u(){let e=process.env.MODELSTAT_API_URL??process.env.AGENT_API_URL,t=O(),r=process.env.MODELSTAT_STATE_FILE??t?.statePath??A(),n={};try{let c=f(r,"utf8");n=JSON.parse(c)}catch{}let s=n;return{bearer:typeof s.bearerToken=="string"?s.bearerToken:void 0,deviceId:typeof s.deviceId=="string"?s.deviceId:void 0,deviceUuid:typeof s.deviceUuid=="string"?s.deviceUuid:void 0,userEmail:typeof s.userEmail=="string"?s.userEmail:void 0,apiUrl:e??t?.apiUrl??(typeof s.apiUrl=="string"&&s.apiUrl&&s.apiUrl!=="http://localhost:3010"?s.apiUrl:"https://modelstat.ai"),statePath:r}}function S(e){return p(b(e.statePath),"mcp-tools-cache.json")}function w(e){try{let t=f(S(e),"utf8"),o=JSON.parse(t);if(Array.isArray(o.tools))return{tools:o.tools}}catch{}return null}function _(e,t){let o=S(e),r=`${o}.tmp-${process.pid}`;try{j(b(o),{recursive:!0}),C(r,JSON.stringify(t),{encoding:"utf8",mode:384}),P(r,o)}catch{try{M(r)}catch{}}}var m=["today","7d","30d","90d","mtd","ytd"],T=[{name:"get_spend_summary",description:"Return total AI token spend for this user, broken down by tool and model, for a time range.",inputSchema:{type:"object",properties:{range:{type:"string",enum:[...m],default:"7d",description:"Time range."}},required:["range"]}},{name:"get_spend_by_project",description:"Return spend grouped by auto-detected project (typically a repository).",inputSchema:{type:"object",properties:{range:{type:"string",enum:[...m],default:"7d"},project:{type:"string",description:"Optional exact project name to filter to."}},required:["range"]}},{name:"get_spend_by_tool",description:"Return spend grouped by AI tool (Claude Code, Cursor, Codex, Aider, Copilot, ChatGPT web, etc.).",inputSchema:{type:"object",properties:{range:{type:"string",enum:[...m],default:"7d"},tool:{type:"string",description:'Optional tool slug to filter to (e.g. "claude-code", "cursor").'}},required:["range"]}},{name:"list_recent_sessions",description:"Return the most recent AI sessions with their model, project, tokens, and USD cost. Useful for spotting expensive outliers.",inputSchema:{type:"object",properties:{limit:{type:"integer",minimum:1,maximum:100,default:20,description:"How many sessions to return (most recent first)."}},required:[]}},{name:"get_device_status",description:"Check whether modelstat is paired on the machine this MCP server is running on, and when the agent last reported.",inputSchema:{type:"object",properties:{},required:[]}},{name:"recommend_model",description:"Given a task description and optional budget cap, recommend the best model for THIS team based on historical session data. Returns the most-used model in the last N days that fits within the budget, plus alternatives. Useful for orchestrator agents choosing between Claude Opus / Sonnet / GPT / etc. before spawning a subagent.",inputSchema:{type:"object",properties:{task:{type:"string",description:"Natural-language description of the task the model will do."},max_cost_usd:{type:"number",description:"Cap on the expected per-session cost (based on rolling 30d averages). Models above this are demoted to alternatives."},tool:{type:"string",description:'Optional tool filter (e.g. "claude-code", "cursor"). Omit to consider all tools.'},window_days:{type:"integer",minimum:1,maximum:365,default:30,description:"Lookback window for the usage aggregate."}},required:["task"]}},{name:"list_segments",description:"List recent segments (redacted abstracts + tags) for an org. Segments are the companion-tagged slices of a session \u2014 each carries a short pre-redacted abstract, strongly-typed tags (project / work_type / domain / etc.), tokens, and classification status. Supports filtering by project, work_type, and domain tag names.",inputSchema:{type:"object",properties:{org_id:{type:"string",description:"Org to list segments from."},project:{type:"string",description:"Filter to segments tagged with this project name."},work_type:{type:"string",description:"Filter by work_type tag (e.g. 'refactoring', 'debugging')."},domain:{type:"string",description:"Filter by domain tag."},limit:{type:"integer",minimum:1,maximum:500,default:50}},required:["org_id"]}},{name:"list_inbox",description:"Sessions awaiting org assignment \u2014 either routed to the user's inbox by their device's routing mode, or proposed into an org by a rule. Each entry includes tool, provider, git slug/branch, and message count so the user can decide where to assign it.",inputSchema:{type:"object",properties:{limit:{type:"integer",minimum:1,maximum:200,default:50}},required:[]}},{name:"list_routing_rules",description:"List the caller's multi-org routing rules in priority order. Rules route new sessions to target orgs based on tag predicates and/or semantic matching on segment abstracts. Returns each rule's target org, match mode, predicate, and enabled state.",inputSchema:{type:"object",properties:{},required:[]}},{name:"assign_session",description:"Manually reassign a session to a different org. Writes to session_routing_log with reason='manual:<user>' and cascades segments.org_id so downstream analytics pick up the new owner. Must be a member of the target org.",inputSchema:{type:"object",properties:{session_id:{type:"string"},org_id:{type:"string"}},required:["session_id","org_id"]}},{name:"get_pipeline_state",description:"Return a live snapshot of pipeline health for an org: per-task queue depths (normalize_ingest_batch, classify_segment, route_session, ...), count of segments awaiting classification, classification lag in seconds, and last-hour throughput. Useful to check whether new ingest batches are landing and being processed end-to-end.",inputSchema:{type:"object",properties:{org_id:{type:"string"}},required:["org_id"]}}],h=new U({name:"modelstat",version:"0.0.1"},{capabilities:{tools:{}}});h.setRequestHandler(I,async()=>{let e=u();try{let t=await l.listTools(e,{timeoutMs:1500});return _(e,t),g(`tools=remote count=${t.tools.length}`),{tools:t.tools}}catch(t){let o=t.message,r=w(e);return r?(g(`tools=cached count=${r.tools.length} (remote=${o})`),{tools:r.tools}):(g(`tools=static count=${T.length} (remote=${o})`),{tools:T})}});h.setRequestHandler(q,async e=>{let t=u(),o=e.params.name,r=e.params.arguments??{};try{return await l.callTool(t,o,r)}catch(n){if(n instanceof a){if(n.status===401)return d("modelstat API returned 401. Your bearer token may have expired \u2014 run `modelstat connect` to re-pair.");if(n.status===404)return d(`Tool \`${o}\` is no longer available \u2014 your MCP catalog may be out of date. Restart your MCP client to refresh.`);let s=n.body?`: ${n.body.slice(0,400)}`:"";return d(`modelstat API error (${n.status})${s}`)}return d(n.message)}});function d(e){return{isError:!0,content:[{type:"text",text:e}]}}function g(e){process.stderr.write(`modelstat-mcp: ${e}
3
+ `)}async function D(){let e=new $;await h.connect(e),process.stderr.write(`modelstat-mcp: ready
4
+ `)}D().catch(e=>{process.stderr.write(`modelstat-mcp: fatal: ${e.message}
5
+ `),process.exit(1)});
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@modelstat/mcp",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for modelstat — ask any MCP-compatible AI tool about your token spend.",
5
+ "keywords": ["mcp", "modelcontextprotocol", "modelstat", "ai", "observability", "token-tracking"],
6
+ "homepage": "https://modelstat.ai/mcp",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/modelstat/modelstat.git",
10
+ "directory": "packages/mcp"
11
+ },
12
+ "bugs": "https://github.com/modelstat/modelstat/issues",
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "bin": {
16
+ "modelstat-mcp": "./dist/index.js"
17
+ },
18
+ "files": ["dist/**/*", "README.md", "LICENSE"],
19
+ "scripts": {
20
+ "build": "tsup src/index.ts --format esm --out-dir dist --clean --no-splitting --minify",
21
+ "dev": "tsx src/index.ts",
22
+ "typecheck": "tsc --noEmit",
23
+ "prepack": "pnpm build"
24
+ },
25
+ "engines": { "node": ">=20" },
26
+ "os": ["darwin", "linux"],
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "tsup": "^8.3.5",
32
+ "tsx": "^4.19.2",
33
+ "typescript": "^5.7.3"
34
+ }
35
+ }