agent-dir 0.4.1 → 1.0.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/src/mcp.js CHANGED
@@ -1,26 +1,26 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
2
3
  import { createRequire } from "node:module";
3
- import { hostname, userInfo } from "node:os";
4
+ import { hostname, release, userInfo } from "node:os";
4
5
  import { CodeGraphIntegration } from "./codegraph.js";
5
6
  import { TelemetryRecorder } from "./telemetry/recorder.js";
6
- import { findDefinition, findExports, findFiles, findImports, findReferences, findSymbol, searchCode, searchFiles, } from "./tools/code.js";
7
+ import { findFiles, findReferences, findSymbol, searchCode } from "./tools/code.js";
7
8
  import { runCommand } from "./tools/commands.js";
8
9
  import { diagnostics } from "./tools/diagnostics.js";
9
- import { applyFileChanges, deleteFile, listDirs, listFiles, patchFiles, readFile, readRange, writeFile, } from "./tools/files.js";
10
- import { gitCommit, gitDiff, gitLog, gitPush, gitRestore, gitStage, gitStatus, gitUnstage, } from "./tools/git.js";
11
- import { fileInfo, packageInfo, projectOverview } from "./tools/metadata.js";
10
+ import { applyFileChanges, readFile, readRange } from "./tools/files.js";
11
+ import { fileInfo, projectOverview } from "./tools/metadata.js";
12
12
  import { runNpm } from "./tools/npm.js";
13
- import { getSkill, listSkillResources, listSkills, readSkillDirectory, readSkillResource, skillResourceUrisForPath, } from "./tools/skills.js";
13
+ import { getSkill, invalidateSkillDiscoveryCache, listSkillResources, listSkills, readSkillDirectory, readSkillResource, skillResourceUrisForPath, } from "./tools/skills.js";
14
14
  const require = createRequire(import.meta.url);
15
15
  const packageVersion = require("../../package.json").version;
16
16
  const PROTOCOL_VERSION = "2026-07-28";
17
- const LEGACY_PROTOCOL_VERSION = "2025-11-25";
18
17
  const SERVER_INFO = { name: "agent-dir", version: packageVersion };
19
18
  const SERVER_INFO_META = { "io.modelcontextprotocol/serverInfo": SERVER_INFO };
20
19
  const PROTOCOL_META = "io.modelcontextprotocol/protocolVersion";
21
20
  const CLIENT_CAPABILITIES_META = "io.modelcontextprotocol/clientCapabilities";
22
21
  const PAGE_SIZE = 50;
23
- const DEFAULT_OUTPUT_BYTES = 32_000;
22
+ const DISCOVERY_CACHE_TTL_MS = 300_000;
23
+ const DEFAULT_OUTPUT_BYTES = 8_000;
24
24
  const INSTRUCTIONS_URI = "agent-dir://instructions";
25
25
  const CAPABILITIES_URI = "agent-dir://capabilities";
26
26
  const SUBSCRIPTION_ID_META = "io.modelcontextprotocol/subscriptionId";
@@ -35,7 +35,6 @@ const arrayOutput = (items) => ({
35
35
  type: "array",
36
36
  items,
37
37
  });
38
- const fileEntrySchema = objectOutput({ path: stringSchema, type: { type: "string", enum: ["file", "directory"] } }, ["path", "type"]);
39
38
  const searchMatchSchema = objectOutput({
40
39
  path: stringSchema,
41
40
  line: { type: "integer" },
@@ -48,37 +47,25 @@ const commandResultSchema = objectOutput({
48
47
  stdout: stringSchema,
49
48
  stderr: stringSchema,
50
49
  }, ["command", "exitCode", "stdout", "stderr"]);
51
- const codeGraphTool = tool("codegraph_explore", "Explore the current project through its optional CodeGraph MCP index. Returns CodeGraph native structured tool results for structural, flow, and symbol questions.", {
50
+ const codeGraphTool = tool("codegraph_explore", "Use first for most non-trivial code-understanding tasks: explain behavior, trace call/data flow, map symbol relationships, or assess change impact. Prefer this over repeated grep/read loops for structural questions. Use search_code/find_symbol/read_range for exact-text lookup and targeted follow-up. Treat returned source as read; avoid re-verifying without a concrete gap. Output is capped; large responses return a preview.", {
52
51
  query: { type: "string", minLength: 1 },
53
- maxFiles: { type: "integer", minimum: 1, maximum: 100 },
52
+ maxFiles: { type: "integer", minimum: 1, maximum: 100, default: 10 },
53
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
54
54
  }, { type: "object" }, ["query"]);
55
55
  const baseTools = [
56
- tool("list_files", "List all visible project files and directories recursively up to the server's discovery depth. Prefer project_overview, find_files, search_code, or list_dirs for targeted discovery; use this only when broad recursive discovery is actually needed.", {
57
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
58
- }, arrayOutput(fileEntrySchema)),
59
- tool("list_dirs", "List files and directories directly inside one or more project directories.", {
60
- paths: { type: "array", items: pathSchema, minItems: 1 },
61
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
62
- }, arrayOutput(objectOutput({ path: stringSchema, entries: arrayOutput(fileEntrySchema) }, [
63
- "path",
64
- "entries",
65
- ])), ["paths"]),
66
56
  tool("read_range", "Read a bounded line range from one UTF-8 project file.", {
67
57
  path: pathSchema,
68
58
  startLine: { type: "integer", minimum: 1 },
69
59
  endLine: { type: "integer", minimum: 1 },
70
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
60
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
71
61
  }, objectOutput({
72
62
  path: stringSchema,
73
63
  startLine: { type: "integer" },
74
64
  endLine: { type: "integer" },
75
65
  content: stringSchema,
66
+ truncated: { type: "boolean" },
76
67
  }, ["path", "startLine", "endLine", "content"]), ["path", "startLine", "endLine"]),
77
- tool("read_files", "Read one or more UTF-8 project files in one call. Prefer read_range when only a bounded section is needed; batch related files here to reduce round trips.", {
78
- paths: { type: "array", items: pathSchema, minItems: 1 },
79
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
80
- }, arrayOutput(objectOutput({ path: stringSchema, content: stringSchema }, ["path", "content"])), ["paths"]),
81
- tool("apply_changes", "Apply multiple file writes, exact patches, and deletes as one preflighted transaction. Use this for related multi-file edits when partial changes would be undesirable; supports dry-run previews.", {
68
+ tool("apply_changes", "Atomically apply related writes, exact patches, and deletes. Returns only per-file status; never returns file contents or diffs. Use dryRun=true to preview changes.", {
82
69
  dryRun: { type: "boolean", default: false },
83
70
  changes: {
84
71
  type: "array",
@@ -109,72 +96,24 @@ const baseTools = [
109
96
  changed: { type: "boolean" },
110
97
  dryRun: { type: "boolean" },
111
98
  }, ["kind", "path", "changed"])), ["changes"]),
112
- tool("write_files", "Create or completely replace one or more UTF-8 project files.", {
113
- dryRun: { type: "boolean", default: false },
114
- files: {
115
- type: "array",
116
- items: objectOutput({ path: pathSchema, content: stringSchema }, ["path", "content"]),
117
- minItems: 1,
118
- },
119
- }, arrayOutput(objectOutput({ path: stringSchema, written: { type: "boolean" } }, ["path", "written"])), ["files"]),
120
- tool("patch_files", "Apply exact text replacements to one or more UTF-8 files. Prefer this for targeted edits instead of rewriting whole files; batch related patches in one call.", {
121
- dryRun: { type: "boolean", default: false },
122
- files: {
123
- type: "array",
124
- items: objectOutput({
125
- path: pathSchema,
126
- patches: {
127
- type: "array",
128
- items: objectOutput({
129
- search: stringSchema,
130
- replace: stringSchema,
131
- count: { type: "integer", minimum: 1 },
132
- }, ["search", "replace"]),
133
- minItems: 1,
134
- },
135
- }, ["path", "patches"]),
136
- minItems: 1,
137
- },
138
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
139
- }, arrayOutput(objectOutput({ path: stringSchema, applied: { type: "integer" }, content: stringSchema }, [
140
- "path",
141
- "applied",
142
- "content",
143
- ])), ["files"]),
144
- tool("delete_files", "Delete one or more project files.", {
145
- paths: { type: "array", items: pathSchema, minItems: 1 },
146
- dryRun: { type: "boolean", default: false },
147
- }, arrayOutput(objectOutput({ path: stringSchema, deleted: { type: "boolean" } }, ["path", "deleted"])), ["paths"]),
148
- tool("search_files", "Search text across visible project files. Prefer this over broad file reads; use search_code for source-only searches and keep maxResults tight.", {
149
- query: stringSchema,
150
- regex: { type: "boolean", default: false },
151
- maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
152
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
153
- }, arrayOutput(searchMatchSchema), ["query"]),
154
- tool("find_files", "Find project files by a simple glob pattern using * and ?. Prefer this over list_files when you know the filename pattern.", {
99
+ tool("find_files", "Find project paths by a simple glob pattern using * and ?. Returns {items, truncated}; use this for targeted filename discovery.", {
155
100
  pattern: stringSchema,
156
101
  maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
157
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
158
- }, arrayOutput(stringSchema), ["pattern"]),
159
- tool("search_code", "Search source-like project files for text or a regular expression. Prefer this over reading files broadly; start with a narrow query and expand only if needed.", {
102
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
103
+ }, objectOutput({ items: arrayOutput(stringSchema), truncated: { type: "boolean" } }, [
104
+ "items",
105
+ "truncated",
106
+ ]), ["pattern"]),
107
+ tool("search_code", "Search source files by text or regex within an 8 KB default budget. Binary files and files over 1 MB are skipped; start narrow and expand only when needed.", {
160
108
  query: stringSchema,
161
109
  regex: { type: "boolean", default: false },
162
110
  maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
163
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
111
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
164
112
  }, arrayOutput(searchMatchSchema), ["query"]),
165
- tool("find_symbol", "Find likely symbol definitions across source files. Prefer this over reading whole modules when locating an implementation.", {
113
+ tool("find_symbol", "Find symbol definitions and locations. Use read_range for source context and find_references to trace usage.", {
166
114
  symbol: stringSchema,
167
115
  maxResults: { type: "integer", minimum: 1, maximum: 500, default: 100 },
168
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
169
- }, arrayOutput(objectOutput({
170
- ...searchMatchSchema.properties,
171
- symbol: stringSchema,
172
- kind: stringSchema,
173
- }, ["path", "line", "column", "text", "symbol", "kind"])), ["symbol"]),
174
- tool("find_definition", "Find likely definitions of a symbol. Use this before reading a large file when you need one implementation.", {
175
- symbol: stringSchema,
176
- maxResults: { type: "integer", minimum: 1, maximum: 500, default: 100 },
177
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
116
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
178
117
  }, arrayOutput(objectOutput({
179
118
  ...searchMatchSchema.properties,
180
119
  symbol: stringSchema,
@@ -183,137 +122,30 @@ const baseTools = [
183
122
  tool("find_references", "Find source-code references to a symbol. Use this instead of searching entire files when tracing usage of a known symbol.", {
184
123
  symbol: stringSchema,
185
124
  maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
186
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
125
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
187
126
  }, arrayOutput(searchMatchSchema), ["symbol"]),
188
- tool("find_imports", "Find import and require statements in source files.", {
189
- maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
190
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
191
- }, arrayOutput(objectOutput({
192
- ...searchMatchSchema.properties,
193
- module: stringSchema,
194
- kind: { type: "string", enum: ["import", "require"] },
195
- }, ["path", "line", "column", "text", "module", "kind"]))),
196
- tool("find_exports", "Find exported declarations in source files.", {
197
- maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
198
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
199
- }, arrayOutput(objectOutput({
200
- ...searchMatchSchema.properties,
201
- symbol: stringSchema,
202
- kind: { type: "string", const: "export" },
203
- }, ["path", "line", "column", "text", "symbol", "kind"]))),
204
- tool("git_status", "Show Git working-tree status and branch information. Use this as the lightweight first Git check; do not run a full diff unless changes need inspection.", {
205
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
206
- }, commandResultSchema),
207
- tool("git_stage", "Stage one or more Git paths.", {
208
- paths: { type: "array", items: pathSchema, minItems: 1 },
209
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
210
- }, commandResultSchema, ["paths"]),
211
- tool("git_unstage", "Unstage one or more Git paths without changing their working-tree contents.", {
212
- paths: { type: "array", items: pathSchema, minItems: 1 },
213
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
214
- }, commandResultSchema, ["paths"]),
215
- tool("git_commit", "Create a Git commit with the currently staged changes.", {
216
- message: stringSchema,
217
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
218
- }, commandResultSchema, ["message"]),
219
- tool("git_restore", "Restore one or more Git paths from the index, discarding unstaged working-tree changes.", {
220
- paths: { type: "array", items: pathSchema, minItems: 1 },
221
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
222
- }, commandResultSchema, ["paths"]),
223
- tool("git_push", "Push the current Git branch to a remote.", {
224
- remote: stringSchema,
225
- branch: stringSchema,
226
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
227
- }, commandResultSchema),
228
- tool("git_diff", "Show a read-only Git diff, optionally staged or limited to a path. Prefer path-scoped or staged diffs over dumping the entire repository diff.", {
229
- staged: { type: "boolean", default: false },
230
- path: pathSchema,
231
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
232
- }, commandResultSchema),
233
- tool("git_log", "Show recent Git commits in a compact, read-only form. Keep the limit small and scope by path when history context is local.", {
234
- limit: { type: "integer", minimum: 1, maximum: 200, default: 20 },
235
- path: pathSchema,
236
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
237
- }, commandResultSchema),
238
- tool("locate", "Locate project code or files in one call. Use kind auto, code, text, or file.", {
239
- query: stringSchema,
240
- kind: { type: "string", enum: ["auto", "code", "text", "file"], default: "auto" },
241
- regex: { type: "boolean", default: false },
242
- maxResults: { type: "integer", minimum: 1, maximum: 100, default: 20 },
243
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
244
- }, { type: "object", additionalProperties: true }, ["query"]),
245
- tool("inspect_symbol", "Inspect a known symbol in one call, returning its definition, bounded source context, and optionally references.", {
246
- symbol: stringSchema,
247
- includeReferences: { type: "boolean", default: false },
248
- contextLines: { type: "integer", minimum: 0, maximum: 20, default: 3 },
249
- maxResults: { type: "integer", minimum: 1, maximum: 20, default: 5 },
250
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
251
- }, { type: "object", additionalProperties: true }, ["symbol"]),
252
- tool("inspect", "Inspect a project target in one call. Use kind auto, file, symbol, or code; returns bounded context and optional symbol references.", {
253
- target: stringSchema,
254
- kind: { type: "string", enum: ["auto", "file", "symbol", "code"], default: "auto" },
255
- includeReferences: { type: "boolean", default: false },
256
- contextLines: { type: "integer", minimum: 0, maximum: 20, default: 3 },
257
- maxResults: { type: "integer", minimum: 1, maximum: 20, default: 5 },
258
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
259
- }, { type: "object", additionalProperties: true }, ["target"]),
260
127
  tool("project_context", "Return a compact LLM-oriented project snapshot combining project overview, package metadata, and active execution policy. Prefer this over separate orientation calls.", {
261
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
128
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
262
129
  }, { type: "object", additionalProperties: true }),
263
- tool("read_relevant", "Locate relevant source matches for a query and return bounded source context in one call. Prefer this when you know what you need but not the exact lines.", {
264
- query: stringSchema,
265
- regex: { type: "boolean", default: false },
266
- contextLines: { type: "integer", minimum: 0, maximum: 20, default: 3 },
267
- maxResults: { type: "integer", minimum: 1, maximum: 20, default: 5 },
268
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
269
- }, { type: "object", additionalProperties: true }, ["query"]),
270
- tool("validate", "Run a unified project validation pass: lightweight diagnostics first, then explicitly requested allowed npm scripts. Use diagnostics for cheap checks and scripts only when executable validation is needed.", {
130
+ tool("validate", "Run a unified project validation pass: lightweight diagnostics first, then explicitly requested allowed npm scripts. Use level=diagnostics for cheap checks and request scripts only when executable validation is needed.", {
271
131
  level: { type: "string", enum: ["diagnostics", "scripts", "full"], default: "diagnostics" },
272
- scripts: { type: "array", items: stringSchema, minItems: 1 },
132
+ scripts: { type: "array", items: stringSchema, minItems: 1, maxItems: 10 },
273
133
  maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
274
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
275
- }, { type: "object", additionalProperties: true }),
276
- tool("git_changes", "Return a compact Git working-tree snapshot combining status and an optional diff. Prefer this over separate git_status and git_diff calls when reviewing changes.", {
277
- includeDiff: { type: "boolean", default: false },
278
- path: pathSchema,
279
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
134
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
280
135
  }, { type: "object", additionalProperties: true }),
281
- tool("project_overview", "Summarize project structure, languages, package managers, project metadata files, and Git state in one lightweight call. Prefer this before broad discovery.", {}, objectOutput({
282
- root: stringSchema,
283
- files: { type: "integer" },
284
- directories: { type: "integer" },
285
- languages: { type: "array" },
286
- packageManagers: { type: "array", items: stringSchema },
287
- projectFiles: { type: "array", items: stringSchema },
288
- git: { type: "object" },
289
- }, ["root", "files", "directories", "languages", "packageManagers", "projectFiles", "git"])),
290
- tool("package_info", "Read normalized metadata from package.json when present.", {}, { type: "object", additionalProperties: true }),
291
- tool("allowed_commands", "Show the npm scripts and commands explicitly allowed by the active server configuration.", {}, objectOutput({
292
- npmScripts: { type: "array", items: stringSchema },
293
- commands: { type: "array", items: stringSchema },
294
- }, ["npmScripts", "commands"])),
295
136
  tool("file_info", "Return filesystem metadata for a project path.", { path: pathSchema }, { type: "object", additionalProperties: true }, ["path"]),
296
- tool("diagnostics", "Run lightweight project-independent diagnostics without invoking tests, linters, or typecheckers. Prefer this before expensive validation when checking basic file/JSON/conflict issues.", {
297
- maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
298
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
299
- }, arrayOutput(objectOutput({
300
- severity: { type: "string", enum: ["error", "warning", "info"] },
301
- code: stringSchema,
302
- path: stringSchema,
303
- line: { type: "integer" },
304
- message: stringSchema,
305
- }, ["severity", "code", "message"]))),
306
137
  tool("run_npm_batch", "Run one or more explicitly allowed npm scripts sequentially. Prefer the narrowest relevant script; batch independent scripts in one call and avoid running full check/test when a targeted check answers the question.", {
307
- scripts: { type: "array", items: stringSchema, minItems: 1 },
308
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
138
+ scripts: { type: "array", items: stringSchema, minItems: 1, maxItems: 10 },
139
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
309
140
  }, arrayOutput(commandResultSchema), ["scripts"]),
310
141
  tool("run_command_batch", "Run one or more explicitly allowed commands sequentially without a shell. Prefer dedicated Agent Dir tools over generic commands because they return structured, bounded results with less token overhead.", {
311
142
  commands: {
312
143
  type: "array",
313
144
  items: objectOutput({ command: stringSchema, args: { type: "array", items: stringSchema } }, ["command"]),
314
145
  minItems: 1,
146
+ maxItems: 10,
315
147
  },
316
- maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
148
+ maxBytes: { type: "integer", minimum: 1024, maximum: 64000, default: DEFAULT_OUTPUT_BYTES },
317
149
  }, arrayOutput(commandResultSchema), ["commands"]),
318
150
  ];
319
151
  function tool(name, description, properties, outputSchema, required = []) {
@@ -345,13 +177,23 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
345
177
  let sessionToolCallCount = 0;
346
178
  let sessionCommandCallCount = 0;
347
179
  let sessionRecorded = false;
180
+ let sessionOutputBytes = 0;
181
+ let sessionEstimatedOutputTokens = 0;
182
+ const seenOutputFingerprints = new Set();
183
+ let cachedTools;
184
+ const getAvailableTools = async () => {
185
+ if (cachedTools && cachedTools.expiresAt > Date.now())
186
+ return cachedTools.value;
187
+ const value = await availableTools(codeGraph);
188
+ cachedTools = { expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS, value };
189
+ return value;
190
+ };
348
191
  const telemetryProjectPromise = telemetry.level === "detailed" || telemetry.level === "diagnostic"
349
192
  ? buildTelemetryProjectContext(root, codeGraph).catch(() => undefined)
350
193
  : undefined;
351
194
  const npmAllowed = commandConfig.npm?.allowedScripts ?? [];
352
195
  const allowedCommands = commandConfig.commands ?? [];
353
196
  const blacklistedCommands = commandConfig.blacklistedCommands ?? [];
354
- const gitEnabled = commandConfig.git ?? allowedCommands.includes("git");
355
197
  const subscriptions = new Set();
356
198
  const notify = (event) => {
357
199
  for (const subscription of subscriptions) {
@@ -383,6 +225,17 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
383
225
  };
384
226
  const handler = async (request) => {
385
227
  const requestStartedAt = performance.now();
228
+ let responseBytes;
229
+ let estimatedResponseTokens;
230
+ const trackedJson = (value, status = 200) => {
231
+ const serialized = JSON.stringify(value);
232
+ responseBytes = Buffer.byteLength(serialized, "utf8");
233
+ estimatedResponseTokens = estimateTokens(serialized);
234
+ return new Response(serialized, {
235
+ status,
236
+ headers: { "content-type": "application/json; charset=utf-8" },
237
+ });
238
+ };
386
239
  if (sessionStartedAt === undefined)
387
240
  sessionStartedAt = requestStartedAt;
388
241
  sessionRequestCount += 1;
@@ -397,6 +250,8 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
397
250
  method,
398
251
  success: mcpSuccess,
399
252
  durationMs: performance.now() - requestStartedAt,
253
+ ...(responseBytes === undefined ? {} : { responseBytes }),
254
+ ...(estimatedResponseTokens === undefined ? {} : { estimatedResponseTokens }),
400
255
  ...(telemetryRequestErrorCategory
401
256
  ? { errorCategory: telemetryRequestErrorCategory }
402
257
  : {}),
@@ -441,64 +296,19 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
441
296
  }
442
297
  const params = message.params ?? {};
443
298
  runtime = telemetryRuntime(params);
444
- const requestedProtocolVersion = typeof params.protocolVersion === "string" ? params.protocolVersion : undefined;
445
- const legacyInitialize = message.method === "initialize";
446
- const legacyRequest = !legacyInitialize &&
447
- request.headers.get("mcp-protocol-version") === LEGACY_PROTOCOL_VERSION &&
448
- !isRecord(params._meta);
449
- if (legacyInitialize) {
450
- const protocolHeader = request.headers.get("mcp-protocol-version");
451
- if (protocolHeader !== null && protocolHeader !== LEGACY_PROTOCOL_VERSION) {
452
- telemetryRequestErrorCategory = "invalid_input";
453
- recordMcpRequest();
454
- return jsonRpcError(message.id ?? null, -32020, "MCP-Protocol-Version does not match the initialize protocol version.", 400);
455
- }
456
- if (requestedProtocolVersion !== LEGACY_PROTOCOL_VERSION) {
457
- telemetryRequestErrorCategory = "invalid_input";
458
- recordMcpRequest();
459
- return jsonRpcError(message.id ?? null, -32602, "Unsupported protocol version.", 400, {
460
- supported: [LEGACY_PROTOCOL_VERSION],
461
- requested: requestedProtocolVersion,
462
- });
463
- }
464
- if (!isRecord(params.capabilities) || !isRecord(params.clientInfo)) {
465
- telemetryRequestErrorCategory = "invalid_input";
466
- recordMcpRequest();
467
- return jsonRpcError(message.id ?? null, -32602, "Invalid initialize params.", 400);
468
- }
469
- mcpSuccess = true;
470
- recordMcpRequest();
471
- return json({
472
- jsonrpc: "2.0",
473
- id: message.id,
474
- result: {
475
- protocolVersion: LEGACY_PROTOCOL_VERSION,
476
- capabilities: capabilities(),
477
- serverInfo: SERVER_INFO,
478
- instructions: buildAgentInstructions(npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, await codeGraph.capability()),
479
- },
480
- });
481
- }
482
- if (legacyRequest) {
483
- if (request.headers.get("mcp-protocol-version") !== LEGACY_PROTOCOL_VERSION) {
484
- telemetryRequestErrorCategory = "invalid_input";
485
- recordMcpRequest();
486
- return jsonRpcError(message.id ?? null, -32020, "MCP-Protocol-Version must be 2025-11-25 for legacy requests.", 400);
487
- }
488
- }
489
- else if (!isRecord(params._meta)) {
299
+ if (!isRecord(params._meta)) {
490
300
  telemetryRequestErrorCategory = "invalid_input";
491
301
  recordMcpRequest();
492
302
  return jsonRpcError(message.id ?? null, -32602, "Missing required request metadata.", 400);
493
303
  }
494
- const meta = isRecord(params._meta) ? params._meta : {};
304
+ const meta = params._meta;
495
305
  const version = meta[PROTOCOL_META];
496
- if (!legacyRequest && typeof version !== "string") {
306
+ if (typeof version !== "string") {
497
307
  telemetryRequestErrorCategory = "invalid_input";
498
308
  recordMcpRequest();
499
309
  return jsonRpcError(message.id ?? null, -32602, "Missing required protocol version metadata.", 400);
500
310
  }
501
- if (!legacyRequest && version !== PROTOCOL_VERSION) {
311
+ if (version !== PROTOCOL_VERSION) {
502
312
  telemetryRequestErrorCategory = "invalid_input";
503
313
  recordMcpRequest();
504
314
  return jsonRpcError(message.id ?? null, -32022, "Unsupported protocol version.", 400, {
@@ -506,7 +316,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
506
316
  requested: version,
507
317
  });
508
318
  }
509
- if (!legacyRequest && !isRecord(meta[CLIENT_CAPABILITIES_META])) {
319
+ if (!isRecord(meta[CLIENT_CAPABILITIES_META])) {
510
320
  telemetryRequestErrorCategory = "invalid_input";
511
321
  recordMcpRequest();
512
322
  return jsonRpcError(message.id ?? null, -32602, "Missing required client capabilities metadata.", 400);
@@ -515,7 +325,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
515
325
  switch (message.method) {
516
326
  case "server/discover":
517
327
  mcpSuccess = true;
518
- return json({
328
+ return trackedJson({
519
329
  jsonrpc: "2.0",
520
330
  id: message.id,
521
331
  result: {
@@ -523,23 +333,23 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
523
333
  supportedVersions: [PROTOCOL_VERSION],
524
334
  capabilities: capabilities(),
525
335
  _meta: SERVER_INFO_META,
526
- instructions: buildAgentInstructions(npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, await codeGraph.capability()),
336
+ instructions: buildDiscoveryInstructions(npmAllowed, allowedCommands, await codeGraph.capability()),
527
337
  codeGraph: await codeGraph.capability(),
528
- ttlMs: 0,
338
+ ttlMs: DISCOVERY_CACHE_TTL_MS,
529
339
  cacheScope: "private",
530
340
  },
531
341
  });
532
342
  case "tools/list": {
533
- const page = paginate(await availableTools(codeGraph, gitEnabled), params.cursor);
343
+ const page = paginate(await getAvailableTools(), params.cursor);
534
344
  mcpSuccess = true;
535
- return json({
345
+ return trackedJson({
536
346
  jsonrpc: "2.0",
537
347
  id: message.id,
538
348
  result: {
539
349
  resultType: "complete",
540
350
  tools: page.items,
541
351
  ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
542
- ttlMs: 0,
352
+ ttlMs: DISCOVERY_CACHE_TTL_MS,
543
353
  cacheScope: "private",
544
354
  _meta: SERVER_INFO_META,
545
355
  },
@@ -551,7 +361,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
551
361
  return jsonRpcError(message.id ?? null, -32602, "tools/call name must be a non-empty string.", 400);
552
362
  }
553
363
  const toolName = params.name;
554
- const toolDefinition = (await availableTools(codeGraph, gitEnabled)).find((item) => item.name === toolName);
364
+ const toolDefinition = (await getAvailableTools()).find((item) => item.name === toolName);
555
365
  if (!toolDefinition)
556
366
  throw new Error(`Unknown tool: ${toolName}`);
557
367
  if (params.arguments !== undefined && !isRecord(params.arguments)) {
@@ -567,19 +377,33 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
567
377
  sessionToolCallCount += 1;
568
378
  const startedAt = performance.now();
569
379
  try {
570
- const result = await callTool(toolName, argumentsValue, root, npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, notify, request.signal, codeGraph);
380
+ const result = await callTool(toolName, argumentsValue, root, npmAllowed, allowedCommands, blacklistedCommands, notify, request.signal, codeGraph);
571
381
  const durationMs = performance.now() - startedAt;
572
382
  const count = resultCount(result);
383
+ const serializedResult = JSON.stringify(result);
384
+ const outputBytes = Buffer.byteLength(serializedResult, "utf8");
385
+ const estimatedOutputTokens = estimateTokens(serializedResult);
386
+ sessionOutputBytes += outputBytes;
387
+ sessionEstimatedOutputTokens += estimatedOutputTokens;
573
388
  const runtime = telemetryRuntime(params);
574
389
  telemetryRecorder.record({
575
390
  event: "tool_call",
576
391
  tool: toolName,
392
+ requestIdHash: telemetryHash(String(message.id)),
577
393
  success: true,
578
394
  durationMs,
579
395
  inputBytes: Buffer.byteLength(JSON.stringify(argumentsValue), "utf8"),
580
- outputBytes: Buffer.byteLength(JSON.stringify(result), "utf8"),
396
+ outputBytes,
397
+ estimatedInputTokens: estimateTokens(JSON.stringify(argumentsValue)),
398
+ estimatedOutputTokens,
399
+ cumulativeOutputBytes: sessionOutputBytes,
400
+ cumulativeEstimatedOutputTokens: sessionEstimatedOutputTokens,
401
+ inputFingerprint: telemetryHash(JSON.stringify({ toolName, argumentsValue })),
402
+ ...(telemetry.level === "diagnostic"
403
+ ? { diagnosticDetails: diagnosticToolDetails(argumentsValue, result) }
404
+ : {}),
581
405
  ...(count === undefined ? {} : { resultCount: count }),
582
- ...toolTelemetryMetrics(toolName, result),
406
+ ...toolTelemetryMetrics(toolName, result, seenOutputFingerprints),
583
407
  }, runtime, telemetryProject);
584
408
  const command = commandTelemetryDescriptor(toolName);
585
409
  if (command) {
@@ -590,11 +414,11 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
590
414
  operation: command.operation,
591
415
  success: true,
592
416
  durationMs,
593
- outputBytes: Buffer.byteLength(JSON.stringify(result), "utf8"),
417
+ outputBytes,
594
418
  }, runtime, telemetryProject);
595
419
  }
596
420
  mcpSuccess = true;
597
- return json({
421
+ return trackedJson({
598
422
  jsonrpc: "2.0",
599
423
  id: message.id,
600
424
  result,
@@ -607,9 +431,21 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
607
431
  telemetryRecorder.record({
608
432
  event: "tool_call",
609
433
  tool: toolName,
434
+ requestIdHash: telemetryHash(String(message.id)),
610
435
  success: false,
611
436
  durationMs,
612
437
  inputBytes: Buffer.byteLength(JSON.stringify(argumentsValue), "utf8"),
438
+ estimatedInputTokens: estimateTokens(JSON.stringify(argumentsValue)),
439
+ inputFingerprint: telemetryHash(JSON.stringify({ toolName, argumentsValue })),
440
+ ...(telemetry.level === "diagnostic"
441
+ ? {
442
+ diagnosticDetails: diagnosticToolDetails(argumentsValue, {
443
+ error: error instanceof Error
444
+ ? { name: error.name, message: error.message, stack: error.stack }
445
+ : String(error),
446
+ }),
447
+ }
448
+ : {}),
613
449
  errorCategory,
614
450
  }, runtime, telemetryProject);
615
451
  const command = commandTelemetryDescriptor(toolName);
@@ -648,14 +484,14 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
648
484
  ];
649
485
  const page = paginate(resources, params.cursor);
650
486
  mcpSuccess = true;
651
- return json({
487
+ return trackedJson({
652
488
  jsonrpc: "2.0",
653
489
  id: message.id,
654
490
  result: {
655
491
  resultType: "complete",
656
492
  resources: page.items,
657
493
  ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
658
- ttlMs: 0,
494
+ ttlMs: DISCOVERY_CACHE_TTL_MS,
659
495
  cacheScope: "private",
660
496
  _meta: SERVER_INFO_META,
661
497
  },
@@ -666,7 +502,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
666
502
  return jsonRpcError(message.id ?? null, -32602, "resources/read uri must be a non-empty string.", 400);
667
503
  if (params.uri === INSTRUCTIONS_URI) {
668
504
  mcpSuccess = true;
669
- return json({
505
+ return trackedJson({
670
506
  jsonrpc: "2.0",
671
507
  id: message.id,
672
508
  result: {
@@ -675,7 +511,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
675
511
  {
676
512
  uri: INSTRUCTIONS_URI,
677
513
  mimeType: "text/markdown",
678
- text: buildAgentInstructions(npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, await codeGraph.capability()),
514
+ text: buildAgentInstructions(npmAllowed, allowedCommands, blacklistedCommands, await codeGraph.capability()),
679
515
  },
680
516
  ],
681
517
  ttlMs: 0,
@@ -686,7 +522,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
686
522
  }
687
523
  if (params.uri === CAPABILITIES_URI) {
688
524
  mcpSuccess = true;
689
- return json({
525
+ return trackedJson({
690
526
  jsonrpc: "2.0",
691
527
  id: message.id,
692
528
  result: {
@@ -695,7 +531,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
695
531
  {
696
532
  uri: CAPABILITIES_URI,
697
533
  mimeType: "application/json",
698
- text: JSON.stringify(buildAgentCapabilities(npmAllowed, allowedCommands, blacklistedCommands, gitEnabled), null, 2),
534
+ text: JSON.stringify(buildAgentCapabilities(npmAllowed, allowedCommands, blacklistedCommands, await codeGraph.capability()), null, 2),
699
535
  },
700
536
  ],
701
537
  ttlMs: 0,
@@ -706,7 +542,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
706
542
  }
707
543
  const value = await readSkillResource(root, params.uri);
708
544
  mcpSuccess = true;
709
- return json({
545
+ return trackedJson({
710
546
  jsonrpc: "2.0",
711
547
  id: message.id,
712
548
  result: {
@@ -724,7 +560,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
724
560
  const resources = await readSkillDirectory(root, params.uri);
725
561
  const page = paginate(resources, params.cursor);
726
562
  mcpSuccess = true;
727
- return json({
563
+ return trackedJson({
728
564
  jsonrpc: "2.0",
729
565
  id: message.id,
730
566
  result: {
@@ -740,7 +576,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
740
576
  case "skills/list": {
741
577
  const page = paginate(await listSkills(root), params.cursor);
742
578
  mcpSuccess = true;
743
- return json({
579
+ return trackedJson({
744
580
  jsonrpc: "2.0",
745
581
  id: message.id,
746
582
  result: {
@@ -762,7 +598,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
762
598
  if (typeof params.uri !== "string" || params.uri.length === 0)
763
599
  return jsonRpcError(message.id ?? null, -32602, "skills/get uri must be a non-empty string.", 400);
764
600
  mcpSuccess = true;
765
- return json({
601
+ return trackedJson({
766
602
  jsonrpc: "2.0",
767
603
  id: message.id,
768
604
  result: {
@@ -781,7 +617,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
781
617
  catch (error) {
782
618
  telemetryRequestErrorCategory = classifyTelemetryError(error);
783
619
  if (message.method === "tools/call")
784
- return json({
620
+ return trackedJson({
785
621
  jsonrpc: "2.0",
786
622
  id: message.id,
787
623
  result: {
@@ -845,16 +681,23 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
845
681
  };
846
682
  return handler;
847
683
  }
848
- async function availableTools(codeGraph, gitEnabled) {
684
+ async function availableTools(codeGraph) {
849
685
  const capability = await codeGraph.capability();
850
- const tools = capability.status === "available" ||
686
+ return capability.status === "available" ||
851
687
  capability.status === "startup_failed" ||
852
688
  capability.status === "runtime_failed"
853
689
  ? [...baseTools, codeGraphTool]
854
690
  : baseTools;
855
- return gitEnabled ? tools : tools.filter((item) => !item.name.startsWith("git_"));
856
691
  }
857
- function buildAgentInstructions(npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, codeGraph) {
692
+ function buildDiscoveryInstructions(npmAllowed, allowedCommands, codeGraph) {
693
+ const scripts = npmAllowed.length ? npmAllowed.join(", ") : "none";
694
+ const commands = allowedCommands.length ? allowedCommands.join(", ") : "none";
695
+ const graph = codeGraph.status === "available"
696
+ ? " For non-trivial code understanding, call codegraph_explore first for behavior, call/data flow, symbol relationships, or change impact; avoid duplicating results with grep/read loops."
697
+ : "";
698
+ return `Use Agent Dir as the primary project interface. Minimize calls and output. Start with project_context; use find_files/search_code/find_symbol/find_references for targeted discovery, read_range for bounded context, and apply_changes for atomic edits. Use validate for diagnostics and allowed npm scripts; use run_command_batch for allowlisted commands, including Git when allowed.${graph} Batch related operations. Avoid recursive listings, whole-file reads, full diffs, and repeated discovery. Allowed npm scripts: ${scripts}. Allowed commands: ${commands}.`;
699
+ }
700
+ function buildAgentInstructions(npmAllowed, allowedCommands, blacklistedCommands, codeGraph) {
858
701
  const npm = npmAllowed.length ? npmAllowed.map((item) => `- ${item}`).join("\n") : "- none";
859
702
  const commands = allowedCommands.length
860
703
  ? allowedCommands.map((item) => `- ${item}`).join("\n")
@@ -865,44 +708,46 @@ function buildAgentInstructions(npmAllowed, allowedCommands, blacklistedCommands
865
708
  const codeGraphSection = codeGraph.status === "available" ||
866
709
  codeGraph.status === "startup_failed" ||
867
710
  codeGraph.status === "runtime_failed"
868
- ? "\n\n## CodeGraph\n- CodeGraph is an optional code-intelligence backend for this project.\n- Prefer `codegraph_explore` for structural, symbol, and code-flow questions when available.\n- Agent Dir pins CodeGraph to this project root; do not attempt to select another project.\n- Current status: " +
711
+ ? "\n\n## CodeGraph\n- CodeGraph is an optional code-intelligence backend for this project.\n- REQUIRED FIRST STEP for non-trivial code understanding: call `codegraph_explore` before grep/search/read loops to explain behavior, trace call/data flow, map symbol relationships, or assess change impact. - Treat returned source as already read; do not re-run searches without a concrete gap.\n- Agent Dir pins CodeGraph to this project root; do not attempt to select another project.\n- Current status: " +
869
712
  codeGraph.status +
870
713
  (codeGraph.detail ? ` — ${codeGraph.detail}` : "") +
871
714
  "."
872
715
  : "";
873
- return `# Agent Dir operating instructions\n\nUse Agent Dir as the primary project interface. Optimize for correctness with the minimum necessary tool calls, filesystem reads, command output, and context.\n\n## Efficiency rules\n- Start with the narrowest operation that can answer the question. Do not dump the repository, large files, or full command output when a targeted operation is sufficient.\n- Prefer project_context for initial orientation; it combines project structure, package metadata, and execution policy in one call. Use project_overview when package metadata is unnecessary.\n- Prefer find_files for known filename patterns and list_dirs for one directory. Use list_files only when recursive discovery is genuinely required.\n- Prefer search_code/search_files before reading files. Search first, then read only the relevant ranges. Use read_relevant when one call can locate and return the needed source context.\n- Prefer find_symbol/find_definition/find_references for known symbols instead of scanning source files manually.\n- Prefer read_range for a bounded section. Use read_files to batch several already-identified files.\n- Prefer patch_files for targeted edits. Do not rewrite an entire file when a small exact replacement is sufficient.\n- Batch related reads, writes, patches, searches, and commands into one tool call when practical.\n- Keep maxResults and Git log limits small unless the initial result is insufficient.\n- Prefer dedicated Agent Dir tools over run_command_batch because dedicated tools return structured, bounded results.\n- Prefer diagnostics before expensive tests, linters, or typechecks when checking basic structural issues.\n- Run the narrowest relevant validation after a change; escalate only when required by the task or release workflow.\n- Before a broad Git diff, use git_status; then inspect only the relevant path or staged diff.\n- Never repeat a successful discovery/read just because another tool can provide the same information.\n\n## Preferred workflow\n1. Orient: project_overview.\n2. Locate: find_files/search_code/find_symbol/find_definition as appropriate.\n3. Read: read_range or batched read_files.\n4. Modify: patch_files for targeted changes; write_files for new/complete files.\n5. Validate: diagnostics first when applicable, then the narrowest relevant npm script.\n6. Review: git_status, then scoped git_diff when needed.\n\n## Execution policy\nAllowed npm scripts:\n${npm}\n\nAllowed executables:\n${commands}\n\nBlacklisted command prefixes:\n${blacklist}\n\nOnly use commands from the execution policy. A blacklisted command prefix overrides an allowed executable. Do not attempt to bypass it with shells or alternate executables.\n\n## Git capability\nDedicated Git MCP tools are ${gitEnabled ? "enabled" : "disabled"} for this profile.\n\n## Tool selection\n- Discovery: project_context > locate > project_overview > find_files/list_dirs > list_files.\n- Code location: inspect_symbol > find_definition/find_symbol > locate > search_code > broad file reads.\n- File reading: read_relevant > read_range > targeted read_files > broad recursive reads.\n- Editing: patch_files > write_files for complete files.\n- Validation: diagnostics > targeted npm script > full check/test.\n- Git inspection: git_changes > git_status > scoped git_diff > full repository diff.\n\nThe execution policy and this guidance are generated from the running Agent Dir configuration; do not maintain a separate client-specific copy.${codeGraphSection}`;
716
+ return `# Agent Dir operating instructions\n\nUse Agent Dir as the primary project interface. Optimize for correctness with the minimum necessary tool calls, filesystem reads, command output, and context.\n\n## Efficiency rules\n- Start with the narrowest operation that can answer the question. Do not dump the repository, large files, or full command output when a targeted operation is sufficient.\n- Prefer project_context for initial orientation; it combines project structure, package metadata, and execution policy in one call. \n- Prefer find_files for known filename patterns\n- Prefer search_code before reading files. Search first, then read only the relevant ranges.\n- Prefer find_symbol/find_references for known symbols instead of scanning source files manually.\n- Prefer read_range for a bounded section.\n- Prefer apply_changes for atomic writes, patches, and deletions. Do not rewrite an entire file when a small exact replacement is sufficient.\n- Batch related reads, writes, patches, searches, and commands into one tool call when practical.\n- Keep maxResults small unless the initial result is insufficient.\n- Prefer dedicated Agent Dir tools over run_command_batch because dedicated tools return structured, bounded results.\n- Prefer validate for lightweight diagnostics and explicitly requested npm scripts.\n- Run the narrowest relevant validation after a change; escalate only when required by the task or release workflow.\n- Never repeat a successful discovery/read just because another tool can provide the same information.\n\n## Preferred workflow\n1. Orient: project_context.\n2. Locate: find_files/search_code/find_symbol as appropriate.\n3. Read: read_range.\n4. Modify: apply_changes for atomic writes, patches, and deletes.\n5. Validate: use validate for diagnostics or explicitly allowed npm scripts for executable checks.\n6. Review: use allowlisted Git commands through run_command_batch when \`git\` is enabled.\n\n## Execution policy\nAllowed npm scripts:\n${npm}\n\nAllowed executables:\n${commands}\n\nBlacklisted command prefixes:\n${blacklist}\n\nOnly use commands from the execution policy. A blacklisted command prefix overrides an allowed executable. Do not attempt to bypass it with shells or alternate executables.\n\n## Git operations\nUse \`run_command_batch\` for Git commands only when \`git\` is in the allowed executable list. The command allowlist and blacklist still apply; avoid destructive operations unless explicitly requested.\n\n## Tool selection\n- Discovery: project_context > find_files > search_code.\n- Code location: find_symbol > find_references > search_code.\n- File reading: read_range.\n- Editing: apply_changes for atomic multi-file edits.\n- Validation: validate > targeted npm script > full check/test.\n- Git operations: run_command_batch with the allowed \`git\` executable, respecting the configured blacklist.\n\nThe execution policy and this guidance are generated from the running Agent Dir configuration; do not maintain a separate client-specific copy.${codeGraphSection}`;
874
717
  }
875
- function buildAgentCapabilities(npmAllowed, allowedCommands, blacklistedCommands, gitEnabled) {
718
+ function buildAgentCapabilities(npmAllowed, allowedCommands, blacklistedCommands, codeGraph) {
876
719
  return {
877
720
  version: packageVersion,
878
- tools: baseTools
879
- .filter((item) => gitEnabled || !item.name.startsWith("git_"))
880
- .map((item) => item.name),
721
+ tools: [
722
+ ...baseTools.map((item) => item.name),
723
+ ...(codeGraph.status === "available" ? ["codegraph_explore"] : []),
724
+ ],
881
725
  resources: [INSTRUCTIONS_URI, CAPABILITIES_URI],
882
726
  execution: {
883
727
  npmScripts: [...npmAllowed],
884
728
  commands: [...allowedCommands],
885
729
  blacklistedCommands: [...blacklistedCommands],
886
- git: gitEnabled,
887
730
  },
888
731
  efficiency: {
889
732
  preferred: {
890
733
  orientation: "project_context",
891
- focusedReading: "read_relevant",
892
- changeReview: "git_changes",
893
- discovery: ["find_files", "list_dirs", "search_code"],
894
- symbolNavigation: ["find_definition", "find_symbol", "find_references"],
895
- reading: ["read_range", "read_files"],
896
- editing: ["apply_changes", "patch_files", "write_files"],
897
- validation: ["validate", "diagnostics", "targeted npm script"],
898
- gitInspection: ["git_changes", "git_status", "git_diff", "git_log"],
734
+ ...(codeGraph.status === "available" ? { structuralAnalysis: "codegraph_explore" } : {}),
735
+ focusedReading: "read_range",
736
+ discovery: ["find_files", "search_code"],
737
+ symbolNavigation: ["find_symbol", "find_references"],
738
+ reading: ["read_range"],
739
+ editing: ["apply_changes"],
740
+ validation: ["validate", "targeted npm script"],
899
741
  },
900
742
  avoid: [
901
743
  "recursive discovery when targeted search is enough",
902
744
  "whole-file reads when a line range is enough",
903
745
  "full-repository diffs when a path-scoped diff is enough",
904
746
  "expensive validation when a targeted check answers the question",
905
- "generic command execution when a dedicated Agent Dir tool exists",
747
+ "generic command execution when a dedicated Agent Dir tool exists, except allowlisted Git operations",
748
+ ...(codeGraph.status === "available"
749
+ ? ["repeated grep/read loops for structural questions already answered by CodeGraph"]
750
+ : []),
906
751
  ],
907
752
  },
908
753
  };
@@ -914,7 +759,7 @@ function capabilities() {
914
759
  extensions: { "io.modelcontextprotocol/skills": { directoryRead: true } },
915
760
  };
916
761
  }
917
- async function callTool(name, args, root, npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, notify, signal, codeGraph) {
762
+ async function callTool(name, args, root, npmAllowed, allowedCommands, blacklistedCommands, notify, signal, codeGraph) {
918
763
  let structuredContent;
919
764
  if (name === "codegraph_explore") {
920
765
  if (!codeGraph)
@@ -924,41 +769,28 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, blacklist
924
769
  if (args.maxFiles !== undefined &&
925
770
  (!Number.isInteger(args.maxFiles) || Number(args.maxFiles) < 1 || Number(args.maxFiles) > 100))
926
771
  throw new Error("maxFiles must be an integer between 1 and 100.");
927
- return await codeGraph.explore(args.query, args.maxFiles === undefined ? undefined : Number(args.maxFiles));
928
- }
929
- if (name.startsWith("git_") && !gitEnabled) {
930
- throw new Error("Dedicated Git tools are disabled for this profile.");
772
+ const maxBytes = outputBytesArg(args.maxBytes);
773
+ const result = await codeGraph.explore(args.query, args.maxFiles === undefined ? 10 : Number(args.maxFiles));
774
+ const serialized = JSON.stringify(result);
775
+ if (Buffer.byteLength(serialized, "utf8") <= maxBytes)
776
+ return result;
777
+ return {
778
+ truncated: true,
779
+ originalBytes: Buffer.byteLength(serialized, "utf8"),
780
+ message: "CodeGraph response exceeded the output budget. Narrow the query or lower maxFiles.",
781
+ preview: truncateText(serialized, Math.min(2_000, maxBytes - 256)).text,
782
+ };
931
783
  }
932
784
  switch (name) {
933
- case "list_files": {
934
- const items = await listFiles(root);
935
- structuredContent = boundedItems(items, outputBytesArg(args.maxBytes)).items;
936
- break;
937
- }
938
- case "list_dirs": {
939
- const items = await listDirs(root, stringArray(args.paths, "paths"));
940
- structuredContent = boundedItems(items, outputBytesArg(args.maxBytes)).items;
941
- break;
942
- }
943
785
  case "read_range": {
944
- const result = await readRange(root, String(args.path), Number(args.startLine), Number(args.endLine));
945
- const bounded = truncateText(result.content, outputBytesArg(args.maxBytes));
946
- structuredContent = { ...result, content: bounded.text };
947
- break;
948
- }
949
- case "read_files": {
950
786
  const maxBytes = outputBytesArg(args.maxBytes);
951
- let remaining = maxBytes;
952
- const output = [];
953
- for (const p of stringArray(args.paths, "paths")) {
954
- const content = await readFile(root, p);
955
- const bounded = truncateText(content, remaining);
956
- output.push({ path: p, content: bounded.text });
957
- remaining -= Buffer.byteLength(bounded.text, "utf8");
958
- if (bounded.truncated || remaining <= 0)
959
- break;
960
- }
961
- structuredContent = output;
787
+ const result = await readRange(root, String(args.path), Number(args.startLine), Number(args.endLine), Math.max(1, maxBytes - 256));
788
+ const bounded = truncateText(result.content, Math.max(1, maxBytes - 256));
789
+ structuredContent = {
790
+ ...result,
791
+ content: bounded.text,
792
+ truncated: result.truncated || bounded.truncated,
793
+ };
962
794
  break;
963
795
  }
964
796
  case "apply_changes": {
@@ -990,247 +822,39 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, blacklist
990
822
  const before = new Map();
991
823
  for (const p of affected)
992
824
  before.set(p, await skillResourceUrisForPath(root, p));
993
- structuredContent = await applyFileChanges(root, changes, dryRun);
994
- if (!dryRun)
825
+ const applied = await applyFileChanges(root, changes, dryRun);
826
+ structuredContent = applied.map((item) => {
827
+ if (!isRecord(item))
828
+ return item;
829
+ const { content: _content, previousContent: _previousContent, ...summary } = item;
830
+ return summary;
831
+ });
832
+ if (!dryRun) {
833
+ invalidateSkillDiscoveryCache(root);
995
834
  for (const p of affected)
996
835
  await notifyResourceChange(notify, root, p, before.get(p) ?? []);
997
- break;
998
- }
999
- case "write_files": {
1000
- const dryRun = args.dryRun === true;
1001
- const output = [];
1002
- for (const file of objectArray(args.files, "files")) {
1003
- const p = String(file.path);
1004
- const before = await skillResourceUrisForPath(root, p);
1005
- if (!dryRun) {
1006
- await writeFile(root, p, String(file.content));
1007
- await notifyResourceChange(notify, root, p, before);
1008
- }
1009
- output.push({ path: p, written: true, ...(dryRun ? { dryRun: true } : {}) });
1010
836
  }
1011
- structuredContent = output;
1012
837
  break;
1013
838
  }
1014
- case "patch_files": {
1015
- const dryRun = args.dryRun === true;
1016
- const files = objectArray(args.files, "files").map((file) => ({
1017
- path: String(file.path),
1018
- patches: objectArray(file.patches, "patches").map((p) => ({
1019
- search: String(p.search),
1020
- replace: String(p.replace),
1021
- ...(p.count === undefined ? {} : { count: Number(p.count) }),
1022
- })),
1023
- }));
1024
- const before = new Map();
1025
- for (const file of files)
1026
- before.set(file.path, await skillResourceUrisForPath(root, file.path));
1027
- if (dryRun) {
1028
- structuredContent = await patchFilesDryRun(root, files);
1029
- }
1030
- else {
1031
- structuredContent = await patchFiles(root, files);
1032
- }
1033
- const maxBytes = outputBytesArg(args.maxBytes);
1034
- if (Array.isArray(structuredContent)) {
1035
- structuredContent = boundedItems(structuredContent.map((item) => {
1036
- if (!isRecord(item) || typeof item.content !== "string")
1037
- return item;
1038
- return { ...item, content: truncateText(item.content, maxBytes).text };
1039
- }), maxBytes).items;
1040
- }
1041
- if (!dryRun) {
1042
- for (const file of files)
1043
- await notifyResourceChange(notify, root, file.path, before.get(file.path) ?? []);
1044
- }
1045
- break;
1046
- }
1047
- case "delete_files": {
1048
- const dryRun = args.dryRun === true;
1049
- const output = [];
1050
- for (const p of stringArray(args.paths, "paths")) {
1051
- const before = await skillResourceUrisForPath(root, p);
1052
- if (!dryRun) {
1053
- await deleteFile(root, p);
1054
- await notifyResourceChange(notify, root, p, before);
1055
- }
1056
- output.push({ path: p, deleted: true, ...(dryRun ? { dryRun: true } : {}) });
1057
- }
1058
- structuredContent = output;
839
+ case "find_files": {
840
+ const maxResults = numberArg(args.maxResults, 200, 1000);
841
+ const results = await findFiles(root, String(args.pattern), maxResults);
842
+ const bounded = boundedItems(results, outputBytesArg(args.maxBytes));
843
+ structuredContent = {
844
+ items: bounded.items,
845
+ truncated: bounded.truncated || results.length >= maxResults,
846
+ };
1059
847
  break;
1060
848
  }
1061
- case "search_files":
1062
- structuredContent = await searchFiles(root, String(args.query), Boolean(args.regex), numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
1063
- break;
1064
- case "find_files":
1065
- structuredContent = boundedItems(await findFiles(root, String(args.pattern), numberArg(args.maxResults, 200, 1000)), outputBytesArg(args.maxBytes)).items;
1066
- break;
1067
849
  case "search_code":
1068
850
  structuredContent = await searchCode(root, String(args.query), Boolean(args.regex), numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
1069
851
  break;
1070
852
  case "find_symbol":
1071
853
  structuredContent = await findSymbol(root, String(args.symbol), numberArg(args.maxResults, 100, 500), outputBytesArg(args.maxBytes));
1072
854
  break;
1073
- case "find_definition":
1074
- structuredContent = await findDefinition(root, String(args.symbol), numberArg(args.maxResults, 100, 500), outputBytesArg(args.maxBytes));
1075
- break;
1076
855
  case "find_references":
1077
856
  structuredContent = await findReferences(root, String(args.symbol), numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
1078
857
  break;
1079
- case "find_imports":
1080
- structuredContent = await findImports(root, numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
1081
- break;
1082
- case "find_exports":
1083
- structuredContent = await findExports(root, numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
1084
- break;
1085
- case "git_status":
1086
- structuredContent = boundCommandResult(await gitStatus(root), outputBytesArg(args.maxBytes));
1087
- break;
1088
- case "git_stage":
1089
- structuredContent = boundCommandResult(await gitStage(root, stringArray(args.paths, "paths")), outputBytesArg(args.maxBytes));
1090
- break;
1091
- case "git_unstage":
1092
- structuredContent = boundCommandResult(await gitUnstage(root, stringArray(args.paths, "paths")), outputBytesArg(args.maxBytes));
1093
- break;
1094
- case "git_commit":
1095
- structuredContent = boundCommandResult(await gitCommit(root, String(args.message)), outputBytesArg(args.maxBytes));
1096
- break;
1097
- case "git_restore":
1098
- structuredContent = boundCommandResult(await gitRestore(root, stringArray(args.paths, "paths")), outputBytesArg(args.maxBytes));
1099
- break;
1100
- case "git_push":
1101
- structuredContent = boundCommandResult(await gitPush(root, args.remote === undefined ? undefined : String(args.remote), args.branch === undefined ? undefined : String(args.branch)), outputBytesArg(args.maxBytes));
1102
- break;
1103
- case "git_diff":
1104
- structuredContent = boundCommandResult(await gitDiff(root, Boolean(args.staged), args.path === undefined ? undefined : String(args.path)), outputBytesArg(args.maxBytes));
1105
- break;
1106
- case "git_log":
1107
- structuredContent = boundCommandResult(await gitLog(root, numberArg(args.limit, 20, 200), args.path === undefined ? undefined : String(args.path)), outputBytesArg(args.maxBytes));
1108
- break;
1109
- case "locate": {
1110
- const query = String(args.query);
1111
- const kind = typeof args.kind === "string" ? args.kind : "auto";
1112
- const regex = args.regex === true;
1113
- const maxResults = numberArg(args.maxResults, 20, 100);
1114
- const resolvedKind = kind === "auto"
1115
- ? /[*?]/.test(query)
1116
- ? "file"
1117
- : /\.(ts|tsx|js|jsx|py|go|rs|java|json|md|yml|yaml|css|html)$/.test(query)
1118
- ? "file"
1119
- : "code"
1120
- : kind;
1121
- const items = resolvedKind === "file"
1122
- ? await findFiles(root, query, maxResults)
1123
- : resolvedKind === "text"
1124
- ? await searchFiles(root, query, regex, maxResults)
1125
- : await searchCode(root, query, regex, maxResults);
1126
- const bounded = boundedItems(items, outputBytesArg(args.maxBytes));
1127
- structuredContent = {
1128
- query,
1129
- kind: resolvedKind,
1130
- items: bounded.items,
1131
- count: bounded.items.length,
1132
- truncated: bounded.truncated || items.length >= maxResults,
1133
- };
1134
- break;
1135
- }
1136
- case "inspect_symbol": {
1137
- const symbol = String(args.symbol);
1138
- const contextLines = args.contextLines === undefined ? 3 : Number(args.contextLines);
1139
- if (!Number.isInteger(contextLines) || contextLines < 0 || contextLines > 20)
1140
- throw new Error("contextLines must be an integer between 0 and 20.");
1141
- const maxResults = numberArg(args.maxResults, 5, 20);
1142
- const maxBytes = outputBytesArg(args.maxBytes);
1143
- const definitions = await findDefinition(root, symbol, maxResults, maxBytes);
1144
- const definitionContext = await Promise.all(definitions.map(async (definition) => {
1145
- const range = await readRange(root, definition.path, Math.max(1, definition.line - contextLines), definition.line + contextLines);
1146
- return { ...definition, context: range.content };
1147
- }));
1148
- const references = args.includeReferences === true
1149
- ? await findReferences(root, symbol, maxResults, maxBytes)
1150
- : undefined;
1151
- const result = {
1152
- symbol,
1153
- definitions: definitionContext,
1154
- ...(references ? { references } : {}),
1155
- };
1156
- const bounded = truncateText(JSON.stringify(result), maxBytes);
1157
- structuredContent = bounded.truncated
1158
- ? { symbol, result: bounded.text, truncated: true }
1159
- : result;
1160
- break;
1161
- }
1162
- case "inspect": {
1163
- const target = String(args.target);
1164
- const requestedKind = typeof args.kind === "string" ? args.kind : "auto";
1165
- const contextLines = args.contextLines === undefined ? 3 : Number(args.contextLines);
1166
- if (!Number.isInteger(contextLines) || contextLines < 0 || contextLines > 20)
1167
- throw new Error("contextLines must be an integer between 0 and 20.");
1168
- const maxResults = numberArg(args.maxResults, 5, 20);
1169
- const maxBytes = outputBytesArg(args.maxBytes);
1170
- let kind = requestedKind;
1171
- if (kind === "auto") {
1172
- try {
1173
- await readFile(root, target);
1174
- kind = "file";
1175
- }
1176
- catch {
1177
- kind = /[*?]/.test(target) ? "code" : "symbol";
1178
- }
1179
- }
1180
- if (kind === "file") {
1181
- const content = await readFile(root, target);
1182
- const boundedContent = truncateText(content, Math.max(1, maxBytes - 128));
1183
- const result = {
1184
- target,
1185
- kind,
1186
- path: target,
1187
- content: boundedContent.text,
1188
- truncated: boundedContent.truncated,
1189
- };
1190
- structuredContent = result;
1191
- break;
1192
- }
1193
- if (kind === "symbol") {
1194
- const definitions = await findDefinition(root, target, maxResults, maxBytes);
1195
- const definitionsWithContext = await Promise.all(definitions.map(async (definition) => {
1196
- const range = await readRange(root, definition.path, Math.max(1, definition.line - contextLines), definition.line + contextLines);
1197
- return { ...definition, context: range.content };
1198
- }));
1199
- const references = args.includeReferences === true
1200
- ? await findReferences(root, target, maxResults, maxBytes)
1201
- : undefined;
1202
- const result = {
1203
- target,
1204
- kind,
1205
- definitions: definitionsWithContext,
1206
- ...(references ? { references } : {}),
1207
- };
1208
- const bounded = truncateText(JSON.stringify(result), maxBytes);
1209
- structuredContent = bounded.truncated
1210
- ? { target, kind, result: bounded.text, truncated: true }
1211
- : result;
1212
- break;
1213
- }
1214
- const matches = await searchCode(root, target, false, maxResults, maxBytes);
1215
- const relevant = await Promise.all(matches.map(async (match) => {
1216
- const range = await readRange(root, match.path, Math.max(1, match.line - contextLines), match.line + contextLines);
1217
- return {
1218
- ...match,
1219
- context: range.content,
1220
- startLine: range.startLine,
1221
- endLine: range.endLine,
1222
- };
1223
- }));
1224
- const bounded = boundedItems(relevant, maxBytes);
1225
- structuredContent = {
1226
- target,
1227
- kind: "code",
1228
- matches: bounded.items,
1229
- count: bounded.items.length,
1230
- truncated: bounded.truncated || relevant.length >= maxResults,
1231
- };
1232
- break;
1233
- }
1234
858
  case "project_context": {
1235
859
  const overview = await projectOverview(root);
1236
860
  let pkg = { path: "package.json", exists: false };
@@ -1253,39 +877,12 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, blacklist
1253
877
  ...overview,
1254
878
  package: pkg,
1255
879
  execution: { npmScripts: [...npmAllowed], commands: [...allowedCommands] },
1256
- }), maxBytes);
880
+ }), Math.max(1, maxBytes - 128));
1257
881
  structuredContent = bounded.truncated
1258
882
  ? { result: bounded.text, truncated: true }
1259
883
  : JSON.parse(bounded.text);
1260
884
  break;
1261
885
  }
1262
- case "read_relevant": {
1263
- const query = String(args.query);
1264
- const contextLines = args.contextLines === undefined ? 3 : Number(args.contextLines);
1265
- if (!Number.isInteger(contextLines) || contextLines < 0 || contextLines > 20)
1266
- throw new Error("contextLines must be an integer between 0 and 20.");
1267
- const maxResults = numberArg(args.maxResults, 5, 20);
1268
- const matches = await searchCode(root, query, Boolean(args.regex), maxResults, outputBytesArg(args.maxBytes));
1269
- const relevant = await Promise.all(matches.map(async (match) => {
1270
- const range = await readRange(root, match.path, Math.max(1, match.line - contextLines), match.line + contextLines);
1271
- return {
1272
- ...match,
1273
- context: range.content,
1274
- startLine: range.startLine,
1275
- endLine: range.endLine,
1276
- };
1277
- }));
1278
- const maxBytes = outputBytesArg(args.maxBytes);
1279
- const bounded = boundedItems(relevant, Math.max(1, maxBytes - 128));
1280
- const result = {
1281
- query,
1282
- matches: bounded.items,
1283
- count: relevant.length,
1284
- truncated: bounded.truncated || relevant.length >= maxResults,
1285
- };
1286
- structuredContent = result;
1287
- break;
1288
- }
1289
886
  case "validate": {
1290
887
  const level = typeof args.level === "string" ? args.level : "diagnostics";
1291
888
  const maxResults = numberArg(args.maxResults, 200, 1000);
@@ -1298,6 +895,8 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, blacklist
1298
895
  : level === "full"
1299
896
  ? ["check", "test"].filter((script) => npmAllowed.includes(script))
1300
897
  : [];
898
+ if (scripts.length > 10)
899
+ throw new Error("validate accepts at most 10 scripts per call.");
1301
900
  const checks = [
1302
901
  {
1303
902
  kind: "diagnostics",
@@ -1317,60 +916,55 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, blacklist
1317
916
  if (result.exitCode !== 0)
1318
917
  break;
1319
918
  }
1320
- structuredContent = {
919
+ const validation = {
1321
920
  level,
1322
921
  ok: checks.every((check) => check.ok === true),
1323
922
  checks,
1324
923
  scriptsRequested: scripts,
1325
924
  };
925
+ const serialized = JSON.stringify(validation);
926
+ if (Buffer.byteLength(serialized, "utf8") <= maxBytes) {
927
+ structuredContent = validation;
928
+ }
929
+ else {
930
+ const perCheckBudget = Math.max(256, Math.floor((maxBytes - 256) / checks.length));
931
+ const compactChecks = checks.map((check) => {
932
+ if (check.kind === "npm" && isRecord(check.result))
933
+ return { ...check, result: boundCommandResult(check.result, perCheckBudget) };
934
+ if (check.kind === "diagnostics" && Array.isArray(check.issues))
935
+ return { ...check, issues: boundedItems(check.issues, perCheckBudget).items };
936
+ return check;
937
+ });
938
+ const compact = { ...validation, checks: compactChecks, truncated: true };
939
+ const compactJson = JSON.stringify(compact);
940
+ if (Buffer.byteLength(compactJson, "utf8") <= maxBytes) {
941
+ structuredContent = compact;
942
+ }
943
+ else {
944
+ structuredContent = {
945
+ level,
946
+ ok: validation.ok,
947
+ truncated: true,
948
+ checks: checks.map((check) => ({
949
+ kind: check.kind,
950
+ ...(typeof check.script === "string" ? { script: check.script } : {}),
951
+ ok: check.ok,
952
+ ...(typeof check.count === "number" ? { count: check.count } : {}),
953
+ })),
954
+ };
955
+ }
956
+ }
1326
957
  break;
1327
958
  }
1328
- case "git_changes": {
1329
- const status = await gitStatus(root);
1330
- const includeDiff = Boolean(args.includeDiff);
1331
- const path = args.path === undefined ? undefined : String(args.path);
1332
- const maxBytes = outputBytesArg(args.maxBytes);
1333
- const diff = includeDiff ? await gitDiff(root, false, path) : undefined;
1334
- const statusBytes = Buffer.byteLength(JSON.stringify(status), "utf8");
1335
- const diffBudget = Math.max(0, maxBytes - statusBytes - 64);
1336
- const boundedDiff = diff ? truncateText(diff.stdout, diffBudget) : undefined;
1337
- const result = {
1338
- status,
1339
- ...(diff
1340
- ? {
1341
- diff: {
1342
- ...diff,
1343
- stdout: boundedDiff?.text ?? "",
1344
- truncated: boundedDiff?.truncated ?? false,
1345
- },
1346
- }
1347
- : {}),
1348
- };
1349
- structuredContent = result;
1350
- break;
1351
- }
1352
- case "project_overview":
1353
- structuredContent = await projectOverview(root);
1354
- break;
1355
- case "package_info":
1356
- structuredContent = await packageInfo(root);
1357
- break;
1358
- case "allowed_commands":
1359
- structuredContent = {
1360
- npmScripts: [...npmAllowed],
1361
- commands: [...allowedCommands],
1362
- blacklistedCommands: [...blacklistedCommands],
1363
- };
1364
- break;
1365
959
  case "file_info":
1366
960
  structuredContent = await fileInfo(root, String(args.path));
1367
961
  break;
1368
- case "diagnostics":
1369
- structuredContent = boundedItems(await diagnostics(root, numberArg(args.maxResults, 200, 1000)), outputBytesArg(args.maxBytes)).items;
1370
- break;
1371
962
  case "run_npm_batch": {
1372
963
  const output = [];
1373
- for (const script of stringArray(args.scripts, "scripts")) {
964
+ const scripts = stringArray(args.scripts, "scripts");
965
+ if (scripts.length > 10)
966
+ throw new Error("run_npm_batch accepts at most 10 scripts per call.");
967
+ for (const script of scripts) {
1374
968
  const value = await runNpm(root, script, 120000, npmAllowed, signal);
1375
969
  output.push(value);
1376
970
  if (value.exitCode !== 0)
@@ -1398,7 +992,10 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, blacklist
1398
992
  }
1399
993
  case "run_command_batch": {
1400
994
  const output = [];
1401
- for (const item of objectArray(args.commands, "commands")) {
995
+ const commands = objectArray(args.commands, "commands");
996
+ if (commands.length > 10)
997
+ throw new Error("run_command_batch accepts at most 10 commands per call.");
998
+ for (const item of commands) {
1402
999
  const value = await runCommand(root, String(item.command), Array.isArray(item.args) ? item.args.map(String) : [], allowedCommands, blacklistedCommands, 120000, signal);
1403
1000
  output.push(value);
1404
1001
  if (value.exitCode !== 0)
@@ -1554,6 +1151,8 @@ function telemetryRuntime(params) {
1554
1151
  ...(typeof clientInfo?.name === "string" ? { mcpClientName: clientInfo.name } : {}),
1555
1152
  ...(typeof clientInfo?.version === "string" ? { mcpClientVersion: clientInfo.version } : {}),
1556
1153
  nodeVersion: process.versions.node,
1154
+ ...linuxDistribution(),
1155
+ kernelVersion: release(),
1557
1156
  hostname: hostname(),
1558
1157
  username: userInfo().username,
1559
1158
  processId: process.pid,
@@ -1562,37 +1161,106 @@ function telemetryRuntime(params) {
1562
1161
  memoryRssBytes: process.memoryUsage().rss,
1563
1162
  };
1564
1163
  }
1565
- function toolTelemetryMetrics(toolName, result) {
1164
+ function linuxDistribution() {
1165
+ if (process.platform !== "linux")
1166
+ return {};
1167
+ try {
1168
+ const values = Object.fromEntries(readFileSync("/etc/os-release", "utf8")
1169
+ .split("\n")
1170
+ .map((line) => line.match(/^([A-Z_]+)=(.*)$/))
1171
+ .filter((match) => match !== null)
1172
+ .map((match) => [match[1], match[2]?.replace(/^"|"$/g, "")]));
1173
+ return {
1174
+ ...(values.ID ? { distroId: values.ID } : {}),
1175
+ ...(values.PRETTY_NAME ? { distroName: values.PRETTY_NAME } : {}),
1176
+ ...(values.VERSION_ID ? { distroVersion: values.VERSION_ID } : {}),
1177
+ };
1178
+ }
1179
+ catch {
1180
+ return {};
1181
+ }
1182
+ }
1183
+ function diagnosticToolDetails(input, output) {
1184
+ const serialize = (value) => {
1185
+ try {
1186
+ const json = JSON.stringify(value);
1187
+ if (json === undefined)
1188
+ return null;
1189
+ if (Buffer.byteLength(json, "utf8") <= 64_000)
1190
+ return value;
1191
+ return {
1192
+ truncated: true,
1193
+ byteLength: Buffer.byteLength(json, "utf8"),
1194
+ preview: json.slice(0, 64_000),
1195
+ };
1196
+ }
1197
+ catch {
1198
+ return { serializationFailed: true };
1199
+ }
1200
+ };
1201
+ return { input: serialize(input), output: serialize(output) };
1202
+ }
1203
+ function telemetryHash(value) {
1204
+ return createHash("sha256").update(value).digest("hex");
1205
+ }
1206
+ // A rough byte-based proxy, not a model-tokenizer count or Claude billing measurement.
1207
+ function estimateTokens(value) {
1208
+ return Math.ceil(Buffer.byteLength(value, "utf8") / 4);
1209
+ }
1210
+ function toolTelemetryMetrics(toolName, result, seenOutputFingerprints) {
1566
1211
  const structured = result.structuredContent;
1567
1212
  if (!Array.isArray(structured) && !isRecord(structured))
1568
1213
  return {};
1569
1214
  const paths = new Set();
1215
+ const files = new Map();
1570
1216
  let truncated = false;
1571
1217
  let paginated = false;
1218
+ let textOutputBytes = 0;
1572
1219
  const visit = (value) => {
1220
+ if (typeof value === "string") {
1221
+ textOutputBytes += Buffer.byteLength(value, "utf8");
1222
+ return;
1223
+ }
1224
+ if (Array.isArray(value)) {
1225
+ for (const child of value)
1226
+ visit(child);
1227
+ return;
1228
+ }
1573
1229
  if (!isRecord(value))
1574
1230
  return;
1575
- if (typeof value.path === "string")
1231
+ if (typeof value.path === "string") {
1576
1232
  paths.add(value.path);
1233
+ const content = typeof value.content === "string" ? value.content : undefined;
1234
+ files.set(value.path, {
1235
+ pathHash: telemetryHash(value.path),
1236
+ ...(content === undefined ? {} : { contentHash: telemetryHash(content) }),
1237
+ bytes: content === undefined ? 0 : Buffer.byteLength(content, "utf8"),
1238
+ });
1239
+ }
1577
1240
  if (value.truncated === true)
1578
1241
  truncated = true;
1579
1242
  if (value.nextCursor !== undefined)
1580
1243
  paginated = true;
1581
- for (const child of Object.values(value)) {
1582
- if (Array.isArray(child))
1583
- for (const item of child)
1584
- visit(item);
1585
- else if (isRecord(child))
1586
- visit(child);
1587
- }
1244
+ for (const child of Object.values(value))
1245
+ visit(child);
1588
1246
  };
1589
1247
  visit(structured);
1248
+ const outputJson = JSON.stringify(result);
1249
+ const outputFingerprint = telemetryHash(outputJson);
1250
+ const outputBytes = Buffer.byteLength(outputJson, "utf8");
1251
+ const duplicateOutputBytes = seenOutputFingerprints.has(outputFingerprint) ? outputBytes : 0;
1252
+ seenOutputFingerprints.add(outputFingerprint);
1590
1253
  return {
1591
- ...(paths.size > 0 && /^(write|patch|delete|read|file|git_changes|inspect)/.test(toolName)
1254
+ ...(paths.size > 0 && /^(write|patch|delete|read|file)/.test(toolName)
1592
1255
  ? { affectedFiles: paths.size }
1593
1256
  : {}),
1594
1257
  ...(truncated ? { truncated: true } : {}),
1595
1258
  ...(paginated ? { paginated: true } : {}),
1259
+ outputFingerprint,
1260
+ duplicateOutputBytes,
1261
+ textOutputBytes,
1262
+ uniqueFileCount: files.size,
1263
+ ...(files.size ? { files: [...files.values()].slice(0, 100) } : {}),
1596
1264
  };
1597
1265
  }
1598
1266
  function commandTelemetryDescriptor(toolName) {
@@ -1602,8 +1270,6 @@ function commandTelemetryDescriptor(toolName) {
1602
1270
  return { family: "command", operation: "batch" };
1603
1271
  if (toolName === "validate")
1604
1272
  return { family: "npm", operation: "validate" };
1605
- if (toolName.startsWith("git_"))
1606
- return { family: "git", operation: toolName.slice("git_".length) };
1607
1273
  return undefined;
1608
1274
  }
1609
1275
  async function buildTelemetryProjectContext(root, codeGraph) {
@@ -1673,8 +1339,8 @@ function objectArray(value, name) {
1673
1339
  }
1674
1340
  function outputBytesArg(value) {
1675
1341
  const result = value === undefined ? DEFAULT_OUTPUT_BYTES : Number(value);
1676
- if (!Number.isInteger(result) || result < 1024 || result > 1_000_000)
1677
- throw new Error("maxBytes must be an integer between 1024 and 1000000.");
1342
+ if (!Number.isInteger(result) || result < 1024 || result > 64_000)
1343
+ throw new Error("maxBytes must be an integer between 1024 and 64000.");
1678
1344
  return result;
1679
1345
  }
1680
1346
  function truncateText(value, maxBytes) {
@@ -1773,33 +1439,6 @@ function decodeCursor(value) {
1773
1439
  throw new Error("Invalid pagination cursor.");
1774
1440
  return offset;
1775
1441
  }
1776
- async function patchFilesDryRun(root, files) {
1777
- return Promise.all(files.map(async ({ path: relativePath, patches }) => {
1778
- let content = await readFile(root, relativePath);
1779
- for (const patch of patches) {
1780
- if (!patch.search)
1781
- throw new Error("Patch search text cannot be empty.");
1782
- const occurrences = content.split(patch.search).length - 1;
1783
- const count = patch.count ?? 1;
1784
- if (occurrences === 0)
1785
- throw new Error(`Patch text was not found in '${relativePath}'.`);
1786
- if (!Number.isInteger(count) || count < 1)
1787
- throw new Error("Patch count must be a positive integer.");
1788
- if (occurrences < count)
1789
- throw new Error(`Patch text occurs ${occurrences} time(s), but ${count} replacement(s) were requested.`);
1790
- let offset = 0;
1791
- for (let index = 0; index < count; index += 1) {
1792
- const position = content.indexOf(patch.search, offset);
1793
- content =
1794
- content.slice(0, position) +
1795
- patch.replace +
1796
- content.slice(position + patch.search.length);
1797
- offset = position + patch.replace.length;
1798
- }
1799
- }
1800
- return { path: relativePath, applied: patches.length, content, dryRun: true };
1801
- }));
1802
- }
1803
1442
  async function notifyResourceChange(notify, root, pathValue, before) {
1804
1443
  const after = await skillResourceUrisForPath(root, pathValue);
1805
1444
  if (before.length === 0 && after.length === 0)