agent-dir 0.1.4 → 0.2.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,8 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
+ import { CodeGraphIntegration } from "./codegraph.js";
2
3
  import { findDefinition, findExports, findFiles, findImports, findReferences, findSymbol, searchCode, searchFiles, } from "./tools/code.js";
3
4
  import { runCommand } from "./tools/commands.js";
4
5
  import { diagnostics } from "./tools/diagnostics.js";
5
- import { deleteFile, listDirs, listFiles, patchFiles, readFile, readRange, writeFile, } from "./tools/files.js";
6
+ import { applyFileChanges, deleteFile, listDirs, listFiles, patchFiles, readFile, readRange, writeFile, } from "./tools/files.js";
6
7
  import { gitCommit, gitDiff, gitLog, gitPush, gitRestore, gitStage, gitStatus, gitUnstage, } from "./tools/git.js";
7
8
  import { fileInfo, packageInfo, projectOverview } from "./tools/metadata.js";
8
9
  import { runNpm } from "./tools/npm.js";
@@ -16,6 +17,7 @@ const SERVER_INFO_META = { "io.modelcontextprotocol/serverInfo": SERVER_INFO };
16
17
  const PROTOCOL_META = "io.modelcontextprotocol/protocolVersion";
17
18
  const CLIENT_CAPABILITIES_META = "io.modelcontextprotocol/clientCapabilities";
18
19
  const PAGE_SIZE = 50;
20
+ const DEFAULT_OUTPUT_BYTES = 32_000;
19
21
  const INSTRUCTIONS_URI = "agent-dir://instructions";
20
22
  const CAPABILITIES_URI = "agent-dir://capabilities";
21
23
  const SUBSCRIPTION_ID_META = "io.modelcontextprotocol/subscriptionId";
@@ -43,9 +45,18 @@ const commandResultSchema = objectOutput({
43
45
  stdout: stringSchema,
44
46
  stderr: stringSchema,
45
47
  }, ["command", "exitCode", "stdout", "stderr"]);
48
+ 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.", {
49
+ query: { type: "string", minLength: 1 },
50
+ maxFiles: { type: "integer", minimum: 1, maximum: 100 },
51
+ }, { type: "object" }, ["query"]);
46
52
  const baseTools = [
47
- 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.", {}, arrayOutput(fileEntrySchema)),
48
- tool("list_dirs", "List files and directories directly inside one or more project directories.", { paths: { type: "array", items: pathSchema, minItems: 1 } }, arrayOutput(objectOutput({ path: stringSchema, entries: arrayOutput(fileEntrySchema) }, [
53
+ 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.", {
54
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
55
+ }, arrayOutput(fileEntrySchema)),
56
+ tool("list_dirs", "List files and directories directly inside one or more project directories.", {
57
+ paths: { type: "array", items: pathSchema, minItems: 1 },
58
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
59
+ }, arrayOutput(objectOutput({ path: stringSchema, entries: arrayOutput(fileEntrySchema) }, [
49
60
  "path",
50
61
  "entries",
51
62
  ])), ["paths"]),
@@ -53,14 +64,50 @@ const baseTools = [
53
64
  path: pathSchema,
54
65
  startLine: { type: "integer", minimum: 1 },
55
66
  endLine: { type: "integer", minimum: 1 },
67
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
56
68
  }, objectOutput({
57
69
  path: stringSchema,
58
70
  startLine: { type: "integer" },
59
71
  endLine: { type: "integer" },
60
72
  content: stringSchema,
61
73
  }, ["path", "startLine", "endLine", "content"]), ["path", "startLine", "endLine"]),
62
- 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.", { paths: { type: "array", items: pathSchema, minItems: 1 } }, arrayOutput(objectOutput({ path: stringSchema, content: stringSchema }, ["path", "content"])), ["paths"]),
74
+ 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.", {
75
+ paths: { type: "array", items: pathSchema, minItems: 1 },
76
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
77
+ }, arrayOutput(objectOutput({ path: stringSchema, content: stringSchema }, ["path", "content"])), ["paths"]),
78
+ 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.", {
79
+ dryRun: { type: "boolean", default: false },
80
+ changes: {
81
+ type: "array",
82
+ minItems: 1,
83
+ items: {
84
+ type: "object",
85
+ properties: {
86
+ kind: { type: "string", enum: ["write", "patch", "delete"] },
87
+ path: pathSchema,
88
+ content: stringSchema,
89
+ patches: {
90
+ type: "array",
91
+ items: objectOutput({
92
+ search: stringSchema,
93
+ replace: stringSchema,
94
+ count: { type: "integer", minimum: 1 },
95
+ }, ["search", "replace"]),
96
+ minItems: 1,
97
+ },
98
+ },
99
+ required: ["kind", "path"],
100
+ additionalProperties: false,
101
+ },
102
+ },
103
+ }, arrayOutput(objectOutput({
104
+ kind: { type: "string", enum: ["write", "patch", "delete"] },
105
+ path: stringSchema,
106
+ changed: { type: "boolean" },
107
+ dryRun: { type: "boolean" },
108
+ }, ["kind", "path", "changed"])), ["changes"]),
63
109
  tool("write_files", "Create or completely replace one or more UTF-8 project files.", {
110
+ dryRun: { type: "boolean", default: false },
64
111
  files: {
65
112
  type: "array",
66
113
  items: objectOutput({ path: pathSchema, content: stringSchema }, ["path", "content"]),
@@ -68,6 +115,7 @@ const baseTools = [
68
115
  },
69
116
  }, arrayOutput(objectOutput({ path: stringSchema, written: { type: "boolean" } }, ["path", "written"])), ["files"]),
70
117
  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.", {
118
+ dryRun: { type: "boolean", default: false },
71
119
  files: {
72
120
  type: "array",
73
121
  items: objectOutput({
@@ -84,29 +132,37 @@ const baseTools = [
84
132
  }, ["path", "patches"]),
85
133
  minItems: 1,
86
134
  },
135
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
87
136
  }, arrayOutput(objectOutput({ path: stringSchema, applied: { type: "integer" }, content: stringSchema }, [
88
137
  "path",
89
138
  "applied",
90
139
  "content",
91
140
  ])), ["files"]),
92
- tool("delete_files", "Delete one or more project files.", { paths: { type: "array", items: pathSchema, minItems: 1 } }, arrayOutput(objectOutput({ path: stringSchema, deleted: { type: "boolean" } }, ["path", "deleted"])), ["paths"]),
141
+ tool("delete_files", "Delete one or more project files.", {
142
+ paths: { type: "array", items: pathSchema, minItems: 1 },
143
+ dryRun: { type: "boolean", default: false },
144
+ }, arrayOutput(objectOutput({ path: stringSchema, deleted: { type: "boolean" } }, ["path", "deleted"])), ["paths"]),
93
145
  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.", {
94
146
  query: stringSchema,
95
147
  regex: { type: "boolean", default: false },
96
148
  maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
149
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
97
150
  }, arrayOutput(searchMatchSchema), ["query"]),
98
151
  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
152
  pattern: stringSchema,
100
153
  maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
154
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
101
155
  }, arrayOutput(stringSchema), ["pattern"]),
102
156
  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.", {
103
157
  query: stringSchema,
104
158
  regex: { type: "boolean", default: false },
105
159
  maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
160
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
106
161
  }, arrayOutput(searchMatchSchema), ["query"]),
107
162
  tool("find_symbol", "Find likely symbol definitions across source files. Prefer this over reading whole modules when locating an implementation.", {
108
163
  symbol: stringSchema,
109
164
  maxResults: { type: "integer", minimum: 1, maximum: 500, default: 100 },
165
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
110
166
  }, arrayOutput(objectOutput({
111
167
  ...searchMatchSchema.properties,
112
168
  symbol: stringSchema,
@@ -115,6 +171,7 @@ const baseTools = [
115
171
  tool("find_definition", "Find likely definitions of a symbol. Use this before reading a large file when you need one implementation.", {
116
172
  symbol: stringSchema,
117
173
  maxResults: { type: "integer", minimum: 1, maximum: 500, default: 100 },
174
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
118
175
  }, arrayOutput(objectOutput({
119
176
  ...searchMatchSchema.properties,
120
177
  symbol: stringSchema,
@@ -123,25 +180,101 @@ const baseTools = [
123
180
  tool("find_references", "Find source-code references to a symbol. Use this instead of searching entire files when tracing usage of a known symbol.", {
124
181
  symbol: stringSchema,
125
182
  maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
183
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
126
184
  }, arrayOutput(searchMatchSchema), ["symbol"]),
127
- tool("find_imports", "Find import and require statements in source files.", { maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 } }, arrayOutput(objectOutput({
185
+ tool("find_imports", "Find import and require statements in source files.", {
186
+ maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
187
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
188
+ }, arrayOutput(objectOutput({
128
189
  ...searchMatchSchema.properties,
129
190
  module: stringSchema,
130
191
  kind: { type: "string", enum: ["import", "require"] },
131
192
  }, ["path", "line", "column", "text", "module", "kind"]))),
132
- tool("find_exports", "Find exported declarations in source files.", { maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 } }, arrayOutput(objectOutput({
193
+ tool("find_exports", "Find exported declarations in source files.", {
194
+ maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
195
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
196
+ }, arrayOutput(objectOutput({
133
197
  ...searchMatchSchema.properties,
134
198
  symbol: stringSchema,
135
199
  kind: { type: "string", const: "export" },
136
200
  }, ["path", "line", "column", "text", "symbol", "kind"]))),
137
- 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.", {}, commandResultSchema),
138
- tool("git_stage", "Stage one or more Git paths.", { paths: { type: "array", items: pathSchema, minItems: 1 } }, commandResultSchema, ["paths"]),
139
- tool("git_unstage", "Unstage one or more Git paths without changing their working-tree contents.", { paths: { type: "array", items: pathSchema, minItems: 1 } }, commandResultSchema, ["paths"]),
140
- tool("git_commit", "Create a Git commit with the currently staged changes.", { message: stringSchema }, commandResultSchema, ["message"]),
141
- tool("git_restore", "Restore one or more Git paths from the index, discarding unstaged working-tree changes.", { paths: { type: "array", items: pathSchema, minItems: 1 } }, commandResultSchema, ["paths"]),
142
- tool("git_push", "Push the current Git branch to a remote.", { remote: stringSchema, branch: stringSchema }, commandResultSchema),
143
- 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.", { staged: { type: "boolean", default: false }, path: pathSchema }, commandResultSchema),
144
- 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.", { limit: { type: "integer", minimum: 1, maximum: 200, default: 20 }, path: pathSchema }, commandResultSchema),
201
+ 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.", {
202
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
203
+ }, commandResultSchema),
204
+ tool("git_stage", "Stage one or more Git paths.", {
205
+ paths: { type: "array", items: pathSchema, minItems: 1 },
206
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
207
+ }, commandResultSchema, ["paths"]),
208
+ tool("git_unstage", "Unstage one or more Git paths without changing their working-tree contents.", {
209
+ paths: { type: "array", items: pathSchema, minItems: 1 },
210
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
211
+ }, commandResultSchema, ["paths"]),
212
+ tool("git_commit", "Create a Git commit with the currently staged changes.", {
213
+ message: stringSchema,
214
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
215
+ }, commandResultSchema, ["message"]),
216
+ tool("git_restore", "Restore one or more Git paths from the index, discarding unstaged working-tree changes.", {
217
+ paths: { type: "array", items: pathSchema, minItems: 1 },
218
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
219
+ }, commandResultSchema, ["paths"]),
220
+ tool("git_push", "Push the current Git branch to a remote.", {
221
+ remote: stringSchema,
222
+ branch: stringSchema,
223
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
224
+ }, commandResultSchema),
225
+ 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.", {
226
+ staged: { type: "boolean", default: false },
227
+ path: pathSchema,
228
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
229
+ }, commandResultSchema),
230
+ 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.", {
231
+ limit: { type: "integer", minimum: 1, maximum: 200, default: 20 },
232
+ path: pathSchema,
233
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
234
+ }, commandResultSchema),
235
+ tool("locate", "Locate project code or files in one call. Use kind auto, code, text, or file.", {
236
+ query: stringSchema,
237
+ kind: { type: "string", enum: ["auto", "code", "text", "file"], default: "auto" },
238
+ regex: { type: "boolean", default: false },
239
+ maxResults: { type: "integer", minimum: 1, maximum: 100, default: 20 },
240
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
241
+ }, { type: "object", additionalProperties: true }, ["query"]),
242
+ tool("inspect_symbol", "Inspect a known symbol in one call, returning its definition, bounded source context, and optionally references.", {
243
+ symbol: stringSchema,
244
+ includeReferences: { type: "boolean", default: false },
245
+ contextLines: { type: "integer", minimum: 0, maximum: 20, default: 3 },
246
+ maxResults: { type: "integer", minimum: 1, maximum: 20, default: 5 },
247
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
248
+ }, { type: "object", additionalProperties: true }, ["symbol"]),
249
+ tool("inspect", "Inspect a project target in one call. Use kind auto, file, symbol, or code; returns bounded context and optional symbol references.", {
250
+ target: stringSchema,
251
+ kind: { type: "string", enum: ["auto", "file", "symbol", "code"], default: "auto" },
252
+ includeReferences: { type: "boolean", default: false },
253
+ contextLines: { type: "integer", minimum: 0, maximum: 20, default: 3 },
254
+ maxResults: { type: "integer", minimum: 1, maximum: 20, default: 5 },
255
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
256
+ }, { type: "object", additionalProperties: true }, ["target"]),
257
+ 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.", {
258
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
259
+ }, { type: "object", additionalProperties: true }),
260
+ 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.", {
261
+ query: stringSchema,
262
+ regex: { type: "boolean", default: false },
263
+ contextLines: { type: "integer", minimum: 0, maximum: 20, default: 3 },
264
+ maxResults: { type: "integer", minimum: 1, maximum: 20, default: 5 },
265
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
266
+ }, { type: "object", additionalProperties: true }, ["query"]),
267
+ 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.", {
268
+ level: { type: "string", enum: ["diagnostics", "scripts", "full"], default: "diagnostics" },
269
+ scripts: { type: "array", items: stringSchema, minItems: 1 },
270
+ maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
271
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
272
+ }, { type: "object", additionalProperties: true }),
273
+ 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.", {
274
+ includeDiff: { type: "boolean", default: false },
275
+ path: pathSchema,
276
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
277
+ }, { type: "object", additionalProperties: true }),
145
278
  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({
146
279
  root: stringSchema,
147
280
  files: { type: "integer" },
@@ -157,20 +290,27 @@ const baseTools = [
157
290
  commands: { type: "array", items: stringSchema },
158
291
  }, ["npmScripts", "commands"])),
159
292
  tool("file_info", "Return filesystem metadata for a project path.", { path: pathSchema }, { type: "object", additionalProperties: true }, ["path"]),
160
- 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.", { maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 } }, arrayOutput(objectOutput({
293
+ 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.", {
294
+ maxResults: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
295
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
296
+ }, arrayOutput(objectOutput({
161
297
  severity: { type: "string", enum: ["error", "warning", "info"] },
162
298
  code: stringSchema,
163
299
  path: stringSchema,
164
300
  line: { type: "integer" },
165
301
  message: stringSchema,
166
302
  }, ["severity", "code", "message"]))),
167
- 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.", { scripts: { type: "array", items: stringSchema, minItems: 1 } }, arrayOutput(commandResultSchema), ["scripts"]),
303
+ 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.", {
304
+ scripts: { type: "array", items: stringSchema, minItems: 1 },
305
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
306
+ }, arrayOutput(commandResultSchema), ["scripts"]),
168
307
  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.", {
169
308
  commands: {
170
309
  type: "array",
171
310
  items: objectOutput({ command: stringSchema, args: { type: "array", items: stringSchema } }, ["command"]),
172
311
  minItems: 1,
173
312
  },
313
+ maxBytes: { type: "integer", minimum: 1024, maximum: 1000000, default: DEFAULT_OUTPUT_BYTES },
174
314
  }, arrayOutput(commandResultSchema), ["commands"]),
175
315
  ];
176
316
  function tool(name, description, properties, outputSchema, required = []) {
@@ -188,6 +328,7 @@ function tool(name, description, properties, outputSchema, required = []) {
188
328
  };
189
329
  }
190
330
  export function createMcpHandler(root, commandConfig = {}) {
331
+ const codeGraph = new CodeGraphIntegration(root);
191
332
  const npmAllowed = commandConfig.npm?.allowedScripts ?? [];
192
333
  const allowedCommands = commandConfig.commands ?? [];
193
334
  const subscriptions = new Set();
@@ -260,7 +401,7 @@ export function createMcpHandler(root, commandConfig = {}) {
260
401
  protocolVersion: LEGACY_PROTOCOL_VERSION,
261
402
  capabilities: capabilities(),
262
403
  serverInfo: SERVER_INFO,
263
- instructions: buildAgentInstructions(npmAllowed, allowedCommands),
404
+ instructions: buildAgentInstructions(npmAllowed, allowedCommands, await codeGraph.capability()),
264
405
  },
265
406
  });
266
407
  }
@@ -292,13 +433,14 @@ export function createMcpHandler(root, commandConfig = {}) {
292
433
  supportedVersions: [PROTOCOL_VERSION],
293
434
  capabilities: capabilities(),
294
435
  _meta: SERVER_INFO_META,
295
- instructions: buildAgentInstructions(npmAllowed, allowedCommands),
436
+ instructions: buildAgentInstructions(npmAllowed, allowedCommands, await codeGraph.capability()),
437
+ codeGraph: await codeGraph.capability(),
296
438
  ttlMs: 0,
297
439
  cacheScope: "private",
298
440
  },
299
441
  });
300
442
  case "tools/list": {
301
- const page = paginate(baseTools, params.cursor);
443
+ const page = paginate(await availableTools(codeGraph), params.cursor);
302
444
  return json({
303
445
  jsonrpc: "2.0",
304
446
  id: message.id,
@@ -316,7 +458,7 @@ export function createMcpHandler(root, commandConfig = {}) {
316
458
  if (typeof params.name !== "string" || params.name.length === 0)
317
459
  return jsonRpcError(message.id ?? null, -32602, "tools/call name must be a non-empty string.", 400);
318
460
  const toolName = params.name;
319
- const toolDefinition = baseTools.find((item) => item.name === toolName);
461
+ const toolDefinition = (await availableTools(codeGraph)).find((item) => item.name === toolName);
320
462
  if (!toolDefinition)
321
463
  throw new Error(`Unknown tool: ${toolName}`);
322
464
  if (params.arguments !== undefined && !isRecord(params.arguments))
@@ -328,7 +470,7 @@ export function createMcpHandler(root, commandConfig = {}) {
328
470
  return json({
329
471
  jsonrpc: "2.0",
330
472
  id: message.id,
331
- result: await callTool(toolName, argumentsValue, root, npmAllowed, allowedCommands, notify, request.signal),
473
+ result: await callTool(toolName, argumentsValue, root, npmAllowed, allowedCommands, notify, request.signal, codeGraph),
332
474
  });
333
475
  }
334
476
  case "resources/list": {
@@ -377,7 +519,7 @@ export function createMcpHandler(root, commandConfig = {}) {
377
519
  {
378
520
  uri: INSTRUCTIONS_URI,
379
521
  mimeType: "text/markdown",
380
- text: buildAgentInstructions(npmAllowed, allowedCommands),
522
+ text: buildAgentInstructions(npmAllowed, allowedCommands, await codeGraph.capability()),
381
523
  },
382
524
  ],
383
525
  ttlMs: 0,
@@ -489,7 +631,8 @@ export function createMcpHandler(root, commandConfig = {}) {
489
631
  const code = message.method === "skills/get" ||
490
632
  message.method === "resources/read" ||
491
633
  message.method === "resources/directory/read" ||
492
- message.method === "subscriptions/listen"
634
+ message.method === "subscriptions/listen" ||
635
+ message.method === "skills/list"
493
636
  ? -32602
494
637
  : -32603;
495
638
  return jsonRpcError(message.id ?? null, code, error instanceof Error ? error.message : String(error), code === -32602 ? 400 : 500);
@@ -511,15 +654,32 @@ export function createMcpHandler(root, commandConfig = {}) {
511
654
  subscription.controller.close();
512
655
  }
513
656
  subscriptions.clear();
657
+ void codeGraph.close();
514
658
  };
515
659
  return handler;
516
660
  }
517
- function buildAgentInstructions(npmAllowed, allowedCommands) {
661
+ async function availableTools(codeGraph) {
662
+ const capability = await codeGraph.capability();
663
+ return capability.status === "available" ||
664
+ capability.status === "startup_failed" ||
665
+ capability.status === "runtime_failed"
666
+ ? [...baseTools, codeGraphTool]
667
+ : baseTools;
668
+ }
669
+ function buildAgentInstructions(npmAllowed, allowedCommands, codeGraph) {
518
670
  const npm = npmAllowed.length ? npmAllowed.map((item) => `- ${item}`).join("\n") : "- none";
519
671
  const commands = allowedCommands.length
520
672
  ? allowedCommands.map((item) => `- ${item}`).join("\n")
521
673
  : "- none";
522
- 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_overview for initial orientation instead of list_files plus package/config reads.\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.\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\nOnly use commands from the execution policy. Do not attempt to bypass it with shells or alternate executables.\n\n## Tool selection\n- Discovery: project_overview > find_files/list_dirs > list_files.\n- Code location: find_definition/find_symbol > search_code > broad file reads.\n- File reading: 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_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.`;
674
+ const codeGraphSection = codeGraph.status === "available" ||
675
+ codeGraph.status === "startup_failed" ||
676
+ codeGraph.status === "runtime_failed"
677
+ ? "\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: " +
678
+ codeGraph.status +
679
+ (codeGraph.detail ? ` — ${codeGraph.detail}` : "") +
680
+ "."
681
+ : "";
682
+ 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\nOnly use commands from the execution policy. Do not attempt to bypass it with shells or alternate executables.\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}`;
523
683
  }
524
684
  function buildAgentCapabilities(npmAllowed, allowedCommands) {
525
685
  return {
@@ -529,13 +689,15 @@ function buildAgentCapabilities(npmAllowed, allowedCommands) {
529
689
  execution: { npmScripts: [...npmAllowed], commands: [...allowedCommands] },
530
690
  efficiency: {
531
691
  preferred: {
532
- orientation: "project_overview",
692
+ orientation: "project_context",
693
+ focusedReading: "read_relevant",
694
+ changeReview: "git_changes",
533
695
  discovery: ["find_files", "list_dirs", "search_code"],
534
696
  symbolNavigation: ["find_definition", "find_symbol", "find_references"],
535
697
  reading: ["read_range", "read_files"],
536
- editing: ["patch_files", "write_files"],
537
- validation: ["diagnostics", "targeted npm script"],
538
- gitInspection: ["git_status", "git_diff", "git_log"],
698
+ editing: ["apply_changes", "patch_files", "write_files"],
699
+ validation: ["validate", "diagnostics", "targeted npm script"],
700
+ gitInspection: ["git_changes", "git_status", "git_diff", "git_log"],
539
701
  },
540
702
  avoid: [
541
703
  "recursive discovery when targeted search is enough",
@@ -554,37 +716,102 @@ function capabilities() {
554
716
  extensions: { "io.modelcontextprotocol/skills": { directoryRead: true } },
555
717
  };
556
718
  }
557
- async function callTool(name, args, root, npmAllowed, allowedCommands, notify, signal) {
719
+ async function callTool(name, args, root, npmAllowed, allowedCommands, notify, signal, codeGraph) {
558
720
  let structuredContent;
721
+ if (name === "codegraph_explore") {
722
+ if (!codeGraph)
723
+ throw new Error("CodeGraph integration is unavailable.");
724
+ if (typeof args.query !== "string" || args.query.trim().length === 0)
725
+ throw new Error("query must be a non-empty string.");
726
+ if (args.maxFiles !== undefined &&
727
+ (!Number.isInteger(args.maxFiles) || Number(args.maxFiles) < 1 || Number(args.maxFiles) > 100))
728
+ throw new Error("maxFiles must be an integer between 1 and 100.");
729
+ return await codeGraph.explore(args.query, args.maxFiles === undefined ? undefined : Number(args.maxFiles));
730
+ }
559
731
  switch (name) {
560
- case "list_files":
561
- structuredContent = await listFiles(root);
732
+ case "list_files": {
733
+ const items = await listFiles(root);
734
+ structuredContent = boundedItems(items, outputBytesArg(args.maxBytes)).items;
735
+ break;
736
+ }
737
+ case "list_dirs": {
738
+ const items = await listDirs(root, stringArray(args.paths, "paths"));
739
+ structuredContent = boundedItems(items, outputBytesArg(args.maxBytes)).items;
562
740
  break;
563
- case "list_dirs":
564
- structuredContent = await listDirs(root, stringArray(args.paths, "paths"));
741
+ }
742
+ case "read_range": {
743
+ const result = await readRange(root, String(args.path), Number(args.startLine), Number(args.endLine));
744
+ const bounded = truncateText(result.content, outputBytesArg(args.maxBytes));
745
+ structuredContent = { ...result, content: bounded.text };
565
746
  break;
566
- case "read_range":
567
- structuredContent = await readRange(root, String(args.path), Number(args.startLine), Number(args.endLine));
747
+ }
748
+ case "read_files": {
749
+ const maxBytes = outputBytesArg(args.maxBytes);
750
+ let remaining = maxBytes;
751
+ const output = [];
752
+ for (const p of stringArray(args.paths, "paths")) {
753
+ const content = await readFile(root, p);
754
+ const bounded = truncateText(content, remaining);
755
+ output.push({ path: p, content: bounded.text });
756
+ remaining -= Buffer.byteLength(bounded.text, "utf8");
757
+ if (bounded.truncated || remaining <= 0)
758
+ break;
759
+ }
760
+ structuredContent = output;
568
761
  break;
569
- case "read_files":
570
- structuredContent = await Promise.all(stringArray(args.paths, "paths").map(async (p) => ({
571
- path: p,
572
- content: await readFile(root, p),
573
- })));
762
+ }
763
+ case "apply_changes": {
764
+ const changes = objectArray(args.changes, "changes").map((change) => {
765
+ const kind = String(change.kind);
766
+ if (kind !== "write" && kind !== "patch" && kind !== "delete")
767
+ throw new Error("Change kind must be write, patch, or delete.");
768
+ if (kind === "write" && typeof change.content !== "string")
769
+ throw new Error("write changes require content.");
770
+ if (kind === "patch" && !Array.isArray(change.patches))
771
+ throw new Error("patch changes require patches.");
772
+ return {
773
+ kind,
774
+ path: String(change.path),
775
+ ...(kind === "write" ? { content: String(change.content) } : {}),
776
+ ...(kind === "patch"
777
+ ? {
778
+ patches: objectArray(change.patches, "patches").map((patch) => ({
779
+ search: String(patch.search),
780
+ replace: String(patch.replace),
781
+ ...(patch.count === undefined ? {} : { count: Number(patch.count) }),
782
+ })),
783
+ }
784
+ : {}),
785
+ };
786
+ });
787
+ const dryRun = args.dryRun === true;
788
+ const affected = [...new Set(changes.map((change) => change.path))];
789
+ const before = new Map();
790
+ for (const p of affected)
791
+ before.set(p, await skillResourceUrisForPath(root, p));
792
+ structuredContent = await applyFileChanges(root, changes, dryRun);
793
+ if (!dryRun)
794
+ for (const p of affected)
795
+ await notifyResourceChange(notify, root, p, before.get(p) ?? []);
574
796
  break;
797
+ }
575
798
  case "write_files": {
799
+ const dryRun = args.dryRun === true;
576
800
  const output = [];
577
801
  for (const file of objectArray(args.files, "files")) {
578
802
  const p = String(file.path);
579
803
  const before = await skillResourceUrisForPath(root, p);
580
- await writeFile(root, p, String(file.content));
581
- await notifyResourceChange(notify, root, p, before);
582
- output.push({ path: p, written: true });
804
+ if (!dryRun) {
805
+ await writeFile(root, p, String(file.content));
806
+ await notifyResourceChange(notify, root, p, before);
807
+ }
808
+ output.push({ path: p, written: true, ...(dryRun ? { dryRun: true } : {}) });
583
809
  }
584
810
  structuredContent = output;
585
811
  break;
586
812
  }
587
813
  case "patch_files": {
814
+ const dryRun = args.dryRun === true;
588
815
  const files = objectArray(args.files, "files").map((file) => ({
589
816
  path: String(file.path),
590
817
  patches: objectArray(file.patches, "patches").map((p) => ({
@@ -596,70 +823,331 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, notify, s
596
823
  const before = new Map();
597
824
  for (const file of files)
598
825
  before.set(file.path, await skillResourceUrisForPath(root, file.path));
599
- structuredContent = await patchFiles(root, files);
600
- for (const file of files)
601
- await notifyResourceChange(notify, root, file.path, before.get(file.path) ?? []);
826
+ if (dryRun) {
827
+ structuredContent = await patchFilesDryRun(root, files);
828
+ }
829
+ else {
830
+ structuredContent = await patchFiles(root, files);
831
+ }
832
+ const maxBytes = outputBytesArg(args.maxBytes);
833
+ if (Array.isArray(structuredContent)) {
834
+ structuredContent = boundedItems(structuredContent.map((item) => {
835
+ if (!isRecord(item) || typeof item.content !== "string")
836
+ return item;
837
+ return { ...item, content: truncateText(item.content, maxBytes).text };
838
+ }), maxBytes).items;
839
+ }
840
+ if (!dryRun) {
841
+ for (const file of files)
842
+ await notifyResourceChange(notify, root, file.path, before.get(file.path) ?? []);
843
+ }
602
844
  break;
603
845
  }
604
846
  case "delete_files": {
847
+ const dryRun = args.dryRun === true;
605
848
  const output = [];
606
849
  for (const p of stringArray(args.paths, "paths")) {
607
850
  const before = await skillResourceUrisForPath(root, p);
608
- await deleteFile(root, p);
609
- await notifyResourceChange(notify, root, p, before);
610
- output.push({ path: p, deleted: true });
851
+ if (!dryRun) {
852
+ await deleteFile(root, p);
853
+ await notifyResourceChange(notify, root, p, before);
854
+ }
855
+ output.push({ path: p, deleted: true, ...(dryRun ? { dryRun: true } : {}) });
611
856
  }
612
857
  structuredContent = output;
613
858
  break;
614
859
  }
615
860
  case "search_files":
616
- structuredContent = await searchFiles(root, String(args.query), Boolean(args.regex), numberArg(args.maxResults, 200, 1000));
861
+ structuredContent = await searchFiles(root, String(args.query), Boolean(args.regex), numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
617
862
  break;
618
863
  case "find_files":
619
- structuredContent = await findFiles(root, String(args.pattern), numberArg(args.maxResults, 200, 1000));
864
+ structuredContent = boundedItems(await findFiles(root, String(args.pattern), numberArg(args.maxResults, 200, 1000)), outputBytesArg(args.maxBytes)).items;
620
865
  break;
621
866
  case "search_code":
622
- structuredContent = await searchCode(root, String(args.query), Boolean(args.regex), numberArg(args.maxResults, 200, 1000));
867
+ structuredContent = await searchCode(root, String(args.query), Boolean(args.regex), numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
623
868
  break;
624
869
  case "find_symbol":
625
- structuredContent = await findSymbol(root, String(args.symbol), numberArg(args.maxResults, 100, 500));
870
+ structuredContent = await findSymbol(root, String(args.symbol), numberArg(args.maxResults, 100, 500), outputBytesArg(args.maxBytes));
626
871
  break;
627
872
  case "find_definition":
628
- structuredContent = await findDefinition(root, String(args.symbol), numberArg(args.maxResults, 100, 500));
873
+ structuredContent = await findDefinition(root, String(args.symbol), numberArg(args.maxResults, 100, 500), outputBytesArg(args.maxBytes));
629
874
  break;
630
875
  case "find_references":
631
- structuredContent = await findReferences(root, String(args.symbol), numberArg(args.maxResults, 200, 1000));
876
+ structuredContent = await findReferences(root, String(args.symbol), numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
632
877
  break;
633
878
  case "find_imports":
634
- structuredContent = await findImports(root, numberArg(args.maxResults, 200, 1000));
879
+ structuredContent = await findImports(root, numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
635
880
  break;
636
881
  case "find_exports":
637
- structuredContent = await findExports(root, numberArg(args.maxResults, 200, 1000));
882
+ structuredContent = await findExports(root, numberArg(args.maxResults, 200, 1000), outputBytesArg(args.maxBytes));
638
883
  break;
639
884
  case "git_status":
640
- structuredContent = await gitStatus(root);
885
+ structuredContent = boundCommandResult(await gitStatus(root), outputBytesArg(args.maxBytes));
641
886
  break;
642
887
  case "git_stage":
643
- structuredContent = await gitStage(root, stringArray(args.paths, "paths"));
888
+ structuredContent = boundCommandResult(await gitStage(root, stringArray(args.paths, "paths")), outputBytesArg(args.maxBytes));
644
889
  break;
645
890
  case "git_unstage":
646
- structuredContent = await gitUnstage(root, stringArray(args.paths, "paths"));
891
+ structuredContent = boundCommandResult(await gitUnstage(root, stringArray(args.paths, "paths")), outputBytesArg(args.maxBytes));
647
892
  break;
648
893
  case "git_commit":
649
- structuredContent = await gitCommit(root, String(args.message));
894
+ structuredContent = boundCommandResult(await gitCommit(root, String(args.message)), outputBytesArg(args.maxBytes));
650
895
  break;
651
896
  case "git_restore":
652
- structuredContent = await gitRestore(root, stringArray(args.paths, "paths"));
897
+ structuredContent = boundCommandResult(await gitRestore(root, stringArray(args.paths, "paths")), outputBytesArg(args.maxBytes));
653
898
  break;
654
899
  case "git_push":
655
- structuredContent = await gitPush(root, args.remote === undefined ? undefined : String(args.remote), args.branch === undefined ? undefined : String(args.branch));
900
+ structuredContent = boundCommandResult(await gitPush(root, args.remote === undefined ? undefined : String(args.remote), args.branch === undefined ? undefined : String(args.branch)), outputBytesArg(args.maxBytes));
656
901
  break;
657
902
  case "git_diff":
658
- structuredContent = await gitDiff(root, Boolean(args.staged), args.path === undefined ? undefined : String(args.path));
903
+ structuredContent = boundCommandResult(await gitDiff(root, Boolean(args.staged), args.path === undefined ? undefined : String(args.path)), outputBytesArg(args.maxBytes));
659
904
  break;
660
905
  case "git_log":
661
- structuredContent = await gitLog(root, numberArg(args.limit, 20, 200), args.path === undefined ? undefined : String(args.path));
906
+ structuredContent = boundCommandResult(await gitLog(root, numberArg(args.limit, 20, 200), args.path === undefined ? undefined : String(args.path)), outputBytesArg(args.maxBytes));
907
+ break;
908
+ case "locate": {
909
+ const query = String(args.query);
910
+ const kind = typeof args.kind === "string" ? args.kind : "auto";
911
+ const regex = args.regex === true;
912
+ const maxResults = numberArg(args.maxResults, 20, 100);
913
+ const resolvedKind = kind === "auto"
914
+ ? /[*?]/.test(query)
915
+ ? "file"
916
+ : /\.(ts|tsx|js|jsx|py|go|rs|java|json|md|yml|yaml|css|html)$/.test(query)
917
+ ? "file"
918
+ : "code"
919
+ : kind;
920
+ const items = resolvedKind === "file"
921
+ ? await findFiles(root, query, maxResults)
922
+ : resolvedKind === "text"
923
+ ? await searchFiles(root, query, regex, maxResults)
924
+ : await searchCode(root, query, regex, maxResults);
925
+ const bounded = boundedItems(items, outputBytesArg(args.maxBytes));
926
+ structuredContent = {
927
+ query,
928
+ kind: resolvedKind,
929
+ items: bounded.items,
930
+ count: bounded.items.length,
931
+ truncated: bounded.truncated || items.length >= maxResults,
932
+ };
933
+ break;
934
+ }
935
+ case "inspect_symbol": {
936
+ const symbol = String(args.symbol);
937
+ const contextLines = args.contextLines === undefined ? 3 : Number(args.contextLines);
938
+ if (!Number.isInteger(contextLines) || contextLines < 0 || contextLines > 20)
939
+ throw new Error("contextLines must be an integer between 0 and 20.");
940
+ const maxResults = numberArg(args.maxResults, 5, 20);
941
+ const maxBytes = outputBytesArg(args.maxBytes);
942
+ const definitions = await findDefinition(root, symbol, maxResults, maxBytes);
943
+ const definitionContext = await Promise.all(definitions.map(async (definition) => {
944
+ const range = await readRange(root, definition.path, Math.max(1, definition.line - contextLines), definition.line + contextLines);
945
+ return { ...definition, context: range.content };
946
+ }));
947
+ const references = args.includeReferences === true
948
+ ? await findReferences(root, symbol, maxResults, maxBytes)
949
+ : undefined;
950
+ const result = {
951
+ symbol,
952
+ definitions: definitionContext,
953
+ ...(references ? { references } : {}),
954
+ };
955
+ const bounded = truncateText(JSON.stringify(result), maxBytes);
956
+ structuredContent = bounded.truncated
957
+ ? { symbol, result: bounded.text, truncated: true }
958
+ : result;
959
+ break;
960
+ }
961
+ case "inspect": {
962
+ const target = String(args.target);
963
+ const requestedKind = typeof args.kind === "string" ? args.kind : "auto";
964
+ const contextLines = args.contextLines === undefined ? 3 : Number(args.contextLines);
965
+ if (!Number.isInteger(contextLines) || contextLines < 0 || contextLines > 20)
966
+ throw new Error("contextLines must be an integer between 0 and 20.");
967
+ const maxResults = numberArg(args.maxResults, 5, 20);
968
+ const maxBytes = outputBytesArg(args.maxBytes);
969
+ let kind = requestedKind;
970
+ if (kind === "auto") {
971
+ try {
972
+ await readFile(root, target);
973
+ kind = "file";
974
+ }
975
+ catch {
976
+ kind = /[*?]/.test(target) ? "code" : "symbol";
977
+ }
978
+ }
979
+ if (kind === "file") {
980
+ const content = await readFile(root, target);
981
+ const boundedContent = truncateText(content, Math.max(1, maxBytes - 128));
982
+ const result = {
983
+ target,
984
+ kind,
985
+ path: target,
986
+ content: boundedContent.text,
987
+ truncated: boundedContent.truncated,
988
+ };
989
+ structuredContent = result;
990
+ break;
991
+ }
992
+ if (kind === "symbol") {
993
+ const definitions = await findDefinition(root, target, maxResults, maxBytes);
994
+ const definitionsWithContext = await Promise.all(definitions.map(async (definition) => {
995
+ const range = await readRange(root, definition.path, Math.max(1, definition.line - contextLines), definition.line + contextLines);
996
+ return { ...definition, context: range.content };
997
+ }));
998
+ const references = args.includeReferences === true
999
+ ? await findReferences(root, target, maxResults, maxBytes)
1000
+ : undefined;
1001
+ const result = {
1002
+ target,
1003
+ kind,
1004
+ definitions: definitionsWithContext,
1005
+ ...(references ? { references } : {}),
1006
+ };
1007
+ const bounded = truncateText(JSON.stringify(result), maxBytes);
1008
+ structuredContent = bounded.truncated
1009
+ ? { target, kind, result: bounded.text, truncated: true }
1010
+ : result;
1011
+ break;
1012
+ }
1013
+ const matches = await searchCode(root, target, false, maxResults, maxBytes);
1014
+ const relevant = await Promise.all(matches.map(async (match) => {
1015
+ const range = await readRange(root, match.path, Math.max(1, match.line - contextLines), match.line + contextLines);
1016
+ return {
1017
+ ...match,
1018
+ context: range.content,
1019
+ startLine: range.startLine,
1020
+ endLine: range.endLine,
1021
+ };
1022
+ }));
1023
+ const bounded = boundedItems(relevant, maxBytes);
1024
+ structuredContent = {
1025
+ target,
1026
+ kind: "code",
1027
+ matches: bounded.items,
1028
+ count: bounded.items.length,
1029
+ truncated: bounded.truncated || relevant.length >= maxResults,
1030
+ };
1031
+ break;
1032
+ }
1033
+ case "project_context": {
1034
+ const overview = await projectOverview(root);
1035
+ let pkg = { path: "package.json", exists: false };
1036
+ try {
1037
+ const packageJson = JSON.parse(await readFile(root, "package.json"));
1038
+ pkg = {
1039
+ path: "package.json",
1040
+ name: packageJson.name ?? null,
1041
+ version: packageJson.version ?? null,
1042
+ description: packageJson.description ?? null,
1043
+ packageManager: packageJson.packageManager ?? null,
1044
+ scripts: packageJson.scripts ?? {},
1045
+ };
1046
+ }
1047
+ catch {
1048
+ // Package metadata is optional; project context remains useful without it.
1049
+ }
1050
+ const maxBytes = outputBytesArg(args.maxBytes);
1051
+ const bounded = truncateText(JSON.stringify({
1052
+ ...overview,
1053
+ package: pkg,
1054
+ execution: { npmScripts: [...npmAllowed], commands: [...allowedCommands] },
1055
+ }), maxBytes);
1056
+ structuredContent = bounded.truncated
1057
+ ? { result: bounded.text, truncated: true }
1058
+ : JSON.parse(bounded.text);
1059
+ break;
1060
+ }
1061
+ case "read_relevant": {
1062
+ const query = String(args.query);
1063
+ const contextLines = args.contextLines === undefined ? 3 : Number(args.contextLines);
1064
+ if (!Number.isInteger(contextLines) || contextLines < 0 || contextLines > 20)
1065
+ throw new Error("contextLines must be an integer between 0 and 20.");
1066
+ const maxResults = numberArg(args.maxResults, 5, 20);
1067
+ const matches = await searchCode(root, query, Boolean(args.regex), maxResults, outputBytesArg(args.maxBytes));
1068
+ const relevant = await Promise.all(matches.map(async (match) => {
1069
+ const range = await readRange(root, match.path, Math.max(1, match.line - contextLines), match.line + contextLines);
1070
+ return {
1071
+ ...match,
1072
+ context: range.content,
1073
+ startLine: range.startLine,
1074
+ endLine: range.endLine,
1075
+ };
1076
+ }));
1077
+ const maxBytes = outputBytesArg(args.maxBytes);
1078
+ const bounded = boundedItems(relevant, Math.max(1, maxBytes - 128));
1079
+ const result = {
1080
+ query,
1081
+ matches: bounded.items,
1082
+ count: relevant.length,
1083
+ truncated: bounded.truncated || relevant.length >= maxResults,
1084
+ };
1085
+ structuredContent = result;
1086
+ break;
1087
+ }
1088
+ case "validate": {
1089
+ const level = typeof args.level === "string" ? args.level : "diagnostics";
1090
+ const maxResults = numberArg(args.maxResults, 200, 1000);
1091
+ const maxBytes = outputBytesArg(args.maxBytes);
1092
+ const diagnosticItems = await diagnostics(root, maxResults);
1093
+ const scripts = level === "diagnostics"
1094
+ ? []
1095
+ : args.scripts !== undefined
1096
+ ? stringArray(args.scripts, "scripts")
1097
+ : level === "full"
1098
+ ? ["check", "test"].filter((script) => npmAllowed.includes(script))
1099
+ : [];
1100
+ const checks = [
1101
+ {
1102
+ kind: "diagnostics",
1103
+ ok: !diagnosticItems.some((item) => item.severity === "error"),
1104
+ count: diagnosticItems.length,
1105
+ issues: boundedItems(diagnosticItems, maxBytes).items,
1106
+ },
1107
+ ];
1108
+ for (const script of scripts) {
1109
+ const result = await runNpm(root, script, 120000, npmAllowed, signal);
1110
+ checks.push({
1111
+ kind: "npm",
1112
+ script,
1113
+ ok: result.exitCode === 0,
1114
+ result,
1115
+ });
1116
+ if (result.exitCode !== 0)
1117
+ break;
1118
+ }
1119
+ structuredContent = {
1120
+ level,
1121
+ ok: checks.every((check) => check.ok === true),
1122
+ checks,
1123
+ scriptsRequested: scripts,
1124
+ };
662
1125
  break;
1126
+ }
1127
+ case "git_changes": {
1128
+ const status = await gitStatus(root);
1129
+ const includeDiff = Boolean(args.includeDiff);
1130
+ const path = args.path === undefined ? undefined : String(args.path);
1131
+ const maxBytes = outputBytesArg(args.maxBytes);
1132
+ const diff = includeDiff ? await gitDiff(root, false, path) : undefined;
1133
+ const statusBytes = Buffer.byteLength(JSON.stringify(status), "utf8");
1134
+ const diffBudget = Math.max(0, maxBytes - statusBytes - 64);
1135
+ const boundedDiff = diff ? truncateText(diff.stdout, diffBudget) : undefined;
1136
+ const result = {
1137
+ status,
1138
+ ...(diff
1139
+ ? {
1140
+ diff: {
1141
+ ...diff,
1142
+ stdout: boundedDiff?.text ?? "",
1143
+ truncated: boundedDiff?.truncated ?? false,
1144
+ },
1145
+ }
1146
+ : {}),
1147
+ };
1148
+ structuredContent = result;
1149
+ break;
1150
+ }
663
1151
  case "project_overview":
664
1152
  structuredContent = await projectOverview(root);
665
1153
  break;
@@ -676,7 +1164,7 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, notify, s
676
1164
  structuredContent = await fileInfo(root, String(args.path));
677
1165
  break;
678
1166
  case "diagnostics":
679
- structuredContent = await diagnostics(root, numberArg(args.maxResults, 200, 1000));
1167
+ structuredContent = boundedItems(await diagnostics(root, numberArg(args.maxResults, 200, 1000)), outputBytesArg(args.maxBytes)).items;
680
1168
  break;
681
1169
  case "run_npm_batch": {
682
1170
  const output = [];
@@ -686,7 +1174,24 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, notify, s
686
1174
  if (value.exitCode !== 0)
687
1175
  break;
688
1176
  }
689
- structuredContent = output;
1177
+ const maxBytes = outputBytesArg(args.maxBytes);
1178
+ const boundedOutput = [];
1179
+ let remaining = maxBytes - 2;
1180
+ for (const item of output) {
1181
+ const separatorBytes = boundedOutput.length ? 1 : 0;
1182
+ if (remaining <= separatorBytes)
1183
+ break;
1184
+ const budget = remaining - separatorBytes;
1185
+ const bounded = boundCommandResult(item, budget);
1186
+ const boundedBytes = Buffer.byteLength(JSON.stringify(bounded), "utf8");
1187
+ if (boundedBytes > budget)
1188
+ break;
1189
+ boundedOutput.push(bounded);
1190
+ remaining -= separatorBytes + boundedBytes;
1191
+ if (remaining <= 0)
1192
+ break;
1193
+ }
1194
+ structuredContent = boundedOutput;
690
1195
  break;
691
1196
  }
692
1197
  case "run_command_batch": {
@@ -697,7 +1202,24 @@ async function callTool(name, args, root, npmAllowed, allowedCommands, notify, s
697
1202
  if (value.exitCode !== 0)
698
1203
  break;
699
1204
  }
700
- structuredContent = output;
1205
+ const maxBytes = outputBytesArg(args.maxBytes);
1206
+ const boundedOutput = [];
1207
+ let remaining = maxBytes - 2;
1208
+ for (const item of output) {
1209
+ const separatorBytes = boundedOutput.length ? 1 : 0;
1210
+ if (remaining <= separatorBytes)
1211
+ break;
1212
+ const budget = remaining - separatorBytes;
1213
+ const bounded = boundCommandResult(item, budget);
1214
+ const boundedBytes = Buffer.byteLength(JSON.stringify(bounded), "utf8");
1215
+ if (boundedBytes > budget)
1216
+ break;
1217
+ boundedOutput.push(bounded);
1218
+ remaining -= separatorBytes + boundedBytes;
1219
+ if (remaining <= 0)
1220
+ break;
1221
+ }
1222
+ structuredContent = boundedOutput;
701
1223
  break;
702
1224
  }
703
1225
  default:
@@ -830,6 +1352,69 @@ function objectArray(value, name) {
830
1352
  throw new Error(`${name} must be an array of objects.`);
831
1353
  return value;
832
1354
  }
1355
+ function outputBytesArg(value) {
1356
+ const result = value === undefined ? DEFAULT_OUTPUT_BYTES : Number(value);
1357
+ if (!Number.isInteger(result) || result < 1024 || result > 1_000_000)
1358
+ throw new Error("maxBytes must be an integer between 1024 and 1000000.");
1359
+ return result;
1360
+ }
1361
+ function truncateText(value, maxBytes) {
1362
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
1363
+ return { text: value, truncated: false };
1364
+ if (maxBytes <= 32)
1365
+ return { text: "", truncated: true };
1366
+ const buffer = Buffer.from(value, "utf8");
1367
+ return {
1368
+ text: `${buffer.subarray(0, Math.max(0, maxBytes - 32)).toString("utf8")}\n…[truncated]`,
1369
+ truncated: true,
1370
+ };
1371
+ }
1372
+ function boundCommandResult(value, maxBytes) {
1373
+ if (!isRecord(value))
1374
+ return value;
1375
+ const command = typeof value.command === "string" ? value.command : "";
1376
+ const stdout = typeof value.stdout === "string" ? value.stdout : "";
1377
+ const stderr = typeof value.stderr === "string" ? value.stderr : "";
1378
+ let commandBudget = Buffer.byteLength(command, "utf8");
1379
+ let stdoutBudget = Buffer.byteLength(stdout, "utf8");
1380
+ let stderrBudget = Buffer.byteLength(stderr, "utf8");
1381
+ const candidate = () => ({
1382
+ ...value,
1383
+ command: truncateText(command, commandBudget).text,
1384
+ stdout: truncateText(stdout, stdoutBudget).text,
1385
+ stderr: truncateText(stderr, stderrBudget).text,
1386
+ });
1387
+ let result = candidate();
1388
+ while (Buffer.byteLength(JSON.stringify(result), "utf8") > maxBytes &&
1389
+ (commandBudget > 0 || stdoutBudget > 0 || stderrBudget > 0)) {
1390
+ const excess = Buffer.byteLength(JSON.stringify(result), "utf8") - maxBytes;
1391
+ if (stdoutBudget >= stderrBudget && stdoutBudget > 0)
1392
+ stdoutBudget = Math.max(0, stdoutBudget - excess);
1393
+ else if (stderrBudget > 0)
1394
+ stderrBudget = Math.max(0, stderrBudget - excess);
1395
+ else
1396
+ commandBudget = Math.max(0, commandBudget - excess);
1397
+ result = candidate();
1398
+ }
1399
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") <= maxBytes)
1400
+ return result;
1401
+ return { ...value, command: truncateText(command, commandBudget).text, stdout: "", stderr: "" };
1402
+ }
1403
+ function boundedItems(items, maxBytes) {
1404
+ const selected = [];
1405
+ let bytes = 2;
1406
+ for (const item of items) {
1407
+ const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf8");
1408
+ const separatorBytes = selected.length ? 1 : 0;
1409
+ if (selected.length && bytes + separatorBytes + itemBytes > maxBytes)
1410
+ break;
1411
+ if (!selected.length && bytes + itemBytes > maxBytes)
1412
+ break;
1413
+ selected.push(item);
1414
+ bytes += separatorBytes + itemBytes;
1415
+ }
1416
+ return { items: selected, truncated: selected.length < items.length };
1417
+ }
833
1418
  function numberArg(value, fallback, max) {
834
1419
  const result = value === undefined ? fallback : Number(value);
835
1420
  if (!Number.isInteger(result) || result < 1 || result > max)
@@ -862,11 +1447,40 @@ function decodeCursor(value) {
862
1447
  catch {
863
1448
  throw new Error("Invalid pagination cursor.");
864
1449
  }
1450
+ if (!/^(?:0|[1-9]\d*)$/.test(decoded))
1451
+ throw new Error("Invalid pagination cursor.");
865
1452
  const offset = Number(decoded);
866
1453
  if (!Number.isSafeInteger(offset) || offset < 0)
867
1454
  throw new Error("Invalid pagination cursor.");
868
1455
  return offset;
869
1456
  }
1457
+ async function patchFilesDryRun(root, files) {
1458
+ return Promise.all(files.map(async ({ path: relativePath, patches }) => {
1459
+ let content = await readFile(root, relativePath);
1460
+ for (const patch of patches) {
1461
+ if (!patch.search)
1462
+ throw new Error("Patch search text cannot be empty.");
1463
+ const occurrences = content.split(patch.search).length - 1;
1464
+ const count = patch.count ?? 1;
1465
+ if (occurrences === 0)
1466
+ throw new Error(`Patch text was not found in '${relativePath}'.`);
1467
+ if (!Number.isInteger(count) || count < 1)
1468
+ throw new Error("Patch count must be a positive integer.");
1469
+ if (occurrences < count)
1470
+ throw new Error(`Patch text occurs ${occurrences} time(s), but ${count} replacement(s) were requested.`);
1471
+ let offset = 0;
1472
+ for (let index = 0; index < count; index += 1) {
1473
+ const position = content.indexOf(patch.search, offset);
1474
+ content =
1475
+ content.slice(0, position) +
1476
+ patch.replace +
1477
+ content.slice(position + patch.search.length);
1478
+ offset = position + patch.replace.length;
1479
+ }
1480
+ }
1481
+ return { path: relativePath, applied: patches.length, content, dryRun: true };
1482
+ }));
1483
+ }
870
1484
  async function notifyResourceChange(notify, root, pathValue, before) {
871
1485
  const after = await skillResourceUrisForPath(root, pathValue);
872
1486
  if (before.length === 0 && after.length === 0)