projectatlas/
mcp.rs

1//! Purpose: Serve `ProjectAtlas` repository intelligence over MCP.
2//! Native MCP adapter for `ProjectAtlas` agent integrations.
3
4use crate::atlas_map::{
5    AtlasMapConfig, IgnoreEntryKind, LintOptions, add_ignore_entry, effective_config_report,
6    init_gitignore, lint_map, list_ignore_entries, load_atlas_config, load_atlas_config_for_root,
7    remove_ignore_entry, write_map,
8};
9#[cfg(test)]
10use crate::runtime::run_scan_pipeline;
11use crate::runtime::{
12    DEFAULT_HEALTH_LIMIT, INDEX_WORKER_SAFE_CEILING, IndexInitRequired, IndexProjectMismatch,
13    IndexRefreshRequired, IndexVerificationIncomplete, InitBootstrapOptions, MAX_HEALTH_LIMIT,
14    MAX_SYMBOL_FILE_BYTES, ProjectWorktreeRequired, PurposeCuratorHandoff, PurposeLintLevel,
15    PurposeReviewRequest, ScanRuntimePlan, SourceObservationRegistry, SymbolBuildOptions,
16    UsageRuntimeInstance, VerifiedReadOutcome, VerifiedReadStamp, build_settings_report,
17    byte_count_to_tokens, canonical_project_root, canonical_source_project_root,
18    config_root_mismatch_error, default_mcp_project_root,
19    estimated_source_tokens_for_indexed_files, estimated_source_tokens_for_paths,
20    index_init_required, index_work_control, init_config_path, lint_database_if_present,
21    next_step_report, next_step_report_payload, normalized_folder_filter,
22    open_atlas_store_for_project, open_atlas_store_read_only_for_project,
23    open_federated_atlas_stores_for_project, purpose_curation_page, purpose_curator_handoff,
24    ranked_file_nodes_with_reasons, ranked_folder_nodes_with_reasons, read_indexed_file_content,
25    record_directory_walk_usage_estimate, record_usage_estimate, record_usage_text,
26    render_health_page, render_purpose_curation_page, render_purpose_review_report,
27    reset_index_files, review_purposes, run_init_bootstrap, run_scan_pipeline_controlled,
28    run_single_watch_refresh_controlled, run_symbol_build_pipeline_controlled,
29    strip_legacy_purpose, telemetry_disabled, validate_purpose_review_admission,
30    validated_indexed_file_key, watcher_status_report,
31};
32use crate::token_tui::{
33    TokenDashboardTheme, render_token_dashboard_plain_with_theme,
34    render_token_trend_dashboard_plain_with_theme,
35};
36use crate::{
37    AgentErrorKind, CliError, DEFAULT_FILE_SUMMARY_LIMIT, DatabaseFilesystemErrorPayload,
38    HarnessConfig, OutputFormat, RootTransition, RuntimeInfoReport, SearchRetrievalModeArg,
39    build_harness_mcp_config_report, build_parity_report, build_root_report, build_runtime_info,
40    controlled_named_output, database_filesystem_error_payload, finalize_coverage_output,
41    render_code_slice, render_file_summary, render_parity_report, render_root_report,
42    render_runtime_info, render_search_report, render_watch_status,
43};
44use projectatlas_core::graph::{
45    Completeness, ConfidenceClass, CoverageRecord, EntitySelector, ExternalSelector,
46    GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind, ProjectInstanceId,
47    RelationOccurrence, RelationResolution, RepositoryFilePath, ReusableTargetSelector, SourceSpan,
48};
49use projectatlas_core::health::Severity;
50use projectatlas_core::outline::build_outline;
51use projectatlas_core::symbols::ParserKind;
52use projectatlas_core::telemetry::{TokenTrendWindow, UsageInstanceOwner};
53use projectatlas_core::toon::{
54    encode_agent_payload, render_outline, render_overview, render_ranked_nodes,
55    render_symbol_relations, render_symbols, render_token_overview, render_token_trends,
56};
57use projectatlas_core::{
58    IndexGeneration, IndexWorkControl, IndexWorkFailure, NavigationNextCall,
59    NavigationNextCapability, Overview, PurposeSource, PurposeStatus, RankedConnection,
60    RankedConnectionCount, RankedConnectionKind, RankedConnectionTarget, RankedNode,
61    RankedReasonCode, normalize_native_path_display, normalize_repo_path,
62    normalize_repo_path_prefix, validated_repo_file_key, validated_repo_node_key,
63};
64use projectatlas_db::{
65    AtlasStore, DbError, HealthQuery, HealthResolution, HealthScope, RepositoryCoverageQuery,
66    read_project_root_read_only, verify_project_database,
67};
68use projectatlas_service::{
69    COVERAGE_PAGE_MAX_LIMIT, CodeSliceBudget, CoverageDigest, CoverageTrustState,
70    DetailedRelationBudget, DetailedRelationNode, DetailedRelationQuery, DetailedRelationReport,
71    DetailedRelationRow, DetailedRelationWork, FederatedDetailedRelationReport,
72    FederatedParticipant, FederatedRelationWork, FederatedRendezvous, FederatedStore,
73    FileCallSummary, FileSummaryReport, FileSymbolSummary, GitImpactSelection,
74    RelationAnalysisMode, RelationAnalysisQuery, RelationAnchor, RelationDirection,
75    RelationNextCall, RelationPurpose, RelationTotalState, SearchQuery, ServiceError,
76    SymbolSliceSelector, TokenReport, TokenReportRequest, build_file_summary_from_source,
77    load_coverage_discovery, load_detailed_relation_page, load_federated_detailed_relations,
78    load_federated_relation_analysis, load_relation_analysis, load_token_report,
79    parse_coverage_parser, parse_coverage_relation, parse_coverage_state,
80    parse_relation_confidence, parse_relation_direction, parse_relation_resolution,
81    parse_symbol_kind, read_indexed_code_slice_from_source_bounded,
82    read_symbol_slice_from_source_bounded, search_indexed_files_with_control,
83};
84use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
85use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
86use rmcp::schemars;
87use rmcp::service::RequestContext;
88use rmcp::{RoleServer, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
89use serde::{Deserialize, Serialize};
90use std::collections::VecDeque;
91use std::fs;
92use std::path::{Component, Path, PathBuf};
93use std::sync::{
94    Arc, Mutex, RwLock,
95    atomic::{AtomicU64, Ordering},
96};
97use std::thread;
98use std::time::{Duration, SystemTime, UNIX_EPOCH};
99
100/// MCP tools required for the agent-first repository-intelligence surface.
101pub(crate) const REQUIRED_MCP_TOOL_NAMES: &[&str] = &[
102    MCP_TOOL_ATLAS_SET_PROJECT_PATH,
103    MCP_TOOL_ATLAS_INIT,
104    MCP_TOOL_ATLAS_MAP,
105    MCP_TOOL_ATLAS_ROOT,
106    MCP_TOOL_ATLAS_ROOT_SET,
107    MCP_TOOL_ATLAS_CONFIG,
108    MCP_TOOL_ATLAS_IGNORE_LIST,
109    MCP_TOOL_ATLAS_IGNORE_INIT_GITIGNORE,
110    MCP_TOOL_ATLAS_IGNORE_ADD,
111    MCP_TOOL_ATLAS_IGNORE_REMOVE,
112    MCP_TOOL_ATLAS_SCAN,
113    MCP_TOOL_ATLAS_OVERVIEW,
114    MCP_TOOL_ATLAS_FOLDERS,
115    MCP_TOOL_ATLAS_FILES,
116    MCP_TOOL_ATLAS_NEXT,
117    MCP_TOOL_ATLAS_OUTLINE,
118    MCP_TOOL_ATLAS_FILE_SUMMARY,
119    MCP_TOOL_ATLAS_SEARCH,
120    MCP_TOOL_ATLAS_SLICE,
121    MCP_TOOL_ATLAS_SYMBOLS_BUILD,
122    MCP_TOOL_ATLAS_SYMBOLS,
123    MCP_TOOL_ATLAS_SYMBOL_RELATIONS,
124    MCP_TOOL_ATLAS_HEALTH,
125    MCP_TOOL_ATLAS_HEALTH_RESOLVE,
126    MCP_TOOL_ATLAS_LINT,
127    MCP_TOOL_ATLAS_TOKEN_REPORT,
128    MCP_TOOL_ATLAS_PARITY_REPORT,
129    MCP_TOOL_ATLAS_SETTINGS,
130    MCP_TOOL_ATLAS_WATCH_STATUS,
131    MCP_TOOL_ATLAS_WATCH_ONCE,
132    MCP_TOOL_ATLAS_STRIP_LEGACY_PURPOSE,
133    MCP_TOOL_ATLAS_RESET_INDEX,
134    MCP_TOOL_ATLAS_MCP_CONFIG,
135    MCP_TOOL_ATLAS_RUNTIME_INFO,
136    MCP_TOOL_ATLAS_SESSION_BRIEF,
137    MCP_TOOL_ATLAS_TASK_STATUS,
138    MCP_TOOL_ATLAS_TASK_CANCEL,
139    MCP_TOOL_ATLAS_PURPOSE_QUEUE,
140    MCP_TOOL_ATLAS_PURPOSE_SET,
141    MCP_TOOL_ATLAS_PURPOSE_REVIEW,
142];
143
144/// MCP tool name for active project selection.
145const MCP_TOOL_ATLAS_SET_PROJECT_PATH: &str = "atlas_set_project_path";
146/// MCP tool name for project initialization.
147const MCP_TOOL_ATLAS_INIT: &str = "atlas_init";
148/// MCP tool name for compatibility map exports.
149const MCP_TOOL_ATLAS_MAP: &str = "atlas_map";
150/// MCP tool name for root diagnostics.
151const MCP_TOOL_ATLAS_ROOT: &str = "atlas_root";
152/// MCP tool name for binding a project root.
153const MCP_TOOL_ATLAS_ROOT_SET: &str = "atlas_root_set";
154/// MCP tool name for effective config reports.
155const MCP_TOOL_ATLAS_CONFIG: &str = "atlas_config";
156/// MCP tool name for ignore policy reports.
157const MCP_TOOL_ATLAS_IGNORE_LIST: &str = "atlas_ignore_list";
158/// MCP tool name for project `.gitignore` initialization.
159const MCP_TOOL_ATLAS_IGNORE_INIT_GITIGNORE: &str = "atlas_ignore_init_gitignore";
160/// MCP tool name for adding manual ignore entries.
161const MCP_TOOL_ATLAS_IGNORE_ADD: &str = "atlas_ignore_add";
162/// MCP tool name for removing manual ignore entries.
163const MCP_TOOL_ATLAS_IGNORE_REMOVE: &str = "atlas_ignore_remove";
164/// MCP tool name for repository scans.
165const MCP_TOOL_ATLAS_SCAN: &str = "atlas_scan";
166/// MCP tool name for repository overviews.
167const MCP_TOOL_ATLAS_OVERVIEW: &str = "atlas_overview";
168/// MCP tool name for folder ranking.
169const MCP_TOOL_ATLAS_FOLDERS: &str = "atlas_folders";
170/// MCP tool name for file ranking.
171const MCP_TOOL_ATLAS_FILES: &str = "atlas_files";
172/// MCP tool name for next-step recommendations.
173const MCP_TOOL_ATLAS_NEXT: &str = "atlas_next";
174/// MCP tool name for file outlines.
175const MCP_TOOL_ATLAS_OUTLINE: &str = "atlas_outline";
176/// MCP tool name for file summaries.
177const MCP_TOOL_ATLAS_FILE_SUMMARY: &str = "atlas_file_summary";
178/// MCP tool name for indexed search.
179const MCP_TOOL_ATLAS_SEARCH: &str = "atlas_search";
180/// MCP tool name for source slices.
181const MCP_TOOL_ATLAS_SLICE: &str = "atlas_slice";
182/// MCP tool name for symbol builds.
183const MCP_TOOL_ATLAS_SYMBOLS_BUILD: &str = "atlas_symbols_build";
184/// MCP tool name for symbol lookup.
185const MCP_TOOL_ATLAS_SYMBOLS: &str = "atlas_symbols";
186/// MCP tool name for symbol relation lookup.
187const MCP_TOOL_ATLAS_SYMBOL_RELATIONS: &str = "atlas_symbol_relations";
188/// MCP tool name for health pages.
189const MCP_TOOL_ATLAS_HEALTH: &str = "atlas_health";
190/// MCP tool name for health resolutions.
191const MCP_TOOL_ATLAS_HEALTH_RESOLVE: &str = "atlas_health_resolve";
192/// MCP tool name for lint reports.
193const MCP_TOOL_ATLAS_LINT: &str = "atlas_lint";
194/// MCP tool name for token reports.
195const MCP_TOOL_ATLAS_TOKEN_REPORT: &str = "atlas_token_report";
196/// MCP tool name for parity reports.
197const MCP_TOOL_ATLAS_PARITY_REPORT: &str = "atlas_parity_report";
198/// MCP tool name for settings reports.
199const MCP_TOOL_ATLAS_SETTINGS: &str = "atlas_settings";
200/// MCP tool name for watcher status.
201const MCP_TOOL_ATLAS_WATCH_STATUS: &str = "atlas_watch_status";
202/// MCP tool name for one-shot watcher refreshes.
203const MCP_TOOL_ATLAS_WATCH_ONCE: &str = "atlas_watch_once";
204/// MCP tool name for legacy purpose cleanup.
205const MCP_TOOL_ATLAS_STRIP_LEGACY_PURPOSE: &str = "atlas_strip_legacy_purpose";
206/// MCP tool name for runtime index reset.
207const MCP_TOOL_ATLAS_RESET_INDEX: &str = "atlas_reset_index";
208/// MCP tool name for generated MCP config documents.
209const MCP_TOOL_ATLAS_MCP_CONFIG: &str = "atlas_mcp_config";
210/// MCP tool name for runtime identity reports.
211const MCP_TOOL_ATLAS_RUNTIME_INFO: &str = "atlas_runtime_info";
212/// MCP tool name for compact agent startup briefs.
213const MCP_TOOL_ATLAS_SESSION_BRIEF: &str = "atlas_session_brief";
214/// MCP tool name for task-progress status lookup.
215const MCP_TOOL_ATLAS_TASK_STATUS: &str = "atlas_task_status";
216/// MCP tool name for task-progress cancellation.
217const MCP_TOOL_ATLAS_TASK_CANCEL: &str = "atlas_task_cancel";
218/// MCP tool name for purpose queue lookup.
219const MCP_TOOL_ATLAS_PURPOSE_QUEUE: &str = "atlas_purpose_queue";
220/// MCP tool name for purpose updates.
221const MCP_TOOL_ATLAS_PURPOSE_SET: &str = "atlas_purpose_set";
222/// MCP tool name for purpose reviews.
223const MCP_TOOL_ATLAS_PURPOSE_REVIEW: &str = "atlas_purpose_review";
224/// `ProjectAtlas` local state directory name.
225const PROJECTATLAS_DIR_NAME: &str = ".projectatlas";
226/// `ProjectAtlas` `SQLite` database filename.
227const PROJECTATLAS_DB_FILE_NAME: &str = "projectatlas.db";
228/// Project-local nested config filename.
229const PROJECTATLAS_CONFIG_FILE_NAME: &str = "config.toml";
230/// Project-local flat config filename.
231const PROJECTATLAS_FLAT_CONFIG_FILE_NAME: &str = "projectatlas.toml";
232/// Maximum distinct project bindings whose MCP telemetry can be sealed at shutdown.
233const MCP_TELEMETRY_PROJECT_BINDING_LIMIT: usize = 64;
234/// Hard ceiling for the default agent-facing settings diagnostic.
235const MCP_SETTINGS_RESPONSE_MAX_BYTES: usize = 64_000;
236/// Prefix for an oversized settings-response diagnostic.
237const MCP_SETTINGS_RESPONSE_LIMIT_PREFIX: &str = "settings response requires ";
238/// Separator for the observed and permitted settings-response byte counts.
239const MCP_SETTINGS_RESPONSE_LIMIT_SEPARATOR: &str = " bytes, exceeding the ";
240/// Suffix for an oversized settings-response diagnostic.
241const MCP_SETTINGS_RESPONSE_LIMIT_SUFFIX: &str = "-byte diagnostic limit";
242/// Maximum generation/filter token baselines retained by one MCP process.
243const MCP_SOURCE_TOKEN_BASELINE_LIMIT: usize = 128;
244/// Default MCP config server key.
245const MCP_DEFAULT_CONFIG_SERVER_NAME: &str = "projectatlas";
246/// Recovery guidance when a path names a subfolder rather than another selected root.
247const SELECTED_ROOT_ASSERTION_GUIDANCE: &str = "pass project_path or call atlas_set_project_path for another repository, or use normal filesystem tools such as Get-Content or rg for files inside the selected project";
248/// Recovery guidance when a path escapes the selected `ProjectAtlas` root.
249const OUTSIDE_SELECTED_PROJECT_GUIDANCE: &str = "pass project_path or call atlas_set_project_path for that repository, or use normal filesystem tools such as Get-Content or rg for files outside the selected ProjectAtlas project";
250/// Current-directory root alias.
251const CURRENT_DIR_ALIAS: &str = ".";
252/// `ProjectAtlas` MCP server display name.
253const MCP_SERVER_NAME: &str = "ProjectAtlas";
254/// Request-local MCP cancellation monitor thread name.
255const MCP_CANCELLATION_MONITOR_THREAD_NAME: &str = "projectatlas-mcp-cancel";
256/// Prefix for cancellation-monitor startup failures.
257const MCP_CANCELLATION_MONITOR_START_ERROR_PREFIX: &str =
258    "MCP request cancellation monitor could not start: ";
259/// MCP error lock-poison message.
260const MCP_PROJECT_STATE_LOCK_POISONED: &str = "MCP project state lock poisoned";
261/// Prefix used only if structured MCP error serialization fails.
262const MCP_ERROR_SERIALIZATION_FALLBACK_PREFIX: &str = "error: ";
263/// MCP payload key for scan reports.
264const MCP_PAYLOAD_SCAN: &str = "scan";
265/// MCP payload key for project initialization reports.
266const MCP_PAYLOAD_INIT: &str = "init";
267/// MCP payload key for compatibility map reports.
268const MCP_PAYLOAD_MAP: &str = "map";
269/// MCP payload key for effective config reports.
270const MCP_PAYLOAD_CONFIG: &str = "config";
271/// MCP payload key for ignore policy reports.
272const MCP_PAYLOAD_IGNORE: &str = "ignore";
273/// MCP payload key for `.gitignore` initialization reports.
274const MCP_PAYLOAD_GITIGNORE: &str = "gitignore";
275/// MCP payload key for lint reports.
276const MCP_PAYLOAD_LINT: &str = "lint";
277/// MCP payload key for symbol-build reports.
278const MCP_PAYLOAD_SYMBOLS_BUILD: &str = "symbols_build";
279/// MCP payload key for detailed symbol-relation reports.
280const MCP_PAYLOAD_SYMBOL_RELATIONS: &str = "symbol_relations";
281/// MCP payload key for health-resolution reports.
282const MCP_PAYLOAD_HEALTH_RESOLUTION: &str = "health_resolution";
283/// MCP payload key for token trend reports.
284const MCP_PAYLOAD_TOKEN_TRENDS: &str = "token_trends";
285/// MCP payload key for token savings reports.
286const MCP_PAYLOAD_TOKEN_SAVINGS: &str = "token_savings";
287/// MCP payload key for optional chart strings.
288const MCP_PAYLOAD_CHART: &str = "chart";
289/// MCP payload key for watcher reports.
290const MCP_PAYLOAD_WATCH: &str = "watch";
291/// MCP payload key for legacy-purpose migration reports.
292const MCP_PAYLOAD_LEGACY_PURPOSE_MIGRATION: &str = "legacy_purpose_migration";
293/// MCP payload key for reset-index reports.
294const MCP_PAYLOAD_RESET_INDEX: &str = "reset_index";
295/// MCP payload key for generated MCP config reports.
296const MCP_PAYLOAD_MCP_CONFIG: &str = "mcp_config";
297/// MCP payload key for next-step recommendation reports.
298const MCP_PAYLOAD_NEXT: &str = "next";
299/// MCP payload key for selected-project audit metadata on routed reads.
300const MCP_PAYLOAD_SELECTED_PROJECT: &str = "selected_project";
301/// MCP payload key for settings reports.
302const MCP_PAYLOAD_SETTINGS: &str = "settings";
303/// MCP payload key for agent startup briefs.
304const MCP_PAYLOAD_SESSION_BRIEF: &str = "session_brief";
305/// Default source status omitted from compact file-summary payloads.
306const MCP_FILE_SOURCE_STATUS_LIVE: &str = "live-source";
307/// MCP payload key for accepted background tasks.
308const MCP_PAYLOAD_TASK_START: &str = "task_start";
309/// MCP payload key for task status lookups.
310const MCP_PAYLOAD_TASK_STATUS: &str = "task_status";
311/// MCP payload key for task cancellation responses.
312const MCP_PAYLOAD_TASK_CANCEL: &str = "task_cancel";
313/// MCP session capability payload key.
314const MCP_PAYLOAD_SESSION_CAPABILITIES: &str = "mcp_session";
315/// Session-brief argument key for per-call project roots.
316const MCP_BRIEF_ARG_PROJECT_PATH: &str = "project_path";
317/// Session-brief argument key for exact repository-relative files.
318const MCP_BRIEF_ARG_FILE: &str = "file";
319/// Session-brief argument key for indexed search patterns.
320const MCP_BRIEF_ARG_PATTERN: &str = "pattern";
321/// Session-brief argument key for relation view selection.
322const MCP_BRIEF_ARG_VIEW: &str = "view";
323/// Session-brief argument key for row limits.
324const MCP_BRIEF_ARG_LIMIT: &str = "limit";
325/// Session-brief argument key for host-owned purpose-curation tasks.
326const MCP_BRIEF_ARG_TASK: &str = "task";
327/// Compact-response argument key used by typed startup recommendations.
328const MCP_BRIEF_ARG_COMPACT: &str = "compact";
329/// Session-brief recommendation target for normal filesystem reads.
330const MCP_BRIEF_TARGET_FILESYSTEM_TOOLS: &str = "filesystem_tools";
331/// Session-brief reason for missing selected indexes.
332const MCP_BRIEF_REASON_SELECTED_INDEX_MISSING: &str = "selected_index_missing";
333/// Session-brief reason for filesystem fallback before an index exists.
334const MCP_BRIEF_REASON_FILESYSTEM_UNTIL_INDEX: &str =
335    "use_filesystem_until_projectatlas_index_exists";
336/// Session-brief reason for following the selected file into its summary.
337const MCP_BRIEF_REASON_RANKED_FILE_SUMMARY: &str = "ranked_file_ready_for_summary";
338/// Session-brief reason for following truncated graph evidence into detailed relations.
339const MCP_BRIEF_REASON_RANKED_FILE_RELATIONS: &str = "ranked_file_ready_for_relations";
340/// Session-brief reason for searching when ranking found no directly navigable file.
341const MCP_BRIEF_REASON_SEARCH_FALLBACK: &str = "no_ranked_file_candidate_search_index";
342/// Session-brief reason for normal filesystem orientation in an indexed empty project.
343const MCP_BRIEF_REASON_NO_FILE_CANDIDATE: &str = "no_ranked_file_candidate";
344/// Session-brief reason for health follow-up.
345const MCP_BRIEF_REASON_HEALTH_BLOCKERS: &str = "unresolved_health_blockers_present";
346/// Session-brief reason for following an actionable purpose-curator handoff.
347const MCP_BRIEF_REASON_PURPOSE_QUEUE: &str = "purpose_queue_ready";
348/// Built-in task-progress contract message.
349const MCP_TASK_PROGRESS_CONTRACT_MESSAGE: &str = "task progress contract available";
350/// MCP telemetry event for overview calls.
351const MCP_EVENT_ATLAS_OVERVIEW: &str = "mcp.atlas_overview";
352/// MCP telemetry event for folder calls.
353const MCP_EVENT_ATLAS_FOLDERS: &str = "mcp.atlas_folders";
354/// MCP telemetry event for file calls.
355const MCP_EVENT_ATLAS_FILES: &str = "mcp.atlas_files";
356/// MCP telemetry event for next-step recommendation calls.
357const MCP_EVENT_ATLAS_NEXT: &str = "mcp.atlas_next";
358/// MCP telemetry event for outline calls.
359const MCP_EVENT_ATLAS_OUTLINE: &str = "mcp.atlas_outline";
360/// MCP telemetry event for file-summary calls.
361const MCP_EVENT_ATLAS_FILE_SUMMARY: &str = "mcp.atlas_file_summary";
362/// MCP telemetry event for search calls.
363const MCP_EVENT_ATLAS_SEARCH: &str = "mcp.atlas_search";
364/// MCP telemetry event for slice calls.
365const MCP_EVENT_ATLAS_SLICE: &str = "mcp.atlas_slice";
366/// MCP telemetry event for symbol calls.
367const MCP_EVENT_ATLAS_SYMBOLS: &str = "mcp.atlas_symbols";
368/// MCP telemetry event for symbol-relation calls.
369const MCP_EVENT_ATLAS_SYMBOL_RELATIONS: &str = "mcp.atlas_symbol_relations";
370/// Compatible MCP symbol-relation view name.
371const MCP_SYMBOL_RELATION_VIEW_LEGACY: &str = "legacy";
372/// Additive detailed MCP symbol-relation view name.
373const MCP_SYMBOL_RELATION_VIEW_DETAILED: &str = "detailed";
374/// Additive closed analysis view on the existing relation route.
375const MCP_SYMBOL_RELATION_VIEW_ANALYSIS: &str = "analysis";
376/// Default closed relation-analysis mode.
377const MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE: &str = "architecture";
378/// VCS impact relation-analysis mode.
379const MCP_RELATION_ANALYSIS_MODE_IMPACT: &str = "impact";
380/// Static trace relation-analysis mode.
381const MCP_RELATION_ANALYSIS_MODE_TRACE: &str = "trace";
382/// Default working-tree VCS impact selection.
383const MCP_RELATION_ANALYSIS_VCS_WORKING_TREE: &str = "working_tree";
384/// Staged-index VCS impact selection.
385const MCP_RELATION_ANALYSIS_VCS_INDEX: &str = "index";
386/// Explicit revision-range VCS impact selection.
387const MCP_RELATION_ANALYSIS_VCS_REVISION_RANGE: &str = "revision_range";
388/// Default direction for detailed MCP symbol-relation requests.
389const MCP_SYMBOL_RELATION_DIRECTION_DEFAULT: &str = "outbound";
390/// Default minimum confidence for detailed MCP symbol-relation requests.
391const MCP_SYMBOL_RELATION_CONFIDENCE_DEFAULT: &str = "low";
392/// Default resolution filter for detailed MCP symbol-relation requests.
393const MCP_SYMBOL_RELATION_RESOLUTION_DEFAULT: &str = "any";
394/// MCP validation error for an unsupported relation view.
395const MCP_ERROR_SYMBOL_RELATION_VIEW: &str = "unsupported symbol relation view";
396/// MCP validation error for legacy query fields in detailed requests.
397const MCP_ERROR_DETAILED_RELATION_QUERY: &str =
398    "detailed symbol relations use exact symbol selectors, not query";
399/// MCP validation error for a detailed request without a file anchor.
400const MCP_ERROR_DETAILED_RELATION_FILE: &str = "detailed symbol relations require file";
401/// MCP validation error for an empty detailed symbol anchor.
402const MCP_ERROR_DETAILED_RELATION_SYMBOL: &str = "detailed relation symbol must not be empty";
403/// MCP validation error for symbol disambiguators without a symbol.
404const MCP_ERROR_DETAILED_RELATION_DISAMBIGUATOR: &str = "symbol disambiguators require symbol";
405/// MCP validation error for a relation limit outside the service range.
406const MCP_ERROR_DETAILED_RELATION_LIMIT: &str = "detailed relation limit exceeds the u32 range";
407/// MCP validation error for compact projection on a non-detailed relation view.
408const MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW: &str =
409    "compact symbol relations require view=detailed";
410/// MCP validation error for analysis controls on another relation view.
411const MCP_ERROR_ANALYSIS_VIEW_REQUIRED: &str = "analysis controls require view=analysis";
412/// MCP validation error for federation on the legacy relation view.
413const MCP_ERROR_FEDERATED_RELATION_VIEW: &str =
414    "roots require the detailed or analysis relation view";
415/// MCP validation error for a symbol trace without a symbol-kind selector.
416const MCP_ERROR_TRACE_TARGET_KIND_REQUIRED: &str = "symbol trace targets require trace_target_kind";
417/// MCP validation error for a symbol trace without a signature selector.
418const MCP_ERROR_TRACE_TARGET_SIGNATURE_REQUIRED: &str =
419    "symbol trace targets require trace_target_signature";
420/// MCP validation error for symbol disambiguators without a trace symbol.
421const MCP_ERROR_TRACE_TARGET_REQUIRED: &str =
422    "trace target symbol disambiguators require trace_target";
423/// MCP validation error for a trace target without its owning file.
424const MCP_ERROR_TRACE_TARGET_FILE_REQUIRED: &str = "trace_target requires trace_target_file";
425/// MCP validation error for revision fields outside revision-range selection.
426const MCP_ERROR_VCS_REVISION_FIELDS: &str = "vcs_base and vcs_head require vcs=revision_range";
427/// MCP validation error for a revision-range selection without its base.
428const MCP_ERROR_VCS_BASE_REQUIRED: &str = "vcs=revision_range requires vcs_base";
429/// MCP validation error for a revision-range selection without its head.
430const MCP_ERROR_VCS_HEAD_REQUIRED: &str = "vcs=revision_range requires vcs_head";
431/// MCP validation error for an unsupported VCS impact selector.
432const MCP_ERROR_UNSUPPORTED_ANALYSIS_VCS: &str = "unsupported analysis VCS selection";
433/// MCP validation error for an unsupported closed relation-analysis mode.
434const MCP_ERROR_UNSUPPORTED_ANALYSIS_MODE: &str = "unsupported relation analysis mode";
435/// MCP telemetry event for health calls.
436const MCP_EVENT_ATLAS_HEALTH: &str = "mcp.atlas_health";
437/// MCP telemetry event for purpose-queue calls.
438const MCP_EVENT_ATLAS_PURPOSE_QUEUE: &str = "mcp.atlas_purpose_queue";
439/// MCP ignore kind token for directory names.
440const MCP_IGNORE_KIND_DIR_NAME: &str = "dir-name";
441/// MCP alternate ignore kind token for directory names.
442const MCP_IGNORE_KIND_DIR_NAME_ALIAS: &str = "dir_name";
443/// MCP ignore kind token for path prefixes.
444const MCP_IGNORE_KIND_PATH_PREFIX: &str = "path-prefix";
445/// MCP alternate ignore kind token for path prefixes.
446const MCP_IGNORE_KIND_PATH_PREFIX_ALIAS: &str = "path_prefix";
447/// MCP purpose lint level token for low strictness.
448const MCP_PURPOSE_LEVEL_LOW: &str = "low";
449/// MCP purpose lint level token for medium strictness.
450const MCP_PURPOSE_LEVEL_MEDIUM: &str = "medium";
451/// MCP purpose lint level token for strict mode.
452const MCP_PURPOSE_LEVEL_STRICT: &str = "strict";
453/// Default purpose-curation task used by session startup briefs.
454const MCP_PURPOSE_TASK_SESSION_STARTUP: &str = "session-startup";
455/// Default purpose-curation task used by direct queue requests.
456const MCP_PURPOSE_TASK_QUEUE: &str = "purpose-curation";
457/// MCP harness token for standard MCP JSON config.
458const MCP_HARNESS_MCP_JSON: &str = "mcp-json";
459/// MCP alternate harness token for standard MCP JSON config.
460const MCP_HARNESS_MCP_JSON_ALIAS: &str = "mcp_json";
461/// MCP harness token for Codex config.
462const MCP_HARNESS_CODEX: &str = "codex";
463/// MCP harness token for Claude Code config.
464const MCP_HARNESS_CLAUDE_CODE: &str = "claude-code";
465/// MCP alternate harness token for Claude Code config.
466const MCP_HARNESS_CLAUDE_CODE_ALIAS: &str = "claude_code";
467/// MCP harness token for `OpenCode` config.
468const MCP_HARNESS_OPENCODE: &str = "opencode";
469/// Required ignore kind diagnostic.
470const MCP_ERROR_IGNORE_KIND_REQUIRED: &str =
471    "ignore kind is required; expected dir-name or path-prefix";
472/// Required ignore kind diagnostic for mutation tools.
473const MCP_ERROR_IGNORE_KIND_REQUIRED_FOR_ADD: &str = "ignore kind is required for atlas_ignore_add";
474/// Invalid coverage start-index diagnostic prefix.
475const MCP_ERROR_COVERAGE_START_INDEX_TOO_LARGE_PREFIX: &str = "coverage start index is too large: ";
476/// Invalid coverage limit diagnostic prefix.
477const MCP_ERROR_COVERAGE_LIMIT_TOO_LARGE_PREFIX: &str = "coverage limit is too large: ";
478/// Coverage-filter diagnostic for the default structural-health mode.
479const MCP_ERROR_COVERAGE_FILTERS_REQUIRE_COVERAGE: &str = "coverage filters require coverage=true";
480/// CI environment variable used by MCP map export safeguards.
481const MCP_ENV_CI: &str = "CI";
482/// GitHub Actions environment variable used by MCP map export safeguards.
483const MCP_ENV_GITHUB_ACTIONS: &str = "GITHUB_ACTIONS";
484/// Compatibility map skip reason in CI.
485const MCP_MAP_SKIPPED_IN_CI_REASON: &str =
486    "skipped in CI; pass force=true to write the compatibility map";
487/// Placeholder when no routed root is available for a diagnostic.
488const MCP_NO_ROOT_PLACEHOLDER: &str = "none";
489/// Invalid ignore kind diagnostic prefix.
490const MCP_ERROR_INVALID_IGNORE_KIND_PREFIX: &str = "invalid ignore kind '";
491/// Invalid ignore kind diagnostic suffix.
492const MCP_ERROR_INVALID_IGNORE_KIND_SUFFIX: &str = "'; expected dir-name or path-prefix";
493/// Invalid purpose lint level diagnostic prefix.
494const MCP_ERROR_INVALID_PURPOSE_LEVEL_PREFIX: &str = "invalid purpose_level '";
495/// Invalid purpose lint level diagnostic suffix.
496const MCP_ERROR_INVALID_PURPOSE_LEVEL_SUFFIX: &str = "'; expected low, medium, or strict";
497/// Invalid harness diagnostic prefix.
498const MCP_ERROR_INVALID_HARNESS_PREFIX: &str = "invalid harness '";
499/// Invalid harness diagnostic suffix.
500const MCP_ERROR_INVALID_HARNESS_SUFFIX: &str =
501    "'; expected mcp-json, codex, claude-code, or opencode";
502/// Ambiguous route diagnostic fragment.
503const MCP_ERROR_FOR_PATH_FRAGMENT: &str = " for '";
504/// Ambiguous route diagnostic fragment.
505const MCP_ERROR_LEXICAL_ROOT_FRAGMENT: &str = "'; lexical root: '";
506/// Ambiguous route diagnostic fragment.
507const MCP_ERROR_RESOLVED_ROOT_FRAGMENT: &str = "'; resolved root: '";
508/// Ambiguous route diagnostic fragment.
509const MCP_ERROR_GUIDANCE_FRAGMENT: &str = "'; ";
510/// Node payload label for rendered folder rows.
511const NODE_LABEL_FOLDERS: &str = "folders";
512/// Node payload label for rendered file rows.
513const NODE_LABEL_FILES: &str = "files";
514/// Error when a symbol disambiguator is supplied without a symbol name.
515const SYMBOL_DISAMBIGUATOR_WITHOUT_SYMBOL_ERROR: &str = "symbol disambiguators require symbol";
516/// Error when a line slice omits its start line.
517const START_LINE_REQUIRED_ERROR: &str = "start_line is required unless symbol is provided";
518/// Error when an absolute MCP file path has no valid indexed project ancestor.
519const PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR: &str =
520    "path is not inside an indexed ProjectAtlas project";
521/// Error when an absolute MCP folder path has no valid indexed project ancestor.
522const FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR: &str =
523    "folder is not inside an indexed ProjectAtlas project";
524/// Error when lexical and canonical path routing disagree.
525const AMBIGUOUS_NEAREST_PROJECT_PATH_ERROR: &str = "absolute MCP path resolves through a symlink or junction with multiple plausible ProjectAtlas roots";
526/// Separator for diagnostic lists of accepted severity names.
527const SEVERITY_EXPECTED_SEPARATOR: &str = ", ";
528/// Final separator for diagnostic lists of accepted severity names.
529const SEVERITY_EXPECTED_FINAL_SEPARATOR: &str = ", or ";
530/// Token trend validation error suffix.
531const TOKEN_TREND_WINDOW_ERROR_SUFFIX: &str = "expected day, week, month, or year";
532/// Validation error for benchmark evidence on token trend requests.
533const TOKEN_TREND_BENCHMARK_ERROR: &str =
534    "benchmark_results is only supported for token overview reports";
535/// Internal mismatch when a trend request returns the overview variant.
536const TOKEN_TRENDS_RESULT_VARIANT_MISMATCH: &str = "token trend request returned an overview";
537/// Internal mismatch when an overview request returns the trends variant.
538const TOKEN_OVERVIEW_RESULT_VARIANT_MISMATCH: &str = "token overview request returned trends";
539/// Token chart theme validation error prefix.
540const TOKEN_CHART_THEME_ERROR_PREFIX: &str = "unsupported token chart theme ";
541/// Token chart theme validation error suffix.
542const TOKEN_CHART_THEME_ERROR_SUFFIX: &str = "; expected dark or light";
543/// Watch-status recommendation when no index exists.
544const WATCH_STATUS_SCAN_RECOMMENDATION: &str =
545    " Run `atlas_scan` first when no ProjectAtlas index exists for this project.";
546/// Default number of rows in an agent startup brief section.
547const SESSION_BRIEF_DEFAULT_LIMIT: usize = 5;
548/// Default number of rows in an explicitly compact startup brief section.
549const COMPACT_SESSION_BRIEF_DEFAULT_LIMIT: usize = 3;
550/// Maximum number of rows in an agent startup brief section.
551const SESSION_BRIEF_MAX_LIMIT: usize = 8;
552/// Bounded MCP task registry capacity.
553const MCP_TASK_REGISTRY_CAPACITY: usize = 32;
554/// Maximum concise task failure text retained in the session registry.
555const MCP_TASK_ERROR_MAX_CHARS: usize = 512;
556/// Built-in task id that exposes the task-progress contract itself.
557const MCP_TASK_CONTRACT_ID: &str = "task-progress-contract";
558/// Task-registry synchronization failure diagnostic.
559const MCP_TASK_REGISTRY_LOCK_POISONED: &str = "MCP task registry lock is poisoned";
560/// Prefix for generated background index task identifiers.
561const MCP_INDEX_TASK_ID_PREFIX: &str = "index-";
562/// Prefix for named background index worker threads.
563const MCP_INDEX_WORKER_NAME_PREFIX: &str = "projectatlas-";
564/// Prefix for active background task limit diagnostics.
565const MCP_INDEX_TASK_LIMIT_PREFIX: &str = "background indexing task limit ";
566/// Suffix for active background task limit diagnostics.
567const MCP_INDEX_TASK_LIMIT_SUFFIX: &str = " is already active";
568/// Maximum background indexing tasks admitted by one MCP server session.
569const MCP_BACKGROUND_TASK_SAFE_CEILING: usize = 4;
570/// Terminal diagnostic when a background worker panics.
571const MCP_INDEX_WORKER_PANIC_ERROR: &str = "background indexing worker panicked";
572/// Prefix for background worker spawn failures.
573const MCP_INDEX_WORKER_SPAWN_ERROR_PREFIX: &str = "failed to start background indexing: ";
574/// Progress message recorded after task admission.
575const MCP_TASK_PROGRESS_ACCEPTED: &str = "accepted";
576/// Progress message recorded while task work is active.
577const MCP_TASK_PROGRESS_RUNNING: &str = "running";
578/// Progress message recorded after successful task completion.
579const MCP_TASK_PROGRESS_COMPLETE: &str = "complete";
580/// Progress message recorded after task failure.
581const MCP_TASK_PROGRESS_FAILED: &str = "failed";
582/// Progress message recorded after cooperative cancellation completes.
583const MCP_TASK_PROGRESS_CANCELED: &str = "canceled";
584/// Progress message recorded after cancellation is requested.
585const MCP_TASK_PROGRESS_CANCELLATION_REQUESTED: &str = "cancellation_requested";
586/// Agent-facing MCP server instructions.
587const MCP_SERVER_INSTRUCTIONS: &str = "ProjectAtlas provides TOON-first repository orientation, folder/file ranking, structured file summaries, symbol graph lookup, exact slices, health checks, and token telemetry for coding agents.";
588
589/// Optional active-project override accepted by MCP tools.
590#[derive(Debug, Deserialize, schemars::JsonSchema)]
591struct AtlasProjectParams {
592    /// Optional project root for this call. Defaults to the active MCP project.
593    project_path: Option<String>,
594}
595
596/// MCP parameter payload for compact agent startup briefs.
597#[derive(Debug, Deserialize, schemars::JsonSchema)]
598struct AtlasSessionBriefParams {
599    /// Optional project root for this call. Defaults to the active MCP project.
600    project_path: Option<String>,
601    /// Optional task query used for folder and file ranking.
602    query: Option<String>,
603    /// Optional host-owned task label for the purpose-curator handoff.
604    purpose_task: Option<String>,
605    /// Return the additive compact startup projection when true.
606    compact: Option<bool>,
607    /// Maximum folder candidates to return.
608    folder_limit: Option<usize>,
609    /// Maximum file candidates to return.
610    file_limit: Option<usize>,
611    /// Maximum health blockers to return.
612    blocker_limit: Option<usize>,
613    /// Maximum actionable low-scope purpose rows in the startup handoff.
614    purpose_limit: Option<usize>,
615}
616
617/// MCP parameter payload for task-progress tools.
618#[derive(Debug, Deserialize, schemars::JsonSchema)]
619struct AtlasTaskParams {
620    /// Opaque MCP-session-local task id.
621    task_id: String,
622}
623
624/// MCP parameter payload for selecting the active project.
625#[derive(Debug, Deserialize, schemars::JsonSchema)]
626struct AtlasSetProjectPathParams {
627    /// Project root to make active for calls that omit `project_path`.
628    project_path: String,
629}
630
631/// MCP parameter payload for initializing a project.
632#[derive(Debug, Deserialize, schemars::JsonSchema)]
633struct AtlasInitParams {
634    /// Optional project root for this call. Defaults to the active MCP project.
635    project_path: Option<String>,
636    /// Create/verify the project surface without running the scan/index pipeline.
637    no_scan: Option<bool>,
638    /// Run the scan/index phase even when a future freshness check could skip it.
639    force_rescan: Option<bool>,
640    /// Maximum UTF-8 file size persisted into `SQLite` text search during the init scan.
641    text_index_max_bytes: Option<u64>,
642}
643
644/// MCP parameter payload for compatibility map exports.
645#[derive(Debug, Deserialize, schemars::JsonSchema)]
646struct AtlasMapParams {
647    /// Optional project root for this call. Defaults to the active MCP project.
648    project_path: Option<String>,
649    /// Write JSON compatibility content when true.
650    json: Option<bool>,
651    /// Force map generation even in CI-like environments.
652    force: Option<bool>,
653}
654
655/// MCP parameter payload for root diagnostics.
656#[derive(Debug, Deserialize, schemars::JsonSchema)]
657struct AtlasRootParams {
658    /// Optional project root for this call. Defaults to the active MCP project.
659    project_path: Option<String>,
660    /// Return the same report shape with `verified` available for gating.
661    verify: Option<bool>,
662}
663
664/// MCP parameter payload for binding a root.
665#[derive(Debug, Deserialize, schemars::JsonSchema)]
666struct AtlasRootSetParams {
667    /// Project root to bind and make active for later calls.
668    root: String,
669    /// Explicit durable transition. Omitted requests retain bind behavior.
670    transition: Option<RootTransition>,
671    /// Include mcp --nearest-project in generated project-local MCP configs.
672    nearest_project: Option<bool>,
673}
674
675/// MCP parameter payload for ignore mutations.
676#[derive(Debug, Deserialize, schemars::JsonSchema)]
677struct AtlasIgnoreMutationParams {
678    /// Optional project root for this call. Defaults to the active MCP project.
679    project_path: Option<String>,
680    /// Ignore kind: dir-name or path-prefix. Omit only for broad remove.
681    kind: Option<String>,
682    /// Directory name or repository-relative path prefix.
683    value: String,
684}
685
686/// MCP parameter payload for lint reports.
687#[derive(Debug, Deserialize, schemars::JsonSchema)]
688struct AtlasLintParams {
689    /// Optional project root for this call. Defaults to the active MCP project.
690    project_path: Option<String>,
691    /// Deprecated compatibility flag matching the CLI.
692    strict_folders: Option<bool>,
693    /// Purpose lint strictness: low, medium, or strict.
694    purpose_level: Option<String>,
695    /// Include untracked-file report.
696    report_untracked: Option<bool>,
697    /// Make untracked-file findings fail lint.
698    strict_untracked: Option<bool>,
699}
700
701/// MCP parameter payload for generated MCP config documents.
702#[derive(Debug, Deserialize, schemars::JsonSchema)]
703struct AtlasMcpConfigParams {
704    /// Optional project root for this call. Defaults to the active MCP project.
705    project_path: Option<String>,
706    /// MCP server name to emit. Defaults to projectatlas.
707    server_name: Option<String>,
708    /// Harness config shape: mcp-json, codex, claude-code, or opencode.
709    harness: Option<String>,
710    /// Include mcp --nearest-project in generated startup args.
711    nearest_project: Option<bool>,
712}
713
714/// Run the official RMCP stdio server.
715pub(crate) fn run_mcp_server(
716    db_path: PathBuf,
717    config_path: Option<PathBuf>,
718    session: String,
719    allow_nearest_project: bool,
720) -> Result<(), CliError> {
721    let server = ProjectAtlasMcpServer::new(db_path, config_path, session, allow_nearest_project);
722    let shutdown_server = server.clone();
723    let runtime = tokio::runtime::Builder::new_multi_thread()
724        .enable_all()
725        .build()
726        .map_err(|source| CliError::Mcp(source.to_string()))?;
727    let result = runtime.block_on(async move {
728        server
729            .serve(rmcp::transport::stdio())
730            .await
731            .map_err(|source| CliError::Mcp(source.to_string()))?
732            .waiting()
733            .await
734            .map_err(|source| CliError::Mcp(source.to_string()))
735            .map(|_| ())
736    });
737    shutdown_server.seal_usage_instances_for_projects();
738    result
739}
740
741/// Return whether the generated RMCP router contains required tool families.
742pub(crate) fn required_mcp_surface_present() -> bool {
743    REQUIRED_MCP_TOOL_NAMES
744        .iter()
745        .all(|name| mcp_tool_route_present(name))
746}
747
748/// Return whether the generated RMCP router has a concrete tool route.
749pub(crate) fn mcp_tool_route_present(name: &str) -> bool {
750    ProjectAtlasMcpServer::tool_router().has_route(name)
751}
752
753/// MCP parameter payload for scanning and symbol refresh.
754#[derive(Debug, Deserialize, schemars::JsonSchema)]
755struct AtlasScanParams {
756    /// Optional project root for this call. Defaults to the active MCP project.
757    project_path: Option<String>,
758    /// Repository root path. Defaults to the configured or indexed project root.
759    path: Option<String>,
760    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute paths.
761    nearest_project: Option<bool>,
762    /// Maximum file size to parse for symbols.
763    max_bytes: Option<u64>,
764    /// Maximum parser worker threads.
765    max_workers: Option<usize>,
766    /// Stop starting parser work after this many seconds.
767    timeout_seconds: Option<u64>,
768    /// Maximum UTF-8 file size persisted into `SQLite` text search.
769    text_index_max_bytes: Option<u64>,
770    /// Run indexing in the bounded session task registry and return immediately.
771    background: Option<bool>,
772}
773
774/// MCP parameter payload for one-shot watcher refresh.
775#[derive(Debug, Deserialize, schemars::JsonSchema)]
776struct AtlasWatchOnceParams {
777    /// Optional project root for this call. Defaults to the active MCP project.
778    project_path: Option<String>,
779    /// Repository root path. Defaults to the configured or indexed project root.
780    path: Option<String>,
781    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute paths.
782    nearest_project: Option<bool>,
783    /// Maximum parser worker threads.
784    max_workers: Option<usize>,
785    /// Stop starting parser work after this many seconds.
786    timeout_seconds: Option<u64>,
787    /// Maximum UTF-8 file size persisted into `SQLite` text search.
788    text_index_max_bytes: Option<u64>,
789    /// Run indexing in the bounded session task registry and return immediately.
790    background: Option<bool>,
791}
792
793/// MCP parameter payload for ranked node lookup.
794#[derive(Debug, Deserialize, schemars::JsonSchema)]
795struct AtlasQueryParams {
796    /// Optional project root for this call. Defaults to the active MCP project.
797    project_path: Option<String>,
798    /// Search query for path and purpose matching.
799    query: Option<String>,
800    /// Maximum number of rows to return.
801    limit: Option<usize>,
802}
803
804/// MCP parameter payload for ranked file lookup with optional absolute folder routing.
805#[derive(Debug, Deserialize, schemars::JsonSchema)]
806struct AtlasFilesParams {
807    /// Optional project root for this call. Defaults to the active MCP project.
808    project_path: Option<String>,
809    /// Search query for path and purpose matching.
810    query: Option<String>,
811    /// Folder path to constrain file lookup.
812    folder: Option<String>,
813    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute folder paths.
814    nearest_project: Option<bool>,
815    /// Optional repository-relative glob filter.
816    file_pattern: Option<String>,
817    /// Include indexed file text as a bounded fallback ranking signal.
818    include_content: Option<bool>,
819    /// Maximum number of rows to return.
820    limit: Option<usize>,
821}
822
823/// MCP parameter payload for outlining a file.
824#[derive(Debug, Deserialize, schemars::JsonSchema)]
825struct AtlasOutlineParams {
826    /// Optional project root for this call. Defaults to the active MCP project.
827    project_path: Option<String>,
828    /// Repository-relative file path.
829    file: String,
830    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute file paths.
831    nearest_project: Option<bool>,
832    /// Number of non-empty preview lines to include.
833    lines: Option<usize>,
834}
835
836/// MCP parameter payload for deterministic file summaries.
837#[derive(Debug, Deserialize, schemars::JsonSchema)]
838struct AtlasFileSummaryParams {
839    /// Optional project root for this call. Defaults to the active MCP project.
840    project_path: Option<String>,
841    /// Repository-relative file path.
842    file: String,
843    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute file paths.
844    nearest_project: Option<bool>,
845    /// Return the additive compact summary projection when true.
846    compact: Option<bool>,
847    /// Maximum rows per functions/methods/classes/types/calls section.
848    limit: Option<usize>,
849}
850
851/// MCP parameter payload for text search.
852#[derive(Debug, Deserialize, schemars::JsonSchema)]
853struct AtlasSearchParams {
854    /// Optional project root for this call. Defaults to the active MCP project.
855    project_path: Option<String>,
856    /// Literal, regex, or fuzzy pattern to search for.
857    pattern: String,
858    /// Retrieval family; lexical is the default and always-available mode.
859    retrieval_mode: Option<SearchRetrievalModeArg>,
860    /// Treat the pattern as a regex.
861    regex: Option<bool>,
862    /// Treat the pattern as a fuzzy subsequence.
863    fuzzy: Option<bool>,
864    /// Match case-sensitively.
865    case_sensitive: Option<bool>,
866    /// Optional repository-relative glob filter.
867    file_pattern: Option<String>,
868    /// Number of context lines before and after a match.
869    context_lines: Option<usize>,
870    /// Pagination start index.
871    start_index: Option<usize>,
872    /// Maximum matches to return.
873    limit: Option<usize>,
874}
875
876/// MCP parameter payload for exact source slices.
877#[derive(Debug, Deserialize, schemars::JsonSchema)]
878struct AtlasSliceParams {
879    /// Optional project root for this call. Defaults to the active MCP project.
880    project_path: Option<String>,
881    /// Repository-relative file path.
882    file: String,
883    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute file paths.
884    nearest_project: Option<bool>,
885    /// One-based start line when no symbol is supplied.
886    start_line: Option<usize>,
887    /// Optional one-based end line.
888    end_line: Option<usize>,
889    /// Symbol name to slice instead of line numbers.
890    symbol: Option<String>,
891    /// Optional parent symbol for disambiguating `symbol`.
892    symbol_parent: Option<String>,
893    /// Optional symbol kind for disambiguating `symbol`.
894    symbol_kind: Option<String>,
895    /// Optional exact signature for disambiguating `symbol`.
896    symbol_signature: Option<String>,
897    /// Optional source line for disambiguating `symbol`.
898    symbol_line: Option<usize>,
899    /// Maximum encoded bytes admitted to the slice response.
900    output_bytes: Option<u32>,
901}
902
903/// MCP parameter payload for symbol and relation lookup.
904#[derive(Debug, Deserialize, schemars::JsonSchema)]
905struct AtlasSymbolsParams {
906    /// Optional project root for this call. Defaults to the active MCP project.
907    project_path: Option<String>,
908    /// Optional repository-relative file path.
909    file: Option<String>,
910    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute file paths.
911    nearest_project: Option<bool>,
912    /// Optional symbol, signature, relation, or path query.
913    query: Option<String>,
914    /// Maximum rows to return.
915    limit: Option<usize>,
916}
917
918/// MCP parameters for legacy, detailed, or closed-analysis relation navigation.
919#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
920struct AtlasSymbolRelationsParams {
921    /// Optional project root for this call. Defaults to the active MCP project.
922    project_path: Option<String>,
923    /// Optional repository-relative file path.
924    file: Option<String>,
925    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute file paths.
926    nearest_project: Option<bool>,
927    /// Optional symbol, signature, relation, or path query.
928    query: Option<String>,
929    /// Preserve `legacy`, opt in to `detailed`, or select closed `analysis`.
930    view: Option<String>,
931    /// Return the opt-in compact detailed projection while preserving exact selectors and trust.
932    compact: Option<bool>,
933    /// Resume one exact generation- and purpose-bound detailed page.
934    cursor: Option<String>,
935    /// Complete ordered project-root set for one read-only federated call.
936    roots: Option<Vec<String>>,
937    /// Exact symbol name used as the detailed anchor; omit for a file anchor.
938    symbol: Option<String>,
939    /// Optional exact parent used to disambiguate the detailed symbol anchor.
940    symbol_parent: Option<String>,
941    /// Optional exact kind used to disambiguate the detailed symbol anchor.
942    symbol_kind: Option<String>,
943    /// Optional exact signature used to disambiguate the detailed symbol anchor.
944    symbol_signature: Option<String>,
945    /// Detailed traversal direction: `outbound` or `inbound`.
946    direction: Option<String>,
947    /// Optional exact legacy or extended relation family.
948    relation: Option<String>,
949    /// Detailed confidence floor: `exact`, `high`, `medium`, or `low`.
950    minimum_confidence: Option<String>,
951    /// Detailed resolution filter.
952    resolution: Option<String>,
953    /// Maximum detailed traversal depth.
954    depth: Option<u32>,
955    /// Retain bounded exact source occurrences in detailed rows.
956    include_occurrences: Option<bool>,
957    /// Maximum exact occurrences retained per detailed relation.
958    occurrence_limit: Option<u32>,
959    /// Maximum adjacency rows inspected by one detailed page.
960    edge_limit: Option<u32>,
961    /// Maximum unique traversal nodes retained across continuation state.
962    node_limit: Option<u32>,
963    /// Maximum unique visited-node state retained across continuation state.
964    visited_limit: Option<u32>,
965    /// Maximum exact occurrences retained across the complete page.
966    occurrence_total_limit: Option<u32>,
967    /// Maximum decoded, cursor, and service-composition intermediate bytes.
968    intermediate_bytes: Option<u64>,
969    /// Maximum service-owned elapsed milliseconds.
970    deadline_ms: Option<u64>,
971    /// Maximum encoded bytes admitted to the detailed response.
972    output_bytes: Option<u32>,
973    /// Closed analysis mode: `architecture`, `impact`, or `trace`.
974    analysis_mode: Option<String>,
975    /// Exact target symbol name for trace mode.
976    trace_target: Option<String>,
977    /// Exact target file for trace mode; alone selects a file target.
978    trace_target_file: Option<String>,
979    /// Exact target parent for symbol trace mode.
980    trace_target_parent: Option<String>,
981    /// Exact target kind required by symbol trace mode.
982    trace_target_kind: Option<String>,
983    /// Exact target signature required by symbol trace mode.
984    trace_target_signature: Option<String>,
985    /// Impact VCS scope: `working_tree`, `index`, or `revision_range`.
986    vcs: Option<String>,
987    /// Older Git revision used by `revision_range`.
988    vcs_base: Option<String>,
989    /// Newer Git revision used by `revision_range`.
990    vcs_head: Option<String>,
991    /// Include communities with containment excluded.
992    include_communities: Option<bool>,
993    /// Include dependency SCC findings.
994    include_cycles: Option<bool>,
995    /// Include conservative dead-code candidates.
996    include_dead_code: Option<bool>,
997    /// Maximum rows to return.
998    limit: Option<usize>,
999}
1000
1001/// Return whether any closed analysis-only control was supplied.
1002fn relation_analysis_controls_present(params: &AtlasSymbolRelationsParams) -> bool {
1003    params.analysis_mode.is_some()
1004        || params.trace_target.is_some()
1005        || params.trace_target_file.is_some()
1006        || params.trace_target_parent.is_some()
1007        || params.trace_target_kind.is_some()
1008        || params.trace_target_signature.is_some()
1009        || params.vcs.is_some()
1010        || params.vcs_base.is_some()
1011        || params.vcs_head.is_some()
1012        || params.include_communities.is_some()
1013        || params.include_cycles.is_some()
1014        || params.include_dead_code.is_some()
1015}
1016
1017/// Decode and validate the optional exact trace target.
1018fn relation_analysis_trace_target(
1019    store: &AtlasStore,
1020    params: &AtlasSymbolRelationsParams,
1021) -> Result<Option<RelationAnchor>, CliError> {
1022    match (&params.trace_target, &params.trace_target_file) {
1023        (Some(name), Some(file)) => {
1024            let file = validated_indexed_file_key(store, Path::new(file))?;
1025            let kind = params.trace_target_kind.as_deref().ok_or_else(|| {
1026                CliError::Service(ServiceError::InvalidInput(
1027                    MCP_ERROR_TRACE_TARGET_KIND_REQUIRED.to_string(),
1028                ))
1029            })?;
1030            let signature = params.trace_target_signature.clone().ok_or_else(|| {
1031                CliError::Service(ServiceError::InvalidInput(
1032                    MCP_ERROR_TRACE_TARGET_SIGNATURE_REQUIRED.to_string(),
1033                ))
1034            })?;
1035            Ok(Some(RelationAnchor::Symbol {
1036                file: RepositoryFilePath::new(Path::new(&file)).map_err(|error| {
1037                    CliError::Service(ServiceError::InvalidInput(error.to_string()))
1038                })?,
1039                name: name.clone(),
1040                symbol_kind: Some(parse_symbol_kind(kind)?),
1041                parent: params.trace_target_parent.clone(),
1042                signature: Some(signature),
1043            }))
1044        }
1045        (None, Some(file)) => {
1046            if params.trace_target_parent.is_some()
1047                || params.trace_target_kind.is_some()
1048                || params.trace_target_signature.is_some()
1049            {
1050                return Err(CliError::Service(ServiceError::InvalidInput(
1051                    MCP_ERROR_TRACE_TARGET_REQUIRED.to_string(),
1052                )));
1053            }
1054            let file = validated_indexed_file_key(store, Path::new(file))?;
1055            Ok(Some(RelationAnchor::File {
1056                file: RepositoryFilePath::new(Path::new(&file)).map_err(|error| {
1057                    CliError::Service(ServiceError::InvalidInput(error.to_string()))
1058                })?,
1059            }))
1060        }
1061        (Some(_), None) => Err(CliError::Service(ServiceError::InvalidInput(
1062            MCP_ERROR_TRACE_TARGET_FILE_REQUIRED.to_string(),
1063        ))),
1064        (None, None) => Ok(None),
1065    }
1066}
1067
1068/// Decode and validate the optional VCS impact selector.
1069fn relation_analysis_vcs(
1070    params: &AtlasSymbolRelationsParams,
1071) -> Result<GitImpactSelection, CliError> {
1072    match params
1073        .vcs
1074        .as_deref()
1075        .unwrap_or(MCP_RELATION_ANALYSIS_VCS_WORKING_TREE)
1076    {
1077        MCP_RELATION_ANALYSIS_VCS_WORKING_TREE => {
1078            if params.vcs_base.is_some() || params.vcs_head.is_some() {
1079                return Err(CliError::Service(ServiceError::InvalidInput(
1080                    MCP_ERROR_VCS_REVISION_FIELDS.to_string(),
1081                )));
1082            }
1083            Ok(GitImpactSelection::WorkingTree)
1084        }
1085        MCP_RELATION_ANALYSIS_VCS_INDEX => {
1086            if params.vcs_base.is_some() || params.vcs_head.is_some() {
1087                return Err(CliError::Service(ServiceError::InvalidInput(
1088                    MCP_ERROR_VCS_REVISION_FIELDS.to_string(),
1089                )));
1090            }
1091            Ok(GitImpactSelection::Index)
1092        }
1093        MCP_RELATION_ANALYSIS_VCS_REVISION_RANGE => Ok(GitImpactSelection::RevisionRange {
1094            base: params.vcs_base.clone().ok_or_else(|| {
1095                CliError::Service(ServiceError::InvalidInput(
1096                    MCP_ERROR_VCS_BASE_REQUIRED.to_string(),
1097                ))
1098            })?,
1099            head: params.vcs_head.clone().ok_or_else(|| {
1100                CliError::Service(ServiceError::InvalidInput(
1101                    MCP_ERROR_VCS_HEAD_REQUIRED.to_string(),
1102                ))
1103            })?,
1104        }),
1105        _unsupported => Err(CliError::Service(ServiceError::InvalidInput(
1106            MCP_ERROR_UNSUPPORTED_ANALYSIS_VCS.to_string(),
1107        ))),
1108    }
1109}
1110
1111/// MCP parameter payload for token savings reports.
1112#[derive(Debug, Deserialize, schemars::JsonSchema)]
1113struct AtlasTokenParams {
1114    /// Optional project root for this call. Defaults to the active MCP project.
1115    project_path: Option<String>,
1116    /// Optional session id filter.
1117    session: Option<String>,
1118    /// Include a readable ASCII chart in the MCP result.
1119    include_chart: Option<bool>,
1120    /// Optional trend grouping window: day, week, month, or year.
1121    trend_window: Option<String>,
1122    /// Optional repository-relative agent-navigation benchmark result.
1123    benchmark_results: Option<String>,
1124    /// Optional chart theme for TUI output: dark or light.
1125    theme: Option<String>,
1126}
1127
1128/// MCP parameter payload for bounded health finding lookup.
1129#[derive(Debug, Deserialize, schemars::JsonSchema)]
1130struct AtlasHealthParams {
1131    /// Optional project root for this call. Defaults to the active MCP project.
1132    project_path: Option<String>,
1133    /// Pagination start index after filters are applied.
1134    start_index: Option<usize>,
1135    /// Maximum findings to return, capped to a safe MCP page size.
1136    limit: Option<usize>,
1137    /// Optional finding category filter.
1138    category: Option<String>,
1139    /// Optional severity filter: info, warning, or error.
1140    severity: Option<String>,
1141    /// Optional repository-relative primary or related path prefix.
1142    path_prefix: Option<String>,
1143    /// Return counts and paging metadata without finding rows.
1144    summary_only: Option<bool>,
1145    /// Restrict findings to source files and folders that contain source files.
1146    source_only: Option<bool>,
1147    /// Include non-source files and asset-only folders in the purpose queue.
1148    include_assets: Option<bool>,
1149    /// Include low-priority files in the purpose queue.
1150    include_low_priority_files: Option<bool>,
1151    /// Opt in to bounded current coverage discovery instead of structural findings.
1152    coverage: Option<bool>,
1153    /// Optional source parser coverage filter.
1154    parser: Option<String>,
1155    /// Optional derived-fact provider coverage filter.
1156    provider: Option<String>,
1157    /// Optional relationship-family coverage filter.
1158    relation: Option<String>,
1159    /// Optional complete, partial, failed, ignored, oversized, quarantined, or stale filter.
1160    coverage_state: Option<String>,
1161    /// Optional exact coverage reason filter.
1162    reason: Option<String>,
1163}
1164
1165/// MCP parameter payload for bounded task-scoped purpose curation.
1166#[derive(Debug, Deserialize, schemars::JsonSchema)]
1167struct AtlasPurposeQueueParams {
1168    /// Shared health paging and purpose-scope filters.
1169    #[serde(flatten)]
1170    health: AtlasHealthParams,
1171    /// Host-owned task label for deterministic purpose-curator work identity.
1172    task: Option<String>,
1173}
1174
1175/// MCP parameter payload for parity reports.
1176#[derive(Debug, Deserialize, schemars::JsonSchema)]
1177struct AtlasParityParams {
1178    /// Optional project root for this call. Defaults to the active MCP project.
1179    project_path: Option<String>,
1180    /// Parity profile. Defaults to repository-intelligence.
1181    profile: Option<String>,
1182}
1183
1184/// MCP parameter payload for legacy purpose cleanup.
1185#[derive(Debug, Deserialize, schemars::JsonSchema)]
1186struct AtlasStripLegacyParams {
1187    /// Optional project root for this call. Defaults to the active MCP project.
1188    project_path: Option<String>,
1189    /// Repository root path. Defaults to the configured or indexed project root.
1190    path: Option<String>,
1191    /// Opt in to nearest indexed `ProjectAtlas` project discovery for absolute paths.
1192    nearest_project: Option<bool>,
1193    /// Remove legacy `.purpose` files when true.
1194    apply: Option<bool>,
1195    /// Preview cleanup without modifying files.
1196    dry_run: Option<bool>,
1197    /// Also report conservative source Purpose header candidates.
1198    strip_source_headers: Option<bool>,
1199}
1200
1201/// MCP parameter payload for runtime index cleanup.
1202#[derive(Debug, Deserialize, schemars::JsonSchema)]
1203struct AtlasResetIndexParams {
1204    /// Optional project root for this call. Defaults to the active MCP project.
1205    project_path: Option<String>,
1206    /// Remove runtime index/cache files when true.
1207    apply: Option<bool>,
1208    /// Preview cleanup without modifying files.
1209    dry_run: Option<bool>,
1210    /// Also remove generated project-local MCP config.
1211    include_mcp_config: Option<bool>,
1212}
1213
1214/// MCP parameter payload for setting purpose metadata.
1215#[derive(Debug, Deserialize, schemars::JsonSchema)]
1216struct AtlasPurposeSetParams {
1217    /// Optional project root for this call. Defaults to the active MCP project.
1218    project_path: Option<String>,
1219    /// Indexed repository-relative path.
1220    path: String,
1221    /// Agent-approved purpose one-liner.
1222    purpose: String,
1223}
1224
1225/// MCP payload for one batch purpose review item.
1226#[derive(Debug, Deserialize, schemars::JsonSchema)]
1227#[schemars(inline)]
1228struct AtlasPurposeReviewItem {
1229    /// Indexed repository-relative path.
1230    path: String,
1231    /// Agent-reviewed purpose one-liner. Required for generated suggestions.
1232    purpose: Option<String>,
1233    /// Confirm the existing non-generated purpose after inspection.
1234    confirm_existing: Option<bool>,
1235    /// Queue task copied from the purpose-curation item.
1236    task: Option<String>,
1237    /// Queue work key copied from the purpose-curation item.
1238    work_key: Option<String>,
1239    /// Queue state token copied from the purpose-curation item.
1240    state_token: Option<String>,
1241}
1242
1243/// MCP parameter payload for batch purpose review.
1244#[derive(Debug, Deserialize, schemars::JsonSchema)]
1245struct AtlasPurposeReviewParams {
1246    /// Optional project root for this call. Defaults to the active MCP project.
1247    project_path: Option<String>,
1248    /// Purpose records to agent-review.
1249    items: Vec<AtlasPurposeReviewItem>,
1250    /// Apply reviewed purposes. Defaults to false for preview.
1251    apply: Option<bool>,
1252}
1253
1254/// MCP parameter payload for resolving health findings.
1255#[derive(Debug, Deserialize, schemars::JsonSchema)]
1256struct AtlasHealthResolveParams {
1257    /// Optional project root for this call. Defaults to the active MCP project.
1258    project_path: Option<String>,
1259    /// Stable finding id from `atlas_health`.
1260    finding_id: String,
1261    /// Finding category.
1262    category: String,
1263    /// Primary path.
1264    path: String,
1265    /// Optional related path.
1266    related_path: Option<String>,
1267    /// Agent rationale for resolving the finding.
1268    rationale: String,
1269}
1270
1271/// Active `ProjectAtlas` database and configuration selected for MCP calls.
1272#[derive(Debug, Clone)]
1273struct McpProjectState {
1274    /// Canonical selected repository root.
1275    root: PathBuf,
1276    /// Selected durable `SQLite` index path.
1277    db_path: PathBuf,
1278    /// Selected scan/import configuration path.
1279    config_path: Option<PathBuf>,
1280}
1281
1282/// Store ownership selected by the existing relation tool request shape.
1283enum SymbolRelationStores<'a> {
1284    /// Compatibility-preserving selected-project query.
1285    Single(&'a AtlasStore),
1286    /// Explicit call-owned ordered read snapshots.
1287    Federated(Vec<FederatedStore>),
1288}
1289
1290impl SymbolRelationStores<'_> {
1291    /// Borrow the selected first project while validating local selectors.
1292    fn primary(&self) -> &AtlasStore {
1293        match self {
1294            Self::Single(store) => store,
1295            Self::Federated(stores) => stores[0].store(),
1296        }
1297    }
1298}
1299
1300/// Exact root/database/project identity where this MCP process recorded telemetry.
1301#[derive(Debug, Clone, Eq, Hash, PartialEq)]
1302struct McpUsageProjectBinding {
1303    /// Canonical selected repository root.
1304    root: PathBuf,
1305    /// Exact authoritative database used for the telemetry write.
1306    db_path: PathBuf,
1307    /// Project identity captured by the already-open selected store.
1308    project_instance_id: ProjectInstanceId,
1309}
1310
1311/// One bounded broad-source token baseline keyed to a complete generation.
1312#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1313struct McpSourceTokenBaselineKey {
1314    /// Exact project binding whose source files supplied the baseline.
1315    binding: McpUsageProjectBinding,
1316    /// Complete publication generation represented by the baseline.
1317    generation: projectatlas_core::IndexGeneration,
1318    /// Optional repository folder filter applied to the baseline.
1319    folder: Option<String>,
1320    /// Optional repository file-pattern filter applied to the baseline.
1321    file_pattern: Option<String>,
1322}
1323
1324/// Deferred telemetry payload recorded only after a verified result is accepted.
1325#[derive(Debug)]
1326struct McpUsageIntent {
1327    /// Stable MCP event family.
1328    command: &'static str,
1329    /// Optional selected path or filter.
1330    path: Option<String>,
1331    /// Optional caller query.
1332    query: Option<String>,
1333    /// Baseline used by the existing usage accounting contract.
1334    baseline: McpUsageBaseline,
1335}
1336
1337/// Existing telemetry baseline variants retained across verified-read acceptance.
1338#[derive(Debug)]
1339enum McpUsageBaseline {
1340    /// Modeled selected-candidate token count.
1341    Estimate(usize),
1342    /// Modeled avoided directory-walk token count.
1343    DirectoryWalk(usize),
1344    /// Exact source text replaced by the accepted response.
1345    Text(String),
1346}
1347
1348impl McpUsageIntent {
1349    /// Defer one selected-candidate token estimate until result acceptance.
1350    fn estimate(
1351        command: &'static str,
1352        path: Option<String>,
1353        query: Option<String>,
1354        baseline_tokens: usize,
1355    ) -> Self {
1356        Self {
1357            command,
1358            path,
1359            query,
1360            baseline: McpUsageBaseline::Estimate(baseline_tokens),
1361        }
1362    }
1363
1364    /// Defer one avoided directory-walk estimate until result acceptance.
1365    fn directory_walk(
1366        command: &'static str,
1367        path: Option<String>,
1368        query: Option<String>,
1369        baseline_tokens: usize,
1370    ) -> Self {
1371        Self {
1372            command,
1373            path,
1374            query,
1375            baseline: McpUsageBaseline::DirectoryWalk(baseline_tokens),
1376        }
1377    }
1378
1379    /// Defer one exact source-text replacement event until result acceptance.
1380    fn text(command: &'static str, path: Option<String>, baseline_text: String) -> Self {
1381        Self {
1382            command,
1383            path,
1384            query: None,
1385            baseline: McpUsageBaseline::Text(baseline_text),
1386        }
1387    }
1388}
1389
1390impl McpUsageProjectBinding {
1391    /// Capture one exact root/database/identity authority without another SQL read.
1392    fn capture(state: &McpProjectState, store: &AtlasStore) -> Result<Self, DbError> {
1393        let captured = store.captured_project_binding()?;
1394        Ok(Self {
1395            root: state.root.clone(),
1396            db_path: state.db_path.clone(),
1397            project_instance_id: captured.project_instance_id,
1398        })
1399    }
1400}
1401
1402/// Current telemetry identity for one exact selected project binding.
1403#[derive(Clone, Debug)]
1404struct McpUsageProjectRuntime {
1405    /// Exact root/database authority that owns this identity.
1406    binding: McpUsageProjectBinding,
1407    /// Current bounded identity for this process/project pair.
1408    instance: Arc<Mutex<UsageRuntimeInstance>>,
1409}
1410
1411/// Mutable bounded telemetry lifecycles shared by all MCP server clones.
1412#[derive(Debug, Default)]
1413struct McpUsageRuntime {
1414    /// Distinct selected-project identities owned by this MCP process.
1415    entries: Vec<McpUsageProjectRuntime>,
1416    /// Bounded modeled baselines reused without decoding every indexed file per call.
1417    source_token_baselines: VecDeque<(McpSourceTokenBaselineKey, usize)>,
1418}
1419
1420impl McpUsageRuntime {
1421    /// Return or create the identity for one selected project binding.
1422    fn instance_for_binding(
1423        &mut self,
1424        binding: McpUsageProjectBinding,
1425    ) -> Option<Arc<Mutex<UsageRuntimeInstance>>> {
1426        if let Some(entry) = self.entries.iter().find(|entry| entry.binding == binding) {
1427            return Some(Arc::clone(&entry.instance));
1428        }
1429        if self.entries.len() >= MCP_TELEMETRY_PROJECT_BINDING_LIMIT {
1430            return None;
1431        }
1432        let instance = Arc::new(Mutex::new(UsageRuntimeInstance::new(
1433            UsageInstanceOwner::McpProcess,
1434        )?));
1435        self.entries.push(McpUsageProjectRuntime {
1436            binding,
1437            instance: Arc::clone(&instance),
1438        });
1439        Some(instance)
1440    }
1441
1442    /// Clone the bounded project/runtime set before shutdown database I/O.
1443    fn snapshot(&self) -> Vec<McpUsageProjectRuntime> {
1444        self.entries.clone()
1445    }
1446
1447    /// Return a cached generation-bound broad-source token baseline.
1448    fn source_token_baseline(&self, key: &McpSourceTokenBaselineKey) -> Option<usize> {
1449        self.source_token_baselines
1450            .iter()
1451            .find_map(|(candidate, value)| (candidate == key).then_some(*value))
1452    }
1453
1454    /// Retain one baseline without allowing arbitrary filter keys to grow memory.
1455    fn insert_source_token_baseline(&mut self, key: McpSourceTokenBaselineKey, value: usize) {
1456        if let Some(index) = self
1457            .source_token_baselines
1458            .iter()
1459            .position(|(candidate, _value)| candidate == &key)
1460        {
1461            let _removed = self.source_token_baselines.remove(index);
1462        }
1463        while self.source_token_baselines.len() >= MCP_SOURCE_TOKEN_BASELINE_LIMIT {
1464            let _removed = self.source_token_baselines.pop_front();
1465        }
1466        self.source_token_baselines.push_back((key, value));
1467    }
1468}
1469
1470/// Select whether project-state discovery validates configuration content immediately.
1471#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1472enum McpConfigValidation {
1473    /// Validate configuration before returning selected project state.
1474    Immediate,
1475    /// Defer configuration reads to the admitted operation-owned work boundary.
1476    Deferred,
1477}
1478
1479/// MCP response for compatibility map export.
1480#[derive(Debug, Serialize)]
1481struct McpMapReport {
1482    /// Canonical project root used for map generation.
1483    root: String,
1484    /// Map path from the effective config.
1485    map_path: String,
1486    /// Whether a map file was written by this call.
1487    written: bool,
1488    /// Whether JSON compatibility output was requested.
1489    json: bool,
1490    /// Human-readable reason when no file was written.
1491    skipped_reason: Option<String>,
1492}
1493
1494/// MCP response for lint reports.
1495#[derive(Debug, Serialize)]
1496struct McpLintReport {
1497    /// Whether lint passed.
1498    ok: bool,
1499    /// CLI-compatible exit code that callers can gate on.
1500    exit_code: i32,
1501    /// Combined lint report text.
1502    report: String,
1503}
1504
1505/// Role-typed selected root for MCP path routing.
1506#[derive(Debug, Clone)]
1507struct McpSelectedRoot(PathBuf);
1508
1509impl McpSelectedRoot {
1510    /// Build the selected root wrapper from active project state.
1511    fn from_state(state: &McpProjectState) -> Self {
1512        Self(state.root.clone())
1513    }
1514
1515    /// Convert an absolute path inside this root into a repository key.
1516    fn repo_key_for(&self, path: &McpAbsolutePath) -> Result<Option<McpRepoKey>, CliError> {
1517        if !path.as_path().starts_with(&self.0) {
1518            return Ok(None);
1519        }
1520        normalize_repo_path(&self.0, path.as_path())
1521            .map(McpRepoKey)
1522            .map(Some)
1523            .map_err(ProjectAtlasMcpServer::selected_project_path_error)
1524    }
1525}
1526
1527/// Role-typed indexed project root discovered by nearest-project routing.
1528#[derive(Debug, Clone)]
1529struct McpIndexedRoot {
1530    /// Canonical indexed project root.
1531    root: PathBuf,
1532    /// Durable index path that records the same root.
1533    db_path: PathBuf,
1534}
1535
1536/// Canonical absolute path supplied to an MCP path-bearing tool.
1537#[derive(Debug, Clone)]
1538struct McpAbsolutePath(PathBuf);
1539
1540impl McpAbsolutePath {
1541    /// Canonicalize an absolute path through its nearest existing ancestor.
1542    ///
1543    /// File selectors may refer to an indexed path that was deleted offline.
1544    /// Canonicalizing the existing ancestor preserves symlink/root-escape
1545    /// checks without requiring the selected leaf to still exist.
1546    fn canonicalize(path: &Path) -> Result<Self, CliError> {
1547        let mut existing = path;
1548        let mut missing_suffix = Vec::new();
1549        while !existing.exists() {
1550            let file_name = existing
1551                .file_name()
1552                .ok_or_else(|| missing_ancestor_error(path))?;
1553            missing_suffix.push(PathBuf::from(file_name));
1554            existing = existing
1555                .parent()
1556                .ok_or_else(|| missing_ancestor_error(path))?;
1557        }
1558        let mut canonical = canonical_project_root(existing)?;
1559        for component in missing_suffix.into_iter().rev() {
1560            canonical.push(component);
1561        }
1562        Ok(Self(canonical))
1563    }
1564
1565    /// Borrow the canonical path.
1566    fn as_path(&self) -> &Path {
1567        &self.0
1568    }
1569
1570    /// Return the directory where nearest-indexed-root discovery should begin.
1571    fn nearest_search_start(&self) -> &Path {
1572        if self.0.is_dir() {
1573            &self.0
1574        } else {
1575            self.0.parent().unwrap_or(self.as_path())
1576        }
1577    }
1578}
1579
1580/// Build the adapter error for an absolute selector without an inspectable ancestor.
1581fn missing_ancestor_error(path: &Path) -> CliError {
1582    CliError::InvalidInput(format!(
1583        "absolute path '{}' has no existing ancestor",
1584        path.display()
1585    ))
1586}
1587
1588/// Repository-relative path key derived from a typed root/path conversion.
1589#[derive(Debug, Clone)]
1590struct McpRepoKey(String);
1591
1592impl McpRepoKey {
1593    /// Consume the wrapper into the repository key string.
1594    fn into_string(self) -> String {
1595        self.0
1596    }
1597}
1598
1599/// MCP path resolution result with selected-project audit state.
1600#[derive(Debug, Clone)]
1601struct McpResolvedRepoPath {
1602    /// Project state selected for the request.
1603    state: McpProjectState,
1604    /// Repository-relative path key inside the selected project.
1605    key: String,
1606    /// Whether nearest-project routing changed the selected root/DB.
1607    routed_project: bool,
1608}
1609
1610/// Agent-facing error payload for failed MCP calls.
1611#[derive(Debug, Serialize)]
1612struct McpErrorResponse {
1613    /// Structured MCP error details.
1614    error: McpErrorPayload,
1615}
1616
1617/// Stable serialized schema for MCP error details.
1618#[derive(Debug, Serialize)]
1619struct McpErrorPayload {
1620    /// Stable machine-readable error kind.
1621    kind: AgentErrorKind,
1622    /// Human-readable error and recovery guidance.
1623    message: String,
1624    /// Bounded local-source mismatch details when refresh is required.
1625    #[serde(skip_serializing_if = "Option::is_none")]
1626    refresh_required: Option<IndexRefreshRequired>,
1627    /// Exact selected-root initialization handoff.
1628    #[serde(skip_serializing_if = "Option::is_none")]
1629    init_required: Option<IndexInitRequired>,
1630    /// Bare/common Git root selection diagnostic.
1631    #[serde(skip_serializing_if = "Option::is_none")]
1632    worktree_required: Option<ProjectWorktreeRequired>,
1633    /// Bounded source/policy diagnostic when verification cannot complete.
1634    #[serde(skip_serializing_if = "Option::is_none")]
1635    verification_incomplete: Option<IndexVerificationIncomplete>,
1636    /// Project/index identity mismatch details.
1637    #[serde(skip_serializing_if = "Option::is_none")]
1638    project_mismatch: Option<IndexProjectMismatch>,
1639    /// Content-free database placement details for a rejected `SQLite` profile.
1640    #[serde(skip_serializing_if = "Option::is_none")]
1641    database_filesystem: Option<DatabaseFilesystemErrorPayload>,
1642    /// Optional retrieval capability state and recovery guidance.
1643    #[serde(skip_serializing_if = "Option::is_none")]
1644    search_capability: Option<crate::SearchCapabilityErrorPayload>,
1645    /// Reusable recovery call when refresh is required.
1646    #[serde(skip_serializing_if = "Option::is_none")]
1647    next: Option<McpNextCall>,
1648}
1649
1650/// Direct MCP recovery selector.
1651#[derive(Debug, Serialize)]
1652struct McpNextCall {
1653    /// Existing MCP tool that safely recovers the selected project.
1654    tool: &'static str,
1655    /// Canonical project root for per-call isolation.
1656    project_path: String,
1657}
1658
1659/// Agent-facing payload for the selected MCP project.
1660#[derive(Debug, Serialize)]
1661struct McpProjectStateResponse {
1662    /// Selected project details.
1663    project: McpProjectStatePayload,
1664}
1665
1666/// Stable serialized schema for the selected MCP project.
1667#[derive(Debug, Serialize)]
1668struct McpProjectStatePayload {
1669    /// Canonical repository root.
1670    root: String,
1671    /// Selected durable `SQLite` index path.
1672    db: String,
1673    /// Selected configuration path when present.
1674    config: Option<String>,
1675    /// Active selection status.
1676    status: McpProjectStatus,
1677}
1678
1679/// Status values for MCP project selection responses.
1680#[derive(Debug, Serialize)]
1681#[serde(rename_all = "snake_case")]
1682enum McpProjectStatus {
1683    /// The selected project is active for defaulted MCP calls.
1684    Active,
1685}
1686
1687/// Agent-facing payload for an MCP purpose update.
1688#[derive(Debug, Serialize)]
1689struct McpPurposeSetResponse {
1690    /// Purpose update result details.
1691    purpose_set: McpPurposeSetPayload,
1692}
1693
1694/// Stable serialized schema for an MCP purpose update.
1695#[derive(Debug, Serialize)]
1696struct McpPurposeSetPayload {
1697    /// Indexed repository-relative path whose purpose was updated.
1698    path: String,
1699    /// Durable purpose status after the update.
1700    status: PurposeStatus,
1701    /// Source of the durable purpose after the update.
1702    source: PurposeSource,
1703    /// Whether the purpose has been agent-reviewed.
1704    agent_reviewed: bool,
1705}
1706
1707/// MCP-session capability/settings payload.
1708#[derive(Debug, Serialize)]
1709struct McpSessionCapabilities {
1710    /// Runtime identity and compiled tool surface.
1711    runtime: RuntimeInfoReport,
1712    /// Selected project identity and index status.
1713    selected_project: McpSelectedProjectCapability,
1714    /// Route-affecting startup policy.
1715    startup_policy: McpStartupPolicy,
1716    /// Absolute-path routing scope.
1717    path_scope: McpPathScope,
1718    /// Scan behavior visible to harnesses.
1719    scan_policy: McpScanPolicy,
1720    /// Token telemetry write mode for this process.
1721    telemetry: McpTelemetryPolicy,
1722    /// Privacy guarantees for this payload.
1723    privacy: McpPrivacyPolicy,
1724}
1725
1726/// Selected project identity inside capability/settings payloads.
1727#[derive(Debug, Serialize)]
1728struct McpSelectedProjectCapability {
1729    /// Canonical repository root.
1730    root: String,
1731    /// Selected durable `SQLite` index path.
1732    db: String,
1733    /// Selected configuration path when present.
1734    config: Option<String>,
1735    /// Whether the selected durable index exists.
1736    index_status: McpIndexStatus,
1737}
1738
1739/// Startup policy fields for MCP sessions.
1740#[derive(Debug, Serialize)]
1741struct McpStartupPolicy {
1742    /// Whether nearest indexed project routing is enabled by default.
1743    nearest_project: McpPolicyState,
1744}
1745
1746/// Scan policy fields relevant before source reads.
1747#[derive(Debug, Serialize)]
1748struct McpScanPolicy {
1749    /// Settings calls and session briefs never scan implicitly.
1750    implicit_scan: McpPolicyState,
1751    /// Maximum `UTF-8` file size persisted into `SQLite` text search.
1752    text_index_max_bytes: u64,
1753}
1754
1755/// Telemetry write policy for this MCP process.
1756#[derive(Debug, Serialize)]
1757struct McpTelemetryPolicy {
1758    /// Whether token telemetry writes are enabled.
1759    mode: McpPolicyState,
1760}
1761
1762/// Privacy contract for capability/settings payloads.
1763#[derive(Debug, Serialize)]
1764struct McpPrivacyPolicy {
1765    /// No arbitrary process environment dump is included.
1766    environment_dump: bool,
1767    /// No secret or token values are included.
1768    secret_values: bool,
1769    /// Host paths are limited to `ProjectAtlas` runtime/root/DB/config paths.
1770    projectatlas_paths_only: bool,
1771}
1772
1773/// Two-state policy enum serialized for MCP contracts.
1774#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1775#[serde(rename_all = "snake_case")]
1776enum McpPolicyState {
1777    /// Policy is enabled.
1778    Enabled,
1779    /// Policy is disabled.
1780    Disabled,
1781}
1782
1783/// Selected index availability.
1784#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1785#[serde(rename_all = "snake_case")]
1786enum McpIndexStatus {
1787    /// The selected index file exists.
1788    Available,
1789    /// The selected index file is missing.
1790    Missing,
1791}
1792
1793/// Absolute path routing scope.
1794#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1795#[serde(rename_all = "snake_case")]
1796enum McpPathScope {
1797    /// Calls stay within the selected project.
1798    SelectedProject,
1799    /// Absolute paths may route to the nearest indexed project.
1800    NearestIndexedProject,
1801}
1802
1803/// Explicit compact file-summary payload with actionable facts and redundant state removed.
1804#[derive(Debug, Serialize)]
1805struct McpFileSummaryPayload<'a> {
1806    /// Explicit compact file intelligence.
1807    file_summary: McpFileSummary<'a>,
1808}
1809
1810/// Compact projection used when an agent follows a default startup recommendation.
1811#[derive(Debug, Serialize)]
1812struct McpFileSummary<'a> {
1813    /// Repository-relative file path.
1814    file_path: &'a str,
1815    /// Detected language or file family.
1816    language: &'a str,
1817    /// Source line count.
1818    line_count: usize,
1819    /// Non-default source state when live source was unavailable.
1820    #[serde(skip_serializing_if = "Option::is_none")]
1821    source_status: Option<&'a str>,
1822    /// Source read diagnostic when one exists.
1823    #[serde(skip_serializing_if = "Option::is_none")]
1824    source_error: Option<&'a str>,
1825    /// Parser family that produced the summary.
1826    parser_kind: &'a str,
1827    /// Summary quality state agents must inspect before trusting generated prose.
1828    summary_status: &'a str,
1829    /// Durable file responsibility when present.
1830    #[serde(skip_serializing_if = "Option::is_none")]
1831    file_purpose: Option<&'a str>,
1832    /// Purpose status for suggestions or other unreviewed rows.
1833    #[serde(skip_serializing_if = "Option::is_none")]
1834    file_purpose_status: Option<&'a str>,
1835    /// Purpose source for suggestions or other unreviewed rows.
1836    #[serde(skip_serializing_if = "Option::is_none")]
1837    file_purpose_source: Option<&'a str>,
1838    /// Whether an agent approved the retained responsibility.
1839    #[serde(skip_serializing_if = "is_false")]
1840    file_purpose_agent_reviewed: bool,
1841    /// Current deterministic content summary.
1842    content_summary: &'a str,
1843    /// Package or module name when present.
1844    #[serde(skip_serializing_if = "Option::is_none")]
1845    package: Option<&'a str>,
1846    /// File documentation when present.
1847    #[serde(skip_serializing_if = "Option::is_none")]
1848    docstring: Option<&'a str>,
1849    /// Whether the default bounded repeated sections omitted rows.
1850    #[serde(skip_serializing_if = "is_false")]
1851    truncated: bool,
1852    /// Indexed functions when present.
1853    #[serde(skip_serializing_if = "Option::is_none")]
1854    functions: Option<Vec<McpFileSymbolSummary<'a>>>,
1855    /// Indexed methods when present.
1856    #[serde(skip_serializing_if = "Option::is_none")]
1857    methods: Option<Vec<McpFileSymbolSummary<'a>>>,
1858    /// Indexed classes when present.
1859    #[serde(skip_serializing_if = "Option::is_none")]
1860    classes: Option<Vec<McpFileSymbolSummary<'a>>>,
1861    /// Indexed type declarations when present.
1862    #[serde(skip_serializing_if = "Option::is_none")]
1863    types: Option<Vec<McpFileSymbolSummary<'a>>>,
1864    /// Imports when present.
1865    #[serde(skip_serializing_if = "Option::is_none")]
1866    imports: Option<&'a [String]>,
1867    /// Manifest dependencies when present.
1868    #[serde(skip_serializing_if = "Option::is_none")]
1869    dependencies: Option<&'a [String]>,
1870    /// Exported declarations when present.
1871    #[serde(skip_serializing_if = "Option::is_none")]
1872    exports: Option<&'a [String]>,
1873    /// Call rows when present.
1874    #[serde(skip_serializing_if = "Option::is_none")]
1875    calls: Option<&'a [FileCallSummary]>,
1876    /// Coverage details only when they require agent attention.
1877    #[serde(skip_serializing_if = "Option::is_none")]
1878    coverage: Option<McpCompactCoverageDigest<'a>>,
1879}
1880
1881/// Compact symbol row that omits empty legacy fields.
1882#[derive(Debug, Serialize)]
1883struct McpFileSymbolSummary<'a> {
1884    /// Symbol name.
1885    name: &'a str,
1886    /// Symbol kind.
1887    kind: &'a str,
1888    /// One-based start line.
1889    line: usize,
1890    /// One-based end line.
1891    end_line: usize,
1892    /// Declaration signature.
1893    signature: &'a str,
1894    /// Whether the declaration is externally visible.
1895    exported: bool,
1896    /// Extracted documentation when present.
1897    #[serde(skip_serializing_if = "Option::is_none")]
1898    documentation: Option<&'a str>,
1899    /// Parent declaration when present.
1900    #[serde(skip_serializing_if = "Option::is_none")]
1901    parent: Option<&'a str>,
1902    /// Indexed callers when present.
1903    #[serde(skip_serializing_if = "Option::is_none")]
1904    called_by: Option<&'a [String]>,
1905}
1906
1907impl<'a> From<&'a FileSymbolSummary> for McpFileSymbolSummary<'a> {
1908    fn from(symbol: &'a FileSymbolSummary) -> Self {
1909        Self {
1910            name: &symbol.name,
1911            kind: &symbol.kind,
1912            line: symbol.line,
1913            end_line: symbol.end_line,
1914            signature: &symbol.signature,
1915            exported: symbol.exported,
1916            documentation: nonempty_str(&symbol.documentation),
1917            parent: nonempty_str(&symbol.parent),
1918            called_by: nonempty_slice(&symbol.called_by),
1919        }
1920    }
1921}
1922
1923/// Project nonempty symbol rows into their compact MCP representation.
1924fn compact_file_symbols(symbols: &[FileSymbolSummary]) -> Option<Vec<McpFileSymbolSummary<'_>>> {
1925    (!symbols.is_empty()).then(|| symbols.iter().map(McpFileSymbolSummary::from).collect())
1926}
1927
1928impl<'a> From<&'a FileSummaryReport> for McpFileSummary<'a> {
1929    fn from(report: &'a FileSummaryReport) -> Self {
1930        let reviewed_purpose = report.file_purpose_agent_reviewed;
1931        let coverage_requires_attention = !report.coverage.available
1932            || report.coverage.trust != CoverageTrustState::Trusted
1933            || report.coverage.omitted > 0
1934            || report.coverage.truncated;
1935        Self {
1936            file_path: &report.file_path,
1937            language: &report.language,
1938            line_count: report.line_count,
1939            source_status: (report.source_status != MCP_FILE_SOURCE_STATUS_LIVE)
1940                .then_some(report.source_status.as_str()),
1941            source_error: nonempty_str(&report.source_error),
1942            parser_kind: &report.parser_kind,
1943            summary_status: &report.summary_status,
1944            file_purpose: nonempty_str(&report.file_purpose),
1945            file_purpose_status: (!reviewed_purpose).then_some(report.file_purpose_status.as_str()),
1946            file_purpose_source: (!reviewed_purpose).then_some(report.file_purpose_source.as_str()),
1947            file_purpose_agent_reviewed: reviewed_purpose,
1948            content_summary: &report.content_summary,
1949            package: nonempty_str(&report.package),
1950            docstring: nonempty_str(&report.docstring),
1951            truncated: report.truncated,
1952            functions: compact_file_symbols(&report.functions),
1953            methods: compact_file_symbols(&report.methods),
1954            classes: compact_file_symbols(&report.classes),
1955            types: compact_file_symbols(&report.types),
1956            imports: nonempty_slice(&report.imports),
1957            dependencies: nonempty_slice(&report.dependencies),
1958            exports: nonempty_slice(&report.exports),
1959            calls: nonempty_slice(&report.calls),
1960            coverage: coverage_requires_attention
1961                .then(|| McpCompactCoverageDigest::from(&report.coverage)),
1962        }
1963    }
1964}
1965
1966/// Adapter-local sparse coverage counts for the explicit compact summary.
1967#[derive(Debug, Serialize)]
1968struct McpCompactCoverageStateCounts {
1969    /// Complete coverage rows.
1970    #[serde(skip_serializing_if = "is_zero_u32")]
1971    complete: u32,
1972    /// Partial coverage rows.
1973    #[serde(skip_serializing_if = "is_zero_u32")]
1974    partial: u32,
1975    /// Failed coverage rows.
1976    #[serde(skip_serializing_if = "is_zero_u32")]
1977    failed: u32,
1978    /// Intentionally ignored coverage rows.
1979    #[serde(skip_serializing_if = "is_zero_u32")]
1980    ignored: u32,
1981    /// Oversized coverage rows.
1982    #[serde(skip_serializing_if = "is_zero_u32")]
1983    oversized: u32,
1984    /// Quarantined coverage rows.
1985    #[serde(skip_serializing_if = "is_zero_u32")]
1986    quarantined: u32,
1987    /// Stale coverage rows.
1988    #[serde(skip_serializing_if = "is_zero_u32")]
1989    stale: u32,
1990}
1991
1992/// Adapter-local compact coverage projection that leaves shared serialization unchanged.
1993#[derive(Debug, Serialize)]
1994struct McpCompactCoverageDigest<'a> {
1995    /// False when no current coverage rows exist.
1996    #[serde(skip_serializing_if = "is_true")]
1997    available: bool,
1998    /// Active generation shared by retained rows.
1999    active_generation: &'a IndexGeneration,
2000    /// Source parser pass recorded for the file.
2001    #[serde(skip_serializing_if = "Option::is_none")]
2002    parser: Option<&'a ParserKind>,
2003    /// Fact provider pass recorded for the file.
2004    #[serde(skip_serializing_if = "Option::is_none")]
2005    provider: Option<&'a ParserKind>,
2006    /// Sparse per-state counts.
2007    states: McpCompactCoverageStateCounts,
2008    /// Total items declared by retained rows.
2009    total: u64,
2010    /// Covered items declared by retained rows.
2011    covered: u64,
2012    /// Omitted or untrusted items declared by retained rows.
2013    #[serde(skip_serializing_if = "is_zero_u64")]
2014    omitted: u64,
2015    /// Number of retained relation-family rows.
2016    #[serde(skip_serializing_if = "is_zero_u32")]
2017    relation_rows: u32,
2018    /// Whether digest bounds omitted additional selected-file rows.
2019    #[serde(skip_serializing_if = "is_false")]
2020    truncated: bool,
2021    /// Conservative trust state across retained rows.
2022    trust: &'a CoverageTrustState,
2023    /// Existing opt-in health call for deeper coverage discovery.
2024    next_call: &'a NavigationNextCall,
2025}
2026
2027impl<'a> From<&'a CoverageDigest> for McpCompactCoverageDigest<'a> {
2028    fn from(coverage: &'a CoverageDigest) -> Self {
2029        Self {
2030            available: coverage.available,
2031            active_generation: &coverage.active_generation,
2032            parser: coverage.parser.as_ref(),
2033            provider: coverage.provider.as_ref(),
2034            states: McpCompactCoverageStateCounts {
2035                complete: coverage.states.complete,
2036                partial: coverage.states.partial,
2037                failed: coverage.states.failed,
2038                ignored: coverage.states.ignored,
2039                oversized: coverage.states.oversized,
2040                quarantined: coverage.states.quarantined,
2041                stale: coverage.states.stale,
2042            },
2043            total: coverage.total,
2044            covered: coverage.covered,
2045            omitted: coverage.omitted,
2046            relation_rows: coverage.relation_rows,
2047            truncated: coverage.truncated,
2048            trust: &coverage.trust,
2049            next_call: &coverage.next_call,
2050        }
2051    }
2052}
2053
2054/// Adapter-local compact detailed-relation node without stable-key duplication.
2055#[derive(Debug, Serialize)]
2056struct McpCompactDetailedRelationNode<'a> {
2057    /// Exact selector accepted by later relation, summary, and slice calls.
2058    selector: &'a EntitySelector,
2059    /// Accepted, unavailable, or non-local purpose state.
2060    purpose: &'a RelationPurpose,
2061    /// Authoritative coverage rows for the selected local owner.
2062    #[serde(skip_serializing_if = "Option::is_none")]
2063    coverage: Option<&'a [CoverageRecord]>,
2064}
2065
2066impl<'a> From<&'a DetailedRelationNode> for McpCompactDetailedRelationNode<'a> {
2067    fn from(node: &'a DetailedRelationNode) -> Self {
2068        Self {
2069            selector: node.entity.selector(),
2070            purpose: &node.purpose,
2071            coverage: nonempty_slice(&node.coverage),
2072        }
2073    }
2074}
2075
2076/// Resolution facts retained by the compact relation projection.
2077#[derive(Debug, Serialize)]
2078#[serde(tag = "status", rename_all = "snake_case")]
2079enum McpCompactRelationResolution<'a> {
2080    /// Exactly one reusable local target was resolved.
2081    Resolved {
2082        /// Exact selector accepted by later calls.
2083        selector: &'a ReusableTargetSelector,
2084        /// Complete generation containing the target.
2085        generation: IndexGeneration,
2086    },
2087    /// More than one valid target remains.
2088    Ambiguous {
2089        /// Original normalized reference.
2090        reference: &'a GraphIdentityText,
2091        /// Number of retained candidates before limits.
2092        candidates: u32,
2093    },
2094    /// No supported static target was found.
2095    Unresolved {
2096        /// Original normalized reference.
2097        reference: &'a GraphIdentityText,
2098    },
2099    /// The target is intentionally outside the selected project.
2100    External {
2101        /// Typed external identity.
2102        external: &'a ExternalSelector,
2103        /// Complete generation containing the external record.
2104        generation: IndexGeneration,
2105    },
2106}
2107
2108impl<'a> From<&'a RelationResolution> for McpCompactRelationResolution<'a> {
2109    fn from(resolution: &'a RelationResolution) -> Self {
2110        match resolution {
2111            RelationResolution::Resolved {
2112                selector,
2113                generation,
2114                ..
2115            } => Self::Resolved {
2116                selector,
2117                generation: *generation,
2118            },
2119            RelationResolution::Ambiguous {
2120                reference,
2121                candidates,
2122            } => Self::Ambiguous {
2123                reference,
2124                candidates: candidates.get(),
2125            },
2126            RelationResolution::Unresolved { reference } => Self::Unresolved { reference },
2127            RelationResolution::External {
2128                external,
2129                generation,
2130                ..
2131            } => Self::External {
2132                external,
2133                generation: *generation,
2134            },
2135        }
2136    }
2137}
2138
2139/// Compact relation facts required for trust and direct navigation.
2140#[derive(Debug, Serialize)]
2141struct McpCompactLogicalRelation<'a> {
2142    /// Typed legacy or extended relation family.
2143    kind: GraphRelationKind,
2144    /// Resolution state and reusable target when local.
2145    resolution: McpCompactRelationResolution<'a>,
2146    /// Coarse trust class.
2147    confidence: ConfidenceClass,
2148    /// Producer completeness for this relation scope.
2149    completeness: Completeness,
2150    /// Complete generation containing the relation.
2151    generation: IndexGeneration,
2152}
2153
2154/// One compact detailed-relation row.
2155#[derive(Debug, Serialize)]
2156struct McpCompactDetailedRelationRow<'a> {
2157    /// One-based traversal depth.
2158    depth: u32,
2159    /// Direction relative to the selected frontier.
2160    direction: RelationDirection,
2161    /// Typed relation facts without stable-key duplication.
2162    relation: McpCompactLogicalRelation<'a>,
2163    /// Exact source selector, purpose, and coverage.
2164    source: McpCompactDetailedRelationNode<'a>,
2165    /// Retained local or external target when one exists.
2166    #[serde(skip_serializing_if = "Option::is_none")]
2167    target: Option<McpCompactDetailedRelationNode<'a>>,
2168    /// Purpose disposition when no target node exists.
2169    #[serde(skip_serializing_if = "Option::is_none")]
2170    target_purpose: Option<&'a RelationPurpose>,
2171    /// Exact selectors for a multi-hop path; direct source/target rows omit this duplicate.
2172    #[serde(skip_serializing_if = "Option::is_none")]
2173    path: Option<Vec<&'a EntitySelector>>,
2174    /// Exact supporting occurrences when requested.
2175    #[serde(skip_serializing_if = "Option::is_none")]
2176    occurrences: Option<Vec<McpCompactRelationOccurrence<'a>>>,
2177    /// Whether the per-relation occurrence ceiling omitted rows.
2178    #[serde(skip_serializing_if = "is_false")]
2179    occurrences_truncated: bool,
2180    /// Existing exact call that consumes the selected local endpoint.
2181    #[serde(skip_serializing_if = "Option::is_none")]
2182    next_call: Option<&'a RelationNextCall>,
2183}
2184
2185/// Compact source occurrence without another copy of the owning relation key.
2186#[derive(Debug, Serialize)]
2187struct McpCompactRelationOccurrence<'a> {
2188    /// Repository-local file containing the evidence.
2189    file: &'a RepositoryFilePath,
2190    /// Exact supporting source range.
2191    span: SourceSpan,
2192    /// Complete generation containing the occurrence.
2193    generation: IndexGeneration,
2194}
2195
2196impl<'a> From<&'a RelationOccurrence> for McpCompactRelationOccurrence<'a> {
2197    fn from(occurrence: &'a RelationOccurrence) -> Self {
2198        Self {
2199            file: occurrence.file(),
2200            span: occurrence.span(),
2201            generation: occurrence.generation(),
2202        }
2203    }
2204}
2205
2206impl<'a> From<&'a DetailedRelationRow> for McpCompactDetailedRelationRow<'a> {
2207    fn from(row: &'a DetailedRelationRow) -> Self {
2208        Self {
2209            depth: row.depth,
2210            direction: row.direction,
2211            relation: McpCompactLogicalRelation {
2212                kind: row.relation.kind(),
2213                resolution: McpCompactRelationResolution::from(row.relation.resolution()),
2214                confidence: row.relation.confidence(),
2215                completeness: row.relation.completeness(),
2216                generation: row.relation.generation(),
2217            },
2218            source: McpCompactDetailedRelationNode::from(&row.source),
2219            target: row
2220                .target
2221                .as_ref()
2222                .map(McpCompactDetailedRelationNode::from),
2223            target_purpose: row.target.is_none().then_some(&row.target_purpose),
2224            path: (row.path.len() > 2)
2225                .then(|| row.path.iter().map(|node| node.entity.selector()).collect()),
2226            occurrences: (!row.occurrences.is_empty()).then(|| {
2227                row.occurrences
2228                    .iter()
2229                    .map(McpCompactRelationOccurrence::from)
2230                    .collect()
2231            }),
2232            occurrences_truncated: row.occurrences_truncated,
2233            next_call: row.next_call.as_ref(),
2234        }
2235    }
2236}
2237
2238/// Opt-in compact projection of one detailed relation page.
2239#[derive(Debug, Serialize)]
2240struct McpCompactDetailedRelationReport<'a> {
2241    /// Exact selected anchor.
2242    anchor: McpCompactDetailedRelationNode<'a>,
2243    /// Complete graph generation captured by the page.
2244    generation: IndexGeneration,
2245    /// Accepted authored-purpose revision captured by the page.
2246    authored_purpose_revision: u64,
2247    /// Direction followed from the anchor.
2248    direction: RelationDirection,
2249    /// Number of retained relation steps.
2250    returned: u32,
2251    /// Number of cyclic or duplicate-node paths pruned.
2252    #[serde(skip_serializing_if = "is_zero_u64")]
2253    pruned_paths: u64,
2254    /// Whether a declared boundary stopped traversal.
2255    #[serde(skip_serializing_if = "is_false")]
2256    truncated: bool,
2257    /// Directly reusable continuation call with the exact original query and budget.
2258    #[serde(skip_serializing_if = "Option::is_none")]
2259    next_call: Option<McpCompactRelationContinuationCall<'a>>,
2260    /// Exact, lower-bound, or unknown cardinality.
2261    total: &'a RelationTotalState,
2262    /// Stable hard limits reached while constructing the response.
2263    #[serde(skip_serializing_if = "Option::is_none")]
2264    reached_limits: Option<&'a [GraphLimitKind]>,
2265    /// Aggregate page and retained-state work.
2266    work: &'a DetailedRelationWork,
2267    /// Ranked node-simple relation steps.
2268    rows: Vec<McpCompactDetailedRelationRow<'a>>,
2269}
2270
2271/// Existing MCP relation call that resumes one exact compact page.
2272#[derive(Debug, Serialize)]
2273struct McpCompactRelationContinuationCall<'a> {
2274    /// Existing MCP tool that owns relation continuation.
2275    tool: &'static str,
2276    /// Exact original request plus its generation-bound cursor.
2277    arguments: McpCompactRelationContinuationArguments<'a>,
2278}
2279
2280/// Exact result-defining arguments required to resume a detailed relation page.
2281#[derive(Debug, Serialize)]
2282struct McpCompactRelationContinuationArguments<'a> {
2283    /// Explicit project root when the original request supplied one.
2284    #[serde(skip_serializing_if = "Option::is_none")]
2285    project_path: Option<&'a str>,
2286    /// Selected repository-relative anchor file.
2287    file: &'a str,
2288    /// Original nearest-project policy when explicitly supplied.
2289    #[serde(skip_serializing_if = "Option::is_none")]
2290    nearest_project: Option<bool>,
2291    /// Original ordered federated roots when supplied.
2292    #[serde(skip_serializing_if = "Option::is_none")]
2293    roots: Option<&'a [String]>,
2294    /// Detailed relation view.
2295    view: &'static str,
2296    /// Preserve the compact response projection.
2297    compact: bool,
2298    /// Generation-, purpose-, query-, order-, and budget-bound cursor.
2299    cursor: &'a str,
2300    /// Exact symbol name when the anchor is a declaration.
2301    #[serde(skip_serializing_if = "Option::is_none")]
2302    symbol: Option<&'a str>,
2303    /// Exact nonempty symbol parent when supplied.
2304    #[serde(skip_serializing_if = "Option::is_none")]
2305    symbol_parent: Option<&'a str>,
2306    /// Exact nonempty symbol kind when supplied.
2307    #[serde(skip_serializing_if = "Option::is_none")]
2308    symbol_kind: Option<&'a str>,
2309    /// Exact nonempty symbol signature when supplied.
2310    #[serde(skip_serializing_if = "Option::is_none")]
2311    symbol_signature: Option<&'a str>,
2312    /// Original traversal direction when explicitly supplied.
2313    #[serde(skip_serializing_if = "Option::is_none")]
2314    direction: Option<&'a str>,
2315    /// Original relation filter when explicitly supplied.
2316    #[serde(skip_serializing_if = "Option::is_none")]
2317    relation: Option<&'a str>,
2318    /// Original confidence floor when explicitly supplied.
2319    #[serde(skip_serializing_if = "Option::is_none")]
2320    minimum_confidence: Option<&'a str>,
2321    /// Original resolution filter when explicitly supplied.
2322    #[serde(skip_serializing_if = "Option::is_none")]
2323    resolution: Option<&'a str>,
2324    /// Original traversal depth when explicitly supplied.
2325    #[serde(skip_serializing_if = "Option::is_none")]
2326    depth: Option<u32>,
2327    /// Original occurrence-retention choice when explicitly supplied.
2328    #[serde(skip_serializing_if = "Option::is_none")]
2329    include_occurrences: Option<bool>,
2330    /// Original returned-row limit when explicitly supplied.
2331    #[serde(skip_serializing_if = "Option::is_none")]
2332    limit: Option<usize>,
2333    /// Original per-relation occurrence limit when explicitly supplied.
2334    #[serde(skip_serializing_if = "Option::is_none")]
2335    occurrence_limit: Option<u32>,
2336    /// Original edge budget when explicitly supplied.
2337    #[serde(skip_serializing_if = "Option::is_none")]
2338    edge_limit: Option<u32>,
2339    /// Original node budget when explicitly supplied.
2340    #[serde(skip_serializing_if = "Option::is_none")]
2341    node_limit: Option<u32>,
2342    /// Original visited-node budget when explicitly supplied.
2343    #[serde(skip_serializing_if = "Option::is_none")]
2344    visited_limit: Option<u32>,
2345    /// Original aggregate occurrence budget when explicitly supplied.
2346    #[serde(skip_serializing_if = "Option::is_none")]
2347    occurrence_total_limit: Option<u32>,
2348    /// Original intermediate-byte budget when explicitly supplied.
2349    #[serde(skip_serializing_if = "Option::is_none")]
2350    intermediate_bytes: Option<u64>,
2351    /// Original service deadline when explicitly supplied.
2352    #[serde(skip_serializing_if = "Option::is_none")]
2353    deadline_ms: Option<u64>,
2354    /// Original rendered-output budget when explicitly supplied.
2355    #[serde(skip_serializing_if = "Option::is_none")]
2356    output_bytes: Option<u32>,
2357}
2358
2359impl<'a> McpCompactDetailedRelationReport<'a> {
2360    /// Project one detailed page and its directly reusable continuation.
2361    fn new(
2362        report: &'a DetailedRelationReport,
2363        file: &'a str,
2364        params: &'a AtlasSymbolRelationsParams,
2365    ) -> Self {
2366        Self {
2367            anchor: McpCompactDetailedRelationNode::from(&report.anchor),
2368            generation: report.generation,
2369            authored_purpose_revision: report.authored_purpose_revision,
2370            direction: report.direction,
2371            returned: report.returned,
2372            pruned_paths: report.pruned_paths,
2373            truncated: report.truncated,
2374            next_call: report.continuation.as_deref().map(|cursor| {
2375                McpCompactRelationContinuationCall {
2376                    tool: MCP_TOOL_ATLAS_SYMBOL_RELATIONS,
2377                    arguments: McpCompactRelationContinuationArguments {
2378                        project_path: params.project_path.as_deref(),
2379                        file,
2380                        nearest_project: params.nearest_project,
2381                        roots: params.roots.as_deref(),
2382                        view: MCP_SYMBOL_RELATION_VIEW_DETAILED,
2383                        compact: true,
2384                        cursor,
2385                        symbol: params.symbol.as_deref(),
2386                        symbol_parent: params.symbol_parent.as_deref().and_then(nonempty_str),
2387                        symbol_kind: params.symbol_kind.as_deref().and_then(nonempty_str),
2388                        symbol_signature: params.symbol_signature.as_deref().and_then(nonempty_str),
2389                        direction: params.direction.as_deref(),
2390                        relation: params.relation.as_deref(),
2391                        minimum_confidence: params.minimum_confidence.as_deref(),
2392                        resolution: params.resolution.as_deref(),
2393                        depth: params.depth,
2394                        include_occurrences: params.include_occurrences,
2395                        limit: params.limit,
2396                        occurrence_limit: params.occurrence_limit,
2397                        edge_limit: params.edge_limit,
2398                        node_limit: params.node_limit,
2399                        visited_limit: params.visited_limit,
2400                        occurrence_total_limit: params.occurrence_total_limit,
2401                        intermediate_bytes: params.intermediate_bytes,
2402                        deadline_ms: params.deadline_ms,
2403                        output_bytes: params.output_bytes,
2404                    },
2405                }
2406            }),
2407            total: &report.total,
2408            reached_limits: nonempty_slice(&report.reached_limits),
2409            work: &report.work,
2410            rows: report
2411                .rows
2412                .iter()
2413                .map(McpCompactDetailedRelationRow::from)
2414                .collect(),
2415        }
2416    }
2417}
2418
2419/// Compact federated wrapper that preserves cross-root evidence and work.
2420#[derive(Debug, Serialize)]
2421struct McpCompactFederatedDetailedRelationReport<'a> {
2422    /// Ordered validated participants.
2423    participants: &'a [FederatedParticipant],
2424    /// Compact first-root detailed relation page.
2425    primary: McpCompactDetailedRelationReport<'a>,
2426    /// Exact cross-root external rendezvous evidence.
2427    #[serde(skip_serializing_if = "Option::is_none")]
2428    rendezvous: Option<&'a [FederatedRendezvous]>,
2429    /// Whether primary or rendezvous work was truncated.
2430    #[serde(skip_serializing_if = "is_false")]
2431    truncated: bool,
2432    /// Stable aggregate limits reached by either stage.
2433    #[serde(skip_serializing_if = "Option::is_none")]
2434    reached_limits: Option<&'a [GraphLimitKind]>,
2435    /// Exact aggregate work.
2436    work: &'a FederatedRelationWork,
2437}
2438
2439impl<'a> McpCompactFederatedDetailedRelationReport<'a> {
2440    /// Project a federated page while preserving its exact continuation call.
2441    fn new(
2442        report: &'a FederatedDetailedRelationReport,
2443        file: &'a str,
2444        params: &'a AtlasSymbolRelationsParams,
2445    ) -> Self {
2446        Self {
2447            participants: &report.participants,
2448            primary: McpCompactDetailedRelationReport::new(&report.primary, file, params),
2449            rendezvous: nonempty_slice(&report.rendezvous),
2450            truncated: report.truncated,
2451            reached_limits: nonempty_slice(&report.reached_limits),
2452            work: &report.work,
2453        }
2454    }
2455}
2456
2457/// Borrow non-empty text into one optional compact field.
2458fn nonempty_str(value: &str) -> Option<&str> {
2459    (!value.is_empty()).then_some(value)
2460}
2461
2462/// Borrow non-empty rows into one optional compact section.
2463fn nonempty_slice<T>(value: &[T]) -> Option<&[T]> {
2464    (!value.is_empty()).then_some(value)
2465}
2466
2467/// Return whether a compact unsigned count is zero.
2468#[allow(clippy::trivially_copy_pass_by_ref)]
2469const fn is_zero_u32(value: &u32) -> bool {
2470    *value == 0
2471}
2472
2473/// Return whether a compact unsigned total is zero.
2474#[allow(clippy::trivially_copy_pass_by_ref)]
2475const fn is_zero_u64(value: &u64) -> bool {
2476    *value == 0
2477}
2478
2479/// Agent startup brief payload.
2480#[derive(Debug, Serialize)]
2481struct McpSessionBrief {
2482    /// Selected project and index identity.
2483    project: McpSelectedProjectCapability,
2484    /// Route-affecting startup policy.
2485    policy: McpBriefPolicy,
2486    /// Overview counts when an index exists.
2487    overview: Option<Overview>,
2488    /// Indexed candidate folder rows.
2489    folders: Vec<McpBriefCandidate>,
2490    /// Indexed candidate file rows.
2491    files: Vec<McpBriefCandidate>,
2492    /// Bounded health blockers.
2493    blockers: McpBriefBlockers,
2494    /// Actionable bounded purpose-curator handoff for the selected project.
2495    purpose_handoff: Option<PurposeCuratorHandoff>,
2496    /// Recommended next calls.
2497    recommendations: Vec<McpBriefRecommendation>,
2498    /// Effective limits and truncation metadata.
2499    limits: McpBriefLimits,
2500}
2501
2502/// Additive compact projection of the compatibility-preserving startup brief.
2503#[derive(Debug, Serialize)]
2504struct McpCompactSessionBrief {
2505    /// Selected project identity needed for routing.
2506    project: McpCompactBriefProject,
2507    /// Non-default route-affecting startup policy.
2508    #[serde(skip_serializing_if = "Option::is_none")]
2509    policy: Option<McpBriefPolicy>,
2510    /// Compact overview counts when an index exists.
2511    #[serde(skip_serializing_if = "Option::is_none")]
2512    overview: Option<McpCompactBriefOverview>,
2513    /// Folder candidates only when no ready file candidate exists.
2514    #[serde(skip_serializing_if = "Vec::is_empty")]
2515    folders: Vec<McpCompactBriefCandidate>,
2516    /// Ready file candidates for the task.
2517    #[serde(skip_serializing_if = "Vec::is_empty")]
2518    files: Vec<McpCompactBriefCandidate>,
2519    /// Unsafe health blocker count when any exist.
2520    #[serde(skip_serializing_if = "Option::is_none")]
2521    blockers: Option<McpCompactBriefBlockers>,
2522    /// Exact host-owned purpose-curator follow-up when work is actionable.
2523    #[serde(skip_serializing_if = "Option::is_none")]
2524    purpose_handoff: Option<McpCompactBriefPurposeHandoff>,
2525    /// Recommended next calls.
2526    recommendations: Vec<McpCompactBriefRecommendation>,
2527    /// Non-default limits and truncation metadata.
2528    #[serde(skip_serializing_if = "Option::is_none")]
2529    limits: Option<McpCompactBriefLimits>,
2530}
2531
2532/// Compact selected-project state for the startup path.
2533#[derive(Debug, Serialize)]
2534struct McpCompactBriefProject {
2535    /// Canonical repository root.
2536    root: String,
2537    /// Whether the durable index is available.
2538    index_status: McpIndexStatus,
2539}
2540
2541/// Compact project counts for task startup.
2542#[derive(Debug, Serialize)]
2543struct McpCompactBriefOverview {
2544    /// Number of indexed files.
2545    files: usize,
2546    /// Number of indexed folders.
2547    folders: usize,
2548}
2549
2550/// Brief policy fields.
2551#[derive(Clone, Copy, Debug, Serialize)]
2552struct McpBriefPolicy {
2553    /// Whether nearest indexed project routing is enabled by default.
2554    nearest_project: McpPolicyState,
2555    /// Absolute-path routing scope.
2556    path_scope: McpPathScope,
2557}
2558
2559impl McpBriefPolicy {
2560    /// Return whether routing uses the ordinary selected-project policy.
2561    fn is_default(self) -> bool {
2562        self.nearest_project == McpPolicyState::Disabled
2563            && self.path_scope == McpPathScope::SelectedProject
2564    }
2565}
2566
2567/// Bounded ranked candidate row for startup briefs.
2568#[derive(Debug, Serialize)]
2569struct McpBriefCandidate {
2570    /// Repository-relative path.
2571    path: String,
2572    /// Indexed node kind.
2573    kind: String,
2574    /// Purpose lifecycle status.
2575    purpose_status: PurposeStatus,
2576    /// Purpose source.
2577    purpose_source: PurposeSource,
2578    /// Whether the purpose is current agent-approved authored responsibility state.
2579    purpose_agent_reviewed: bool,
2580    /// Purpose one-liner when present.
2581    purpose: Option<String>,
2582    /// Observed content summary when present.
2583    summary: Option<String>,
2584    /// Bounded ranking reasons.
2585    reasons: Vec<String>,
2586    /// Bounded compact ranking reason codes.
2587    reason_codes: Vec<RankedReasonCode>,
2588    /// Sparse stable-order connection counts.
2589    connection_counts: Vec<RankedConnectionCount>,
2590    /// Bounded high-value current connection sample.
2591    connections: Vec<RankedConnection>,
2592    /// Whether the bounded sample omitted any validated relation through family or global overflow.
2593    connections_truncated: bool,
2594    /// Existing navigation capability recommended after this row.
2595    next_call: NavigationNextCall,
2596}
2597
2598/// Bounded compact ranked candidate row for task startup.
2599#[derive(Debug, Serialize)]
2600struct McpCompactBriefCandidate {
2601    /// Repository-relative path.
2602    path: String,
2603    /// Purpose lifecycle status when it is not already agent-approved.
2604    #[serde(skip_serializing_if = "Option::is_none")]
2605    purpose_status: Option<PurposeStatus>,
2606    /// Purpose source when it is not already agent-approved.
2607    #[serde(skip_serializing_if = "Option::is_none")]
2608    purpose_source: Option<PurposeSource>,
2609    /// Whether the purpose is current agent-approved authored responsibility state.
2610    #[serde(skip_serializing_if = "is_false")]
2611    purpose_agent_reviewed: bool,
2612    /// Purpose one-liner when present.
2613    #[serde(skip_serializing_if = "Option::is_none")]
2614    purpose: Option<String>,
2615    /// One high-value current connection when available.
2616    #[serde(skip_serializing_if = "Vec::is_empty")]
2617    connections: Vec<RankedConnection>,
2618    /// Whether the bounded sample omitted any validated relation through family or global overflow.
2619    #[serde(skip_serializing_if = "is_false")]
2620    connections_truncated: bool,
2621    /// Existing navigation capability recommended after this row.
2622    next_call: NavigationNextCall,
2623}
2624
2625/// Compact host-owned purpose-curator handoff for startup briefs.
2626#[derive(Debug, Serialize)]
2627#[allow(clippy::struct_excessive_bools)]
2628struct McpCompactBriefPurposeHandoff {
2629    /// Whether this report is intended for an agent harness.
2630    #[serde(skip_serializing_if = "is_true")]
2631    agent_harness_expected: bool,
2632    /// Whether the current main agent may process the same bounded batch.
2633    #[serde(skip_serializing_if = "is_true")]
2634    main_agent_fallback: bool,
2635    /// Explicitly records that `ProjectAtlas` did not spawn a host agent.
2636    #[serde(skip_serializing_if = "is_false")]
2637    server_started_curator: bool,
2638    /// Successful maintenance should not add ordinary conversation output.
2639    #[serde(skip_serializing_if = "is_true")]
2640    silent_on_success: bool,
2641    /// Whether the queue call has more rows after this bounded batch.
2642    #[serde(skip_serializing_if = "is_false")]
2643    truncated: bool,
2644    /// Exact existing MCP call that returns conditional-review tokens and bounded row context.
2645    next_call: McpCompactBriefRecommendation,
2646}
2647
2648/// Bounded health blocker section.
2649#[derive(Clone, Debug, Serialize)]
2650struct McpBriefBlockers {
2651    /// Findings after filters are applied.
2652    total: usize,
2653    /// Findings returned in this brief.
2654    returned: usize,
2655    /// Whether more blockers exist.
2656    truncated: bool,
2657    /// Blocker rows.
2658    items: Vec<McpBriefBlocker>,
2659}
2660
2661/// Compact blocker count; the recommendation carries the exact bounded health call.
2662#[derive(Debug, Serialize)]
2663struct McpCompactBriefBlockers {
2664    /// Findings after filters are applied.
2665    total: usize,
2666}
2667
2668/// Return whether a serialized optional fact is false and can be omitted.
2669#[allow(clippy::trivially_copy_pass_by_ref)]
2670const fn is_false(value: &bool) -> bool {
2671    !*value
2672}
2673
2674/// Return whether a serialized invariant is true and can be omitted.
2675#[allow(clippy::trivially_copy_pass_by_ref)]
2676const fn is_true(value: &bool) -> bool {
2677    *value
2678}
2679
2680/// One health blocker row for startup briefs.
2681#[derive(Clone, Debug, Serialize)]
2682struct McpBriefBlocker {
2683    /// Stable finding id.
2684    id: String,
2685    /// Finding severity.
2686    severity: Severity,
2687    /// Finding category.
2688    category: String,
2689    /// Primary path.
2690    path: String,
2691    /// Related path when applicable.
2692    related_path: Option<String>,
2693    /// Health message.
2694    message: String,
2695    /// Recommended agent action.
2696    recommendation: String,
2697}
2698
2699/// One typed startup recommendation.
2700#[derive(Debug, Serialize)]
2701struct McpBriefRecommendation {
2702    /// Stable recommendation kind.
2703    kind: McpBriefRecommendationKind,
2704    /// MCP tool name or filesystem/tool family.
2705    target: String,
2706    /// Concise machine-readable reason.
2707    reason: String,
2708    /// Suggested arguments for the target.
2709    arguments: serde_json::Value,
2710}
2711
2712/// One typed recommendation in the compact startup projection.
2713#[derive(Debug, Serialize)]
2714struct McpCompactBriefRecommendation {
2715    /// Stable recommendation kind.
2716    kind: McpBriefRecommendationKind,
2717    /// MCP tool name or filesystem/tool family.
2718    target: String,
2719    /// Concise machine-readable reason.
2720    reason: String,
2721    /// Suggested arguments for the target.
2722    arguments: serde_json::Value,
2723}
2724
2725/// Startup recommendation kinds.
2726#[derive(Clone, Copy, Debug, Serialize)]
2727#[serde(rename_all = "snake_case")]
2728enum McpBriefRecommendationKind {
2729    /// Initialize the selected project-local atlas.
2730    Init,
2731    /// Inspect the already-ranked file summary.
2732    Summary,
2733    /// Search the index when ranking has no directly navigable file.
2734    Search,
2735    /// Inspect detailed relations for the already-ranked file.
2736    Relations,
2737    /// Inspect structural health.
2738    Health,
2739    /// Load one bounded task-scoped purpose-curation queue.
2740    PurposeQueue,
2741    /// Read exact source or non-indexed files with normal filesystem tools.
2742    FilesystemTools,
2743}
2744
2745/// Effective startup brief row limits.
2746#[derive(Debug, Serialize)]
2747struct McpBriefLimits {
2748    /// Effective folder row limit.
2749    folder_limit: usize,
2750    /// Effective file row limit.
2751    file_limit: usize,
2752    /// Effective blocker row limit.
2753    blocker_limit: usize,
2754    /// Effective actionable purpose row limit.
2755    purpose_limit: usize,
2756    /// Whether folder candidates were truncated.
2757    folders_truncated: bool,
2758    /// Whether file candidates were truncated.
2759    files_truncated: bool,
2760    /// Whether more actionable low-scope purpose rows exist.
2761    purposes_truncated: bool,
2762}
2763
2764/// Non-default limits and truncation state in the compact startup projection.
2765#[derive(Debug, Serialize)]
2766struct McpCompactBriefLimits {
2767    /// Effective folder row limit.
2768    #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
2769    folder_limit: usize,
2770    /// Effective file row limit.
2771    #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
2772    file_limit: usize,
2773    /// Effective blocker row limit.
2774    #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
2775    blocker_limit: usize,
2776    /// Effective actionable purpose row limit.
2777    #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
2778    purpose_limit: usize,
2779    /// Whether folder candidates were omitted or truncated.
2780    #[serde(skip_serializing_if = "is_false")]
2781    folders_truncated: bool,
2782    /// Whether file candidates were truncated.
2783    #[serde(skip_serializing_if = "is_false")]
2784    files_truncated: bool,
2785    /// Whether more actionable low-scope purpose rows exist.
2786    #[serde(skip_serializing_if = "is_false")]
2787    purposes_truncated: bool,
2788}
2789
2790/// Return whether a startup row limit is the compact projection default.
2791#[allow(clippy::trivially_copy_pass_by_ref)]
2792const fn is_compact_brief_default_limit(value: &usize) -> bool {
2793    *value == COMPACT_SESSION_BRIEF_DEFAULT_LIMIT
2794}
2795
2796/// Bounded in-memory registry for MCP task-progress records.
2797#[derive(Debug, Clone)]
2798struct McpTaskRegistry {
2799    /// Session-local task records.
2800    records: VecDeque<McpTaskRecord>,
2801}
2802
2803impl McpTaskRegistry {
2804    /// Create a registry with the built-in task-progress contract record.
2805    fn new() -> Self {
2806        let now = mcp_unix_time_ms();
2807        let mut registry = Self {
2808            records: VecDeque::new(),
2809        };
2810        registry.insert(McpTaskRecord {
2811            task_id: MCP_TASK_CONTRACT_ID.to_string(),
2812            operation: McpTaskOperation::Contract,
2813            state: McpTaskState::Complete,
2814            created_at_ms: now,
2815            updated_at_ms: now,
2816            progress: Some(McpTaskProgress {
2817                current: Some(1),
2818                total: Some(1),
2819                message: Some(MCP_TASK_PROGRESS_CONTRACT_MESSAGE.to_string()),
2820            }),
2821            error: None,
2822            result_ref: Some(MCP_TOOL_ATLAS_TASK_STATUS.to_string()),
2823            cancelable: false,
2824            control: None,
2825        });
2826        registry
2827    }
2828
2829    /// Insert or replace one task record while preserving the fixed registry capacity.
2830    fn insert(&mut self, record: McpTaskRecord) {
2831        if let Some(existing_index) = self
2832            .records
2833            .iter()
2834            .position(|current| current.task_id == record.task_id)
2835        {
2836            let _removed = self.records.remove(existing_index);
2837        }
2838        while self.records.len() >= MCP_TASK_REGISTRY_CAPACITY {
2839            if let Some(finished_index) = self
2840                .records
2841                .iter()
2842                .position(McpTaskRecord::is_terminal_state)
2843            {
2844                let _evicted = self.records.remove(finished_index);
2845            } else {
2846                let _evicted = self.records.pop_front();
2847            }
2848        }
2849        self.records.push_back(record);
2850    }
2851
2852    /// Return a task record by id.
2853    fn get(&self, task_id: &str) -> Option<McpTaskRecord> {
2854        self.records
2855            .iter()
2856            .find(|record| record.task_id == task_id)
2857            .cloned()
2858    }
2859
2860    /// Update a matching task through a bounded mutable pass.
2861    fn update<F>(&mut self, task_id: &str, update: F) -> Option<McpTaskRecord>
2862    where
2863        F: FnOnce(&mut McpTaskRecord),
2864    {
2865        let record = self
2866            .records
2867            .iter_mut()
2868            .find(|record| record.task_id == task_id)?;
2869        update(record);
2870        Some(record.clone())
2871    }
2872}
2873
2874/// One MCP task-progress record.
2875#[derive(Debug, Clone, Serialize)]
2876struct McpTaskRecord {
2877    /// Opaque session-local task id.
2878    task_id: String,
2879    /// Operation family.
2880    operation: McpTaskOperation,
2881    /// Current task state.
2882    state: McpTaskState,
2883    /// Creation timestamp in Unix milliseconds.
2884    created_at_ms: u128,
2885    /// Last update timestamp in Unix milliseconds.
2886    updated_at_ms: u128,
2887    /// Optional progress counters/message.
2888    progress: Option<McpTaskProgress>,
2889    /// Concise failure diagnostic when present.
2890    error: Option<String>,
2891    /// Result reference or follow-up tool when present.
2892    result_ref: Option<String>,
2893    /// Whether this task can be canceled by the current server.
2894    cancelable: bool,
2895    /// Shared cooperative cancellation boundary for active indexing work.
2896    #[serde(skip)]
2897    control: Option<IndexWorkControl>,
2898}
2899
2900impl McpTaskRecord {
2901    /// Return whether this record is in a terminal state and can be evicted first.
2902    fn is_terminal_state(&self) -> bool {
2903        matches!(
2904            self.state,
2905            McpTaskState::Complete | McpTaskState::Failed | McpTaskState::Canceled
2906        )
2907    }
2908}
2909
2910/// MCP task operation kind.
2911#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
2912#[serde(rename_all = "snake_case")]
2913enum McpTaskOperation {
2914    /// Contract/schema marker task.
2915    Contract,
2916    /// Repository scan and index operation.
2917    Scan,
2918    /// One-shot watch refresh operation.
2919    WatchOnce,
2920    /// Symbol projection rebuild operation.
2921    SymbolsBuild,
2922    /// Future search operation.
2923    Search,
2924}
2925
2926/// MCP task lifecycle state.
2927#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
2928#[serde(rename_all = "snake_case")]
2929enum McpTaskState {
2930    /// Task has not started.
2931    Pending,
2932    /// Task is running.
2933    Running,
2934    /// Task completed successfully.
2935    Complete,
2936    /// Task failed.
2937    Failed,
2938    /// Task was canceled.
2939    Canceled,
2940}
2941
2942/// Optional task progress fields.
2943#[derive(Debug, Clone, Serialize)]
2944struct McpTaskProgress {
2945    /// Completed unit count when known.
2946    current: Option<u64>,
2947    /// Total unit count when known.
2948    total: Option<u64>,
2949    /// Concise progress message.
2950    message: Option<String>,
2951}
2952
2953/// Task status lookup response.
2954#[derive(Debug, Serialize)]
2955struct McpTaskStatusResponse {
2956    /// Requested task id.
2957    task_id: String,
2958    /// Lookup outcome.
2959    lookup: McpTaskLookupStatus,
2960    /// Supported task states in this contract.
2961    states: Vec<McpTaskState>,
2962    /// Supported operation families in this contract.
2963    operations: Vec<McpTaskOperation>,
2964    /// Registry capacity.
2965    registry_capacity: usize,
2966    /// Task record when found.
2967    task: Option<McpTaskRecord>,
2968}
2969
2970/// Task lookup outcome.
2971#[derive(Debug, Eq, PartialEq, Serialize)]
2972#[serde(rename_all = "snake_case")]
2973enum McpTaskLookupStatus {
2974    /// The task was found.
2975    Found,
2976    /// The task id is unknown to this MCP session.
2977    NotFound,
2978}
2979
2980/// Task cancellation response.
2981#[derive(Debug, Serialize)]
2982struct McpTaskCancelResponse {
2983    /// Requested task id.
2984    task_id: String,
2985    /// Cancellation outcome.
2986    result: McpTaskCancelResult,
2987    /// Registry capacity.
2988    registry_capacity: usize,
2989    /// Task record when found.
2990    task: Option<McpTaskRecord>,
2991}
2992
2993/// Immediate response for one accepted background indexing task.
2994#[derive(Debug, Serialize)]
2995struct McpTaskStartResponse {
2996    /// Opaque session-local task id.
2997    task_id: String,
2998    /// Accepted operation family.
2999    operation: McpTaskOperation,
3000    /// Initial task state.
3001    state: McpTaskState,
3002    /// Existing MCP tool used to poll the task.
3003    status_tool: &'static str,
3004    /// Existing MCP tool used to request cancellation.
3005    cancel_tool: &'static str,
3006}
3007
3008/// Task cancellation outcome.
3009#[derive(Debug, Eq, PartialEq, Serialize)]
3010#[serde(rename_all = "snake_case")]
3011enum McpTaskCancelResult {
3012    /// Cooperative cancellation was delivered to active work.
3013    CancellationRequested,
3014    /// The task id is unknown to this MCP session.
3015    NotFound,
3016    /// The task was already finished.
3017    AlreadyFinished,
3018    /// The task exists but cannot currently be canceled.
3019    NotCancelable,
3020}
3021
3022/// Fixed worker partition shared by background indexing tasks in one MCP server.
3023#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3024struct McpBackgroundResourceEnvelope {
3025    /// Maximum concurrently admitted background tasks.
3026    task_limit: usize,
3027    /// Maximum scan and parser workers available to each admitted task.
3028    workers_per_task: usize,
3029    /// Maximum aggregate workers owned by all admitted tasks.
3030    total_worker_limit: usize,
3031}
3032
3033impl McpBackgroundResourceEnvelope {
3034    /// Derive one fixed partition from supported host availability and process policy.
3035    fn for_host() -> Self {
3036        let available_workers = thread::available_parallelism().map_or(1, usize::from);
3037        Self::from_available_workers(available_workers)
3038    }
3039
3040    /// Derive a deterministic envelope from an observed host worker count.
3041    fn from_available_workers(available_workers: usize) -> Self {
3042        let total_worker_limit = available_workers.clamp(1, INDEX_WORKER_SAFE_CEILING);
3043        let task_limit = total_worker_limit.min(MCP_BACKGROUND_TASK_SAFE_CEILING);
3044        let workers_per_task = (total_worker_limit / task_limit).max(1);
3045        Self {
3046            task_limit,
3047            workers_per_task,
3048            total_worker_limit,
3049        }
3050    }
3051}
3052
3053/// Native `ProjectAtlas` MCP server backed by the same services as the CLI.
3054#[derive(Debug, Clone)]
3055pub(crate) struct ProjectAtlasMcpServer {
3056    /// Active project state for calls that omit `project_path`.
3057    project_state: Arc<RwLock<McpProjectState>>,
3058    /// Caller-visible compatibility label applied to this MCP process's events.
3059    session: String,
3060    /// Bounded telemetry lifecycle shared by every server clone and routed project.
3061    usage_runtime: Arc<Mutex<McpUsageRuntime>>,
3062    /// Whether absolute path arguments may select the nearest indexed project by default.
3063    allow_nearest_project: bool,
3064    /// Bounded MCP task-progress records for this server session.
3065    task_registry: Arc<RwLock<McpTaskRegistry>>,
3066    /// Fixed aggregate worker partition for background indexing tasks.
3067    background_resources: McpBackgroundResourceEnvelope,
3068    /// Monotonic session-local task identifier source.
3069    next_task_sequence: Arc<AtomicU64>,
3070    /// Bounded per-project source observers and verified read epochs.
3071    source_observations: Arc<SourceObservationRegistry>,
3072    /// Official RMCP tool router.
3073    tool_router: ToolRouter<Self>,
3074}
3075
3076/// Bridge one RMCP request cancellation token into synchronous index work.
3077struct McpRequestCancellationBridge {
3078    /// Signal used to stop the request-local cancellation monitor.
3079    stop: Arc<std::sync::atomic::AtomicBool>,
3080    /// Join handle for the bounded request-local monitor thread.
3081    monitor: Option<thread::JoinHandle<()>>,
3082    /// Owned RMCP request context retained for the final cancellation check.
3083    context: Option<RequestContext<RoleServer>>,
3084}
3085
3086impl McpRequestCancellationBridge {
3087    /// Start a request-local monitor from one owned RMCP context.
3088    fn start(
3089        context: RequestContext<RoleServer>,
3090        control: &IndexWorkControl,
3091    ) -> Result<Self, CliError> {
3092        let token = context.ct.clone();
3093        Self::start_with_probe_and_context(move || token.is_cancelled(), control, Some(context))
3094    }
3095
3096    /// Start a cancellation monitor from a deterministic probe for tests.
3097    #[cfg(test)]
3098    fn start_with_probe<P>(probe: P, control: &IndexWorkControl) -> Result<Self, CliError>
3099    where
3100        P: Fn() -> bool + Send + 'static,
3101    {
3102        Self::start_with_probe_and_context(probe, control, None)
3103    }
3104
3105    /// Start a cancellation monitor and retain its owning request context.
3106    fn start_with_probe_and_context<P>(
3107        probe: P,
3108        control: &IndexWorkControl,
3109        context: Option<RequestContext<RoleServer>>,
3110    ) -> Result<Self, CliError>
3111    where
3112        P: Fn() -> bool + Send + 'static,
3113    {
3114        if probe() {
3115            control.cancel();
3116        }
3117        let observed_control = control.clone();
3118        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
3119        let monitor_stop = Arc::clone(&stop);
3120        let monitor = thread::Builder::new()
3121            .name(MCP_CANCELLATION_MONITOR_THREAD_NAME.to_string())
3122            .spawn(move || {
3123                while !monitor_stop.load(Ordering::Acquire) {
3124                    if probe() {
3125                        observed_control.cancel();
3126                        break;
3127                    }
3128                    thread::sleep(std::time::Duration::from_millis(5));
3129                }
3130            })
3131            .map_err(|source| {
3132                let mut message = MCP_CANCELLATION_MONITOR_START_ERROR_PREFIX.to_string();
3133                message.push_str(&source.to_string());
3134                CliError::InvalidInput(message)
3135            })?;
3136        Ok(Self {
3137            stop,
3138            monitor: Some(monitor),
3139            context,
3140        })
3141    }
3142
3143    /// Return whether RMCP canceled the owning request.
3144    fn is_cancelled(&self) -> bool {
3145        self.context
3146            .as_ref()
3147            .is_some_and(|context| context.ct.is_cancelled())
3148    }
3149}
3150
3151impl Drop for McpRequestCancellationBridge {
3152    fn drop(&mut self) {
3153        self.stop.store(true, Ordering::Release);
3154        if let Some(monitor) = self.monitor.take() {
3155            drop(monitor.join());
3156        }
3157    }
3158}
3159
3160impl ProjectAtlasMcpServer {
3161    /// Create a `ProjectAtlas` MCP server instance.
3162    pub(crate) fn new(
3163        db_path: PathBuf,
3164        config_path: Option<PathBuf>,
3165        session: String,
3166        allow_nearest_project: bool,
3167    ) -> Self {
3168        Self {
3169            project_state: Arc::new(RwLock::new(Self::startup_project_state(
3170                db_path,
3171                config_path,
3172            ))),
3173            session,
3174            usage_runtime: Arc::new(Mutex::new(McpUsageRuntime::default())),
3175            allow_nearest_project,
3176            task_registry: Arc::new(RwLock::new(McpTaskRegistry::new())),
3177            background_resources: McpBackgroundResourceEnvelope::for_host(),
3178            next_task_sequence: Arc::new(AtomicU64::new(1)),
3179            source_observations: Arc::new(SourceObservationRegistry::default()),
3180            tool_router: Self::tool_router(),
3181        }
3182    }
3183
3184    /// Open the durable index through one root-bound read snapshot.
3185    fn open_read_store(state: &McpProjectState) -> Result<AtlasStore, CliError> {
3186        if !state.db_path.exists() {
3187            return Err(index_init_required(&state.root, &state.db_path));
3188        }
3189        open_atlas_store_read_only_for_project(&state.db_path, &state.root)
3190    }
3191
3192    /// Run one normal MCP query inside the verified source-epoch boundary.
3193    #[cfg(test)]
3194    fn with_fresh_store<T, F>(
3195        &self,
3196        state: &McpProjectState,
3197        query: F,
3198    ) -> Result<VerifiedReadOutcome<T>, CliError>
3199    where
3200        F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
3201    {
3202        self.with_fresh_store_for_request(state, None, query)
3203    }
3204
3205    /// Run one verified query with optional RMCP request cancellation bridging.
3206    fn with_fresh_store_for_request<T, F>(
3207        &self,
3208        state: &McpProjectState,
3209        context: Option<RequestContext<RoleServer>>,
3210        mut query: F,
3211    ) -> Result<VerifiedReadOutcome<T>, CliError>
3212    where
3213        F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
3214    {
3215        self.with_fresh_store_controlled_for_request(state, context, |store, stamp, _control| {
3216            query(store, stamp)
3217        })
3218    }
3219
3220    /// Run one verified query that consumes the request cancellation boundary.
3221    fn with_fresh_store_controlled_for_request<T, F>(
3222        &self,
3223        state: &McpProjectState,
3224        context: Option<RequestContext<RoleServer>>,
3225        mut query: F,
3226    ) -> Result<VerifiedReadOutcome<T>, CliError>
3227    where
3228        F: FnMut(&AtlasStore, VerifiedReadStamp, &IndexWorkControl) -> Result<T, CliError>,
3229    {
3230        if !state.db_path.is_file() {
3231            return Err(index_init_required(&state.root, &state.db_path));
3232        }
3233        let control =
3234            index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
3235        let bridge = context
3236            .map(|context| McpRequestCancellationBridge::start(context, &control))
3237            .transpose()?;
3238        let result = self.source_observations.with_verified_read(
3239            &state.db_path,
3240            &state.root,
3241            state.config_path.as_deref(),
3242            &control,
3243            |store, stamp| query(store, stamp, &control),
3244        );
3245        if bridge
3246            .as_ref()
3247            .is_some_and(McpRequestCancellationBridge::is_cancelled)
3248        {
3249            control.cancel();
3250        }
3251        drop(bridge);
3252        result
3253    }
3254
3255    /// Run one rendered MCP query with request cancellation bridged into index work.
3256    fn with_fresh_string_for_request<F>(
3257        &self,
3258        state: &McpProjectState,
3259        context: Option<RequestContext<RoleServer>>,
3260        mut query: F,
3261    ) -> Result<String, CliError>
3262    where
3263        F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<String, CliError>,
3264    {
3265        self.with_fresh_string_and_usage_for_request(state, context, |store, stamp| {
3266            Ok((query(store, stamp)?, None))
3267        })
3268    }
3269
3270    /// Record optional usage only after source and `SQLite` result acceptance succeeds.
3271    fn with_fresh_string_and_usage_for_request<F>(
3272        &self,
3273        state: &McpProjectState,
3274        context: Option<RequestContext<RoleServer>>,
3275        mut query: F,
3276    ) -> Result<String, CliError>
3277    where
3278        F: FnMut(
3279            &AtlasStore,
3280            VerifiedReadStamp,
3281        ) -> Result<(String, Option<McpUsageIntent>), CliError>,
3282    {
3283        self.with_fresh_string_and_usage_controlled_for_request(
3284            state,
3285            context,
3286            |store, stamp, _control| query(store, stamp),
3287        )
3288    }
3289
3290    /// Render one verified MCP query that consumes request cancellation.
3291    fn with_fresh_string_and_usage_controlled_for_request<F>(
3292        &self,
3293        state: &McpProjectState,
3294        context: Option<RequestContext<RoleServer>>,
3295        query: F,
3296    ) -> Result<String, CliError>
3297    where
3298        F: FnMut(
3299            &AtlasStore,
3300            VerifiedReadStamp,
3301            &IndexWorkControl,
3302        ) -> Result<(String, Option<McpUsageIntent>), CliError>,
3303    {
3304        let outcome = self.with_fresh_store_controlled_for_request(state, context, query)?;
3305        let stamp = outcome.stamp.clone();
3306        let (value, usage) = outcome.value;
3307        let output_bytes = value.len();
3308        let outcome = VerifiedReadOutcome {
3309            value,
3310            stamp,
3311            work: outcome.work,
3312        }
3313        .with_output_bytes(output_bytes);
3314        if let Some(usage) = usage {
3315            self.record_accepted_usage(state, &outcome.stamp, &usage, &outcome.value);
3316        }
3317        Ok(outcome.value)
3318    }
3319
3320    /// Open the durable index for mutation.
3321    fn open_mut_store(state: &McpProjectState) -> Result<AtlasStore, CliError> {
3322        open_atlas_store_for_project(&state.db_path, &state.root)
3323    }
3324
3325    /// Open an existing selected-project index for purpose or health mutation.
3326    fn open_existing_mut_store(state: &McpProjectState) -> Result<AtlasStore, CliError> {
3327        if !state.db_path.is_file() {
3328            return Err(index_init_required(&state.root, &state.db_path));
3329        }
3330        Self::open_mut_store(state)
3331    }
3332
3333    /// Return whether this MCP process can record optional telemetry.
3334    fn telemetry_enabled() -> bool {
3335        !telemetry_disabled()
3336    }
3337
3338    /// Record telemetry and rotate only this project's scope if baselines are full.
3339    fn record_usage_for_state<F>(&self, state: &McpProjectState, store: &AtlasStore, mut record: F)
3340    where
3341        F: FnMut(UsageRuntimeInstance) -> Result<(), CliError>,
3342    {
3343        if telemetry_disabled() {
3344            return;
3345        }
3346        let Ok(binding) = McpUsageProjectBinding::capture(state, store) else {
3347            return;
3348        };
3349        let Some(project_instance) = self
3350            .usage_runtime
3351            .lock()
3352            .ok()
3353            .and_then(|mut runtime| runtime.instance_for_binding(binding))
3354        else {
3355            return;
3356        };
3357        let Ok(mut usage_instance) = project_instance.lock() else {
3358            return;
3359        };
3360        if !matches!(
3361            record(*usage_instance),
3362            Err(CliError::Db(DbError::TelemetryBaselineCapacity))
3363        ) {
3364            return;
3365        }
3366
3367        let Some(next_instance) = UsageRuntimeInstance::new(UsageInstanceOwner::McpProcess) else {
3368            return;
3369        };
3370        // Creating a candidate has no lifecycle effect; install it only after
3371        // the old identity is durably sealed so failure cannot leak or replace it.
3372        if usage_instance.seal(store).is_err() {
3373            return;
3374        }
3375        *usage_instance = next_instance;
3376        drop(record(next_instance));
3377    }
3378
3379    /// Best-effort telemetry for one result whose source epoch has already been accepted.
3380    fn record_accepted_usage(
3381        &self,
3382        state: &McpProjectState,
3383        stamp: &VerifiedReadStamp,
3384        intent: &McpUsageIntent,
3385        output: &str,
3386    ) {
3387        if !Self::telemetry_enabled() {
3388            return;
3389        }
3390        let Ok(store) = Self::open_read_store(state) else {
3391            return;
3392        };
3393        let Ok(binding) = store.captured_project_binding() else {
3394            return;
3395        };
3396        if binding.project_instance_id != stamp.project_instance_id {
3397            return;
3398        }
3399        self.record_usage_for_state(state, &store, |usage_instance| match &intent.baseline {
3400            McpUsageBaseline::Estimate(baseline_tokens) => record_usage_estimate(
3401                &store,
3402                Some(usage_instance),
3403                &self.session,
3404                intent.command,
3405                intent.path.clone(),
3406                intent.query.clone(),
3407                *baseline_tokens,
3408                output,
3409            ),
3410            McpUsageBaseline::DirectoryWalk(baseline_tokens) => {
3411                record_directory_walk_usage_estimate(
3412                    &store,
3413                    Some(usage_instance),
3414                    &self.session,
3415                    intent.command,
3416                    intent.path.clone(),
3417                    intent.query.clone(),
3418                    *baseline_tokens,
3419                    output,
3420                )
3421            }
3422            McpUsageBaseline::Text(baseline_text) => record_usage_text(
3423                &store,
3424                Some(usage_instance),
3425                &self.session,
3426                intent.command,
3427                intent.path.clone(),
3428                intent.query.clone(),
3429                baseline_text,
3430                output,
3431            ),
3432        });
3433    }
3434
3435    /// Reuse an optional broad-source telemetry baseline within one complete generation.
3436    fn estimated_source_tokens_cached(
3437        &self,
3438        state: &McpProjectState,
3439        store: &AtlasStore,
3440        stamp: &VerifiedReadStamp,
3441        folder: Option<&str>,
3442        file_pattern: Option<&str>,
3443    ) -> Result<usize, CliError> {
3444        let key = McpSourceTokenBaselineKey {
3445            binding: McpUsageProjectBinding {
3446                root: state.root.clone(),
3447                db_path: state.db_path.clone(),
3448                project_instance_id: stamp.project_instance_id,
3449            },
3450            generation: stamp.generation,
3451            folder: folder.map(ToOwned::to_owned),
3452            file_pattern: file_pattern.map(ToOwned::to_owned),
3453        };
3454        if let Some(value) = self
3455            .usage_runtime
3456            .lock()
3457            .ok()
3458            .and_then(|runtime| runtime.source_token_baseline(&key))
3459        {
3460            return Ok(value);
3461        }
3462        let value = estimated_source_tokens_for_indexed_files(store, folder, file_pattern)?;
3463        if let Ok(mut runtime) = self.usage_runtime.lock() {
3464            runtime.insert_source_token_baseline(key, value);
3465        }
3466        Ok(value)
3467    }
3468
3469    /// Best-effort seal each selected project's current identity at shutdown.
3470    fn seal_usage_instances_for_projects(&self) {
3471        let Some(projects) = self
3472            .usage_runtime
3473            .lock()
3474            .ok()
3475            .map(|runtime| runtime.snapshot())
3476        else {
3477            return;
3478        };
3479        for project in projects {
3480            let Ok(instance) = project.instance.lock() else {
3481                continue;
3482            };
3483            if let Ok(store) =
3484                open_atlas_store_for_project(&project.binding.db_path, &project.binding.root)
3485            {
3486                drop(instance.seal(&store));
3487            }
3488        }
3489    }
3490
3491    /// Load effective atlas config for the selected state.
3492    fn load_config_for_state(state: &McpProjectState) -> Result<AtlasMapConfig, CliError> {
3493        state
3494            .config_path
3495            .as_deref()
3496            .map_or_else(
3497                || load_atlas_config_for_root(&state.root).map_err(CliError::from),
3498                |config_path| load_atlas_config(Some(config_path)).map_err(CliError::from),
3499            )
3500            .map(|config| config.with_database_path(&state.db_path))
3501    }
3502
3503    /// Return the selected project root used by admin-style MCP calls.
3504    fn admin_project_root(
3505        &self,
3506        project_path: Option<String>,
3507    ) -> Result<McpProjectState, CliError> {
3508        self.state_for_project_path(project_path)
3509    }
3510
3511    /// Parse an MCP ignore kind parameter.
3512    fn parse_ignore_kind(
3513        kind: Option<&str>,
3514        required: bool,
3515    ) -> Result<Option<IgnoreEntryKind>, CliError> {
3516        let Some(kind) = kind.map(str::trim).filter(|value| !value.is_empty()) else {
3517            return if required {
3518                Err(CliError::InvalidInput(
3519                    MCP_ERROR_IGNORE_KIND_REQUIRED.to_string(),
3520                ))
3521            } else {
3522                Ok(None)
3523            };
3524        };
3525        match kind {
3526            MCP_IGNORE_KIND_DIR_NAME | MCP_IGNORE_KIND_DIR_NAME_ALIAS => {
3527                Ok(Some(IgnoreEntryKind::DirName))
3528            }
3529            MCP_IGNORE_KIND_PATH_PREFIX | MCP_IGNORE_KIND_PATH_PREFIX_ALIAS => {
3530                Ok(Some(IgnoreEntryKind::PathPrefix))
3531            }
3532            other => Err(CliError::InvalidInput(Self::invalid_parameter_message(
3533                MCP_ERROR_INVALID_IGNORE_KIND_PREFIX,
3534                other,
3535                MCP_ERROR_INVALID_IGNORE_KIND_SUFFIX,
3536            ))),
3537        }
3538    }
3539
3540    /// Parse an MCP purpose lint level parameter.
3541    fn parse_purpose_lint_level(value: Option<&str>) -> Result<PurposeLintLevel, CliError> {
3542        match value.map(str::trim).filter(|value| !value.is_empty()) {
3543            None | Some(MCP_PURPOSE_LEVEL_LOW) => Ok(PurposeLintLevel::Low),
3544            Some(MCP_PURPOSE_LEVEL_MEDIUM) => Ok(PurposeLintLevel::Medium),
3545            Some(MCP_PURPOSE_LEVEL_STRICT) => Ok(PurposeLintLevel::Strict),
3546            Some(other) => Err(CliError::InvalidInput(Self::invalid_parameter_message(
3547                MCP_ERROR_INVALID_PURPOSE_LEVEL_PREFIX,
3548                other,
3549                MCP_ERROR_INVALID_PURPOSE_LEVEL_SUFFIX,
3550            ))),
3551        }
3552    }
3553
3554    /// Parse an MCP harness config parameter.
3555    fn parse_harness_config(value: Option<&str>) -> Result<HarnessConfig, CliError> {
3556        match value.map(str::trim).filter(|value| !value.is_empty()) {
3557            None | Some(MCP_HARNESS_MCP_JSON | MCP_HARNESS_MCP_JSON_ALIAS) => {
3558                Ok(HarnessConfig::McpJson)
3559            }
3560            Some(MCP_HARNESS_CODEX) => Ok(HarnessConfig::Codex),
3561            Some(MCP_HARNESS_CLAUDE_CODE | MCP_HARNESS_CLAUDE_CODE_ALIAS) => {
3562                Ok(HarnessConfig::ClaudeCode)
3563            }
3564            Some(MCP_HARNESS_OPENCODE) => Ok(HarnessConfig::OpenCode),
3565            Some(other) => Err(CliError::InvalidInput(Self::invalid_parameter_message(
3566                MCP_ERROR_INVALID_HARNESS_PREFIX,
3567                other,
3568                MCP_ERROR_INVALID_HARNESS_SUFFIX,
3569            ))),
3570        }
3571    }
3572
3573    /// Parse the optional token chart theme parameter.
3574    fn parse_token_chart_theme(value: Option<&str>) -> Result<TokenDashboardTheme, CliError> {
3575        match value.map(str::trim).filter(|value| !value.is_empty()) {
3576            None => Ok(TokenDashboardTheme::Dark),
3577            Some(theme) => TokenDashboardTheme::parse(theme).ok_or_else(|| {
3578                CliError::InvalidInput(Self::invalid_parameter_message(
3579                    TOKEN_CHART_THEME_ERROR_PREFIX,
3580                    theme,
3581                    TOKEN_CHART_THEME_ERROR_SUFFIX,
3582                ))
3583            }),
3584        }
3585    }
3586
3587    /// Build an invalid-parameter diagnostic from centralized fragments.
3588    fn invalid_parameter_message(prefix: &str, value: &str, suffix: &str) -> String {
3589        let mut message = String::with_capacity(prefix.len() + value.len() + suffix.len());
3590        message.push_str(prefix);
3591        message.push_str(value);
3592        message.push_str(suffix);
3593        message
3594    }
3595
3596    /// Build a compatibility map report, writing the map unless CI skip policy applies.
3597    fn build_map_report(
3598        state: &McpProjectState,
3599        json: bool,
3600        force: bool,
3601    ) -> Result<McpMapReport, CliError> {
3602        let config = Self::load_config_for_state(state)?;
3603        let skipped_reason = if !force
3604            && (crate::truthy_env(MCP_ENV_CI) || crate::truthy_env(MCP_ENV_GITHUB_ACTIONS))
3605        {
3606            Some(MCP_MAP_SKIPPED_IN_CI_REASON.to_string())
3607        } else {
3608            write_map(&config, json)?;
3609            None
3610        };
3611        Ok(McpMapReport {
3612            root: normalize_native_path_display(&config.root),
3613            map_path: normalize_native_path_display(&config.map_path),
3614            written: skipped_reason.is_none(),
3615            json,
3616            skipped_reason,
3617        })
3618    }
3619
3620    /// Build typed MCP session capabilities from active server state.
3621    fn session_capabilities(
3622        &self,
3623        state: &McpProjectState,
3624        text_index_max_bytes: u64,
3625    ) -> McpSessionCapabilities {
3626        McpSessionCapabilities {
3627            runtime: build_runtime_info(),
3628            selected_project: Self::selected_project_capability(state),
3629            startup_policy: McpStartupPolicy {
3630                nearest_project: Self::policy_state(self.allow_nearest_project),
3631            },
3632            path_scope: self.path_scope(),
3633            scan_policy: McpScanPolicy {
3634                implicit_scan: McpPolicyState::Disabled,
3635                text_index_max_bytes,
3636            },
3637            telemetry: McpTelemetryPolicy {
3638                mode: Self::policy_state(!telemetry_disabled()),
3639            },
3640            privacy: McpPrivacyPolicy {
3641                environment_dump: false,
3642                secret_values: false,
3643                projectatlas_paths_only: true,
3644            },
3645        }
3646    }
3647
3648    /// Encode settings plus additive MCP-session capability fields.
3649    fn render_settings_with_capabilities(
3650        &self,
3651        state: &McpProjectState,
3652    ) -> Result<String, CliError> {
3653        let report = build_settings_report(
3654            &state.db_path,
3655            state.config_path.as_deref(),
3656            OutputFormat::Toon,
3657        )?;
3658        let capabilities = self.session_capabilities(state, report.text_index_max_bytes);
3659        let rendered = Self::encode_two_named_payloads(
3660            MCP_PAYLOAD_SETTINGS,
3661            &report,
3662            MCP_PAYLOAD_SESSION_CAPABILITIES,
3663            &capabilities,
3664        )?;
3665        if rendered.len() > MCP_SETTINGS_RESPONSE_MAX_BYTES {
3666            let mut message = MCP_SETTINGS_RESPONSE_LIMIT_PREFIX.to_string();
3667            message.push_str(&rendered.len().to_string());
3668            message.push_str(MCP_SETTINGS_RESPONSE_LIMIT_SEPARATOR);
3669            message.push_str(&MCP_SETTINGS_RESPONSE_MAX_BYTES.to_string());
3670            message.push_str(MCP_SETTINGS_RESPONSE_LIMIT_SUFFIX);
3671            return Err(CliError::InvalidInput(message));
3672        }
3673        Ok(rendered)
3674    }
3675
3676    /// Build the selected-project capability row.
3677    fn selected_project_capability(state: &McpProjectState) -> McpSelectedProjectCapability {
3678        McpSelectedProjectCapability {
3679            root: normalize_native_path_display(&state.root),
3680            db: normalize_native_path_display(&state.db_path),
3681            config: state
3682                .config_path
3683                .as_ref()
3684                .map(normalize_native_path_display),
3685            index_status: if state.db_path.exists() {
3686                McpIndexStatus::Available
3687            } else {
3688                McpIndexStatus::Missing
3689            },
3690        }
3691    }
3692
3693    /// Return the path scope for this MCP server.
3694    fn path_scope(&self) -> McpPathScope {
3695        if self.allow_nearest_project {
3696            McpPathScope::NearestIndexedProject
3697        } else {
3698            McpPathScope::SelectedProject
3699        }
3700    }
3701
3702    /// Convert a bool into a serialized policy state.
3703    fn policy_state(enabled: bool) -> McpPolicyState {
3704        if enabled {
3705            McpPolicyState::Enabled
3706        } else {
3707            McpPolicyState::Disabled
3708        }
3709    }
3710
3711    /// Clamp a caller-provided brief limit to the supported range.
3712    fn brief_limit(value: Option<usize>) -> usize {
3713        value
3714            .unwrap_or(SESSION_BRIEF_DEFAULT_LIMIT)
3715            .clamp(1, SESSION_BRIEF_MAX_LIMIT)
3716    }
3717
3718    /// Build a read-only agent startup brief.
3719    fn build_session_brief(
3720        &self,
3721        params: AtlasSessionBriefParams,
3722        context: Option<RequestContext<RoleServer>>,
3723    ) -> Result<McpSessionBrief, CliError> {
3724        let selected_project_path = params.project_path.clone();
3725        let state = self.state_for_project_path(selected_project_path.clone())?;
3726        let query = Self::query_or_empty(params.query);
3727        let purpose_task = params
3728            .purpose_task
3729            .unwrap_or_else(|| MCP_PURPOSE_TASK_SESSION_STARTUP.to_string());
3730        let folder_limit = Self::brief_limit(params.folder_limit);
3731        let file_limit = Self::brief_limit(params.file_limit);
3732        let blocker_limit = Self::brief_limit(params.blocker_limit);
3733        let purpose_limit = Self::brief_limit(params.purpose_limit);
3734        let project = Self::selected_project_capability(&state);
3735        if !state.db_path.exists() {
3736            let init_project_path = Some(normalize_native_path_display(&state.root));
3737            return Ok(McpSessionBrief {
3738                project,
3739                policy: self.brief_policy(),
3740                overview: None,
3741                folders: Vec::new(),
3742                files: Vec::new(),
3743                blockers: McpBriefBlockers {
3744                    total: 0,
3745                    returned: 0,
3746                    truncated: false,
3747                    items: Vec::new(),
3748                },
3749                purpose_handoff: None,
3750                recommendations: Self::missing_index_recommendations(init_project_path),
3751                limits: McpBriefLimits {
3752                    folder_limit,
3753                    file_limit,
3754                    blocker_limit,
3755                    purpose_limit,
3756                    folders_truncated: false,
3757                    files_truncated: false,
3758                    purposes_truncated: false,
3759                },
3760            });
3761        }
3762        let outcome = self.with_fresh_store_for_request(&state, context, |store, _stamp| {
3763            let overview = store.overview()?;
3764            let folder_rows =
3765                ranked_folder_nodes_with_reasons(store, &query, folder_limit.saturating_add(1))?;
3766            let file_rows = ranked_file_nodes_with_reasons(
3767                store,
3768                &query,
3769                None,
3770                None,
3771                file_limit.saturating_add(1),
3772                false,
3773            )?;
3774            let blockers = Self::brief_blockers(store, blocker_limit)?;
3775            let purpose_query = HealthQuery {
3776                start_index: 0,
3777                limit: purpose_limit,
3778                category: None,
3779                severity: None,
3780                path_prefix: None,
3781                summary_only: false,
3782                scope: HealthScope::purpose_default(),
3783            };
3784            let purpose_queue = purpose_curation_page(store, &purpose_query, &purpose_task)?;
3785            let purposes_truncated = purpose_queue.truncated;
3786            let folders_truncated = folder_rows.len() > folder_limit;
3787            let files_truncated = file_rows.len() > file_limit;
3788            let next_navigation_call = file_rows.first().map(|row| row.next_call.clone());
3789            Ok(McpSessionBrief {
3790                project: Self::selected_project_capability(&state),
3791                policy: self.brief_policy(),
3792                overview: Some(overview),
3793                folders: folder_rows
3794                    .into_iter()
3795                    .take(folder_limit)
3796                    .map(Self::brief_candidate)
3797                    .collect(),
3798                files: file_rows
3799                    .into_iter()
3800                    .take(file_limit)
3801                    .map(Self::brief_candidate)
3802                    .collect(),
3803                recommendations: Self::indexed_project_recommendations(
3804                    &query,
3805                    next_navigation_call,
3806                    blockers.total,
3807                    blocker_limit,
3808                    selected_project_path.clone(),
3809                ),
3810                blockers,
3811                purpose_handoff: purpose_queue
3812                    .actionable
3813                    .then(|| purpose_curator_handoff(purpose_queue)),
3814                limits: McpBriefLimits {
3815                    folder_limit,
3816                    file_limit,
3817                    blocker_limit,
3818                    purpose_limit,
3819                    folders_truncated,
3820                    files_truncated,
3821                    purposes_truncated,
3822                },
3823            })
3824        })?;
3825        Ok(outcome.value)
3826    }
3827
3828    /// Build brief policy fields.
3829    fn brief_policy(&self) -> McpBriefPolicy {
3830        McpBriefPolicy {
3831            nearest_project: Self::policy_state(self.allow_nearest_project),
3832            path_scope: self.path_scope(),
3833        }
3834    }
3835
3836    /// Build the additive compact projection without changing legacy defaults or query behavior.
3837    fn build_compact_session_brief(
3838        &self,
3839        mut params: AtlasSessionBriefParams,
3840        context: Option<RequestContext<RoleServer>>,
3841    ) -> Result<McpCompactSessionBrief, CliError> {
3842        let project_path = params.project_path.clone();
3843        params.compact = None;
3844        params
3845            .folder_limit
3846            .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
3847        params
3848            .file_limit
3849            .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
3850        params
3851            .blocker_limit
3852            .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
3853        params
3854            .purpose_limit
3855            .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
3856        let brief = self.build_session_brief(params, context)?;
3857        Ok(Self::compact_session_brief(&brief, project_path))
3858    }
3859
3860    /// Project the compatibility report into the explicit compact response shape.
3861    fn compact_session_brief(
3862        brief: &McpSessionBrief,
3863        project_path: Option<String>,
3864    ) -> McpCompactSessionBrief {
3865        let omit_folders = !brief.files.is_empty();
3866        let folders_truncated =
3867            brief.limits.folders_truncated || (omit_folders && !brief.folders.is_empty());
3868        let compact_limits = McpCompactBriefLimits {
3869            folder_limit: brief.limits.folder_limit,
3870            file_limit: brief.limits.file_limit,
3871            blocker_limit: brief.limits.blocker_limit,
3872            purpose_limit: brief.limits.purpose_limit,
3873            folders_truncated,
3874            files_truncated: brief.limits.files_truncated,
3875            purposes_truncated: brief.limits.purposes_truncated,
3876        };
3877        let limits_are_default = is_compact_brief_default_limit(&compact_limits.folder_limit)
3878            && is_compact_brief_default_limit(&compact_limits.file_limit)
3879            && is_compact_brief_default_limit(&compact_limits.blocker_limit)
3880            && is_compact_brief_default_limit(&compact_limits.purpose_limit)
3881            && !compact_limits.folders_truncated
3882            && !compact_limits.files_truncated
3883            && !compact_limits.purposes_truncated;
3884        McpCompactSessionBrief {
3885            project: McpCompactBriefProject {
3886                root: brief.project.root.clone(),
3887                index_status: brief.project.index_status,
3888            },
3889            policy: (!brief.policy.is_default()).then_some(brief.policy),
3890            overview: brief
3891                .overview
3892                .as_ref()
3893                .map(|overview| McpCompactBriefOverview {
3894                    files: overview.files,
3895                    folders: overview.folders,
3896                }),
3897            folders: if omit_folders {
3898                Vec::new()
3899            } else {
3900                brief
3901                    .folders
3902                    .iter()
3903                    .map(Self::compact_brief_candidate)
3904                    .collect()
3905            },
3906            files: brief
3907                .files
3908                .iter()
3909                .map(Self::compact_brief_candidate)
3910                .collect(),
3911            blockers: (brief.blockers.total > 0).then_some(McpCompactBriefBlockers {
3912                total: brief.blockers.total,
3913            }),
3914            purpose_handoff: brief
3915                .purpose_handoff
3916                .as_ref()
3917                .map(|handoff| Self::compact_brief_purpose_handoff(handoff, project_path)),
3918            recommendations: brief
3919                .recommendations
3920                .iter()
3921                .map(Self::compact_brief_recommendation)
3922                .collect(),
3923            limits: (!limits_are_default).then_some(compact_limits),
3924        }
3925    }
3926
3927    /// Convert a ranked node into the compatibility-preserving startup candidate.
3928    fn brief_candidate(row: RankedNode) -> McpBriefCandidate {
3929        let purpose_agent_reviewed = row.node.purpose.agent_reviewed();
3930        McpBriefCandidate {
3931            path: row.node.node.path,
3932            kind: row.node.node.kind.to_string(),
3933            purpose_status: row.node.purpose.status,
3934            purpose_source: row.node.purpose.source,
3935            purpose_agent_reviewed,
3936            purpose: row.node.purpose.purpose,
3937            summary: row.node.summary,
3938            reasons: row.reasons,
3939            reason_codes: row.reason_codes,
3940            connection_counts: row.connection_counts,
3941            connections: row.connections,
3942            connections_truncated: row.connections_truncated,
3943            next_call: row.next_call,
3944        }
3945    }
3946
3947    /// Project one compatibility candidate into the bounded compact shape.
3948    fn compact_brief_candidate(row: &McpBriefCandidate) -> McpCompactBriefCandidate {
3949        let connections = row
3950            .connections
3951            .iter()
3952            .find(|connection| Self::brief_connection_is_crisp(connection))
3953            .cloned()
3954            .into_iter()
3955            .collect::<Vec<_>>();
3956        let next_call = if row.next_call.capability == NavigationNextCapability::Relations {
3957            NavigationNextCall {
3958                capability: NavigationNextCapability::Summary,
3959                path: row.path.clone(),
3960            }
3961        } else {
3962            row.next_call.clone()
3963        };
3964        McpCompactBriefCandidate {
3965            path: row.path.clone(),
3966            purpose_status: (!row.purpose_agent_reviewed).then_some(row.purpose_status),
3967            purpose_source: (!row.purpose_agent_reviewed).then_some(row.purpose_source),
3968            purpose_agent_reviewed: row.purpose_agent_reviewed,
3969            purpose: row.purpose.clone(),
3970            connections_truncated: row.connections_truncated
3971                || connections.len() < row.connections.len(),
3972            connections,
3973            next_call,
3974        }
3975    }
3976
3977    /// Prefer a resolved non-import edge as the single default startup connection sample.
3978    fn brief_connection_is_crisp(connection: &RankedConnection) -> bool {
3979        connection.kind != RankedConnectionKind::Import
3980            && !matches!(
3981                &connection.target,
3982                RankedConnectionTarget::Unresolved { .. }
3983            )
3984    }
3985
3986    /// Project an actionable handoff into one exact follow-up call instead of duplicating rows.
3987    fn compact_brief_purpose_handoff(
3988        handoff: &PurposeCuratorHandoff,
3989        project_path: Option<String>,
3990    ) -> McpCompactBriefPurposeHandoff {
3991        McpCompactBriefPurposeHandoff {
3992            agent_harness_expected: handoff.agent_harness_expected,
3993            main_agent_fallback: handoff.main_agent_fallback,
3994            server_started_curator: handoff.server_started_curator,
3995            silent_on_success: handoff.silent_on_success,
3996            truncated: handoff.queue.truncated,
3997            next_call: McpCompactBriefRecommendation {
3998                kind: McpBriefRecommendationKind::PurposeQueue,
3999                target: MCP_TOOL_ATLAS_PURPOSE_QUEUE.to_string(),
4000                reason: MCP_BRIEF_REASON_PURPOSE_QUEUE.to_string(),
4001                arguments: Self::brief_call_arguments(
4002                    project_path,
4003                    &[(MCP_BRIEF_ARG_TASK, &handoff.queue.task)],
4004                    Some((MCP_BRIEF_ARG_LIMIT, handoff.queue.limit)),
4005                ),
4006            },
4007        }
4008    }
4009
4010    /// Add compact opt-ins to one legacy recommendation without changing its source report.
4011    fn compact_brief_recommendation(
4012        recommendation: &McpBriefRecommendation,
4013    ) -> McpCompactBriefRecommendation {
4014        let relation_to_summary =
4015            matches!(recommendation.kind, McpBriefRecommendationKind::Relations);
4016        let mut arguments = recommendation.arguments.clone();
4017        if let Some(object) = arguments.as_object_mut() {
4018            if relation_to_summary {
4019                object.remove(MCP_BRIEF_ARG_VIEW);
4020            }
4021            if relation_to_summary
4022                || matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
4023            {
4024                object.insert(
4025                    MCP_BRIEF_ARG_COMPACT.to_string(),
4026                    serde_json::Value::Bool(true),
4027                );
4028            }
4029        }
4030        McpCompactBriefRecommendation {
4031            kind: if relation_to_summary {
4032                McpBriefRecommendationKind::Summary
4033            } else {
4034                recommendation.kind
4035            },
4036            target: if relation_to_summary {
4037                MCP_TOOL_ATLAS_FILE_SUMMARY.to_string()
4038            } else {
4039                recommendation.target.clone()
4040            },
4041            reason: if relation_to_summary {
4042                MCP_BRIEF_REASON_RANKED_FILE_SUMMARY.to_string()
4043            } else {
4044                recommendation.reason.clone()
4045            },
4046            arguments,
4047        }
4048    }
4049
4050    /// Build bounded health blockers for a session brief.
4051    fn brief_blockers(
4052        store: &AtlasStore,
4053        blocker_limit: usize,
4054    ) -> Result<McpBriefBlockers, CliError> {
4055        let query = HealthQuery {
4056            start_index: 0,
4057            limit: blocker_limit,
4058            category: None,
4059            severity: None,
4060            path_prefix: None,
4061            summary_only: false,
4062            scope: HealthScope::all(),
4063        };
4064        let page = store.unresolved_health_findings_page_current(&query)?;
4065        let total = page.total;
4066        let returned = page.returned;
4067        Ok(McpBriefBlockers {
4068            total,
4069            returned,
4070            truncated: returned < total,
4071            items: page
4072                .findings
4073                .into_iter()
4074                .map(|finding| McpBriefBlocker {
4075                    id: finding.id,
4076                    severity: finding.severity,
4077                    category: finding.category,
4078                    path: finding.path,
4079                    related_path: finding.related_path,
4080                    message: finding.message,
4081                    recommendation: finding.recommendation,
4082                })
4083                .collect(),
4084        })
4085    }
4086
4087    /// Recommend next calls for a missing index.
4088    fn missing_index_recommendations(project_path: Option<String>) -> Vec<McpBriefRecommendation> {
4089        vec![
4090            McpBriefRecommendation {
4091                kind: McpBriefRecommendationKind::Init,
4092                target: MCP_TOOL_ATLAS_INIT.to_string(),
4093                reason: MCP_BRIEF_REASON_SELECTED_INDEX_MISSING.to_string(),
4094                arguments: Self::project_path_arguments(project_path.clone()),
4095            },
4096            McpBriefRecommendation {
4097                kind: McpBriefRecommendationKind::FilesystemTools,
4098                target: MCP_BRIEF_TARGET_FILESYSTEM_TOOLS.to_string(),
4099                reason: MCP_BRIEF_REASON_FILESYSTEM_UNTIL_INDEX.to_string(),
4100                arguments: Self::project_path_arguments(project_path),
4101            },
4102        ]
4103    }
4104
4105    /// Recommend next calls for an indexed project.
4106    fn indexed_project_recommendations(
4107        query: &str,
4108        next_navigation_call: Option<NavigationNextCall>,
4109        blocker_total: usize,
4110        blocker_limit: usize,
4111        project_path: Option<String>,
4112    ) -> Vec<McpBriefRecommendation> {
4113        let mut recommendations = match next_navigation_call {
4114            Some(next_call) if next_call.capability == NavigationNextCapability::Summary => {
4115                vec![McpBriefRecommendation {
4116                    kind: McpBriefRecommendationKind::Summary,
4117                    target: MCP_TOOL_ATLAS_FILE_SUMMARY.to_string(),
4118                    reason: MCP_BRIEF_REASON_RANKED_FILE_SUMMARY.to_string(),
4119                    arguments: Self::brief_call_arguments(
4120                        project_path.clone(),
4121                        &[(MCP_BRIEF_ARG_FILE, &next_call.path)],
4122                        None,
4123                    ),
4124                }]
4125            }
4126            Some(next_call) if next_call.capability == NavigationNextCapability::Relations => {
4127                vec![McpBriefRecommendation {
4128                    kind: McpBriefRecommendationKind::Relations,
4129                    target: MCP_TOOL_ATLAS_SYMBOL_RELATIONS.to_string(),
4130                    reason: MCP_BRIEF_REASON_RANKED_FILE_RELATIONS.to_string(),
4131                    arguments: Self::brief_call_arguments(
4132                        project_path.clone(),
4133                        &[
4134                            (MCP_BRIEF_ARG_FILE, &next_call.path),
4135                            (MCP_BRIEF_ARG_VIEW, MCP_SYMBOL_RELATION_VIEW_DETAILED),
4136                        ],
4137                        None,
4138                    ),
4139                }]
4140            }
4141            _ if !query.trim().is_empty() => vec![McpBriefRecommendation {
4142                kind: McpBriefRecommendationKind::Search,
4143                target: MCP_TOOL_ATLAS_SEARCH.to_string(),
4144                reason: MCP_BRIEF_REASON_SEARCH_FALLBACK.to_string(),
4145                arguments: Self::brief_call_arguments(
4146                    project_path.clone(),
4147                    &[(MCP_BRIEF_ARG_PATTERN, query)],
4148                    None,
4149                ),
4150            }],
4151            _ => vec![McpBriefRecommendation {
4152                kind: McpBriefRecommendationKind::FilesystemTools,
4153                target: MCP_BRIEF_TARGET_FILESYSTEM_TOOLS.to_string(),
4154                reason: MCP_BRIEF_REASON_NO_FILE_CANDIDATE.to_string(),
4155                arguments: Self::project_path_arguments(project_path.clone()),
4156            }],
4157        };
4158        if blocker_total > 0 {
4159            recommendations.push(McpBriefRecommendation {
4160                kind: McpBriefRecommendationKind::Health,
4161                target: MCP_TOOL_ATLAS_HEALTH.to_string(),
4162                reason: MCP_BRIEF_REASON_HEALTH_BLOCKERS.to_string(),
4163                arguments: Self::brief_call_arguments(
4164                    project_path,
4165                    &[],
4166                    Some((MCP_BRIEF_ARG_LIMIT, blocker_limit)),
4167                ),
4168            });
4169        }
4170        recommendations
4171    }
4172
4173    /// Build a `JSON` object containing `project_path` when present.
4174    fn project_path_arguments(project_path: Option<String>) -> serde_json::Value {
4175        project_path.map_or_else(
4176            || serde_json::Value::Object(serde_json::Map::new()),
4177            |path| Self::string_argument(MCP_BRIEF_ARG_PROJECT_PATH, path),
4178        )
4179    }
4180
4181    /// Build recommendation call arguments with optional project path and one payload argument.
4182    fn brief_call_arguments(
4183        project_path: Option<String>,
4184        string_args: &[(&'static str, &str)],
4185        usize_arg: Option<(&'static str, usize)>,
4186    ) -> serde_json::Value {
4187        let mut arguments = serde_json::Map::new();
4188        if let Some(path) = project_path {
4189            arguments.insert(
4190                MCP_BRIEF_ARG_PROJECT_PATH.to_string(),
4191                serde_json::Value::String(path),
4192            );
4193        }
4194        for (key, value) in string_args {
4195            arguments.insert(
4196                (*key).to_string(),
4197                serde_json::Value::String((*value).to_string()),
4198            );
4199        }
4200        if let Some((key, value)) = usize_arg {
4201            arguments.insert(key.to_string(), serde_json::json!(value));
4202        }
4203        serde_json::Value::Object(arguments)
4204    }
4205
4206    /// Build a one-field string argument object for typed recommendations.
4207    fn string_argument(key: &'static str, value: impl Into<String>) -> serde_json::Value {
4208        let mut arguments = serde_json::Map::new();
4209        arguments.insert(key.to_string(), serde_json::Value::String(value.into()));
4210        serde_json::Value::Object(arguments)
4211    }
4212
4213    /// Return all task model states for contract discovery.
4214    fn task_state_values() -> Vec<McpTaskState> {
4215        vec![
4216            McpTaskState::Pending,
4217            McpTaskState::Running,
4218            McpTaskState::Complete,
4219            McpTaskState::Failed,
4220            McpTaskState::Canceled,
4221        ]
4222    }
4223
4224    /// Return all task operation values for contract discovery.
4225    fn task_operation_values() -> Vec<McpTaskOperation> {
4226        vec![
4227            McpTaskOperation::Contract,
4228            McpTaskOperation::Scan,
4229            McpTaskOperation::WatchOnce,
4230            McpTaskOperation::SymbolsBuild,
4231            McpTaskOperation::Search,
4232        ]
4233    }
4234
4235    /// Start one bounded session-local indexing task.
4236    fn start_index_task<F>(
4237        &self,
4238        operation: McpTaskOperation,
4239        options: SymbolBuildOptions,
4240        result_ref: &'static str,
4241        work: F,
4242    ) -> Result<McpTaskStartResponse, CliError>
4243    where
4244        F: FnOnce(&IndexWorkControl, SymbolBuildOptions) -> Result<(), CliError> + Send + 'static,
4245    {
4246        let options = options.with_worker_ceiling(self.background_resources.workers_per_task);
4247        let control = index_work_control(&options);
4248        let mut task_id = MCP_INDEX_TASK_ID_PREFIX.to_string();
4249        task_id.push_str(
4250            &self
4251                .next_task_sequence
4252                .fetch_add(1, Ordering::Relaxed)
4253                .to_string(),
4254        );
4255        let now = mcp_unix_time_ms();
4256        {
4257            let mut registry = self
4258                .task_registry
4259                .write()
4260                .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))?;
4261            let active = registry
4262                .records
4263                .iter()
4264                .filter(|record| !record.is_terminal_state())
4265                .count();
4266            if active >= self.background_resources.task_limit {
4267                let mut message = MCP_INDEX_TASK_LIMIT_PREFIX.to_string();
4268                message.push_str(&self.background_resources.task_limit.to_string());
4269                message.push_str(MCP_INDEX_TASK_LIMIT_SUFFIX);
4270                return Err(CliError::Mcp(message));
4271            }
4272            registry.insert(McpTaskRecord {
4273                task_id: task_id.clone(),
4274                operation: operation.clone(),
4275                state: McpTaskState::Pending,
4276                created_at_ms: now,
4277                updated_at_ms: now,
4278                progress: Some(McpTaskProgress {
4279                    current: None,
4280                    total: None,
4281                    message: Some(MCP_TASK_PROGRESS_ACCEPTED.to_string()),
4282                }),
4283                error: None,
4284                result_ref: None,
4285                cancelable: true,
4286                control: Some(control.clone()),
4287            });
4288        }
4289
4290        let registry = Arc::clone(&self.task_registry);
4291        let worker_task_id = task_id.clone();
4292        let mut worker_name = MCP_INDEX_WORKER_NAME_PREFIX.to_string();
4293        worker_name.push_str(&task_id);
4294        let spawn_result = thread::Builder::new().name(worker_name).spawn(move || {
4295            if let Ok(mut registry) = registry.write() {
4296                registry.update(&worker_task_id, |record| {
4297                    record.state = McpTaskState::Running;
4298                    record.updated_at_ms = mcp_unix_time_ms();
4299                    record.progress = Some(McpTaskProgress {
4300                        current: None,
4301                        total: None,
4302                        message: Some(MCP_TASK_PROGRESS_RUNNING.to_string()),
4303                    });
4304                });
4305            }
4306            let outcome =
4307                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&control, options)));
4308            let (state, progress, error, completed_result_ref) = match outcome {
4309                Ok(Ok(())) => (
4310                    McpTaskState::Complete,
4311                    MCP_TASK_PROGRESS_COMPLETE,
4312                    None,
4313                    Some(result_ref.to_string()),
4314                ),
4315                Ok(Err(error)) => {
4316                    let state = if task_error_is_canceled(&error) {
4317                        McpTaskState::Canceled
4318                    } else {
4319                        McpTaskState::Failed
4320                    };
4321                    (
4322                        state,
4323                        if state == McpTaskState::Canceled {
4324                            MCP_TASK_PROGRESS_CANCELED
4325                        } else {
4326                            MCP_TASK_PROGRESS_FAILED
4327                        },
4328                        Some(bounded_task_error(&error)),
4329                        None,
4330                    )
4331                }
4332                Err(_panic) => (
4333                    McpTaskState::Failed,
4334                    MCP_TASK_PROGRESS_FAILED,
4335                    Some(MCP_INDEX_WORKER_PANIC_ERROR.to_string()),
4336                    None,
4337                ),
4338            };
4339            if let Ok(mut registry) = registry.write() {
4340                registry.update(&worker_task_id, |record| {
4341                    record.state = state;
4342                    record.updated_at_ms = mcp_unix_time_ms();
4343                    record.progress = Some(McpTaskProgress {
4344                        current: None,
4345                        total: None,
4346                        message: Some(progress.to_string()),
4347                    });
4348                    record.error = error;
4349                    record.result_ref = completed_result_ref;
4350                    record.cancelable = false;
4351                    record.control = None;
4352                });
4353            }
4354        });
4355        if let Err(source) = spawn_result {
4356            let mut spawn_error = MCP_INDEX_WORKER_SPAWN_ERROR_PREFIX.to_string();
4357            spawn_error.push_str(&source.to_string());
4358            if let Ok(mut registry) = self.task_registry.write() {
4359                registry.update(&task_id, |record| {
4360                    record.state = McpTaskState::Failed;
4361                    record.updated_at_ms = mcp_unix_time_ms();
4362                    record.progress = Some(McpTaskProgress {
4363                        current: None,
4364                        total: None,
4365                        message: Some(MCP_TASK_PROGRESS_FAILED.to_string()),
4366                    });
4367                    record.error = Some(spawn_error.clone());
4368                    record.cancelable = false;
4369                    record.control = None;
4370                });
4371            }
4372            return Err(CliError::Mcp(spawn_error));
4373        }
4374
4375        Ok(McpTaskStartResponse {
4376            task_id,
4377            operation,
4378            state: McpTaskState::Pending,
4379            status_tool: MCP_TOOL_ATLAS_TASK_STATUS,
4380            cancel_tool: MCP_TOOL_ATLAS_TASK_CANCEL,
4381        })
4382    }
4383
4384    /// Look up one MCP task status.
4385    fn task_status(&self, task_id: String) -> Result<McpTaskStatusResponse, CliError> {
4386        let registry = self
4387            .task_registry
4388            .read()
4389            .map_err(|_poisoned| CliError::Mcp(MCP_TASK_REGISTRY_LOCK_POISONED.to_string()))?;
4390        let task = registry.get(&task_id);
4391        Ok(McpTaskStatusResponse {
4392            task_id,
4393            lookup: if task.is_some() {
4394                McpTaskLookupStatus::Found
4395            } else {
4396                McpTaskLookupStatus::NotFound
4397            },
4398            states: Self::task_state_values(),
4399            operations: Self::task_operation_values(),
4400            registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
4401            task,
4402        })
4403    }
4404
4405    /// Cancel one MCP task when cancellation is supported.
4406    fn task_cancel(&self, task_id: String) -> Result<McpTaskCancelResponse, CliError> {
4407        let mut registry = self
4408            .task_registry
4409            .write()
4410            .map_err(|_poisoned| CliError::Mcp(MCP_TASK_REGISTRY_LOCK_POISONED.to_string()))?;
4411        let Some(record) = registry.get(&task_id) else {
4412            return Ok(McpTaskCancelResponse {
4413                task_id,
4414                result: McpTaskCancelResult::NotFound,
4415                registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
4416                task: None,
4417            });
4418        };
4419        if matches!(
4420            record.state,
4421            McpTaskState::Complete | McpTaskState::Failed | McpTaskState::Canceled
4422        ) {
4423            return Ok(McpTaskCancelResponse {
4424                task_id,
4425                result: McpTaskCancelResult::AlreadyFinished,
4426                registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
4427                task: Some(record),
4428            });
4429        }
4430        if !record.cancelable {
4431            return Ok(McpTaskCancelResponse {
4432                task_id,
4433                result: McpTaskCancelResult::NotCancelable,
4434                registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
4435                task: Some(record),
4436            });
4437        }
4438        let task = registry.update(&task_id, |record| {
4439            if let Some(control) = &record.control {
4440                control.cancel();
4441            }
4442            record.updated_at_ms = mcp_unix_time_ms();
4443            record.progress = Some(McpTaskProgress {
4444                current: None,
4445                total: None,
4446                message: Some(MCP_TASK_PROGRESS_CANCELLATION_REQUESTED.to_string()),
4447            });
4448            record.cancelable = false;
4449        });
4450        Ok(McpTaskCancelResponse {
4451            task_id,
4452            result: McpTaskCancelResult::CancellationRequested,
4453            registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
4454            task,
4455        })
4456    }
4457
4458    /// Build a CLI-compatible lint report for MCP callers.
4459    fn lint_report_for_state(
4460        state: &McpProjectState,
4461        params: &AtlasLintParams,
4462    ) -> Result<McpLintReport, CliError> {
4463        let config = Self::load_config_for_state(state)?;
4464        let (mut report, mut exit_code) = lint_map(
4465            &config,
4466            LintOptions {
4467                strict_folders: params.strict_folders.unwrap_or(false),
4468                report_untracked: params.report_untracked.unwrap_or(false),
4469                strict_untracked: params.strict_untracked.unwrap_or(false),
4470            },
4471        )?;
4472        let purpose_level = Self::parse_purpose_lint_level(params.purpose_level.as_deref())?;
4473        let (db_report, db_exit_code) = lint_database_if_present(
4474            &state.db_path,
4475            &state.root,
4476            state.config_path.as_deref(),
4477            purpose_level,
4478        )?;
4479        if !db_report.is_empty() {
4480            if !report.ends_with('\n') {
4481                report.push('\n');
4482            }
4483            report.push_str(&db_report);
4484        }
4485        exit_code = exit_code.max(db_exit_code);
4486        Ok(McpLintReport {
4487            ok: exit_code == 0,
4488            exit_code,
4489            report,
4490        })
4491    }
4492
4493    /// Build startup state from CLI-supplied DB/config paths.
4494    fn startup_project_state(db_path: PathBuf, config_path: Option<PathBuf>) -> McpProjectState {
4495        let root = Self::startup_project_root(&db_path, config_path.as_deref());
4496        let config_path = config_path.filter(|path| Self::config_matches_project_root(&root, path));
4497        McpProjectState {
4498            root,
4499            db_path,
4500            config_path,
4501        }
4502    }
4503
4504    /// Resolve startup root best-effort so server construction stays infallible.
4505    fn startup_project_root(db_path: &Path, config_path: Option<&Path>) -> PathBuf {
4506        if let Ok(root) = default_mcp_project_root(db_path, config_path) {
4507            return root;
4508        }
4509        if let Some(root) = Self::project_root_from_default_db_path(db_path) {
4510            return root;
4511        }
4512        std::env::current_dir()
4513            .ok()
4514            .and_then(|root| canonical_project_root(&root).ok())
4515            .unwrap_or_else(|| PathBuf::from(CURRENT_DIR_ALIAS))
4516    }
4517
4518    /// Infer a root from a conventional `root/.projectatlas/projectatlas.db` path.
4519    fn project_root_from_default_db_path(db_path: &Path) -> Option<PathBuf> {
4520        let atlas_dir = db_path.parent()?;
4521        if atlas_dir.file_name()? != PROJECTATLAS_DIR_NAME {
4522            return None;
4523        }
4524        let root = atlas_dir.parent()?;
4525        canonical_project_root(root)
4526            .ok()
4527            .or_else(|| Some(root.to_path_buf()))
4528    }
4529
4530    /// Read the active MCP project state.
4531    fn active_project_state(&self) -> Result<McpProjectState, CliError> {
4532        let state = self
4533            .project_state
4534            .read()
4535            .map(|state| state.clone())
4536            .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))?;
4537        canonical_source_project_root(&state.root)?;
4538        Ok(state)
4539    }
4540
4541    /// Replace the active MCP project state.
4542    fn set_active_project_state(&self, state: McpProjectState) -> Result<(), CliError> {
4543        *self
4544            .project_state
4545            .write()
4546            .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))? =
4547            state;
4548        Ok(())
4549    }
4550
4551    /// Return active state or a per-call project override.
4552    fn state_for_project_path(
4553        &self,
4554        project_path: Option<String>,
4555    ) -> Result<McpProjectState, CliError> {
4556        self.state_for_project_path_with_config_validation(
4557            project_path,
4558            McpConfigValidation::Immediate,
4559        )
4560    }
4561
4562    /// Return project state under the requested configuration-validation timing.
4563    fn state_for_project_path_with_config_validation(
4564        &self,
4565        project_path: Option<String>,
4566        validation: McpConfigValidation,
4567    ) -> Result<McpProjectState, CliError> {
4568        let project_path = Self::normalized_optional_path(project_path);
4569        project_path.map_or_else(
4570            || self.active_project_state(),
4571            |path| {
4572                Self::project_state_from_root_with_config_validation(Path::new(&path), validation)
4573            },
4574        )
4575    }
4576
4577    /// Return the nearest-project policy for one call, honoring explicit overrides.
4578    fn nearest_project_enabled(&self, override_value: Option<bool>) -> bool {
4579        override_value.unwrap_or(self.allow_nearest_project)
4580    }
4581
4582    /// Return selected state and validate an optional root assertion.
4583    fn state_and_root_path(
4584        &self,
4585        project_path: Option<String>,
4586        path: Option<String>,
4587        nearest_project: bool,
4588    ) -> Result<(McpProjectState, PathBuf), CliError> {
4589        self.state_and_root_path_with_config_validation(
4590            project_path,
4591            path,
4592            nearest_project,
4593            McpConfigValidation::Immediate,
4594        )
4595    }
4596
4597    /// Select a background project without reading configuration before task admission.
4598    fn background_state_and_root_path(
4599        &self,
4600        project_path: Option<String>,
4601        path: Option<String>,
4602        nearest_project: bool,
4603    ) -> Result<(McpProjectState, PathBuf), CliError> {
4604        self.state_and_root_path_with_config_validation(
4605            project_path,
4606            path,
4607            nearest_project,
4608            McpConfigValidation::Deferred,
4609        )
4610    }
4611
4612    /// Select project state and validate a root assertion under one config timing policy.
4613    fn state_and_root_path_with_config_validation(
4614        &self,
4615        project_path: Option<String>,
4616        path: Option<String>,
4617        nearest_project: bool,
4618        validation: McpConfigValidation,
4619    ) -> Result<(McpProjectState, PathBuf), CliError> {
4620        let state =
4621            self.state_for_project_path_with_config_validation(project_path.clone(), validation)?;
4622        let root = match (
4623            Self::normalized_optional_path(project_path),
4624            Self::normalized_optional_path(path),
4625        ) {
4626            (None, Some(path)) => match Self::path_or_project_root(&state, Some(path.clone())) {
4627                Ok(root) => root,
4628                Err(active_error) => {
4629                    if !nearest_project {
4630                        return Err(active_error);
4631                    }
4632                    if Self::absolute_path_inside_selected_root(&state, &path)? {
4633                        return Err(active_error);
4634                    }
4635                    let Some(indexed_state) =
4636                        Self::nearest_root_state_for_root_argument_with_config_validation(
4637                            Path::new(&path),
4638                            validation,
4639                        )?
4640                    else {
4641                        return Err(active_error);
4642                    };
4643                    let root = indexed_state.root.clone();
4644                    return Ok((indexed_state, root));
4645                }
4646            },
4647            (_, path) => Self::path_or_project_root(&state, path)?,
4648        };
4649        Ok((state, root))
4650    }
4651
4652    /// Return whether a root assertion path is inside the selected root but not root-equivalent.
4653    fn absolute_path_inside_selected_root(
4654        state: &McpProjectState,
4655        path: &str,
4656    ) -> Result<bool, CliError> {
4657        let candidate = PathBuf::from(path);
4658        if !candidate.is_absolute() {
4659            return Ok(false);
4660        }
4661        let resolved = canonical_project_root(&candidate)?;
4662        Ok(resolved != state.root && resolved.starts_with(&state.root))
4663    }
4664
4665    /// Return nearest indexed root under one configuration-validation timing policy.
4666    fn nearest_root_state_for_root_argument_with_config_validation(
4667        path: &Path,
4668        validation: McpConfigValidation,
4669    ) -> Result<Option<McpProjectState>, CliError> {
4670        let Ok(addressed_root) = canonical_project_root(path) else {
4671            return Ok(None);
4672        };
4673        let Some(indexed_state) =
4674            Self::project_state_from_nearest_indexed_path_with_config_validation(path, validation)?
4675        else {
4676            return Ok(None);
4677        };
4678        if addressed_root == indexed_state.root {
4679            Ok(Some(indexed_state))
4680        } else {
4681            Ok(None)
4682        }
4683    }
4684
4685    /// Return state and a repository-relative file key for an MCP file argument.
4686    fn state_and_file_key(
4687        &self,
4688        project_path: Option<&str>,
4689        file: &str,
4690        nearest_project: bool,
4691    ) -> Result<McpResolvedRepoPath, CliError> {
4692        let state = self.state_for_project_path(project_path.map(ToString::to_string))?;
4693        let file_path = PathBuf::from(&file);
4694        if !file_path.is_absolute() {
4695            let file_key = validated_repo_file_key(&file_path)
4696                .map_err(|source| CliError::InvalidInput(source.to_string()))?;
4697            return Ok(McpResolvedRepoPath {
4698                state,
4699                key: file_key,
4700                routed_project: false,
4701            });
4702        }
4703        if nearest_project && project_path.is_none() {
4704            let resolved = Self::nearest_state_and_repo_key(&state, file)?.ok_or_else(|| {
4705                Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
4706            })?;
4707            let file_key = validated_repo_file_key(Path::new(&resolved.key))
4708                .map_err(|source| CliError::InvalidInput(source.to_string()))?;
4709            return Ok(McpResolvedRepoPath {
4710                key: file_key,
4711                ..resolved
4712            });
4713        }
4714        if let Some(file_key) = Self::absolute_path_key_in_selected_project(&state, &file_path)? {
4715            let file_key = validated_repo_file_key(Path::new(&file_key))
4716                .map_err(|source| CliError::InvalidInput(source.to_string()))?;
4717            return Ok(McpResolvedRepoPath {
4718                state,
4719                key: file_key,
4720                routed_project: false,
4721            });
4722        }
4723        if project_path.is_some() {
4724            return Err(Self::selected_project_path_error(
4725                PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
4726            ));
4727        }
4728        if !nearest_project {
4729            return Err(Self::selected_project_path_error(
4730                PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
4731            ));
4732        }
4733        Err(Self::selected_project_path_error(
4734            PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
4735        ))
4736    }
4737
4738    /// Return state and an optional repository-relative file key for MCP symbol arguments.
4739    fn state_and_optional_file_key(
4740        &self,
4741        project_path: Option<&str>,
4742        file: Option<&str>,
4743        nearest_project: bool,
4744    ) -> Result<(McpProjectState, Option<String>, bool), CliError> {
4745        let Some(file) = file else {
4746            return self
4747                .state_for_project_path(project_path.map(ToString::to_string))
4748                .map(|state| (state, None, false));
4749        };
4750        let resolved = self.state_and_file_key(project_path, file, nearest_project)?;
4751        Ok((resolved.state, Some(resolved.key), resolved.routed_project))
4752    }
4753
4754    /// Return state and an optional folder filter for MCP file-ranking arguments.
4755    fn state_and_optional_folder_filter(
4756        &self,
4757        project_path: Option<&str>,
4758        folder: Option<&str>,
4759        nearest_project: bool,
4760    ) -> Result<(McpProjectState, Option<String>, bool), CliError> {
4761        let state = self.state_for_project_path(project_path.map(ToString::to_string))?;
4762        let Some(folder) = folder.map(str::trim).filter(|folder| !folder.is_empty()) else {
4763            return Ok((state, None, false));
4764        };
4765        let folder_path = PathBuf::from(&folder);
4766        if !folder_path.is_absolute() {
4767            let folder_filter = normalized_folder_filter(folder)?;
4768            return Ok((state, Some(folder_filter), false));
4769        }
4770        if nearest_project && project_path.is_none() {
4771            let resolved = Self::nearest_state_and_repo_key(&state, folder)?.ok_or_else(|| {
4772                Self::selected_project_path_error(FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR)
4773            })?;
4774            let folder_filter = normalized_folder_filter(&resolved.key)?;
4775            return Ok((resolved.state, Some(folder_filter), resolved.routed_project));
4776        }
4777        if let Some(folder_filter) =
4778            Self::absolute_path_key_in_selected_project(&state, &folder_path)?
4779        {
4780            let folder_filter = normalized_folder_filter(&folder_filter)?;
4781            return Ok((state, Some(folder_filter), false));
4782        }
4783        if project_path.is_some() {
4784            return Err(Self::selected_project_path_error(
4785                FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
4786            ));
4787        }
4788        if !nearest_project {
4789            return Err(Self::selected_project_path_error(
4790                FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
4791            ));
4792        }
4793        Err(Self::selected_project_path_error(
4794            FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
4795        ))
4796    }
4797
4798    /// Resolve an absolute addressed path to the nearest indexed project and repo key.
4799    fn nearest_state_and_repo_key(
4800        active_state: &McpProjectState,
4801        path: &str,
4802    ) -> Result<Option<McpResolvedRepoPath>, CliError> {
4803        let path = Path::new(path);
4804        let absolute_path = McpAbsolutePath::canonicalize(path)?;
4805        let lexical_state = Self::project_state_from_nearest_lexical_indexed_path(path)?;
4806        let canonical_state = Self::project_state_from_nearest_indexed_path(path)?;
4807        Self::reject_ambiguous_nearest_project_path(
4808            path,
4809            lexical_state.as_ref(),
4810            canonical_state.as_ref(),
4811            &absolute_path,
4812        )?;
4813        let Some(state) = canonical_state else {
4814            return Ok(None);
4815        };
4816        if state.root == active_state.root {
4817            let key = McpSelectedRoot::from_state(active_state)
4818                .repo_key_for(&absolute_path)?
4819                .ok_or_else(|| {
4820                    Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
4821                })?;
4822            return Ok(Some(McpResolvedRepoPath {
4823                state: active_state.clone(),
4824                key: key.into_string(),
4825                routed_project: false,
4826            }));
4827        }
4828        let key = McpSelectedRoot::from_state(&state)
4829            .repo_key_for(&absolute_path)?
4830            .ok_or_else(|| {
4831                Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
4832            })?;
4833        Ok(Some(McpResolvedRepoPath {
4834            state,
4835            key: key.into_string(),
4836            routed_project: true,
4837        }))
4838    }
4839
4840    /// Return a selected-project repository key for an absolute path inside the active root.
4841    fn absolute_path_key_in_selected_project(
4842        state: &McpProjectState,
4843        path: &Path,
4844    ) -> Result<Option<String>, CliError> {
4845        let absolute_path = McpAbsolutePath::canonicalize(path)?;
4846        McpSelectedRoot::from_state(state)
4847            .repo_key_for(&absolute_path)
4848            .map(|key| key.map(McpRepoKey::into_string))
4849    }
4850
4851    /// Normalize optional project/root path text from MCP payloads.
4852    fn normalized_optional_path(path: Option<String>) -> Option<String> {
4853        path.map(|path| path.trim().to_string())
4854            .filter(|path| !path.is_empty())
4855    }
4856
4857    /// Build `ProjectAtlas` state for one project root.
4858    fn project_state_from_root(root: &Path) -> Result<McpProjectState, CliError> {
4859        Self::project_state_from_root_with_config_validation(root, McpConfigValidation::Immediate)
4860    }
4861
4862    /// Build project state while controlling when configuration content is validated.
4863    fn project_state_from_root_with_config_validation(
4864        root: &Path,
4865        validation: McpConfigValidation,
4866    ) -> Result<McpProjectState, CliError> {
4867        let root = canonical_source_project_root(root)?;
4868        if !root.is_dir() {
4869            return Err(CliError::InvalidInput(format!(
4870                "project path '{}' is not a directory",
4871                root.display()
4872            )));
4873        }
4874        let db_path = Self::projectatlas_db_path(&root);
4875        let config_path = Self::config_path_for_project_root(&root, validation)?;
4876        Ok(McpProjectState {
4877            root,
4878            db_path,
4879            config_path,
4880        })
4881    }
4882
4883    /// Build project state from the nearest indexed ancestor of an addressed path.
4884    fn project_state_from_nearest_indexed_path(
4885        path: &Path,
4886    ) -> Result<Option<McpProjectState>, CliError> {
4887        Self::project_state_from_nearest_indexed_path_with_config_validation(
4888            path,
4889            McpConfigValidation::Immediate,
4890        )
4891    }
4892
4893    /// Build nearest canonical project state under one config-validation policy.
4894    fn project_state_from_nearest_indexed_path_with_config_validation(
4895        path: &Path,
4896        validation: McpConfigValidation,
4897    ) -> Result<Option<McpProjectState>, CliError> {
4898        let Ok(absolute_path) = McpAbsolutePath::canonicalize(path) else {
4899            return Ok(None);
4900        };
4901        let mut candidate = absolute_path.nearest_search_start();
4902        loop {
4903            if let Some(indexed_root) = Self::indexed_root_from_candidate(candidate) {
4904                let config_path =
4905                    Self::config_path_for_project_root(&indexed_root.root, validation)?;
4906                return Ok(Some(McpProjectState {
4907                    root: indexed_root.root,
4908                    db_path: indexed_root.db_path,
4909                    config_path,
4910                }));
4911            }
4912            let Some(parent) = candidate.parent() else {
4913                return Ok(None);
4914            };
4915            candidate = parent;
4916        }
4917    }
4918
4919    /// Build project state from the nearest lexical indexed ancestor of an addressed path.
4920    fn project_state_from_nearest_lexical_indexed_path(
4921        path: &Path,
4922    ) -> Result<Option<McpProjectState>, CliError> {
4923        Self::project_state_from_nearest_lexical_indexed_path_with_config_validation(
4924            path,
4925            McpConfigValidation::Immediate,
4926        )
4927    }
4928
4929    /// Build nearest lexical project state under one config-validation policy.
4930    fn project_state_from_nearest_lexical_indexed_path_with_config_validation(
4931        path: &Path,
4932        validation: McpConfigValidation,
4933    ) -> Result<Option<McpProjectState>, CliError> {
4934        if !path.is_absolute() {
4935            return Ok(None);
4936        }
4937        let lexical_path = Self::lexically_normalized_absolute_path(path);
4938        let mut candidate = if lexical_path.is_dir() {
4939            lexical_path
4940        } else {
4941            lexical_path
4942                .parent()
4943                .unwrap_or(lexical_path.as_path())
4944                .to_path_buf()
4945        };
4946        loop {
4947            if let Some(indexed_root) = Self::indexed_root_from_lexical_candidate(&candidate) {
4948                let config_path =
4949                    Self::config_path_for_project_root(&indexed_root.root, validation)?;
4950                return Ok(Some(McpProjectState {
4951                    root: indexed_root.root,
4952                    db_path: indexed_root.db_path,
4953                    config_path,
4954                }));
4955            }
4956            let Some(parent) = candidate.parent() else {
4957                return Ok(None);
4958            };
4959            candidate = parent.to_path_buf();
4960        }
4961    }
4962
4963    /// Return a role-typed indexed root when a candidate folder has a matching DB.
4964    fn indexed_root_from_candidate(candidate: &Path) -> Option<McpIndexedRoot> {
4965        let Ok(root) = canonical_project_root(candidate) else {
4966            return None;
4967        };
4968        let db_path = Self::projectatlas_db_path(&root);
4969        if !db_path.is_file() || !Self::indexed_db_matches_root(&db_path, &root) {
4970            return None;
4971        }
4972        Some(McpIndexedRoot { root, db_path })
4973    }
4974
4975    /// Return an indexed lexical root without treating symlinked descendants as the lexical owner.
4976    fn indexed_root_from_lexical_candidate(candidate: &Path) -> Option<McpIndexedRoot> {
4977        if Self::path_has_symlink_component(candidate) {
4978            return None;
4979        }
4980        let Ok(root) = canonical_project_root(candidate) else {
4981            return None;
4982        };
4983        if normalize_native_path_display(candidate) != normalize_native_path_display(&root) {
4984            return None;
4985        }
4986        let db_path = Self::projectatlas_db_path(&root);
4987        if !db_path.is_file() || !Self::indexed_db_matches_root(&db_path, &root) {
4988            return None;
4989        }
4990        Some(McpIndexedRoot { root, db_path })
4991    }
4992
4993    /// Return a normalized absolute path without resolving symlinks or junctions.
4994    fn lexically_normalized_absolute_path(path: &Path) -> PathBuf {
4995        let mut normalized = PathBuf::new();
4996        for component in path.components() {
4997            match component {
4998                Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
4999                Component::RootDir => normalized.push(component.as_os_str()),
5000                Component::CurDir => {}
5001                Component::ParentDir => {
5002                    normalized.pop();
5003                }
5004                Component::Normal(segment) => normalized.push(segment),
5005            }
5006        }
5007        normalized
5008    }
5009
5010    /// Return whether a path contains a symlink component in its lexical ancestry.
5011    fn path_has_symlink_component(path: &Path) -> bool {
5012        let mut current = PathBuf::new();
5013        for component in path.components() {
5014            match component {
5015                Component::Prefix(prefix) => current.push(prefix.as_os_str()),
5016                Component::RootDir => current.push(component.as_os_str()),
5017                Component::CurDir => {}
5018                Component::ParentDir => {
5019                    current.pop();
5020                }
5021                Component::Normal(segment) => {
5022                    current.push(segment);
5023                    if fs::symlink_metadata(&current)
5024                        .is_ok_and(|metadata| Self::metadata_is_symlink_or_reparse_point(&metadata))
5025                    {
5026                        return true;
5027                    }
5028                }
5029            }
5030        }
5031        false
5032    }
5033
5034    /// Return whether metadata represents a symlink or Windows reparse point.
5035    fn metadata_is_symlink_or_reparse_point(metadata: &fs::Metadata) -> bool {
5036        if metadata.file_type().is_symlink() {
5037            return true;
5038        }
5039        #[cfg(windows)]
5040        {
5041            use std::os::windows::fs::MetadataExt;
5042            const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
5043            metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
5044        }
5045        #[cfg(not(windows))]
5046        {
5047            false
5048        }
5049    }
5050
5051    /// Reject nearest-project routing when lexical and canonical roots disagree.
5052    fn reject_ambiguous_nearest_project_path(
5053        path: &Path,
5054        lexical_state: Option<&McpProjectState>,
5055        canonical_state: Option<&McpProjectState>,
5056        canonical_path: &McpAbsolutePath,
5057    ) -> Result<(), CliError> {
5058        if let (Some(lexical_state), Some(canonical_state)) = (lexical_state, canonical_state) {
5059            if lexical_state.root != canonical_state.root {
5060                return Err(Self::ambiguous_nearest_project_path_error(
5061                    path,
5062                    Some(lexical_state),
5063                    Some(canonical_state),
5064                ));
5065            }
5066            return Ok(());
5067        }
5068        let lexical_path = Self::lexically_normalized_absolute_path(path);
5069        if Self::path_has_symlink_component(&lexical_path)
5070            || lexical_state.is_some_and(|state| !canonical_path.as_path().starts_with(&state.root))
5071        {
5072            return Err(Self::ambiguous_nearest_project_path_error(
5073                path,
5074                lexical_state,
5075                canonical_state,
5076            ));
5077        }
5078        Ok(())
5079    }
5080
5081    /// Build a clear MCP error for ambiguous symlink or junction routing.
5082    fn ambiguous_nearest_project_path_error(
5083        path: &Path,
5084        lexical_state: Option<&McpProjectState>,
5085        canonical_state: Option<&McpProjectState>,
5086    ) -> CliError {
5087        let lexical_root = lexical_state.map_or_else(
5088            || MCP_NO_ROOT_PLACEHOLDER.to_string(),
5089            |state| normalize_native_path_display(&state.root),
5090        );
5091        let resolved_root = canonical_state.map_or_else(
5092            || MCP_NO_ROOT_PLACEHOLDER.to_string(),
5093            |state| normalize_native_path_display(&state.root),
5094        );
5095        let path_display = normalize_native_path_display(path);
5096        let mut message = String::new();
5097        message.push_str(AMBIGUOUS_NEAREST_PROJECT_PATH_ERROR);
5098        message.push_str(MCP_ERROR_FOR_PATH_FRAGMENT);
5099        message.push_str(&path_display);
5100        message.push_str(MCP_ERROR_LEXICAL_ROOT_FRAGMENT);
5101        message.push_str(&lexical_root);
5102        message.push_str(MCP_ERROR_RESOLVED_ROOT_FRAGMENT);
5103        message.push_str(&resolved_root);
5104        message.push_str(MCP_ERROR_GUIDANCE_FRAGMENT);
5105        message.push_str(OUTSIDE_SELECTED_PROJECT_GUIDANCE);
5106        CliError::InvalidInput(message)
5107    }
5108
5109    /// Return whether an existing DB records the same canonical project root.
5110    fn indexed_db_matches_root(db_path: &Path, root: &Path) -> bool {
5111        let Ok(stored_root) = read_project_root_read_only(db_path) else {
5112            return false;
5113        };
5114        let Some(stored_root) = stored_root else {
5115            return false;
5116        };
5117        let Ok(stored_root) = canonical_project_root(Path::new(&stored_root)) else {
5118            return false;
5119        };
5120        let Ok(candidate_root) = canonical_project_root(root) else {
5121            return false;
5122        };
5123        stored_root == candidate_root
5124    }
5125
5126    /// Return the standard `ProjectAtlas` DB path for one project root.
5127    fn projectatlas_db_path(root: &Path) -> PathBuf {
5128        root.join(PROJECTATLAS_DIR_NAME)
5129            .join(PROJECTATLAS_DB_FILE_NAME)
5130    }
5131
5132    /// Return the standard nested `ProjectAtlas` config path for one project root.
5133    fn projectatlas_nested_config_path(root: &Path) -> PathBuf {
5134        root.join(PROJECTATLAS_DIR_NAME)
5135            .join(PROJECTATLAS_CONFIG_FILE_NAME)
5136    }
5137
5138    /// Return the flat `ProjectAtlas` config path for one project root.
5139    fn projectatlas_flat_config_path(root: &Path) -> PathBuf {
5140        root.join(PROJECTATLAS_FLAT_CONFIG_FILE_NAME)
5141    }
5142
5143    /// Find a project-local config and optionally reject one pointing at another root.
5144    fn config_path_for_project_root(
5145        root: &Path,
5146        validation: McpConfigValidation,
5147    ) -> Result<Option<PathBuf>, CliError> {
5148        for config_path in [
5149            Self::projectatlas_nested_config_path(root),
5150            Self::projectatlas_flat_config_path(root),
5151        ] {
5152            if config_path.exists() {
5153                if validation == McpConfigValidation::Immediate {
5154                    Self::validate_project_config_root(root, &config_path)?;
5155                }
5156                return Ok(Some(config_path));
5157            }
5158        }
5159        Ok(None)
5160    }
5161
5162    /// Ensure a selected project's config cannot redirect the MCP root.
5163    fn validate_project_config_root(root: &Path, config_path: &Path) -> Result<(), CliError> {
5164        let config = load_atlas_config(Some(config_path))?;
5165        let config_root = canonical_project_root(&config.root)?;
5166        if config_root != root {
5167            return Err(config_root_mismatch_error(config_path, &config_root, root));
5168        }
5169        Ok(())
5170    }
5171
5172    /// Return whether a startup config belongs to the selected root.
5173    fn config_matches_project_root(root: &Path, config_path: &Path) -> bool {
5174        Self::validate_project_config_root(root, config_path).is_ok()
5175    }
5176
5177    /// Render active project state for agents.
5178    fn render_project_state(state: &McpProjectState) -> Result<String, CliError> {
5179        let payload = McpProjectStateResponse {
5180            project: Self::project_state_payload(state),
5181        };
5182        Self::encode_serialized_payload(payload)
5183    }
5184
5185    /// Build selected-project payload fields.
5186    fn project_state_payload(state: &McpProjectState) -> McpProjectStatePayload {
5187        McpProjectStatePayload {
5188            root: normalize_native_path_display(&state.root),
5189            db: normalize_native_path_display(&state.db_path),
5190            config: state
5191                .config_path
5192                .as_ref()
5193                .map(normalize_native_path_display),
5194            status: McpProjectStatus::Active,
5195        }
5196    }
5197
5198    /// Prefix routed cross-project read payloads with selected root/DB metadata.
5199    fn with_selected_project_audit(
5200        state: &McpProjectState,
5201        routed_project: bool,
5202        toon: String,
5203    ) -> Result<String, CliError> {
5204        if !routed_project {
5205            return Ok(toon);
5206        }
5207        let prefix = Self::encode_named_payload(
5208            MCP_PAYLOAD_SELECTED_PROJECT,
5209            &Self::project_state_payload(state),
5210        )?;
5211        let mut audited = String::with_capacity(prefix.len() + 1 + toon.len());
5212        audited.push_str(&prefix);
5213        audited.push('\n');
5214        audited.push_str(&toon);
5215        Ok(audited)
5216    }
5217
5218    /// Prefix one controlled analysis payload with selected root/DB metadata.
5219    fn with_selected_project_audit_controlled(
5220        state: &McpProjectState,
5221        routed_project: bool,
5222        toon: String,
5223        control: &IndexWorkControl,
5224    ) -> Result<String, CliError> {
5225        control.check(projectatlas_core::IndexWorkStage::RepositoryTraversal)?;
5226        if !routed_project {
5227            return Ok(toon);
5228        }
5229        let prefix = controlled_named_output(
5230            OutputFormat::Toon,
5231            MCP_PAYLOAD_SELECTED_PROJECT,
5232            &Self::project_state_payload(state),
5233            control,
5234        )?;
5235        let mut audited = String::with_capacity(prefix.len() + 1 + toon.len());
5236        audited.push_str(&prefix);
5237        audited.push('\n');
5238        audited.push_str(&toon);
5239        control.check(projectatlas_core::IndexWorkStage::RepositoryTraversal)?;
5240        Ok(audited)
5241    }
5242
5243    /// Return a path parameter or the selected project root.
5244    fn path_or_project_root(
5245        state: &McpProjectState,
5246        path: Option<String>,
5247    ) -> Result<PathBuf, CliError> {
5248        let Some(value) = path else {
5249            return Ok(state.root.clone());
5250        };
5251        if value.is_empty() {
5252            return Ok(state.root.clone());
5253        }
5254        let original = value.clone();
5255        let candidate = PathBuf::from(value);
5256        let resolved = if candidate.is_absolute() {
5257            candidate
5258        } else {
5259            state.root.join(candidate)
5260        };
5261        let resolved = canonical_project_root(&resolved)?;
5262        if resolved == state.root {
5263            Ok(resolved)
5264        } else if resolved.starts_with(&state.root) {
5265            let resolved_display = resolved.display();
5266            let project_root_display = state.root.display();
5267            Err(CliError::InvalidInput(format!(
5268                "MCP path '{original}' resolves to '{resolved_display}', not the selected project root '{project_root_display}'; {SELECTED_ROOT_ASSERTION_GUIDANCE}"
5269            )))
5270        } else {
5271            let resolved_display = resolved.display();
5272            let project_root_display = state.root.display();
5273            Err(CliError::InvalidInput(format!(
5274                "MCP path '{original}' resolves to '{resolved_display}', outside the selected project root '{project_root_display}'; {OUTSIDE_SELECTED_PROJECT_GUIDANCE}"
5275            )))
5276        }
5277    }
5278
5279    /// Validate an MCP purpose path as an indexed folder or file key.
5280    fn validated_indexed_node_key(store: &AtlasStore, path: &str) -> Result<String, CliError> {
5281        let node_key = validated_repo_node_key(std::path::Path::new(path))
5282            .map_err(Self::selected_project_path_error)?;
5283        if store.load_node_by_path(&node_key)?.is_none() {
5284            return Err(CliError::InvalidInput(format!(
5285                "path {node_key:?} is not indexed in the MCP-bound project"
5286            )));
5287        }
5288        Ok(node_key)
5289    }
5290
5291    /// Add selected-project guidance to repository-relative path errors.
5292    fn selected_project_path_error(message: impl std::fmt::Display) -> CliError {
5293        CliError::InvalidInput(format!("{message}; {OUTSIDE_SELECTED_PROJECT_GUIDANCE}"))
5294    }
5295
5296    /// Return a query parameter with a stable default.
5297    fn query_or_empty(query: Option<String>) -> String {
5298        query.unwrap_or_default()
5299    }
5300
5301    /// Encode one serializable payload as agent-readable TOON text.
5302    fn encode_serialized_payload<T>(payload: T) -> Result<String, CliError>
5303    where
5304        T: Serialize,
5305    {
5306        Ok(encode_agent_payload(&serde_json::to_value(payload)?))
5307    }
5308
5309    /// Encode a dynamic top-level payload key without relying on `json!` key syntax.
5310    fn encode_named_payload<T>(key: &str, payload: &T) -> Result<String, CliError>
5311    where
5312        T: Serialize,
5313    {
5314        let mut object = serde_json::Map::new();
5315        object.insert(key.to_string(), serde_json::to_value(payload)?);
5316        Ok(encode_agent_payload(&serde_json::Value::Object(object)))
5317    }
5318
5319    /// Encode two dynamic top-level payload keys without relying on `json!` key syntax.
5320    fn encode_two_named_payloads<T, U>(
5321        first_key: &str,
5322        first_payload: &T,
5323        second_key: &str,
5324        second_payload: &U,
5325    ) -> Result<String, CliError>
5326    where
5327        T: Serialize,
5328        U: Serialize,
5329    {
5330        let mut object = serde_json::Map::new();
5331        object.insert(first_key.to_string(), serde_json::to_value(first_payload)?);
5332        object.insert(
5333            second_key.to_string(),
5334            serde_json::to_value(second_payload)?,
5335        );
5336        Ok(encode_agent_payload(&serde_json::Value::Object(object)))
5337    }
5338
5339    /// Encode an MCP error as a structured agent-readable payload.
5340    fn encode_error_payload(error: &CliError) -> String {
5341        let (
5342            kind,
5343            refresh_required,
5344            init_required,
5345            worktree_required,
5346            verification_incomplete,
5347            project_mismatch,
5348            database_filesystem,
5349            search_capability,
5350            next,
5351        ) = match error {
5352            CliError::InitRequired(report) => (
5353                AgentErrorKind::InitRequired,
5354                None,
5355                Some(report.as_ref().clone()),
5356                None,
5357                None,
5358                None,
5359                None,
5360                None,
5361                Some(McpNextCall {
5362                    tool: MCP_TOOL_ATLAS_INIT,
5363                    project_path: report.project_root.clone(),
5364                }),
5365            ),
5366            CliError::WorktreeRequired(report) => (
5367                AgentErrorKind::WorktreeRequired,
5368                None,
5369                None,
5370                Some(report.as_ref().clone()),
5371                None,
5372                None,
5373                None,
5374                None,
5375                None,
5376            ),
5377            CliError::RefreshRequired(report) => (
5378                AgentErrorKind::RefreshRequired,
5379                Some(report.as_ref().clone()),
5380                None,
5381                None,
5382                None,
5383                None,
5384                None,
5385                None,
5386                Some(McpNextCall {
5387                    tool: MCP_TOOL_ATLAS_WATCH_ONCE,
5388                    project_path: report.project_root.clone(),
5389                }),
5390            ),
5391            CliError::VerificationIncomplete(report) => (
5392                AgentErrorKind::VerificationIncomplete,
5393                None,
5394                None,
5395                None,
5396                Some(report.as_ref().clone()),
5397                None,
5398                None,
5399                None,
5400                None,
5401            ),
5402            CliError::ProjectMismatch(report) => (
5403                AgentErrorKind::ProjectMismatch,
5404                None,
5405                None,
5406                None,
5407                None,
5408                Some(report.as_ref().clone()),
5409                None,
5410                None,
5411                None,
5412            ),
5413            CliError::Service(ServiceError::SearchCapabilityUnavailable {
5414                requested_mode,
5415                state,
5416                guidance,
5417            }) => (
5418                AgentErrorKind::SearchCapabilityUnavailable,
5419                None,
5420                None,
5421                None,
5422                None,
5423                None,
5424                None,
5425                Some(crate::SearchCapabilityErrorPayload {
5426                    requested_mode: *requested_mode,
5427                    state,
5428                    recovery: guidance,
5429                }),
5430                None,
5431            ),
5432            _ => database_filesystem_error_payload(error).map_or(
5433                (
5434                    AgentErrorKind::Error,
5435                    None,
5436                    None,
5437                    None,
5438                    None,
5439                    None,
5440                    None,
5441                    None,
5442                    None,
5443                ),
5444                |(kind, database_filesystem)| {
5445                    (
5446                        kind,
5447                        None,
5448                        None,
5449                        None,
5450                        None,
5451                        None,
5452                        Some(database_filesystem),
5453                        None,
5454                        None,
5455                    )
5456                },
5457            ),
5458        };
5459        let payload = McpErrorResponse {
5460            error: McpErrorPayload {
5461                kind,
5462                message: error.to_string(),
5463                refresh_required,
5464                init_required,
5465                worktree_required,
5466                verification_incomplete,
5467                project_mismatch,
5468                database_filesystem,
5469                search_capability,
5470                next,
5471            },
5472        };
5473        serde_json::to_value(payload).map_or_else(
5474            |source| {
5475                let mut message = MCP_ERROR_SERIALIZATION_FALLBACK_PREFIX.to_string();
5476                message.push_str(&source.to_string());
5477                message
5478            },
5479            |value| encode_agent_payload(&value),
5480        )
5481    }
5482
5483    /// Convert a command result into an agent-readable TOON MCP text payload.
5484    fn as_mcp_text(result: Result<String, CliError>) -> String {
5485        match result {
5486            Ok(text) => text,
5487            Err(error) => Self::encode_error_payload(&error),
5488        }
5489    }
5490}
5491
5492/// Convert MCP health parameters into a DB health query.
5493fn health_query_from_params(
5494    params: &AtlasHealthParams,
5495    scope: HealthScope,
5496) -> Result<HealthQuery, CliError> {
5497    Ok(HealthQuery {
5498        start_index: params.start_index.unwrap_or(0),
5499        limit: params
5500            .limit
5501            .filter(|value| *value > 0)
5502            .unwrap_or(DEFAULT_HEALTH_LIMIT)
5503            .min(MAX_HEALTH_LIMIT),
5504        category: trimmed_filter(params.category.as_deref()),
5505        severity: trimmed_filter(params.severity.as_deref())
5506            .as_deref()
5507            .map(parse_health_severity)
5508            .transpose()?,
5509        path_prefix: trimmed_filter(params.path_prefix.as_deref())
5510            .map(|value| normalize_repo_path_prefix(&value)),
5511        summary_only: params.summary_only.unwrap_or(false),
5512        scope,
5513    })
5514}
5515
5516/// Return whether MCP parameters contain coverage-only filters.
5517fn has_coverage_filters(params: &AtlasHealthParams) -> bool {
5518    params.parser.is_some()
5519        || params.provider.is_some()
5520        || params.relation.is_some()
5521        || params.coverage_state.is_some()
5522        || params.reason.is_some()
5523}
5524
5525/// Convert explicit MCP coverage parameters into one typed bounded DB query.
5526fn coverage_query_from_params(
5527    params: &AtlasHealthParams,
5528) -> Result<RepositoryCoverageQuery, CliError> {
5529    let limit = params
5530        .limit
5531        .filter(|value| *value > 0)
5532        .unwrap_or(DEFAULT_HEALTH_LIMIT)
5533        .min(COVERAGE_PAGE_MAX_LIMIT as usize);
5534    Ok(RepositoryCoverageQuery {
5535        start_index: u32::try_from(params.start_index.unwrap_or(0)).map_err(|error| {
5536            let mut message = String::from(MCP_ERROR_COVERAGE_START_INDEX_TOO_LARGE_PREFIX);
5537            message.push_str(&error.to_string());
5538            CliError::InvalidInput(message)
5539        })?,
5540        limit: u32::try_from(limit).map_err(|error| {
5541            let mut message = String::from(MCP_ERROR_COVERAGE_LIMIT_TOO_LARGE_PREFIX);
5542            message.push_str(&error.to_string());
5543            CliError::InvalidInput(message)
5544        })?,
5545        path_prefix: trimmed_filter(params.path_prefix.as_deref())
5546            .map(|value| normalize_repo_path_prefix(&value)),
5547        parser: trimmed_filter(params.parser.as_deref())
5548            .as_deref()
5549            .map(parse_coverage_parser)
5550            .transpose()?,
5551        provider: trimmed_filter(params.provider.as_deref())
5552            .as_deref()
5553            .map(parse_coverage_parser)
5554            .transpose()?,
5555        relation: trimmed_filter(params.relation.as_deref())
5556            .as_deref()
5557            .map(parse_coverage_relation)
5558            .transpose()?,
5559        state: trimmed_filter(params.coverage_state.as_deref())
5560            .as_deref()
5561            .map(parse_coverage_state)
5562            .transpose()?,
5563        reason: trimmed_filter(params.reason.as_deref()),
5564    })
5565}
5566
5567/// Return the DB scope for MCP purpose queue parameters.
5568fn purpose_queue_scope(params: &AtlasHealthParams) -> HealthScope {
5569    match (
5570        params.include_assets.unwrap_or(false),
5571        params.include_low_priority_files.unwrap_or(false),
5572    ) {
5573        (false, false) => HealthScope::purpose_default(),
5574        (true, false) => HealthScope::purpose_with_assets(),
5575        (false, true) => HealthScope::purpose_with_source_files(),
5576        (true, true) => HealthScope::all(),
5577    }
5578}
5579
5580/// Return a trimmed non-empty string parameter.
5581fn trimmed_filter(value: Option<&str>) -> Option<String> {
5582    value
5583        .map(str::trim)
5584        .filter(|value| !value.is_empty())
5585        .map(ToString::to_string)
5586}
5587
5588/// Parse an MCP health severity filter.
5589fn parse_health_severity(value: &str) -> Result<Severity, CliError> {
5590    let trimmed = value.trim();
5591    trimmed.parse::<Severity>().map_err(|_source| {
5592        let expected = expected_health_severity_names();
5593        CliError::InvalidInput(format!(
5594            "invalid health severity '{trimmed}'; expected {expected}"
5595        ))
5596    })
5597}
5598
5599/// Render accepted health severity names for diagnostics.
5600fn expected_health_severity_names() -> String {
5601    let mut expected = Severity::Info.as_str().to_string();
5602    expected.push_str(SEVERITY_EXPECTED_SEPARATOR);
5603    expected.push_str(Severity::Warning.as_str());
5604    expected.push_str(SEVERITY_EXPECTED_FINAL_SEPARATOR);
5605    expected.push_str(Severity::Error.as_str());
5606    expected
5607}
5608
5609#[tool_router(router = tool_router)]
5610impl ProjectAtlasMcpServer {
5611    /// Select the active project root for subsequent MCP calls.
5612    #[tool(
5613        name = "atlas_set_project_path",
5614        description = "Select the active ProjectAtlas project root for later MCP calls that omit project_path."
5615    )]
5616    fn atlas_set_project_path(
5617        &self,
5618        Parameters(params): Parameters<AtlasSetProjectPathParams>,
5619    ) -> String {
5620        Self::as_mcp_text((|| {
5621            let state = Self::project_state_from_root(Path::new(&params.project_path))?;
5622            self.set_active_project_state(state.clone())?;
5623            Self::render_project_state(&state)
5624        })())
5625    }
5626
5627    /// Initialize a `ProjectAtlas` project-local config surface.
5628    #[tool(
5629        name = "atlas_init",
5630        description = "Initialize ProjectAtlas project-local config, database, host MCP configs, scan/index, and purpose handoff."
5631    )]
5632    fn atlas_init(&self, Parameters(params): Parameters<AtlasInitParams>) -> String {
5633        Self::as_mcp_text((|| {
5634            let state = self.admin_project_root(params.project_path)?;
5635            let config_path = init_config_path(&state.root, state.config_path.as_deref());
5636            let mut report = run_init_bootstrap(
5637                &state.root,
5638                &state.db_path,
5639                Some(&config_path),
5640                &InitBootstrapOptions {
5641                    no_scan: params.no_scan.unwrap_or(false),
5642                    force_rescan: params.force_rescan.unwrap_or(false),
5643                    text_index_max_bytes: params.text_index_max_bytes,
5644                },
5645            )?;
5646            crate::write_init_mcp_config_files(
5647                &mut report,
5648                &state.root.join(PROJECTATLAS_DIR_NAME),
5649                &state.db_path,
5650                &config_path,
5651                false,
5652            );
5653            Self::encode_named_payload(MCP_PAYLOAD_INIT, &report)
5654        })())
5655    }
5656
5657    /// Write an explicit compatibility map export.
5658    #[tool(
5659        name = "atlas_map",
5660        description = "Write the explicit compatibility ProjectAtlas map export for older workflows."
5661    )]
5662    fn atlas_map(&self, Parameters(params): Parameters<AtlasMapParams>) -> String {
5663        Self::as_mcp_text((|| {
5664            let state = self.admin_project_root(params.project_path)?;
5665            let report = Self::build_map_report(
5666                &state,
5667                params.json.unwrap_or(false),
5668                params.force.unwrap_or(false),
5669            )?;
5670            Self::encode_named_payload(MCP_PAYLOAD_MAP, &report)
5671        })())
5672    }
5673
5674    /// Return root/DB/config diagnostics.
5675    #[tool(
5676        name = "atlas_root",
5677        description = "Show or verify ProjectAtlas root, DB, config, and runtime identity."
5678    )]
5679    fn atlas_root(&self, Parameters(params): Parameters<AtlasRootParams>) -> String {
5680        Self::as_mcp_text((|| {
5681            let state = self.admin_project_root(params.project_path)?;
5682            let report = build_root_report(&state.db_path, state.config_path.as_deref())?;
5683            if params.verify.unwrap_or(false) && report.verified {
5684                verify_project_database(&state.db_path, Path::new(&report.root))?;
5685            }
5686            Ok(render_root_report(&report))
5687        })())
5688    }
5689
5690    /// Bind, move, or detach a project root and then make it active.
5691    #[tool(
5692        name = "atlas_root_set",
5693        description = "Bind a repository root, generate project-local MCP configs, and make it active for later MCP calls."
5694    )]
5695    fn atlas_root_set(&self, Parameters(params): Parameters<AtlasRootSetParams>) -> String {
5696        Self::as_mcp_text((|| {
5697            let root = canonical_project_root(Path::new(&params.root))?;
5698            let report = crate::bind_project_root(
5699                &root,
5700                params.transition.unwrap_or(RootTransition::Bind),
5701                params.nearest_project.unwrap_or(false),
5702            )?;
5703            let state = Self::project_state_from_root(&root)?;
5704            self.set_active_project_state(state)?;
5705            Ok(render_root_report(&report))
5706        })())
5707    }
5708
5709    /// Return the effective `ProjectAtlas` config.
5710    #[tool(
5711        name = "atlas_config",
5712        description = "Return the effective ProjectAtlas scan, purpose, and output configuration."
5713    )]
5714    fn atlas_config(&self, Parameters(params): Parameters<AtlasProjectParams>) -> String {
5715        Self::as_mcp_text((|| {
5716            let state = self.admin_project_root(params.project_path)?;
5717            let report = effective_config_report(&Self::load_config_for_state(&state)?);
5718            Self::encode_named_payload(MCP_PAYLOAD_CONFIG, &report)
5719        })())
5720    }
5721
5722    /// Return the effective `ProjectAtlas` ignore policy.
5723    #[tool(
5724        name = "atlas_ignore_list",
5725        description = "List effective ProjectAtlas manual ignore policy and inherited .gitignore status."
5726    )]
5727    fn atlas_ignore_list(&self, Parameters(params): Parameters<AtlasProjectParams>) -> String {
5728        Self::as_mcp_text((|| {
5729            let state = self.admin_project_root(params.project_path)?;
5730            let report = list_ignore_entries(state.config_path.as_deref(), &state.root)?;
5731            Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
5732        })())
5733    }
5734
5735    /// Create a project-root `.gitignore` when it is absent.
5736    #[tool(
5737        name = "atlas_ignore_init_gitignore",
5738        description = "Create a project-root .gitignore when it is missing."
5739    )]
5740    fn atlas_ignore_init_gitignore(
5741        &self,
5742        Parameters(params): Parameters<AtlasProjectParams>,
5743    ) -> String {
5744        Self::as_mcp_text((|| {
5745            let state = self.admin_project_root(params.project_path)?;
5746            let report = init_gitignore(state.config_path.as_deref(), &state.root)?;
5747            Self::encode_named_payload(MCP_PAYLOAD_GITIGNORE, &report)
5748        })())
5749    }
5750
5751    /// Add a manual `ProjectAtlas` ignore entry.
5752    #[tool(
5753        name = "atlas_ignore_add",
5754        description = "Add one manual ProjectAtlas ignore entry to the selected project's config."
5755    )]
5756    fn atlas_ignore_add(
5757        &self,
5758        Parameters(params): Parameters<AtlasIgnoreMutationParams>,
5759    ) -> String {
5760        Self::as_mcp_text((|| {
5761            let state = self.admin_project_root(params.project_path)?;
5762            let kind = Self::parse_ignore_kind(params.kind.as_deref(), true)?.ok_or_else(|| {
5763                CliError::InvalidInput(MCP_ERROR_IGNORE_KIND_REQUIRED_FOR_ADD.to_owned())
5764            })?;
5765            let report = add_ignore_entry(
5766                state.config_path.as_deref(),
5767                &state.root,
5768                kind,
5769                &params.value,
5770            )?;
5771            Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
5772        })())
5773    }
5774
5775    /// Remove a manual `ProjectAtlas` ignore entry.
5776    #[tool(
5777        name = "atlas_ignore_remove",
5778        description = "Remove one manual ProjectAtlas ignore entry from the selected project's config."
5779    )]
5780    fn atlas_ignore_remove(
5781        &self,
5782        Parameters(params): Parameters<AtlasIgnoreMutationParams>,
5783    ) -> String {
5784        Self::as_mcp_text((|| {
5785            let state = self.admin_project_root(params.project_path)?;
5786            let kind = Self::parse_ignore_kind(params.kind.as_deref(), false)?;
5787            let report = remove_ignore_entry(
5788                state.config_path.as_deref(),
5789                &state.root,
5790                kind,
5791                &params.value,
5792            )?;
5793            Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
5794        })())
5795    }
5796
5797    /// Scan a repository, import purpose metadata, rebuild symbols, and return an overview.
5798    #[tool(
5799        name = "atlas_scan",
5800        description = "Scan repository structure, import ProjectAtlas purpose metadata, rebuild symbols, and return a TOON overview."
5801    )]
5802    fn atlas_scan(&self, Parameters(params): Parameters<AtlasScanParams>) -> String {
5803        Self::as_mcp_text((|| {
5804            let nearest_project = self.nearest_project_enabled(params.nearest_project);
5805            let background = params.background.unwrap_or(false);
5806            let (state, path) = if background {
5807                self.background_state_and_root_path(
5808                    params.project_path,
5809                    params.path,
5810                    nearest_project,
5811                )?
5812            } else {
5813                self.state_and_root_path(params.project_path, params.path, nearest_project)?
5814            };
5815            let symbol_options = SymbolBuildOptions::new(
5816                params.max_bytes.unwrap_or(MAX_SYMBOL_FILE_BYTES),
5817                params.max_workers,
5818                params.timeout_seconds,
5819            );
5820            let text_index_max_bytes = params.text_index_max_bytes;
5821            if background {
5822                let task = self.start_index_task(
5823                    McpTaskOperation::Scan,
5824                    symbol_options,
5825                    MCP_TOOL_ATLAS_OVERVIEW,
5826                    move |control, symbol_options| {
5827                        let plan = ScanRuntimePlan::for_path_controlled(
5828                            state.config_path.as_deref(),
5829                            &path,
5830                            text_index_max_bytes,
5831                            control,
5832                        )?;
5833                        let mut store = Self::open_mut_store(&state)?;
5834                        run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, control)?;
5835                        Ok(())
5836                    },
5837                )?;
5838                return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
5839            }
5840            let control = index_work_control(&symbol_options);
5841            let plan = ScanRuntimePlan::for_path_controlled(
5842                state.config_path.as_deref(),
5843                &path,
5844                text_index_max_bytes,
5845                &control,
5846            )?;
5847            let mut store = Self::open_mut_store(&state)?;
5848            let report =
5849                run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control)?;
5850            Self::encode_named_payload(MCP_PAYLOAD_SCAN, &report)
5851        })())
5852    }
5853
5854    /// Render one verified overview response with optional request cancellation.
5855    fn atlas_overview_response(
5856        &self,
5857        params: AtlasProjectParams,
5858        context: Option<RequestContext<RoleServer>>,
5859    ) -> String {
5860        Self::as_mcp_text((|| {
5861            let state = self.state_for_project_path(params.project_path)?;
5862            self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
5863                let overview = store.overview()?;
5864                let toon = render_overview(&overview);
5865                let usage = Self::telemetry_enabled()
5866                    .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
5867                    .and_then(Result::ok)
5868                    .map(|baseline_tokens| {
5869                        McpUsageIntent::directory_walk(
5870                            MCP_EVENT_ATLAS_OVERVIEW,
5871                            None,
5872                            None,
5873                            baseline_tokens,
5874                        )
5875                    });
5876                Ok((toon, usage))
5877            })
5878        })())
5879    }
5880
5881    /// Return the indexed repository overview.
5882    #[tool(
5883        name = "atlas_overview",
5884        description = "Return a compact TOON overview of indexed files, folders, and purpose coverage."
5885    )]
5886    fn atlas_overview(
5887        &self,
5888        Parameters(params): Parameters<AtlasProjectParams>,
5889        context: RequestContext<RoleServer>,
5890    ) -> String {
5891        self.atlas_overview_response(params, Some(context))
5892    }
5893
5894    /// Rank folders before an agent chooses a work area.
5895    #[tool(
5896        name = "atlas_folders",
5897        description = "Rank repository folders by query and purpose so agents choose a work area before opening files."
5898    )]
5899    fn atlas_folders(
5900        &self,
5901        Parameters(params): Parameters<AtlasQueryParams>,
5902        context: RequestContext<RoleServer>,
5903    ) -> String {
5904        self.atlas_folders_response(params, Some(context))
5905    }
5906
5907    /// Build the folders response with optional request telemetry context.
5908    fn atlas_folders_response(
5909        &self,
5910        params: AtlasQueryParams,
5911        context: Option<RequestContext<RoleServer>>,
5912    ) -> String {
5913        Self::as_mcp_text((|| {
5914            let state = self.state_for_project_path(params.project_path)?;
5915            let query = Self::query_or_empty(params.query);
5916            self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
5917                let selected =
5918                    ranked_folder_nodes_with_reasons(store, &query, params.limit.unwrap_or(10))?;
5919                let toon = render_ranked_nodes(NODE_LABEL_FOLDERS, &selected);
5920                let usage = Self::telemetry_enabled()
5921                    .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
5922                    .and_then(Result::ok)
5923                    .map(|baseline_tokens| {
5924                        McpUsageIntent::directory_walk(
5925                            MCP_EVENT_ATLAS_FOLDERS,
5926                            None,
5927                            Some(query.clone()),
5928                            baseline_tokens,
5929                        )
5930                    });
5931                Ok((toon, usage))
5932            })
5933        })())
5934    }
5935
5936    /// Rank files after an agent has chosen a folder or query.
5937    #[tool(
5938        name = "atlas_files",
5939        description = "Rank repository files by query, purpose, optional folder, and optional indexed text fallback before an agent opens source."
5940    )]
5941    fn atlas_files(
5942        &self,
5943        Parameters(params): Parameters<AtlasFilesParams>,
5944        context: RequestContext<RoleServer>,
5945    ) -> String {
5946        self.atlas_files_response(params, Some(context))
5947    }
5948
5949    /// Build the files response with optional request telemetry context.
5950    fn atlas_files_response(
5951        &self,
5952        params: AtlasFilesParams,
5953        context: Option<RequestContext<RoleServer>>,
5954    ) -> String {
5955        Self::as_mcp_text((|| {
5956            let nearest_project = self.nearest_project_enabled(params.nearest_project);
5957            let (state, folder_filter, routed_project) = self.state_and_optional_folder_filter(
5958                params.project_path.as_deref(),
5959                params.folder.as_deref(),
5960                nearest_project,
5961            )?;
5962            let query = Self::query_or_empty(params.query);
5963            self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
5964                let selected = ranked_file_nodes_with_reasons(
5965                    store,
5966                    &query,
5967                    folder_filter.as_deref(),
5968                    params.file_pattern.as_deref(),
5969                    params.limit.unwrap_or(10),
5970                    params.include_content.unwrap_or(false),
5971                )?;
5972                let toon = Self::with_selected_project_audit(
5973                    &state,
5974                    routed_project,
5975                    render_ranked_nodes(NODE_LABEL_FILES, &selected),
5976                )?;
5977                let usage = Self::telemetry_enabled()
5978                    .then(|| {
5979                        self.estimated_source_tokens_cached(
5980                            &state,
5981                            store,
5982                            &stamp,
5983                            folder_filter.as_deref(),
5984                            params.file_pattern.as_deref(),
5985                        )
5986                    })
5987                    .and_then(Result::ok)
5988                    .map(|baseline_tokens| {
5989                        McpUsageIntent::estimate(
5990                            MCP_EVENT_ATLAS_FILES,
5991                            params
5992                                .file_pattern
5993                                .clone()
5994                                .or_else(|| folder_filter.clone()),
5995                            Some(query.clone()),
5996                            baseline_tokens,
5997                        )
5998                    });
5999                Ok((toon, usage))
6000            })
6001        })())
6002    }
6003
6004    /// Recommend the next indexed folders, files, and inspection commands.
6005    #[tool(
6006        name = "atlas_next",
6007        description = "Recommend top indexed folders/files with reasons and deterministic follow-up commands for a task query."
6008    )]
6009    fn atlas_next(
6010        &self,
6011        Parameters(params): Parameters<AtlasQueryParams>,
6012        context: RequestContext<RoleServer>,
6013    ) -> String {
6014        Self::as_mcp_text((|| {
6015            let state = self.state_for_project_path(params.project_path)?;
6016            let query = Self::query_or_empty(params.query);
6017            self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
6018                let report = next_step_report(store, &query, params.limit)?;
6019                let payload = next_step_report_payload(&report);
6020                let toon = Self::encode_named_payload(MCP_PAYLOAD_NEXT, &payload)?;
6021                let usage = Self::telemetry_enabled()
6022                    .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
6023                    .and_then(Result::ok)
6024                    .map(|baseline_tokens| {
6025                        McpUsageIntent::directory_walk(
6026                            MCP_EVENT_ATLAS_NEXT,
6027                            None,
6028                            Some(query.clone()),
6029                            baseline_tokens,
6030                        )
6031                    });
6032                Ok((toon, usage))
6033            })
6034        })())
6035    }
6036
6037    /// Build a compact file outline.
6038    #[tool(
6039        name = "atlas_outline",
6040        description = "Return compact TOON outline and preview context for a selected file."
6041    )]
6042    fn atlas_outline(
6043        &self,
6044        Parameters(params): Parameters<AtlasOutlineParams>,
6045        context: RequestContext<RoleServer>,
6046    ) -> String {
6047        Self::as_mcp_text((|| {
6048            let nearest_project = self.nearest_project_enabled(params.nearest_project);
6049            let resolved = self.state_and_file_key(
6050                params.project_path.as_deref(),
6051                &params.file,
6052                nearest_project,
6053            )?;
6054            let state = resolved.state;
6055            self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, _stamp| {
6056                let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
6057                let content = read_indexed_file_content(store, &file_key)?;
6058                let language = store
6059                    .load_node_by_path(&file_key)?
6060                    .and_then(|node| node.node.language);
6061                let outline =
6062                    build_outline(&file_key, language, &content, params.lines.unwrap_or(12));
6063                let toon = Self::with_selected_project_audit(
6064                    &state,
6065                    resolved.routed_project,
6066                    render_outline(&outline),
6067                )?;
6068                let usage = Some(McpUsageIntent::text(
6069                    MCP_EVENT_ATLAS_OUTLINE,
6070                    Some(file_key),
6071                    content,
6072                ));
6073                Ok((toon, usage))
6074            })
6075        })())
6076    }
6077
6078    /// Render one verified structured file-summary response.
6079    fn atlas_file_summary_response(
6080        &self,
6081        params: &AtlasFileSummaryParams,
6082        context: Option<RequestContext<RoleServer>>,
6083    ) -> String {
6084        Self::as_mcp_text((|| {
6085            let nearest_project = self.nearest_project_enabled(params.nearest_project);
6086            let resolved = self.state_and_file_key(
6087                params.project_path.as_deref(),
6088                &params.file,
6089                nearest_project,
6090            )?;
6091            let state = resolved.state;
6092            self.with_fresh_string_and_usage_for_request(&state, context, |store, _stamp| {
6093                let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
6094                let content = read_indexed_file_content(store, &file_key)?;
6095                let report = build_file_summary_from_source(
6096                    store,
6097                    Path::new(&file_key),
6098                    params.limit.unwrap_or(DEFAULT_FILE_SUMMARY_LIMIT),
6099                    &content,
6100                )?;
6101                let rendered = if params.compact.unwrap_or(false) {
6102                    encode_agent_payload(&McpFileSummaryPayload {
6103                        file_summary: McpFileSummary::from(&report),
6104                    })
6105                } else {
6106                    render_file_summary(&report)
6107                };
6108                let toon =
6109                    Self::with_selected_project_audit(&state, resolved.routed_project, rendered)?;
6110                let usage = Some(McpUsageIntent::text(
6111                    MCP_EVENT_ATLAS_FILE_SUMMARY,
6112                    Some(report.file_path),
6113                    content,
6114                ));
6115                Ok((toon, usage))
6116            })
6117        })())
6118    }
6119
6120    /// Return deterministic structured file intelligence from the deep index.
6121    #[tool(
6122        name = "atlas_file_summary",
6123        description = "Return structured TOON file intelligence: file purpose, content summary, imports, symbols, line ranges, and calls."
6124    )]
6125    fn atlas_file_summary(
6126        &self,
6127        Parameters(params): Parameters<AtlasFileSummaryParams>,
6128        context: RequestContext<RoleServer>,
6129    ) -> String {
6130        self.atlas_file_summary_response(&params, Some(context))
6131    }
6132
6133    /// Search selected indexed files with optional context lines.
6134    #[tool(
6135        name = "atlas_search",
6136        description = "Search indexed files with literal, regex, or fuzzy matching, file filters, pagination, and TOON results."
6137    )]
6138    fn atlas_search(
6139        &self,
6140        Parameters(params): Parameters<AtlasSearchParams>,
6141        context: RequestContext<RoleServer>,
6142    ) -> String {
6143        Self::as_mcp_text((|| {
6144            let state = self.state_for_project_path(params.project_path.clone())?;
6145            self.with_fresh_string_and_usage_controlled_for_request(
6146                &state,
6147                Some(context),
6148                |store, _stamp, control| {
6149                    let report = search_indexed_files_with_control(
6150                        store,
6151                        &SearchQuery {
6152                            pattern: &params.pattern,
6153                            regex: params.regex.unwrap_or(false),
6154                            fuzzy: params.fuzzy.unwrap_or(false),
6155                            case_sensitive: params.case_sensitive.unwrap_or(false),
6156                            file_pattern: params.file_pattern.as_deref(),
6157                            context_lines: params.context_lines.unwrap_or(0),
6158                            start_index: params.start_index.unwrap_or(0),
6159                            limit: params.limit.unwrap_or(20),
6160                            retrieval_mode: params.retrieval_mode.unwrap_or_default().into(),
6161                        },
6162                        Some(control),
6163                    )?;
6164                    let toon = render_search_report(&report);
6165                    let usage = Some(McpUsageIntent::estimate(
6166                        MCP_EVENT_ATLAS_SEARCH,
6167                        params.file_pattern.clone(),
6168                        Some(params.pattern.clone()),
6169                        byte_count_to_tokens(report.searched_bytes),
6170                    ));
6171                    Ok((toon, usage))
6172                },
6173            )
6174        })())
6175    }
6176
6177    /// Return an exact line or symbol slice from a selected file.
6178    #[tool(
6179        name = "atlas_slice",
6180        description = "Return exact source for a selected line range or indexed symbol, after folder/file orientation."
6181    )]
6182    fn atlas_slice(
6183        &self,
6184        Parameters(params): Parameters<AtlasSliceParams>,
6185        context: RequestContext<RoleServer>,
6186    ) -> String {
6187        Self::as_mcp_text((|| {
6188            let nearest_project = self.nearest_project_enabled(params.nearest_project);
6189            let resolved = self.state_and_file_key(
6190                params.project_path.as_deref(),
6191                &params.file,
6192                nearest_project,
6193            )?;
6194            let state = resolved.state;
6195            self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, _stamp| {
6196                let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
6197                let file = PathBuf::from(&file_key);
6198                let content = read_indexed_file_content(store, &file_key)?;
6199                let output_budget = CodeSliceBudget::new(
6200                    params
6201                        .output_bytes
6202                        .unwrap_or(CodeSliceBudget::DEFAULT_OUTPUT_BYTES),
6203                )?;
6204                let report = if let Some(symbol) = params.symbol.as_ref() {
6205                    read_symbol_slice_from_source_bounded(
6206                        store,
6207                        &file,
6208                        &SymbolSliceSelector {
6209                            name: symbol,
6210                            parent: params.symbol_parent.as_deref().and_then(nonempty_str),
6211                            kind: params.symbol_kind.as_deref().and_then(nonempty_str),
6212                            signature: params.symbol_signature.as_deref().and_then(nonempty_str),
6213                            line: params.symbol_line,
6214                        },
6215                        &content,
6216                        output_budget,
6217                    )?
6218                } else {
6219                    if params
6220                        .symbol_parent
6221                        .as_deref()
6222                        .and_then(nonempty_str)
6223                        .is_some()
6224                        || params
6225                            .symbol_kind
6226                            .as_deref()
6227                            .and_then(nonempty_str)
6228                            .is_some()
6229                        || params
6230                            .symbol_signature
6231                            .as_deref()
6232                            .and_then(nonempty_str)
6233                            .is_some()
6234                        || params.symbol_line.is_some()
6235                    {
6236                        return Err(CliError::InvalidInput(
6237                            SYMBOL_DISAMBIGUATOR_WITHOUT_SYMBOL_ERROR.to_string(),
6238                        ));
6239                    }
6240                    let start_line = params.start_line.ok_or_else(|| {
6241                        CliError::InvalidInput(START_LINE_REQUIRED_ERROR.to_string())
6242                    })?;
6243                    read_indexed_code_slice_from_source_bounded(
6244                        store,
6245                        &file,
6246                        start_line,
6247                        params.end_line,
6248                        &content,
6249                        output_budget,
6250                    )?
6251                };
6252                let toon = report.fit_output(|report| {
6253                    Self::with_selected_project_audit(
6254                        &state,
6255                        resolved.routed_project,
6256                        render_code_slice(report),
6257                    )
6258                })?;
6259                let usage = Some(McpUsageIntent::text(
6260                    MCP_EVENT_ATLAS_SLICE,
6261                    Some(report.slice().path.clone()),
6262                    content,
6263                ));
6264                Ok((toon, usage))
6265            })
6266        })())
6267    }
6268
6269    /// Rebuild symbol graphs for indexed files.
6270    #[tool(
6271        name = "atlas_symbols_build",
6272        description = "Rebuild ProjectAtlas symbol graphs for indexed files and return a TOON build report."
6273    )]
6274    fn atlas_symbols_build(&self, Parameters(params): Parameters<AtlasScanParams>) -> String {
6275        Self::as_mcp_text((|| {
6276            let nearest_project = self.nearest_project_enabled(params.nearest_project);
6277            let background = params.background.unwrap_or(false);
6278            let (state, path) = if background {
6279                self.background_state_and_root_path(
6280                    params.project_path,
6281                    params.path,
6282                    nearest_project,
6283                )?
6284            } else {
6285                self.state_and_root_path(params.project_path, params.path, nearest_project)?
6286            };
6287            let options = SymbolBuildOptions::new(
6288                params.max_bytes.unwrap_or(MAX_SYMBOL_FILE_BYTES),
6289                params.max_workers,
6290                params.timeout_seconds,
6291            );
6292            let text_index_max_bytes = params.text_index_max_bytes;
6293            if background {
6294                let task = self.start_index_task(
6295                    McpTaskOperation::SymbolsBuild,
6296                    options,
6297                    MCP_TOOL_ATLAS_SYMBOLS,
6298                    move |control, options| {
6299                        let plan = ScanRuntimePlan::for_path_controlled(
6300                            state.config_path.as_deref(),
6301                            &path,
6302                            text_index_max_bytes,
6303                            control,
6304                        )?;
6305                        let mut store = Self::open_mut_store(&state)?;
6306                        run_symbol_build_pipeline_controlled(
6307                            &mut store, &plan, &options, None, control,
6308                        )?;
6309                        Ok(())
6310                    },
6311                )?;
6312                return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
6313            }
6314            let control = index_work_control(&options);
6315            let plan = ScanRuntimePlan::for_path_controlled(
6316                state.config_path.as_deref(),
6317                &path,
6318                text_index_max_bytes,
6319                &control,
6320            )?;
6321            let mut store = Self::open_mut_store(&state)?;
6322            let report =
6323                run_symbol_build_pipeline_controlled(&mut store, &plan, &options, None, &control)?;
6324            Self::encode_named_payload(MCP_PAYLOAD_SYMBOLS_BUILD, &report)
6325        })())
6326    }
6327
6328    /// Render one verified symbol-list response.
6329    fn atlas_symbols_response(
6330        &self,
6331        params: &AtlasSymbolsParams,
6332        context: Option<RequestContext<RoleServer>>,
6333    ) -> String {
6334        Self::as_mcp_text((|| {
6335            let nearest_project = self.nearest_project_enabled(params.nearest_project);
6336            let (state, file, routed_project) = self.state_and_optional_file_key(
6337                params.project_path.as_deref(),
6338                params.file.as_deref(),
6339                nearest_project,
6340            )?;
6341            self.with_fresh_string_and_usage_for_request(&state, context, |store, _stamp| {
6342                let file = file
6343                    .as_deref()
6344                    .map(|path| validated_indexed_file_key(store, Path::new(path)))
6345                    .transpose()?;
6346                let symbols = store.load_symbols(
6347                    file.as_deref(),
6348                    params.query.as_deref(),
6349                    params.limit.unwrap_or(50),
6350                )?;
6351                let toon = Self::with_selected_project_audit(
6352                    &state,
6353                    routed_project,
6354                    render_symbols(&symbols),
6355                )?;
6356                let usage = Self::telemetry_enabled()
6357                    .then(|| {
6358                        estimated_source_tokens_for_paths(
6359                            store,
6360                            symbols.iter().map(|symbol| symbol.path.as_str()),
6361                        )
6362                    })
6363                    .and_then(Result::ok)
6364                    .map(|baseline_tokens| {
6365                        McpUsageIntent::estimate(
6366                            MCP_EVENT_ATLAS_SYMBOLS,
6367                            file.clone(),
6368                            params.query.clone(),
6369                            baseline_tokens,
6370                        )
6371                    });
6372                Ok((toon, usage))
6373            })
6374        })())
6375    }
6376
6377    /// List indexed symbols.
6378    #[tool(
6379        name = "atlas_symbols",
6380        description = "List indexed symbols by optional file and query as compact TOON."
6381    )]
6382    fn atlas_symbols(
6383        &self,
6384        Parameters(params): Parameters<AtlasSymbolsParams>,
6385        context: RequestContext<RoleServer>,
6386    ) -> String {
6387        self.atlas_symbols_response(&params, Some(context))
6388    }
6389
6390    /// Render the shared detailed/analysis contract through one selected store set.
6391    fn detailed_symbol_relations_response(
6392        state: &McpProjectState,
6393        routed_project: bool,
6394        file: &str,
6395        params: &AtlasSymbolRelationsParams,
6396        analysis: bool,
6397        stores: SymbolRelationStores<'_>,
6398        control: &IndexWorkControl,
6399    ) -> Result<(String, Option<McpUsageIntent>), CliError> {
6400        if params.query.is_some() {
6401            return Err(CliError::Service(ServiceError::InvalidInput(
6402                MCP_ERROR_DETAILED_RELATION_QUERY.to_string(),
6403            )));
6404        }
6405        let primary = stores.primary();
6406        let file = validated_indexed_file_key(primary, Path::new(file))?;
6407        let graph_file = RepositoryFilePath::new(Path::new(&file))
6408            .map_err(|error| CliError::Service(ServiceError::InvalidInput(error.to_string())))?;
6409        let anchor = if let Some(symbol) = params.symbol.as_ref() {
6410            if symbol.is_empty() {
6411                return Err(CliError::Service(ServiceError::InvalidInput(
6412                    MCP_ERROR_DETAILED_RELATION_SYMBOL.to_string(),
6413                )));
6414            }
6415            RelationAnchor::Symbol {
6416                file: graph_file,
6417                name: symbol.clone(),
6418                symbol_kind: params
6419                    .symbol_kind
6420                    .as_deref()
6421                    .and_then(nonempty_str)
6422                    .map(parse_symbol_kind)
6423                    .transpose()?,
6424                parent: params
6425                    .symbol_parent
6426                    .as_deref()
6427                    .and_then(nonempty_str)
6428                    .map(ToString::to_string),
6429                signature: params
6430                    .symbol_signature
6431                    .as_deref()
6432                    .and_then(nonempty_str)
6433                    .map(ToString::to_string),
6434            }
6435        } else {
6436            if params
6437                .symbol_parent
6438                .as_deref()
6439                .and_then(nonempty_str)
6440                .is_some()
6441                || params
6442                    .symbol_kind
6443                    .as_deref()
6444                    .and_then(nonempty_str)
6445                    .is_some()
6446                || params
6447                    .symbol_signature
6448                    .as_deref()
6449                    .and_then(nonempty_str)
6450                    .is_some()
6451            {
6452                return Err(CliError::Service(ServiceError::InvalidInput(
6453                    MCP_ERROR_DETAILED_RELATION_DISAMBIGUATOR.to_string(),
6454                )));
6455            }
6456            RelationAnchor::File { file: graph_file }
6457        };
6458        let rows = u32::try_from(params.limit.unwrap_or(50)).map_err(|_overflow| {
6459            CliError::Service(ServiceError::InvalidInput(
6460                MCP_ERROR_DETAILED_RELATION_LIMIT.to_string(),
6461            ))
6462        })?;
6463        let limits = GraphLimits::new(
6464            rows,
6465            params.occurrence_limit.unwrap_or(25),
6466            params.depth.unwrap_or(1),
6467            params.output_bytes.unwrap_or(256 * 1024),
6468        )
6469        .map_err(|error| CliError::Service(ServiceError::InvalidInput(error.to_string())))?;
6470        let relations = DetailedRelationQuery {
6471            anchor,
6472            direction: parse_relation_direction(
6473                params
6474                    .direction
6475                    .as_deref()
6476                    .unwrap_or(MCP_SYMBOL_RELATION_DIRECTION_DEFAULT),
6477            )?,
6478            relation: params
6479                .relation
6480                .as_deref()
6481                .map(parse_coverage_relation)
6482                .transpose()?,
6483            minimum_confidence: parse_relation_confidence(
6484                params
6485                    .minimum_confidence
6486                    .as_deref()
6487                    .unwrap_or(MCP_SYMBOL_RELATION_CONFIDENCE_DEFAULT),
6488            )?,
6489            resolution: parse_relation_resolution(
6490                params
6491                    .resolution
6492                    .as_deref()
6493                    .unwrap_or(MCP_SYMBOL_RELATION_RESOLUTION_DEFAULT),
6494            )?,
6495            include_occurrences: params.include_occurrences.unwrap_or(false),
6496            budget: DetailedRelationBudget::from_graph_limits(limits).with_aggregate_limits(
6497                params.edge_limit,
6498                params.node_limit,
6499                params.visited_limit,
6500                params.occurrence_total_limit,
6501                params.intermediate_bytes,
6502                params.deadline_ms,
6503            )?,
6504            cursor: params.cursor.clone(),
6505        };
6506        let usage = matches!(&stores, SymbolRelationStores::Single(_))
6507            .then(|| {
6508                Self::telemetry_enabled()
6509                    .then(|| {
6510                        estimated_source_tokens_for_paths(primary, std::iter::once(file.as_str()))
6511                    })
6512                    .and_then(Result::ok)
6513                    .map(|baseline_tokens| {
6514                        McpUsageIntent::estimate(
6515                            MCP_EVENT_ATLAS_SYMBOL_RELATIONS,
6516                            Some(file.clone()),
6517                            params.symbol.clone(),
6518                            baseline_tokens,
6519                        )
6520                    })
6521            })
6522            .flatten();
6523
6524        if analysis {
6525            let mode = match params
6526                .analysis_mode
6527                .as_deref()
6528                .unwrap_or(MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE)
6529            {
6530                MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE => RelationAnalysisMode::Architecture,
6531                MCP_RELATION_ANALYSIS_MODE_IMPACT => RelationAnalysisMode::Impact,
6532                MCP_RELATION_ANALYSIS_MODE_TRACE => RelationAnalysisMode::Trace,
6533                _unsupported => {
6534                    return Err(CliError::Service(ServiceError::InvalidInput(
6535                        MCP_ERROR_UNSUPPORTED_ANALYSIS_MODE.to_string(),
6536                    )));
6537                }
6538            };
6539            let trace_target = relation_analysis_trace_target(primary, params)?;
6540            let vcs_explicit =
6541                params.vcs.is_some() || params.vcs_base.is_some() || params.vcs_head.is_some();
6542            let vcs = relation_analysis_vcs(params)?;
6543            let query = RelationAnalysisQuery {
6544                relations,
6545                mode,
6546                trace_target,
6547                vcs: (mode == RelationAnalysisMode::Impact || vcs_explicit).then_some(vcs),
6548                include_communities: params.include_communities.unwrap_or(false),
6549                include_cycles: params.include_cycles.unwrap_or(false),
6550                include_dead_code: params.include_dead_code.unwrap_or(false),
6551            };
6552            let toon = match stores {
6553                SymbolRelationStores::Single(store) => {
6554                    let draft = load_relation_analysis(store, &query, Some(control))?;
6555                    draft
6556                        .fit_output(|report, control| {
6557                            Self::with_selected_project_audit_controlled(
6558                                state,
6559                                routed_project,
6560                                controlled_named_output(
6561                                    OutputFormat::Toon,
6562                                    MCP_PAYLOAD_SYMBOL_RELATIONS,
6563                                    report,
6564                                    control,
6565                                )?,
6566                                control,
6567                            )
6568                        })?
6569                        .1
6570                }
6571                SymbolRelationStores::Federated(stores) => {
6572                    let draft = load_federated_relation_analysis(stores, &query, Some(control))?;
6573                    draft
6574                        .fit_output(|report, control| {
6575                            Self::with_selected_project_audit_controlled(
6576                                state,
6577                                routed_project,
6578                                controlled_named_output(
6579                                    OutputFormat::Toon,
6580                                    MCP_PAYLOAD_SYMBOL_RELATIONS,
6581                                    report,
6582                                    control,
6583                                )?,
6584                                control,
6585                            )
6586                        })?
6587                        .1
6588                }
6589            };
6590            return Ok((toon, usage));
6591        }
6592
6593        let toon = match stores {
6594            SymbolRelationStores::Single(store) => {
6595                let draft = load_detailed_relation_page(store, &relations, Some(control))?;
6596                draft
6597                    .fit_output(Some(control), |report| {
6598                        let payload = if params.compact.unwrap_or(false) {
6599                            Self::encode_named_payload(
6600                                MCP_PAYLOAD_SYMBOL_RELATIONS,
6601                                &McpCompactDetailedRelationReport::new(report, &file, params),
6602                            )?
6603                        } else {
6604                            Self::encode_named_payload(MCP_PAYLOAD_SYMBOL_RELATIONS, report)?
6605                        };
6606                        Self::with_selected_project_audit(state, routed_project, payload)
6607                    })?
6608                    .1
6609            }
6610            SymbolRelationStores::Federated(stores) => {
6611                let draft = load_federated_detailed_relations(stores, &relations, Some(control))?;
6612                draft
6613                    .fit_output(Some(control), |report| {
6614                        let payload = if params.compact.unwrap_or(false) {
6615                            Self::encode_named_payload(
6616                                MCP_PAYLOAD_SYMBOL_RELATIONS,
6617                                &McpCompactFederatedDetailedRelationReport::new(
6618                                    report, &file, params,
6619                                ),
6620                            )?
6621                        } else {
6622                            Self::encode_named_payload(MCP_PAYLOAD_SYMBOL_RELATIONS, report)?
6623                        };
6624                        Self::with_selected_project_audit(state, routed_project, payload)
6625                    })?
6626                    .1
6627            }
6628        };
6629        Ok((toon, usage))
6630    }
6631
6632    /// Render one verified legacy or detailed symbol-relation response.
6633    fn atlas_symbol_relations_response(
6634        &self,
6635        params: &AtlasSymbolRelationsParams,
6636        context: Option<RequestContext<RoleServer>>,
6637    ) -> String {
6638        Self::as_mcp_text((|| {
6639            let (detailed, analysis) = match params
6640                .view
6641                .as_deref()
6642                .unwrap_or(MCP_SYMBOL_RELATION_VIEW_LEGACY)
6643            {
6644                MCP_SYMBOL_RELATION_VIEW_LEGACY => (false, false),
6645                MCP_SYMBOL_RELATION_VIEW_DETAILED => (true, false),
6646                MCP_SYMBOL_RELATION_VIEW_ANALYSIS => (true, true),
6647                _unsupported => {
6648                    return Err(CliError::Service(ServiceError::InvalidInput(
6649                        MCP_ERROR_SYMBOL_RELATION_VIEW.to_string(),
6650                    )));
6651                }
6652            };
6653            if params.compact.unwrap_or(false) && (!detailed || analysis) {
6654                return Err(CliError::Service(ServiceError::InvalidInput(
6655                    MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW.to_string(),
6656                )));
6657            }
6658            if !analysis && relation_analysis_controls_present(params) {
6659                return Err(CliError::Service(ServiceError::InvalidInput(
6660                    MCP_ERROR_ANALYSIS_VIEW_REQUIRED.to_string(),
6661                )));
6662            }
6663            let nearest_project = self.nearest_project_enabled(params.nearest_project);
6664            let (state, file, routed_project) = self.state_and_optional_file_key(
6665                params.project_path.as_deref(),
6666                params.file.as_deref(),
6667                nearest_project,
6668            )?;
6669            if let Some(roots) = params.roots.as_ref() {
6670                if !detailed {
6671                    return Err(CliError::Service(ServiceError::InvalidInput(
6672                        MCP_ERROR_FEDERATED_RELATION_VIEW.to_string(),
6673                    )));
6674                }
6675                let file = file.as_deref().ok_or_else(|| {
6676                    CliError::Service(ServiceError::InvalidInput(
6677                        MCP_ERROR_DETAILED_RELATION_FILE.to_string(),
6678                    ))
6679                })?;
6680                let control =
6681                    index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None))
6682                        .with_timeout_ceiling(Duration::from_millis(
6683                            params.deadline_ms.unwrap_or(10_000).clamp(1, 60_000),
6684                        ));
6685                let bridge = context
6686                    .map(|context| McpRequestCancellationBridge::start(context, &control))
6687                    .transpose()?;
6688                let roots = roots.iter().map(PathBuf::from).collect::<Vec<_>>();
6689                let stores = open_federated_atlas_stores_for_project(
6690                    &state.db_path,
6691                    &state.root,
6692                    state.config_path.as_deref(),
6693                    &roots,
6694                    &control,
6695                )?;
6696                let result = Self::detailed_symbol_relations_response(
6697                    &state,
6698                    routed_project,
6699                    file,
6700                    params,
6701                    analysis,
6702                    SymbolRelationStores::Federated(stores),
6703                    &control,
6704                )
6705                .map(|(toon, _usage)| toon);
6706                if bridge
6707                    .as_ref()
6708                    .is_some_and(McpRequestCancellationBridge::is_cancelled)
6709                {
6710                    control.cancel();
6711                }
6712                drop(bridge);
6713                return result;
6714            }
6715            self.with_fresh_string_and_usage_controlled_for_request(
6716                &state,
6717                context,
6718                |store, _stamp, control| {
6719                    let file = file
6720                        .as_deref()
6721                        .map(|path| validated_indexed_file_key(store, Path::new(path)))
6722                        .transpose()?;
6723                    if detailed {
6724                        let file = file.as_deref().ok_or_else(|| {
6725                            CliError::Service(ServiceError::InvalidInput(
6726                                MCP_ERROR_DETAILED_RELATION_FILE.to_string(),
6727                            ))
6728                        })?;
6729                        return Self::detailed_symbol_relations_response(
6730                            &state,
6731                            routed_project,
6732                            file,
6733                            params,
6734                            analysis,
6735                            SymbolRelationStores::Single(store),
6736                            control,
6737                        );
6738                    }
6739
6740                    let relations = store.load_symbol_relations(
6741                        file.as_deref(),
6742                        params.query.as_deref(),
6743                        params.limit.unwrap_or(50),
6744                    )?;
6745                    let toon = Self::with_selected_project_audit(
6746                        &state,
6747                        routed_project,
6748                        render_symbol_relations(&relations),
6749                    )?;
6750                    let usage = Self::telemetry_enabled()
6751                        .then(|| {
6752                            estimated_source_tokens_for_paths(
6753                                store,
6754                                relations.iter().map(|relation| relation.path.as_str()),
6755                            )
6756                        })
6757                        .and_then(Result::ok)
6758                        .map(|baseline_tokens| {
6759                            McpUsageIntent::estimate(
6760                                MCP_EVENT_ATLAS_SYMBOL_RELATIONS,
6761                                file.clone(),
6762                                params.query.clone(),
6763                                baseline_tokens,
6764                            )
6765                        });
6766                    Ok((toon, usage))
6767                },
6768            )
6769        })())
6770    }
6771
6772    /// List indexed symbol relations.
6773    #[tool(
6774        name = "atlas_symbol_relations",
6775        description = "List imports, calls, dependencies, and containment edges as compact TOON."
6776    )]
6777    fn atlas_symbol_relations(
6778        &self,
6779        Parameters(params): Parameters<AtlasSymbolRelationsParams>,
6780        context: RequestContext<RoleServer>,
6781    ) -> String {
6782        self.atlas_symbol_relations_response(&params, Some(context))
6783    }
6784
6785    /// Return structural health findings.
6786    #[tool(
6787        name = "atlas_health",
6788        description = "Return a bounded ProjectAtlas structural health page with optional category, severity, and path-prefix filters."
6789    )]
6790    fn atlas_health(
6791        &self,
6792        Parameters(params): Parameters<AtlasHealthParams>,
6793        context: RequestContext<RoleServer>,
6794    ) -> String {
6795        Self::as_mcp_text((|| {
6796            let state = self.state_for_project_path(params.project_path.clone())?;
6797            if params.coverage.unwrap_or(false) {
6798                let query = coverage_query_from_params(&params)?;
6799                return self.with_fresh_string_and_usage_for_request(
6800                    &state,
6801                    Some(context),
6802                    |store, stamp| {
6803                        let mut report = load_coverage_discovery(store, query.clone())?;
6804                        let toon = finalize_coverage_output(OutputFormat::Toon, &mut report)?;
6805                        let usage = Self::telemetry_enabled()
6806                            .then(|| {
6807                                self.estimated_source_tokens_cached(
6808                                    &state, store, &stamp, None, None,
6809                                )
6810                            })
6811                            .and_then(Result::ok)
6812                            .map(|baseline_tokens| {
6813                                McpUsageIntent::directory_walk(
6814                                    MCP_EVENT_ATLAS_HEALTH,
6815                                    None,
6816                                    None,
6817                                    baseline_tokens,
6818                                )
6819                            });
6820                        Ok((toon, usage))
6821                    },
6822                );
6823            }
6824            if has_coverage_filters(&params) {
6825                return Err(CliError::InvalidInput(
6826                    MCP_ERROR_COVERAGE_FILTERS_REQUIRE_COVERAGE.to_string(),
6827                ));
6828            }
6829            let scope = if params.source_only.unwrap_or(false) {
6830                HealthScope::source_only()
6831            } else {
6832                HealthScope::all()
6833            };
6834            let query = health_query_from_params(&params, scope)?;
6835            self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
6836                let page = store.unresolved_health_findings_page_current(&query)?;
6837                let toon = render_health_page(&page, &query);
6838                let usage = Self::telemetry_enabled()
6839                    .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
6840                    .and_then(Result::ok)
6841                    .map(|baseline_tokens| {
6842                        McpUsageIntent::directory_walk(
6843                            MCP_EVENT_ATLAS_HEALTH,
6844                            None,
6845                            None,
6846                            baseline_tokens,
6847                        )
6848                    });
6849                Ok((toon, usage))
6850            })
6851        })())
6852    }
6853
6854    /// Mark an intentional deterministic health finding as resolved.
6855    #[tool(
6856        name = "atlas_health_resolve",
6857        description = "Mark a deterministic ProjectAtlas health finding as agent-resolved with rationale."
6858    )]
6859    fn atlas_health_resolve(
6860        &self,
6861        Parameters(params): Parameters<AtlasHealthResolveParams>,
6862    ) -> String {
6863        Self::as_mcp_text((|| {
6864            let state = self.state_for_project_path(params.project_path)?;
6865            let store = Self::open_existing_mut_store(&state)?;
6866            let resolution = HealthResolution {
6867                finding_id: params.finding_id,
6868                category: params.category,
6869                path: params.path,
6870                related_path: params.related_path,
6871                rationale: params.rationale,
6872            };
6873            store.resolve_health_finding(&resolution)?;
6874            Self::encode_named_payload(MCP_PAYLOAD_HEALTH_RESOLUTION, &resolution)
6875        })())
6876    }
6877
6878    /// Run `ProjectAtlas` lint checks without terminating the MCP transport.
6879    #[tool(
6880        name = "atlas_lint",
6881        description = "Run ProjectAtlas lint checks and return an ok flag, CLI-compatible exit code, and report text."
6882    )]
6883    fn atlas_lint(&self, Parameters(params): Parameters<AtlasLintParams>) -> String {
6884        Self::as_mcp_text((|| {
6885            let state = self.admin_project_root(params.project_path.clone())?;
6886            let report = Self::lint_report_for_state(&state, &params)?;
6887            Self::encode_named_payload(MCP_PAYLOAD_LINT, &report)
6888        })())
6889    }
6890
6891    /// Return token savings telemetry.
6892    #[tool(
6893        name = "atlas_token_report",
6894        description = "Return ProjectAtlas token-savings telemetry for the whole index or one session."
6895    )]
6896    fn atlas_token_report(&self, Parameters(params): Parameters<AtlasTokenParams>) -> String {
6897        Self::as_mcp_text((|| {
6898            let state = self.state_for_project_path(params.project_path.clone())?;
6899            let store = Self::open_read_store(&state)?;
6900            let include_chart = params.include_chart.unwrap_or(false);
6901            let chart_theme = Self::parse_token_chart_theme(params.theme.as_deref())?;
6902            if let Some(window) = params.trend_window.as_deref() {
6903                if params.benchmark_results.is_some() {
6904                    return Err(CliError::InvalidInput(
6905                        TOKEN_TREND_BENCHMARK_ERROR.to_string(),
6906                    ));
6907                }
6908                let window = TokenTrendWindow::parse(window).ok_or_else(|| {
6909                    CliError::InvalidInput(format!(
6910                        "unsupported token trend window {window:?}; {TOKEN_TREND_WINDOW_ERROR_SUFFIX}"
6911                    ))
6912                })?;
6913                let report = match load_token_report(
6914                    &store,
6915                    TokenReportRequest::Trends {
6916                        caller_label: params.session.as_deref(),
6917                        window,
6918                    },
6919                )? {
6920                    TokenReport::Trends(report) => report,
6921                    TokenReport::Overview(_) => {
6922                        return Err(CliError::InvalidInput(
6923                            TOKEN_TRENDS_RESULT_VARIANT_MISMATCH.to_string(),
6924                        ));
6925                    }
6926                };
6927                if include_chart {
6928                    let chart = render_token_trend_dashboard_plain_with_theme(&report, chart_theme);
6929                    return Self::encode_two_named_payloads(
6930                        MCP_PAYLOAD_TOKEN_TRENDS,
6931                        &report,
6932                        MCP_PAYLOAD_CHART,
6933                        &chart,
6934                    );
6935                }
6936                return Ok(render_token_trends(&report));
6937            }
6938            let overview = match load_token_report(
6939                &store,
6940                TokenReportRequest::Overview {
6941                    caller_label: params.session.as_deref(),
6942                    benchmark_results: params.benchmark_results.as_deref().map(Path::new),
6943                },
6944            )? {
6945                TokenReport::Overview(overview) => overview,
6946                TokenReport::Trends(_) => {
6947                    return Err(CliError::InvalidInput(
6948                        TOKEN_OVERVIEW_RESULT_VARIANT_MISMATCH.to_string(),
6949                    ));
6950                }
6951            };
6952            if include_chart {
6953                let chart = render_token_dashboard_plain_with_theme(
6954                    &overview,
6955                    params.session.as_deref(),
6956                    chart_theme,
6957                );
6958                return Self::encode_two_named_payloads(
6959                    MCP_PAYLOAD_TOKEN_SAVINGS,
6960                    &overview,
6961                    MCP_PAYLOAD_CHART,
6962                    &chart,
6963                );
6964            }
6965            Ok(render_token_overview(&overview))
6966        })())
6967    }
6968
6969    /// Return repository-intelligence parity readiness.
6970    #[tool(
6971        name = "atlas_parity_report",
6972        description = "Return a ProjectAtlas repository-intelligence parity gate report for release and agent-runtime readiness."
6973    )]
6974    fn atlas_parity_report(
6975        &self,
6976        Parameters(params): Parameters<AtlasParityParams>,
6977        context: RequestContext<RoleServer>,
6978    ) -> String {
6979        Self::as_mcp_text((|| {
6980            let state = self.state_for_project_path(params.project_path)?;
6981            let profile = params
6982                .profile
6983                .unwrap_or_else(|| crate::REPOSITORY_INTELLIGENCE_PROFILE.to_string());
6984            self.with_fresh_string_for_request(&state, Some(context), |store, _stamp| {
6985                Ok(render_parity_report(&build_parity_report(store, &profile)?))
6986            })
6987        })())
6988    }
6989
6990    /// Return local settings and cache/index locations.
6991    #[tool(
6992        name = "atlas_settings",
6993        description = "Return ProjectAtlas local settings, config, and durable index paths."
6994    )]
6995    fn atlas_settings(&self, Parameters(params): Parameters<AtlasProjectParams>) -> String {
6996        Self::as_mcp_text((|| {
6997            let state = self.state_for_project_path(params.project_path)?;
6998            self.render_settings_with_capabilities(&state)
6999        })())
7000    }
7001
7002    /// Return watcher availability and operating mode.
7003    #[tool(
7004        name = "atlas_watch_status",
7005        description = "Return ProjectAtlas watcher availability and current operating mode."
7006    )]
7007    fn atlas_watch_status(&self, Parameters(params): Parameters<AtlasProjectParams>) -> String {
7008        let state = match self.state_for_project_path(params.project_path) {
7009            Ok(state) => state,
7010            Err(error) => return Self::as_mcp_text(Err(error)),
7011        };
7012        let mut report = watcher_status_report(false);
7013        if !state.db_path.exists() {
7014            report
7015                .recommendation
7016                .push_str(WATCH_STATUS_SCAN_RECOMMENDATION);
7017        }
7018        Self::as_mcp_text(Ok(render_watch_status(&report)))
7019    }
7020
7021    /// Run one incremental refresh pass.
7022    #[tool(
7023        name = "atlas_watch_once",
7024        description = "Run one MCP-safe watcher refresh pass over the repository and rebuild changed symbols, with optional worker, timeout, and text-index size controls."
7025    )]
7026    fn atlas_watch_once(&self, Parameters(params): Parameters<AtlasWatchOnceParams>) -> String {
7027        Self::as_mcp_text((|| {
7028            let nearest_project = self.nearest_project_enabled(params.nearest_project);
7029            let background = params.background.unwrap_or(false);
7030            let (state, path) = if background {
7031                self.background_state_and_root_path(
7032                    params.project_path,
7033                    params.path,
7034                    nearest_project,
7035                )?
7036            } else {
7037                self.state_and_root_path(params.project_path, params.path, nearest_project)?
7038            };
7039            let symbol_options = SymbolBuildOptions::new(
7040                MAX_SYMBOL_FILE_BYTES,
7041                params.max_workers,
7042                params.timeout_seconds,
7043            );
7044            let text_index_max_bytes = params.text_index_max_bytes;
7045            if background {
7046                let task = self.start_index_task(
7047                    McpTaskOperation::WatchOnce,
7048                    symbol_options,
7049                    MCP_TOOL_ATLAS_OVERVIEW,
7050                    move |control, symbol_options| {
7051                        let plan = ScanRuntimePlan::for_path_controlled(
7052                            state.config_path.as_deref(),
7053                            &path,
7054                            text_index_max_bytes,
7055                            control,
7056                        )?;
7057                        let mut store = Self::open_mut_store(&state)?;
7058                        run_single_watch_refresh_controlled(
7059                            &mut store,
7060                            &plan,
7061                            &symbol_options,
7062                            control,
7063                        )?;
7064                        Ok(())
7065                    },
7066                )?;
7067                return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
7068            }
7069            let control = index_work_control(&symbol_options);
7070            let plan = ScanRuntimePlan::for_path_controlled(
7071                state.config_path.as_deref(),
7072                &path,
7073                text_index_max_bytes,
7074                &control,
7075            )?;
7076            let mut store = Self::open_mut_store(&state)?;
7077            let report =
7078                run_single_watch_refresh_controlled(&mut store, &plan, &symbol_options, &control)?;
7079            Self::encode_named_payload(MCP_PAYLOAD_WATCH, &report)
7080        })())
7081    }
7082
7083    /// Preview or remove legacy `.purpose` files.
7084    #[tool(
7085        name = "atlas_strip_legacy_purpose",
7086        description = "Preview or remove legacy .purpose files after their metadata has been imported to SQLite."
7087    )]
7088    fn atlas_strip_legacy_purpose(
7089        &self,
7090        Parameters(params): Parameters<AtlasStripLegacyParams>,
7091    ) -> String {
7092        Self::as_mcp_text((|| {
7093            let nearest_project = self.nearest_project_enabled(params.nearest_project);
7094            let (state, path) =
7095                self.state_and_root_path(params.project_path, params.path, nearest_project)?;
7096            let report = strip_legacy_purpose(
7097                &path,
7098                state.config_path.as_deref(),
7099                params.apply.unwrap_or(false),
7100                params.dry_run.unwrap_or(false),
7101                params
7102                    .strip_source_headers
7103                    .unwrap_or_else(|| state.config_path.is_some()),
7104            )?;
7105            Self::encode_named_payload(MCP_PAYLOAD_LEGACY_PURPOSE_MIGRATION, &report)
7106        })())
7107    }
7108
7109    /// Preview or remove local runtime index/cache files.
7110    #[tool(
7111        name = "atlas_reset_index",
7112        description = "Preview or clear ProjectAtlas local SQLite index/cache files for recovery."
7113    )]
7114    fn atlas_reset_index(&self, Parameters(params): Parameters<AtlasResetIndexParams>) -> String {
7115        Self::as_mcp_text((|| {
7116            let state = self.state_for_project_path(params.project_path)?;
7117            let report = reset_index_files(
7118                &state.db_path,
7119                params.apply.unwrap_or(false),
7120                params.dry_run.unwrap_or(false),
7121                params.include_mcp_config.unwrap_or(false),
7122            )?;
7123            Self::encode_named_payload(MCP_PAYLOAD_RESET_INDEX, &report)
7124        })())
7125    }
7126
7127    /// Generate a project-local MCP config document.
7128    #[tool(
7129        name = "atlas_mcp_config",
7130        description = "Return a generated ProjectAtlas MCP config document for mcp-json, codex, claude-code, or opencode hosts."
7131    )]
7132    fn atlas_mcp_config(&self, Parameters(params): Parameters<AtlasMcpConfigParams>) -> String {
7133        Self::as_mcp_text((|| {
7134            let state = self.admin_project_root(params.project_path)?;
7135            let harness = Self::parse_harness_config(params.harness.as_deref())?;
7136            let server_name = params
7137                .server_name
7138                .unwrap_or_else(|| MCP_DEFAULT_CONFIG_SERVER_NAME.to_string());
7139            let report = build_harness_mcp_config_report(
7140                harness,
7141                &server_name,
7142                &state.db_path,
7143                state.config_path.as_deref(),
7144                params.nearest_project.unwrap_or(false),
7145            )?;
7146            Self::encode_named_payload(MCP_PAYLOAD_MCP_CONFIG, &report)
7147        })())
7148    }
7149
7150    /// Return runtime identity and compiled MCP tool surface.
7151    #[tool(
7152        name = "atlas_runtime_info",
7153        description = "Return ProjectAtlas runtime identity, version, capabilities, and compiled MCP tool names."
7154    )]
7155    fn atlas_runtime_info(&self, Parameters(_params): Parameters<AtlasProjectParams>) -> String {
7156        let _session_scope = self.session.as_str();
7157        Self::as_mcp_text(Ok(render_runtime_info(&build_runtime_info())))
7158    }
7159
7160    /// Return a compact startup brief for agents.
7161    #[tool(
7162        name = "atlas_session_brief",
7163        description = "Return selected project identity, index state, ranked candidates, blockers, and typed next-call recommendations for agent startup."
7164    )]
7165    fn atlas_session_brief(
7166        &self,
7167        Parameters(params): Parameters<AtlasSessionBriefParams>,
7168        context: RequestContext<RoleServer>,
7169    ) -> String {
7170        Self::as_mcp_text((|| {
7171            if params.compact.unwrap_or(false) {
7172                let brief = self.build_compact_session_brief(params, Some(context))?;
7173                Self::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)
7174            } else {
7175                let brief = self.build_session_brief(params, Some(context))?;
7176                Self::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)
7177            }
7178        })())
7179    }
7180
7181    /// Return typed status for one MCP task-progress record.
7182    #[tool(
7183        name = "atlas_task_status",
7184        description = "Return typed status for a bounded MCP task-progress record."
7185    )]
7186    fn atlas_task_status(&self, Parameters(params): Parameters<AtlasTaskParams>) -> String {
7187        Self::as_mcp_text((|| {
7188            let status = self.task_status(params.task_id)?;
7189            Self::encode_named_payload(MCP_PAYLOAD_TASK_STATUS, &status)
7190        })())
7191    }
7192
7193    /// Request cancellation for one MCP task-progress record.
7194    #[tool(
7195        name = "atlas_task_cancel",
7196        description = "Request cancellation for a bounded MCP task-progress record."
7197    )]
7198    fn atlas_task_cancel(&self, Parameters(params): Parameters<AtlasTaskParams>) -> String {
7199        Self::as_mcp_text((|| {
7200            let cancel = self.task_cancel(params.task_id)?;
7201            Self::encode_named_payload(MCP_PAYLOAD_TASK_CANCEL, &cancel)
7202        })())
7203    }
7204
7205    /// Return a bounded purpose curation queue.
7206    #[tool(
7207        name = "atlas_purpose_queue",
7208        description = "Return a bounded folder-first queue of ProjectAtlas paths that need agent purpose curation."
7209    )]
7210    fn atlas_purpose_queue(
7211        &self,
7212        Parameters(params): Parameters<AtlasPurposeQueueParams>,
7213        context: RequestContext<RoleServer>,
7214    ) -> String {
7215        Self::as_mcp_text((|| {
7216            let state = self.state_for_project_path(params.health.project_path.clone())?;
7217            let query =
7218                health_query_from_params(&params.health, purpose_queue_scope(&params.health))?;
7219            let task = params
7220                .task
7221                .as_deref()
7222                .unwrap_or(MCP_PURPOSE_TASK_QUEUE)
7223                .to_string();
7224            self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
7225                let page = purpose_curation_page(store, &query, &task)?;
7226                let toon = render_purpose_curation_page(&page);
7227                let usage = Self::telemetry_enabled()
7228                    .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
7229                    .and_then(Result::ok)
7230                    .map(|baseline_tokens| {
7231                        McpUsageIntent::directory_walk(
7232                            MCP_EVENT_ATLAS_PURPOSE_QUEUE,
7233                            None,
7234                            None,
7235                            baseline_tokens,
7236                        )
7237                    });
7238                Ok((toon, usage))
7239            })
7240        })())
7241    }
7242
7243    /// Set an agent-approved purpose in the durable index.
7244    #[tool(
7245        name = "atlas_purpose_set",
7246        description = "Set agent-approved ProjectAtlas purpose metadata for one indexed path."
7247    )]
7248    fn atlas_purpose_set(&self, Parameters(params): Parameters<AtlasPurposeSetParams>) -> String {
7249        Self::as_mcp_text((|| {
7250            let state = self.state_for_project_path(params.project_path)?;
7251            let store = Self::open_existing_mut_store(&state)?;
7252            let node_key = Self::validated_indexed_node_key(&store, &params.path)?;
7253            store.set_purpose(&node_key, &params.purpose, PurposeSource::Agent)?;
7254            Self::encode_serialized_payload(McpPurposeSetResponse {
7255                purpose_set: McpPurposeSetPayload {
7256                    path: node_key,
7257                    status: PurposeStatus::Approved,
7258                    source: PurposeSource::Agent,
7259                    agent_reviewed: true,
7260                },
7261            })
7262        })())
7263    }
7264
7265    /// Batch-review existing purpose records through the MCP surface.
7266    #[tool(
7267        name = "atlas_purpose_review",
7268        description = "Preview or apply agent-reviewed ProjectAtlas purpose metadata for multiple indexed paths."
7269    )]
7270    fn atlas_purpose_review(
7271        &self,
7272        Parameters(params): Parameters<AtlasPurposeReviewParams>,
7273        context: RequestContext<RoleServer>,
7274    ) -> String {
7275        Self::as_mcp_text((|| {
7276            let apply = params.apply.unwrap_or(false);
7277            let requests = params
7278                .items
7279                .into_iter()
7280                .map(|item| PurposeReviewRequest {
7281                    path: item.path,
7282                    purpose: item.purpose,
7283                    confirm_existing: item.confirm_existing.unwrap_or(false),
7284                    task: item.task,
7285                    work_key: item.work_key,
7286                    state_token: item.state_token,
7287                })
7288                .collect::<Vec<_>>();
7289            validate_purpose_review_admission(&requests)?;
7290            let state = self.state_for_project_path(params.project_path)?;
7291            if apply {
7292                let store = Self::open_existing_mut_store(&state)?;
7293                let report = review_purposes(&store, &requests, true)?;
7294                return Ok(render_purpose_review_report(&report));
7295            }
7296            self.with_fresh_string_for_request(&state, Some(context), |store, _stamp| {
7297                let report = review_purposes(store, &requests, false)?;
7298                Ok(render_purpose_review_report(&report))
7299            })
7300        })())
7301    }
7302}
7303
7304#[tool_handler(router = self.tool_router)]
7305impl ServerHandler for ProjectAtlasMcpServer {
7306    fn get_info(&self) -> ServerInfo {
7307        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
7308            .with_server_info(Implementation::new(
7309                MCP_SERVER_NAME,
7310                env!("CARGO_PKG_VERSION"),
7311            ))
7312            .with_instructions(MCP_SERVER_INSTRUCTIONS)
7313    }
7314}
7315
7316/// Return current Unix time in milliseconds for MCP task status records.
7317fn mcp_unix_time_ms() -> u128 {
7318    SystemTime::now()
7319        .duration_since(UNIX_EPOCH)
7320        .map_or(0, |duration| duration.as_millis())
7321}
7322
7323/// Recognize cooperative cancellation through each typed adapter error layer.
7324fn task_error_is_canceled(error: &CliError) -> bool {
7325    std::iter::successors(
7326        Some(error as &(dyn std::error::Error + 'static)),
7327        |source| source.source(),
7328    )
7329    .any(|source| {
7330        matches!(
7331            source.downcast_ref::<IndexWorkFailure>(),
7332            Some(IndexWorkFailure::Cancelled { .. })
7333        )
7334    })
7335}
7336
7337/// Retain one concise task failure without letting diagnostics grow unbounded.
7338fn bounded_task_error(error: &CliError) -> String {
7339    error
7340        .to_string()
7341        .chars()
7342        .take(MCP_TASK_ERROR_MAX_CHARS)
7343        .collect()
7344}
7345
7346#[cfg(test)]
7347mod tests {
7348    use super::*;
7349    use crate::atlas_map::init_project_with_config;
7350    use notify::{Event, EventKind, event::ModifyKind};
7351    use projectatlas_core::graph::{
7352        Completeness, ConfidenceClass, EntitySelector, ExtendedRelationKind, ExternalSelector,
7353        GraphEntity, GraphIdentityText, GraphRelationKind, LogicalRelation, PackageSelector,
7354        RelationResolution, RepositoryFilePath,
7355    };
7356    use projectatlas_core::symbols::RelationKind;
7357    use projectatlas_core::{
7358        IndexCancellation, IndexWorkStage, RankedConnectionDirection, RankedConnectionKind,
7359        RankedConnectionTarget,
7360    };
7361    use std::collections::BTreeSet;
7362    use std::fs;
7363    use std::io;
7364    use std::process::Command as StdCommand;
7365    use std::time::{Duration, Instant};
7366
7367    fn require(condition: bool, message: &str) -> Result<(), Box<dyn std::error::Error>> {
7368        if condition {
7369            Ok(())
7370        } else {
7371            Err(io::Error::other(message.to_string()).into())
7372        }
7373    }
7374
7375    #[test]
7376    fn task_errors_classify_only_typed_cancellation_as_canceled() {
7377        let stage = IndexWorkStage::RepositoryTraversal;
7378        assert!(task_error_is_canceled(&CliError::IndexWork(
7379            IndexWorkFailure::Cancelled { stage },
7380        )));
7381        assert!(task_error_is_canceled(&CliError::Fs(
7382            projectatlas_fs::FsError::IndexWork(IndexWorkFailure::Cancelled { stage }),
7383        )));
7384        assert!(task_error_is_canceled(&CliError::Db(DbError::IndexWork(
7385            IndexWorkFailure::Cancelled { stage },
7386        ))));
7387        assert!(task_error_is_canceled(&CliError::Service(
7388            ServiceError::Db(DbError::IndexWork(IndexWorkFailure::Cancelled { stage })),
7389        )));
7390        assert!(!task_error_is_canceled(&CliError::IndexWork(
7391            IndexWorkFailure::DeadlineExceeded { stage },
7392        )));
7393        assert!(!task_error_is_canceled(&CliError::Db(DbError::IndexWork(
7394            IndexWorkFailure::DeadlineExceeded { stage },
7395        ))));
7396        assert!(!task_error_is_canceled(&CliError::Fs(
7397            projectatlas_fs::FsError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
7398                stage,
7399                resource: projectatlas_core::IndexWorkResource::Entries,
7400                limit: 1,
7401                observed: 2,
7402            }),
7403        )));
7404    }
7405
7406    fn usage_test_project(
7407        parent: &Path,
7408        name: &str,
7409    ) -> Result<(McpProjectState, AtlasStore), Box<dyn std::error::Error>> {
7410        let root = parent.join(name);
7411        fs::create_dir_all(root.join(".projectatlas"))?;
7412        let db_path = root.join(".projectatlas").join("projectatlas.db");
7413        let store = AtlasStore::open_for_project(&db_path, &root)?;
7414        Ok((
7415            McpProjectState {
7416                root,
7417                db_path,
7418                config_path: None,
7419            },
7420            store,
7421        ))
7422    }
7423
7424    fn usage_runtime_identity(
7425        server: &ProjectAtlasMcpServer,
7426        state: &McpProjectState,
7427        store: &AtlasStore,
7428    ) -> Result<UsageRuntimeInstance, Box<dyn std::error::Error>> {
7429        let binding = McpUsageProjectBinding::capture(state, store)?;
7430        let project_instance = server
7431            .usage_runtime
7432            .lock()
7433            .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
7434            .entries
7435            .iter()
7436            .find(|entry| entry.binding == binding)
7437            .map(|entry| Arc::clone(&entry.instance))
7438            .ok_or_else(|| io::Error::other("operating-system entropy was unavailable"))?;
7439        let identity = *project_instance
7440            .lock()
7441            .map_err(|_poisoned| io::Error::other("project usage lock poisoned"))?;
7442        Ok(identity)
7443    }
7444
7445    #[test]
7446    fn mcp_server_clones_share_one_runtime_identity() -> Result<(), Box<dyn std::error::Error>> {
7447        let temp = tempfile::tempdir()?;
7448        let (state, store) = usage_test_project(temp.path(), "selected-project")?;
7449        let first = ProjectAtlasMcpServer::new(
7450            state.db_path.clone(),
7451            None,
7452            "shared-label".to_string(),
7453            false,
7454        );
7455        first.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
7456        let first_identity = usage_runtime_identity(&first, &state, &store)?;
7457        let cloned = first.clone();
7458        require(
7459            usage_runtime_identity(&first, &state, &store)? == first_identity,
7460            "cloning an MCP server changed the original telemetry identity",
7461        )?;
7462        let restarted = ProjectAtlasMcpServer::new(
7463            state.db_path.clone(),
7464            None,
7465            "shared-label".to_string(),
7466            false,
7467        );
7468        restarted.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
7469        let restarted_identity = usage_runtime_identity(&restarted, &state, &store)?;
7470
7471        require(
7472            usage_runtime_identity(&cloned, &state, &store)? == first_identity,
7473            "cloning an MCP server changed its process-scoped telemetry identity",
7474        )?;
7475        require(
7476            restarted_identity != first_identity,
7477            "a separately constructed MCP server reused the prior runtime identity",
7478        )
7479    }
7480
7481    #[test]
7482    fn mcp_request_cancellation_bridge_reaches_index_work() -> Result<(), Box<dyn std::error::Error>>
7483    {
7484        let cancellation = Arc::new(std::sync::atomic::AtomicBool::new(false));
7485        let observed = Arc::clone(&cancellation);
7486        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7487        let bridge = McpRequestCancellationBridge::start_with_probe(
7488            move || observed.load(Ordering::Acquire),
7489            &control,
7490        )?;
7491
7492        cancellation.store(true, Ordering::Release);
7493        let mut canceled = false;
7494        for _attempt in 0..100 {
7495            if matches!(
7496                control.check(IndexWorkStage::Publication),
7497                Err(IndexWorkFailure::Cancelled { .. })
7498            ) {
7499                canceled = true;
7500                break;
7501            }
7502            thread::sleep(Duration::from_millis(2));
7503        }
7504        drop(bridge);
7505
7506        require(
7507            canceled,
7508            "RMCP cancellation probe did not reach the shared index work control",
7509        )
7510    }
7511
7512    #[test]
7513    fn mcp_same_path_project_identity_rotation_starts_a_distinct_runtime_entry()
7514    -> Result<(), Box<dyn std::error::Error>> {
7515        if telemetry_disabled() {
7516            return Ok(());
7517        }
7518        let temp = tempfile::tempdir()?;
7519        let (state, store) = usage_test_project(temp.path(), "selected-project")?;
7520        let server = ProjectAtlasMcpServer::new(
7521            state.db_path.clone(),
7522            None,
7523            "shared-label".to_string(),
7524            false,
7525        );
7526        let old_project_identity = store.captured_project_binding()?.project_instance_id;
7527        server.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
7528        let old_runtime_identity = usage_runtime_identity(&server, &state, &store)?;
7529        drop(store);
7530
7531        AtlasStore::transition_project_root(
7532            &state.db_path,
7533            &state.root,
7534            projectatlas_db::ProjectRootTransition::Detach,
7535        )?;
7536        let detached_store = AtlasStore::open_for_project(&state.db_path, &state.root)?;
7537        let detached_project_identity = detached_store
7538            .captured_project_binding()?
7539            .project_instance_id;
7540        server.record_usage_for_state(&state, &detached_store, |_usage_instance| Ok(()));
7541        let detached_runtime_identity = usage_runtime_identity(&server, &state, &detached_store)?;
7542        let tracked = server
7543            .usage_runtime
7544            .lock()
7545            .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
7546            .entries
7547            .len();
7548
7549        require(
7550            detached_project_identity != old_project_identity,
7551            "detach did not rotate the captured project identity",
7552        )?;
7553        require(
7554            detached_runtime_identity != old_runtime_identity,
7555            "same-path detach reused the previous project's telemetry identity",
7556        )?;
7557        require(
7558            tracked == 2,
7559            "same-path project identities did not retain distinct bounded runtime entries",
7560        )
7561    }
7562
7563    #[test]
7564    fn mcp_telemetry_project_bindings_are_deduplicated_and_hard_bounded()
7565    -> Result<(), Box<dyn std::error::Error>> {
7566        let bindings = (0..=MCP_TELEMETRY_PROJECT_BINDING_LIMIT)
7567            .map(|index| {
7568                let identity_byte = u8::try_from(index + 1)?;
7569                Ok(McpUsageProjectBinding {
7570                    project_instance_id: ProjectInstanceId::from_bytes([identity_byte; 16])?,
7571                    root: PathBuf::from(format!("project-{index}")),
7572                    db_path: PathBuf::from(format!("project-{index}.db")),
7573                })
7574            })
7575            .collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
7576        let mut runtime = McpUsageRuntime::default();
7577
7578        for binding in bindings.iter().take(MCP_TELEMETRY_PROJECT_BINDING_LIMIT) {
7579            require(
7580                runtime.instance_for_binding(binding.clone()).is_some(),
7581                "a binding inside the telemetry project bound was rejected",
7582            )?;
7583        }
7584        require(
7585            runtime.instance_for_binding(bindings[0].clone()).is_some(),
7586            "an existing binding was rejected after the telemetry project bound was full",
7587        )?;
7588        require(
7589            runtime
7590                .instance_for_binding(bindings[MCP_TELEMETRY_PROJECT_BINDING_LIMIT].clone())
7591                .is_none(),
7592            "a new binding exceeded the telemetry project bound",
7593        )?;
7594        require(
7595            runtime.entries.len() == MCP_TELEMETRY_PROJECT_BINDING_LIMIT,
7596            "telemetry project binding registry exceeded its hard bound",
7597        )
7598    }
7599
7600    #[test]
7601    fn mcp_telemetry_busy_project_does_not_block_another_project()
7602    -> Result<(), Box<dyn std::error::Error>> {
7603        if telemetry_disabled() {
7604            return Ok(());
7605        }
7606        let server = ProjectAtlasMcpServer::new(
7607            PathBuf::from("startup.db"),
7608            None,
7609            "shared-label".to_string(),
7610            false,
7611        );
7612        let temp = tempfile::tempdir()?;
7613        let (state_a, store_a) = usage_test_project(temp.path(), "project-a")?;
7614        let (state_b, store_b) = usage_test_project(temp.path(), "project-b")?;
7615        let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1);
7616        let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1);
7617        let server_a = server.clone();
7618        let a_handle = std::thread::spawn(move || -> Result<(), String> {
7619            server_a.record_usage_for_state(&state_a, &store_a, |_usage_instance| {
7620                entered_tx.send(()).map_err(|error| {
7621                    CliError::InvalidInput(format!("test coordination failed: {error}"))
7622                })?;
7623                release_rx
7624                    .recv_timeout(Duration::from_secs(5))
7625                    .map_err(|error| {
7626                        CliError::InvalidInput(format!("test coordination failed: {error}"))
7627                    })?;
7628                Ok(())
7629            });
7630            Ok(())
7631        });
7632        entered_rx.recv_timeout(Duration::from_secs(2))?;
7633
7634        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
7635        let server_b = server;
7636        let b_handle = std::thread::spawn(move || -> Result<(), String> {
7637            server_b.record_usage_for_state(&state_b, &store_b, |_usage_instance| {
7638                done_tx.send(()).map_err(|error| {
7639                    CliError::InvalidInput(format!("test coordination failed: {error}"))
7640                })?;
7641                Ok(())
7642            });
7643            Ok(())
7644        });
7645        let project_b_completed = done_rx.recv_timeout(Duration::from_secs(1)).is_ok();
7646        release_tx.send(())?;
7647        a_handle
7648            .join()
7649            .map_err(|_panic| io::Error::other("project A telemetry thread panicked"))?
7650            .map_err(io::Error::other)?;
7651        b_handle
7652            .join()
7653            .map_err(|_panic| io::Error::other("project B telemetry thread panicked"))?
7654            .map_err(io::Error::other)?;
7655
7656        require(
7657            project_b_completed,
7658            "one project's blocked telemetry delayed another project",
7659        )
7660    }
7661
7662    #[test]
7663    fn mcp_telemetry_keeps_identity_when_capacity_seal_fails()
7664    -> Result<(), Box<dyn std::error::Error>> {
7665        if telemetry_disabled() {
7666            return Ok(());
7667        }
7668        let temp = tempfile::tempdir()?;
7669        let (state, store) = usage_test_project(temp.path(), "selected-project")?;
7670        let server = ProjectAtlasMcpServer::new(
7671            state.db_path.clone(),
7672            None,
7673            "shared-label".to_string(),
7674            false,
7675        );
7676        server.record_usage_for_state(&state, &store, |usage_instance| {
7677            record_usage_estimate(
7678                &store,
7679                Some(usage_instance),
7680                "seal-failure-test",
7681                MCP_EVENT_ATLAS_OVERVIEW,
7682                None,
7683                None,
7684                8,
7685                "overview:\n  files: 1\n",
7686            )
7687        });
7688        let initial_identity = usage_runtime_identity(&server, &state, &store)?;
7689        let busy_connection = rusqlite::Connection::open(&state.db_path)?;
7690        busy_connection.execute_batch("BEGIN IMMEDIATE")?;
7691        let mut calls = 0usize;
7692
7693        server.record_usage_for_state(&state, &store, |_usage_instance| {
7694            calls += 1;
7695            Err(CliError::Db(DbError::TelemetryBaselineCapacity))
7696        });
7697        busy_connection.execute_batch("ROLLBACK")?;
7698
7699        require(calls == 1, "failed sealing unexpectedly retried the event")?;
7700        require(
7701            usage_runtime_identity(&server, &state, &store)? == initial_identity,
7702            "failed sealing replaced the still-active project identity",
7703        )
7704    }
7705
7706    #[test]
7707    fn mcp_telemetry_rotates_and_retries_once_when_baselines_reach_capacity()
7708    -> Result<(), Box<dyn std::error::Error>> {
7709        if telemetry_disabled() {
7710            return Ok(());
7711        }
7712        let temp = tempfile::tempdir()?;
7713        let root = temp.path().join("selected-project");
7714        fs::create_dir_all(root.join(".projectatlas"))?;
7715        let db_path = root.join(".projectatlas").join("projectatlas.db");
7716        let store = AtlasStore::open_for_project(&db_path, &root)?;
7717        let state = McpProjectState {
7718            root,
7719            db_path: db_path.clone(),
7720            config_path: None,
7721        };
7722        let other_root = temp.path().join("other-project");
7723        fs::create_dir_all(other_root.join(".projectatlas"))?;
7724        let other_db_path = other_root.join(".projectatlas").join("projectatlas.db");
7725        let other_store = AtlasStore::open_for_project(&other_db_path, &other_root)?;
7726        let other_state = McpProjectState {
7727            root: other_root,
7728            db_path: other_db_path,
7729            config_path: None,
7730        };
7731        let server = ProjectAtlasMcpServer::new(db_path, None, "shared-label".to_string(), false);
7732        server.record_usage_for_state(&state, &store, |usage_instance| {
7733            record_usage_estimate(
7734                &store,
7735                Some(usage_instance),
7736                "rotation-test",
7737                MCP_EVENT_ATLAS_OVERVIEW,
7738                None,
7739                None,
7740                8,
7741                "overview:\n  files: 1\n",
7742            )
7743        });
7744        server.record_usage_for_state(&other_state, &other_store, |_usage_instance| Ok(()));
7745        let initial_identity = usage_runtime_identity(&server, &state, &store)?;
7746        let other_identity = usage_runtime_identity(&server, &other_state, &other_store)?;
7747        let mut calls = 0usize;
7748
7749        server.record_usage_for_state(&state, &store, |_usage_instance| {
7750            calls += 1;
7751            if calls == 1 {
7752                Err(CliError::Db(DbError::TelemetryBaselineCapacity))
7753            } else {
7754                Ok(())
7755            }
7756        });
7757
7758        let tracked = server
7759            .usage_runtime
7760            .lock()
7761            .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
7762            .entries
7763            .len();
7764        let rotated_identity = usage_runtime_identity(&server, &state, &store)?;
7765        require(calls == 2, "capacity handling did not retry exactly once")?;
7766        require(
7767            rotated_identity != initial_identity,
7768            "capacity handling did not rotate the bounded runtime identity",
7769        )?;
7770        require(
7771            tracked == 2,
7772            "rotation changed the bounded project binding inventory",
7773        )?;
7774        require(
7775            usage_runtime_identity(&server, &other_state, &other_store)? == other_identity,
7776            "one project's capacity rotation changed another project's identity",
7777        )
7778    }
7779
7780    #[test]
7781    fn navigation_result_survives_telemetry_write_failure() -> Result<(), Box<dyn std::error::Error>>
7782    {
7783        if telemetry_disabled() {
7784            return Ok(());
7785        }
7786        let temp = tempfile::tempdir()?;
7787        let repo = temp.path().join("repo");
7788        fs::create_dir_all(repo.join("src"))?;
7789        fs::write(repo.join("src").join("lib.rs"), "pub fn owner() {}\n")?;
7790        let config_path = repo.join(".projectatlas").join("config.toml");
7791        init_project_with_config(&repo, Some(&config_path))?;
7792        let db_path = repo.join(".projectatlas").join("projectatlas.db");
7793        let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
7794        let mut store = open_atlas_store_for_project(&db_path, &repo)?;
7795        run_scan_pipeline(
7796            &mut store,
7797            &plan,
7798            &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
7799        )?;
7800        drop(store);
7801
7802        let connection = rusqlite::Connection::open(&db_path)?;
7803        connection.execute_batch("BEGIN IMMEDIATE")?;
7804
7805        let server = ProjectAtlasMcpServer::new(
7806            db_path,
7807            Some(config_path),
7808            "shared-label".to_string(),
7809            false,
7810        );
7811        let result =
7812            server.atlas_overview_response(AtlasProjectParams { project_path: None }, None);
7813        connection.execute_batch("ROLLBACK")?;
7814
7815        if result.contains("overview:") && result.contains("files:") {
7816            Ok(())
7817        } else {
7818            Err(io::Error::other(format!(
7819                "telemetry failure replaced an already-built navigation result: {result}"
7820            ))
7821            .into())
7822        }
7823    }
7824
7825    #[test]
7826    fn mcp_calls_share_server_identity_and_new_server_uses_another()
7827    -> Result<(), Box<dyn std::error::Error>> {
7828        if telemetry_disabled() {
7829            return Ok(());
7830        }
7831        let temp = tempfile::tempdir()?;
7832        let repo = temp.path().join("repo");
7833        fs::create_dir_all(repo.join("src"))?;
7834        fs::write(repo.join("src").join("lib.rs"), "pub fn owner() {}\n")?;
7835        let config_path = repo.join(".projectatlas").join("config.toml");
7836        init_project_with_config(&repo, Some(&config_path))?;
7837        let db_path = repo.join(".projectatlas").join("projectatlas.db");
7838        let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
7839        let mut store = open_atlas_store_for_project(&db_path, &repo)?;
7840        run_scan_pipeline(
7841            &mut store,
7842            &plan,
7843            &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
7844        )?;
7845        drop(store);
7846
7847        let call_overview = |server: &ProjectAtlasMcpServer| {
7848            server.atlas_overview_response(AtlasProjectParams { project_path: None }, None)
7849        };
7850        let first = ProjectAtlasMcpServer::new(
7851            db_path.clone(),
7852            Some(config_path.clone()),
7853            "shared-label".to_string(),
7854            false,
7855        );
7856        require(
7857            call_overview(&first).contains("overview:"),
7858            "first MCP call failed",
7859        )?;
7860        require(
7861            call_overview(&first).contains("overview:"),
7862            "second MCP call failed",
7863        )?;
7864        let restarted = ProjectAtlasMcpServer::new(
7865            db_path.clone(),
7866            Some(config_path),
7867            "shared-label".to_string(),
7868            false,
7869        );
7870        require(
7871            call_overview(&restarted).contains("overview:"),
7872            "restarted MCP call failed",
7873        )?;
7874
7875        let connection = rusqlite::Connection::open(db_path)?;
7876        let instances: i64 = connection.query_row(
7877            "SELECT COUNT(*) FROM usage_instances WHERE owner = 'mcp_process' AND caller_label = 'shared-label'",
7878            [],
7879            |row| row.get(0),
7880        )?;
7881        let events: i64 =
7882            connection.query_row("SELECT COUNT(*) FROM usage_events", [], |row| row.get(0))?;
7883        require(
7884            instances == 2 && events == 3,
7885            "MCP calls did not reuse one identity per server construction",
7886        )
7887    }
7888
7889    #[test]
7890    fn mcp_database_filesystem_failures_are_typed_and_actionable()
7891    -> Result<(), Box<dyn std::error::Error>> {
7892        let error = CliError::Db(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
7893            path: PathBuf::from("project")
7894                .join(".projectatlas")
7895                .join("projectatlas.db"),
7896            mount_point: Some(PathBuf::from("project")),
7897            filesystem_type: Some("nfs".to_string()),
7898        });
7899        let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
7900        require(
7901            payload.contains("kind: database_filesystem_unsupported")
7902                && payload.contains("filesystem_type: nfs")
7903                && payload.contains("supported local filesystem"),
7904            "MCP TOON lost typed filesystem details or recovery guidance",
7905        )
7906    }
7907
7908    #[test]
7909    fn mcp_search_capability_failures_are_typed_and_actionable()
7910    -> Result<(), Box<dyn std::error::Error>> {
7911        let error = CliError::Service(ServiceError::SearchCapabilityUnavailable {
7912            requested_mode: projectatlas_service::SearchRetrievalMode::Hybrid,
7913            state: "not-installed",
7914            guidance: "install and build a compatible semantic generation",
7915        });
7916        let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
7917        require(
7918            payload.contains("kind: search_capability_unavailable")
7919                && payload.contains("requested_mode")
7920                && payload.contains("hybrid")
7921                && payload.contains("state")
7922                && payload.contains("not-installed")
7923                && payload.contains("compatible semantic generation"),
7924            "MCP TOON lost typed search-capability state or recovery guidance",
7925        )
7926    }
7927
7928    #[test]
7929    fn mcp_records_usage_only_for_the_accepted_verified_attempt()
7930    -> Result<(), Box<dyn std::error::Error>> {
7931        if telemetry_disabled() {
7932            return Ok(());
7933        }
7934        let temp = tempfile::tempdir()?;
7935        let repo = temp.path().join("repo");
7936        let source = repo.join("src").join("lib.rs");
7937        fs::create_dir_all(
7938            source
7939                .parent()
7940                .ok_or_else(|| io::Error::other("missing parent"))?,
7941        )?;
7942        let original = "pub fn original() {}\n";
7943        let revised = "pub fn revised() {}\n";
7944        fs::write(&source, original)?;
7945        let config_path = repo.join(".projectatlas").join("config.toml");
7946        init_project_with_config(&repo, Some(&config_path))?;
7947        let db_path = repo.join(".projectatlas").join("projectatlas.db");
7948        let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
7949        let mut store = open_atlas_store_for_project(&db_path, &repo)?;
7950        run_scan_pipeline(
7951            &mut store,
7952            &plan,
7953            &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
7954        )?;
7955        drop(store);
7956        let state = McpProjectState {
7957            root: repo.clone(),
7958            db_path: db_path.clone(),
7959            config_path: Some(config_path.clone()),
7960        };
7961        let server = ProjectAtlasMcpServer::new(
7962            db_path.clone(),
7963            Some(config_path.clone()),
7964            "accepted-attempt".to_string(),
7965            false,
7966        );
7967        let mut attempts = 0_u64;
7968
7969        let response =
7970            server.with_fresh_string_and_usage_for_request(&state, None, |store, _stamp| {
7971                attempts = attempts.saturating_add(1);
7972                let hash = store
7973                    .load_node_by_path("src/lib.rs")?
7974                    .and_then(|node| node.node.content_hash)
7975                    .ok_or_else(|| CliError::InvalidInput("source hash missing".to_string()))?;
7976                if attempts == 1 {
7977                    fs::write(&source, revised).map_err(|source_error| CliError::Io {
7978                        path: source.clone(),
7979                        source: source_error,
7980                    })?;
7981                    server.source_observations.inject_test_event(
7982                        &db_path,
7983                        &repo,
7984                        Some(&config_path),
7985                        Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source.clone()),
7986                    )?;
7987                }
7988                Ok((
7989                    hash,
7990                    Some(McpUsageIntent::estimate(
7991                        MCP_EVENT_ATLAS_OVERVIEW,
7992                        None,
7993                        None,
7994                        1,
7995                    )),
7996                ))
7997            })?;
7998
7999        require(
8000            attempts >= 2,
8001            "mid-query source edit did not retry the query",
8002        )?;
8003        require(
8004            response == blake3::hash(revised.as_bytes()).to_hex().to_string(),
8005            "MCP returned the provisional pre-edit result",
8006        )?;
8007        let connection = rusqlite::Connection::open(db_path)?;
8008        let events: i64 =
8009            connection.query_row("SELECT COUNT(*) FROM usage_events", [], |row| row.get(0))?;
8010        require(
8011            events == 1,
8012            "MCP telemetry recorded a discarded provisional attempt",
8013        )
8014    }
8015
8016    /// Wait for one admitted task to reach a terminal state.
8017    fn wait_for_background_task(
8018        server: &ProjectAtlasMcpServer,
8019        task_id: &str,
8020    ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
8021        for _attempt in 0..5_000 {
8022            let status = server.task_status(task_id.to_string())?;
8023            if let Some(record) = status.task.filter(McpTaskRecord::is_terminal_state) {
8024                return Ok(record);
8025            }
8026            thread::sleep(Duration::from_millis(1));
8027        }
8028        Err(io::Error::other("background task did not reach a terminal state").into())
8029    }
8030
8031    /// Admit one successful task and wait for completion.
8032    fn run_successful_background_task(
8033        server: &ProjectAtlasMcpServer,
8034    ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
8035        let task = server.start_index_task(
8036            McpTaskOperation::Scan,
8037            SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
8038            MCP_TOOL_ATLAS_OVERVIEW,
8039            |_control, _options| Ok(()),
8040        )?;
8041        wait_for_background_task(server, &task.task_id)
8042    }
8043
8044    /// Wait for the latest task of one operation to reach a terminal state.
8045    fn wait_for_background_operation(
8046        server: &ProjectAtlasMcpServer,
8047        operation: &McpTaskOperation,
8048    ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
8049        let task_id = server
8050            .task_registry
8051            .read()
8052            .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
8053            .records
8054            .iter()
8055            .rev()
8056            .find(|record| &record.operation == operation)
8057            .map(|record| record.task_id.clone())
8058            .ok_or_else(|| io::Error::other("background task was not admitted"))?;
8059        wait_for_background_task(server, &task_id)
8060    }
8061
8062    /// Assert that persisted index work is visible through normal agent reads.
8063    fn require_agent_index_reads(
8064        server: &ProjectAtlasMcpServer,
8065        project_path: &str,
8066        expected_symbol: &str,
8067    ) -> Result<(), Box<dyn std::error::Error>> {
8068        let overview = server.atlas_overview_response(
8069            AtlasProjectParams {
8070                project_path: Some(project_path.to_string()),
8071            },
8072            None,
8073        );
8074        require(
8075            overview.contains("overview:"),
8076            "agent overview did not read the published index",
8077        )?;
8078
8079        let summary = server.atlas_file_summary_response(
8080            &AtlasFileSummaryParams {
8081                project_path: Some(project_path.to_string()),
8082                file: "src/lib.rs".to_string(),
8083                nearest_project: Some(false),
8084                compact: None,
8085                limit: Some(25),
8086            },
8087            None,
8088        );
8089        require(
8090            summary.contains("file_summary:")
8091                && summary.contains("src/lib.rs")
8092                && summary.contains(expected_symbol),
8093            "agent file summary omitted published source facts",
8094        )?;
8095
8096        let symbols = server.atlas_symbols_response(
8097            &AtlasSymbolsParams {
8098                project_path: Some(project_path.to_string()),
8099                file: Some("src/lib.rs".to_string()),
8100                nearest_project: Some(false),
8101                query: None,
8102                limit: Some(50),
8103            },
8104            None,
8105        );
8106        require(
8107            symbols.contains("symbols[") && symbols.contains(expected_symbol),
8108            "agent symbol read omitted published parser output",
8109        )?;
8110
8111        let relations = server.atlas_symbol_relations_response(
8112            &AtlasSymbolRelationsParams {
8113                project_path: Some(project_path.to_string()),
8114                file: Some("src/lib.rs".to_string()),
8115                nearest_project: Some(false),
8116                query: None,
8117                limit: Some(50),
8118                ..AtlasSymbolRelationsParams::default()
8119            },
8120            None,
8121        );
8122        require(
8123            relations.contains("relations[") && relations.contains(expected_symbol),
8124            "agent relation read omitted published graph output",
8125        )?;
8126        let explicit_legacy = server.atlas_symbol_relations_response(
8127            &AtlasSymbolRelationsParams {
8128                project_path: Some(project_path.to_string()),
8129                file: Some("src/lib.rs".to_string()),
8130                nearest_project: Some(false),
8131                view: Some("legacy".to_string()),
8132                query: None,
8133                limit: Some(50),
8134                ..AtlasSymbolRelationsParams::default()
8135            },
8136            None,
8137        );
8138        require(
8139            relations == explicit_legacy,
8140            "explicit MCP legacy relation view changed default response bytes or ordering",
8141        )?;
8142        let compact_legacy = server.atlas_symbol_relations_response(
8143            &AtlasSymbolRelationsParams {
8144                project_path: Some(project_path.to_string()),
8145                file: Some("src/lib.rs".to_string()),
8146                nearest_project: Some(false),
8147                compact: Some(true),
8148                limit: Some(50),
8149                ..AtlasSymbolRelationsParams::default()
8150            },
8151            None,
8152        );
8153        require(
8154            compact_legacy.contains(MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW),
8155            "compact relation projection did not reject the legacy view",
8156        )?;
8157        let zero_limit_legacy = server.atlas_symbol_relations_response(
8158            &AtlasSymbolRelationsParams {
8159                project_path: Some(project_path.to_string()),
8160                file: Some("src/lib.rs".to_string()),
8161                nearest_project: Some(false),
8162                limit: Some(0),
8163                ..AtlasSymbolRelationsParams::default()
8164            },
8165            None,
8166        );
8167        require(
8168            zero_limit_legacy.contains("relations[1]"),
8169            "MCP legacy zero limit no longer preserves its one-row compatibility behavior",
8170        )?;
8171        let detailed = server.atlas_symbol_relations_response(
8172            &AtlasSymbolRelationsParams {
8173                project_path: Some(project_path.to_string()),
8174                file: Some("src/lib.rs".to_string()),
8175                nearest_project: Some(false),
8176                view: Some("detailed".to_string()),
8177                direction: Some("outbound".to_string()),
8178                limit: Some(50),
8179                ..AtlasSymbolRelationsParams::default()
8180            },
8181            None,
8182        );
8183        require(
8184            detailed.contains("symbol_relations:") && detailed.contains("anchor:"),
8185            "detailed MCP relation route did not return the bounded graph envelope",
8186        )?;
8187        if expected_symbol == "third" {
8188            let compact_detailed = server.atlas_symbol_relations_response(
8189                &AtlasSymbolRelationsParams {
8190                    project_path: Some(project_path.to_string()),
8191                    file: Some("src/lib.rs".to_string()),
8192                    nearest_project: Some(false),
8193                    view: Some("detailed".to_string()),
8194                    compact: Some(true),
8195                    symbol: Some("first".to_string()),
8196                    direction: Some("outbound".to_string()),
8197                    include_occurrences: Some(true),
8198                    limit: Some(1),
8199                    output_bytes: Some(8 * 1_024),
8200                    ..AtlasSymbolRelationsParams::default()
8201                },
8202                None,
8203            );
8204            require(
8205                compact_detailed.len() <= 8 * 1_024
8206                    && compact_detailed.contains("returned: 1")
8207                    && compact_detailed.contains("status: resolved")
8208                    && compact_detailed.contains("confidence: exact")
8209                    && compact_detailed.contains("completeness: complete")
8210                    && compact_detailed.contains("Own café λ relation navigation")
8211                    && compact_detailed.contains("next_call:")
8212                    && compact_detailed.contains("occurrences[1]:")
8213                    && !compact_detailed.contains("occurrences[1]:\n        - relation:"),
8214                "compact detailed relation omitted trust, purpose, occurrence, next-call, or bounded-output behavior",
8215            )?;
8216            let first_detailed_page = server.atlas_symbol_relations_response(
8217                &AtlasSymbolRelationsParams {
8218                    project_path: Some(project_path.to_string()),
8219                    file: Some("src/lib.rs".to_string()),
8220                    nearest_project: Some(false),
8221                    view: Some("detailed".to_string()),
8222                    symbol: Some("first".to_string()),
8223                    direction: Some("outbound".to_string()),
8224                    depth: Some(2),
8225                    limit: Some(1),
8226                    output_bytes: Some(64 * 1024),
8227                    ..AtlasSymbolRelationsParams::default()
8228                },
8229                None,
8230            );
8231            let first_detailed_value: serde_json::Value =
8232                toon_format::decode_default(&first_detailed_page)?;
8233            let first_detailed_report = first_detailed_value
8234                .get("symbol_relations")
8235                .ok_or_else(|| io::Error::other("first detailed MCP page omitted its envelope"))?;
8236            let continuation = first_detailed_report
8237                .get("continuation")
8238                .and_then(serde_json::Value::as_str)
8239                .ok_or_else(|| io::Error::other("first detailed MCP page omitted its cursor"))?;
8240            require(
8241                first_detailed_report
8242                    .get("returned")
8243                    .and_then(serde_json::Value::as_u64)
8244                    == Some(1)
8245                    && first_detailed_report
8246                        .get("rows")
8247                        .and_then(serde_json::Value::as_array)
8248                        .is_some_and(|rows| rows.len() == 1),
8249                "first detailed MCP page was not a nonempty bounded symbol result",
8250            )?;
8251            let second_detailed_page = server.atlas_symbol_relations_response(
8252                &AtlasSymbolRelationsParams {
8253                    project_path: Some(project_path.to_string()),
8254                    file: Some("src/lib.rs".to_string()),
8255                    nearest_project: Some(false),
8256                    view: Some("detailed".to_string()),
8257                    cursor: Some(continuation.to_string()),
8258                    symbol: Some("first".to_string()),
8259                    direction: Some("outbound".to_string()),
8260                    depth: Some(2),
8261                    limit: Some(1),
8262                    output_bytes: Some(64 * 1024),
8263                    ..AtlasSymbolRelationsParams::default()
8264                },
8265                None,
8266            );
8267            let second_detailed_value: serde_json::Value =
8268                toon_format::decode_default(&second_detailed_page)?;
8269            let second_detailed_report = second_detailed_value
8270                .get("symbol_relations")
8271                .ok_or_else(|| io::Error::other("second detailed MCP page omitted its envelope"))?;
8272            require(
8273                second_detailed_report
8274                    .get("returned")
8275                    .and_then(serde_json::Value::as_u64)
8276                    == Some(1)
8277                    && second_detailed_report.get("rows") != first_detailed_report.get("rows")
8278                    && second_detailed_page.contains("Own café λ relation navigation"),
8279                "detailed MCP cursor did not resume a distinct Unicode-safe symbol row",
8280            )?;
8281            let compact_continuation_page = server.atlas_symbol_relations_response(
8282                &AtlasSymbolRelationsParams {
8283                    project_path: Some(project_path.to_string()),
8284                    file: Some("src/lib.rs".to_string()),
8285                    nearest_project: Some(false),
8286                    view: Some("detailed".to_string()),
8287                    compact: Some(true),
8288                    symbol: Some("first".to_string()),
8289                    symbol_parent: Some(String::new()),
8290                    direction: Some("outbound".to_string()),
8291                    depth: Some(2),
8292                    limit: Some(1),
8293                    output_bytes: Some(64 * 1024),
8294                    ..AtlasSymbolRelationsParams::default()
8295                },
8296                None,
8297            );
8298            let compact_continuation_value: serde_json::Value =
8299                toon_format::decode_default(&compact_continuation_page)?;
8300            let compact_continuation_report = compact_continuation_value
8301                .get("symbol_relations")
8302                .ok_or_else(|| io::Error::other("compact relation page omitted its envelope"))?;
8303            let compact_next_call = compact_continuation_report
8304                .get("next_call")
8305                .ok_or_else(|| io::Error::other("compact relation page omitted its next call"))?;
8306            require(
8307                compact_next_call
8308                    .get("tool")
8309                    .and_then(serde_json::Value::as_str)
8310                    == Some(MCP_TOOL_ATLAS_SYMBOL_RELATIONS),
8311                "compact relation continuation did not name its owning MCP tool",
8312            )?;
8313            let compact_next_arguments = compact_next_call
8314                .get("arguments")
8315                .cloned()
8316                .ok_or_else(|| io::Error::other("compact next call omitted its arguments"))?;
8317            require(
8318                compact_next_arguments.get("cursor").is_some()
8319                    && compact_next_arguments.get("symbol_parent").is_none(),
8320                "compact next call did not preserve its cursor or normalize an empty parent",
8321            )?;
8322            let compact_next_params: AtlasSymbolRelationsParams =
8323                serde_json::from_value(compact_next_arguments)?;
8324            let compact_resumed_page =
8325                server.atlas_symbol_relations_response(&compact_next_params, None);
8326            require(
8327                compact_resumed_page.contains("symbol_relations:")
8328                    && !compact_resumed_page.contains("cursor does not match query")
8329                    && !compact_resumed_page.contains("graph symbol anchor is not available"),
8330                "compact relation next call was not directly reusable",
8331            )?;
8332        }
8333        let bounded_output_bytes = 4 * 1024_u32;
8334        let bounded = server.atlas_symbol_relations_response(
8335            &AtlasSymbolRelationsParams {
8336                project_path: Some(project_path.to_string()),
8337                file: Some("src/lib.rs".to_string()),
8338                nearest_project: Some(false),
8339                view: Some("detailed".to_string()),
8340                direction: Some("outbound".to_string()),
8341                limit: Some(50),
8342                edge_limit: Some(50),
8343                node_limit: Some(50),
8344                visited_limit: Some(50),
8345                occurrence_total_limit: Some(50),
8346                intermediate_bytes: Some(128 * 1024),
8347                deadline_ms: Some(2_000),
8348                output_bytes: Some(bounded_output_bytes),
8349                ..AtlasSymbolRelationsParams::default()
8350            },
8351            None,
8352        );
8353        require(
8354            bounded.contains("symbol_relations:")
8355                && bounded.len() <= bounded_output_bytes as usize
8356                && bounded.contains("Own café λ relation navigation")
8357                && bounded.contains(&format!("rendered_output_bytes: {}", bounded.len())),
8358            "detailed MCP relation output did not enforce or report the exact routed envelope bytes",
8359        )?;
8360
8361        let analysis = server.atlas_symbol_relations_response(
8362            &AtlasSymbolRelationsParams {
8363                project_path: Some(project_path.to_string()),
8364                file: Some("src/lib.rs".to_string()),
8365                nearest_project: Some(false),
8366                view: Some("analysis".to_string()),
8367                symbol: Some("first".to_string()),
8368                direction: Some("outbound".to_string()),
8369                depth: Some(2),
8370                limit: Some(50),
8371                output_bytes: Some(64 * 1024),
8372                include_communities: Some(true),
8373                include_cycles: Some(true),
8374                ..AtlasSymbolRelationsParams::default()
8375            },
8376            None,
8377        );
8378        require(
8379            analysis.contains("symbol_relations:")
8380                && analysis.contains("mode: architecture")
8381                && analysis.contains("findings[")
8382                && analysis.contains("next_call:")
8383                && analysis.contains("work:"),
8384            "MCP relation analysis omitted its closed mode, findings, work, or reusable next call",
8385        )?;
8386
8387        let state = ProjectAtlasMcpServer::project_state_from_root(Path::new(project_path))?;
8388        let publication_before = ProjectAtlasMcpServer::open_read_store(&state)?
8389            .index_publication()?
8390            .ok_or_else(|| io::Error::other("MCP impact fixture publication missing"))?;
8391        let task_records_before = server
8392            .task_registry
8393            .read()
8394            .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
8395            .records
8396            .len();
8397        let impact_started = Instant::now();
8398        let impact = server.atlas_symbol_relations_response(
8399            &AtlasSymbolRelationsParams {
8400                project_path: Some(project_path.to_string()),
8401                file: Some("src/lib.rs".to_string()),
8402                nearest_project: Some(false),
8403                view: Some("analysis".to_string()),
8404                symbol: Some("first".to_string()),
8405                direction: Some("outbound".to_string()),
8406                depth: Some(2),
8407                limit: Some(8),
8408                edge_limit: Some(8),
8409                node_limit: Some(16),
8410                visited_limit: Some(16),
8411                occurrence_total_limit: Some(16),
8412                intermediate_bytes: Some(128 * 1_024),
8413                deadline_ms: Some(1_000),
8414                output_bytes: Some(64 * 1_024),
8415                analysis_mode: Some("impact".to_string()),
8416                vcs: Some("working_tree".to_string()),
8417                include_dead_code: Some(true),
8418                ..AtlasSymbolRelationsParams::default()
8419            },
8420            None,
8421        );
8422        let impact_elapsed = impact_started.elapsed();
8423        require(
8424            impact_elapsed <= Duration::from_secs(5)
8425                && impact.contains("mode: impact")
8426                && (impact.contains("state: available") || impact.contains("state: unavailable")),
8427            "MCP impact analysis exceeded its elapsed tolerance or omitted typed mode/VCS state",
8428        )?;
8429        let impact_value: serde_json::Value = toon_format::decode_default(&impact)?;
8430        let impact_report = impact_value
8431            .get(MCP_PAYLOAD_SYMBOL_RELATIONS)
8432            .ok_or_else(|| io::Error::other("MCP impact response omitted its envelope"))?;
8433        let bounded_work = [
8434            ("/returned", 8_u64),
8435            ("/work/relations/inspected_edges", 8),
8436            ("/work/relations/active_nodes", 16),
8437            ("/work/relations/visited_nodes", 16),
8438            ("/work/analyzed_nodes", 16),
8439            ("/work/analyzed_edges", 8),
8440            ("/work/peak_intermediate_bytes", 128 * 1_024),
8441            ("/work/rendered_output_bytes", 64 * 1_024),
8442        ];
8443        require(
8444            impact.len() <= 64 * 1_024
8445                && bounded_work.iter().all(|(path, limit)| {
8446                    impact_report
8447                        .pointer(path)
8448                        .and_then(serde_json::Value::as_u64)
8449                        .is_some_and(|observed| observed <= *limit)
8450                }),
8451            "MCP impact analysis crossed or omitted a declared row/node/edge/visited/intermediate/output budget",
8452        )?;
8453        require(
8454            ProjectAtlasMcpServer::open_read_store(&state)?
8455                .index_publication()?
8456                .as_ref()
8457                == Some(&publication_before)
8458                && server
8459                    .task_registry
8460                    .read()
8461                    .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
8462                    .records
8463                    .len()
8464                    == task_records_before,
8465            "read-only MCP impact analysis changed publication or retained a task record",
8466        )?;
8467        let follow_up_started = Instant::now();
8468        let follow_up = server.atlas_overview_response(
8469            AtlasProjectParams {
8470                project_path: Some(project_path.to_string()),
8471            },
8472            None,
8473        );
8474        require(
8475            follow_up_started.elapsed() <= Duration::from_secs(2)
8476                && follow_up.contains("overview:"),
8477            "immediate MCP follow-up read was not responsive after bounded impact analysis",
8478        )?;
8479
8480        let trace = server.atlas_symbol_relations_response(
8481            &AtlasSymbolRelationsParams {
8482                project_path: Some(project_path.to_string()),
8483                file: Some("src/lib.rs".to_string()),
8484                nearest_project: Some(false),
8485                view: Some("analysis".to_string()),
8486                symbol: Some("first".to_string()),
8487                direction: Some("outbound".to_string()),
8488                depth: Some(2),
8489                limit: Some(50),
8490                analysis_mode: Some("trace".to_string()),
8491                trace_target: Some("second".to_string()),
8492                trace_target_file: Some("src/lib.rs".to_string()),
8493                trace_target_kind: Some("function".to_string()),
8494                trace_target_signature: Some("fn second ( )".to_string()),
8495                ..AtlasSymbolRelationsParams::default()
8496            },
8497            None,
8498        );
8499        require(
8500            trace.contains("mode: trace")
8501                && trace.contains("kind: static_trace")
8502                && trace.contains("status: confirmed")
8503                && trace.contains("name: second")
8504                && trace.contains("capability: symbol_slice"),
8505            "MCP trace analysis omitted its confirmed path or reusable exact selector",
8506        )?;
8507
8508        let misplaced = server.atlas_symbol_relations_response(
8509            &AtlasSymbolRelationsParams {
8510                project_path: Some(project_path.to_string()),
8511                file: Some("src/lib.rs".to_string()),
8512                nearest_project: Some(false),
8513                view: Some("detailed".to_string()),
8514                analysis_mode: Some("impact".to_string()),
8515                ..AtlasSymbolRelationsParams::default()
8516            },
8517            None,
8518        );
8519        require(
8520            misplaced.contains("analysis controls require view=analysis"),
8521            "MCP detailed relation view accepted analysis-only controls",
8522        )?;
8523
8524        let missing_trace_target = server.atlas_symbol_relations_response(
8525            &AtlasSymbolRelationsParams {
8526                project_path: Some(project_path.to_string()),
8527                file: Some("src/lib.rs".to_string()),
8528                nearest_project: Some(false),
8529                view: Some("analysis".to_string()),
8530                analysis_mode: Some("trace".to_string()),
8531                ..AtlasSymbolRelationsParams::default()
8532            },
8533            None,
8534        );
8535        require(
8536            missing_trace_target.contains("analysis trace requires an exact file or symbol target"),
8537            "MCP trace analysis accepted a missing exact target",
8538        )?;
8539
8540        let misplaced_vcs = server.atlas_symbol_relations_response(
8541            &AtlasSymbolRelationsParams {
8542                project_path: Some(project_path.to_string()),
8543                file: Some("src/lib.rs".to_string()),
8544                nearest_project: Some(false),
8545                view: Some("analysis".to_string()),
8546                analysis_mode: Some("architecture".to_string()),
8547                vcs: Some("working_tree".to_string()),
8548                ..AtlasSymbolRelationsParams::default()
8549            },
8550            None,
8551        );
8552        require(
8553            misplaced_vcs.contains("VCS selection is valid only for impact analysis"),
8554            "MCP silently dropped an explicit VCS selector outside impact mode",
8555        )
8556    }
8557
8558    #[test]
8559    fn current_dir_alias_paths_use_active_mcp_project() -> Result<(), Box<dyn std::error::Error>> {
8560        let temp = tempfile::tempdir()?;
8561        let repo = temp.path().join("repo-a");
8562        fs::create_dir(&repo)?;
8563        let db_path = repo.join(".projectatlas").join("projectatlas.db");
8564        let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
8565        let expected_root = canonical_project_root(&repo)?;
8566
8567        let (_state, root) = server.state_and_root_path(None, Some("./".to_string()), false)?;
8568        require(
8569            root == expected_root,
8570            "current-dir alias did not use active root",
8571        )?;
8572
8573        #[cfg(windows)]
8574        {
8575            let (_state, root) =
8576                server.state_and_root_path(None, Some(".\\".to_string()), false)?;
8577            require(
8578                root == expected_root,
8579                "windows current-dir alias did not use active root",
8580            )?;
8581        }
8582
8583        Ok(())
8584    }
8585
8586    #[test]
8587    fn bare_startup_and_root_set_preserve_worktree_required_without_state()
8588    -> Result<(), Box<dyn std::error::Error>> {
8589        let temp = tempfile::tempdir()?;
8590        let bare = temp.path().join("repository.git");
8591        let output = StdCommand::new("git")
8592            .args(["init", "--bare"])
8593            .arg(&bare)
8594            .output()?;
8595        require(output.status.success(), "git init --bare failed")?;
8596        let db_path = bare.join(".projectatlas").join("projectatlas.db");
8597        let server =
8598            ProjectAtlasMcpServer::new(db_path, None, "mcp-bare-root-test".to_string(), false);
8599
8600        let Err(error) = server.active_project_state() else {
8601            return Err(io::Error::other("bare MCP startup state was exposed as active").into());
8602        };
8603        if !matches!(error, CliError::WorktreeRequired(_)) {
8604            return Err(io::Error::other(format!(
8605                "bare MCP startup did not preserve typed worktree_required state: {error:?}"
8606            ))
8607            .into());
8608        }
8609
8610        let response = server.atlas_root_set(Parameters(AtlasRootSetParams {
8611            root: bare.to_string_lossy().to_string(),
8612            transition: None,
8613            nearest_project: None,
8614        }));
8615        require(
8616            response.contains("worktree_required"),
8617            "atlas_root_set did not reject a bare Git control root",
8618        )?;
8619        require(
8620            !bare.join(".projectatlas").exists(),
8621            "bare MCP startup or root-set refusal created project state",
8622        )
8623    }
8624
8625    #[test]
8626    fn selected_project_config_cannot_redirect_root() -> Result<(), Box<dyn std::error::Error>> {
8627        let temp = tempfile::tempdir()?;
8628        let repo_a = temp.path().join("repo-a");
8629        let repo_b = temp.path().join("repo-b");
8630        fs::create_dir(&repo_a)?;
8631        fs::create_dir(&repo_b)?;
8632        fs::create_dir(repo_b.join(".projectatlas"))?;
8633        let escaped_repo_a = repo_a.to_string_lossy().replace('\\', "/");
8634        fs::write(
8635            repo_b.join(".projectatlas").join("config.toml"),
8636            format!("[project]\nroot = \"{escaped_repo_a}\"\n"),
8637        )?;
8638
8639        let Err(error) = ProjectAtlasMcpServer::project_state_from_root(&repo_b) else {
8640            return Err(io::Error::other("stale selected-project config was accepted").into());
8641        };
8642        require(
8643            error.to_string().contains("outside selected project root"),
8644            "stale selected-project config error was not root-scoped",
8645        )?;
8646
8647        Ok(())
8648    }
8649
8650    #[test]
8651    fn startup_config_mismatch_cannot_bind_one_root_to_another_db()
8652    -> Result<(), Box<dyn std::error::Error>> {
8653        let temp = tempfile::tempdir()?;
8654        let repo_a = temp.path().join("repo-a");
8655        let repo_b = temp.path().join("repo-b");
8656        fs::create_dir(&repo_a)?;
8657        fs::create_dir(&repo_b)?;
8658        fs::create_dir(repo_a.join(".projectatlas"))?;
8659        let escaped_repo_a = repo_a.to_string_lossy().replace('\\', "/");
8660        let config_a = repo_a.join(".projectatlas").join("config.toml");
8661        fs::write(
8662            &config_a,
8663            format!("[project]\nroot = \"{escaped_repo_a}\"\n"),
8664        )?;
8665
8666        let db_b = repo_b.join(".projectatlas").join("projectatlas.db");
8667        let server =
8668            ProjectAtlasMcpServer::new(db_b.clone(), Some(config_a), "mcp-test".to_string(), false);
8669        let state = server.active_project_state()?;
8670
8671        require(
8672            state.root == canonical_project_root(&repo_b)?,
8673            "startup state did not fall back to the DB project root",
8674        )?;
8675        require(
8676            state.db_path == db_b,
8677            "startup state changed the selected DB path",
8678        )?;
8679        require(
8680            state.config_path.is_none(),
8681            "startup state retained a config from another project root",
8682        )?;
8683
8684        Ok(())
8685    }
8686
8687    #[test]
8688    fn read_only_store_does_not_create_missing_index() -> Result<(), Box<dyn std::error::Error>> {
8689        let temp = tempfile::tempdir()?;
8690        let repo = temp.path().join("repo-a");
8691        fs::create_dir(&repo)?;
8692        let state = ProjectAtlasMcpServer::project_state_from_root(&repo)?;
8693
8694        let Err(error) = ProjectAtlasMcpServer::open_read_store(&state) else {
8695            return Err(io::Error::other("missing index opened unexpectedly").into());
8696        };
8697        require(
8698            matches!(error, CliError::InitRequired(_)),
8699            "missing index did not return typed init_required state",
8700        )?;
8701        let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
8702        require(
8703            payload.contains("kind: init_required")
8704                && payload.contains("init_required:")
8705                && payload.contains("tool: atlas_init")
8706                && payload.contains(&normalize_native_path_display(&repo)),
8707            "missing index payload did not contain the exact atlas_init recovery call",
8708        )?;
8709        require(
8710            !repo.join(".projectatlas").exists(),
8711            "read-only store created .projectatlas",
8712        )?;
8713
8714        Ok(())
8715    }
8716
8717    #[test]
8718    fn atlas_init_explicit_project_path_bootstraps_without_switching_active_project()
8719    -> Result<(), Box<dyn std::error::Error>> {
8720        let temp = tempfile::tempdir()?;
8721        let repo_a = temp.path().join("repo-a");
8722        let repo_b = temp.path().join("repo-b");
8723        fs::create_dir(&repo_a)?;
8724        fs::create_dir(&repo_b)?;
8725        let db_a = repo_a.join(".projectatlas").join("projectatlas.db");
8726        let server = ProjectAtlasMcpServer::new(db_a, None, "mcp-test".to_string(), false);
8727        let active_before = server.active_project_state()?;
8728
8729        let text = server.atlas_init(Parameters(AtlasInitParams {
8730            project_path: Some(repo_b.to_string_lossy().to_string()),
8731            no_scan: Some(true),
8732            force_rescan: Some(false),
8733            text_index_max_bytes: None,
8734        }));
8735
8736        let expected_b = normalize_native_path_display(canonical_project_root(&repo_b)?);
8737        require(
8738            text.contains("init:"),
8739            "atlas_init did not return named init payload",
8740        )?;
8741        require(
8742            text.contains(&expected_b),
8743            "atlas_init did not report the explicit project path",
8744        )?;
8745        require(
8746            text.contains("status: skipped"),
8747            "atlas_init --no-scan did not report skipped scan",
8748        )?;
8749        require(
8750            text.contains("purpose_handoff:")
8751                && text.contains("execution_owner: agent_host")
8752                && text.contains("recommended_subagent_reasoning: lowest_host_enforced")
8753                && text.contains("main_agent_fallback: true")
8754                && text.contains("server_started_curator: false")
8755                && text.contains("silent_on_success: true")
8756                && text.contains("curation_scope: low"),
8757            "atlas_init did not expose the host-owned low-scope curator handoff",
8758        )?;
8759        require(
8760            repo_b
8761                .join(".projectatlas")
8762                .join("projectatlas.db")
8763                .is_file(),
8764            "atlas_init did not create the explicit project's DB",
8765        )?;
8766        require(
8767            repo_b
8768                .join(".projectatlas")
8769                .join("projectatlas.mcp.json")
8770                .is_file()
8771                && repo_b
8772                    .join(".projectatlas")
8773                    .join("projectatlas.claude.mcp.json")
8774                    .is_file()
8775                && repo_b
8776                    .join(".projectatlas")
8777                    .join("projectatlas.opencode.json")
8778                    .is_file(),
8779            "atlas_init did not generate host MCP configs",
8780        )?;
8781
8782        let active_after = server.active_project_state()?;
8783        require(
8784            active_after.root == active_before.root,
8785            "atlas_init with explicit project_path changed the active default root",
8786        )?;
8787        require(
8788            !repo_a.join(".projectatlas").exists(),
8789            "explicit atlas_init mutated the active project",
8790        )?;
8791
8792        Ok(())
8793    }
8794
8795    #[test]
8796    fn session_brief_missing_index_stays_read_only() -> Result<(), Box<dyn std::error::Error>> {
8797        let temp = tempfile::tempdir()?;
8798        let repo = temp.path().join("repo-a");
8799        fs::create_dir(&repo)?;
8800        let db_path = repo.join(".projectatlas").join("projectatlas.db");
8801        let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
8802
8803        let brief = server.build_session_brief(
8804            AtlasSessionBriefParams {
8805                project_path: None,
8806                query: Some("startup".to_string()),
8807                purpose_task: None,
8808                compact: None,
8809                folder_limit: None,
8810                file_limit: None,
8811                blocker_limit: None,
8812                purpose_limit: None,
8813            },
8814            None,
8815        )?;
8816
8817        require(
8818            brief.project.index_status == McpIndexStatus::Missing,
8819            "missing index was not represented as typed state",
8820        )?;
8821        require(
8822            brief.overview.is_none(),
8823            "missing-index brief unexpectedly included overview",
8824        )?;
8825        require(
8826            brief.recommendations.iter().any(|recommendation| {
8827                matches!(recommendation.kind, McpBriefRecommendationKind::Init)
8828                    && recommendation.target == MCP_TOOL_ATLAS_INIT
8829                    && recommendation.arguments.get(MCP_BRIEF_ARG_PROJECT_PATH)
8830                        == Some(&serde_json::json!(normalize_native_path_display(&repo)))
8831            }),
8832            "missing-index brief did not recommend atlas_init for the exact selected root",
8833        )?;
8834        require(
8835            !repo.join(".projectatlas").exists(),
8836            "session brief created .projectatlas for a missing index",
8837        )?;
8838
8839        Ok(())
8840    }
8841
8842    #[test]
8843    fn session_brief_recommendations_preserve_per_call_project_path()
8844    -> Result<(), Box<dyn std::error::Error>> {
8845        let project_path = "F:/example/repo-b".to_string();
8846        let recommendations = ProjectAtlasMcpServer::indexed_project_recommendations(
8847            "startup",
8848            Some(NavigationNextCall {
8849                capability: NavigationNextCapability::Summary,
8850                path: "src/lib.rs".to_string(),
8851            }),
8852            1,
8853            7,
8854            Some(project_path.clone()),
8855        );
8856
8857        require(
8858            recommendations.iter().all(|recommendation| {
8859                recommendation.arguments.get(MCP_BRIEF_ARG_PROJECT_PATH)
8860                    == Some(&serde_json::Value::String(project_path.clone()))
8861            }),
8862            "indexed brief recommendations did not preserve project_path",
8863        )?;
8864        require(
8865            recommendations.iter().any(|recommendation| {
8866                matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
8867                    && recommendation.target == MCP_TOOL_ATLAS_FILE_SUMMARY
8868                    && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
8869                        == Some(&serde_json::Value::String("src/lib.rs".to_string()))
8870            }),
8871            "summary recommendation did not preserve the ranked file selector",
8872        )?;
8873        require(
8874            recommendations.iter().all(|recommendation| {
8875                recommendation.target != MCP_TOOL_ATLAS_FOLDERS
8876                    && recommendation.target != MCP_TOOL_ATLAS_FILES
8877            }),
8878            "indexed brief recommended rerunning folder or file ranking",
8879        )?;
8880        require(
8881            recommendations.iter().any(|recommendation| {
8882                matches!(recommendation.kind, McpBriefRecommendationKind::Health)
8883                    && recommendation.arguments.get(MCP_BRIEF_ARG_LIMIT)
8884                        == Some(&serde_json::json!(7))
8885            }),
8886            "health recommendation did not preserve limit",
8887        )?;
8888
8889        let relation_recommendations = ProjectAtlasMcpServer::indexed_project_recommendations(
8890            "startup",
8891            Some(NavigationNextCall {
8892                capability: NavigationNextCapability::Relations,
8893                path: "src/graph.rs".to_string(),
8894            }),
8895            0,
8896            7,
8897            Some(project_path),
8898        );
8899        require(
8900            relation_recommendations.iter().any(|recommendation| {
8901                matches!(recommendation.kind, McpBriefRecommendationKind::Relations)
8902                    && recommendation.target == MCP_TOOL_ATLAS_SYMBOL_RELATIONS
8903                    && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
8904                        == Some(&serde_json::Value::String("src/graph.rs".to_string()))
8905                    && recommendation.arguments.get(MCP_BRIEF_ARG_VIEW)
8906                        == Some(&serde_json::Value::String("detailed".to_string()))
8907            }),
8908            "relation recommendation did not preserve the ranked file and detailed view",
8909        )?;
8910
8911        Ok(())
8912    }
8913
8914    #[test]
8915    fn session_brief_file_candidates_ignore_indexed_text_fallback()
8916    -> Result<(), Box<dyn std::error::Error>> {
8917        let temp = tempfile::tempdir()?;
8918        let repo = temp.path().join("repo-a");
8919        fs::create_dir_all(repo.join("src"))?;
8920        fs::write(
8921            repo.join("src").join("owner.rs"),
8922            "const ROUTE: &str = \"hiddenNeedle\";\n",
8923        )?;
8924        let db_path = repo.join(".projectatlas").join("projectatlas.db");
8925        let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
8926        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
8927        let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30));
8928        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
8929        drop(store);
8930
8931        let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
8932        let brief = server.build_session_brief(
8933            AtlasSessionBriefParams {
8934                project_path: None,
8935                query: Some("hiddenNeedle".to_string()),
8936                purpose_task: None,
8937                compact: None,
8938                folder_limit: Some(5),
8939                file_limit: Some(5),
8940                blocker_limit: Some(5),
8941                purpose_limit: Some(5),
8942            },
8943            None,
8944        )?;
8945
8946        require(
8947            brief.files.is_empty(),
8948            "session brief returned a content-only indexed-text hit",
8949        )?;
8950        require(
8951            brief.recommendations.iter().any(|recommendation| {
8952                matches!(recommendation.kind, McpBriefRecommendationKind::Search)
8953                    && recommendation.target == MCP_TOOL_ATLAS_SEARCH
8954                    && recommendation.arguments.get(MCP_BRIEF_ARG_PATTERN)
8955                        == Some(&serde_json::Value::String("hiddenNeedle".to_string()))
8956            }),
8957            "session brief did not route a content-only query directly to indexed search",
8958        )?;
8959
8960        let navigable = server.build_session_brief(
8961            AtlasSessionBriefParams {
8962                project_path: None,
8963                query: Some("owner".to_string()),
8964                purpose_task: None,
8965                compact: None,
8966                folder_limit: Some(5),
8967                file_limit: Some(5),
8968                blocker_limit: Some(5),
8969                purpose_limit: Some(5),
8970            },
8971            None,
8972        )?;
8973        let candidate = navigable
8974            .files
8975            .first()
8976            .ok_or_else(|| std::io::Error::other("navigable brief file is missing"))?;
8977        require(
8978            candidate.reason_codes.contains(&RankedReasonCode::Path)
8979                && candidate.next_call.capability == NavigationNextCapability::Summary
8980                && !candidate.purpose_agent_reviewed,
8981            "session brief dropped ranked navigation evidence",
8982        )?;
8983        require(
8984            navigable.recommendations.iter().any(|recommendation| {
8985                matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
8986                    && recommendation.target == MCP_TOOL_ATLAS_FILE_SUMMARY
8987                    && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
8988                        == Some(&serde_json::Value::String(candidate.path.clone()))
8989            }) && navigable.recommendations.iter().all(|recommendation| {
8990                recommendation.target != MCP_TOOL_ATLAS_FOLDERS
8991                    && recommendation.target != MCP_TOOL_ATLAS_FILES
8992            }),
8993            "session brief recommendation did not follow its returned ranked file directly",
8994        )?;
8995
8996        Ok(())
8997    }
8998
8999    #[test]
9000    fn mcp_navigation_and_session_brief_propagate_typed_graph_evidence()
9001    -> Result<(), Box<dyn std::error::Error>> {
9002        let temp = tempfile::tempdir()?;
9003        let repo = temp.path().join("repo-a");
9004        fs::create_dir_all(repo.join("src"))?;
9005        fs::create_dir_all(repo.join("tests"))?;
9006        fs::write(
9007            repo.join("Cargo.toml"),
9008            "[package]\nname = \"adapter-navigation\"\nversion = \"0.1.0\"\n",
9009        )?;
9010        for path in [
9011            "src/navigation_owner.rs",
9012            "src/navigation_local.rs",
9013            "src/navigation_unresolved.rs",
9014            "tests/navigation_owner.rs",
9015        ] {
9016            fs::write(repo.join(path), "pub fn navigation_fixture() {}\n")?;
9017        }
9018        let db_path = repo.join(".projectatlas").join("projectatlas.db");
9019        let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
9020        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
9021        run_scan_pipeline(
9022            &mut store,
9023            &plan,
9024            &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
9025        )?;
9026        publish_mcp_navigation_graph(&mut store)?;
9027        drop(store);
9028
9029        let server =
9030            ProjectAtlasMcpServer::new(db_path, None, "mcp-navigation-test".to_string(), false);
9031        let folders_text = server.atlas_folders_response(
9032            AtlasQueryParams {
9033                project_path: None,
9034                query: Some("navigation".to_string()),
9035                limit: Some(10),
9036            },
9037            None,
9038        );
9039        let files_text = server.atlas_files_response(
9040            AtlasFilesParams {
9041                project_path: None,
9042                query: Some("navigation".to_string()),
9043                folder: None,
9044                nearest_project: Some(false),
9045                file_pattern: None,
9046                include_content: Some(false),
9047                limit: Some(10),
9048            },
9049            None,
9050        );
9051        for (surface, text) in [
9052            ("atlas_folders", &folders_text),
9053            ("atlas_files", &files_text),
9054        ] {
9055            require(
9056                text.contains("connection_counts")
9057                    && text.contains("connections")
9058                    && text.contains("direction:")
9059                    && text.contains("target:")
9060                    && text.contains("connections_truncated: true"),
9061                &format!("{surface} dropped nonempty typed graph evidence: {text}"),
9062            )?;
9063        }
9064
9065        let brief = server.build_session_brief(
9066            AtlasSessionBriefParams {
9067                project_path: None,
9068                query: Some("navigation".to_string()),
9069                purpose_task: None,
9070                compact: None,
9071                folder_limit: Some(10),
9072                file_limit: Some(10),
9073                blocker_limit: Some(10),
9074                purpose_limit: Some(10),
9075            },
9076            None,
9077        )?;
9078        let folder = brief
9079            .folders
9080            .iter()
9081            .find(|candidate| candidate.path == "src")
9082            .ok_or_else(|| io::Error::other("graph-enriched MCP folder is missing"))?;
9083        require(
9084            folder.connection_counts.len() == 7
9085                && folder.connections.len() == 3
9086                && folder.connections_truncated,
9087            "MCP folder lost count, sample, or global truncation evidence",
9088        )?;
9089        let owner = brief
9090            .files
9091            .iter()
9092            .find(|candidate| candidate.path == "src/navigation_owner.rs")
9093            .ok_or_else(|| io::Error::other("graph-enriched MCP owner file is missing"))?;
9094        require(
9095            owner.connection_counts.len() == 7
9096                && owner.connections.len() == 3
9097                && owner.connections_truncated
9098                && owner.next_call.capability
9099                    == projectatlas_core::NavigationNextCapability::Relations,
9100            "MCP file or session brief lost graph truncation or relations navigation",
9101        )?;
9102        let compact_relations = server.atlas_symbol_relations_response(
9103            &AtlasSymbolRelationsParams {
9104                project_path: None,
9105                file: Some("src/navigation_owner.rs".to_string()),
9106                nearest_project: Some(false),
9107                view: Some("detailed".to_string()),
9108                compact: Some(true),
9109                direction: Some("outbound".to_string()),
9110                include_occurrences: Some(true),
9111                limit: Some(10),
9112                output_bytes: Some(64 * 1_024),
9113                ..AtlasSymbolRelationsParams::default()
9114            },
9115            None,
9116        );
9117        require(
9118            compact_relations.contains("status: resolved")
9119                && compact_relations.contains("status: ambiguous")
9120                && compact_relations.contains("status: external")
9121                && compact_relations.contains("status: unresolved")
9122                && compact_relations.contains("reference: \"navigation-ambiguous\"")
9123                && compact_relations.contains("candidates: 2")
9124                && compact_relations.contains("next_call:"),
9125            &format!(
9126                "compact detailed relations dropped a resolution state or reusable next call: {compact_relations}"
9127            ),
9128        )?;
9129
9130        let compact = server.build_compact_session_brief(
9131            AtlasSessionBriefParams {
9132                project_path: None,
9133                query: Some("navigation_owner".to_string()),
9134                purpose_task: None,
9135                compact: Some(true),
9136                folder_limit: None,
9137                file_limit: None,
9138                blocker_limit: None,
9139                purpose_limit: None,
9140            },
9141            None,
9142        )?;
9143        let compact_owner = compact
9144            .files
9145            .iter()
9146            .find(|candidate| candidate.path == "src/navigation_owner.rs")
9147            .ok_or_else(|| io::Error::other("compact graph owner file is missing"))?;
9148        require(
9149            compact_owner.connections.len() == 1
9150                && compact_owner.connections.iter().all(|connection| {
9151                    connection.kind != RankedConnectionKind::Import
9152                        && !matches!(
9153                            &connection.target,
9154                            RankedConnectionTarget::Unresolved { .. }
9155                        )
9156                })
9157                && compact_owner.next_call.capability == NavigationNextCapability::Summary
9158                && compact_owner.purpose_agent_reviewed,
9159            "compact session brief did not retain one crisp edge and summary-first routing",
9160        )?;
9161        let expanded_text =
9162            ProjectAtlasMcpServer::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)?;
9163        require(
9164            expanded_text.contains("missing_purposes:")
9165                && expanded_text.contains("stale_purposes:")
9166                && expanded_text.contains("approved_purposes:")
9167                && expanded_text.contains("suggested_purposes:"),
9168            "compatibility session brief lost purpose lifecycle counts",
9169        )?;
9170        let compact_text =
9171            ProjectAtlasMcpServer::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &compact)?;
9172        require(
9173            compact
9174                .blockers
9175                .as_ref()
9176                .is_some_and(|blockers| blockers.total > 0)
9177                && !compact_text.contains("\n    db:")
9178                && !compact_text.contains("\n    config:")
9179                && !compact_text.contains("\n  policy:")
9180                && !compact_text.contains("\n    items:")
9181                && !compact_text.contains("reason_codes")
9182                && !compact_text.contains("connection_counts")
9183                && !compact_text.contains("agent_harness_expected")
9184                && !compact_text.contains("server_started_curator")
9185                && !compact_text.contains("missing_purposes")
9186                && !compact_text.contains("recommended_subagent_reasoning")
9187                && compact_text.len() <= 4_096,
9188            &format!("compact session brief retained default-only chatter: {compact_text}"),
9189        )?;
9190
9191        let families = brief
9192            .files
9193            .iter()
9194            .flat_map(|candidate| candidate.connection_counts.iter().map(|count| count.kind))
9195            .collect::<BTreeSet<_>>();
9196        require(
9197            families
9198                == BTreeSet::from([
9199                    RankedConnectionKind::Package,
9200                    RankedConnectionKind::Import,
9201                    RankedConnectionKind::Call,
9202                    RankedConnectionKind::Reference,
9203                    RankedConnectionKind::Test,
9204                    RankedConnectionKind::Route,
9205                    RankedConnectionKind::Config,
9206                ]),
9207            &format!("MCP graph families were not propagated: {families:?}"),
9208        )?;
9209        let connections = brief
9210            .files
9211            .iter()
9212            .flat_map(|candidate| candidate.connections.iter())
9213            .collect::<Vec<_>>();
9214        require(
9215            connections
9216                .iter()
9217                .any(|connection| connection.direction == RankedConnectionDirection::Outbound)
9218                && connections
9219                    .iter()
9220                    .any(|connection| connection.direction == RankedConnectionDirection::Inbound),
9221            "MCP graph samples did not preserve both directions",
9222        )?;
9223        for (name, present) in [
9224            (
9225                "local",
9226                connections.iter().any(|connection| {
9227                    matches!(connection.target, RankedConnectionTarget::Local { .. })
9228                }),
9229            ),
9230            (
9231                "package",
9232                connections.iter().any(|connection| {
9233                    matches!(connection.target, RankedConnectionTarget::Package { .. })
9234                }),
9235            ),
9236            (
9237                "external",
9238                connections.iter().any(|connection| {
9239                    matches!(connection.target, RankedConnectionTarget::External { .. })
9240                }),
9241            ),
9242            (
9243                "unresolved",
9244                connections.iter().any(|connection| {
9245                    matches!(connection.target, RankedConnectionTarget::Unresolved { .. })
9246                }),
9247            ),
9248        ] {
9249            require(
9250                present,
9251                &format!("MCP graph samples omitted {name} targets"),
9252            )?;
9253        }
9254        Ok(())
9255    }
9256
9257    fn publish_mcp_navigation_graph(
9258        store: &mut AtlasStore,
9259    ) -> Result<(), Box<dyn std::error::Error>> {
9260        let project = store
9261            .project_instance_id()?
9262            .ok_or_else(|| io::Error::other("MCP navigation project identity is missing"))?;
9263        let current_publication = store
9264            .index_publication()?
9265            .ok_or_else(|| io::Error::other("MCP navigation publication is missing"))?;
9266        let fingerprint = current_publication
9267            .contract_fingerprint
9268            .clone()
9269            .ok_or_else(|| io::Error::other("MCP navigation fingerprint is missing"))?;
9270        let generation = current_publication
9271            .generation
9272            .checked_next()
9273            .ok_or_else(|| io::Error::other("MCP navigation generation overflow"))?;
9274        let file_entity = |path: &str| {
9275            GraphEntity::new(
9276                project,
9277                EntitySelector::File {
9278                    path: RepositoryFilePath::new(Path::new(path))?,
9279                },
9280                generation,
9281            )
9282        };
9283        let owner = file_entity("src/navigation_owner.rs")?;
9284        let local = file_entity("src/navigation_local.rs")?;
9285        let unresolved = file_entity("src/navigation_unresolved.rs")?;
9286        let test = file_entity("tests/navigation_owner.rs")?;
9287        let package = GraphEntity::new(
9288            project,
9289            EntitySelector::Package {
9290                package: PackageSelector {
9291                    manager: GraphIdentityText::new("cargo")?,
9292                    name: GraphIdentityText::new("adapter-navigation")?,
9293                    manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
9294                },
9295            },
9296            generation,
9297        )?;
9298        let external = GraphEntity::new(
9299            project,
9300            EntitySelector::External {
9301                external: ExternalSelector {
9302                    system: GraphIdentityText::new("crates.io")?,
9303                    identity: GraphIdentityText::new("serde@1")?,
9304                },
9305            },
9306            generation,
9307        )?;
9308        let resolved = |source: &GraphEntity, kind, target: &GraphEntity| {
9309            Ok::<_, Box<dyn std::error::Error>>(LogicalRelation::new(
9310                source,
9311                kind,
9312                RelationResolution::resolved(target)?,
9313                ConfidenceClass::Exact,
9314                Completeness::Complete,
9315                generation,
9316            )?)
9317        };
9318        let unresolved_relation = |source: &GraphEntity, kind, reference: &str| {
9319            Ok::<_, Box<dyn std::error::Error>>(LogicalRelation::new(
9320                source,
9321                kind,
9322                RelationResolution::Unresolved {
9323                    reference: GraphIdentityText::new(reference)?,
9324                },
9325                ConfidenceClass::High,
9326                Completeness::Partial,
9327                generation,
9328            )?)
9329        };
9330        let relations = vec![
9331            resolved(
9332                &package,
9333                GraphRelationKind::Legacy(RelationKind::DependsOn),
9334                &owner,
9335            )?,
9336            LogicalRelation::new(
9337                &owner,
9338                GraphRelationKind::Legacy(RelationKind::Imports),
9339                RelationResolution::external(&external)?,
9340                ConfidenceClass::Exact,
9341                Completeness::Complete,
9342                generation,
9343            )?,
9344            resolved(
9345                &owner,
9346                GraphRelationKind::Legacy(RelationKind::Calls),
9347                &local,
9348            )?,
9349            unresolved_relation(
9350                &unresolved,
9351                GraphRelationKind::Extended(ExtendedRelationKind::References),
9352                "navigation-reference",
9353            )?,
9354            resolved(
9355                &test,
9356                GraphRelationKind::Extended(ExtendedRelationKind::Tests),
9357                &owner,
9358            )?,
9359            resolved(
9360                &owner,
9361                GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
9362                &local,
9363            )?,
9364            LogicalRelation::new(
9365                &owner,
9366                GraphRelationKind::Extended(ExtendedRelationKind::References),
9367                RelationResolution::Ambiguous {
9368                    reference: GraphIdentityText::new("navigation-ambiguous")?,
9369                    candidates: std::num::NonZeroU32::new(2)
9370                        .ok_or_else(|| io::Error::other("ambiguous fixture count is zero"))?,
9371                },
9372                ConfidenceClass::High,
9373                Completeness::Partial,
9374                generation,
9375            )?,
9376            unresolved_relation(
9377                &owner,
9378                GraphRelationKind::Extended(ExtendedRelationKind::Configures),
9379                "NAVIGATION_MODE",
9380            )?,
9381        ];
9382        let nodes = store
9383            .load_nodes()?
9384            .into_iter()
9385            .map(|node| node.node)
9386            .collect::<Vec<_>>();
9387        {
9388            let mut publication = store.begin_index_publication(&fingerprint)?;
9389            publication.begin_scan_replacement()?;
9390            publication.upsert_scan_node_batch(&nodes)?;
9391            publication.finish_scan_replacement()?;
9392            publication.replace_repository_graph(
9393                project,
9394                &[owner, local, unresolved, test, package, external],
9395                &relations,
9396                &[],
9397                &[],
9398            )?;
9399            publication.complete()?;
9400        }
9401        store.set_purpose("src", "Navigation graph folder", PurposeSource::Agent)?;
9402        store.set_purpose(
9403            "src/navigation_owner.rs",
9404            "Navigation graph owner",
9405            PurposeSource::Agent,
9406        )?;
9407        store.set_purpose(
9408            "src/navigation_unresolved.rs",
9409            "Navigation unresolved graph owner",
9410            PurposeSource::Agent,
9411        )?;
9412        Ok(())
9413    }
9414
9415    #[test]
9416    fn session_brief_exposes_host_owned_purpose_curator_handoff()
9417    -> Result<(), Box<dyn std::error::Error>> {
9418        let temp = tempfile::tempdir()?;
9419        let repo = temp.path().join("repo-a");
9420        fs::create_dir_all(repo.join("src"))?;
9421        fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
9422        let db_path = repo.join(".projectatlas").join("projectatlas.db");
9423        let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
9424        let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
9425        let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30));
9426        run_scan_pipeline(&mut store, &plan, &symbol_options)?;
9427        drop(store);
9428
9429        let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
9430        let brief = server.build_session_brief(
9431            AtlasSessionBriefParams {
9432                project_path: None,
9433                query: Some("startup".to_string()),
9434                purpose_task: Some("startup-task".to_string()),
9435                compact: None,
9436                folder_limit: Some(5),
9437                file_limit: Some(5),
9438                blocker_limit: Some(5),
9439                purpose_limit: Some(1),
9440            },
9441            None,
9442        )?;
9443        let handoff = brief
9444            .purpose_handoff
9445            .as_ref()
9446            .ok_or_else(|| std::io::Error::other("actionable purpose handoff missing"))?;
9447        require(
9448            handoff.execution_owner == "agent_host",
9449            "session handoff was not host-owned",
9450        )?;
9451        require(
9452            handoff.recommended_subagent_reasoning == "lowest_host_enforced",
9453            "session handoff did not request the lowest host-enforced reasoning",
9454        )?;
9455        require(
9456            handoff.main_agent_fallback && !handoff.server_started_curator,
9457            "session handoff misrepresented curator execution ownership",
9458        )?;
9459        require(
9460            handoff.silent_on_success,
9461            "session handoff was not quiet on successful maintenance",
9462        )?;
9463        require(
9464            handoff.queue.task == "startup-task"
9465                && handoff.queue.curation_scope == "low"
9466                && handoff.queue.actionable
9467                && handoff.queue.returned == 1
9468                && handoff.queue.limit == 1
9469                && handoff.queue.truncated,
9470            "compatibility session handoff lost its bounded purpose queue metadata",
9471        )?;
9472        require(
9473            handoff.queue.items.iter().all(|item| {
9474                item.work_key.len() == 64
9475                    && item.state_token.len() == 64
9476                    && !item.purpose_agent_reviewed
9477            }),
9478            "session handoff item tokens or lifecycle state were incomplete",
9479        )?;
9480        let compact = server.build_compact_session_brief(
9481            AtlasSessionBriefParams {
9482                project_path: None,
9483                query: Some("startup".to_string()),
9484                purpose_task: Some("startup-task".to_string()),
9485                compact: Some(true),
9486                folder_limit: Some(5),
9487                file_limit: Some(5),
9488                blocker_limit: Some(5),
9489                purpose_limit: Some(1),
9490            },
9491            None,
9492        )?;
9493        let compact_handoff = compact
9494            .purpose_handoff
9495            .as_ref()
9496            .ok_or_else(|| std::io::Error::other("compact purpose handoff missing"))?;
9497        require(
9498            matches!(
9499                compact_handoff.next_call.kind,
9500                McpBriefRecommendationKind::PurposeQueue
9501            ) && compact_handoff.next_call.target == MCP_TOOL_ATLAS_PURPOSE_QUEUE
9502                && compact_handoff.next_call.arguments.get(MCP_BRIEF_ARG_TASK)
9503                    == Some(&serde_json::json!("startup-task"))
9504                && compact_handoff.next_call.arguments.get(MCP_BRIEF_ARG_LIMIT)
9505                    == Some(&serde_json::json!(1)),
9506            "compact handoff did not preserve the exact bounded purpose-queue call",
9507        )?;
9508        require(
9509            brief.limits.purpose_limit == 1 && brief.limits.purposes_truncated,
9510            "session brief purpose limits were not reported",
9511        )?;
9512        Ok(())
9513    }
9514
9515    #[test]
9516    fn settings_capabilities_report_nearest_policy() -> Result<(), Box<dyn std::error::Error>> {
9517        let temp = tempfile::tempdir()?;
9518        let repo = temp.path().join("repo-a");
9519        fs::create_dir(&repo)?;
9520        let db_path = repo.join(".projectatlas").join("projectatlas.db");
9521        let disabled =
9522            ProjectAtlasMcpServer::new(db_path.clone(), None, "mcp-test".to_string(), false);
9523        let enabled = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), true);
9524        let disabled_state = disabled.active_project_state()?;
9525        let enabled_state = enabled.active_project_state()?;
9526
9527        let disabled_text = disabled.render_settings_with_capabilities(&disabled_state)?;
9528        require(
9529            disabled_text.contains("mcp_session:"),
9530            "settings did not include mcp_session capabilities",
9531        )?;
9532        require(
9533            disabled_text.contains("path_scope: selected_project"),
9534            "disabled nearest-project policy was not typed",
9535        )?;
9536        require(
9537            disabled_text.contains("language_registry:")
9538                && disabled_text.contains("accepted_set_digest:")
9539                && disabled_text.contains("semantic_provider_digest:")
9540                && disabled_text.contains("semantic_relation_contract_digest:")
9541                && disabled_text.contains("relation_family_inventory:")
9542                && disabled_text.contains("optional_disabled_families:")
9543                && disabled_text.contains("benchmarked:")
9544                && !disabled_text.contains("accepted_minimum:")
9545                && !disabled_text.contains("provenance_source:")
9546                && disabled_text.contains("optional_catalog:")
9547                && disabled_text.contains("database:")
9548                && disabled_text.contains("compile_options:")
9549                && disabled_text.contains("search:")
9550                && disabled_text.contains("default_mode: lexical")
9551                && disabled_text.contains("optional_parser_pack:"),
9552            "settings did not project compact shared language registry truth",
9553        )?;
9554        require(
9555            disabled_text.len() <= MCP_SETTINGS_RESPONSE_MAX_BYTES,
9556            "settings exceeded its agent-facing output bound",
9557        )?;
9558        let enabled_text = enabled.render_settings_with_capabilities(&enabled_state)?;
9559        require(
9560            enabled_text.contains("path_scope: nearest_indexed_project"),
9561            "enabled nearest-project policy was not typed",
9562        )?;
9563        require(
9564            !enabled_text.contains("GITHUB_TOKEN") && !enabled_text.contains("GH_TOKEN"),
9565            "settings capabilities leaked token environment names",
9566        )?;
9567
9568        Ok(())
9569    }
9570
9571    #[test]
9572    fn task_progress_status_and_cancel_are_typed() -> Result<(), Box<dyn std::error::Error>> {
9573        let temp = tempfile::tempdir()?;
9574        let repo = temp.path().join("repo-a");
9575        fs::create_dir(&repo)?;
9576        let db_path = repo.join(".projectatlas").join("projectatlas.db");
9577        let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
9578
9579        let status = server.task_status(MCP_TASK_CONTRACT_ID.to_string())?;
9580        require(
9581            status.lookup == McpTaskLookupStatus::Found,
9582            "contract task missing",
9583        )?;
9584        require(
9585            status
9586                .task
9587                .as_ref()
9588                .is_some_and(|task| task.state == McpTaskState::Complete),
9589            "contract task was not complete",
9590        )?;
9591        require(
9592            status.states.contains(&McpTaskState::Pending)
9593                && status.states.contains(&McpTaskState::Canceled),
9594            "status response did not expose task state contract",
9595        )?;
9596        let cancel = server.task_cancel(MCP_TASK_CONTRACT_ID.to_string())?;
9597        require(
9598            cancel.result == McpTaskCancelResult::AlreadyFinished,
9599            "completed contract task did not return already_finished",
9600        )?;
9601        let missing = server.task_status("missing-task".to_string())?;
9602        require(
9603            missing.lookup == McpTaskLookupStatus::NotFound,
9604            "unknown task did not return not_found",
9605        )?;
9606
9607        Ok(())
9608    }
9609
9610    #[test]
9611    fn background_task_envelope_and_cancellation_reach_owned_control()
9612    -> Result<(), Box<dyn std::error::Error>> {
9613        let temp = tempfile::tempdir()?;
9614        let repo = temp.path().join("repo-a");
9615        fs::create_dir(&repo)?;
9616        let db_path = repo.join(".projectatlas").join("projectatlas.db");
9617        let mut server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
9618        let host_envelope = server.background_resources;
9619        let host_workers = thread::available_parallelism().map_or(1, usize::from);
9620        let representative_envelope = McpBackgroundResourceEnvelope::from_available_workers(8);
9621        require(
9622            host_envelope.task_limit <= MCP_BACKGROUND_TASK_SAFE_CEILING
9623                && host_envelope.workers_per_task > 0
9624                && host_envelope.total_worker_limit
9625                    <= host_workers.clamp(1, INDEX_WORKER_SAFE_CEILING)
9626                && host_envelope.workers_per_task * host_envelope.task_limit
9627                    <= host_envelope.total_worker_limit,
9628            "background resource envelope exceeded its host or process worker budget",
9629        )?;
9630        require(
9631            representative_envelope
9632                == (McpBackgroundResourceEnvelope {
9633                    task_limit: 4,
9634                    workers_per_task: 2,
9635                    total_worker_limit: 8,
9636                })
9637                && McpBackgroundResourceEnvelope::from_available_workers(0).total_worker_limit == 1
9638                && McpBackgroundResourceEnvelope::from_available_workers(usize::MAX)
9639                    .total_worker_limit
9640                    == INDEX_WORKER_SAFE_CEILING,
9641            "background resource envelope did not partition representative host capacities",
9642        )?;
9643
9644        server.background_resources = McpBackgroundResourceEnvelope::from_available_workers(4);
9645        let envelope = server.background_resources;
9646        let started = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
9647        let databases_ready = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
9648        let release = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
9649        let observed_option_workers = Arc::new(AtomicU64::new(0));
9650        let observed_control_workers = Arc::new(AtomicU64::new(0));
9651        let mut concurrent_tasks = Vec::new();
9652        for task_index in 0..envelope.task_limit {
9653            let isolated_root = temp.path().join(format!("repo-{task_index}"));
9654            let isolated_db = isolated_root.join(".projectatlas").join("projectatlas.db");
9655            fs::create_dir_all(isolated_root.join(".projectatlas"))?;
9656            let worker_started = Arc::clone(&started);
9657            let worker_databases_ready = Arc::clone(&databases_ready);
9658            let worker_release = Arc::clone(&release);
9659            let worker_option_workers = Arc::clone(&observed_option_workers);
9660            let worker_control_workers = Arc::clone(&observed_control_workers);
9661            concurrent_tasks.push(server.start_index_task(
9662                McpTaskOperation::Scan,
9663                SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
9664                MCP_TOOL_ATLAS_OVERVIEW,
9665                move |control, options| {
9666                    worker_option_workers
9667                        .fetch_add(options.reported_workers() as u64, Ordering::Relaxed);
9668                    worker_control_workers.fetch_add(
9669                        control.worker_ceiling().unwrap_or_default() as u64,
9670                        Ordering::Relaxed,
9671                    );
9672                    worker_started.wait();
9673                    let store = open_atlas_store_for_project(&isolated_db, &isolated_root);
9674                    worker_databases_ready.wait();
9675                    worker_release.wait();
9676                    let _store = store?;
9677                    Ok(())
9678                },
9679            )?);
9680        }
9681        started.wait();
9682        let admitted_workers = envelope.workers_per_task * envelope.task_limit;
9683        require(
9684            observed_option_workers.load(Ordering::Relaxed) == admitted_workers as u64
9685                && observed_control_workers.load(Ordering::Relaxed) == admitted_workers as u64
9686                && admitted_workers <= envelope.total_worker_limit,
9687            "concurrent background tasks did not share the aggregate worker envelope",
9688        )?;
9689        let overflow = server.start_index_task(
9690            McpTaskOperation::Scan,
9691            SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
9692            MCP_TOOL_ATLAS_OVERVIEW,
9693            |_control, _options| Ok(()),
9694        );
9695        require(
9696            matches!(overflow, Err(CliError::Mcp(message)) if message.starts_with(MCP_INDEX_TASK_LIMIT_PREFIX)),
9697            "background task admission exceeded the server task limit",
9698        )?;
9699        require(
9700            server.task_status(MCP_TASK_CONTRACT_ID.to_string())?.lookup
9701                == McpTaskLookupStatus::Found,
9702            "task status became unresponsive while concurrent work was active",
9703        )?;
9704        databases_ready.wait();
9705        release.wait();
9706        for task in concurrent_tasks {
9707            require(
9708                wait_for_background_task(&server, &task.task_id)?.state == McpTaskState::Complete,
9709                "concurrent background task did not finish successfully",
9710            )?;
9711        }
9712
9713        let cancel_started = Arc::new(std::sync::Barrier::new(2));
9714        let worker_cancel_started = Arc::clone(&cancel_started);
9715        let canceled_task = server.start_index_task(
9716            McpTaskOperation::Scan,
9717            SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
9718            MCP_TOOL_ATLAS_OVERVIEW,
9719            move |control, options| {
9720                if options.reported_workers() != control.worker_ceiling().unwrap_or_default() {
9721                    return Err(CliError::Mcp(
9722                        "background parser and operation worker ceilings diverged".to_string(),
9723                    ));
9724                }
9725                worker_cancel_started.wait();
9726                loop {
9727                    control
9728                        .check(projectatlas_core::IndexWorkStage::RepositoryTraversal)
9729                        .map_err(|failure| {
9730                            CliError::Fs(projectatlas_fs::FsError::IndexWork(failure))
9731                        })?;
9732                    thread::yield_now();
9733                }
9734            },
9735        )?;
9736        cancel_started.wait();
9737
9738        let running = server.task_status(canceled_task.task_id.clone())?;
9739        require(
9740            running
9741                .task
9742                .as_ref()
9743                .is_some_and(|record| record.state == McpTaskState::Running),
9744            "background task did not become running",
9745        )?;
9746        let cancel = server.task_cancel(canceled_task.task_id.clone())?;
9747        require(
9748            cancel.result == McpTaskCancelResult::CancellationRequested,
9749            "task cancellation did not reach the active work control",
9750        )?;
9751
9752        require(
9753            wait_for_background_task(&server, &canceled_task.task_id)?.state
9754                == McpTaskState::Canceled,
9755            "background task did not finish canceled after consuming the signal",
9756        )?;
9757        require(
9758            run_successful_background_task(&server)?.state == McpTaskState::Complete,
9759            "successful task was not admitted after cancellation",
9760        )?;
9761
9762        let failed_task = server.start_index_task(
9763            McpTaskOperation::Scan,
9764            SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
9765            MCP_TOOL_ATLAS_OVERVIEW,
9766            |_control, _options| Err(CliError::Mcp("expected task failure".to_string())),
9767        )?;
9768        require(
9769            wait_for_background_task(&server, &failed_task.task_id)?.state == McpTaskState::Failed,
9770            "background task error did not become a terminal failure",
9771        )?;
9772        require(
9773            run_successful_background_task(&server)?.state == McpTaskState::Complete,
9774            "successful task was not admitted after failure",
9775        )?;
9776
9777        let panicked_task = server.start_index_task(
9778            McpTaskOperation::Scan,
9779            SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
9780            MCP_TOOL_ATLAS_OVERVIEW,
9781            |_control, _options| -> Result<(), CliError> {
9782                std::panic::resume_unwind(Box::new("expected background task panic"));
9783            },
9784        )?;
9785        let panicked = wait_for_background_task(&server, &panicked_task.task_id)?;
9786        require(
9787            panicked.state == McpTaskState::Failed
9788                && panicked.error.as_deref() == Some(MCP_INDEX_WORKER_PANIC_ERROR),
9789            "background task panic did not remain bounded and terminal",
9790        )?;
9791        require(
9792            run_successful_background_task(&server)?.state == McpTaskState::Complete,
9793            "terminal task lifecycle did not release background admission capacity",
9794        )?;
9795
9796        Ok(())
9797    }
9798
9799    #[test]
9800    fn background_scan_defers_config_validation_and_rejects_root_redirection()
9801    -> Result<(), Box<dyn std::error::Error>> {
9802        let temp = tempfile::tempdir()?;
9803        let repo = temp.path().join("repo-a");
9804        let redirected = temp.path().join("repo-b");
9805        let atlas_dir = repo.join(".projectatlas");
9806        fs::create_dir_all(&atlas_dir)?;
9807        fs::create_dir_all(&redirected)?;
9808        let config_path = atlas_dir.join("config.toml");
9809        fs::write(&config_path, "[project]\nroot = \"../../repo-b\"\n")?;
9810        let db_path = atlas_dir.join("projectatlas.db");
9811        let server =
9812            ProjectAtlasMcpServer::new(db_path, Some(config_path), "mcp-test".to_string(), false);
9813
9814        let response = server.atlas_scan(Parameters(AtlasScanParams {
9815            project_path: Some(repo.to_string_lossy().into_owned()),
9816            path: None,
9817            nearest_project: Some(false),
9818            max_bytes: None,
9819            max_workers: Some(1),
9820            timeout_seconds: None,
9821            text_index_max_bytes: None,
9822            background: Some(true),
9823        }));
9824        require(
9825            response.contains(MCP_PAYLOAD_TASK_START),
9826            "explicit background project validated redirecting config before task admission",
9827        )?;
9828        let admitted_task_id = server
9829            .task_registry
9830            .read()
9831            .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
9832            .records
9833            .iter()
9834            .find(|record| matches!(record.operation, McpTaskOperation::Scan))
9835            .map(|record| record.task_id.clone())
9836            .ok_or_else(|| io::Error::other("admitted background scan task missing"))?;
9837        let mut admitted_terminal = None;
9838        for _attempt in 0..1_000 {
9839            let status = server.task_status(admitted_task_id.clone())?;
9840            if status
9841                .task
9842                .as_ref()
9843                .is_some_and(McpTaskRecord::is_terminal_state)
9844            {
9845                admitted_terminal = status.task;
9846                break;
9847            }
9848            thread::sleep(Duration::from_millis(1));
9849        }
9850        require(
9851            admitted_terminal
9852                .as_ref()
9853                .is_some_and(|record| record.state == McpTaskState::Failed),
9854            "redirecting background config did not fail inside the admitted task",
9855        )?;
9856        require(
9857            admitted_terminal
9858                .as_ref()
9859                .and_then(|record| record.error.as_deref())
9860                .is_some_and(|error| error.contains("outside selected project root")),
9861            "controlled plan loading did not preserve root-redirection refusal",
9862        )?;
9863
9864        Ok(())
9865    }
9866
9867    #[test]
9868    fn index_adapters_publish_scan_symbol_and_watch_effects()
9869    -> Result<(), Box<dyn std::error::Error>> {
9870        let temp = tempfile::tempdir()?;
9871        let repo = temp.path().join("repo");
9872        let source_dir = repo.join("src");
9873        fs::create_dir_all(&source_dir)?;
9874        let source_path = source_dir.join("lib.rs");
9875        fs::write(
9876            &source_path,
9877            "pub fn first() { second(); }\nfn second() {}\n",
9878        )?;
9879        let config_path = repo.join(".projectatlas").join("config.toml");
9880        init_project_with_config(&repo, Some(&config_path))?;
9881        let db_path = repo.join(".projectatlas").join("projectatlas.db");
9882        let server =
9883            ProjectAtlasMcpServer::new(db_path.clone(), None, "mcp-test".to_string(), false);
9884        let project_path = repo.to_string_lossy().into_owned();
9885
9886        for operation in [
9887            McpTaskOperation::Scan,
9888            McpTaskOperation::SymbolsBuild,
9889            McpTaskOperation::WatchOnce,
9890        ] {
9891            match operation {
9892                McpTaskOperation::SymbolsBuild => {
9893                    let store = open_atlas_store_for_project(&db_path, &repo)?;
9894                    store.clear_symbol_graph_for_path("src/lib.rs")?;
9895                    require(
9896                        store.symbol_count_for_path("src/lib.rs")? == 0,
9897                        "symbol fixture was not cleared before background rebuild",
9898                    )?;
9899                }
9900                McpTaskOperation::WatchOnce => {
9901                    fs::write(
9902                        &source_path,
9903                        "pub fn first() { second(); third(); }\nfn second() {}\nfn third() {}\n",
9904                    )?;
9905                }
9906                McpTaskOperation::Scan | McpTaskOperation::Contract | McpTaskOperation::Search => {}
9907            }
9908
9909            let response = match operation {
9910                McpTaskOperation::Scan | McpTaskOperation::SymbolsBuild => {
9911                    let params = AtlasScanParams {
9912                        project_path: Some(project_path.clone()),
9913                        path: None,
9914                        nearest_project: Some(false),
9915                        max_bytes: None,
9916                        max_workers: Some(1),
9917                        timeout_seconds: None,
9918                        text_index_max_bytes: None,
9919                        background: Some(true),
9920                    };
9921                    if operation == McpTaskOperation::Scan {
9922                        server.atlas_scan(Parameters(params))
9923                    } else {
9924                        server.atlas_symbols_build(Parameters(params))
9925                    }
9926                }
9927                McpTaskOperation::WatchOnce => {
9928                    server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
9929                        project_path: Some(project_path.clone()),
9930                        path: None,
9931                        nearest_project: Some(false),
9932                        max_workers: Some(1),
9933                        timeout_seconds: None,
9934                        text_index_max_bytes: None,
9935                        background: Some(true),
9936                    }))
9937                }
9938                McpTaskOperation::Contract | McpTaskOperation::Search => unreachable!(),
9939            };
9940            require(
9941                response.contains(MCP_PAYLOAD_TASK_START),
9942                "production background adapter did not admit its task",
9943            )?;
9944            let terminal = wait_for_background_operation(&server, &operation)?;
9945            require(
9946                terminal.state == McpTaskState::Complete,
9947                terminal
9948                    .error
9949                    .as_deref()
9950                    .unwrap_or("background task failed"),
9951            )?;
9952
9953            let expected_symbol = match operation {
9954                McpTaskOperation::Scan | McpTaskOperation::SymbolsBuild => "second",
9955                McpTaskOperation::WatchOnce => "third",
9956                McpTaskOperation::Contract | McpTaskOperation::Search => unreachable!(),
9957            };
9958            let store = open_atlas_store_for_project(&db_path, &repo)?;
9959            store.set_purpose(
9960                "src/lib.rs",
9961                "Own café λ relation navigation",
9962                PurposeSource::Agent,
9963            )?;
9964            drop(store);
9965            require_agent_index_reads(&server, &project_path, expected_symbol)?;
9966        }
9967
9968        let store = open_atlas_store_for_project(&db_path, &repo)?;
9969        store.clear_symbol_graph_for_path("src/lib.rs")?;
9970        drop(store);
9971        let synchronous_symbols = server.atlas_symbols_build(Parameters(AtlasScanParams {
9972            project_path: Some(project_path.clone()),
9973            path: None,
9974            nearest_project: Some(false),
9975            max_bytes: None,
9976            max_workers: Some(1),
9977            timeout_seconds: None,
9978            text_index_max_bytes: None,
9979            background: Some(false),
9980        }));
9981        require(
9982            synchronous_symbols.contains(MCP_PAYLOAD_SYMBOLS_BUILD),
9983            "synchronous symbol adapter did not return its completed report",
9984        )?;
9985        require_agent_index_reads(&server, &project_path, "second")?;
9986
9987        fs::write(
9988            &source_path,
9989            "pub fn first() { second(); fourth(); }\nfn second() {}\nfn fourth() {}\n",
9990        )?;
9991        let synchronous_watch = server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
9992            project_path: Some(project_path.clone()),
9993            path: None,
9994            nearest_project: Some(false),
9995            max_workers: Some(1),
9996            timeout_seconds: None,
9997            text_index_max_bytes: None,
9998            background: Some(false),
9999        }));
10000        require(
10001            synchronous_watch.contains(MCP_PAYLOAD_WATCH),
10002            "synchronous watch adapter did not return its completed report",
10003        )?;
10004        require_agent_index_reads(&server, &project_path, "fourth")?;
10005        Ok(())
10006    }
10007
10008    #[test]
10009    fn long_lived_mcp_query_families_reuse_one_verified_epoch()
10010    -> Result<(), Box<dyn std::error::Error>> {
10011        const LARGE_UNRELATED_SOURCE_FILES: usize = 256;
10012        const MAX_MEASURED_QUERY_OUTPUT_BYTES: usize = 64 * 1_024;
10013        const MAX_MEASURED_QUERY_ELAPSED: Duration = Duration::from_secs(30);
10014
10015        let measure = |unrelated_source_files: usize| {
10016            let temp = tempfile::tempdir()?;
10017            let repo = temp.path().join("repo");
10018            let source_dir = repo.join("src");
10019            fs::create_dir_all(&source_dir)?;
10020            fs::write(
10021                source_dir.join("lib.rs"),
10022                "pub fn first() { second(); }\nfn second() {}\n",
10023            )?;
10024            for index in 0..unrelated_source_files {
10025                fs::write(
10026                    source_dir.join(format!("unrelated_{index:03}.rs")),
10027                    format!("pub fn unrelated_{index:03}() {{}}\n"),
10028                )?;
10029            }
10030            let config_path = repo.join(".projectatlas").join("config.toml");
10031            init_project_with_config(&repo, Some(&config_path))?;
10032            let db_path = repo.join(".projectatlas").join("projectatlas.db");
10033            let mut writer = open_atlas_store_for_project(&db_path, &repo)?;
10034            let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
10035            run_scan_pipeline(
10036                &mut writer,
10037                &plan,
10038                &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), None),
10039            )?;
10040            drop(writer);
10041
10042            let server = ProjectAtlasMcpServer::new(
10043                db_path.clone(),
10044                None,
10045                "verified-epoch-test".to_string(),
10046                false,
10047            );
10048            let state = McpProjectState {
10049                root: repo,
10050                db_path,
10051                config_path: None,
10052            };
10053            let first = server.with_fresh_store(&state, |store, _stamp| Ok(store.overview()?))?;
10054            require(
10055                first.work.exact_verifications >= 1,
10056                "first long-lived MCP read did not establish exact source truth",
10057            )?;
10058            require(
10059                first.work.filesystem_entries > u64::try_from(unrelated_source_files)?,
10060                "scale fixture did not exercise its complete repository source set",
10061            )?;
10062            let expected_stamp = first.stamp;
10063
10064            let folder = server.with_fresh_store(&state, |store, _stamp| {
10065                Ok(render_ranked_nodes(
10066                    NODE_LABEL_FOLDERS,
10067                    &ranked_folder_nodes_with_reasons(store, "", 4)?,
10068                ))
10069            })?;
10070            let folder_output_bytes = folder.value.len();
10071            let folder = folder.with_output_bytes(folder_output_bytes);
10072            let files = server.with_fresh_store(&state, |store, _stamp| {
10073                Ok(render_ranked_nodes(
10074                    NODE_LABEL_FILES,
10075                    &ranked_file_nodes_with_reasons(store, "", Some("src"), None, 4, false)?,
10076                ))
10077            })?;
10078            let files_output_bytes = files.value.len();
10079            let files = files.with_output_bytes(files_output_bytes);
10080            let summary = server.with_fresh_store(&state, |store, _stamp| {
10081                let content = read_indexed_file_content(store, "src/lib.rs")?;
10082                let report = build_file_summary_from_source(
10083                    store,
10084                    Path::new("src/lib.rs"),
10085                    DEFAULT_FILE_SUMMARY_LIMIT,
10086                    &content,
10087                )?;
10088                Ok(render_file_summary(&report))
10089            })?;
10090            let summary_output_bytes = summary.value.len();
10091            let summary = summary.with_output_bytes(summary_output_bytes);
10092            let relations = server.with_fresh_store(&state, |store, _stamp| {
10093                Ok(render_symbol_relations(&store.load_symbol_relations(
10094                    Some("src/lib.rs"),
10095                    None,
10096                    8,
10097                )?))
10098            })?;
10099            let relation_output_bytes = relations.value.len();
10100            let relations = relations.with_output_bytes(relation_output_bytes);
10101
10102            let mut measurements = Vec::new();
10103            for (family, outcome) in [
10104                ("folder", folder),
10105                ("file", files),
10106                ("summary", summary),
10107                ("relation", relations),
10108            ] {
10109                require(
10110                    outcome.stamp == expected_stamp,
10111                    &format!("{family} call did not remain bound to the verified epoch"),
10112                )?;
10113                require(
10114                    outcome.work.exact_verifications == 0
10115                        && outcome.work.filesystem_entries == 0
10116                        && outcome.work.filesystem_bytes == 0
10117                        && outcome.work.decoded_nodes == 0,
10118                    &format!("{family} call repeated repository-sized freshness work"),
10119                )?;
10120                require(
10121                    outcome.work.sqlite_read_statements == 1,
10122                    &format!("{family} freshness check used unexpected SQLite work"),
10123                )?;
10124                require(
10125                    outcome.work.output_bytes == u64::try_from(outcome.value.len())?,
10126                    &format!("{family} call did not record its accepted rendered bytes"),
10127                )?;
10128                require(
10129                    outcome.value.len() <= MAX_MEASURED_QUERY_OUTPUT_BYTES,
10130                    &format!("{family} call exceeded the focused output bound"),
10131                )?;
10132                require(
10133                    !outcome.work.elapsed.is_zero()
10134                        && outcome.work.elapsed <= MAX_MEASURED_QUERY_ELAPSED,
10135                    &format!("{family} call did not retain a bounded elapsed measurement"),
10136                )?;
10137                measurements.push((family, outcome.work));
10138            }
10139            Ok::<_, Box<dyn std::error::Error>>(measurements)
10140        };
10141
10142        let small = measure(0)?;
10143        let large = measure(LARGE_UNRELATED_SOURCE_FILES)?;
10144        for ((small_family, small_work), (large_family, large_work)) in small.into_iter().zip(large)
10145        {
10146            require(
10147                small_family == large_family,
10148                "small and large query measurements used different families",
10149            )?;
10150            require(
10151                small_work.exact_verifications == large_work.exact_verifications
10152                    && small_work.filesystem_entries == large_work.filesystem_entries
10153                    && small_work.filesystem_bytes == large_work.filesystem_bytes
10154                    && small_work.sqlite_read_statements == large_work.sqlite_read_statements
10155                    && small_work.decoded_nodes == large_work.decoded_nodes,
10156                &format!("{small_family} warm freshness work changed with repository scale"),
10157            )?;
10158        }
10159        Ok(())
10160    }
10161
10162    #[test]
10163    fn task_registry_evictions_prefer_old_terminal_records()
10164    -> Result<(), Box<dyn std::error::Error>> {
10165        let mut registry = McpTaskRegistry {
10166            records: VecDeque::new(),
10167        };
10168        registry.insert(McpTaskRecord {
10169            task_id: "running-0".to_string(),
10170            operation: McpTaskOperation::Search,
10171            state: McpTaskState::Running,
10172            created_at_ms: 0,
10173            updated_at_ms: 0,
10174            progress: None,
10175            error: None,
10176            result_ref: None,
10177            cancelable: true,
10178            control: None,
10179        });
10180        for index in 1..MCP_TASK_REGISTRY_CAPACITY {
10181            registry.insert(McpTaskRecord {
10182                task_id: format!("complete-{index}"),
10183                operation: McpTaskOperation::Search,
10184                state: McpTaskState::Complete,
10185                created_at_ms: index as u128,
10186                updated_at_ms: index as u128,
10187                progress: None,
10188                error: None,
10189                result_ref: None,
10190                cancelable: false,
10191                control: None,
10192            });
10193        }
10194
10195        registry.insert(McpTaskRecord {
10196            task_id: "new-complete".to_string(),
10197            operation: McpTaskOperation::Search,
10198            state: McpTaskState::Complete,
10199            created_at_ms: 100,
10200            updated_at_ms: 100,
10201            progress: None,
10202            error: None,
10203            result_ref: Some("atlas_search".to_string()),
10204            cancelable: false,
10205            control: None,
10206        });
10207
10208        require(
10209            registry.records.len() == MCP_TASK_REGISTRY_CAPACITY,
10210            "task registry exceeded configured capacity",
10211        )?;
10212        require(
10213            registry.get("running-0").is_some(),
10214            "registry evicted a running record before old terminal records",
10215        )?;
10216        require(
10217            registry.get("complete-1").is_none(),
10218            "registry did not evict the oldest terminal record",
10219        )?;
10220        require(
10221            registry.get("new-complete").is_some(),
10222            "registry did not retain the newly inserted record",
10223        )?;
10224
10225        Ok(())
10226    }
10227
10228    #[test]
10229    fn selected_root_absolute_path_keys_stay_inside_selected_project()
10230    -> Result<(), Box<dyn std::error::Error>> {
10231        let temp = tempfile::tempdir()?;
10232        let repo = temp.path().join("repo");
10233        let outside = temp.path().join("outside");
10234        fs::create_dir_all(repo.join("src"))?;
10235        fs::create_dir_all(outside.join("src"))?;
10236        let inside_file = repo.join("src").join("lib.rs");
10237        let outside_file = outside.join("src").join("lib.rs");
10238        fs::write(&inside_file, "pub fn inside() {}\n")?;
10239        fs::write(&outside_file, "pub fn outside() {}\n")?;
10240        let state = McpProjectState {
10241            root: canonical_project_root(&repo)?,
10242            db_path: repo.join(".projectatlas").join("projectatlas.db"),
10243            config_path: None,
10244        };
10245
10246        let inside_key =
10247            ProjectAtlasMcpServer::absolute_path_key_in_selected_project(&state, &inside_file)?
10248                .ok_or_else(|| io::Error::other("inside selected root did not produce key"))?;
10249        require(
10250            inside_key == "src/lib.rs",
10251            "inside selected root produced wrong repo key",
10252        )?;
10253        require(
10254            ProjectAtlasMcpServer::absolute_path_key_in_selected_project(&state, &outside_file)?
10255                .is_none(),
10256            "outside selected root produced a repo key",
10257        )?;
10258
10259        Ok(())
10260    }
10261
10262    #[test]
10263    fn indexed_root_candidate_requires_matching_project_root()
10264    -> Result<(), Box<dyn std::error::Error>> {
10265        let temp = tempfile::tempdir()?;
10266        let repo = temp.path().join("repo");
10267        let other = temp.path().join("other");
10268        fs::create_dir_all(repo.join(".projectatlas"))?;
10269        fs::create_dir_all(&other)?;
10270
10271        require(
10272            ProjectAtlasMcpServer::indexed_root_from_candidate(&repo).is_none(),
10273            "candidate without DB was treated as indexed",
10274        )?;
10275
10276        let db_path = repo.join(".projectatlas").join("projectatlas.db");
10277        {
10278            let _store = open_atlas_store_for_project(&db_path, &other)?;
10279        }
10280        require(
10281            ProjectAtlasMcpServer::indexed_root_from_candidate(&repo).is_none(),
10282            "candidate with mismatched DB root was treated as indexed",
10283        )?;
10284
10285        reset_index_files(&db_path, true, false, false)?;
10286        {
10287            let _store = open_atlas_store_for_project(&db_path, &repo)?;
10288        }
10289        let indexed = ProjectAtlasMcpServer::indexed_root_from_candidate(&repo)
10290            .ok_or_else(|| io::Error::other("matching DB root was not accepted"))?;
10291        require(
10292            indexed.root == canonical_project_root(&repo)?,
10293            "indexed root did not preserve canonical candidate root",
10294        )?;
10295        let expected_db_path =
10296            ProjectAtlasMcpServer::projectatlas_db_path(&canonical_project_root(&repo)?);
10297        require(
10298            indexed.db_path == expected_db_path,
10299            "indexed root changed DB path",
10300        )?;
10301
10302        Ok(())
10303    }
10304}