zopkit-bug-resolution-mcp 1.5.0 → 1.7.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/README.md +44 -5
- package/index.js +488 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# bug-resolution MCP server
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A six-tool MCP server so Claude Code (or Cursor, or any other MCP client)
|
|
4
4
|
can work a Zopkit bug report end to end as native tool calls — plus report
|
|
5
5
|
usage status — instead of hand-rolling curl commands:
|
|
6
6
|
|
|
@@ -25,8 +25,28 @@ usage status — instead of hand-rolling curl commands:
|
|
|
25
25
|
Code usage percentage (there's no API to fetch it on your behalf — see
|
|
26
26
|
`backend/src/features/claude-usage/README.md`), shown on the
|
|
27
27
|
company-admin panel's "Claude Usage" tab, via `POST /api/admin/claude-usage`.
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
- **`report_claude_token_usage`** — unlike the one above, this one is NOT
|
|
29
|
+
self-attested: it scans this developer's own local Claude Code transcripts
|
|
30
|
+
(`~/.claude/projects/**/*.jsonl` AND their nested `.../subagents/*.jsonl` —
|
|
31
|
+
same machine the MCP server runs on, no `/usage`, no status line, no
|
|
32
|
+
asking a human) for a configurable window (`days`, default 7), streamed
|
|
33
|
+
line-by-line and cached per-file so repeat calls in one session are
|
|
34
|
+
near-instant. Dedupes streamed duplicates by `message.id` and reports real
|
|
35
|
+
input/output/cache-creation/cache-read token totals (subagent spend folded
|
|
36
|
+
in, not just detected), a per-model split, how full the most recently
|
|
37
|
+
active session's context window currently is, session count, and whether
|
|
38
|
+
any sessions used web search or spawned subagents — via
|
|
39
|
+
`POST /api/admin/claude-usage/token-breakdown`. Also runs automatically,
|
|
40
|
+
genuinely non-blocking (never awaited), as a side effect of the four
|
|
41
|
+
bug-report tools above (`submit_bug_resolution`, `fetch_bug_context`,
|
|
42
|
+
`fetch_assigned_bugs`, `create_bug_report`) on success — so a developer
|
|
43
|
+
who never touches this tool directly still gets a token breakdown
|
|
44
|
+
reported just by doing normal bug-report work, with zero added latency on
|
|
45
|
+
those calls. Failures from the auto-report path are swallowed entirely;
|
|
46
|
+
they never surface as a tool error, and (since it isn't awaited) never
|
|
47
|
+
delay the real response either.
|
|
48
|
+
|
|
49
|
+
All six are thin wrappers — see `backend/src/features/bug-reports/README.md`
|
|
30
50
|
and `backend/src/features/claude-usage/README.md` for what each endpoint
|
|
31
51
|
actually does server-side.
|
|
32
52
|
|
|
@@ -120,7 +140,7 @@ whoever asked you to fix it).
|
|
|
120
140
|
writeup and moves its status to `needs_testing` — a human still needs to
|
|
121
141
|
verify it before it's `resolved`.
|
|
122
142
|
|
|
123
|
-
|
|
143
|
+
Four more, independent of the fetch/resolve flow above:
|
|
124
144
|
|
|
125
145
|
- Ask it to call `fetch_assigned_bugs` to see what's currently assigned to
|
|
126
146
|
you — no id needed. By default it shows everything except `resolved`,
|
|
@@ -136,7 +156,21 @@ Three more, independent of the fetch/resolve flow above:
|
|
|
136
156
|
`/usage`, the status line, or the account's billing page — there's no way
|
|
137
157
|
for the tool to look this up itself) to update the "Claude Usage" tab in
|
|
138
158
|
the company-admin panel. Pass an optional `note` for context (e.g. "Team
|
|
139
|
-
plan, resets Monday")
|
|
159
|
+
plan, resets Monday"), and an optional `resetsAt` (ISO timestamp) whenever
|
|
160
|
+
you can see a concrete reset time (e.g. in the status line's rate-limit
|
|
161
|
+
JSON) — this is what lets the panel show real staleness instead of a
|
|
162
|
+
human having to parse a reset time out of free-text `note`.
|
|
163
|
+
- Ask it to call `report_claude_token_usage` (optionally with `days: N` to
|
|
164
|
+
scan a window other than the default 7) to push a real token/model/context
|
|
165
|
+
breakdown up to the same "Claude Usage" tab — distinct from and
|
|
166
|
+
independent of `report_claude_usage` above. You usually don't need to call
|
|
167
|
+
this directly: it already fires automatically after every successful
|
|
168
|
+
`fetch_bug_context`, `submit_bug_resolution`, `fetch_assigned_bugs`, or
|
|
169
|
+
`create_bug_report` call, genuinely in the background (it's never awaited,
|
|
170
|
+
so it adds no latency and its own response text never shows up in that
|
|
171
|
+
tool's reply) — this standalone call is for an explicit, on-demand check
|
|
172
|
+
with a full summary (tokens by model, current context occupancy, subagent
|
|
173
|
+
share) in the response.
|
|
140
174
|
|
|
141
175
|
## Notes
|
|
142
176
|
|
|
@@ -150,3 +184,8 @@ Three more, independent of the fetch/resolve flow above:
|
|
|
150
184
|
- `fetch_bug_context` and `submit_bug_resolution` still need a report id —
|
|
151
185
|
this server doesn't search or browse. `fetch_assigned_bugs` is the one tool
|
|
152
186
|
that doesn't require knowing an id up front.
|
|
187
|
+
- `report_claude_token_usage`'s breakdown only ever sends the BASENAME of a
|
|
188
|
+
session's working directory (e.g. `isocode`, not `/Users/you/Downloads/
|
|
189
|
+
isocode`) to the company-admin panel — real project names, no full
|
|
190
|
+
filesystem paths or usernames, even though the local scan reads the full
|
|
191
|
+
path off the transcript.
|
package/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// bug-resolution MCP server —
|
|
2
|
+
// bug-resolution MCP server — six tools wrapping the bug-reports resolution
|
|
3
3
|
// API (see backend/src/features/bug-reports/README.md) so a developer's
|
|
4
4
|
// Claude Code / Cursor session can work a bug report end to end as native
|
|
5
5
|
// tool calls instead of hand-rolling curl commands:
|
|
@@ -14,11 +14,23 @@
|
|
|
14
14
|
// my plate", always the API key owner's own assigned reports.
|
|
15
15
|
// - create_bug_report: POST /api/admin/bug-reports/report — file a new bug
|
|
16
16
|
// the tool found, optionally self-assigning it.
|
|
17
|
-
// - report_claude_usage: POST /api/admin/claude-usage —
|
|
18
|
-
// developer's current Claude Code usage percentage
|
|
19
|
-
// backend/src/features/claude-usage/README.md for why this is
|
|
17
|
+
// - report_claude_usage: POST /api/admin/claude-usage — SELF-ATTESTED:
|
|
18
|
+
// self-reports this developer's current Claude Code usage percentage
|
|
19
|
+
// (see backend/src/features/claude-usage/README.md for why this is
|
|
20
20
|
// self-reported rather than fetched from an Anthropic API), shown on
|
|
21
21
|
// the company-admin panel's "Claude Usage" tab.
|
|
22
|
+
// - report_claude_token_usage: POST /api/admin/claude-usage/token-breakdown
|
|
23
|
+
// — REAL, not self-attested: scans this developer's own local Claude
|
|
24
|
+
// Code transcripts (~/.claude/projects/**/*.jsonl AND their nested
|
|
25
|
+
// .../subagents/*.jsonl — this server runs on the same machine) for a
|
|
26
|
+
// token/model/context breakdown over a configurable window (default 7
|
|
27
|
+
// days): per-model token split, how full the most recently active
|
|
28
|
+
// session's context window is right now, and web-search/subagent usage
|
|
29
|
+
// — subagent token spend is folded into the totals, not just detected.
|
|
30
|
+
// The other four bug-resolution tools above also trigger this
|
|
31
|
+
// automatically as a genuinely non-blocking side effect
|
|
32
|
+
// (tryAutoReportTokenUsage never awaited) — "every task done in the bug
|
|
33
|
+
// reporter" keeps this fresh with zero extra asks and zero added latency.
|
|
22
34
|
// Thin wrapper: all the actual logic (auth, scoping, the DB writes) lives
|
|
23
35
|
// server-side; this just forwards the HTTP calls the README documents.
|
|
24
36
|
//
|
|
@@ -27,6 +39,10 @@
|
|
|
27
39
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
28
40
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
29
41
|
import { z } from 'zod';
|
|
42
|
+
import { createReadStream, readdirSync, statSync } from 'node:fs';
|
|
43
|
+
import { createInterface } from 'node:readline';
|
|
44
|
+
import { join, basename } from 'node:path';
|
|
45
|
+
import { homedir } from 'node:os';
|
|
30
46
|
|
|
31
47
|
const API_KEY = process.env.BUG_REPORT_API_KEY;
|
|
32
48
|
const BASE_URL = (process.env.BUG_REPORT_API_BASE_URL || 'https://api.zopkit.com').replace(/\/$/, '');
|
|
@@ -36,7 +52,7 @@ if (!API_KEY) {
|
|
|
36
52
|
'Generate one from the company-admin panel (Bug Reports → API Keys) and set it when registering this server.');
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.
|
|
55
|
+
const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.7.0' });
|
|
40
56
|
|
|
41
57
|
const BUG_SEVERITIES = ['low', 'medium', 'high', 'critical'];
|
|
42
58
|
// Keep in sync with BUG_STATUSES in backend/src/db/schema/platform/bug-reports.ts.
|
|
@@ -66,6 +82,373 @@ async function fetchImageContentBlock(url) {
|
|
|
66
82
|
return { type: 'image', data: buffer.toString('base64'), mimeType: contentType };
|
|
67
83
|
}
|
|
68
84
|
|
|
85
|
+
// ── Token usage breakdown (report_claude_token_usage) ──────────────────────
|
|
86
|
+
// Unlike usagePercent (self-attested, see submit_bug_resolution's comment on
|
|
87
|
+
// why), this is read straight off Claude Code's own local transcript files —
|
|
88
|
+
// this MCP server is a local process on the SAME machine as the Claude Code
|
|
89
|
+
// session using it, so it can read ~/.claude/projects/**/*.jsonl directly.
|
|
90
|
+
// No statusLine setup, no asking a human, no per-seat API that doesn't exist.
|
|
91
|
+
//
|
|
92
|
+
// Each API response gets logged 2-3x in these files (streaming writes the
|
|
93
|
+
// same final usage numbers once per content-block flush) — every count below
|
|
94
|
+
// dedupes by message.id, or totals would be inflated 2-3x.
|
|
95
|
+
const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
96
|
+
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
|
|
97
|
+
// Client-side web tools (not Anthropic's server-executed web_search/
|
|
98
|
+
// web_fetch — see usage.server_tool_use below, a DIFFERENT mechanism) show up
|
|
99
|
+
// as ordinary tool_use blocks instead. Both count as "used web search" for
|
|
100
|
+
// weeklySessionsWithWebSearch; missing this half undercounted it before.
|
|
101
|
+
const WEB_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
|
|
102
|
+
|
|
103
|
+
// Context-window ceiling per model family — used only to turn
|
|
104
|
+
// currentContextTokens into a percentage the panel can render as a bar.
|
|
105
|
+
// Heuristic, not queried from Anthropic (there's no local API for "is this
|
|
106
|
+
// account's extended-context beta on"): Sonnet ships with a 1M-token beta
|
|
107
|
+
// context window in Claude Code; Opus/Haiku are the standard 200k. Falls
|
|
108
|
+
// back to 200k for anything unrecognized — an undercount of the true limit
|
|
109
|
+
// is the safe failure direction (a 1M-context Opus session would just show
|
|
110
|
+
// as "over 100%", visibly wrong and prompting a fix, not silently hidden).
|
|
111
|
+
function contextLimitForModel(model) {
|
|
112
|
+
if (!model) return 200_000;
|
|
113
|
+
if (model.includes('sonnet')) return 1_000_000;
|
|
114
|
+
return 200_000;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every transcript file to scan in the window: each main session file
|
|
119
|
+
* PLUS its subagent transcripts (…/<sessionId>/subagents/*.jsonl) — a
|
|
120
|
+
* separate, nested location a flat directory listing never finds, which is
|
|
121
|
+
* exactly why subagent token usage was invisible before (an Agent/Task tool
|
|
122
|
+
* call was detected on the main thread, but what THAT subagent itself spent
|
|
123
|
+
* was never read at all). Each entry is tagged so the caller can attribute
|
|
124
|
+
* subagent tokens separately while still rolling them into the session total.
|
|
125
|
+
*/
|
|
126
|
+
function listRecentTranscripts(sinceMs) {
|
|
127
|
+
const files = [];
|
|
128
|
+
let projectDirs;
|
|
129
|
+
try {
|
|
130
|
+
projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true });
|
|
131
|
+
} catch {
|
|
132
|
+
return files; // no ~/.claude/projects at all — nothing to scan
|
|
133
|
+
}
|
|
134
|
+
for (const projectDir of projectDirs) {
|
|
135
|
+
if (!projectDir.isDirectory()) continue;
|
|
136
|
+
const dirPath = join(PROJECTS_DIR, projectDir.name);
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
entries = readdirSync(dirPath, { withFileTypes: true });
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
145
|
+
const filePath = join(dirPath, entry.name);
|
|
146
|
+
try {
|
|
147
|
+
if (statSync(filePath).mtimeMs >= sinceMs) {
|
|
148
|
+
files.push({ filePath, isSubagent: false, sessionDir: join(dirPath, entry.name.replace(/\.jsonl$/, '')) });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// file vanished between readdir and stat — skip
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Second pass for subagent files — needs the session dirs discovered above.
|
|
157
|
+
for (const main of files.slice()) {
|
|
158
|
+
let subFiles;
|
|
159
|
+
try {
|
|
160
|
+
subFiles = readdirSync(join(main.sessionDir, 'subagents'), { withFileTypes: true });
|
|
161
|
+
} catch {
|
|
162
|
+
continue; // no subagents dir for this session — the common case
|
|
163
|
+
}
|
|
164
|
+
for (const sub of subFiles) {
|
|
165
|
+
if (!sub.isFile() || !sub.name.endsWith('.jsonl')) continue;
|
|
166
|
+
const filePath = join(main.sessionDir, 'subagents', sub.name);
|
|
167
|
+
try {
|
|
168
|
+
if (statSync(filePath).mtimeMs >= sinceMs) {
|
|
169
|
+
files.push({ filePath, isSubagent: true, parentSessionId: basename(main.filePath, '.jsonl') });
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// skip
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return files;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Per-process cache keyed by (path, mtime, size) — a transcript already
|
|
180
|
+
// scanned this run, unchanged on disk, is never re-read. Only helps within
|
|
181
|
+
// one long-lived MCP server process (one Claude Code session calling this
|
|
182
|
+
// repeatedly), not across separate sessions — still the common case, since
|
|
183
|
+
// the auto-report path fires on every bug-report tool call.
|
|
184
|
+
const scanCache = new Map();
|
|
185
|
+
|
|
186
|
+
/** One transcript's token totals + what it used — null if nothing in range
|
|
187
|
+
* (e.g. mtime was recent but every logged message predates sinceMs).
|
|
188
|
+
* Streams line-by-line (readline over a read stream) instead of loading the
|
|
189
|
+
* whole file into memory — a long session's transcript can run into the
|
|
190
|
+
* hundreds of MB, and the previous readFileSync+split approach held the
|
|
191
|
+
* entire thing (plus the split array) in memory at once. */
|
|
192
|
+
async function scanTranscript(filePath, sinceMs) {
|
|
193
|
+
let stat;
|
|
194
|
+
try {
|
|
195
|
+
stat = statSync(filePath);
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const cacheKey = `${filePath}:${stat.mtimeMs}:${stat.size}`;
|
|
200
|
+
const cached = scanCache.get(cacheKey);
|
|
201
|
+
if (cached) return cached.sinceMs <= sinceMs ? cached.result : null; // a wider window than what's cached still needs a rescan — conservative
|
|
202
|
+
|
|
203
|
+
const seenMessageIds = new Set();
|
|
204
|
+
let inputTokens = 0, outputTokens = 0, cacheCreationTokens = 0, cacheReadTokens = 0;
|
|
205
|
+
let usedWebSearch = false, usedSubagents = false;
|
|
206
|
+
let startedAt = null, cwd = null, gitBranch = null, sessionId = null, version = null;
|
|
207
|
+
/** @type {Record<string, {inputTokens:number, outputTokens:number, cacheCreationTokens:number, cacheReadTokens:number}>} */
|
|
208
|
+
const byModel = {};
|
|
209
|
+
let lastModel = null, lastContextTokens = 0, lastTimestamp = null;
|
|
210
|
+
|
|
211
|
+
const rl = createInterface({ input: createReadStream(filePath, 'utf8'), crlfDelay: Infinity });
|
|
212
|
+
for await (const line of rl) {
|
|
213
|
+
if (!line.trim()) continue;
|
|
214
|
+
let entry;
|
|
215
|
+
try {
|
|
216
|
+
entry = JSON.parse(line);
|
|
217
|
+
} catch {
|
|
218
|
+
continue; // truncated/partial last line — skip it, not the whole file
|
|
219
|
+
}
|
|
220
|
+
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
221
|
+
// A missing timestamp means "can't verify this is in-window" — exclude
|
|
222
|
+
// it rather than risk a pre-window message slipping into the totals (the
|
|
223
|
+
// opposite bug from before: entry.timestamp && ... let a falsy timestamp
|
|
224
|
+
// skip the check entirely, i.e. silently PASS it through unfiltered).
|
|
225
|
+
if (!entry.timestamp || Date.parse(entry.timestamp) < sinceMs) continue;
|
|
226
|
+
|
|
227
|
+
if (cwd === null && entry.cwd) cwd = basename(entry.cwd); // basename only — see upsertTokenBreakdown's privacy comment
|
|
228
|
+
if (sessionId === null && entry.sessionId) sessionId = entry.sessionId;
|
|
229
|
+
if (gitBranch === null && entry.gitBranch) gitBranch = entry.gitBranch;
|
|
230
|
+
if (version === null && entry.version) version = entry.version;
|
|
231
|
+
if (!startedAt || entry.timestamp < startedAt) startedAt = entry.timestamp;
|
|
232
|
+
|
|
233
|
+
// Tool-use detection runs on every line, duplicates included — setting a
|
|
234
|
+
// boolean true twice is harmless, unlike double-counting tokens below.
|
|
235
|
+
if (Array.isArray(entry.message.content)) {
|
|
236
|
+
for (const block of entry.message.content) {
|
|
237
|
+
if (block?.type !== 'tool_use') continue;
|
|
238
|
+
if (SUBAGENT_TOOL_NAMES.has(block.name)) usedSubagents = true;
|
|
239
|
+
if (WEB_TOOL_NAMES.has(block.name)) usedWebSearch = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const messageId = entry.message.id;
|
|
244
|
+
if (messageId) {
|
|
245
|
+
if (seenMessageIds.has(messageId)) continue;
|
|
246
|
+
seenMessageIds.add(messageId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const usage = entry.message.usage;
|
|
250
|
+
if (!usage) continue;
|
|
251
|
+
const model = entry.message.model || 'unknown';
|
|
252
|
+
const mi = usage.input_tokens || 0, mo = usage.output_tokens || 0;
|
|
253
|
+
const mc = usage.cache_creation_input_tokens || 0, mr = usage.cache_read_input_tokens || 0;
|
|
254
|
+
inputTokens += mi; outputTokens += mo; cacheCreationTokens += mc; cacheReadTokens += mr;
|
|
255
|
+
// Claude Code emits internally-synthesized assistant entries tagged
|
|
256
|
+
// model: "<synthetic>" (and similar) with zero real usage — real API
|
|
257
|
+
// calls always cost SOMETHING, so skip attributing a model entry when
|
|
258
|
+
// every field is zero, rather than littering byModel with noise rows.
|
|
259
|
+
if (mi + mo + mc + mr > 0) {
|
|
260
|
+
const m = (byModel[model] ??= { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 });
|
|
261
|
+
m.inputTokens += mi; m.outputTokens += mo; m.cacheCreationTokens += mc; m.cacheReadTokens += mr;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const serverTools = usage.server_tool_use;
|
|
265
|
+
if (serverTools && ((serverTools.web_search_requests || 0) > 0 || (serverTools.web_fetch_requests || 0) > 0)) {
|
|
266
|
+
usedWebSearch = true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// "Current context occupancy" — what the model was actually carrying at
|
|
270
|
+
// THIS turn: the uncached input plus everything served from/written to
|
|
271
|
+
// cache. Tracked off the chronologically LAST REAL message in the file
|
|
272
|
+
// (not the biggest, and not a trailing zero-usage synthetic entry,
|
|
273
|
+
// which would otherwise report "0 tokens / <synthetic>" for a session
|
|
274
|
+
// that actually ended on a real turn).
|
|
275
|
+
if (mi + mo + mc + mr > 0 && (!lastTimestamp || entry.timestamp >= lastTimestamp)) {
|
|
276
|
+
lastTimestamp = entry.timestamp;
|
|
277
|
+
lastModel = model;
|
|
278
|
+
lastContextTokens = mi + mc + mr;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
|
283
|
+
const result = totalTokens === 0 ? null : {
|
|
284
|
+
totalTokens, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens,
|
|
285
|
+
usedWebSearch, usedSubagents, startedAt, lastTimestamp, cwd, gitBranch, sessionId, version, byModel,
|
|
286
|
+
currentContextTokens: lastContextTokens,
|
|
287
|
+
currentContextModel: lastModel,
|
|
288
|
+
};
|
|
289
|
+
scanCache.set(cacheKey, { sinceMs, result });
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function mergeByModel(target, source) {
|
|
294
|
+
for (const [model, v] of Object.entries(source)) {
|
|
295
|
+
const m = (target[model] ??= { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 });
|
|
296
|
+
m.inputTokens += v.inputTokens; m.outputTokens += v.outputTokens;
|
|
297
|
+
m.cacheCreationTokens += v.cacheCreationTokens; m.cacheReadTokens += v.cacheReadTokens;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Scans every local Claude Code transcript touched in the last `days` days
|
|
303
|
+
* (main sessions AND their subagent transcripts — see listRecentTranscripts)
|
|
304
|
+
* and aggregates it into the shape reportClaudeTokenUsageSchema expects.
|
|
305
|
+
* Deliberately NOT scoped to this repo/project — the whole point is "how
|
|
306
|
+
* much Claude Code usage this week", same scope /usage itself reports.
|
|
307
|
+
*/
|
|
308
|
+
async function computeWeeklyTokenBreakdown(days = 7) {
|
|
309
|
+
const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
310
|
+
const fileRefs = listRecentTranscripts(sinceMs);
|
|
311
|
+
const scanned = await Promise.all(fileRefs.map((f) => scanTranscript(f.filePath, sinceMs)));
|
|
312
|
+
|
|
313
|
+
const mainSessions = [];
|
|
314
|
+
const subagentResults = [];
|
|
315
|
+
fileRefs.forEach((ref, i) => {
|
|
316
|
+
const s = scanned[i];
|
|
317
|
+
if (!s) return;
|
|
318
|
+
if (ref.isSubagent) subagentResults.push(s);
|
|
319
|
+
else mainSessions.push(s);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const totals = mainSessions.reduce(
|
|
323
|
+
(acc, s) => ({
|
|
324
|
+
inputTokens: acc.inputTokens + s.inputTokens,
|
|
325
|
+
outputTokens: acc.outputTokens + s.outputTokens,
|
|
326
|
+
cacheCreationTokens: acc.cacheCreationTokens + s.cacheCreationTokens,
|
|
327
|
+
cacheReadTokens: acc.cacheReadTokens + s.cacheReadTokens,
|
|
328
|
+
}),
|
|
329
|
+
{ inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// Subagent "new work" (input+output+cache-creation, same exclusion as
|
|
333
|
+
// newTokenCount below) folded into the SAME four columns so the headline
|
|
334
|
+
// total is actually complete — a session that spawned subagents no longer
|
|
335
|
+
// silently undercounts what those subagents spent. Also kept as its own
|
|
336
|
+
// figure (subagentTokens) so the breakdown stays honest about the split.
|
|
337
|
+
const subagentTokens = subagentResults.reduce((sum, s) => sum + s.inputTokens + s.outputTokens + s.cacheCreationTokens, 0);
|
|
338
|
+
totals.inputTokens += subagentResults.reduce((sum, s) => sum + s.inputTokens, 0);
|
|
339
|
+
totals.outputTokens += subagentResults.reduce((sum, s) => sum + s.outputTokens, 0);
|
|
340
|
+
totals.cacheCreationTokens += subagentResults.reduce((sum, s) => sum + s.cacheCreationTokens, 0);
|
|
341
|
+
totals.cacheReadTokens += subagentResults.reduce((sum, s) => sum + s.cacheReadTokens, 0);
|
|
342
|
+
|
|
343
|
+
const byModel = {};
|
|
344
|
+
for (const s of mainSessions) mergeByModel(byModel, s.byModel);
|
|
345
|
+
for (const s of subagentResults) mergeByModel(byModel, s.byModel);
|
|
346
|
+
|
|
347
|
+
// "Right now" context occupancy — pick the MAIN session whose own last
|
|
348
|
+
// message is chronologically most recent (not whichever started most
|
|
349
|
+
// recently — a session can run for hours), and use its last-message
|
|
350
|
+
// context snapshot. null when nothing was scanned at all.
|
|
351
|
+
const mostRecentSession = mainSessions.reduce(
|
|
352
|
+
(latest, s) => (!latest || (s.lastTimestamp ?? '') > (latest.lastTimestamp ?? '') ? s : latest),
|
|
353
|
+
null
|
|
354
|
+
);
|
|
355
|
+
const currentContext = mostRecentSession && {
|
|
356
|
+
tokens: mostRecentSession.currentContextTokens,
|
|
357
|
+
model: mostRecentSession.currentContextModel,
|
|
358
|
+
limit: contextLimitForModel(mostRecentSession.currentContextModel),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const topSessions = [...mainSessions]
|
|
362
|
+
.sort((a, b) => b.totalTokens - a.totalTokens)
|
|
363
|
+
.slice(0, 8)
|
|
364
|
+
.map((s) => ({
|
|
365
|
+
totalTokens: s.totalTokens,
|
|
366
|
+
startedAt: s.startedAt || new Date(sinceMs).toISOString(),
|
|
367
|
+
cwd: s.cwd,
|
|
368
|
+
gitBranch: s.gitBranch,
|
|
369
|
+
sessionId: s.sessionId,
|
|
370
|
+
version: s.version,
|
|
371
|
+
model: s.currentContextModel,
|
|
372
|
+
contextTokens: s.currentContextTokens,
|
|
373
|
+
usedWebSearch: s.usedWebSearch,
|
|
374
|
+
usedSubagents: s.usedSubagents,
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
periodStart: new Date(sinceMs).toISOString(),
|
|
379
|
+
periodEnd: new Date().toISOString(),
|
|
380
|
+
...totals,
|
|
381
|
+
subagentTokens,
|
|
382
|
+
byModel,
|
|
383
|
+
currentContextTokens: currentContext?.tokens ?? null,
|
|
384
|
+
currentContextModel: currentContext?.model ?? null,
|
|
385
|
+
currentContextLimit: currentContext?.limit ?? null,
|
|
386
|
+
sessionCount: mainSessions.length,
|
|
387
|
+
sessionsWithWebSearch: mainSessions.filter((s) => s.usedWebSearch).length,
|
|
388
|
+
// A session counts as "used subagents" either because the main thread
|
|
389
|
+
// itself spawned one (tool_use detection) OR because subagent transcript
|
|
390
|
+
// files were found for it at all — the two signals can disagree if a
|
|
391
|
+
// subagent was spawned but its own file hasn't flushed yet.
|
|
392
|
+
sessionsWithSubagents: mainSessions.filter(
|
|
393
|
+
(s) => s.usedSubagents || subagentResults.some((sub) => fileRefs.some((r) => r.isSubagent && r.parentSessionId === s.sessionId))
|
|
394
|
+
).length,
|
|
395
|
+
topSessions,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** "585k" / "2.3M" / "1.1B" — raw token counts in a long session routinely
|
|
400
|
+
* reach the hundreds of millions (every turn re-pays for its whole cached
|
|
401
|
+
* context, so cache-read alone compounds fast), so a plain toLocaleString()
|
|
402
|
+
* reads as an alarming wall of digits. Still real numbers — just formatted
|
|
403
|
+
* for a human skimming a report, not a ledger. One decimal place
|
|
404
|
+
* everywhere, including k — the panel (frontend's api.ts) uses the same
|
|
405
|
+
* convention, so a number never reads differently in the two places it
|
|
406
|
+
* can show up. */
|
|
407
|
+
function formatTokenCount(n) {
|
|
408
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
|
409
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
410
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
|
411
|
+
return String(n);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** input + output + cache-WRITE tokens — genuinely new content processed
|
|
415
|
+
* this period (subagent spend already folded in — see computeWeeklyTokenBreakdown).
|
|
416
|
+
* Deliberately excludes cache-READ: that's a heavily-discounted re-read of
|
|
417
|
+
* context already paid for once, and in a long session it dwarfs
|
|
418
|
+
* everything else by 10-100x, so folding it into one headline number would
|
|
419
|
+
* make "new work this week" unreadable. Still reported separately in full
|
|
420
|
+
* (see the schema) — just not what ranks/headlines. */
|
|
421
|
+
function newTokenCount(breakdown) {
|
|
422
|
+
return breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Fire-and-forget: scan + POST the breakdown, swallowing any failure (a
|
|
427
|
+
* missing ~/.claude/projects dir, a network blip, whatever) — this must
|
|
428
|
+
* never fail or slow down the tool call it's piggybacking on. Genuinely
|
|
429
|
+
* non-blocking now (not awaited by its four callers below) — the scan can
|
|
430
|
+
* run into the hundreds of MB and take the better part of a second even
|
|
431
|
+
* streamed, which used to add that same delay to every single bug-report
|
|
432
|
+
* tool call despite the "fire-and-forget" name. Returns nothing: with no
|
|
433
|
+
* await, there's no result left by the time the real tool response goes
|
|
434
|
+
* out, so the old "(Also updated your Claude token usage: ...)" footer is
|
|
435
|
+
* gone too — the next report_claude_token_usage call (or the next bug tool,
|
|
436
|
+
* since this still runs after every one) is what actually surfaces it.
|
|
437
|
+
*/
|
|
438
|
+
function tryAutoReportTokenUsage() {
|
|
439
|
+
if (!API_KEY) return;
|
|
440
|
+
computeWeeklyTokenBreakdown()
|
|
441
|
+
.then((breakdown) => {
|
|
442
|
+
if (breakdown.sessionCount === 0) return;
|
|
443
|
+
return fetch(`${BASE_URL}/api/admin/claude-usage/token-breakdown`, {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
|
446
|
+
body: JSON.stringify(breakdown),
|
|
447
|
+
});
|
|
448
|
+
})
|
|
449
|
+
.catch(() => {}); // never let this break (or even be waited on by) the real tool call
|
|
450
|
+
}
|
|
451
|
+
|
|
69
452
|
server.registerTool(
|
|
70
453
|
'submit_bug_resolution',
|
|
71
454
|
{
|
|
@@ -116,6 +499,7 @@ server.registerTool(
|
|
|
116
499
|
}
|
|
117
500
|
|
|
118
501
|
const report = body?.data;
|
|
502
|
+
tryAutoReportTokenUsage();
|
|
119
503
|
return {
|
|
120
504
|
content: [{
|
|
121
505
|
type: 'text',
|
|
@@ -209,6 +593,8 @@ server.registerTool(
|
|
|
209
593
|
}
|
|
210
594
|
}
|
|
211
595
|
|
|
596
|
+
tryAutoReportTokenUsage();
|
|
597
|
+
|
|
212
598
|
return { content };
|
|
213
599
|
}
|
|
214
600
|
);
|
|
@@ -282,7 +668,13 @@ server.registerTool(
|
|
|
282
668
|
? `${reports.length} of ${meta.total} assigned report(s) (showing offset ${meta.offset}–${meta.offset + reports.length - 1}; pass a higher offset for more):`
|
|
283
669
|
: `${reports.length} assigned report(s):`;
|
|
284
670
|
|
|
285
|
-
|
|
671
|
+
tryAutoReportTokenUsage();
|
|
672
|
+
return {
|
|
673
|
+
content: [{
|
|
674
|
+
type: 'text',
|
|
675
|
+
text: `${header}\n${summary}`,
|
|
676
|
+
}],
|
|
677
|
+
};
|
|
286
678
|
}
|
|
287
679
|
);
|
|
288
680
|
|
|
@@ -332,6 +724,7 @@ server.registerTool(
|
|
|
332
724
|
}
|
|
333
725
|
|
|
334
726
|
const report = body?.data;
|
|
727
|
+
tryAutoReportTokenUsage();
|
|
335
728
|
return {
|
|
336
729
|
content: [{
|
|
337
730
|
type: 'text',
|
|
@@ -358,9 +751,16 @@ server.registerTool(
|
|
|
358
751
|
inputSchema: {
|
|
359
752
|
usagePercent: z.number().min(0).max(100).describe('Current usage, 0-100 (fractional values like 72.5 are fine).'),
|
|
360
753
|
note: z.string().max(500).optional().describe('Optional context, e.g. "Team plan, resets Monday".'),
|
|
754
|
+
// Structured, not buried in `note` — lets the panel show a real
|
|
755
|
+
// countdown/staleness indicator instead of free text it has to
|
|
756
|
+
// truncate. Pass it whenever you can see a concrete reset time (e.g.
|
|
757
|
+
// the statusLine's rate-limit JSON) — omit it if you can't; a
|
|
758
|
+
// percentage without a known reset time is still worth reporting.
|
|
759
|
+
resetsAt: z.string().datetime({ offset: true }).optional()
|
|
760
|
+
.describe('ISO 8601 timestamp this percentage resets at, if known (e.g. from the status line).'),
|
|
361
761
|
},
|
|
362
762
|
},
|
|
363
|
-
async ({ usagePercent, note }) => {
|
|
763
|
+
async ({ usagePercent, note, resetsAt }) => {
|
|
364
764
|
if (!API_KEY) {
|
|
365
765
|
return {
|
|
366
766
|
isError: true,
|
|
@@ -373,7 +773,7 @@ server.registerTool(
|
|
|
373
773
|
res = await fetch(`${BASE_URL}/api/admin/claude-usage`, {
|
|
374
774
|
method: 'POST',
|
|
375
775
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
|
376
|
-
body: JSON.stringify({ usagePercent, note }),
|
|
776
|
+
body: JSON.stringify({ usagePercent, note, resetsAt }),
|
|
377
777
|
});
|
|
378
778
|
} catch (err) {
|
|
379
779
|
return {
|
|
@@ -401,5 +801,85 @@ server.registerTool(
|
|
|
401
801
|
}
|
|
402
802
|
);
|
|
403
803
|
|
|
804
|
+
server.registerTool(
|
|
805
|
+
'report_claude_token_usage',
|
|
806
|
+
{
|
|
807
|
+
title: 'Report Claude token usage breakdown',
|
|
808
|
+
description:
|
|
809
|
+
"Scans this developer's own local Claude Code transcripts " +
|
|
810
|
+
'(~/.claude/projects/**/*.jsonl AND their nested .../subagents/*.jsonl — this MCP server runs on ' +
|
|
811
|
+
'the same machine, so it can read them directly) and reports a real token/model/context breakdown: ' +
|
|
812
|
+
'total tokens (including subagent spend, not just the main thread), a per-model split (so it can ' +
|
|
813
|
+
"actually be priced), how full the most recently active session's context window currently is, and " +
|
|
814
|
+
'how many sessions used web search or spawned a subagent. Unlike ' +
|
|
815
|
+
"report_claude_usage's percentage, nothing here is self-attested — every number is read off real " +
|
|
816
|
+
'local files, so call this freely; it never needs to ask a human for anything. ' +
|
|
817
|
+
"The other bug-resolution tools already call this automatically (fire-and-forget) as a side effect, " +
|
|
818
|
+
"so you usually don't need to call it directly — this exists for an explicit, on-demand check, and " +
|
|
819
|
+
'lets you ask for a window other than the default 7 days.',
|
|
820
|
+
inputSchema: {
|
|
821
|
+
days: z.number().int().min(1).max(90).optional()
|
|
822
|
+
.describe('How many days back to scan (default 7). The reported periodStart/periodEnd always say exactly what was scanned.'),
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
async ({ days }) => {
|
|
826
|
+
if (!API_KEY) {
|
|
827
|
+
return {
|
|
828
|
+
isError: true,
|
|
829
|
+
content: [{ type: 'text', text: 'BUG_REPORT_API_KEY is not set on this MCP server — see README.md.' }],
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const breakdown = await computeWeeklyTokenBreakdown(days ?? 7);
|
|
834
|
+
if (breakdown.sessionCount === 0) {
|
|
835
|
+
return {
|
|
836
|
+
content: [{ type: 'text', text: `No Claude Code transcripts found in the last ${days ?? 7} day(s) under ~/.claude/projects — nothing to report.` }],
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
let res;
|
|
841
|
+
try {
|
|
842
|
+
res = await fetch(`${BASE_URL}/api/admin/claude-usage/token-breakdown`, {
|
|
843
|
+
method: 'POST',
|
|
844
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
|
845
|
+
body: JSON.stringify(breakdown),
|
|
846
|
+
});
|
|
847
|
+
} catch (err) {
|
|
848
|
+
return {
|
|
849
|
+
isError: true,
|
|
850
|
+
content: [{ type: 'text', text: `Could not reach ${BASE_URL}: ${err instanceof Error ? err.message : String(err)}` }],
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const body = await res.json().catch(() => null);
|
|
855
|
+
|
|
856
|
+
if (!res.ok) {
|
|
857
|
+
const message = body?.message || body?.error || `HTTP ${res.status}`;
|
|
858
|
+
return { isError: true, content: [{ type: 'text', text: `Failed to report Claude token usage: ${message}` }] };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const modelLines = Object.entries(breakdown.byModel)
|
|
862
|
+
.map(([model, t]) => ` - ${model}: ${formatTokenCount(t.inputTokens + t.outputTokens + t.cacheCreationTokens)} new (+${formatTokenCount(t.cacheReadTokens)} cache-read)`)
|
|
863
|
+
.join('\n');
|
|
864
|
+
const contextLine = breakdown.currentContextTokens != null
|
|
865
|
+
? `Current context (most recently active session, ${breakdown.currentContextModel}): ` +
|
|
866
|
+
`${formatTokenCount(breakdown.currentContextTokens)} / ${formatTokenCount(breakdown.currentContextLimit)} ` +
|
|
867
|
+
`(${Math.round((breakdown.currentContextTokens / breakdown.currentContextLimit) * 100)}%)`
|
|
868
|
+
: 'Current context: unknown (no in-window session found)';
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
content: [{
|
|
872
|
+
type: 'text',
|
|
873
|
+
text: `Reported ${formatTokenCount(newTokenCount(breakdown))} new tokens ` +
|
|
874
|
+
`(plus ${formatTokenCount(breakdown.cacheReadTokens)} discounted cache-read re-reads) ` +
|
|
875
|
+
`across ${breakdown.sessionCount} session(s) over the last ${days ?? 7} day(s) ` +
|
|
876
|
+
`(${breakdown.sessionsWithWebSearch} used web search, ${breakdown.sessionsWithSubagents} used subagents, ` +
|
|
877
|
+
`${formatTokenCount(breakdown.subagentTokens)} of the new-token total came from subagents).\n` +
|
|
878
|
+
`By model:\n${modelLines}\n${contextLine}`,
|
|
879
|
+
}],
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
);
|
|
883
|
+
|
|
404
884
|
const transport = new StdioServerTransport();
|
|
405
885
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zopkit-bug-resolution-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "MCP server for Zopkit wrapper bug reports: fetch full bug context (with screenshots), submit how a bug was fixed, list bugs assigned to you, file new bug reports,
|
|
5
|
+
"description": "MCP server for Zopkit wrapper bug reports: fetch full bug context (with screenshots), submit how a bug was fixed, list bugs assigned to you, file new bug reports, report your Claude Code usage status, and report a real token/model/context-window breakdown (including subagent spend) from your local transcripts. Requires a BUG_REPORT_API_KEY (generated in the company-admin panel) — see README.md.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=18"
|