@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 +21 -0
- package/README.md +82 -0
- package/dist/credentials.js +90 -0
- package/dist/index.js +41 -0
- package/dist/usage.js +189 -0
- package/package.json +42 -0
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
|
+
}
|