context-doctor 0.25.0 → 0.27.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/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.25.0" }, { instructions: SERVER_INSTRUCTIONS });
54
+ function createServer({ fileAccess = true } = {}) {
55
+ const server = new McpServer({ name: "context-doctor", version: "0.27.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({
@@ -68,7 +71,9 @@ function createServer() {
68
71
  })).max(500).describe("Only the blocks over ~500 tokens, repeated, or images. Plain turns need not be listed."),
69
72
  }).optional().describe("Coarse description of the conversation for chat apps. One size hint per block: lines for code, words for prose, chars (or tokens) for logs and tool output, which vary most per line."),
70
73
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
71
- }, async ({ conversation, sketch, model }) => {
74
+ },
75
+ // Pure: reads its input and returns text. Clients (Codex, ChatGPT) may skip approval for read-only tools.
76
+ { title: "Profile context", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ conversation, sketch, model }) => {
72
77
  if (sketch) {
73
78
  return { content: [{ type: "text", text: runSketch({ ...sketch, model: sketch.model ?? model }) }] };
74
79
  }
@@ -81,13 +86,15 @@ function createServer() {
81
86
  const profile = profileConversation(parseConversation(conversation), model);
82
87
  return { content: [{ type: "text", text: renderProfile(profile) }] };
83
88
  });
84
- 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.", {
85
90
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
86
91
  strategies: z.array(z.enum(STRATEGY_IDS)).optional()
87
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."),
88
93
  keep_recent: z.number().int().nonnegative().optional().describe("Messages at the tail to leave untouched (default 6; 0 = none)"),
89
94
  max_tool_result_tokens: z.number().int().positive().optional().describe("Token budget for trimmed tool results (default 300)"),
90
- }, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
95
+ },
96
+ // Pure: reads its input and returns text. Clients (Codex, ChatGPT) may skip approval for read-only tools.
97
+ { title: "Optimize context", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
91
98
  const result = optimizeConversation(conversation, {
92
99
  strategies: strategies,
93
100
  keepRecent: keep_recent,
@@ -129,6 +136,31 @@ function createServer() {
129
136
  }
130
137
  return { content };
131
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
+ });
132
164
  // A prompt shows up in Claude Desktop's "+" menu, so a user can run a checkup
133
165
  // with one click instead of typing a request. It is the only proactive
134
166
  // surface MCP offers besides `instructions`.
@@ -148,7 +180,9 @@ function createServer() {
148
180
  }));
149
181
  server.tool("context_best_practices", "Get a curated checklist of context-management best practices, optionally specialized for a provider (anthropic, openai).", {
150
182
  provider: z.enum(["general", "anthropic", "openai"]).optional().describe("Provider to specialize tips for (default: general)"),
151
- }, async ({ provider }) => {
183
+ },
184
+ // Pure: reads its input and returns text. Clients (Codex, ChatGPT) may skip approval for read-only tools.
185
+ { title: "Context best practices", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ provider }) => {
152
186
  const tips = [...BEST_PRACTICES.general, ...(provider && provider !== "general" ? BEST_PRACTICES[provider] : [])];
153
187
  return { content: [{ type: "text", text: tips.map((t, i) => `${i + 1}. ${t}`).join("\n") }] };
154
188
  });
@@ -239,7 +273,7 @@ if (argv.includes("--http")) {
239
273
  setHeader(req, "accept", "application/json, text/event-stream");
240
274
  }
241
275
  // Fresh server + transport per request (stateless — nothing shared).
242
- const server = createServer();
276
+ const server = createServer({ fileAccess: false });
243
277
  const transport = new StreamableHTTPServerTransport({
244
278
  sessionIdGenerator: undefined,
245
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
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `context-doctor overhead`: what every request pays before you type.
3
+ *
4
+ * Each request an agent sends re-sends the same front matter: the harness's
5
+ * system prompt, tool and MCP schemas, skills, and the memory files the user
6
+ * wrote (CLAUDE.md and its imports, rules, auto memory, AGENTS.md, Cursor
7
+ * rules, GEMINI.md). It is cached, so each read is cheap, but it is read on
8
+ * every request of every session, and at full write price after every cold
9
+ * start. Nobody sees it: transcripts do not contain it.
10
+ *
11
+ * Two measurements, both local:
12
+ * - the fixed overhead itself, from the first request of each Claude Code
13
+ * session (the API's own count, minus the first user message);
14
+ * - the memory files that load in a directory, sized with Claude's ratios and
15
+ * priced per month from the user's own request and cold-start counts.
16
+ */
17
+ import { type McpServerConfig, type McpServerSize, type McpUsage } from "./mcpschema.js";
18
+ export type Agent = "Claude Code" | "Codex" | "Cursor" | "Gemini CLI";
19
+ export interface MemoryFile {
20
+ agent: Agent;
21
+ path: string;
22
+ tokens: number;
23
+ /** Why it loads: "project", "user", "import", "rules", "auto memory". */
24
+ via: string;
25
+ }
26
+ export interface OverheadFinding {
27
+ severity: "warn" | "info";
28
+ message: string;
29
+ suggestion: string;
30
+ }
31
+ export interface Baseline {
32
+ sessions: number;
33
+ /** Median tokens of the first request, minus the first user message. */
34
+ median: number;
35
+ p90: number;
36
+ requests: number;
37
+ coldStarts: number;
38
+ days: number;
39
+ model?: string;
40
+ /** USD a month for each 1,000 tokens of fixed overhead, at this usage. */
41
+ usdPerKPerMonth?: number;
42
+ }
43
+ export interface OverheadReport {
44
+ cwd: string;
45
+ baseline?: Baseline;
46
+ files: MemoryFile[];
47
+ findings: OverheadFinding[];
48
+ mcp: McpSection;
49
+ }
50
+ export interface McpSection {
51
+ configs: McpServerConfig[];
52
+ usage: McpUsage;
53
+ /** Present when the servers were launched and asked for their tools (`--mcp`). */
54
+ sizes?: McpServerSize[];
55
+ }
56
+ /** Every memory file the four agents load for a session started in `cwd`. */
57
+ export declare function findMemoryFiles(cwd?: string, home?: string): MemoryFile[];
58
+ /**
59
+ * Fixed overhead per session, measured: the first main-chain request's input
60
+ * (input + cache read + cache write) minus the first user message, plus the
61
+ * request and cold-start counts that turn tokens into a monthly bill.
62
+ */
63
+ export declare function measureBaseline(days?: number, paths?: string[]): Baseline | undefined;
64
+ /** What is worth changing in the memory files. */
65
+ export declare function overheadFindings(files: MemoryFile[], baseline?: Baseline): OverheadFinding[];
66
+ export declare function overheadReport(opts?: {
67
+ cwd?: string;
68
+ home?: string;
69
+ days?: number;
70
+ paths?: string[];
71
+ }): OverheadReport;
72
+ /** Launch each configured server and size its tool definitions, then add the findings they support. */
73
+ export declare function measureMcpSizes(r: OverheadReport): Promise<void>;
74
+ /** Configured servers nobody called: their definitions ride on every request for nothing. */
75
+ export declare function mcpFindings(m: McpSection, baseline?: Baseline): OverheadFinding[];
76
+ export declare function renderOverhead(r: OverheadReport, home?: string): string;