context-doctor 0.26.0 → 0.28.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/dist/gemini.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Gemini CLI chat recordings, read into the same shape as Claude Code and
3
+ * Codex transcripts so `session`, `watch` and the every-prompt hook work on
4
+ * them.
5
+ *
6
+ * Gemini CLI writes ~/.gemini/tmp/<project>/chats/session-*.jsonl: a metadata
7
+ * record, then one record per message (`user`, `gemini`, `info`, `error`).
8
+ * A message written again with the same id replaces the earlier copy (tool
9
+ * results and token counts arrive after the text), `{"$rewindTo": id}` drops
10
+ * that message and everything after it, and `{"$set": …}` updates metadata.
11
+ * Older versions wrote the whole conversation as one JSON document with a
12
+ * `messages` array; both are read.
13
+ *
14
+ * Each `gemini` message records the API's own usage (`tokens.input` is the
15
+ * request's promptTokenCount, cached part included), so the live context size
16
+ * is exact, as it is for Claude Code and Codex.
17
+ */
18
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { forEachLine } from "./session.js";
22
+ /** True when the file's first record looks like a Gemini CLI chat (cheap: reads 4 KB). */
23
+ export function isGeminiChat(path) {
24
+ if (!/\.jsonl?$/.test(path))
25
+ return false;
26
+ try {
27
+ const buf = Buffer.alloc(4096);
28
+ const fd = openSync(path, "r");
29
+ const n = readSync(fd, buf, 0, 4096, 0);
30
+ closeSync(fd);
31
+ const head = buf.subarray(0, n).toString("utf8");
32
+ return /"projectHash"\s*:/.test(head) && /"sessionId"\s*:/.test(head);
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /** Text of a Gemini `content`: a string, or Part[] with `text` fields. */
39
+ function text(content) {
40
+ if (typeof content === "string")
41
+ return content;
42
+ if (Array.isArray(content))
43
+ return content.map((p) => (typeof p === "string" ? p : typeof p?.text === "string" ? p.text : "")).filter(Boolean).join("\n");
44
+ return "";
45
+ }
46
+ /** What a tool call returned, as the model saw it. */
47
+ function resultText(call) {
48
+ const r = call?.result;
49
+ if (typeof r === "string")
50
+ return r;
51
+ if (Array.isArray(r)) {
52
+ return r
53
+ .map((p) => {
54
+ const resp = p?.functionResponse?.response;
55
+ if (resp !== undefined)
56
+ return typeof resp?.output === "string" ? resp.output : JSON.stringify(resp);
57
+ return typeof p?.text === "string" ? p.text : "";
58
+ })
59
+ .filter(Boolean)
60
+ .join("\n");
61
+ }
62
+ return typeof call?.resultDisplay === "string" ? call.resultDisplay : r === undefined ? "" : JSON.stringify(r);
63
+ }
64
+ /** The ordered, de-duplicated, rewind-applied messages of a chat file. */
65
+ export function readGeminiMessages(path) {
66
+ const order = [];
67
+ const byId = new Map();
68
+ let anon = 0;
69
+ const upsert = (m) => {
70
+ const id = typeof m.id === "string" ? m.id : `anon-${anon++}`;
71
+ if (!byId.has(id))
72
+ order.push(id);
73
+ byId.set(id, m);
74
+ };
75
+ const raw = readFileSync(path, "utf8");
76
+ const trimmed = raw.trimStart();
77
+ if (trimmed.startsWith("{") && !trimmed.includes("\n{")) {
78
+ // Legacy: one JSON document.
79
+ try {
80
+ const doc = JSON.parse(raw);
81
+ for (const m of doc.messages ?? [])
82
+ upsert(m);
83
+ return order.map((id) => byId.get(id));
84
+ }
85
+ catch {
86
+ /* fall through to JSONL */
87
+ }
88
+ }
89
+ forEachLine(path, (line) => {
90
+ if (!line.trim())
91
+ return;
92
+ let r;
93
+ try {
94
+ r = JSON.parse(line);
95
+ }
96
+ catch {
97
+ return;
98
+ }
99
+ if (!r || typeof r !== "object")
100
+ return;
101
+ if (typeof r.$rewindTo === "string") {
102
+ const at = order.indexOf(r.$rewindTo);
103
+ if (at >= 0)
104
+ for (const id of order.splice(at))
105
+ byId.delete(id);
106
+ return;
107
+ }
108
+ if (typeof r.type === "string" && ("content" in r || "toolCalls" in r))
109
+ upsert(r);
110
+ });
111
+ return order.map((id) => byId.get(id));
112
+ }
113
+ export function parseGeminiChat(path) {
114
+ const messages = [];
115
+ const usageSamples = [];
116
+ let model;
117
+ let reportedInputTokens;
118
+ for (const m of readGeminiMessages(path)) {
119
+ if (m.type === "user") {
120
+ const t = text(m.content);
121
+ if (t)
122
+ messages.push({ role: "user", content: t });
123
+ }
124
+ else if (m.type === "gemini") {
125
+ if (typeof m.model === "string")
126
+ model = m.model;
127
+ const input = Number(m.tokens?.input);
128
+ if (Number.isFinite(input) && input > 0) {
129
+ reportedInputTokens = input;
130
+ usageSamples.push({ index: messages.length, input });
131
+ }
132
+ const blocks = [];
133
+ const t = text(m.content);
134
+ if (t)
135
+ blocks.push({ type: "text", text: t });
136
+ const calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
137
+ for (const c of calls)
138
+ blocks.push({ type: "tool_use", id: String(c.id ?? c.name), name: String(c.name ?? "tool"), input: c.args ?? {} });
139
+ if (blocks.length)
140
+ messages.push({ role: "assistant", content: blocks });
141
+ const results = calls.filter((c) => c.result !== undefined || c.resultDisplay !== undefined);
142
+ if (results.length) {
143
+ messages.push({ role: "user", content: results.map((c) => ({ type: "tool_result", tool_use_id: String(c.id ?? c.name), content: resultText(c) })) });
144
+ }
145
+ }
146
+ }
147
+ return {
148
+ conversationJson: JSON.stringify({ messages }),
149
+ model,
150
+ messageCount: messages.length,
151
+ reportedInputTokens,
152
+ usageSamples,
153
+ path,
154
+ };
155
+ }
156
+ /** Gemini CLI chats on this machine: ~/.gemini/tmp/<project>/chats/session-*.json[l]. */
157
+ export function listGeminiChats(home = homedir()) {
158
+ const root = join(home, ".gemini", "tmp");
159
+ if (!existsSync(root))
160
+ return [];
161
+ const out = [];
162
+ let projects;
163
+ try {
164
+ projects = readdirSync(root);
165
+ }
166
+ catch {
167
+ return [];
168
+ }
169
+ for (const project of projects) {
170
+ const dir = join(root, project, "chats");
171
+ let files;
172
+ try {
173
+ files = readdirSync(dir);
174
+ }
175
+ catch {
176
+ continue;
177
+ }
178
+ for (const f of files) {
179
+ if (!/^session-.*\.jsonl?$/.test(f))
180
+ continue;
181
+ const path = join(dir, f);
182
+ try {
183
+ const st = statSync(path);
184
+ out.push({ path, project: "gemini", modifiedAt: st.mtime, sizeBytes: st.size });
185
+ }
186
+ catch {
187
+ /* vanished */
188
+ }
189
+ }
190
+ }
191
+ return out;
192
+ }
package/dist/hook.js CHANGED
@@ -199,8 +199,10 @@ export async function runHook() {
199
199
  // systemMessage is shown to the user in the app; additionalContext goes to the model.
200
200
  if (notice)
201
201
  out.systemMessage = notice;
202
+ // Gemini CLI's prompt hook is BeforeAgent; it reads the same output shape.
203
+ const event = input.hook_event_name === "BeforeAgent" ? "BeforeAgent" : "UserPromptSubmit";
202
204
  if (lines.length > 0)
203
- out.hookSpecificOutput = { hookEventName: "UserPromptSubmit", additionalContext: block };
205
+ out.hookSpecificOutput = { hookEventName: event, additionalContext: block };
204
206
  console.log(JSON.stringify(out));
205
207
  };
206
208
  if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR) {
package/dist/index.d.ts CHANGED
@@ -26,3 +26,9 @@ export * from "./autopilot.js";
26
26
  export * from "./savings.js";
27
27
  export * from "./coldresume.js";
28
28
  export * from "./compactwindow.js";
29
+ export * from "./pack.js";
30
+ export * from "./overhead.js";
31
+ export * from "./split.js";
32
+ export * from "./mcpschema.js";
33
+ export * from "./wrap.js";
34
+ export * from "./ci.js";
package/dist/index.js CHANGED
@@ -21,3 +21,9 @@ export * from "./autopilot.js";
21
21
  export * from "./savings.js";
22
22
  export * from "./coldresume.js";
23
23
  export * from "./compactwindow.js";
24
+ export * from "./pack.js";
25
+ export * from "./overhead.js";
26
+ export * from "./split.js";
27
+ export * from "./mcpschema.js";
28
+ export * from "./wrap.js";
29
+ export * from "./ci.js";
package/dist/install.d.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * Every config edit is a careful JSON merge with a .backup file written first.
7
7
  * `context-doctor uninstall` reverses it.
8
8
  */
9
+ export declare function geminiSettingsPath(): string;
10
+ export declare function geminiCliPresent(): boolean;
9
11
  /**
10
12
  * How to invoke the published package as an MCP server on a given platform.
11
13
  *
package/dist/install.js CHANGED
@@ -25,8 +25,19 @@ function targets() {
25
25
  { name: "Claude Desktop", configPath: desktop, detect: () => existsSync(dirname(desktop)) },
26
26
  { name: "Claude Code", configPath: claudeCode, detect: () => existsSync(claudeCode) || existsSync(join(homedir(), ".claude")) },
27
27
  { name: "Cursor", configPath: cursor, detect: () => existsSync(join(homedir(), ".cursor")) },
28
+ { name: "Gemini CLI", configPath: geminiSettingsPath(), detect: geminiCliPresent },
28
29
  ];
29
30
  }
31
+ // -- Gemini CLI: MCP servers and hooks both live in ~/.gemini/settings.json,
32
+ // hooks in Claude Code's shape under the event BeforeAgent (its prompt hook).
33
+ // ~/.gemini alone is not enough to call it present: Google's Antigravity IDE
34
+ // creates it too, without the CLI.
35
+ export function geminiSettingsPath() {
36
+ return join(homedir(), ".gemini", "settings.json");
37
+ }
38
+ export function geminiCliPresent() {
39
+ return existsSync(geminiSettingsPath()) || existsSync(join(homedir(), ".gemini", "tmp")) || binOnPath("gemini") !== null;
40
+ }
30
41
  /**
31
42
  * The server command to write into configs.
32
43
  *
@@ -204,12 +215,12 @@ function installHook() {
204
215
  * ({hooks:{UserPromptSubmit:[{hooks:[{type:"command",command}]}]}}), so one
205
216
  * writer serves both; only the path differs.
206
217
  */
207
- function installHookInto(settingsPath, gateDir) {
218
+ function installHookInto(settingsPath, gateDir, event = "UserPromptSubmit") {
208
219
  if (!existsSync(gateDir))
209
220
  return null; // that app is not on this machine
210
221
  const settings = readJson(settingsPath);
211
222
  settings.hooks = settings.hooks ?? {};
212
- const entries = settings.hooks.UserPromptSubmit ?? [];
223
+ const entries = settings.hooks[event] ?? [];
213
224
  const want = hookCommand();
214
225
  // Re-running install must REPAIR a stale entry, not skip it. Earlier versions
215
226
  // wrote a version-pinned node binary; if we only checked "is it present?" an
@@ -223,20 +234,21 @@ function installHookInto(settingsPath, gateDir) {
223
234
  // Replace every entry of ours with exactly one correct entry.
224
235
  const others = entries.filter((e) => !isOurHookEntry(e));
225
236
  others.push({ hooks: [{ type: "command", command: want }] });
226
- settings.hooks.UserPromptSubmit = others;
237
+ settings.hooks[event] = others;
227
238
  writeJsonWithBackup(settingsPath, settings);
228
239
  return settingsPath;
229
240
  }
230
241
  else {
231
242
  return settingsPath; // already correct — leave the file untouched
232
243
  }
233
- settings.hooks.UserPromptSubmit = entries;
244
+ settings.hooks[event] = entries;
234
245
  writeJsonWithBackup(settingsPath, settings);
235
246
  return settingsPath;
236
247
  }
237
248
  function uninstallHook() {
238
249
  uninstallHookFrom(join(homedir(), ".claude", "settings.json"), "Claude Code every-prompt hook");
239
250
  uninstallHookFrom(join(homedir(), ".codex", "hooks.json"), "Codex every-prompt hook");
251
+ uninstallHookFrom(geminiSettingsPath(), "Gemini CLI every-prompt hook", "BeforeAgent");
240
252
  uninstallCursorHook();
241
253
  }
242
254
  /**
@@ -285,18 +297,18 @@ function uninstallCursorHook() {
285
297
  writeJsonWithBackup(path, config);
286
298
  console.log("✓ Cursor after-tool-call hook removed");
287
299
  }
288
- function uninstallHookFrom(settingsPath, label) {
300
+ function uninstallHookFrom(settingsPath, label, event = "UserPromptSubmit") {
289
301
  if (!existsSync(settingsPath))
290
302
  return;
291
303
  const settings = readJson(settingsPath);
292
- const entries = settings.hooks?.UserPromptSubmit;
304
+ const entries = settings.hooks?.[event];
293
305
  if (!entries)
294
306
  return;
295
307
  const filtered = entries.filter((e) => !isOurHookEntry(e));
296
308
  if (filtered.length !== entries.length) {
297
- settings.hooks.UserPromptSubmit = filtered;
309
+ settings.hooks[event] = filtered;
298
310
  if (filtered.length === 0)
299
- delete settings.hooks.UserPromptSubmit;
311
+ delete settings.hooks[event];
300
312
  writeJsonWithBackup(settingsPath, settings);
301
313
  console.log(`✓ ${label} removed`);
302
314
  }
@@ -441,7 +453,7 @@ export function runInstall(options = {}) {
441
453
  const entry = serverEntry();
442
454
  const found = targets().filter((t) => t.detect());
443
455
  if (found.length === 0 && !existsSync(codexDir())) {
444
- console.log("No supported AI apps detected (Claude Desktop, Claude Code, Cursor).");
456
+ console.log("No supported AI apps detected (Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI).");
445
457
  console.log("Manual setup — add to your app's MCP config:");
446
458
  console.log(JSON.stringify({ mcpServers: { "context-doctor": entry } }, null, 2));
447
459
  return { failures: [] };
@@ -497,6 +509,17 @@ export function runInstall(options = {}) {
497
509
  failures.push("Cursor hook");
498
510
  }
499
511
  installCodex(entry, failures);
512
+ if (geminiCliPresent()) {
513
+ try {
514
+ const hook = installHookInto(geminiSettingsPath(), join(homedir(), ".gemini"), "BeforeAgent");
515
+ if (hook)
516
+ console.log(`✓ Gemini CLI every-prompt hook installed (${hook})`);
517
+ }
518
+ catch (e) {
519
+ console.error(`✗ Gemini CLI hook: ${e.message}`);
520
+ failures.push("Gemini CLI hook");
521
+ }
522
+ }
500
523
  if (options.statusLine) {
501
524
  try {
502
525
  switch (installStatusLine()) {
package/dist/mcp.d.ts CHANGED
@@ -11,5 +11,6 @@
11
11
  * profile_context — analyze a conversation/prompt, report token breakdown + findings
12
12
  * optimize_context — apply safe strategies, return the slimmed conversation
13
13
  * context_best_practices — curated checklist for a given provider/use case
14
+ * pack_context — only the chunks of big files a question needs, within a token budget
14
15
  */
15
16
  export {};
package/dist/mcp.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * profile_context — analyze a conversation/prompt, report token breakdown + findings
12
12
  * optimize_context — apply safe strategies, return the slimmed conversation
13
13
  * context_best_practices — curated checklist for a given provider/use case
14
+ * pack_context — only the chunks of big files a question needs, within a token budget
14
15
  */
15
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
17
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -22,6 +23,7 @@ import { renderProfile } from "./report.js";
22
23
  import { formatTokens } from "./tokens.js";
23
24
  import { recordLedger } from "./ledger.js";
24
25
  import { runSketch } from "./sketch.js";
26
+ import { packContextSemantic, readSources, renderPack } from "./pack.js";
25
27
  /**
26
28
  * Server instructions are injected by MCP clients (Claude Desktop, Cursor, …)
27
29
  * into the system context of EVERY conversation where this server is enabled.
@@ -38,18 +40,19 @@ import { runSketch } from "./sketch.js";
38
40
  // thresholds keep it from firing on every turn, which would cost more context
39
41
  // than it saves.
40
42
  const SERVER_INSTRUCTIONS = `Context hygiene rules (always on):
41
- 1. Summarize any paste or tool result over ~2k tokens into the points you will use, then work from the summary; never carry it verbatim.
42
- 2. Reference earlier content by name; never re-quote it. Never inline base64.
43
- 3. When the conversation passes ~30 turns, or holds 3+ large pastes, or the user asks about tokens, cost, speed or limits: call profile_context BEFORE answering and act on its top finding. In a chat app pass a \`sketch\` (turn count + the large/repeated blocks, ~120 tokens), not the conversation. Do not estimate token counts yourself.
44
- 4. If optimize_context returns a pruned-turns digest, you write the ≤150-token replacement summary.`;
43
+ 1. Summarize any paste or tool result over ~2k tokens into the points you need; never carry it verbatim.
44
+ 2. Refer to earlier content by name; never re-quote it or inline base64.
45
+ 3. Past ~30 turns or 3+ large pastes, or when asked about tokens, cost, speed or limits: call profile_context BEFORE answering and act on its top finding. In a chat app pass a \`sketch\` (turns + large/repeated blocks), not the conversation. Do not estimate token counts yourself.
46
+ 4. If optimize_context returns a pruned-turns digest, write its ≤150-token summary.
47
+ 5. To answer from a large file or folder you won't edit, call pack_context with the question; don't read it whole.`;
45
48
  const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"];
46
49
  /**
47
50
  * Build a fully-configured server instance. A factory (not a singleton) so the
48
51
  * stateless HTTP mode can hand every request its own server, per the MCP SDK's
49
52
  * recommended pattern.
50
53
  */
51
- function createServer() {
52
- const server = new McpServer({ name: "context-doctor", version: "0.26.0" }, { instructions: SERVER_INSTRUCTIONS });
54
+ function createServer({ fileAccess = true } = {}) {
55
+ const server = new McpServer({ name: "context-doctor", version: "0.28.0" }, { instructions: SERVER_INSTRUCTIONS });
53
56
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
54
57
  conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
55
58
  sketch: z.object({
@@ -83,7 +86,7 @@ function createServer() {
83
86
  const profile = profileConversation(parseConversation(conversation), model);
84
87
  return { content: [{ type: "text", text: renderProfile(profile) }] };
85
88
  });
86
- server.tool("optimize_context", "Rewrite a conversation to reclaim tokens using deterministic strategies: dedupe repeated content, trim stale tool results, strip base64 blobs, optionally prune old history. Returns the slimmed conversation JSON plus a savings summary. No LLM calls — safe and inspectable. Call this after profile_context finds recoverable waste and the user wants it fixed; add the prune-history strategy only with the user's consent, then write the replacement summary yourself as the result instructs.", {
89
+ server.tool("optimize_context", "Rewrite a conversation to reclaim tokens with deterministic strategies (dedupe, trim stale tool results, strip base64; prune-history is optional and lossy). Returns the slimmed JSON and a savings summary; no LLM calls. Use after profile_context finds recoverable waste the user wants fixed. Add prune-history only with the user's consent, then write the replacement summary as the result instructs.", {
87
90
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
88
91
  strategies: z.array(z.enum(STRATEGY_IDS)).optional()
89
92
  .describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add trim-tool-calls to shrink big inline file writes, or prune-history for lossy compaction of old turns."),
@@ -133,6 +136,31 @@ function createServer() {
133
136
  }
134
137
  return { content };
135
138
  });
139
+ server.tool("pack_context", "Read only the parts of large files a question needs: chunks files or folders along their structure, ranks the chunks against `query` and returns the best that fit `max_tokens`, each with an id and line range. No query: an outline; pick with `ids`. Use instead of reading a big doc, log or folder whole when not editing it. Text, code, PDF, Word, PowerPoint, ODT.", {
140
+ paths: z.array(z.string()).max(50).optional().describe("Files or folders (absolute, or relative to the server's cwd)"),
141
+ text: z.string().optional().describe("Raw text to pack instead of files"),
142
+ query: z.string().optional().describe("The question; omit for an outline"),
143
+ max_tokens: z.number().int().positive().max(200_000).optional().describe("Budget, default 4000"),
144
+ ids: z.array(z.string()).max(200).optional().describe("Chunk ids from an outline or result, e.g. docs/guide.md#4"),
145
+ model: z.string().optional().describe("Model the budget is counted for (default Claude)"),
146
+ semantic: z.boolean().optional().describe("Also rank by meaning (local Ollama embeddings)"),
147
+ },
148
+ // Reads files, never writes. Clients (Codex, ChatGPT) may skip approval for read-only tools.
149
+ { title: "Pack context", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ paths, text, query, max_tokens, ids, model, semantic }) => {
150
+ const err = (t) => ({ content: [{ type: "text", text: t }], isError: true });
151
+ if (paths?.length && !fileAccess) {
152
+ return err("This server runs over HTTP and does not read files. Pass the document as `text`.");
153
+ }
154
+ const { sources, skipped } = paths?.length ? readSources(paths) : { sources: [], skipped: [] };
155
+ if (text)
156
+ sources.push({ name: "text", text });
157
+ if (sources.length === 0) {
158
+ return err(skipped.length ? `Nothing readable: ${skipped.join("; ")}` : "Pass `paths` (files or folders) or `text`.");
159
+ }
160
+ const result = await packContextSemantic(sources, { query, budget: max_tokens, ids, model, semantic });
161
+ const note = skipped.length ? `\nSkipped: ${skipped.slice(0, 10).join("; ")}${skipped.length > 10 ? ` (+${skipped.length - 10} more)` : ""}` : "";
162
+ return { content: [{ type: "text", text: renderPack(result) + note }] };
163
+ });
136
164
  // A prompt shows up in Claude Desktop's "+" menu, so a user can run a checkup
137
165
  // with one click instead of typing a request. It is the only proactive
138
166
  // surface MCP offers besides `instructions`.
@@ -245,7 +273,7 @@ if (argv.includes("--http")) {
245
273
  setHeader(req, "accept", "application/json, text/event-stream");
246
274
  }
247
275
  // Fresh server + transport per request (stateless — nothing shared).
248
- const server = createServer();
276
+ const server = createServer({ fileAccess: false });
249
277
  const transport = new StreamableHTTPServerTransport({
250
278
  sessionIdGenerator: undefined,
251
279
  // A client that never asked for a stream gets plain JSON back.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The MCP schema tax: what each MCP server's tool definitions add to every
3
+ * request, and whether the server is used at all.
4
+ *
5
+ * Tool definitions (name, description, JSON schema) ride in the request's
6
+ * `tools` array, so they are part of the fixed overhead `overhead` measures;
7
+ * transcripts never contain them. Two sources:
8
+ * - usage, from transcripts: every MCP call is a tool_use named
9
+ * `mcp__<server>__<tool>`, which also reveals servers the app provides
10
+ * (desktop browser, connectors) that no config file lists;
11
+ * - size, by asking each configured server for its tool list, exactly as
12
+ * Claude Code does at startup. That launches the servers, so it runs only
13
+ * when asked (`overhead --mcp`).
14
+ *
15
+ * Claude Code's tool search defers MCP definitions until the model looks them
16
+ * up; with it on, an unused server costs about its tool names, not its
17
+ * schemas. Usage of ToolSearch in the transcripts says whether that is so.
18
+ */
19
+ export interface McpServerConfig {
20
+ name: string;
21
+ scope: "user" | "project" | ".mcp.json";
22
+ command?: string;
23
+ args?: string[];
24
+ env?: Record<string, string>;
25
+ url?: string;
26
+ type?: string;
27
+ }
28
+ export interface McpServerSize {
29
+ name: string;
30
+ tools?: number;
31
+ /** Tokens of the full definitions: what an up-front load costs on every request. */
32
+ schemaTokens?: number;
33
+ /** Tokens of the tool names alone: roughly what a deferred load costs. */
34
+ nameTokens?: number;
35
+ error?: string;
36
+ }
37
+ export interface McpUsage {
38
+ /** Calls per server name (as it appears in tool names), main chain and subagents. */
39
+ calls: Map<string, number>;
40
+ sessions: number;
41
+ /** Sessions in which the model used ToolSearch, i.e. MCP definitions were deferred. */
42
+ toolSearchSessions: number;
43
+ }
44
+ /** Claude Code's spelling of a server name inside tool names. */
45
+ export declare function toolPrefixName(server: string): string;
46
+ /** MCP servers Claude Code starts in `cwd`, with their launch configs. */
47
+ export declare function claudeMcpConfigs(cwd?: string, home?: string): McpServerConfig[];
48
+ /** MCP calls per server over the last `days`, from Claude Code transcripts (a cheap line scan, no JSON parse per line). */
49
+ export declare function mcpUsage(days?: number, paths?: string[]): McpUsage;
50
+ /** Connect to one server the way a client does and size its tool list. */
51
+ export declare function measureServer(cfg: McpServerConfig, timeoutMs?: number): Promise<McpServerSize>;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The MCP schema tax: what each MCP server's tool definitions add to every
3
+ * request, and whether the server is used at all.
4
+ *
5
+ * Tool definitions (name, description, JSON schema) ride in the request's
6
+ * `tools` array, so they are part of the fixed overhead `overhead` measures;
7
+ * transcripts never contain them. Two sources:
8
+ * - usage, from transcripts: every MCP call is a tool_use named
9
+ * `mcp__<server>__<tool>`, which also reveals servers the app provides
10
+ * (desktop browser, connectors) that no config file lists;
11
+ * - size, by asking each configured server for its tool list, exactly as
12
+ * Claude Code does at startup. That launches the servers, so it runs only
13
+ * when asked (`overhead --mcp`).
14
+ *
15
+ * Claude Code's tool search defers MCP definitions until the model looks them
16
+ * up; with it on, an unused server costs about its tool names, not its
17
+ * schemas. Usage of ToolSearch in the transcripts says whether that is so.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join, resolve } from "node:path";
22
+ import { forEachLine, listSessions } from "./session.js";
23
+ import { estimateTokens } from "./tokens.js";
24
+ const CLAUDE = "claude-opus-5";
25
+ /** Claude Code's spelling of a server name inside tool names. */
26
+ export function toolPrefixName(server) {
27
+ return server.replace(/[^A-Za-z0-9_-]/g, "_");
28
+ }
29
+ /** MCP servers Claude Code starts in `cwd`, with their launch configs. */
30
+ export function claudeMcpConfigs(cwd = process.cwd(), home = homedir()) {
31
+ const out = new Map();
32
+ const addAll = (servers, scope) => {
33
+ for (const [name, c] of Object.entries(servers ?? {})) {
34
+ if (c && typeof c === "object")
35
+ out.set(name, { name, scope, command: c.command, args: c.args, env: c.env, url: c.url, type: c.type });
36
+ }
37
+ };
38
+ try {
39
+ const cfg = JSON.parse(readFileSync(join(home, ".claude.json"), "utf8"));
40
+ addAll(cfg.mcpServers, "user");
41
+ addAll(cfg.projects?.[resolve(cwd)]?.mcpServers, "project");
42
+ }
43
+ catch {
44
+ /* absent */
45
+ }
46
+ try {
47
+ addAll(JSON.parse(readFileSync(join(cwd, ".mcp.json"), "utf8")).mcpServers, ".mcp.json");
48
+ }
49
+ catch {
50
+ /* absent */
51
+ }
52
+ return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
53
+ }
54
+ /** MCP calls per server over the last `days`, from Claude Code transcripts (a cheap line scan, no JSON parse per line). */
55
+ export function mcpUsage(days = 30, paths) {
56
+ const since = Date.now() - days * 86_400_000;
57
+ const files = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
58
+ const calls = new Map();
59
+ let toolSearchSessions = 0;
60
+ for (const path of files) {
61
+ let searched = false;
62
+ forEachLine(path, (line) => {
63
+ if (!line.includes('"tool_use"'))
64
+ return;
65
+ for (const m of line.matchAll(/"name":"mcp__([^"]+?)__[^"]+"/g))
66
+ calls.set(m[1], (calls.get(m[1]) ?? 0) + 1);
67
+ if (!searched && line.includes('"name":"ToolSearch"'))
68
+ searched = true;
69
+ });
70
+ if (searched)
71
+ toolSearchSessions++;
72
+ }
73
+ return { calls, sessions: files.length, toolSearchSessions };
74
+ }
75
+ /** Connect to one server the way a client does and size its tool list. */
76
+ export async function measureServer(cfg, timeoutMs = 15_000) {
77
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
78
+ const client = new Client({ name: "context-doctor-overhead", version: "1" });
79
+ let transport;
80
+ if (cfg.command) {
81
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
82
+ transport = new StdioClientTransport({
83
+ command: cfg.command,
84
+ args: cfg.args ?? [],
85
+ env: { ...process.env, ...(cfg.env ?? {}) },
86
+ stderr: "ignore",
87
+ });
88
+ }
89
+ else if (cfg.url && cfg.type !== "sse") {
90
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
91
+ transport = new StreamableHTTPClientTransport(new URL(cfg.url));
92
+ }
93
+ else {
94
+ return { name: cfg.name, error: cfg.type === "sse" ? "SSE server: not measured" : "no command or url" };
95
+ }
96
+ let timer;
97
+ const work = (async () => {
98
+ await client.connect(transport);
99
+ const tools = [];
100
+ let cursor;
101
+ do {
102
+ const page = await client.listTools(cursor ? { cursor } : {});
103
+ tools.push(...page.tools);
104
+ cursor = page.nextCursor;
105
+ } while (cursor && tools.length < 5000);
106
+ const prefix = `mcp__${toolPrefixName(cfg.name)}__`;
107
+ const schemaTokens = tools.reduce((s, t) => s + estimateTokens(JSON.stringify({ name: prefix + t.name, description: t.description ?? "", input_schema: t.inputSchema ?? {} }), CLAUDE), 0);
108
+ const nameTokens = tools.reduce((s, t) => s + estimateTokens(prefix + t.name + "\n", CLAUDE), 0);
109
+ return { name: cfg.name, tools: tools.length, schemaTokens, nameTokens };
110
+ })();
111
+ const timeout = new Promise((res) => {
112
+ timer = setTimeout(() => res({ name: cfg.name, error: `no answer in ${timeoutMs / 1000}s` }), timeoutMs);
113
+ });
114
+ try {
115
+ return await Promise.race([work, timeout]);
116
+ }
117
+ catch (e) {
118
+ const msg = String(e?.message ?? e).split("\n")[0];
119
+ return { name: cfg.name, error: /401|403|unauthori[sz]ed/i.test(msg) ? "needs sign-in (not measured)" : msg.slice(0, 100) };
120
+ }
121
+ finally {
122
+ clearTimeout(timer);
123
+ await client.close().catch(() => { });
124
+ }
125
+ }