@zokizuan/satori-mcp 3.0.0 → 3.1.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
@@ -7,13 +7,14 @@ MCP server for Satori — agent-safe semantic code search and indexing.
7
7
  - Capability-driven execution via `CapabilityResolver`
8
8
  - Runtime-first `search_codebase` with explicit `scope`, `resultMode`, `groupBy`, and optional `debug` traces
9
9
  - First-class `call_graph` tool with deterministic node/edge sorting and TS/Python support
10
+ - Sidecar-backed `file_outline` tool for per-file symbol navigation and direct call_graph jump handles
10
11
  - Snapshot v3 safety with index fingerprints and strict `requires_reindex` access gates
11
12
  - Deterministic train-in-the-error responses for incompatible or legacy index states
12
13
  - Query-time exclusion support with `.gitignore`-style matching
13
14
  - Structured search telemetry logs (`[TELEMETRY]` JSON to `stderr`)
14
15
  - Zod-first tool schemas converted to MCP JSON Schema for `ListTools`
15
16
  - Auto-generated tool docs from live tool schemas
16
- - `read_file` line-range retrieval with default large-file truncation guard
17
+ - `read_file` line-range retrieval with default large-file truncation guard and optional `mode="annotated"` metadata envelope
17
18
  - Optional proactive sync watcher mode (debounced filesystem events)
18
19
  - Index-time AST scope breadcrumbs (TS/JS/Python) rendered in search output as `🧬 Scope`
19
20
  - Fingerprint schema `dense_v3`/`hybrid_v3` with hard gate for all pre-v3 indexes
@@ -24,19 +25,20 @@ MCP server for Satori — agent-safe semantic code search and indexing.
24
25
  [MCP Client]
25
26
  -> [index.ts bootstrap + ListTools/CallTool]
26
27
  -> [tool registry]
27
- -> [manage_index | search_codebase | call_graph | read_file | list_codebases]
28
+ -> [manage_index | search_codebase | call_graph | file_outline | read_file | list_codebases]
28
29
  -> [ToolContext DI]
29
30
  -> [CapabilityResolver]
30
31
  -> [SnapshotManager v3 + access gate]
31
32
  -> [Context / Vector store / Embedding / Reranker adapters]
32
33
  ```
33
34
 
34
- Tool surface is hard-broken to 5 tools. This keeps routing explicit while exposing call-chain traversal as a first-class operation.
35
+ Tool surface is hard-broken to 6 tools. This keeps routing explicit while exposing call-chain traversal and file-level navigation as first-class operations.
35
36
 
36
37
  ## read_file Behavior
37
38
 
38
39
  - Supports optional `start_line` and `end_line` (1-based, inclusive)
39
40
  - When no range is provided and file length exceeds `READ_FILE_MAX_LINES` (default `1000`), output is truncated and includes a continuation hint with `path` and next `start_line`
41
+ - Optional `mode="annotated"` returns content plus `outlineStatus`, `outline`, `hasMore`, and reindex hints when sidecar data is unavailable
40
42
 
41
43
  ## Proactive Sync
42
44
 
@@ -93,6 +95,18 @@ Traverse the prebuilt TS/Python call graph sidecar for callers/callees/bidirecti
93
95
  | `depth` | integer | no | `1` | Traversal depth (max 3). |
94
96
  | `limit` | integer | no | `20` | Maximum number of returned edges. |
95
97
 
98
+ ### `file_outline`
99
+
100
+ Return a sidecar-backed symbol outline for one file, including call_graph jump handles.
101
+
102
+ | Parameter | Type | Required | Default | Description |
103
+ |---|---|---|---|---|
104
+ | `path` | string | yes | | ABSOLUTE path to the indexed codebase root. |
105
+ | `file` | string | yes | | Relative file path inside the codebase root. |
106
+ | `start_line` | integer | no | | Optional start line filter (1-based, inclusive). |
107
+ | `end_line` | integer | no | | Optional end line filter (1-based, inclusive). |
108
+ | `limitSymbols` | integer | no | `500` | Maximum number of returned symbols after line filtering. |
109
+
96
110
  ### `read_file`
97
111
 
98
112
  Read file content from the local filesystem, with optional 1-based inclusive line ranges and safe truncation.
@@ -102,6 +116,7 @@ Read file content from the local filesystem, with optional 1-based inclusive lin
102
116
  | `path` | string | yes | | ABSOLUTE path to the file. |
103
117
  | `start_line` | integer | no | | Optional start line (1-based, inclusive). |
104
118
  | `end_line` | integer | no | | Optional end line (1-based, inclusive). |
119
+ | `mode` | enum("plain", "annotated") | no | `"plain"` | Output mode. plain returns text only; annotated returns content plus sidecar-backed outline metadata. |
105
120
 
106
121
  ### `list_codebases`
107
122
 
@@ -2,6 +2,7 @@ import { Context } from "@zokizuan/satori-core";
2
2
  import { SnapshotManager } from "./snapshot.js";
3
3
  import { SyncManager } from "./sync.js";
4
4
  import { IndexFingerprint } from "../config.js";
5
+ import { FileOutlineInput } from "./search-types.js";
5
6
  import { CallGraphSidecarManager } from "./call-graph.js";
6
7
  export declare class ToolHandlers {
7
8
  private context;
@@ -26,6 +27,7 @@ export declare class ToolHandlers {
26
27
  private getBreadcrumbMergeKey;
27
28
  private formatScopeLine;
28
29
  private normalizeSearchPath;
30
+ private hasPathSegment;
29
31
  private isTestPath;
30
32
  private isDocPath;
31
33
  private isGeneratedPath;
@@ -39,6 +41,11 @@ export declare class ToolHandlers {
39
41
  private buildFallbackGroupId;
40
42
  private isCallGraphLanguageSupported;
41
43
  private buildCallGraphHint;
44
+ private normalizeRelativeFilePath;
45
+ private buildRequiresReindexFileOutlinePayload;
46
+ private getOutlineStatusForLanguage;
47
+ private sortFileOutlineSymbols;
48
+ private buildSearchPassWarning;
42
49
  private mapCallGraphStatus;
43
50
  private getContextIgnorePatterns;
44
51
  private rebuildCallGraphForIndex;
@@ -115,9 +122,33 @@ export declare class ToolHandlers {
115
122
  excludedBySubdirectory: number;
116
123
  filterPass: "initial" | "expanded";
117
124
  freshnessMode: string | undefined;
125
+ searchPassCount: number;
126
+ searchPassSuccessCount: number;
127
+ searchPassFailureCount: number;
118
128
  };
119
129
  };
120
130
  isError?: undefined;
131
+ } | {
132
+ content: {
133
+ type: string;
134
+ text: string;
135
+ }[];
136
+ isError: boolean;
137
+ meta: {
138
+ searchDiagnostics: {
139
+ queryLength: number;
140
+ limitRequested: number;
141
+ resultsBeforeFilter: number;
142
+ resultsAfterFilter: number;
143
+ excludedByIgnore: number;
144
+ excludedBySubdirectory: number;
145
+ filterPass: "initial" | "expanded";
146
+ freshnessMode: string | undefined;
147
+ searchPassCount: number;
148
+ searchPassSuccessCount: number;
149
+ searchPassFailureCount: number;
150
+ };
151
+ };
121
152
  } | {
122
153
  content: {
123
154
  type: string;
@@ -126,6 +157,19 @@ export declare class ToolHandlers {
126
157
  isError?: undefined;
127
158
  meta?: undefined;
128
159
  }>;
160
+ handleFileOutline(args: FileOutlineInput): Promise<{
161
+ content: {
162
+ type: string;
163
+ text: string;
164
+ }[];
165
+ isError: boolean;
166
+ } | {
167
+ content: {
168
+ type: string;
169
+ text: string;
170
+ }[];
171
+ isError?: undefined;
172
+ }>;
129
173
  handleCallGraph(args: any): Promise<{
130
174
  content: {
131
175
  type: string;
@@ -328,15 +328,24 @@ export class ToolHandlers {
328
328
  normalizeSearchPath(relativePath) {
329
329
  return relativePath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
330
330
  }
331
+ hasPathSegment(normalizedPath, segment) {
332
+ return normalizedPath === segment
333
+ || normalizedPath.startsWith(`${segment}/`)
334
+ || normalizedPath.includes(`/${segment}/`);
335
+ }
331
336
  isTestPath(normalizedPath) {
332
- return normalizedPath.includes('/test/')
333
- || normalizedPath.includes('/tests/')
334
- || normalizedPath.includes('/__tests__/')
337
+ return this.hasPathSegment(normalizedPath, 'test')
338
+ || this.hasPathSegment(normalizedPath, 'tests')
339
+ || this.hasPathSegment(normalizedPath, '__tests__')
335
340
  || /\.test\.[^/]+$/.test(normalizedPath)
336
341
  || /\.spec\.[^/]+$/.test(normalizedPath);
337
342
  }
338
343
  isDocPath(normalizedPath) {
339
- return normalizedPath.includes('/docs/')
344
+ return this.hasPathSegment(normalizedPath, 'docs')
345
+ || this.hasPathSegment(normalizedPath, 'doc')
346
+ || this.hasPathSegment(normalizedPath, 'documentation')
347
+ || this.hasPathSegment(normalizedPath, 'guide')
348
+ || this.hasPathSegment(normalizedPath, 'guides')
340
349
  || normalizedPath.endsWith('.md')
341
350
  || normalizedPath.endsWith('.mdx')
342
351
  || normalizedPath.endsWith('.rst')
@@ -449,6 +458,46 @@ export class ToolHandlers {
449
458
  }
450
459
  };
451
460
  }
461
+ normalizeRelativeFilePath(relativeFilePath) {
462
+ return relativeFilePath.replace(/\\/g, '/').replace(/^\.\/+/, '').trim();
463
+ }
464
+ buildRequiresReindexFileOutlinePayload(codebasePath, input, detail) {
465
+ const detailLine = detail ? `${detail}\n\n` : '';
466
+ return {
467
+ status: 'requires_reindex',
468
+ path: codebasePath,
469
+ file: input.file,
470
+ outline: null,
471
+ hasMore: false,
472
+ message: `${detailLine}Call graph sidecar is missing or incompatible. Please run manage_index with {"action":"reindex","path":"${codebasePath}"}.`,
473
+ hints: {
474
+ reindex: this.buildReindexHint(codebasePath)
475
+ }
476
+ };
477
+ }
478
+ getOutlineStatusForLanguage(relativeFilePath) {
479
+ if (this.isCallGraphLanguageSupported('unknown', relativeFilePath)) {
480
+ return 'ok';
481
+ }
482
+ return 'unsupported';
483
+ }
484
+ sortFileOutlineSymbols(symbols) {
485
+ return [...symbols].sort((a, b) => {
486
+ const startCmp = this.compareNullableNumbersAsc(a.span?.startLine, b.span?.startLine);
487
+ if (startCmp !== 0)
488
+ return startCmp;
489
+ const endCmp = this.compareNullableNumbersAsc(a.span?.endLine, b.span?.endLine);
490
+ if (endCmp !== 0)
491
+ return endCmp;
492
+ const labelCmp = this.compareNullableStringsAsc(a.symbolLabel, b.symbolLabel);
493
+ if (labelCmp !== 0)
494
+ return labelCmp;
495
+ return this.compareNullableStringsAsc(a.symbolId, b.symbolId);
496
+ });
497
+ }
498
+ buildSearchPassWarning(passId) {
499
+ return `SEARCH_PASS_FAILED:${passId} - ${passId} semantic search pass failed; results may be degraded.`;
500
+ }
452
501
  mapCallGraphStatus(graph) {
453
502
  if (graph.supported) {
454
503
  return 'ok';
@@ -1228,6 +1277,9 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
1228
1277
  excludedBySubdirectory: 0,
1229
1278
  filterPass: 'expanded',
1230
1279
  freshnessMode: undefined,
1280
+ searchPassCount: 0,
1281
+ searchPassSuccessCount: 0,
1282
+ searchPassFailureCount: 0,
1231
1283
  };
1232
1284
  try {
1233
1285
  await this.syncIndexedCodebasesFromCloud();
@@ -1329,8 +1381,40 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
1329
1381
  console.log(`[SEARCH] 🧠 Using embedding provider: ${encoderEngine.getProvider()} for search`);
1330
1382
  const candidateLimit = Math.max(1, Math.min(SEARCH_MAX_CANDIDATES, Math.max(input.limit * 8, 32)));
1331
1383
  const expandedQuery = `${input.query}\nimplementation runtime source entrypoint`;
1332
- const passOne = await this.context.semanticSearch(effectiveRoot, input.query, candidateLimit, 0.3);
1333
- const passTwo = await this.context.semanticSearch(effectiveRoot, expandedQuery, candidateLimit, 0.3);
1384
+ const passDescriptors = [
1385
+ { id: 'primary', query: input.query },
1386
+ { id: 'expanded', query: expandedQuery },
1387
+ ];
1388
+ searchDiagnostics.searchPassCount = passDescriptors.length;
1389
+ const passSettled = await Promise.allSettled(passDescriptors.map((pass) => {
1390
+ return this.context.semanticSearch(effectiveRoot, pass.query, candidateLimit, 0.3);
1391
+ }));
1392
+ const searchWarnings = [];
1393
+ const successfulPasses = [];
1394
+ for (let idx = 0; idx < passSettled.length; idx++) {
1395
+ const passResult = passSettled[idx];
1396
+ const passDescriptor = passDescriptors[idx];
1397
+ if (passResult.status === 'fulfilled' && Array.isArray(passResult.value)) {
1398
+ successfulPasses.push({
1399
+ id: passDescriptor.id,
1400
+ results: passResult.value
1401
+ });
1402
+ continue;
1403
+ }
1404
+ searchWarnings.push(this.buildSearchPassWarning(passDescriptor.id));
1405
+ }
1406
+ searchDiagnostics.searchPassSuccessCount = successfulPasses.length;
1407
+ searchDiagnostics.searchPassFailureCount = searchDiagnostics.searchPassCount - successfulPasses.length;
1408
+ if (successfulPasses.length === 0) {
1409
+ return {
1410
+ content: [{
1411
+ type: "text",
1412
+ text: "Error searching code: all semantic search passes failed. Please retry and verify embedding/vector backends are reachable."
1413
+ }],
1414
+ isError: true,
1415
+ meta: { searchDiagnostics }
1416
+ };
1417
+ }
1334
1418
  const byChunkKey = new Map();
1335
1419
  const addPass = (results, passWeight = 1) => {
1336
1420
  for (let i = 0; i < results.length; i++) {
@@ -1359,8 +1443,9 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
1359
1443
  }
1360
1444
  }
1361
1445
  };
1362
- addPass(passOne, 1);
1363
- addPass(passTwo, 1);
1446
+ for (const pass of successfulPasses) {
1447
+ addPass(pass.results, 1);
1448
+ }
1364
1449
  const beforeFilter = byChunkKey.size;
1365
1450
  const scored = [];
1366
1451
  for (const candidate of byChunkKey.values()) {
@@ -1424,6 +1509,7 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
1424
1509
  limit: input.limit,
1425
1510
  resultMode: "raw",
1426
1511
  freshnessDecision,
1512
+ ...(searchWarnings.length > 0 ? { warnings: searchWarnings } : {}),
1427
1513
  results: rawResults
1428
1514
  };
1429
1515
  return {
@@ -1521,6 +1607,7 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
1521
1607
  limit: input.limit,
1522
1608
  resultMode: "grouped",
1523
1609
  freshnessDecision,
1610
+ ...(searchWarnings.length > 0 ? { warnings: searchWarnings } : {}),
1524
1611
  results: groupedResults.slice(0, input.limit)
1525
1612
  };
1526
1613
  return {
@@ -1544,6 +1631,197 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
1544
1631
  };
1545
1632
  }
1546
1633
  }
1634
+ async handleFileOutline(args) {
1635
+ const limitSymbols = Number.isFinite(args?.limitSymbols)
1636
+ ? Math.max(1, Number(args.limitSymbols))
1637
+ : 500;
1638
+ const requestedStartLine = Number.isFinite(args?.start_line) ? Math.max(1, Number(args.start_line)) : undefined;
1639
+ const requestedEndLine = Number.isFinite(args?.end_line) ? Math.max(1, Number(args.end_line)) : undefined;
1640
+ try {
1641
+ await this.syncIndexedCodebasesFromCloud();
1642
+ const absoluteRoot = ensureAbsolutePath(args.path);
1643
+ const normalizedFile = this.normalizeRelativeFilePath(args.file);
1644
+ const absoluteFile = path.resolve(absoluteRoot, normalizedFile);
1645
+ const relativeToRoot = path.relative(absoluteRoot, absoluteFile);
1646
+ if (!fs.existsSync(absoluteRoot)) {
1647
+ return {
1648
+ content: [{
1649
+ type: "text",
1650
+ text: `Error: Path '${absoluteRoot}' does not exist.`
1651
+ }],
1652
+ isError: true
1653
+ };
1654
+ }
1655
+ const rootStat = fs.statSync(absoluteRoot);
1656
+ if (!rootStat.isDirectory()) {
1657
+ return {
1658
+ content: [{
1659
+ type: "text",
1660
+ text: `Error: Path '${absoluteRoot}' is not a directory`
1661
+ }],
1662
+ isError: true
1663
+ };
1664
+ }
1665
+ if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
1666
+ return {
1667
+ content: [{
1668
+ type: "text",
1669
+ text: `Error: File '${normalizedFile}' must be inside codebase root '${absoluteRoot}'.`
1670
+ }],
1671
+ isError: true
1672
+ };
1673
+ }
1674
+ trackCodebasePath(absoluteRoot);
1675
+ const blockedRoot = this.getMatchingBlockedRoot(absoluteRoot);
1676
+ if (blockedRoot) {
1677
+ const payload = this.buildRequiresReindexFileOutlinePayload(blockedRoot.path, {
1678
+ ...args,
1679
+ file: normalizedFile
1680
+ }, blockedRoot.message);
1681
+ return {
1682
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1683
+ };
1684
+ }
1685
+ const gateResult = this.enforceFingerprintGate(absoluteRoot);
1686
+ if (gateResult.blockedResponse) {
1687
+ const payload = this.buildRequiresReindexFileOutlinePayload(absoluteRoot, {
1688
+ ...args,
1689
+ file: normalizedFile
1690
+ }, gateResult.message);
1691
+ return {
1692
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1693
+ };
1694
+ }
1695
+ const sidecarInfo = this.snapshotManager.getCodebaseCallGraphSidecar(absoluteRoot);
1696
+ if (!sidecarInfo || sidecarInfo.version !== 'v3') {
1697
+ const payload = this.buildRequiresReindexFileOutlinePayload(absoluteRoot, {
1698
+ ...args,
1699
+ file: normalizedFile
1700
+ });
1701
+ return {
1702
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1703
+ };
1704
+ }
1705
+ if (!fs.existsSync(absoluteFile)) {
1706
+ const payload = {
1707
+ status: 'not_found',
1708
+ path: absoluteRoot,
1709
+ file: normalizedFile,
1710
+ outline: null,
1711
+ hasMore: false,
1712
+ message: `File '${normalizedFile}' does not exist under codebase root '${absoluteRoot}'.`
1713
+ };
1714
+ return {
1715
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1716
+ };
1717
+ }
1718
+ const fileStat = fs.statSync(absoluteFile);
1719
+ if (!fileStat.isFile()) {
1720
+ const payload = {
1721
+ status: 'not_found',
1722
+ path: absoluteRoot,
1723
+ file: normalizedFile,
1724
+ outline: null,
1725
+ hasMore: false,
1726
+ message: `'${normalizedFile}' is not a file.`
1727
+ };
1728
+ return {
1729
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1730
+ };
1731
+ }
1732
+ const languageStatus = this.getOutlineStatusForLanguage(normalizedFile);
1733
+ if (languageStatus !== 'ok') {
1734
+ const payload = {
1735
+ status: 'unsupported',
1736
+ path: absoluteRoot,
1737
+ file: normalizedFile,
1738
+ outline: null,
1739
+ hasMore: false,
1740
+ message: `File '${normalizedFile}' is not supported for sidecar outline. Supported extensions: .ts, .tsx, .py.`
1741
+ };
1742
+ return {
1743
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1744
+ };
1745
+ }
1746
+ const sidecar = this.callGraphManager.loadSidecar(absoluteRoot);
1747
+ if (!sidecar) {
1748
+ const payload = this.buildRequiresReindexFileOutlinePayload(absoluteRoot, {
1749
+ ...args,
1750
+ file: normalizedFile
1751
+ });
1752
+ return {
1753
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1754
+ };
1755
+ }
1756
+ const windowStart = requestedStartLine;
1757
+ const windowEnd = requestedEndLine && requestedStartLine
1758
+ ? Math.max(requestedEndLine, requestedStartLine)
1759
+ : requestedEndLine;
1760
+ const byFile = sidecar.nodes.filter((node) => this.normalizeRelativeFilePath(node.file) === normalizedFile);
1761
+ const windowed = byFile.filter((node) => {
1762
+ if (!windowStart && !windowEnd) {
1763
+ return true;
1764
+ }
1765
+ const startsBeforeWindowEnd = windowEnd === undefined || node.span.startLine <= windowEnd;
1766
+ const endsAfterWindowStart = windowStart === undefined || node.span.endLine >= windowStart;
1767
+ return startsBeforeWindowEnd && endsAfterWindowStart;
1768
+ });
1769
+ const symbols = this.sortFileOutlineSymbols(windowed.map((node) => {
1770
+ const symbolLabel = (typeof node.symbolLabel === 'string' && node.symbolLabel.trim().length > 0)
1771
+ ? node.symbolLabel
1772
+ : node.symbolId;
1773
+ return {
1774
+ symbolId: node.symbolId,
1775
+ symbolLabel,
1776
+ span: {
1777
+ startLine: node.span.startLine,
1778
+ endLine: node.span.endLine
1779
+ },
1780
+ callGraphHint: {
1781
+ supported: true,
1782
+ symbolRef: {
1783
+ file: normalizedFile,
1784
+ symbolId: node.symbolId,
1785
+ symbolLabel,
1786
+ span: {
1787
+ startLine: node.span.startLine,
1788
+ endLine: node.span.endLine
1789
+ }
1790
+ }
1791
+ }
1792
+ };
1793
+ }));
1794
+ const hasMore = symbols.length > limitSymbols;
1795
+ const missingSymbolMetadataCount = sidecar.notes.filter((note) => {
1796
+ return note.type === 'missing_symbol_metadata' && this.normalizeRelativeFilePath(note.file) === normalizedFile;
1797
+ }).length;
1798
+ const warnings = missingSymbolMetadataCount > 0
1799
+ ? [`OUTLINE_MISSING_SYMBOL_METADATA:${missingSymbolMetadataCount}`]
1800
+ : undefined;
1801
+ const payload = {
1802
+ status: 'ok',
1803
+ path: absoluteRoot,
1804
+ file: normalizedFile,
1805
+ outline: {
1806
+ symbols: symbols.slice(0, limitSymbols)
1807
+ },
1808
+ hasMore,
1809
+ ...(warnings ? { warnings } : {})
1810
+ };
1811
+ return {
1812
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1813
+ };
1814
+ }
1815
+ catch (error) {
1816
+ return {
1817
+ content: [{
1818
+ type: "text",
1819
+ text: `Error building file outline: ${error?.message || error}`
1820
+ }],
1821
+ isError: true
1822
+ };
1823
+ }
1824
+ }
1547
1825
  async handleCallGraph(args) {
1548
1826
  const rawDirection = args?.direction;
1549
1827
  const direction = rawDirection === 'callers' || rawDirection === 'callees' || rawDirection === 'both'
@@ -67,6 +67,7 @@ interface SearchBaseResponseEnvelope {
67
67
  freshnessDecision: FreshnessDecision | {
68
68
  mode: "skipped_requires_reindex";
69
69
  } | null;
70
+ warnings?: string[];
70
71
  message?: string;
71
72
  hints?: Record<string, unknown>;
72
73
  }
@@ -88,5 +89,34 @@ export interface SearchRequestInput {
88
89
  limit: number;
89
90
  debug?: boolean;
90
91
  }
92
+ export interface FileOutlineInput {
93
+ path: string;
94
+ file: string;
95
+ start_line?: number;
96
+ end_line?: number;
97
+ limitSymbols?: number;
98
+ }
99
+ export type FileOutlineStatus = "ok" | "not_found" | "requires_reindex" | "unsupported";
100
+ export interface FileOutlineSymbolResult {
101
+ symbolId: string;
102
+ symbolLabel: string;
103
+ span: SearchSpan;
104
+ callGraphHint: {
105
+ supported: true;
106
+ symbolRef: CallGraphSymbolRef;
107
+ };
108
+ }
109
+ export interface FileOutlineResponseEnvelope {
110
+ status: FileOutlineStatus;
111
+ path: string;
112
+ file: string;
113
+ outline: {
114
+ symbols: FileOutlineSymbolResult[];
115
+ } | null;
116
+ hasMore: boolean;
117
+ warnings?: string[];
118
+ message?: string;
119
+ hints?: Record<string, unknown>;
120
+ }
91
121
  export {};
92
122
  //# sourceMappingURL=search-types.d.ts.map
@@ -11,6 +11,10 @@ export interface SearchTelemetryEvent {
11
11
  reranker_used: boolean;
12
12
  latency_ms: number;
13
13
  freshness_mode?: string;
14
+ search_pass_count?: number;
15
+ search_pass_success_count?: number;
16
+ search_pass_failure_count?: number;
17
+ parallel_fanout?: boolean;
14
18
  error?: string;
15
19
  }
16
20
  export declare function emitSearchTelemetry(event: SearchTelemetryEvent): void;
@@ -0,0 +1,3 @@
1
+ import { McpTool } from './types.js';
2
+ export declare const fileOutlineTool: McpTool;
3
+ //# sourceMappingURL=file_outline.d.ts.map
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { formatZodError } from './types.js';
3
+ const fileOutlineInputSchema = z.object({
4
+ path: z.string().min(1).describe('ABSOLUTE path to the indexed codebase root.'),
5
+ file: z.string().min(1).describe('Relative file path inside the codebase root.'),
6
+ start_line: z.number().int().positive().optional().describe('Optional start line filter (1-based, inclusive).'),
7
+ end_line: z.number().int().positive().optional().describe('Optional end line filter (1-based, inclusive).'),
8
+ limitSymbols: z.number().int().positive().default(500).optional().describe('Maximum number of returned symbols after line filtering.'),
9
+ });
10
+ export const fileOutlineTool = {
11
+ name: 'file_outline',
12
+ description: () => 'Return a sidecar-backed symbol outline for one file, including call_graph jump handles.',
13
+ inputSchemaZod: () => fileOutlineInputSchema,
14
+ execute: async (args, ctx) => {
15
+ const parsed = fileOutlineInputSchema.safeParse(args || {});
16
+ if (!parsed.success) {
17
+ return {
18
+ content: [{
19
+ type: 'text',
20
+ text: formatZodError('file_outline', parsed.error)
21
+ }],
22
+ isError: true
23
+ };
24
+ }
25
+ return ctx.toolHandlers.handleFileOutline(parsed.data);
26
+ }
27
+ };
28
+ //# sourceMappingURL=file_outline.js.map
@@ -1,11 +1,13 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { z } from "zod";
3
4
  import { formatZodError } from "./types.js";
4
5
  import { ensureAbsolutePath } from "../utils.js";
5
6
  const readFileInputSchema = z.object({
6
7
  path: z.string().min(1).describe("ABSOLUTE path to the file."),
7
8
  start_line: z.number().int().positive().optional().describe("Optional start line (1-based, inclusive)."),
8
- end_line: z.number().int().positive().optional().describe("Optional end line (1-based, inclusive).")
9
+ end_line: z.number().int().positive().optional().describe("Optional end line (1-based, inclusive)."),
10
+ mode: z.enum(["plain", "annotated"]).default("plain").optional().describe("Output mode. plain returns text only; annotated returns content plus sidecar-backed outline metadata.")
9
11
  });
10
12
  function splitIntoLines(content) {
11
13
  if (content.length === 0) {
@@ -20,6 +22,32 @@ function splitIntoLines(content) {
20
22
  function clamp(value, min, max) {
21
23
  return Math.min(max, Math.max(min, value));
22
24
  }
25
+ function normalizeRelativePath(value) {
26
+ return value.replace(/\\/g, "/");
27
+ }
28
+ function isOutlineSupportedFile(absolutePath) {
29
+ const ext = path.extname(absolutePath).toLowerCase();
30
+ return ext === ".ts" || ext === ".tsx" || ext === ".py";
31
+ }
32
+ function resolveCodebaseRootForFile(absolutePath, ctx) {
33
+ const allCodebases = typeof ctx.snapshotManager?.getAllCodebases === "function"
34
+ ? ctx.snapshotManager.getAllCodebases()
35
+ : [];
36
+ if (!Array.isArray(allCodebases)) {
37
+ return undefined;
38
+ }
39
+ const matches = allCodebases
40
+ .map((item) => (item && typeof item.path === "string") ? ensureAbsolutePath(item.path) : undefined)
41
+ .filter((candidate) => Boolean(candidate))
42
+ .filter((candidate) => {
43
+ return absolutePath === candidate || absolutePath.startsWith(`${candidate}${path.sep}`);
44
+ });
45
+ if (matches.length === 0) {
46
+ return undefined;
47
+ }
48
+ matches.sort((a, b) => b.length - a.length);
49
+ return matches[0];
50
+ }
23
51
  export const readFileTool = {
24
52
  name: "read_file",
25
53
  description: () => "Read file content from the local filesystem, with optional 1-based inclusive line ranges and safe truncation.",
@@ -36,6 +64,7 @@ export const readFileTool = {
36
64
  };
37
65
  }
38
66
  const input = parsed.data;
67
+ const mode = input.mode || "plain";
39
68
  try {
40
69
  const absolutePath = ensureAbsolutePath(input.path);
41
70
  if (!fs.existsSync(absolutePath)) {
@@ -54,21 +83,17 @@ export const readFileTool = {
54
83
  const content = fs.readFileSync(absolutePath, "utf-8");
55
84
  const lines = splitIntoLines(content);
56
85
  const totalLines = lines.length;
57
- if (totalLines === 0) {
58
- return {
59
- content: [{
60
- type: "text",
61
- text: content
62
- }]
63
- };
64
- }
65
86
  const maxLines = Math.max(1, ctx.readFileMaxLines);
66
87
  const hasStart = input.start_line !== undefined;
67
88
  const hasEnd = input.end_line !== undefined;
68
89
  let startLine = 1;
69
- let endLine = totalLines;
90
+ let endLine = totalLines > 0 ? totalLines : 0;
70
91
  let addContinuationHint = false;
71
- if (!hasStart && !hasEnd) {
92
+ if (totalLines === 0) {
93
+ startLine = 1;
94
+ endLine = 0;
95
+ }
96
+ else if (!hasStart && !hasEnd) {
72
97
  if (totalLines > maxLines) {
73
98
  endLine = maxLines;
74
99
  addContinuationHint = true;
@@ -86,21 +111,87 @@ export const readFileTool = {
86
111
  startLine = clamp(input.start_line, 1, totalLines);
87
112
  endLine = clamp(input.end_line, startLine, totalLines);
88
113
  }
89
- const selected = lines.slice(startLine - 1, endLine).join("\n");
90
- if (!addContinuationHint) {
114
+ const selected = totalLines === 0 ? content : lines.slice(startLine - 1, endLine).join("\n");
115
+ const nextStartLine = addContinuationHint ? endLine + 1 : undefined;
116
+ const hint = addContinuationHint
117
+ ? `\n\n(File truncated at line ${endLine}. To read more, call read_file with path="${absolutePath}" and start_line=${nextStartLine}.)`
118
+ : "";
119
+ const contentWithHint = `${selected}${hint}`;
120
+ if (mode === "plain") {
91
121
  return {
92
122
  content: [{
93
123
  type: "text",
94
- text: selected
124
+ text: contentWithHint
95
125
  }]
96
126
  };
97
127
  }
98
- const nextStartLine = endLine + 1;
99
- const hint = `\n\n(File truncated at line ${endLine}. To read more, call read_file with path="${absolutePath}" and start_line=${nextStartLine}.)`;
128
+ const supportedByExtension = isOutlineSupportedFile(absolutePath);
129
+ const resolvedRoot = resolveCodebaseRootForFile(absolutePath, ctx);
130
+ const relativeFile = resolvedRoot
131
+ ? normalizeRelativePath(path.relative(resolvedRoot, absolutePath))
132
+ : undefined;
133
+ let outlineStatus = supportedByExtension ? "requires_reindex" : "unsupported";
134
+ let outline = null;
135
+ let hasMore = false;
136
+ let warnings;
137
+ let hints;
138
+ if (!supportedByExtension) {
139
+ outlineStatus = "unsupported";
140
+ }
141
+ else if (!resolvedRoot || !relativeFile) {
142
+ outlineStatus = "requires_reindex";
143
+ hints = {
144
+ reindex: {
145
+ tool: "manage_index",
146
+ args: {
147
+ action: "reindex",
148
+ path: path.dirname(absolutePath),
149
+ },
150
+ },
151
+ };
152
+ }
153
+ else {
154
+ try {
155
+ const outlineResponse = await ctx.toolHandlers.handleFileOutline({
156
+ path: resolvedRoot,
157
+ file: relativeFile,
158
+ start_line: totalLines === 0 ? undefined : startLine,
159
+ end_line: totalLines === 0 ? undefined : endLine,
160
+ });
161
+ const parsedOutline = JSON.parse(outlineResponse.content?.[0]?.text || "{}");
162
+ const status = parsedOutline?.status;
163
+ if (status === "ok" || status === "requires_reindex" || status === "unsupported") {
164
+ outlineStatus = status;
165
+ }
166
+ else {
167
+ outlineStatus = "requires_reindex";
168
+ }
169
+ outline = outlineStatus === "ok" && parsedOutline?.outline ? parsedOutline.outline : null;
170
+ hasMore = parsedOutline?.hasMore === true;
171
+ if (Array.isArray(parsedOutline?.warnings)) {
172
+ warnings = parsedOutline.warnings.filter((item) => typeof item === "string");
173
+ }
174
+ if (parsedOutline?.hints && typeof parsedOutline.hints === "object") {
175
+ hints = parsedOutline.hints;
176
+ }
177
+ }
178
+ catch {
179
+ outlineStatus = "requires_reindex";
180
+ }
181
+ }
100
182
  return {
101
183
  content: [{
102
184
  type: "text",
103
- text: `${selected}${hint}`
185
+ text: JSON.stringify({
186
+ path: absolutePath,
187
+ mode: "annotated",
188
+ content: contentWithHint,
189
+ outlineStatus,
190
+ outline,
191
+ hasMore,
192
+ ...(warnings && warnings.length > 0 ? { warnings } : {}),
193
+ ...(hints ? { hints } : {})
194
+ }, null, 2)
104
195
  }]
105
196
  };
106
197
  }
@@ -2,12 +2,14 @@ import { zodToJsonSchema } from "zod-to-json-schema";
2
2
  import { manageIndexTool } from "./manage_index.js";
3
3
  import { searchCodebaseTool } from "./search_codebase.js";
4
4
  import { callGraphTool } from "./call_graph.js";
5
+ import { fileOutlineTool } from "./file_outline.js";
5
6
  import { readFileTool } from "./read_file.js";
6
7
  import { listCodebasesTool } from "./list_codebases.js";
7
8
  export const toolList = [
8
9
  manageIndexTool,
9
10
  searchCodebaseTool,
10
11
  callGraphTool,
12
+ fileOutlineTool,
11
13
  readFileTool,
12
14
  listCodebasesTool,
13
15
  ];
@@ -35,6 +35,9 @@ function extractDiagnostics(response) {
35
35
  excludedByIgnore: safeNumber(metaDiagnostics.excludedByIgnore, 0),
36
36
  resultsReturned: afterFilter,
37
37
  freshnessMode: typeof metaDiagnostics.freshnessMode === "string" ? metaDiagnostics.freshnessMode : undefined,
38
+ searchPassCount: safeNumber(metaDiagnostics.searchPassCount, 0),
39
+ searchPassSuccessCount: safeNumber(metaDiagnostics.searchPassSuccessCount, 0),
40
+ searchPassFailureCount: safeNumber(metaDiagnostics.searchPassFailureCount, 0),
38
41
  };
39
42
  }
40
43
  const text = response.content?.[0]?.text;
@@ -50,6 +53,9 @@ function extractDiagnostics(response) {
50
53
  excludedByIgnore: safeNumber(parsed?.excludedByIgnore, 0),
51
54
  resultsReturned: results,
52
55
  freshnessMode: typeof parsed?.freshnessDecision?.mode === "string" ? parsed.freshnessDecision.mode : undefined,
56
+ searchPassCount: safeNumber(parsed?.searchPassCount, 0),
57
+ searchPassSuccessCount: safeNumber(parsed?.searchPassSuccessCount, 0),
58
+ searchPassFailureCount: safeNumber(parsed?.searchPassFailureCount, 0),
53
59
  };
54
60
  }
55
61
  catch {
@@ -103,6 +109,10 @@ export const searchCodebaseTool = {
103
109
  reranker_used: false,
104
110
  latency_ms: Date.now() - startedAt,
105
111
  freshness_mode: diagnostics.freshnessMode,
112
+ search_pass_count: diagnostics.searchPassCount,
113
+ search_pass_success_count: diagnostics.searchPassSuccessCount,
114
+ search_pass_failure_count: diagnostics.searchPassFailureCount,
115
+ parallel_fanout: true,
106
116
  ...(response.isError ? { error: getErrorMessage(response) } : {})
107
117
  });
108
118
  return response;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zokizuan/satori-mcp",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "MCP server for Satori with agent-safe semantic search and indexing",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",