@anatoly314/claude-usage-mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anatoly Tarnavsky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # claude-usage-mcp
2
+
3
+ An MCP server that reports your real Claude subscription usage — 5-hour
4
+ session window, weekly limit, and per-model limits — pulled from the same
5
+ endpoint Claude Code's `/usage` command uses.
6
+
7
+ ## Requirements
8
+
9
+ - macOS or Linux
10
+ - Node.js 20+
11
+ - Claude Code logged in on a Pro, Max, or Team plan
12
+
13
+ This server reads Claude Code's own OAuth access token (from the macOS
14
+ Keychain, or `~/.claude/.credentials.json` as a fallback). It never asks for
15
+ or stores credentials itself.
16
+
17
+ ## Install / configure
18
+
19
+ Add to your `.mcp.json`.
20
+
21
+ Local build:
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "claude-usage": {
27
+ "command": "node",
28
+ "args": ["/path/to/claude-usage-mcp/dist/index.js"]
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Or via npx:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "claude-usage": {
40
+ "command": "npx",
41
+ "args": ["-y", "@anatoly314/claude-usage-mcp"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Tool
48
+
49
+ ### `get_usage`
50
+
51
+ Takes no arguments. Returns the current usage snapshot as JSON.
52
+
53
+ ```json
54
+ {
55
+ "session_5h": { "utilization_percent": 42, "resets_at": "2026-09-01T18:00:00Z" },
56
+ "weekly_7d": { "utilization_percent": 61, "resets_at": "2026-09-05T00:00:00Z" },
57
+ "model_limits": [
58
+ {
59
+ "model": "Opus",
60
+ "kind": "weekly_scoped",
61
+ "utilization_percent": 51,
62
+ "resets_at": "2026-09-05T00:00:00Z",
63
+ "is_active": true
64
+ }
65
+ ],
66
+ "fetched_at": "2026-09-01T15:04:00Z",
67
+ "stale": false
68
+ }
69
+ ```
70
+
71
+ If a fetch fails, the last successful response is served instead with
72
+ `stale: true` and `stale_age_seconds` set.
73
+
74
+ ## Notes
75
+
76
+ - This uses an undocumented Anthropic endpoint. It may change or break
77
+ without notice.
78
+ - Responses are cached for 120 seconds; after a failed fetch, further
79
+ requests back off for 60 seconds before retrying, to avoid hammering the
80
+ API.
81
+ - Everything stays local — no data leaves your machine except the request to
82
+ Anthropic's usage endpoint.
@@ -0,0 +1,90 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ const execFileAsync = promisify(execFile);
7
+ const KEYCHAIN_SERVICE = "Claude Code-credentials";
8
+ const FALLBACK_CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
9
+ const KEYCHAIN_TIMEOUT_MS = 5000;
10
+ export class CredentialsError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "CredentialsError";
14
+ }
15
+ }
16
+ function extractAccessToken(raw, source) {
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ }
21
+ catch {
22
+ throw new CredentialsError(`Credentials at ${source} did not contain valid JSON.`);
23
+ }
24
+ const token = parsed.claudeAiOauth?.accessToken;
25
+ if (!token || typeof token !== "string") {
26
+ throw new CredentialsError(`Credentials at ${source} did not contain a claudeAiOauth.accessToken field.`);
27
+ }
28
+ return token;
29
+ }
30
+ // A locked Keychain, non-macOS, or a missing entry are all expected/benign
31
+ // and would otherwise log on every cache-miss call; warn about them once
32
+ // per process instead of spamming stderr.
33
+ let keychainMissWarned = false;
34
+ function warnKeychainMissOnce(err) {
35
+ if (keychainMissWarned)
36
+ return;
37
+ keychainMissWarned = true;
38
+ console.error(`[credentials] Keychain lookup failed, falling back to file: ${err instanceof Error ? err.message : String(err)}`);
39
+ }
40
+ async function readFromKeychain() {
41
+ let stdout;
42
+ try {
43
+ ({ stdout } = await execFileAsync("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], { timeout: KEYCHAIN_TIMEOUT_MS, maxBuffer: 1_000_000 }));
44
+ }
45
+ catch (err) {
46
+ // Keychain entry not found, `security` unavailable (non-macOS), user
47
+ // denied access, or a locked Keychain timed out waiting on a GUI prompt.
48
+ warnKeychainMissOnce(err);
49
+ return { token: null };
50
+ }
51
+ try {
52
+ return { token: extractAccessToken(stdout.trim(), "macOS Keychain") };
53
+ }
54
+ catch (err) {
55
+ const credErr = err instanceof CredentialsError ? err : new CredentialsError(String(err));
56
+ console.error(`[credentials] Keychain entry found but could not be parsed: ${credErr.message}`);
57
+ return { token: null, malformedError: credErr };
58
+ }
59
+ }
60
+ async function readFromFile() {
61
+ try {
62
+ const raw = await readFile(FALLBACK_CREDENTIALS_PATH, "utf8");
63
+ return extractAccessToken(raw, FALLBACK_CREDENTIALS_PATH);
64
+ }
65
+ catch (err) {
66
+ console.error(`[credentials] File lookup failed at ${FALLBACK_CREDENTIALS_PATH}: ${err instanceof Error ? err.message : String(err)}`);
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * Reads the current Claude Code OAuth access token fresh from disk/keychain.
72
+ * Never caches — Claude Code may refresh the token out-of-band and we must
73
+ * always pick up the latest one.
74
+ */
75
+ export async function getAccessToken() {
76
+ const keychainResult = await readFromKeychain();
77
+ if (keychainResult.token)
78
+ return keychainResult.token;
79
+ const fromFile = await readFromFile();
80
+ if (fromFile)
81
+ return fromFile;
82
+ if (keychainResult.malformedError) {
83
+ throw new CredentialsError(`Keychain entry "${KEYCHAIN_SERVICE}" was found but could not be parsed ` +
84
+ `(${keychainResult.malformedError.message}), and no valid credentials were found at ` +
85
+ `${FALLBACK_CREDENTIALS_PATH} either. Make sure you are logged in via \`claude\` (Claude Code CLI).`);
86
+ }
87
+ throw new CredentialsError("No Claude Code credentials found. Checked macOS Keychain " +
88
+ `("${KEYCHAIN_SERVICE}") and ${FALLBACK_CREDENTIALS_PATH}. ` +
89
+ "Make sure you are logged in via `claude` (Claude Code CLI).");
90
+ }
package/dist/index.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { getUsage, UsageAuthError, UsageFetchError } from "./usage.js";
5
+ import { CredentialsError } from "./credentials.js";
6
+ const server = new McpServer({
7
+ name: "claude-usage-mcp",
8
+ version: "0.1.0",
9
+ });
10
+ server.registerTool("get_usage", {
11
+ description: "Report the current user's Claude subscription usage: 5-hour session window, " +
12
+ "7-day weekly limit, and any per-model weekly limits, as utilization " +
13
+ "percentages with reset timestamps.",
14
+ inputSchema: {},
15
+ }, async () => {
16
+ try {
17
+ const usage = await getUsage();
18
+ return {
19
+ content: [{ type: "text", text: JSON.stringify(usage, null, 2) }],
20
+ };
21
+ }
22
+ catch (err) {
23
+ const message = err instanceof UsageAuthError || err instanceof CredentialsError || err instanceof UsageFetchError
24
+ ? err.message
25
+ : `Unexpected error fetching usage: ${err instanceof Error ? err.message : String(err)}`;
26
+ console.error(`[get_usage] ${message}`);
27
+ return {
28
+ content: [{ type: "text", text: message }],
29
+ isError: true,
30
+ };
31
+ }
32
+ });
33
+ async function main() {
34
+ const transport = new StdioServerTransport();
35
+ await server.connect(transport);
36
+ console.error("claude-usage-mcp server running on stdio");
37
+ }
38
+ main().catch((err) => {
39
+ console.error("Fatal error starting claude-usage-mcp:", err);
40
+ process.exit(1);
41
+ });
package/dist/usage.js ADDED
@@ -0,0 +1,189 @@
1
+ import { getAccessToken } from "./credentials.js";
2
+ const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
3
+ const ANTHROPIC_BETA_HEADER = "oauth-2025-04-20";
4
+ // Must resemble a real Claude Code version string — without a plausible
5
+ // User-Agent this endpoint routes requests into an aggressively
6
+ // rate-limited bucket (persistent 429s).
7
+ const USER_AGENT = "claude-code/2.1.252";
8
+ const CACHE_TTL_MS = 120_000;
9
+ const BACKOFF_MS = 60_000;
10
+ export class UsageAuthError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "UsageAuthError";
14
+ }
15
+ }
16
+ export class UsageFetchError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "UsageFetchError";
20
+ }
21
+ }
22
+ let cache = null;
23
+ let inFlight = null;
24
+ // Set after a failed fetch (429/5xx/network/parse error) so we don't hammer
25
+ // the API on every call while it's down; cleared on the next success.
26
+ let nextRetryAtMs = 0;
27
+ function mapPool(value) {
28
+ if (!value || typeof value !== "object")
29
+ return null;
30
+ const v = value;
31
+ const utilization = typeof v.utilization === "number" ? v.utilization : null;
32
+ const resetsAt = typeof v.resets_at === "string" ? v.resets_at : null;
33
+ return { utilization_percent: utilization, resets_at: resetsAt };
34
+ }
35
+ /**
36
+ * The endpoint's top-level `seven_day_opus`/`seven_day_sonnet` fields are
37
+ * legacy and always null — real per-model data lives in the `limits[]`
38
+ * array as entries like:
39
+ * {"kind":"weekly_scoped","percent":51,"resets_at":"...",
40
+ * "scope":{"model":{"id":null,"display_name":"Fable"}},"is_active":true}
41
+ * Parse defensively: `limits` may be missing, not an array, or contain
42
+ * malformed elements, and model names are never hardcoded here.
43
+ */
44
+ function parseModelLimits(value) {
45
+ if (!Array.isArray(value))
46
+ return [];
47
+ const results = [];
48
+ for (const entry of value) {
49
+ if (!entry || typeof entry !== "object")
50
+ continue;
51
+ const e = entry;
52
+ const scope = e.scope && typeof e.scope === "object" ? e.scope : null;
53
+ const modelScope = scope && scope.model && typeof scope.model === "object"
54
+ ? scope.model
55
+ : null;
56
+ if (!modelScope)
57
+ continue;
58
+ const displayName = typeof modelScope.display_name === "string" ? modelScope.display_name : null;
59
+ const id = typeof modelScope.id === "string" ? modelScope.id : null;
60
+ const model = displayName ?? id ?? "unknown";
61
+ const percent = typeof e.percent === "number" ? e.percent : null;
62
+ const resetsAt = typeof e.resets_at === "string" ? e.resets_at : null;
63
+ const isActive = typeof e.is_active === "boolean" ? e.is_active : null;
64
+ const kind = typeof e.kind === "string" ? e.kind : null;
65
+ results.push({ model, kind, utilization_percent: percent, resets_at: resetsAt, is_active: isActive });
66
+ }
67
+ return results;
68
+ }
69
+ function parseUsageResponse(json) {
70
+ const obj = json && typeof json === "object" ? json : {};
71
+ return {
72
+ session_5h: mapPool(obj.five_hour),
73
+ weekly_7d: mapPool(obj.seven_day),
74
+ model_limits: parseModelLimits(obj.limits),
75
+ fetched_at: new Date().toISOString(),
76
+ stale: false,
77
+ // The endpoint is undocumented and carries several other fields (spend/
78
+ // credit info, feature flags, ...) beyond what's modeled above — keep
79
+ // the full response around rather than silently dropping data.
80
+ raw: obj,
81
+ };
82
+ }
83
+ function withStale(entry, reason) {
84
+ const staleAgeSeconds = Math.round((Date.now() - entry.fetchedAtMs) / 1000);
85
+ return { ...entry.result, stale: true, stale_reason: reason, stale_age_seconds: staleAgeSeconds };
86
+ }
87
+ async function fetchUsage() {
88
+ // Read fresh on every cache miss: Claude Code may have refreshed the
89
+ // token out-of-band and we must pick up the latest one. We never attempt
90
+ // to refresh it ourselves.
91
+ const token = await getAccessToken();
92
+ let response;
93
+ try {
94
+ response = await fetch(USAGE_URL, {
95
+ headers: {
96
+ Authorization: `Bearer ${token}`,
97
+ "anthropic-beta": ANTHROPIC_BETA_HEADER,
98
+ "User-Agent": USER_AGENT,
99
+ },
100
+ });
101
+ }
102
+ catch (err) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ nextRetryAtMs = Date.now() + BACKOFF_MS;
105
+ if (cache) {
106
+ console.error(`[usage] network error, serving stale cache: ${message}`);
107
+ return withStale(cache, `network error: ${message}`);
108
+ }
109
+ throw new UsageFetchError(`Network error fetching usage: ${message}`);
110
+ }
111
+ if (response.status === 401) {
112
+ const bodyText = await response.text().catch(() => "");
113
+ throw new UsageAuthError("Authentication failed (401) — the Claude Code access token is invalid or expired. " +
114
+ "Run `claude` to sign in again. " +
115
+ `Response: ${bodyText.slice(0, 300)}`);
116
+ }
117
+ if (response.status === 429 || response.status >= 500) {
118
+ const bodyText = await response.text().catch(() => "");
119
+ nextRetryAtMs = Date.now() + BACKOFF_MS;
120
+ if (cache) {
121
+ console.error(`[usage] usage API returned ${response.status}, serving stale cache. Body: ${bodyText.slice(0, 300)}`);
122
+ return withStale(cache, `HTTP ${response.status} from usage API`);
123
+ }
124
+ throw new UsageFetchError(`Usage API returned ${response.status} and no cached data is available yet. Body: ${bodyText.slice(0, 300)}`);
125
+ }
126
+ if (!response.ok) {
127
+ const bodyText = await response.text().catch(() => "");
128
+ nextRetryAtMs = Date.now() + BACKOFF_MS;
129
+ if (cache) {
130
+ console.error(`[usage] usage API returned unexpected status ${response.status}, serving stale cache. Body: ${bodyText.slice(0, 300)}`);
131
+ return withStale(cache, `HTTP ${response.status} from usage API`);
132
+ }
133
+ throw new UsageFetchError(`Usage API returned unexpected status ${response.status}. Body: ${bodyText.slice(0, 300)}`);
134
+ }
135
+ let json;
136
+ try {
137
+ json = await response.json();
138
+ }
139
+ catch {
140
+ nextRetryAtMs = Date.now() + BACKOFF_MS;
141
+ if (cache) {
142
+ console.error("[usage] failed to parse usage API response as JSON, serving stale cache");
143
+ return withStale(cache, "failed to parse usage API response as JSON");
144
+ }
145
+ throw new UsageFetchError("Usage API returned a response that could not be parsed as JSON.");
146
+ }
147
+ const result = parseUsageResponse(json);
148
+ // Capture the write time fresh here (not at the start of the request) so
149
+ // the cache TTL and the user-visible fetched_at reflect when the response
150
+ // actually arrived, not when the fetch was kicked off.
151
+ const fetchedAtMs = Date.now();
152
+ result.fetched_at = new Date(fetchedAtMs).toISOString();
153
+ cache = { result, fetchedAtMs };
154
+ nextRetryAtMs = 0;
155
+ return result;
156
+ }
157
+ /**
158
+ * Fetches usage, using an in-memory cache (120s TTL). On 429/5xx/network
159
+ * errors, falls back to the last successful response (marked stale) if one
160
+ * is cached. On 401, never serves stale data — surfaces the auth problem.
161
+ * Concurrent cache-misses share a single in-flight request.
162
+ *
163
+ * After a failure, a 60s backoff window applies: further calls skip the
164
+ * network entirely and either serve the stale cache or rethrow the error
165
+ * result, until the window elapses.
166
+ */
167
+ export async function getUsage() {
168
+ const now = Date.now();
169
+ if (cache && now - cache.fetchedAtMs < CACHE_TTL_MS) {
170
+ return cache.result;
171
+ }
172
+ if (now < nextRetryAtMs) {
173
+ if (cache) {
174
+ return withStale(cache, "backing off after a previous fetch failure");
175
+ }
176
+ throw new UsageFetchError(`Usage API fetch failed previously and no cached data is available; ` +
177
+ `backing off until ${new Date(nextRetryAtMs).toISOString()}.`);
178
+ }
179
+ if (inFlight) {
180
+ return inFlight;
181
+ }
182
+ inFlight = fetchUsage();
183
+ try {
184
+ return await inFlight;
185
+ }
186
+ finally {
187
+ inFlight = null;
188
+ }
189
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@anatoly314/claude-usage-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server exposing Claude subscription usage (session/weekly/per-model limits)",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "claude-usage-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
16
+ "start": "node dist/index.js",
17
+ "prepublishOnly": "npm run clean && npm run build",
18
+ "pack:local": "rm -f *.tgz && npm run clean && npm run build && npm pack",
19
+ "install:local": "npm install -g ./anatoly314-claude-usage-mcp-*.tgz",
20
+ "uninstall:local": "npm uninstall -g @anatoly314/claude-usage-mcp"
21
+ },
22
+ "keywords": ["mcp", "claude", "claude-code", "usage", "model-context-protocol"],
23
+ "author": "Anatoly Tarnavsky",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/anatoly-lab/claude-usage-mcp"
28
+ },
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.30.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^26.4.0",
40
+ "typescript": "^7.0.2"
41
+ }
42
+ }