@takara-ai/miru-code 1.10.1 → 1.12.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/README.md CHANGED
@@ -48,6 +48,7 @@ Plugin installs don't carry full CLI parity — what each one gives you depends
48
48
  | `miru` / Caveman / STE skills | ✅ | ✅ | ✅ |
49
49
  | Dedicated sub-agent (`miru:miru-code`) | ✅ | — | — |
50
50
  | Benchmark mode toggle (`/plugin configure`) | ✅ | — | — |
51
+ | Self-hosted SageMaker embeddings (`/plugin configure`) | ✅ | — | — |
51
52
  | Credentials | own plugin-scoped dir | own plugin-scoped dir | n/a |
52
53
 
53
54
  `—` means the IDE's plugin schema has no equivalent mechanism to port these to (not a packaging gap we can close): Codex's and Cursor's plugin manifests have no `agents` or `userConfig` fields, so the dedicated sub-agent and the benchmark toggle are Claude-Code-only.
@@ -323,7 +324,7 @@ Miru sends **file contents** to the [Takara inference API](https://takara.ai) wh
323
324
 
324
325
  If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. `MIRU_WORKSPACE_ROOT` is an opt-in boundary for MCP local `repo` paths only, and restricts indexing to a single workspace directory when set.
325
326
 
326
- Enterprise self-hosted embeddings (no Takara egress): see [docs/self-hosted-sagemaker.md](docs/self-hosted-sagemaker.md).
327
+ Enterprise self-hosted embeddings (no Takara egress): see [docs/self-hosted-sagemaker.md](docs/self-hosted-sagemaker.md), including the [Claude Code plugin settings](docs/self-hosted-sagemaker.md#claude-code-plugin).
327
328
 
328
329
  ## Benchmark mode
329
330
 
@@ -375,7 +376,7 @@ Override with `MIRU_BENCHMARK_HISTORY_PATH`. Append-only JSONL of compact token
375
376
  | `repo` | no | Startup directory by default; set for another repo |
376
377
  | `include` | no | Gitignore-style glob patterns; only matching files are searched |
377
378
  | `exclude` | no | Gitignore-style glob patterns; matching files are skipped |
378
- | `mode` | no | `count` · `locations` · `lines` (default). Prefer `count`/`locations` when possible |
379
+ | `mode` | no | `count` · `files` (unique paths) · `locations` · `lines` (default). Prefer `count`/`files`/`locations` when possible |
379
380
  | `limit` | no | Cap returned hits. Omit to return all matches |
380
381
  | `ignore_case` | no | Case-insensitive match (default `false`) |
381
382
 
@@ -174,6 +174,28 @@ After that, indexing and search embed via SageMaker — no egress to `infer.taka
174
174
 
175
175
  If `TAKARA_API_KEY` is still set in your shell or `.env.local`, remove it there too so it cannot conflict later.
176
176
 
177
+ ### Claude Code plugin
178
+
179
+ The plugin has no terminal step. Do steps 1–3 first, then set the plugin settings. Run `/plugin`, open **miru**, and set:
180
+
181
+ | Setting | Value |
182
+ | ------------------------ | ---------------------------------------------------------------------------------------- |
183
+ | `SageMaker endpoint ARN` | The `EndpointArn` output from step 2 |
184
+ | `AWS profile` | The profile from step 3. It must already exist in `~/.aws`; Miru never creates one. |
185
+
186
+ Or from a shell, then restart Claude Code:
187
+
188
+ ```bash
189
+ echo '{"sagemaker_endpoint_arn":"arn:aws:sagemaker:<region>:<account-id>:endpoint/<name>","aws_profile":"miru"}' \
190
+ | claude plugin configure miru@miru --values-stdin
191
+ ```
192
+
193
+ When the ARN is set, it **takes priority over any stored Takara sign-in** and Miru does not contact `infer.takara.ai`. Nothing is deleted, so clearing the ARN returns you to Takara. Leaving the ARN blank means Takara is used. The profile is only read when an ARN is set.
194
+
195
+ If the profile uses SSO, run `aws sso login --profile <name>` again whenever the token expires.
196
+
197
+ The plugin's `auth` tool only handles Takara sign-in; SageMaker is configured through these settings only.
198
+
177
199
  ## Switch back to Takara
178
200
 
179
201
  ```bash
@@ -184,6 +206,8 @@ Enter your Takara API key. Setup validates it, saves it, and **deletes any store
184
206
 
185
207
  Also unset any `MIRU_SAGEMAKER_*` variables if you set them in the environment or `.env.local`.
186
208
 
209
+ In the Claude Code plugin, clear the **SageMaker endpoint ARN** setting and restart.
210
+
187
211
  ## Environment alternatives
188
212
 
189
213
  Prefer `miru setup --sagemaker` so auth is checked automatically. You can also set:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "mcpName": "ai.takara/miru",
5
5
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
6
6
  "type": "module",
@@ -1,5 +1,4 @@
1
1
  ---
2
2
  name: miru-code
3
- description: Code search via Miru MCP. Use search, expand, and find_related. DO NOT use Grep, Glob, or Read for exploration when Miru MCP is available.
4
- tools: Bash, Read
3
+ description: Use proactively for any codebase exploration — "where is X handled", "how does Y work", "what calls Z", mapping an unfamiliar area, or finding the right file before an edit. Searches by meaning via Miru MCP instead of Grep/Glob/Read sweeps.
5
4
  ---
@@ -68,19 +68,6 @@ function mergeNode(node: Node, desiredLength: number): ChunkBoundary[] {
68
68
  return mergeAdjacentChunks(rawChunks, desiredLength);
69
69
  }
70
70
 
71
- function byteBoundariesToCharBoundaries(
72
- source: string,
73
- byteBoundaries: ChunkBoundary[],
74
- ): ChunkBoundary[] {
75
- const sourceBytes = new TextEncoder().encode(source);
76
- const decoder = new TextDecoder();
77
-
78
- return byteBoundaries.map((boundary) => ({
79
- start: decoder.decode(sourceBytes.subarray(0, boundary.start)).length,
80
- end: decoder.decode(sourceBytes.subarray(0, boundary.end)).length,
81
- }));
82
- }
83
-
84
71
  /** AST-aware chunk boundaries via vendored tree-sitter grammars; null to fall back. */
85
72
  export async function chunkAst(
86
73
  source: string,
@@ -129,8 +116,8 @@ function parseBoundaries(
129
116
  }
130
117
 
131
118
  try {
132
- const byteBoundaries = mergeNode(tree.rootNode, desiredLength);
133
- return byteBoundariesToCharBoundaries(source, byteBoundaries);
119
+ // web-tree-sitter reports UTF-16 string indices (not UTF-8 bytes), so boundaries slice `source` directly.
120
+ return mergeNode(tree.rootNode, desiredLength);
134
121
  } finally {
135
122
  tree.delete();
136
123
  }
@@ -1,11 +1,29 @@
1
1
  import type { Chunk } from "../types.ts";
2
2
  import { chunkAst } from "./ast.ts";
3
- import { chunkLines } from "./lines.ts";
3
+ import { type ChunkBoundary, chunkLines } from "./lines.ts";
4
4
  import { chunkStructural } from "./structural.ts";
5
5
 
6
6
  /** Match upstream hybrid-search chunking target length. */
7
7
  const DESIRED_CHUNK_LENGTH_CHARS = 1500;
8
8
 
9
+ /**
10
+ * Sibling AST nodes split mid-line leave their inter-token whitespace in no chunk, so rejoining
11
+ * the line later yields `exportasync`. Extend the earlier chunk across a same-line, whitespace-only
12
+ * gap; gaps containing a newline (between declarations) are left exactly as they were.
13
+ */
14
+ function closeSameLineGap(
15
+ source: string,
16
+ boundary: ChunkBoundary,
17
+ next: ChunkBoundary | undefined,
18
+ ): ChunkBoundary {
19
+ if (!next || next.start <= boundary.end) {
20
+ return boundary;
21
+ }
22
+ return /^[^\S\n]+$/.test(source.slice(boundary.end, next.start))
23
+ ? { start: boundary.start, end: next.start }
24
+ : boundary;
25
+ }
26
+
9
27
  export async function chunkSource(
10
28
  source: string,
11
29
  filePath: string,
@@ -26,7 +44,8 @@ export async function chunkSource(
26
44
  prefixNewlineCounts[i + 1] = prevCount + (source[i] === "\n" ? 1 : 0);
27
45
  }
28
46
 
29
- for (const boundary of boundaries) {
47
+ for (const [i, raw] of boundaries.entries()) {
48
+ const boundary = closeSameLineGap(source, raw, boundaries[i + 1]);
30
49
  const endIndex = Math.max(boundary.end - 1, boundary.start);
31
50
  const text = source.slice(boundary.start, endIndex + 1);
32
51
  chunks.push({
package/src/cli.ts CHANGED
@@ -541,8 +541,8 @@ async function runCli(argv: string[]): Promise<void> {
541
541
  const arg = contentRest[i];
542
542
  if (arg === "--mode" && contentRest[i + 1]) {
543
543
  const value = contentRest[++i];
544
- if (value !== "count" && value !== "locations" && value !== "lines") {
545
- fail(`Unknown locate mode "${value}". Use count, locations, or lines.`);
544
+ if (value !== "count" && value !== "files" && value !== "locations" && value !== "lines") {
545
+ fail(`Unknown locate mode "${value}". Use count, files, locations, or lines.`);
546
546
  process.exit(1);
547
547
  }
548
548
  options.mode = value as LiteralMode;
@@ -116,6 +116,32 @@ function clearTakaraEnv(): void {
116
116
  delete process.env.TAKARA_API_KEY;
117
117
  }
118
118
 
119
+ /** A plugin `userConfig` value, or undefined when blank or left as an unsubstituted `${...}`. */
120
+ function pluginConfigValue(name: string): string | undefined {
121
+ const value = process.env[name]?.trim();
122
+ return value && !isUnsubstitutedPlaceholder(value) ? value : undefined;
123
+ }
124
+
125
+ /**
126
+ * Plugin settings (`sagemaker_endpoint_arn` / `aws_profile` in plugin.json) arrive as
127
+ * MIRU_PLUGIN_* env vars. When an endpoint is configured there it is an explicit choice, so it
128
+ * wins over any stored credentials; Takara env is dropped because the modes are exclusive.
129
+ */
130
+ function applyPluginSageMakerConfig(): boolean {
131
+ const arn = pluginConfigValue("MIRU_PLUGIN_SAGEMAKER_ENDPOINT_ARN");
132
+ if (!arn) {
133
+ return false;
134
+ }
135
+ process.env.MIRU_SAGEMAKER_ENDPOINT_ARN = arn;
136
+ const profile = pluginConfigValue("MIRU_PLUGIN_AWS_PROFILE");
137
+ if (profile) {
138
+ process.env.AWS_PROFILE = profile;
139
+ }
140
+ clearTakaraEnv();
141
+ activeStoredToken = null;
142
+ return true;
143
+ }
144
+
119
145
  function hydrateSageMakerEnv(sagemaker: StoredSageMakerCredentials): void {
120
146
  process.env.MIRU_SAGEMAKER_ENDPOINT_ARN = sagemaker.endpoint_arn;
121
147
  if (sagemaker.profile && !process.env.AWS_PROFILE) {
@@ -144,6 +170,9 @@ function markLoadedToken(token: string): void {
144
170
  /** Hydrate TAKARA_API_KEY / SageMaker env vars from the credentials file when env is unset. */
145
171
  export async function loadStoredCredentials(): Promise<boolean> {
146
172
  normalizeTakaraApiKeyEnv();
173
+ if (applyPluginSageMakerConfig()) {
174
+ return true;
175
+ }
147
176
  const stored = await readStoredCredentials();
148
177
  if (!stored) {
149
178
  return false;
@@ -324,6 +324,8 @@ export interface EmbeddingClient {
324
324
  input: string[] | string,
325
325
  model: string,
326
326
  dimensions?: number,
327
+ /** True for search queries; sent as a tiny `X-Q` header. Indexing sends nothing. */
328
+ query?: boolean,
327
329
  ): Promise<EmbeddingResponse>;
328
330
  }
329
331
 
@@ -364,6 +366,7 @@ function createClient(fetchImpl: EmbeddingFetch = fetch): EmbeddingClient {
364
366
  input: string[] | string,
365
367
  model: string,
366
368
  dimensions?: number,
369
+ query?: boolean,
367
370
  ): Promise<EmbeddingResponse> {
368
371
  await loadStoredCredentials();
369
372
  const apiKey = resolveEmbeddingApiKey();
@@ -376,6 +379,7 @@ function createClient(fetchImpl: EmbeddingFetch = fetch): EmbeddingClient {
376
379
  headers: {
377
380
  Authorization: `Bearer ${apiKey}`,
378
381
  "Content-Type": "application/json",
382
+ ...(query ? { "X-Q": "1" } : {}),
379
383
  },
380
384
  body: JSON.stringify(body),
381
385
  });
@@ -429,11 +433,11 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
429
433
  }
430
434
 
431
435
  /** Embed pre-windowed inputs; issues a single API request (may 413-split). */
432
- async embedInputs(texts: string[]): Promise<Float32Array[]> {
436
+ async embedInputs(texts: string[], query = false): Promise<Float32Array[]> {
433
437
  if (texts.length === 0) {
434
438
  return [];
435
439
  }
436
- const vectors = await this.embedBatchRawWithRetry(texts.map(sanitizeEmbeddingInput));
440
+ const vectors = await this.embedBatchRawWithRetry(texts.map(sanitizeEmbeddingInput), query);
437
441
  for (const vec of vectors) {
438
442
  if (this.dimensions === 0 && vec.length > 0) {
439
443
  this.dimensions = vec.length;
@@ -442,7 +446,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
442
446
  return vectors;
443
447
  }
444
448
 
445
- async embedDocuments(texts: string[]): Promise<Float32Array[]> {
449
+ async embedDocuments(texts: string[], query = false): Promise<Float32Array[]> {
446
450
  if (texts.length === 0) {
447
451
  return [];
448
452
  }
@@ -455,7 +459,10 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
455
459
  const batches = batchEmbeddingWindowJobs(jobs, this.batchSize);
456
460
  const concurrency = resolveWorkerConcurrency();
457
461
  await mapPool(batches, concurrency, async (batch) => {
458
- const vectors = await this.embedInputs(batch.map((job) => job.text));
462
+ const vectors = await this.embedInputs(
463
+ batch.map((job) => job.text),
464
+ query,
465
+ );
459
466
  assignEmbeddingWindowVectors(batch, vectors, buckets);
460
467
  });
461
468
 
@@ -468,7 +475,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
468
475
  return out;
469
476
  }
470
477
 
471
- private async requestEmbeddings(texts: string[]): Promise<EmbeddingResponse> {
478
+ private async requestEmbeddings(texts: string[], query: boolean): Promise<EmbeddingResponse> {
472
479
  let lastErr: unknown;
473
480
  for (let attempt = 0; attempt <= MAX_TRANSIENT_EMBED_RETRIES; attempt++) {
474
481
  try {
@@ -477,6 +484,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
477
484
  texts,
478
485
  this.model,
479
486
  this.requestedDimensions,
487
+ query,
480
488
  );
481
489
  const elapsed = performance.now() - started;
482
490
  this.stats.requests += 1;
@@ -498,12 +506,13 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
498
506
  throw lastErr;
499
507
  }
500
508
 
501
- private async embedBatchRawWithRetry(texts: string[]): Promise<Float32Array[]> {
509
+ private async embedBatchRawWithRetry(texts: string[], query: boolean): Promise<Float32Array[]> {
502
510
  if (texts.length === 0) {
503
511
  return [];
504
512
  }
513
+ const retry = (batch: string[]) => this.embedBatchRawWithRetry(batch, query);
505
514
  try {
506
- const response = await this.requestEmbeddings(texts);
515
+ const response = await this.requestEmbeddings(texts, query);
507
516
  const vectors = vectorsFromResponse(response.data, texts.length);
508
517
  for (const vec of vectors) {
509
518
  if (this.requestedDimensions != null && vec.length !== this.requestedDimensions) {
@@ -525,8 +534,8 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
525
534
  this.stats.payloadTooLarge += 1;
526
535
  this.stats.retries += 1;
527
536
  const mid = Math.ceil(texts.length / 2);
528
- const left = await this.embedBatchRawWithRetry(texts.slice(0, mid));
529
- const right = await this.embedBatchRawWithRetry(texts.slice(mid));
537
+ const left = await retry(texts.slice(0, mid));
538
+ const right = await retry(texts.slice(mid));
530
539
  return [...left, ...right];
531
540
  }
532
541
  if (isPayloadTooLargeError(err) && texts.length === 1) {
@@ -544,8 +553,8 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
544
553
  const mid = Math.floor(text.length / 2);
545
554
  const leftText = text.slice(0, mid);
546
555
  const rightText = text.slice(mid);
547
- const left = await this.embedBatchRawWithRetry([leftText]);
548
- const right = await this.embedBatchRawWithRetry([rightText]);
556
+ const left = await retry([leftText]);
557
+ const right = await retry([rightText]);
549
558
  const parts = [left[0], right[0]].filter((vec): vec is Float32Array => vec !== undefined);
550
559
  if (parts.length === 0) {
551
560
  this.stats.errors += 1;
@@ -559,7 +568,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
559
568
  }
560
569
 
561
570
  async embedQuery(text: string): Promise<Float32Array> {
562
- const [vec] = await this.embedDocuments([text]);
571
+ const [vec] = await this.embedDocuments([text], true);
563
572
  if (!vec) {
564
573
  throw new Error("OpenAI returned no embedding for query");
565
574
  }
@@ -1,8 +1,14 @@
1
1
  /** Shared code-search policy text for instructions, Cursor rules, and sub-agents. */
2
2
 
3
3
  export const SNIPPET_GUIDANCE =
4
- "Search returns compact snippets (~±15 lines). On `truncated: true`, call `expand` with " +
5
- "`file_path` and `anchor_line` — only if the snippet doesn't already answer the question.";
4
+ "Snippets are ~±15 lines. On `truncated: true`, call `expand` with `file_path` and " +
5
+ "`anchor_line` — only if the snippet doesn't already answer.";
6
+
7
+ export const WHEN_TO_REACH_FOR_MIRU =
8
+ "Reach for Miru FIRST, before opening files, whenever you don't know the exact path: starting in unfamiliar code, " +
9
+ "finding where to edit, how/where/why questions, tracing callers or similar code, docs/config. " +
10
+ "`locate` also does regex (`/re/`) and lists files by name (`include` globs, no literal). " +
11
+ "Grep/Glob only for files Miru does not index (lockfiles, binaries, generated or git-ignored).";
6
12
 
7
13
  export interface NativeToolNames {
8
14
  explorationDenied: string;
@@ -14,6 +20,8 @@ export function buildSearchPolicyTable(native: NativeToolNames): string {
14
20
  return `| Task | Use | Not |
15
21
  |------|-----|-----|
16
22
  | Exact literal (identifier, quoted string, env var, error code) | Miru MCP \`locate\` | Miru \`search\`, ${native.grep} |
23
+ | Regex over code | Miru MCP \`locate\` with \`/regex/\` (\`mode=files\` for paths only) | ${native.grep} |
24
+ | List files by name pattern | Miru MCP \`locate\` with \`include\` globs and no literal | Glob |
17
25
  | How/where/what handles X? (conceptual) | Miru MCP \`search\` (once) | ${native.grep}, ${native.explorationDenied} |
18
26
  | Same file, more context | Miru MCP \`expand\` on \`truncated: true\` | Re-search, ${native.read} whole file |
19
27
  | Similar code elsewhere | Miru MCP \`find_related\` | ${native.grep} chains |
@@ -24,6 +32,8 @@ export function buildSearchPolicyTable(native: NativeToolNames): string {
24
32
  export function buildSearchPolicyBody(native: NativeToolNames): string {
25
33
  return `DO NOT use ${native.explorationDenied} to explore how code works when Miru MCP is available.
26
34
 
35
+ ${WHEN_TO_REACH_FOR_MIRU}
36
+
27
37
  **Check first:** exact token (identifier, quoted string, env var, error code) → \`locate\`. Otherwise → \`search\`.
28
38
 
29
39
  ${SNIPPET_GUIDANCE}
@@ -31,7 +41,7 @@ ${SNIPPET_GUIDANCE}
31
41
  \`repo\` is optional, set it for another repo.
32
42
 
33
43
  Use Miru MCP tools:
34
- - \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
44
+ - \`locate\` — exact substring (env var, symbol, error code) or \`/regex/\`; no literal + \`include\` globs lists files; prefer \`mode=count\`, \`files\` or \`locations\`
35
45
  - \`search\` — one call per conceptual question; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
36
46
  - \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
37
47
  - \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
@@ -49,6 +59,7 @@ Stop rules:
49
59
  Native tools are allowed ONLY when:
50
60
  - reading a file you already located via Miru, to edit it
51
61
  - searching outside the indexed repo
62
+ - files Miru does not index (lockfiles, binaries, generated or git-ignored files)
52
63
 
53
64
  ${buildSearchPolicyTable(native)}`;
54
65
  }
@@ -116,14 +127,14 @@ export const SUBAGENT_BODY = buildSubagentBody(DEFAULT_NATIVE_TOOLS);
116
127
 
117
128
  export const MCP_SERVER_INSTRUCTIONS =
118
129
  "Miru `search` is your default for all code search queries in indexed repos — the best, fastest, and cheapest way to find code; better than any other tool. " +
119
- "DO NOT use Grep, grep_search, codebase_search, Glob, SemanticSearch, or Read to explore code when this server is available. " +
130
+ `${WHEN_TO_REACH_FOR_MIRU} ` +
131
+ "DO NOT use Grep, grep_search, codebase_search, Glob, SemanticSearch, or Read to explore code while this server is available. " +
120
132
  "Exact token (identifier, quoted string, env var, error code) → `locate`; everything else → `search` once, no paraphrase retries. " +
121
133
  `${SNIPPET_GUIDANCE} ` +
122
- "`find_related` traces similar code elsewhere, not more context in the same file. " +
123
- "`repo` is optional, good for multi repo workflows. " +
124
- "Native Grep/Glob only outside the indexed repo or for non-code tasks. " +
125
- "Default indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
126
- "On credential errors, call this server's `auth` tool — not the host `mcp_auth` tool.";
134
+ "`find_related` finds similar code elsewhere. " +
135
+ "`repo` is optional (other repos). " +
136
+ "Indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
137
+ "On credential errors, call this server's `auth` tool, not the host `mcp_auth`.";
127
138
 
128
139
  export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
129
140
  `${MCP_SERVER_INSTRUCTIONS} ` +
@@ -135,22 +146,17 @@ export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
135
146
  "To leave benchmark mode, tell the user to run `miru benchmark off` and restart the agent.";
136
147
 
137
148
  export const MCP_SEARCH_TOOL_DESCRIPTION =
149
+ "Semantic codebase search: find code by meaning (where/how/what handles X, callers, related code, docs, config) instead of grepping or reading files. Use FIRST when you don't know the exact path. " +
138
150
  "Your default search for all code search queries in this indexed repo — the best, fastest, and cheapest way to find code; better than any other tool. " +
139
- "Returns up to 5 compact snippets (~±15 lines). One call per question. Use `include` / `exclude` Gitignore-style globs to scope files; these work the same way on `locate`. " +
140
- "Exact literal (env var, symbol, error code, quoted text)? Use `locate` instead. " +
141
- "On `truncated: true`, call `expand` — only if the snippet doesn't already answer the question.";
151
+ "Up to 5 snippets (~±15 lines), one call per question; exact literals → `locate`. " +
152
+ "On `truncated: true`, `expand` only if the snippet doesn't already answer.";
142
153
 
143
154
  export const MCP_LOCATE_TOOL_DESCRIPTION =
144
- "Exact substring locator over the Miru index. Use for known literals (env vars, symbols, error codes, quoted text) — not meaning-based questions (`search`), " +
145
- 'even ones phrased as "where is X" or "what handles X" if X is a literal. ' +
146
- "Returns ALL matches by default as compact {n,files,hits} — do NOT fall back to Grep/rg when n is large. " +
147
- "Prefer mode=locations (or count for totals only); use lines when you need matching line text. " +
148
- "Optional `limit` only if you intentionally want a sample. " +
149
- "Pass an array to `literal` for several spellings in one call. " +
150
- '`match_variants: true` also matches other casings (e.g. "rateLimit" also finds "rate_limit"). ' +
151
- "`include`/`exclude` (gitignore-style globs) scope to part of a monorepo; " +
152
- "`context_lines` in `lines` mode gets inline context instead of a follow-up `expand`. " +
153
- "In benchmark mode, includes compact token savings vs agent Grep.";
155
+ "Exact substring locator over the Miru index, also `/regex/` and file lists (grep for identifiers, env vars, error strings, quoted text, without the noise). " +
156
+ 'Use for known literals, not meaning-based questions (`search`), even when phrased "where is X" if X is a literal. ' +
157
+ "A literal in slashes (`/foo\\w+Bar/`, `/…/i`) is a regex. No literal + `include` globs lists files by name pattern; mode=files returns just matching paths. " +
158
+ "Returns ALL matches by default — do NOT fall back to Grep/rg when n is large; prefer mode=count/files/locations unless you need line text. " +
159
+ "`context_lines` (mode=lines) saves a follow-up `expand`. In benchmark mode, includes token savings vs Grep.";
154
160
 
155
161
  export const MCP_EXPAND_TOOL_DESCRIPTION =
156
162
  "More context in the SAME file as a search hit. Pass `file_path` + `anchor_line` from the hit. " +
package/src/literal.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import ignore from "ignore";
4
4
  import type { Chunk } from "./types.ts";
5
5
 
6
- export type LiteralMode = "count" | "locations" | "lines";
6
+ export type LiteralMode = "count" | "files" | "locations" | "lines";
7
7
 
8
8
  export const DEFAULT_LITERAL_MODE: LiteralMode = "lines";
9
9
 
@@ -31,6 +31,8 @@ export interface LiteralLocateResult {
31
31
  /** True only when an explicit `limit` cut the hit list short. */
32
32
  truncated: boolean;
33
33
  hits: LiteralHit[];
34
+ /** Sorted unique repo-relative paths. Present only in `files` mode and for file listings. */
35
+ file_list?: string[];
34
36
  }
35
37
 
36
38
  export interface LiteralLocateOptions {
@@ -54,6 +56,26 @@ export interface LiteralLocateOptions {
54
56
  context_lines?: number;
55
57
  }
56
58
 
59
+ /** A `/body/` (optionally `/body/i`) literal is a regex only if the body has regex syntax, so `/api/users/` stays a plain substring. */
60
+ const SLASH_REGEX = /^\/(.+)\/(i?)$/s;
61
+ const REGEX_SYNTAX = /[\\^$.*+?()[\]{}|]/;
62
+ /** Regex is tested per line; cap the tested prefix so a minified line cannot stall the scan. */
63
+ const MAX_REGEX_LINE_CHARS = 4096;
64
+
65
+ function parseRegexLiteral(literal: string, ignoreCase: boolean): RegExp | null {
66
+ const m = SLASH_REGEX.exec(literal);
67
+ if (!m || !REGEX_SYNTAX.test(m[1] ?? "")) {
68
+ return null;
69
+ }
70
+ try {
71
+ return new RegExp(m[1] ?? "", ignoreCase || m[2] === "i" ? "i" : "");
72
+ } catch (err) {
73
+ throw new Error(
74
+ `Invalid regex ${literal}: ${err instanceof Error ? err.message : String(err)}`,
75
+ );
76
+ }
77
+ }
78
+
57
79
  function resolveLimit(limit: number | undefined): number | null {
58
80
  if (limit == null || !Number.isFinite(limit)) {
59
81
  return null;
@@ -61,6 +83,18 @@ function resolveLimit(limit: number | undefined): number | null {
61
83
  return Math.max(1, Math.floor(limit));
62
84
  }
63
85
 
86
+ /** True when any literal would be treated as a regex (so fixed-string baselines are not comparable). */
87
+ export function hasRegexLiteral(literal: string | readonly string[]): boolean {
88
+ return (Array.isArray(literal) ? literal : [literal]).some(
89
+ (lit) => parseRegexLiteral(lit, false) !== null,
90
+ );
91
+ }
92
+
93
+ function regexMatchesLine(line: string, regexes: readonly RegExp[]): boolean {
94
+ const tested = line.length > MAX_REGEX_LINE_CHARS ? line.slice(0, MAX_REGEX_LINE_CHARS) : line;
95
+ return regexes.some((re) => re.test(tested));
96
+ }
97
+
64
98
  function lineMatchesAny(
65
99
  haystack: string,
66
100
  needles: readonly string[],
@@ -147,6 +181,46 @@ export function filterChunksByGlob(
147
181
  });
148
182
  }
149
183
 
184
+ /**
185
+ * The AST chunker can split one source line across two adjacent chunks (it divides large
186
+ * declarations on token boundaries), so neither fragment alone contains the full line. Rejoin
187
+ * chunks that share a boundary line before scanning, or matches spanning the split are lost.
188
+ */
189
+ export function stitchSplitLines(chunks: readonly Chunk[]): readonly Chunk[] {
190
+ const byFile = new Map<string, Chunk[]>();
191
+ for (const chunk of chunks) {
192
+ const list = byFile.get(chunk.file_path);
193
+ if (list) {
194
+ list.push(chunk);
195
+ } else {
196
+ byFile.set(chunk.file_path, [chunk]);
197
+ }
198
+ }
199
+ const stitched: Chunk[] = [];
200
+ for (const list of byFile.values()) {
201
+ list.sort((a, b) => a.start_line - b.start_line || a.end_line - b.end_line);
202
+ let current = list[0];
203
+ if (!current) {
204
+ continue;
205
+ }
206
+ for (let i = 1; i < list.length; i++) {
207
+ const next = list[i] as Chunk;
208
+ if (next.start_line === current.end_line && next.end_line > current.start_line) {
209
+ current = {
210
+ ...current,
211
+ content: current.content + next.content,
212
+ end_line: Math.max(current.end_line, next.end_line),
213
+ };
214
+ } else {
215
+ stitched.push(current);
216
+ current = next;
217
+ }
218
+ }
219
+ stitched.push(current);
220
+ }
221
+ return stitched;
222
+ }
223
+
150
224
  /**
151
225
  * Scan indexed chunks for an exact substring (or, with an array `literal`/`match_variants`,
152
226
  * any of several OR-matched substrings). Dedupes by file:line when chunks overlap.
@@ -168,13 +242,27 @@ export function locateLiteral(
168
242
 
169
243
  const inputLiterals = (Array.isArray(literal) ? literal : [literal]).filter((l) => l.length > 0);
170
244
  const label = inputLiterals.join(" | ");
171
- const needles = expandLiterals(inputLiterals, options.match_variants === true);
245
+ const regexes: RegExp[] = [];
246
+ const plainLiterals: string[] = [];
247
+ for (const lit of inputLiterals) {
248
+ const re = parseRegexLiteral(lit, ignoreCase);
249
+ if (re) {
250
+ regexes.push(re);
251
+ } else {
252
+ plainLiterals.push(lit);
253
+ }
254
+ }
255
+ const needles = expandLiterals(plainLiterals, options.match_variants === true);
256
+ const matchesLine = (text: string): boolean =>
257
+ lineMatchesAny(text, needles, ignoreCase) || regexMatchesLine(text, regexes);
172
258
 
173
- if (needles.length === 0) {
259
+ if (needles.length === 0 && regexes.length === 0) {
174
260
  return { literal: label, mode, n: 0, files: 0, truncated: false, hits: [] };
175
261
  }
176
262
 
177
- const scopedChunks = filterChunksByGlob(chunks, options.include, options.exclude);
263
+ const scopedChunks = stitchSplitLines(
264
+ filterChunksByGlob(chunks, options.include, options.exclude),
265
+ );
178
266
 
179
267
  const seen = new Set<string>();
180
268
  const fileSet = new Set<string>();
@@ -182,13 +270,13 @@ export function locateLiteral(
182
270
  let n = 0;
183
271
 
184
272
  for (const chunk of scopedChunks) {
185
- if (!lineMatchesAny(chunk.content, needles, ignoreCase)) {
273
+ if (regexes.length === 0 && !lineMatchesAny(chunk.content, needles, ignoreCase)) {
186
274
  continue;
187
275
  }
188
276
  const lines = chunk.content.split("\n");
189
277
  for (let i = 0; i < lines.length; i++) {
190
278
  const lineText = lines[i] ?? "";
191
- if (!lineMatchesAny(lineText, needles, ignoreCase)) {
279
+ if (!matchesLine(lineText)) {
192
280
  continue;
193
281
  }
194
282
  const line = chunk.start_line + i;
@@ -199,7 +287,7 @@ export function locateLiteral(
199
287
  seen.add(key);
200
288
  fileSet.add(chunk.file_path);
201
289
  n += 1;
202
- if (mode === "count") {
290
+ if (mode === "count" || mode === "files") {
203
291
  continue;
204
292
  }
205
293
  if (limit != null && hits.length >= limit) {
@@ -224,15 +312,44 @@ export function locateLiteral(
224
312
  mode,
225
313
  n,
226
314
  files: fileSet.size,
227
- truncated: mode !== "count" && limit != null && n > hits.length,
228
- hits: mode === "count" ? [] : hits,
315
+ truncated: mode !== "count" && mode !== "files" && limit != null && n > hits.length,
316
+ hits: mode === "count" || mode === "files" ? [] : hits,
229
317
  };
230
- if (needles.length > 1) {
231
- result.literals = needles;
318
+ if (mode === "files") {
319
+ result.file_list = [...fileSet].sort();
320
+ }
321
+ const searched = [...needles, ...inputLiterals.filter((l) => !plainLiterals.includes(l))];
322
+ if (searched.length > 1) {
323
+ result.literals = searched;
232
324
  }
233
325
  return result;
234
326
  }
235
327
 
328
+ /**
329
+ * List indexed files, optionally scoped by gitignore-style `include`/`exclude` globs
330
+ * (the `locate` answer when no literal is given). Only indexed files appear.
331
+ */
332
+ export function listIndexedFiles(
333
+ chunks: readonly Chunk[],
334
+ options: Pick<LiteralLocateOptions, "include" | "exclude" | "mode"> = {},
335
+ ): LiteralLocateResult {
336
+ const mode = options.mode === "count" ? "count" : "files";
337
+ const files = [
338
+ ...new Set(
339
+ filterChunksByGlob(chunks, options.include, options.exclude).map((c) => c.file_path),
340
+ ),
341
+ ].sort();
342
+ return {
343
+ literal: "",
344
+ mode,
345
+ n: files.length,
346
+ files: files.length,
347
+ truncated: false,
348
+ hits: [],
349
+ ...(mode === "files" ? { file_list: files } : {}),
350
+ };
351
+ }
352
+
236
353
  /** Compact agent-facing JSON (minimal keys). */
237
354
  export function formatLiteralLocate(result: LiteralLocateResult): Record<string, unknown> {
238
355
  const base: Record<string, unknown> = {
@@ -249,6 +366,11 @@ export function formatLiteralLocate(result: LiteralLocateResult): Record<string,
249
366
  return base;
250
367
  }
251
368
 
369
+ if (result.mode === "files") {
370
+ base.paths = result.file_list ?? [];
371
+ return base;
372
+ }
373
+
252
374
  if (result.truncated) {
253
375
  base.truncated = true;
254
376
  }
@@ -34,12 +34,10 @@ const RECOVERY_HINT =
34
34
  "signing in, check the account token balance.";
35
35
 
36
36
  const AUTH_TOOL_DESCRIPTION =
37
- "Sign in with Takara credentials via device-code login — no terminal required. " +
38
- "Only call this in direct response to a tool error mentioning missing, expired, rejected, or " +
39
- "invalid credentials — never speculatively, since it starts a real sign-in prompt for the " +
40
- "user. Call with no arguments: it opens the device-login page in the user's browser, then " +
41
- "waits (up to about 45 seconds) for them to approve and returns once they have. If it " +
42
- 'returns "still waiting", tell the user the URL and code and call `auth` again.';
37
+ "Sign in with Takara device-code login. Only call in direct response to a tool error about " +
38
+ "missing, expired, rejected, or invalid credentials — never speculatively: it starts a real " +
39
+ "sign-in prompt. No arguments: opens the login page in the user's browser and waits up to ~45s " +
40
+ 'for approval. If it returns "still waiting", tell the user the URL and code and call `auth` again.';
43
41
 
44
42
  /** Browser opener seam; the real one spawns a detached `open`/`xdg-open`/`start`. */
45
43
  export type BrowserOpener = (url: string) => boolean;
@@ -207,7 +205,7 @@ export function registerAuthTool(
207
205
  .enum(["start", "check"])
208
206
  .optional()
209
207
  .describe(
210
- '"start" begins or resumes a device-code login and waits for approval (default); "check" is a single non-blocking poll.',
208
+ '"start" begins/resumes login and waits for approval (default); "check" polls once.',
211
209
  ),
212
210
  },
213
211
  },
@@ -1,3 +1,4 @@
1
+ import { hasRegexLiteral } from "../literal.ts";
1
2
  import type { ExpandResults } from "../utils.ts";
2
3
 
3
4
  /** Plain-text rendering of `formatResults()` output — same info as the JSON shape, no scaffolding. */
@@ -21,6 +22,39 @@ export function formatResultsText(payload: {
21
22
  return blocks.join("\n\n");
22
23
  }
23
24
 
25
+ /** Very long lines (minified bundles, lockfiles) cost far more than they inform. */
26
+ const MAX_LINE_CHARS = 400;
27
+
28
+ function clipLine(text: string): string {
29
+ return text.length > MAX_LINE_CHARS
30
+ ? `${text.slice(0, MAX_LINE_CHARS)}… [+${text.length - MAX_LINE_CHARS} chars]`
31
+ : text;
32
+ }
33
+
34
+ /** Group sorted repo-relative paths by directory so each directory prints once. */
35
+ export function formatPathList(paths: readonly string[]): string {
36
+ const byDir = new Map<string, string[]>();
37
+ for (const path of paths) {
38
+ const slash = path.lastIndexOf("/");
39
+ const dir = slash < 0 ? "./" : path.slice(0, slash + 1);
40
+ const names = byDir.get(dir);
41
+ const name = slash < 0 ? path : path.slice(slash + 1);
42
+ if (names) {
43
+ names.push(name);
44
+ } else {
45
+ byDir.set(dir, [name]);
46
+ }
47
+ }
48
+ // A directory holding one match prints as a plain path; grouping only pays off for two or more.
49
+ return [...byDir]
50
+ .map(([dir, names]) =>
51
+ names.length === 1
52
+ ? `${dir === "./" ? "" : dir}${names[0]}`
53
+ : `${dir}\n ${names.join("\n ")}`,
54
+ )
55
+ .join("\n");
56
+ }
57
+
24
58
  /** Plain-text rendering of `formatLiteralLocate()` output. */
25
59
  export function formatLiteralLocateText(payload: Record<string, unknown>): string {
26
60
  const label =
@@ -28,7 +62,19 @@ export function formatLiteralLocateText(payload: Record<string, unknown>): strin
28
62
  const n = payload.n as number;
29
63
  const files = payload.files as number;
30
64
  const truncatedNote = payload.truncated ? " (truncated — pass a narrower include/exclude)" : "";
31
- const header = `"${label}": ${n} match${n === 1 ? "" : "es"} across ${files} file${files === 1 ? "" : "s"}${truncatedNote}`;
65
+ const fileCount = `${files} file${files === 1 ? "" : "s"}`;
66
+ // Short plain literals are worth echoing; a regex the caller just sent is not (it tokenizes poorly).
67
+ const counts = `${n} match${n === 1 ? "" : "es"} across ${fileCount}${truncatedNote}`;
68
+ const header = !label
69
+ ? fileCount
70
+ : hasRegexLiteral(label.split(" | "))
71
+ ? counts
72
+ : `"${label}": ${counts}`;
73
+
74
+ const paths = payload.paths as string[] | undefined;
75
+ if (paths) {
76
+ return paths.length === 0 ? header : `${header}\n\n${formatPathList(paths)}`;
77
+ }
32
78
 
33
79
  const hits = payload.hits as
34
80
  | Array<{ f: string; l: number; t?: string; ctx?: string[]; ctx_l?: number }>
@@ -38,12 +84,40 @@ export function formatLiteralLocateText(payload: Record<string, unknown>): strin
38
84
  }
39
85
 
40
86
  const lines = [header, ""];
87
+ if (hits.some((hit) => hit.ctx != null)) {
88
+ for (const hit of hits) {
89
+ lines.push(
90
+ hit.t !== undefined ? `${hit.f}:${hit.l}: ${clipLine(hit.t)}` : `${hit.f}:${hit.l}`,
91
+ );
92
+ if (hit.ctx != null && hit.ctx_l !== undefined) {
93
+ const ctxStart = hit.ctx_l;
94
+ for (let i = 0; i < hit.ctx.length; i++) {
95
+ lines.push(` ${ctxStart + i}: ${clipLine(hit.ctx[i] ?? "")}`);
96
+ }
97
+ }
98
+ }
99
+ return lines.join("\n");
100
+ }
101
+
102
+ // Each path once: `path` then indented `line: text`, or `path: 3, 9, 40` without text.
103
+ const byFile = new Map<string, Array<{ l: number; t?: string }>>();
41
104
  for (const hit of hits) {
42
- lines.push(hit.t !== undefined ? `${hit.f}:${hit.l}: ${hit.t}` : `${hit.f}:${hit.l}`);
43
- if (hit.ctx != null && hit.ctx_l !== undefined) {
44
- const ctxStart = hit.ctx_l;
45
- for (let i = 0; i < hit.ctx.length; i++) {
46
- lines.push(` ${ctxStart + i}: ${hit.ctx[i]}`);
105
+ const group = byFile.get(hit.f);
106
+ if (group) {
107
+ group.push({ l: hit.l, t: hit.t });
108
+ } else {
109
+ byFile.set(hit.f, [{ l: hit.l, t: hit.t }]);
110
+ }
111
+ }
112
+ for (const [file, group] of byFile) {
113
+ if (group.every((hit) => hit.t === undefined)) {
114
+ lines.push(`${file}: ${group.map((hit) => hit.l).join(", ")}`);
115
+ } else if (group.length === 1) {
116
+ lines.push(`${file}:${group[0]?.l}: ${clipLine(group[0]?.t ?? "")}`);
117
+ } else {
118
+ lines.push(file);
119
+ for (const hit of group) {
120
+ lines.push(` ${hit.l}: ${clipLine(hit.t ?? "")}`);
47
121
  }
48
122
  }
49
123
  }
@@ -24,6 +24,7 @@ type ToolHandler<Args> = (args: Args) => Promise<{
24
24
 
25
25
  type RegisteredTool = {
26
26
  description: string;
27
+ meta?: Record<string, unknown>;
27
28
  inputSchema: Record<string, z.ZodType>;
28
29
  handler: ToolHandler<Record<string, unknown>>;
29
30
  };
@@ -49,11 +50,13 @@ export class MiruMcpServer {
49
50
  config: {
50
51
  description: string;
51
52
  inputSchema: S;
53
+ _meta?: Record<string, unknown>;
52
54
  },
53
55
  handler: ToolHandler<z.infer<z.ZodObject<S>>>,
54
56
  ): void {
55
57
  this.tools.set(name, {
56
58
  description: config.description,
59
+ meta: config._meta,
57
60
  inputSchema: config.inputSchema,
58
61
  handler: handler as ToolHandler<Record<string, unknown>>,
59
62
  });
@@ -163,6 +166,7 @@ export class MiruMcpServer {
163
166
  name,
164
167
  description: tool.description,
165
168
  inputSchema: shapeToJsonSchema(tool.inputSchema),
169
+ ...(tool.meta ? { _meta: tool.meta } : {}),
166
170
  }));
167
171
  }
168
172
 
@@ -234,8 +238,32 @@ function isNotification(message: JsonRpcMessage): message is { jsonrpc: "2.0"; m
234
238
  return "method" in message && !("id" in message);
235
239
  }
236
240
 
241
+ /**
242
+ * Trim schema noise that costs tokens on every session but tells the model nothing: zod emits
243
+ * Number.MAX_SAFE_INTEGER / MIN_SAFE_INTEGER bounds for every `.int()`, plus `minLength: 1` and
244
+ * `minItems: 1` on non-empty strings and arrays. Validation still runs in zod at call time.
245
+ */
246
+ function stripImplicitIntegerBounds(node: unknown): unknown {
247
+ if (Array.isArray(node)) {
248
+ return node.map(stripImplicitIntegerBounds);
249
+ }
250
+ if (node && typeof node === "object") {
251
+ return Object.fromEntries(
252
+ Object.entries(node as Record<string, unknown>)
253
+ .filter(
254
+ ([key, value]) =>
255
+ !(key === "maximum" && value === Number.MAX_SAFE_INTEGER) &&
256
+ !(key === "minimum" && value === Number.MIN_SAFE_INTEGER) &&
257
+ !((key === "minLength" || key === "minItems") && value === 1),
258
+ )
259
+ .map(([key, value]) => [key, stripImplicitIntegerBounds(value)]),
260
+ );
261
+ }
262
+ return node;
263
+ }
264
+
237
265
  function shapeToJsonSchema(shape: Record<string, z.ZodType>): Record<string, unknown> {
238
266
  const schema = z.toJSONSchema(z.object(shape)) as Record<string, unknown>;
239
267
  const { $schema: _schema, ...rest } = schema;
240
- return rest;
268
+ return stripImplicitIntegerBounds(rest) as Record<string, unknown>;
241
269
  }
package/src/mcp/server.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  MCP_SEARCH_TOOL_DESCRIPTION,
22
22
  MCP_SERVER_INSTRUCTIONS,
23
23
  } from "../installer/search-policy.ts";
24
- import { formatLiteralLocate } from "../literal.ts";
24
+ import { formatLiteralLocate, hasRegexLiteral, listIndexedFiles } from "../literal.ts";
25
25
  import type { Chunk, ContentType, SearchResult } from "../types.ts";
26
26
  import {
27
27
  DEFAULT_EXPAND_AFTER,
@@ -44,8 +44,7 @@ import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
44
44
  import { MiruMcpServer } from "./runtime.ts";
45
45
 
46
46
  const REPO_DESCRIPTION =
47
- "`repo` is optional. Set a local path or Git URL for another repo. " +
48
- "The index is built on the first tool call and cached for the session.";
47
+ "`repo` is optional: local path or Git URL (default: the server's directory).";
49
48
 
50
49
  const BENCHMARK_SKIP_NOTES = {
51
50
  local_repo_only: "Benchmark comparisons require a local repo path; git URL repos are skipped.",
@@ -55,6 +54,8 @@ const BENCHMARK_SKIP_NOTES = {
55
54
  "Benchmark comparison skipped: locate.limit is global, while rg/grep limits are per file. Omit limit for a valid token and recall comparison.",
56
55
  incompatible_literal_baseline:
57
56
  "Benchmark comparison skipped: a comparable literal baseline requires rg or compatible grep. Windows findstr does not preserve locate scope, context, or count semantics.",
57
+ regex_or_file_list:
58
+ "Benchmark comparison skipped: regex and file-list locate calls have no equivalent fixed-string rg baseline.",
58
59
  grep_timeout:
59
60
  "Benchmark Grep baseline timed out; Miru results are still returned. " +
60
61
  "Raise MIRU_BENCHMARK_SEARCH_TIMEOUT (seconds) if needed.",
@@ -118,6 +119,9 @@ async function persistBenchmarkQuery(
118
119
  }
119
120
  }
120
121
 
122
+ /** Claude Code defers MCP tools behind ToolSearch by default; keep Miru visible next to Grep/Glob/Read, including to subagents. */
123
+ const LOAD_UPFRONT_META = { "anthropic/alwaysLoad": true };
124
+
121
125
  export function createMcpServer(
122
126
  cache: IndexCache,
123
127
  options?: {
@@ -155,32 +159,24 @@ export function createMcpServer(
155
159
  server.registerTool(
156
160
  "search",
157
161
  {
158
- description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes the selected directory or URL on first call; later calls reuse the session cache.`,
162
+ description: MCP_SEARCH_TOOL_DESCRIPTION,
163
+ _meta: LOAD_UPFRONT_META,
159
164
  inputSchema: {
160
- query: z
161
- .string()
162
- .describe(
163
- "Natural language or code query — your default for all code search in this repo.",
164
- ),
165
+ query: z.string().describe("Natural language or code query."),
165
166
  repo: z.string().optional().describe(REPO_DESCRIPTION),
166
167
  include: z
167
168
  .array(z.string().min(1))
168
169
  .min(1)
169
170
  .optional()
170
171
  .describe(
171
- 'Gitignore-style glob patterns; only matching files are searched (e.g. ["src/auth/**", "docs/**/*.md"]). Same semantics as `locate.include`.',
172
+ 'Gitignore-style globs; only matching files are searched, e.g. ["src/auth/**"]. Same as `locate.include`.',
172
173
  ),
173
174
  exclude: z
174
175
  .array(z.string().min(1))
175
176
  .min(1)
176
177
  .optional()
177
- .describe(
178
- "Gitignore-style glob patterns; matching files are skipped. Same semantics as `locate.exclude`.",
179
- ),
180
- dedupe_by_file: z
181
- .boolean()
182
- .optional()
183
- .describe("Keep only the best hit per file (default true)."),
178
+ .describe("Gitignore-style globs; matching files are skipped. Same as `locate.exclude`."),
179
+ dedupe_by_file: z.boolean().optional().describe("One best hit per file (default true)."),
184
180
  },
185
181
  },
186
182
  async ({ query, repo, include, exclude, dedupe_by_file: dedupeByFile }) => {
@@ -248,26 +244,24 @@ export function createMcpServer(
248
244
  "locate",
249
245
  {
250
246
  description: MCP_LOCATE_TOOL_DESCRIPTION,
247
+ _meta: LOAD_UPFRONT_META,
251
248
  inputSchema: {
252
249
  literal: z
253
250
  .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
254
251
  .optional()
255
252
  .describe(
256
- "Exact substring to find (env var, symbol, error code, quoted text). " +
257
- "Pass an array to OR-match several substrings (e.g. spelling/casing variants) in one call.",
253
+ "Exact substring, or a regex in slashes like `/foo\\w+Bar/` (`/…/i` ignores case). An array ORs several. Omit and pass `include` to list files.",
258
254
  ),
259
255
  query: z
260
256
  .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
261
257
  .optional()
262
- .describe(
263
- "Alias for `literal` — accepted so a call shaped like `search(query=...)` still resolves as an exact-substring lookup.",
264
- ),
258
+ .describe("Alias for `literal`."),
265
259
  repo: z.string().optional().describe(REPO_DESCRIPTION),
266
260
  mode: z
267
- .enum(["count", "locations", "lines"])
261
+ .enum(["count", "files", "locations", "lines"])
268
262
  .optional()
269
263
  .describe(
270
- "count=totals only; locations=path:line; lines=path:line+text (default). Prefer count/locations when possible.",
264
+ "count=totals; files=unique paths; locations=path:lines; lines=path:line+text (default).",
271
265
  ),
272
266
  limit: z
273
267
  .number()
@@ -275,46 +269,56 @@ export function createMcpServer(
275
269
  .min(1)
276
270
  .optional()
277
271
  .describe(
278
- "Optional cap on returned hits. Omit to return ALL matches (recommended). Do not fall back to Grep if n is large — use mode=count or locations instead.",
272
+ "Cap on hits. Omit for ALL matches (recommended); if n is large use mode=count/files, not Grep.",
279
273
  ),
280
- ignore_case: z.boolean().optional().describe("Case-insensitive match (default false)."),
274
+ ignore_case: z.boolean().optional().describe("Case-insensitive (default false)."),
281
275
  match_variants: z
282
276
  .boolean()
283
277
  .optional()
284
- .describe(
285
- 'Also match other ways the same word might be written in code, e.g. "rateLimit" also finds "rate_limit" and "RATE_LIMIT".',
286
- ),
278
+ .describe('Also match other casings, e.g. "rateLimit" finds "rate_limit", "RATE_LIMIT".'),
287
279
  include: z
288
280
  .array(z.string().min(1))
289
281
  .optional()
290
282
  .describe(
291
- 'Gitignore-style glob patterns; only matching files are searched (e.g. "apps/tldr/**/*.go").',
283
+ 'Gitignore-style globs; only matching files are searched, e.g. ["apps/**/*.go"].',
292
284
  ),
293
285
  exclude: z
294
286
  .array(z.string().min(1))
295
287
  .optional()
296
- .describe("Gitignore-style glob patterns; matching files are skipped."),
288
+ .describe("Gitignore-style globs; matching files are skipped."),
297
289
  context_lines: z
298
290
  .number()
299
291
  .int()
300
292
  .min(0)
301
293
  .optional()
302
- .describe("Lines of context before/after each match, like `grep -C` (mode=lines only)."),
294
+ .describe("Context lines around each match (mode=lines only)."),
303
295
  },
304
296
  },
305
297
  async ({ literal, query, repo, ...locateOpts }) => {
306
298
  const lit = literal ?? query;
307
- if (lit == null) {
308
- return toolErrorText(new Error("locate requires `literal` (exact substring to find)."));
299
+ if (lit == null && !locateOpts.include?.length) {
300
+ return toolErrorText(
301
+ new Error(
302
+ "locate requires `literal` (exact substring or /regex/), or `include` globs to list files.",
303
+ ),
304
+ );
309
305
  }
310
306
  try {
311
307
  const source = repo ?? defaultRepo;
312
308
  const index = await getIndexForRepo(source, cache);
313
309
 
310
+ if (lit == null) {
311
+ const listing = listIndexedFiles(index.chunks, locateOpts);
312
+ const body = formatLiteralLocateText(formatLiteralLocate(listing));
313
+ return toolText(benchmark ? withBenchmarkSkippedNote(body, "regex_or_file_list") : body);
314
+ }
315
+
314
316
  let skip: BenchmarkSkipReason | undefined;
315
317
  const repoRoot = localRepoRoot(source);
316
318
  if (benchmark && repoRoot && typeof lit === "string") {
317
- if (locateOpts.limit != null) {
319
+ if (locateOpts.mode === "files" || hasRegexLiteral(lit)) {
320
+ skip = "regex_or_file_list";
321
+ } else if (locateOpts.limit != null) {
318
322
  skip = "limited_locate";
319
323
  } else if (!selectLiteralBaseline()) {
320
324
  skip = "incompatible_literal_baseline";
@@ -353,27 +357,26 @@ export function createMcpServer(
353
357
  "expand",
354
358
  {
355
359
  description: MCP_EXPAND_TOOL_DESCRIPTION,
360
+ _meta: LOAD_UPFRONT_META,
356
361
  inputSchema: {
357
- file_path: z
358
- .string()
359
- .describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
362
+ file_path: z.string().describe("Path from a search hit."),
360
363
  anchor_line: z
361
364
  .number()
362
365
  .int()
363
- .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
366
+ .describe("Line from the hit (`anchor_line` if truncated, else `start_line`)."),
364
367
  repo: z.string().optional().describe(REPO_DESCRIPTION),
365
368
  before: z
366
369
  .number()
367
370
  .int()
368
371
  .min(0)
369
372
  .optional()
370
- .describe(`Extra chunks before the anchor (default ${DEFAULT_EXPAND_BEFORE}).`),
373
+ .describe(`Extra chunks before (default ${DEFAULT_EXPAND_BEFORE}).`),
371
374
  after: z
372
375
  .number()
373
376
  .int()
374
377
  .min(0)
375
378
  .optional()
376
- .describe(`Extra chunks after the anchor (default ${DEFAULT_EXPAND_AFTER}).`),
379
+ .describe(`Extra chunks after (default ${DEFAULT_EXPAND_AFTER}).`),
377
380
  },
378
381
  },
379
382
  async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
@@ -415,14 +418,13 @@ export function createMcpServer(
415
418
  "find_related",
416
419
  {
417
420
  description: MCP_FIND_RELATED_TOOL_DESCRIPTION,
421
+ _meta: LOAD_UPFRONT_META,
418
422
  inputSchema: {
419
- file_path: z
420
- .string()
421
- .describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
423
+ file_path: z.string().describe("Path from a search hit."),
422
424
  anchor_line: z
423
425
  .number()
424
426
  .int()
425
- .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
427
+ .describe("Line from the hit (`anchor_line` if truncated, else `start_line`)."),
426
428
  repo: z.string().optional().describe(REPO_DESCRIPTION),
427
429
  },
428
430
  },