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.
Files changed (3) hide show
  1. package/README.md +44 -5
  2. package/index.js +488 -8
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # bug-resolution MCP server
2
2
 
3
- A five-tool MCP server so Claude Code (or Cursor, or any other MCP client)
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
- All five are thin wrappers — see `backend/src/features/bug-reports/README.md`
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
- Three more, independent of the fetch/resolve flow above:
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 — five tools wrapping the bug-reports resolution
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 — self-reports this
18
- // developer's current Claude Code usage percentage (see
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.5.0' });
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
- return { content: [{ type: 'text', text: `${header}\n${summary}` }] };
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.5.0",
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, and report your Claude Code usage status. Requires a BUG_REPORT_API_KEY (generated in the company-admin panel) — see README.md.",
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"