1use 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
100pub(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
144const MCP_TOOL_ATLAS_SET_PROJECT_PATH: &str = "atlas_set_project_path";
146const MCP_TOOL_ATLAS_INIT: &str = "atlas_init";
148const MCP_TOOL_ATLAS_MAP: &str = "atlas_map";
150const MCP_TOOL_ATLAS_ROOT: &str = "atlas_root";
152const MCP_TOOL_ATLAS_ROOT_SET: &str = "atlas_root_set";
154const MCP_TOOL_ATLAS_CONFIG: &str = "atlas_config";
156const MCP_TOOL_ATLAS_IGNORE_LIST: &str = "atlas_ignore_list";
158const MCP_TOOL_ATLAS_IGNORE_INIT_GITIGNORE: &str = "atlas_ignore_init_gitignore";
160const MCP_TOOL_ATLAS_IGNORE_ADD: &str = "atlas_ignore_add";
162const MCP_TOOL_ATLAS_IGNORE_REMOVE: &str = "atlas_ignore_remove";
164const MCP_TOOL_ATLAS_SCAN: &str = "atlas_scan";
166const MCP_TOOL_ATLAS_OVERVIEW: &str = "atlas_overview";
168const MCP_TOOL_ATLAS_FOLDERS: &str = "atlas_folders";
170const MCP_TOOL_ATLAS_FILES: &str = "atlas_files";
172const MCP_TOOL_ATLAS_NEXT: &str = "atlas_next";
174const MCP_TOOL_ATLAS_OUTLINE: &str = "atlas_outline";
176const MCP_TOOL_ATLAS_FILE_SUMMARY: &str = "atlas_file_summary";
178const MCP_TOOL_ATLAS_SEARCH: &str = "atlas_search";
180const MCP_TOOL_ATLAS_SLICE: &str = "atlas_slice";
182const MCP_TOOL_ATLAS_SYMBOLS_BUILD: &str = "atlas_symbols_build";
184const MCP_TOOL_ATLAS_SYMBOLS: &str = "atlas_symbols";
186const MCP_TOOL_ATLAS_SYMBOL_RELATIONS: &str = "atlas_symbol_relations";
188const MCP_TOOL_ATLAS_HEALTH: &str = "atlas_health";
190const MCP_TOOL_ATLAS_HEALTH_RESOLVE: &str = "atlas_health_resolve";
192const MCP_TOOL_ATLAS_LINT: &str = "atlas_lint";
194const MCP_TOOL_ATLAS_TOKEN_REPORT: &str = "atlas_token_report";
196const MCP_TOOL_ATLAS_PARITY_REPORT: &str = "atlas_parity_report";
198const MCP_TOOL_ATLAS_SETTINGS: &str = "atlas_settings";
200const MCP_TOOL_ATLAS_WATCH_STATUS: &str = "atlas_watch_status";
202const MCP_TOOL_ATLAS_WATCH_ONCE: &str = "atlas_watch_once";
204const MCP_TOOL_ATLAS_STRIP_LEGACY_PURPOSE: &str = "atlas_strip_legacy_purpose";
206const MCP_TOOL_ATLAS_RESET_INDEX: &str = "atlas_reset_index";
208const MCP_TOOL_ATLAS_MCP_CONFIG: &str = "atlas_mcp_config";
210const MCP_TOOL_ATLAS_RUNTIME_INFO: &str = "atlas_runtime_info";
212const MCP_TOOL_ATLAS_SESSION_BRIEF: &str = "atlas_session_brief";
214const MCP_TOOL_ATLAS_TASK_STATUS: &str = "atlas_task_status";
216const MCP_TOOL_ATLAS_TASK_CANCEL: &str = "atlas_task_cancel";
218const MCP_TOOL_ATLAS_PURPOSE_QUEUE: &str = "atlas_purpose_queue";
220const MCP_TOOL_ATLAS_PURPOSE_SET: &str = "atlas_purpose_set";
222const MCP_TOOL_ATLAS_PURPOSE_REVIEW: &str = "atlas_purpose_review";
224const PROJECTATLAS_DIR_NAME: &str = ".projectatlas";
226const PROJECTATLAS_DB_FILE_NAME: &str = "projectatlas.db";
228const PROJECTATLAS_CONFIG_FILE_NAME: &str = "config.toml";
230const PROJECTATLAS_FLAT_CONFIG_FILE_NAME: &str = "projectatlas.toml";
232const MCP_TELEMETRY_PROJECT_BINDING_LIMIT: usize = 64;
234const MCP_SETTINGS_RESPONSE_MAX_BYTES: usize = 64_000;
236const MCP_SETTINGS_RESPONSE_LIMIT_PREFIX: &str = "settings response requires ";
238const MCP_SETTINGS_RESPONSE_LIMIT_SEPARATOR: &str = " bytes, exceeding the ";
240const MCP_SETTINGS_RESPONSE_LIMIT_SUFFIX: &str = "-byte diagnostic limit";
242const MCP_SOURCE_TOKEN_BASELINE_LIMIT: usize = 128;
244const MCP_DEFAULT_CONFIG_SERVER_NAME: &str = "projectatlas";
246const 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";
248const 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";
250const CURRENT_DIR_ALIAS: &str = ".";
252const MCP_SERVER_NAME: &str = "ProjectAtlas";
254const MCP_CANCELLATION_MONITOR_THREAD_NAME: &str = "projectatlas-mcp-cancel";
256const MCP_CANCELLATION_MONITOR_START_ERROR_PREFIX: &str =
258 "MCP request cancellation monitor could not start: ";
259const MCP_PROJECT_STATE_LOCK_POISONED: &str = "MCP project state lock poisoned";
261const MCP_ERROR_SERIALIZATION_FALLBACK_PREFIX: &str = "error: ";
263const MCP_PAYLOAD_SCAN: &str = "scan";
265const MCP_PAYLOAD_INIT: &str = "init";
267const MCP_PAYLOAD_MAP: &str = "map";
269const MCP_PAYLOAD_CONFIG: &str = "config";
271const MCP_PAYLOAD_IGNORE: &str = "ignore";
273const MCP_PAYLOAD_GITIGNORE: &str = "gitignore";
275const MCP_PAYLOAD_LINT: &str = "lint";
277const MCP_PAYLOAD_SYMBOLS_BUILD: &str = "symbols_build";
279const MCP_PAYLOAD_SYMBOL_RELATIONS: &str = "symbol_relations";
281const MCP_PAYLOAD_HEALTH_RESOLUTION: &str = "health_resolution";
283const MCP_PAYLOAD_TOKEN_TRENDS: &str = "token_trends";
285const MCP_PAYLOAD_TOKEN_SAVINGS: &str = "token_savings";
287const MCP_PAYLOAD_CHART: &str = "chart";
289const MCP_PAYLOAD_WATCH: &str = "watch";
291const MCP_PAYLOAD_LEGACY_PURPOSE_MIGRATION: &str = "legacy_purpose_migration";
293const MCP_PAYLOAD_RESET_INDEX: &str = "reset_index";
295const MCP_PAYLOAD_MCP_CONFIG: &str = "mcp_config";
297const MCP_PAYLOAD_NEXT: &str = "next";
299const MCP_PAYLOAD_SELECTED_PROJECT: &str = "selected_project";
301const MCP_PAYLOAD_SETTINGS: &str = "settings";
303const MCP_PAYLOAD_SESSION_BRIEF: &str = "session_brief";
305const MCP_FILE_SOURCE_STATUS_LIVE: &str = "live-source";
307const MCP_PAYLOAD_TASK_START: &str = "task_start";
309const MCP_PAYLOAD_TASK_STATUS: &str = "task_status";
311const MCP_PAYLOAD_TASK_CANCEL: &str = "task_cancel";
313const MCP_PAYLOAD_SESSION_CAPABILITIES: &str = "mcp_session";
315const MCP_BRIEF_ARG_PROJECT_PATH: &str = "project_path";
317const MCP_BRIEF_ARG_FILE: &str = "file";
319const MCP_BRIEF_ARG_PATTERN: &str = "pattern";
321const MCP_BRIEF_ARG_VIEW: &str = "view";
323const MCP_BRIEF_ARG_LIMIT: &str = "limit";
325const MCP_BRIEF_ARG_TASK: &str = "task";
327const MCP_BRIEF_ARG_COMPACT: &str = "compact";
329const MCP_BRIEF_TARGET_FILESYSTEM_TOOLS: &str = "filesystem_tools";
331const MCP_BRIEF_REASON_SELECTED_INDEX_MISSING: &str = "selected_index_missing";
333const MCP_BRIEF_REASON_FILESYSTEM_UNTIL_INDEX: &str =
335 "use_filesystem_until_projectatlas_index_exists";
336const MCP_BRIEF_REASON_RANKED_FILE_SUMMARY: &str = "ranked_file_ready_for_summary";
338const MCP_BRIEF_REASON_RANKED_FILE_RELATIONS: &str = "ranked_file_ready_for_relations";
340const MCP_BRIEF_REASON_SEARCH_FALLBACK: &str = "no_ranked_file_candidate_search_index";
342const MCP_BRIEF_REASON_NO_FILE_CANDIDATE: &str = "no_ranked_file_candidate";
344const MCP_BRIEF_REASON_HEALTH_BLOCKERS: &str = "unresolved_health_blockers_present";
346const MCP_BRIEF_REASON_PURPOSE_QUEUE: &str = "purpose_queue_ready";
348const MCP_TASK_PROGRESS_CONTRACT_MESSAGE: &str = "task progress contract available";
350const MCP_EVENT_ATLAS_OVERVIEW: &str = "mcp.atlas_overview";
352const MCP_EVENT_ATLAS_FOLDERS: &str = "mcp.atlas_folders";
354const MCP_EVENT_ATLAS_FILES: &str = "mcp.atlas_files";
356const MCP_EVENT_ATLAS_NEXT: &str = "mcp.atlas_next";
358const MCP_EVENT_ATLAS_OUTLINE: &str = "mcp.atlas_outline";
360const MCP_EVENT_ATLAS_FILE_SUMMARY: &str = "mcp.atlas_file_summary";
362const MCP_EVENT_ATLAS_SEARCH: &str = "mcp.atlas_search";
364const MCP_EVENT_ATLAS_SLICE: &str = "mcp.atlas_slice";
366const MCP_EVENT_ATLAS_SYMBOLS: &str = "mcp.atlas_symbols";
368const MCP_EVENT_ATLAS_SYMBOL_RELATIONS: &str = "mcp.atlas_symbol_relations";
370const MCP_SYMBOL_RELATION_VIEW_LEGACY: &str = "legacy";
372const MCP_SYMBOL_RELATION_VIEW_DETAILED: &str = "detailed";
374const MCP_SYMBOL_RELATION_VIEW_ANALYSIS: &str = "analysis";
376const MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE: &str = "architecture";
378const MCP_RELATION_ANALYSIS_MODE_IMPACT: &str = "impact";
380const MCP_RELATION_ANALYSIS_MODE_TRACE: &str = "trace";
382const MCP_RELATION_ANALYSIS_VCS_WORKING_TREE: &str = "working_tree";
384const MCP_RELATION_ANALYSIS_VCS_INDEX: &str = "index";
386const MCP_RELATION_ANALYSIS_VCS_REVISION_RANGE: &str = "revision_range";
388const MCP_SYMBOL_RELATION_DIRECTION_DEFAULT: &str = "outbound";
390const MCP_SYMBOL_RELATION_CONFIDENCE_DEFAULT: &str = "low";
392const MCP_SYMBOL_RELATION_RESOLUTION_DEFAULT: &str = "any";
394const MCP_ERROR_SYMBOL_RELATION_VIEW: &str = "unsupported symbol relation view";
396const MCP_ERROR_DETAILED_RELATION_QUERY: &str =
398 "detailed symbol relations use exact symbol selectors, not query";
399const MCP_ERROR_DETAILED_RELATION_FILE: &str = "detailed symbol relations require file";
401const MCP_ERROR_DETAILED_RELATION_SYMBOL: &str = "detailed relation symbol must not be empty";
403const MCP_ERROR_DETAILED_RELATION_DISAMBIGUATOR: &str = "symbol disambiguators require symbol";
405const MCP_ERROR_DETAILED_RELATION_LIMIT: &str = "detailed relation limit exceeds the u32 range";
407const MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW: &str =
409 "compact symbol relations require view=detailed";
410const MCP_ERROR_ANALYSIS_VIEW_REQUIRED: &str = "analysis controls require view=analysis";
412const MCP_ERROR_FEDERATED_RELATION_VIEW: &str =
414 "roots require the detailed or analysis relation view";
415const MCP_ERROR_TRACE_TARGET_KIND_REQUIRED: &str = "symbol trace targets require trace_target_kind";
417const MCP_ERROR_TRACE_TARGET_SIGNATURE_REQUIRED: &str =
419 "symbol trace targets require trace_target_signature";
420const MCP_ERROR_TRACE_TARGET_REQUIRED: &str =
422 "trace target symbol disambiguators require trace_target";
423const MCP_ERROR_TRACE_TARGET_FILE_REQUIRED: &str = "trace_target requires trace_target_file";
425const MCP_ERROR_VCS_REVISION_FIELDS: &str = "vcs_base and vcs_head require vcs=revision_range";
427const MCP_ERROR_VCS_BASE_REQUIRED: &str = "vcs=revision_range requires vcs_base";
429const MCP_ERROR_VCS_HEAD_REQUIRED: &str = "vcs=revision_range requires vcs_head";
431const MCP_ERROR_UNSUPPORTED_ANALYSIS_VCS: &str = "unsupported analysis VCS selection";
433const MCP_ERROR_UNSUPPORTED_ANALYSIS_MODE: &str = "unsupported relation analysis mode";
435const MCP_EVENT_ATLAS_HEALTH: &str = "mcp.atlas_health";
437const MCP_EVENT_ATLAS_PURPOSE_QUEUE: &str = "mcp.atlas_purpose_queue";
439const MCP_IGNORE_KIND_DIR_NAME: &str = "dir-name";
441const MCP_IGNORE_KIND_DIR_NAME_ALIAS: &str = "dir_name";
443const MCP_IGNORE_KIND_PATH_PREFIX: &str = "path-prefix";
445const MCP_IGNORE_KIND_PATH_PREFIX_ALIAS: &str = "path_prefix";
447const MCP_PURPOSE_LEVEL_LOW: &str = "low";
449const MCP_PURPOSE_LEVEL_MEDIUM: &str = "medium";
451const MCP_PURPOSE_LEVEL_STRICT: &str = "strict";
453const MCP_PURPOSE_TASK_SESSION_STARTUP: &str = "session-startup";
455const MCP_PURPOSE_TASK_QUEUE: &str = "purpose-curation";
457const MCP_HARNESS_MCP_JSON: &str = "mcp-json";
459const MCP_HARNESS_MCP_JSON_ALIAS: &str = "mcp_json";
461const MCP_HARNESS_CODEX: &str = "codex";
463const MCP_HARNESS_CLAUDE_CODE: &str = "claude-code";
465const MCP_HARNESS_CLAUDE_CODE_ALIAS: &str = "claude_code";
467const MCP_HARNESS_OPENCODE: &str = "opencode";
469const MCP_ERROR_IGNORE_KIND_REQUIRED: &str =
471 "ignore kind is required; expected dir-name or path-prefix";
472const MCP_ERROR_IGNORE_KIND_REQUIRED_FOR_ADD: &str = "ignore kind is required for atlas_ignore_add";
474const MCP_ERROR_COVERAGE_START_INDEX_TOO_LARGE_PREFIX: &str = "coverage start index is too large: ";
476const MCP_ERROR_COVERAGE_LIMIT_TOO_LARGE_PREFIX: &str = "coverage limit is too large: ";
478const MCP_ERROR_COVERAGE_FILTERS_REQUIRE_COVERAGE: &str = "coverage filters require coverage=true";
480const MCP_ENV_CI: &str = "CI";
482const MCP_ENV_GITHUB_ACTIONS: &str = "GITHUB_ACTIONS";
484const MCP_MAP_SKIPPED_IN_CI_REASON: &str =
486 "skipped in CI; pass force=true to write the compatibility map";
487const MCP_NO_ROOT_PLACEHOLDER: &str = "none";
489const MCP_ERROR_INVALID_IGNORE_KIND_PREFIX: &str = "invalid ignore kind '";
491const MCP_ERROR_INVALID_IGNORE_KIND_SUFFIX: &str = "'; expected dir-name or path-prefix";
493const MCP_ERROR_INVALID_PURPOSE_LEVEL_PREFIX: &str = "invalid purpose_level '";
495const MCP_ERROR_INVALID_PURPOSE_LEVEL_SUFFIX: &str = "'; expected low, medium, or strict";
497const MCP_ERROR_INVALID_HARNESS_PREFIX: &str = "invalid harness '";
499const MCP_ERROR_INVALID_HARNESS_SUFFIX: &str =
501 "'; expected mcp-json, codex, claude-code, or opencode";
502const MCP_ERROR_FOR_PATH_FRAGMENT: &str = " for '";
504const MCP_ERROR_LEXICAL_ROOT_FRAGMENT: &str = "'; lexical root: '";
506const MCP_ERROR_RESOLVED_ROOT_FRAGMENT: &str = "'; resolved root: '";
508const MCP_ERROR_GUIDANCE_FRAGMENT: &str = "'; ";
510const NODE_LABEL_FOLDERS: &str = "folders";
512const NODE_LABEL_FILES: &str = "files";
514const SYMBOL_DISAMBIGUATOR_WITHOUT_SYMBOL_ERROR: &str = "symbol disambiguators require symbol";
516const START_LINE_REQUIRED_ERROR: &str = "start_line is required unless symbol is provided";
518const PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR: &str =
520 "path is not inside an indexed ProjectAtlas project";
521const FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR: &str =
523 "folder is not inside an indexed ProjectAtlas project";
524const AMBIGUOUS_NEAREST_PROJECT_PATH_ERROR: &str = "absolute MCP path resolves through a symlink or junction with multiple plausible ProjectAtlas roots";
526const SEVERITY_EXPECTED_SEPARATOR: &str = ", ";
528const SEVERITY_EXPECTED_FINAL_SEPARATOR: &str = ", or ";
530const TOKEN_TREND_WINDOW_ERROR_SUFFIX: &str = "expected day, week, month, or year";
532const TOKEN_TREND_BENCHMARK_ERROR: &str =
534 "benchmark_results is only supported for token overview reports";
535const TOKEN_TRENDS_RESULT_VARIANT_MISMATCH: &str = "token trend request returned an overview";
537const TOKEN_OVERVIEW_RESULT_VARIANT_MISMATCH: &str = "token overview request returned trends";
539const TOKEN_CHART_THEME_ERROR_PREFIX: &str = "unsupported token chart theme ";
541const TOKEN_CHART_THEME_ERROR_SUFFIX: &str = "; expected dark or light";
543const WATCH_STATUS_SCAN_RECOMMENDATION: &str =
545 " Run `atlas_scan` first when no ProjectAtlas index exists for this project.";
546const SESSION_BRIEF_DEFAULT_LIMIT: usize = 5;
548const COMPACT_SESSION_BRIEF_DEFAULT_LIMIT: usize = 3;
550const SESSION_BRIEF_MAX_LIMIT: usize = 8;
552const MCP_TASK_REGISTRY_CAPACITY: usize = 32;
554const MCP_TASK_ERROR_MAX_CHARS: usize = 512;
556const MCP_TASK_CONTRACT_ID: &str = "task-progress-contract";
558const MCP_TASK_REGISTRY_LOCK_POISONED: &str = "MCP task registry lock is poisoned";
560const MCP_INDEX_TASK_ID_PREFIX: &str = "index-";
562const MCP_INDEX_WORKER_NAME_PREFIX: &str = "projectatlas-";
564const MCP_INDEX_TASK_LIMIT_PREFIX: &str = "background indexing task limit ";
566const MCP_INDEX_TASK_LIMIT_SUFFIX: &str = " is already active";
568const MCP_BACKGROUND_TASK_SAFE_CEILING: usize = 4;
570const MCP_INDEX_WORKER_PANIC_ERROR: &str = "background indexing worker panicked";
572const MCP_INDEX_WORKER_SPAWN_ERROR_PREFIX: &str = "failed to start background indexing: ";
574const MCP_TASK_PROGRESS_ACCEPTED: &str = "accepted";
576const MCP_TASK_PROGRESS_RUNNING: &str = "running";
578const MCP_TASK_PROGRESS_COMPLETE: &str = "complete";
580const MCP_TASK_PROGRESS_FAILED: &str = "failed";
582const MCP_TASK_PROGRESS_CANCELED: &str = "canceled";
584const MCP_TASK_PROGRESS_CANCELLATION_REQUESTED: &str = "cancellation_requested";
586const 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#[derive(Debug, Deserialize, schemars::JsonSchema)]
591struct AtlasProjectParams {
592 project_path: Option<String>,
594}
595
596#[derive(Debug, Deserialize, schemars::JsonSchema)]
598struct AtlasSessionBriefParams {
599 project_path: Option<String>,
601 query: Option<String>,
603 purpose_task: Option<String>,
605 compact: Option<bool>,
607 folder_limit: Option<usize>,
609 file_limit: Option<usize>,
611 blocker_limit: Option<usize>,
613 purpose_limit: Option<usize>,
615}
616
617#[derive(Debug, Deserialize, schemars::JsonSchema)]
619struct AtlasTaskParams {
620 task_id: String,
622}
623
624#[derive(Debug, Deserialize, schemars::JsonSchema)]
626struct AtlasSetProjectPathParams {
627 project_path: String,
629}
630
631#[derive(Debug, Deserialize, schemars::JsonSchema)]
633struct AtlasInitParams {
634 project_path: Option<String>,
636 no_scan: Option<bool>,
638 force_rescan: Option<bool>,
640 text_index_max_bytes: Option<u64>,
642}
643
644#[derive(Debug, Deserialize, schemars::JsonSchema)]
646struct AtlasMapParams {
647 project_path: Option<String>,
649 json: Option<bool>,
651 force: Option<bool>,
653}
654
655#[derive(Debug, Deserialize, schemars::JsonSchema)]
657struct AtlasRootParams {
658 project_path: Option<String>,
660 verify: Option<bool>,
662}
663
664#[derive(Debug, Deserialize, schemars::JsonSchema)]
666struct AtlasRootSetParams {
667 root: String,
669 transition: Option<RootTransition>,
671 nearest_project: Option<bool>,
673}
674
675#[derive(Debug, Deserialize, schemars::JsonSchema)]
677struct AtlasIgnoreMutationParams {
678 project_path: Option<String>,
680 kind: Option<String>,
682 value: String,
684}
685
686#[derive(Debug, Deserialize, schemars::JsonSchema)]
688struct AtlasLintParams {
689 project_path: Option<String>,
691 strict_folders: Option<bool>,
693 purpose_level: Option<String>,
695 report_untracked: Option<bool>,
697 strict_untracked: Option<bool>,
699}
700
701#[derive(Debug, Deserialize, schemars::JsonSchema)]
703struct AtlasMcpConfigParams {
704 project_path: Option<String>,
706 server_name: Option<String>,
708 harness: Option<String>,
710 nearest_project: Option<bool>,
712}
713
714pub(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
741pub(crate) fn required_mcp_surface_present() -> bool {
743 REQUIRED_MCP_TOOL_NAMES
744 .iter()
745 .all(|name| mcp_tool_route_present(name))
746}
747
748pub(crate) fn mcp_tool_route_present(name: &str) -> bool {
750 ProjectAtlasMcpServer::tool_router().has_route(name)
751}
752
753#[derive(Debug, Deserialize, schemars::JsonSchema)]
755struct AtlasScanParams {
756 project_path: Option<String>,
758 path: Option<String>,
760 nearest_project: Option<bool>,
762 max_bytes: Option<u64>,
764 max_workers: Option<usize>,
766 timeout_seconds: Option<u64>,
768 text_index_max_bytes: Option<u64>,
770 background: Option<bool>,
772}
773
774#[derive(Debug, Deserialize, schemars::JsonSchema)]
776struct AtlasWatchOnceParams {
777 project_path: Option<String>,
779 path: Option<String>,
781 nearest_project: Option<bool>,
783 max_workers: Option<usize>,
785 timeout_seconds: Option<u64>,
787 text_index_max_bytes: Option<u64>,
789 background: Option<bool>,
791}
792
793#[derive(Debug, Deserialize, schemars::JsonSchema)]
795struct AtlasQueryParams {
796 project_path: Option<String>,
798 query: Option<String>,
800 limit: Option<usize>,
802}
803
804#[derive(Debug, Deserialize, schemars::JsonSchema)]
806struct AtlasFilesParams {
807 project_path: Option<String>,
809 query: Option<String>,
811 folder: Option<String>,
813 nearest_project: Option<bool>,
815 file_pattern: Option<String>,
817 include_content: Option<bool>,
819 limit: Option<usize>,
821}
822
823#[derive(Debug, Deserialize, schemars::JsonSchema)]
825struct AtlasOutlineParams {
826 project_path: Option<String>,
828 file: String,
830 nearest_project: Option<bool>,
832 lines: Option<usize>,
834}
835
836#[derive(Debug, Deserialize, schemars::JsonSchema)]
838struct AtlasFileSummaryParams {
839 project_path: Option<String>,
841 file: String,
843 nearest_project: Option<bool>,
845 compact: Option<bool>,
847 limit: Option<usize>,
849}
850
851#[derive(Debug, Deserialize, schemars::JsonSchema)]
853struct AtlasSearchParams {
854 project_path: Option<String>,
856 pattern: String,
858 retrieval_mode: Option<SearchRetrievalModeArg>,
860 regex: Option<bool>,
862 fuzzy: Option<bool>,
864 case_sensitive: Option<bool>,
866 file_pattern: Option<String>,
868 context_lines: Option<usize>,
870 start_index: Option<usize>,
872 limit: Option<usize>,
874}
875
876#[derive(Debug, Deserialize, schemars::JsonSchema)]
878struct AtlasSliceParams {
879 project_path: Option<String>,
881 file: String,
883 nearest_project: Option<bool>,
885 start_line: Option<usize>,
887 end_line: Option<usize>,
889 symbol: Option<String>,
891 symbol_parent: Option<String>,
893 symbol_kind: Option<String>,
895 symbol_signature: Option<String>,
897 symbol_line: Option<usize>,
899 output_bytes: Option<u32>,
901}
902
903#[derive(Debug, Deserialize, schemars::JsonSchema)]
905struct AtlasSymbolsParams {
906 project_path: Option<String>,
908 file: Option<String>,
910 nearest_project: Option<bool>,
912 query: Option<String>,
914 limit: Option<usize>,
916}
917
918#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
920struct AtlasSymbolRelationsParams {
921 project_path: Option<String>,
923 file: Option<String>,
925 nearest_project: Option<bool>,
927 query: Option<String>,
929 view: Option<String>,
931 compact: Option<bool>,
933 cursor: Option<String>,
935 roots: Option<Vec<String>>,
937 symbol: Option<String>,
939 symbol_parent: Option<String>,
941 symbol_kind: Option<String>,
943 symbol_signature: Option<String>,
945 direction: Option<String>,
947 relation: Option<String>,
949 minimum_confidence: Option<String>,
951 resolution: Option<String>,
953 depth: Option<u32>,
955 include_occurrences: Option<bool>,
957 occurrence_limit: Option<u32>,
959 edge_limit: Option<u32>,
961 node_limit: Option<u32>,
963 visited_limit: Option<u32>,
965 occurrence_total_limit: Option<u32>,
967 intermediate_bytes: Option<u64>,
969 deadline_ms: Option<u64>,
971 output_bytes: Option<u32>,
973 analysis_mode: Option<String>,
975 trace_target: Option<String>,
977 trace_target_file: Option<String>,
979 trace_target_parent: Option<String>,
981 trace_target_kind: Option<String>,
983 trace_target_signature: Option<String>,
985 vcs: Option<String>,
987 vcs_base: Option<String>,
989 vcs_head: Option<String>,
991 include_communities: Option<bool>,
993 include_cycles: Option<bool>,
995 include_dead_code: Option<bool>,
997 limit: Option<usize>,
999}
1000
1001fn 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
1017fn relation_analysis_trace_target(
1019 store: &AtlasStore,
1020 params: &AtlasSymbolRelationsParams,
1021) -> Result<Option<RelationAnchor>, CliError> {
1022 match (¶ms.trace_target, ¶ms.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
1068fn 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#[derive(Debug, Deserialize, schemars::JsonSchema)]
1113struct AtlasTokenParams {
1114 project_path: Option<String>,
1116 session: Option<String>,
1118 include_chart: Option<bool>,
1120 trend_window: Option<String>,
1122 benchmark_results: Option<String>,
1124 theme: Option<String>,
1126}
1127
1128#[derive(Debug, Deserialize, schemars::JsonSchema)]
1130struct AtlasHealthParams {
1131 project_path: Option<String>,
1133 start_index: Option<usize>,
1135 limit: Option<usize>,
1137 category: Option<String>,
1139 severity: Option<String>,
1141 path_prefix: Option<String>,
1143 summary_only: Option<bool>,
1145 source_only: Option<bool>,
1147 include_assets: Option<bool>,
1149 include_low_priority_files: Option<bool>,
1151 coverage: Option<bool>,
1153 parser: Option<String>,
1155 provider: Option<String>,
1157 relation: Option<String>,
1159 coverage_state: Option<String>,
1161 reason: Option<String>,
1163}
1164
1165#[derive(Debug, Deserialize, schemars::JsonSchema)]
1167struct AtlasPurposeQueueParams {
1168 #[serde(flatten)]
1170 health: AtlasHealthParams,
1171 task: Option<String>,
1173}
1174
1175#[derive(Debug, Deserialize, schemars::JsonSchema)]
1177struct AtlasParityParams {
1178 project_path: Option<String>,
1180 profile: Option<String>,
1182}
1183
1184#[derive(Debug, Deserialize, schemars::JsonSchema)]
1186struct AtlasStripLegacyParams {
1187 project_path: Option<String>,
1189 path: Option<String>,
1191 nearest_project: Option<bool>,
1193 apply: Option<bool>,
1195 dry_run: Option<bool>,
1197 strip_source_headers: Option<bool>,
1199}
1200
1201#[derive(Debug, Deserialize, schemars::JsonSchema)]
1203struct AtlasResetIndexParams {
1204 project_path: Option<String>,
1206 apply: Option<bool>,
1208 dry_run: Option<bool>,
1210 include_mcp_config: Option<bool>,
1212}
1213
1214#[derive(Debug, Deserialize, schemars::JsonSchema)]
1216struct AtlasPurposeSetParams {
1217 project_path: Option<String>,
1219 path: String,
1221 purpose: String,
1223}
1224
1225#[derive(Debug, Deserialize, schemars::JsonSchema)]
1227#[schemars(inline)]
1228struct AtlasPurposeReviewItem {
1229 path: String,
1231 purpose: Option<String>,
1233 confirm_existing: Option<bool>,
1235 task: Option<String>,
1237 work_key: Option<String>,
1239 state_token: Option<String>,
1241}
1242
1243#[derive(Debug, Deserialize, schemars::JsonSchema)]
1245struct AtlasPurposeReviewParams {
1246 project_path: Option<String>,
1248 items: Vec<AtlasPurposeReviewItem>,
1250 apply: Option<bool>,
1252}
1253
1254#[derive(Debug, Deserialize, schemars::JsonSchema)]
1256struct AtlasHealthResolveParams {
1257 project_path: Option<String>,
1259 finding_id: String,
1261 category: String,
1263 path: String,
1265 related_path: Option<String>,
1267 rationale: String,
1269}
1270
1271#[derive(Debug, Clone)]
1273struct McpProjectState {
1274 root: PathBuf,
1276 db_path: PathBuf,
1278 config_path: Option<PathBuf>,
1280}
1281
1282enum SymbolRelationStores<'a> {
1284 Single(&'a AtlasStore),
1286 Federated(Vec<FederatedStore>),
1288}
1289
1290impl SymbolRelationStores<'_> {
1291 fn primary(&self) -> &AtlasStore {
1293 match self {
1294 Self::Single(store) => store,
1295 Self::Federated(stores) => stores[0].store(),
1296 }
1297 }
1298}
1299
1300#[derive(Debug, Clone, Eq, Hash, PartialEq)]
1302struct McpUsageProjectBinding {
1303 root: PathBuf,
1305 db_path: PathBuf,
1307 project_instance_id: ProjectInstanceId,
1309}
1310
1311#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1313struct McpSourceTokenBaselineKey {
1314 binding: McpUsageProjectBinding,
1316 generation: projectatlas_core::IndexGeneration,
1318 folder: Option<String>,
1320 file_pattern: Option<String>,
1322}
1323
1324#[derive(Debug)]
1326struct McpUsageIntent {
1327 command: &'static str,
1329 path: Option<String>,
1331 query: Option<String>,
1333 baseline: McpUsageBaseline,
1335}
1336
1337#[derive(Debug)]
1339enum McpUsageBaseline {
1340 Estimate(usize),
1342 DirectoryWalk(usize),
1344 Text(String),
1346}
1347
1348impl McpUsageIntent {
1349 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 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 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 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#[derive(Clone, Debug)]
1404struct McpUsageProjectRuntime {
1405 binding: McpUsageProjectBinding,
1407 instance: Arc<Mutex<UsageRuntimeInstance>>,
1409}
1410
1411#[derive(Debug, Default)]
1413struct McpUsageRuntime {
1414 entries: Vec<McpUsageProjectRuntime>,
1416 source_token_baselines: VecDeque<(McpSourceTokenBaselineKey, usize)>,
1418}
1419
1420impl McpUsageRuntime {
1421 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 fn snapshot(&self) -> Vec<McpUsageProjectRuntime> {
1444 self.entries.clone()
1445 }
1446
1447 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1472enum McpConfigValidation {
1473 Immediate,
1475 Deferred,
1477}
1478
1479#[derive(Debug, Serialize)]
1481struct McpMapReport {
1482 root: String,
1484 map_path: String,
1486 written: bool,
1488 json: bool,
1490 skipped_reason: Option<String>,
1492}
1493
1494#[derive(Debug, Serialize)]
1496struct McpLintReport {
1497 ok: bool,
1499 exit_code: i32,
1501 report: String,
1503}
1504
1505#[derive(Debug, Clone)]
1507struct McpSelectedRoot(PathBuf);
1508
1509impl McpSelectedRoot {
1510 fn from_state(state: &McpProjectState) -> Self {
1512 Self(state.root.clone())
1513 }
1514
1515 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#[derive(Debug, Clone)]
1529struct McpIndexedRoot {
1530 root: PathBuf,
1532 db_path: PathBuf,
1534}
1535
1536#[derive(Debug, Clone)]
1538struct McpAbsolutePath(PathBuf);
1539
1540impl McpAbsolutePath {
1541 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 fn as_path(&self) -> &Path {
1567 &self.0
1568 }
1569
1570 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
1580fn missing_ancestor_error(path: &Path) -> CliError {
1582 CliError::InvalidInput(format!(
1583 "absolute path '{}' has no existing ancestor",
1584 path.display()
1585 ))
1586}
1587
1588#[derive(Debug, Clone)]
1590struct McpRepoKey(String);
1591
1592impl McpRepoKey {
1593 fn into_string(self) -> String {
1595 self.0
1596 }
1597}
1598
1599#[derive(Debug, Clone)]
1601struct McpResolvedRepoPath {
1602 state: McpProjectState,
1604 key: String,
1606 routed_project: bool,
1608}
1609
1610#[derive(Debug, Serialize)]
1612struct McpErrorResponse {
1613 error: McpErrorPayload,
1615}
1616
1617#[derive(Debug, Serialize)]
1619struct McpErrorPayload {
1620 kind: AgentErrorKind,
1622 message: String,
1624 #[serde(skip_serializing_if = "Option::is_none")]
1626 refresh_required: Option<IndexRefreshRequired>,
1627 #[serde(skip_serializing_if = "Option::is_none")]
1629 init_required: Option<IndexInitRequired>,
1630 #[serde(skip_serializing_if = "Option::is_none")]
1632 worktree_required: Option<ProjectWorktreeRequired>,
1633 #[serde(skip_serializing_if = "Option::is_none")]
1635 verification_incomplete: Option<IndexVerificationIncomplete>,
1636 #[serde(skip_serializing_if = "Option::is_none")]
1638 project_mismatch: Option<IndexProjectMismatch>,
1639 #[serde(skip_serializing_if = "Option::is_none")]
1641 database_filesystem: Option<DatabaseFilesystemErrorPayload>,
1642 #[serde(skip_serializing_if = "Option::is_none")]
1644 search_capability: Option<crate::SearchCapabilityErrorPayload>,
1645 #[serde(skip_serializing_if = "Option::is_none")]
1647 next: Option<McpNextCall>,
1648}
1649
1650#[derive(Debug, Serialize)]
1652struct McpNextCall {
1653 tool: &'static str,
1655 project_path: String,
1657}
1658
1659#[derive(Debug, Serialize)]
1661struct McpProjectStateResponse {
1662 project: McpProjectStatePayload,
1664}
1665
1666#[derive(Debug, Serialize)]
1668struct McpProjectStatePayload {
1669 root: String,
1671 db: String,
1673 config: Option<String>,
1675 status: McpProjectStatus,
1677}
1678
1679#[derive(Debug, Serialize)]
1681#[serde(rename_all = "snake_case")]
1682enum McpProjectStatus {
1683 Active,
1685}
1686
1687#[derive(Debug, Serialize)]
1689struct McpPurposeSetResponse {
1690 purpose_set: McpPurposeSetPayload,
1692}
1693
1694#[derive(Debug, Serialize)]
1696struct McpPurposeSetPayload {
1697 path: String,
1699 status: PurposeStatus,
1701 source: PurposeSource,
1703 agent_reviewed: bool,
1705}
1706
1707#[derive(Debug, Serialize)]
1709struct McpSessionCapabilities {
1710 runtime: RuntimeInfoReport,
1712 selected_project: McpSelectedProjectCapability,
1714 startup_policy: McpStartupPolicy,
1716 path_scope: McpPathScope,
1718 scan_policy: McpScanPolicy,
1720 telemetry: McpTelemetryPolicy,
1722 privacy: McpPrivacyPolicy,
1724}
1725
1726#[derive(Debug, Serialize)]
1728struct McpSelectedProjectCapability {
1729 root: String,
1731 db: String,
1733 config: Option<String>,
1735 index_status: McpIndexStatus,
1737}
1738
1739#[derive(Debug, Serialize)]
1741struct McpStartupPolicy {
1742 nearest_project: McpPolicyState,
1744}
1745
1746#[derive(Debug, Serialize)]
1748struct McpScanPolicy {
1749 implicit_scan: McpPolicyState,
1751 text_index_max_bytes: u64,
1753}
1754
1755#[derive(Debug, Serialize)]
1757struct McpTelemetryPolicy {
1758 mode: McpPolicyState,
1760}
1761
1762#[derive(Debug, Serialize)]
1764struct McpPrivacyPolicy {
1765 environment_dump: bool,
1767 secret_values: bool,
1769 projectatlas_paths_only: bool,
1771}
1772
1773#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1775#[serde(rename_all = "snake_case")]
1776enum McpPolicyState {
1777 Enabled,
1779 Disabled,
1781}
1782
1783#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1785#[serde(rename_all = "snake_case")]
1786enum McpIndexStatus {
1787 Available,
1789 Missing,
1791}
1792
1793#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1795#[serde(rename_all = "snake_case")]
1796enum McpPathScope {
1797 SelectedProject,
1799 NearestIndexedProject,
1801}
1802
1803#[derive(Debug, Serialize)]
1805struct McpFileSummaryPayload<'a> {
1806 file_summary: McpFileSummary<'a>,
1808}
1809
1810#[derive(Debug, Serialize)]
1812struct McpFileSummary<'a> {
1813 file_path: &'a str,
1815 language: &'a str,
1817 line_count: usize,
1819 #[serde(skip_serializing_if = "Option::is_none")]
1821 source_status: Option<&'a str>,
1822 #[serde(skip_serializing_if = "Option::is_none")]
1824 source_error: Option<&'a str>,
1825 parser_kind: &'a str,
1827 summary_status: &'a str,
1829 #[serde(skip_serializing_if = "Option::is_none")]
1831 file_purpose: Option<&'a str>,
1832 #[serde(skip_serializing_if = "Option::is_none")]
1834 file_purpose_status: Option<&'a str>,
1835 #[serde(skip_serializing_if = "Option::is_none")]
1837 file_purpose_source: Option<&'a str>,
1838 #[serde(skip_serializing_if = "is_false")]
1840 file_purpose_agent_reviewed: bool,
1841 content_summary: &'a str,
1843 #[serde(skip_serializing_if = "Option::is_none")]
1845 package: Option<&'a str>,
1846 #[serde(skip_serializing_if = "Option::is_none")]
1848 docstring: Option<&'a str>,
1849 #[serde(skip_serializing_if = "is_false")]
1851 truncated: bool,
1852 #[serde(skip_serializing_if = "Option::is_none")]
1854 functions: Option<Vec<McpFileSymbolSummary<'a>>>,
1855 #[serde(skip_serializing_if = "Option::is_none")]
1857 methods: Option<Vec<McpFileSymbolSummary<'a>>>,
1858 #[serde(skip_serializing_if = "Option::is_none")]
1860 classes: Option<Vec<McpFileSymbolSummary<'a>>>,
1861 #[serde(skip_serializing_if = "Option::is_none")]
1863 types: Option<Vec<McpFileSymbolSummary<'a>>>,
1864 #[serde(skip_serializing_if = "Option::is_none")]
1866 imports: Option<&'a [String]>,
1867 #[serde(skip_serializing_if = "Option::is_none")]
1869 dependencies: Option<&'a [String]>,
1870 #[serde(skip_serializing_if = "Option::is_none")]
1872 exports: Option<&'a [String]>,
1873 #[serde(skip_serializing_if = "Option::is_none")]
1875 calls: Option<&'a [FileCallSummary]>,
1876 #[serde(skip_serializing_if = "Option::is_none")]
1878 coverage: Option<McpCompactCoverageDigest<'a>>,
1879}
1880
1881#[derive(Debug, Serialize)]
1883struct McpFileSymbolSummary<'a> {
1884 name: &'a str,
1886 kind: &'a str,
1888 line: usize,
1890 end_line: usize,
1892 signature: &'a str,
1894 exported: bool,
1896 #[serde(skip_serializing_if = "Option::is_none")]
1898 documentation: Option<&'a str>,
1899 #[serde(skip_serializing_if = "Option::is_none")]
1901 parent: Option<&'a str>,
1902 #[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
1923fn 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#[derive(Debug, Serialize)]
1968struct McpCompactCoverageStateCounts {
1969 #[serde(skip_serializing_if = "is_zero_u32")]
1971 complete: u32,
1972 #[serde(skip_serializing_if = "is_zero_u32")]
1974 partial: u32,
1975 #[serde(skip_serializing_if = "is_zero_u32")]
1977 failed: u32,
1978 #[serde(skip_serializing_if = "is_zero_u32")]
1980 ignored: u32,
1981 #[serde(skip_serializing_if = "is_zero_u32")]
1983 oversized: u32,
1984 #[serde(skip_serializing_if = "is_zero_u32")]
1986 quarantined: u32,
1987 #[serde(skip_serializing_if = "is_zero_u32")]
1989 stale: u32,
1990}
1991
1992#[derive(Debug, Serialize)]
1994struct McpCompactCoverageDigest<'a> {
1995 #[serde(skip_serializing_if = "is_true")]
1997 available: bool,
1998 active_generation: &'a IndexGeneration,
2000 #[serde(skip_serializing_if = "Option::is_none")]
2002 parser: Option<&'a ParserKind>,
2003 #[serde(skip_serializing_if = "Option::is_none")]
2005 provider: Option<&'a ParserKind>,
2006 states: McpCompactCoverageStateCounts,
2008 total: u64,
2010 covered: u64,
2012 #[serde(skip_serializing_if = "is_zero_u64")]
2014 omitted: u64,
2015 #[serde(skip_serializing_if = "is_zero_u32")]
2017 relation_rows: u32,
2018 #[serde(skip_serializing_if = "is_false")]
2020 truncated: bool,
2021 trust: &'a CoverageTrustState,
2023 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#[derive(Debug, Serialize)]
2056struct McpCompactDetailedRelationNode<'a> {
2057 selector: &'a EntitySelector,
2059 purpose: &'a RelationPurpose,
2061 #[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#[derive(Debug, Serialize)]
2078#[serde(tag = "status", rename_all = "snake_case")]
2079enum McpCompactRelationResolution<'a> {
2080 Resolved {
2082 selector: &'a ReusableTargetSelector,
2084 generation: IndexGeneration,
2086 },
2087 Ambiguous {
2089 reference: &'a GraphIdentityText,
2091 candidates: u32,
2093 },
2094 Unresolved {
2096 reference: &'a GraphIdentityText,
2098 },
2099 External {
2101 external: &'a ExternalSelector,
2103 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#[derive(Debug, Serialize)]
2141struct McpCompactLogicalRelation<'a> {
2142 kind: GraphRelationKind,
2144 resolution: McpCompactRelationResolution<'a>,
2146 confidence: ConfidenceClass,
2148 completeness: Completeness,
2150 generation: IndexGeneration,
2152}
2153
2154#[derive(Debug, Serialize)]
2156struct McpCompactDetailedRelationRow<'a> {
2157 depth: u32,
2159 direction: RelationDirection,
2161 relation: McpCompactLogicalRelation<'a>,
2163 source: McpCompactDetailedRelationNode<'a>,
2165 #[serde(skip_serializing_if = "Option::is_none")]
2167 target: Option<McpCompactDetailedRelationNode<'a>>,
2168 #[serde(skip_serializing_if = "Option::is_none")]
2170 target_purpose: Option<&'a RelationPurpose>,
2171 #[serde(skip_serializing_if = "Option::is_none")]
2173 path: Option<Vec<&'a EntitySelector>>,
2174 #[serde(skip_serializing_if = "Option::is_none")]
2176 occurrences: Option<Vec<McpCompactRelationOccurrence<'a>>>,
2177 #[serde(skip_serializing_if = "is_false")]
2179 occurrences_truncated: bool,
2180 #[serde(skip_serializing_if = "Option::is_none")]
2182 next_call: Option<&'a RelationNextCall>,
2183}
2184
2185#[derive(Debug, Serialize)]
2187struct McpCompactRelationOccurrence<'a> {
2188 file: &'a RepositoryFilePath,
2190 span: SourceSpan,
2192 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#[derive(Debug, Serialize)]
2240struct McpCompactDetailedRelationReport<'a> {
2241 anchor: McpCompactDetailedRelationNode<'a>,
2243 generation: IndexGeneration,
2245 authored_purpose_revision: u64,
2247 direction: RelationDirection,
2249 returned: u32,
2251 #[serde(skip_serializing_if = "is_zero_u64")]
2253 pruned_paths: u64,
2254 #[serde(skip_serializing_if = "is_false")]
2256 truncated: bool,
2257 #[serde(skip_serializing_if = "Option::is_none")]
2259 next_call: Option<McpCompactRelationContinuationCall<'a>>,
2260 total: &'a RelationTotalState,
2262 #[serde(skip_serializing_if = "Option::is_none")]
2264 reached_limits: Option<&'a [GraphLimitKind]>,
2265 work: &'a DetailedRelationWork,
2267 rows: Vec<McpCompactDetailedRelationRow<'a>>,
2269}
2270
2271#[derive(Debug, Serialize)]
2273struct McpCompactRelationContinuationCall<'a> {
2274 tool: &'static str,
2276 arguments: McpCompactRelationContinuationArguments<'a>,
2278}
2279
2280#[derive(Debug, Serialize)]
2282struct McpCompactRelationContinuationArguments<'a> {
2283 #[serde(skip_serializing_if = "Option::is_none")]
2285 project_path: Option<&'a str>,
2286 file: &'a str,
2288 #[serde(skip_serializing_if = "Option::is_none")]
2290 nearest_project: Option<bool>,
2291 #[serde(skip_serializing_if = "Option::is_none")]
2293 roots: Option<&'a [String]>,
2294 view: &'static str,
2296 compact: bool,
2298 cursor: &'a str,
2300 #[serde(skip_serializing_if = "Option::is_none")]
2302 symbol: Option<&'a str>,
2303 #[serde(skip_serializing_if = "Option::is_none")]
2305 symbol_parent: Option<&'a str>,
2306 #[serde(skip_serializing_if = "Option::is_none")]
2308 symbol_kind: Option<&'a str>,
2309 #[serde(skip_serializing_if = "Option::is_none")]
2311 symbol_signature: Option<&'a str>,
2312 #[serde(skip_serializing_if = "Option::is_none")]
2314 direction: Option<&'a str>,
2315 #[serde(skip_serializing_if = "Option::is_none")]
2317 relation: Option<&'a str>,
2318 #[serde(skip_serializing_if = "Option::is_none")]
2320 minimum_confidence: Option<&'a str>,
2321 #[serde(skip_serializing_if = "Option::is_none")]
2323 resolution: Option<&'a str>,
2324 #[serde(skip_serializing_if = "Option::is_none")]
2326 depth: Option<u32>,
2327 #[serde(skip_serializing_if = "Option::is_none")]
2329 include_occurrences: Option<bool>,
2330 #[serde(skip_serializing_if = "Option::is_none")]
2332 limit: Option<usize>,
2333 #[serde(skip_serializing_if = "Option::is_none")]
2335 occurrence_limit: Option<u32>,
2336 #[serde(skip_serializing_if = "Option::is_none")]
2338 edge_limit: Option<u32>,
2339 #[serde(skip_serializing_if = "Option::is_none")]
2341 node_limit: Option<u32>,
2342 #[serde(skip_serializing_if = "Option::is_none")]
2344 visited_limit: Option<u32>,
2345 #[serde(skip_serializing_if = "Option::is_none")]
2347 occurrence_total_limit: Option<u32>,
2348 #[serde(skip_serializing_if = "Option::is_none")]
2350 intermediate_bytes: Option<u64>,
2351 #[serde(skip_serializing_if = "Option::is_none")]
2353 deadline_ms: Option<u64>,
2354 #[serde(skip_serializing_if = "Option::is_none")]
2356 output_bytes: Option<u32>,
2357}
2358
2359impl<'a> McpCompactDetailedRelationReport<'a> {
2360 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#[derive(Debug, Serialize)]
2421struct McpCompactFederatedDetailedRelationReport<'a> {
2422 participants: &'a [FederatedParticipant],
2424 primary: McpCompactDetailedRelationReport<'a>,
2426 #[serde(skip_serializing_if = "Option::is_none")]
2428 rendezvous: Option<&'a [FederatedRendezvous]>,
2429 #[serde(skip_serializing_if = "is_false")]
2431 truncated: bool,
2432 #[serde(skip_serializing_if = "Option::is_none")]
2434 reached_limits: Option<&'a [GraphLimitKind]>,
2435 work: &'a FederatedRelationWork,
2437}
2438
2439impl<'a> McpCompactFederatedDetailedRelationReport<'a> {
2440 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
2457fn nonempty_str(value: &str) -> Option<&str> {
2459 (!value.is_empty()).then_some(value)
2460}
2461
2462fn nonempty_slice<T>(value: &[T]) -> Option<&[T]> {
2464 (!value.is_empty()).then_some(value)
2465}
2466
2467#[allow(clippy::trivially_copy_pass_by_ref)]
2469const fn is_zero_u32(value: &u32) -> bool {
2470 *value == 0
2471}
2472
2473#[allow(clippy::trivially_copy_pass_by_ref)]
2475const fn is_zero_u64(value: &u64) -> bool {
2476 *value == 0
2477}
2478
2479#[derive(Debug, Serialize)]
2481struct McpSessionBrief {
2482 project: McpSelectedProjectCapability,
2484 policy: McpBriefPolicy,
2486 overview: Option<Overview>,
2488 folders: Vec<McpBriefCandidate>,
2490 files: Vec<McpBriefCandidate>,
2492 blockers: McpBriefBlockers,
2494 purpose_handoff: Option<PurposeCuratorHandoff>,
2496 recommendations: Vec<McpBriefRecommendation>,
2498 limits: McpBriefLimits,
2500}
2501
2502#[derive(Debug, Serialize)]
2504struct McpCompactSessionBrief {
2505 project: McpCompactBriefProject,
2507 #[serde(skip_serializing_if = "Option::is_none")]
2509 policy: Option<McpBriefPolicy>,
2510 #[serde(skip_serializing_if = "Option::is_none")]
2512 overview: Option<McpCompactBriefOverview>,
2513 #[serde(skip_serializing_if = "Vec::is_empty")]
2515 folders: Vec<McpCompactBriefCandidate>,
2516 #[serde(skip_serializing_if = "Vec::is_empty")]
2518 files: Vec<McpCompactBriefCandidate>,
2519 #[serde(skip_serializing_if = "Option::is_none")]
2521 blockers: Option<McpCompactBriefBlockers>,
2522 #[serde(skip_serializing_if = "Option::is_none")]
2524 purpose_handoff: Option<McpCompactBriefPurposeHandoff>,
2525 recommendations: Vec<McpCompactBriefRecommendation>,
2527 #[serde(skip_serializing_if = "Option::is_none")]
2529 limits: Option<McpCompactBriefLimits>,
2530}
2531
2532#[derive(Debug, Serialize)]
2534struct McpCompactBriefProject {
2535 root: String,
2537 index_status: McpIndexStatus,
2539}
2540
2541#[derive(Debug, Serialize)]
2543struct McpCompactBriefOverview {
2544 files: usize,
2546 folders: usize,
2548}
2549
2550#[derive(Clone, Copy, Debug, Serialize)]
2552struct McpBriefPolicy {
2553 nearest_project: McpPolicyState,
2555 path_scope: McpPathScope,
2557}
2558
2559impl McpBriefPolicy {
2560 fn is_default(self) -> bool {
2562 self.nearest_project == McpPolicyState::Disabled
2563 && self.path_scope == McpPathScope::SelectedProject
2564 }
2565}
2566
2567#[derive(Debug, Serialize)]
2569struct McpBriefCandidate {
2570 path: String,
2572 kind: String,
2574 purpose_status: PurposeStatus,
2576 purpose_source: PurposeSource,
2578 purpose_agent_reviewed: bool,
2580 purpose: Option<String>,
2582 summary: Option<String>,
2584 reasons: Vec<String>,
2586 reason_codes: Vec<RankedReasonCode>,
2588 connection_counts: Vec<RankedConnectionCount>,
2590 connections: Vec<RankedConnection>,
2592 connections_truncated: bool,
2594 next_call: NavigationNextCall,
2596}
2597
2598#[derive(Debug, Serialize)]
2600struct McpCompactBriefCandidate {
2601 path: String,
2603 #[serde(skip_serializing_if = "Option::is_none")]
2605 purpose_status: Option<PurposeStatus>,
2606 #[serde(skip_serializing_if = "Option::is_none")]
2608 purpose_source: Option<PurposeSource>,
2609 #[serde(skip_serializing_if = "is_false")]
2611 purpose_agent_reviewed: bool,
2612 #[serde(skip_serializing_if = "Option::is_none")]
2614 purpose: Option<String>,
2615 #[serde(skip_serializing_if = "Vec::is_empty")]
2617 connections: Vec<RankedConnection>,
2618 #[serde(skip_serializing_if = "is_false")]
2620 connections_truncated: bool,
2621 next_call: NavigationNextCall,
2623}
2624
2625#[derive(Debug, Serialize)]
2627#[allow(clippy::struct_excessive_bools)]
2628struct McpCompactBriefPurposeHandoff {
2629 #[serde(skip_serializing_if = "is_true")]
2631 agent_harness_expected: bool,
2632 #[serde(skip_serializing_if = "is_true")]
2634 main_agent_fallback: bool,
2635 #[serde(skip_serializing_if = "is_false")]
2637 server_started_curator: bool,
2638 #[serde(skip_serializing_if = "is_true")]
2640 silent_on_success: bool,
2641 #[serde(skip_serializing_if = "is_false")]
2643 truncated: bool,
2644 next_call: McpCompactBriefRecommendation,
2646}
2647
2648#[derive(Clone, Debug, Serialize)]
2650struct McpBriefBlockers {
2651 total: usize,
2653 returned: usize,
2655 truncated: bool,
2657 items: Vec<McpBriefBlocker>,
2659}
2660
2661#[derive(Debug, Serialize)]
2663struct McpCompactBriefBlockers {
2664 total: usize,
2666}
2667
2668#[allow(clippy::trivially_copy_pass_by_ref)]
2670const fn is_false(value: &bool) -> bool {
2671 !*value
2672}
2673
2674#[allow(clippy::trivially_copy_pass_by_ref)]
2676const fn is_true(value: &bool) -> bool {
2677 *value
2678}
2679
2680#[derive(Clone, Debug, Serialize)]
2682struct McpBriefBlocker {
2683 id: String,
2685 severity: Severity,
2687 category: String,
2689 path: String,
2691 related_path: Option<String>,
2693 message: String,
2695 recommendation: String,
2697}
2698
2699#[derive(Debug, Serialize)]
2701struct McpBriefRecommendation {
2702 kind: McpBriefRecommendationKind,
2704 target: String,
2706 reason: String,
2708 arguments: serde_json::Value,
2710}
2711
2712#[derive(Debug, Serialize)]
2714struct McpCompactBriefRecommendation {
2715 kind: McpBriefRecommendationKind,
2717 target: String,
2719 reason: String,
2721 arguments: serde_json::Value,
2723}
2724
2725#[derive(Clone, Copy, Debug, Serialize)]
2727#[serde(rename_all = "snake_case")]
2728enum McpBriefRecommendationKind {
2729 Init,
2731 Summary,
2733 Search,
2735 Relations,
2737 Health,
2739 PurposeQueue,
2741 FilesystemTools,
2743}
2744
2745#[derive(Debug, Serialize)]
2747struct McpBriefLimits {
2748 folder_limit: usize,
2750 file_limit: usize,
2752 blocker_limit: usize,
2754 purpose_limit: usize,
2756 folders_truncated: bool,
2758 files_truncated: bool,
2760 purposes_truncated: bool,
2762}
2763
2764#[derive(Debug, Serialize)]
2766struct McpCompactBriefLimits {
2767 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
2769 folder_limit: usize,
2770 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
2772 file_limit: usize,
2773 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
2775 blocker_limit: usize,
2776 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
2778 purpose_limit: usize,
2779 #[serde(skip_serializing_if = "is_false")]
2781 folders_truncated: bool,
2782 #[serde(skip_serializing_if = "is_false")]
2784 files_truncated: bool,
2785 #[serde(skip_serializing_if = "is_false")]
2787 purposes_truncated: bool,
2788}
2789
2790#[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#[derive(Debug, Clone)]
2798struct McpTaskRegistry {
2799 records: VecDeque<McpTaskRecord>,
2801}
2802
2803impl McpTaskRegistry {
2804 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 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 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 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#[derive(Debug, Clone, Serialize)]
2876struct McpTaskRecord {
2877 task_id: String,
2879 operation: McpTaskOperation,
2881 state: McpTaskState,
2883 created_at_ms: u128,
2885 updated_at_ms: u128,
2887 progress: Option<McpTaskProgress>,
2889 error: Option<String>,
2891 result_ref: Option<String>,
2893 cancelable: bool,
2895 #[serde(skip)]
2897 control: Option<IndexWorkControl>,
2898}
2899
2900impl McpTaskRecord {
2901 fn is_terminal_state(&self) -> bool {
2903 matches!(
2904 self.state,
2905 McpTaskState::Complete | McpTaskState::Failed | McpTaskState::Canceled
2906 )
2907 }
2908}
2909
2910#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
2912#[serde(rename_all = "snake_case")]
2913enum McpTaskOperation {
2914 Contract,
2916 Scan,
2918 WatchOnce,
2920 SymbolsBuild,
2922 Search,
2924}
2925
2926#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
2928#[serde(rename_all = "snake_case")]
2929enum McpTaskState {
2930 Pending,
2932 Running,
2934 Complete,
2936 Failed,
2938 Canceled,
2940}
2941
2942#[derive(Debug, Clone, Serialize)]
2944struct McpTaskProgress {
2945 current: Option<u64>,
2947 total: Option<u64>,
2949 message: Option<String>,
2951}
2952
2953#[derive(Debug, Serialize)]
2955struct McpTaskStatusResponse {
2956 task_id: String,
2958 lookup: McpTaskLookupStatus,
2960 states: Vec<McpTaskState>,
2962 operations: Vec<McpTaskOperation>,
2964 registry_capacity: usize,
2966 task: Option<McpTaskRecord>,
2968}
2969
2970#[derive(Debug, Eq, PartialEq, Serialize)]
2972#[serde(rename_all = "snake_case")]
2973enum McpTaskLookupStatus {
2974 Found,
2976 NotFound,
2978}
2979
2980#[derive(Debug, Serialize)]
2982struct McpTaskCancelResponse {
2983 task_id: String,
2985 result: McpTaskCancelResult,
2987 registry_capacity: usize,
2989 task: Option<McpTaskRecord>,
2991}
2992
2993#[derive(Debug, Serialize)]
2995struct McpTaskStartResponse {
2996 task_id: String,
2998 operation: McpTaskOperation,
3000 state: McpTaskState,
3002 status_tool: &'static str,
3004 cancel_tool: &'static str,
3006}
3007
3008#[derive(Debug, Eq, PartialEq, Serialize)]
3010#[serde(rename_all = "snake_case")]
3011enum McpTaskCancelResult {
3012 CancellationRequested,
3014 NotFound,
3016 AlreadyFinished,
3018 NotCancelable,
3020}
3021
3022#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3024struct McpBackgroundResourceEnvelope {
3025 task_limit: usize,
3027 workers_per_task: usize,
3029 total_worker_limit: usize,
3031}
3032
3033impl McpBackgroundResourceEnvelope {
3034 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 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#[derive(Debug, Clone)]
3055pub(crate) struct ProjectAtlasMcpServer {
3056 project_state: Arc<RwLock<McpProjectState>>,
3058 session: String,
3060 usage_runtime: Arc<Mutex<McpUsageRuntime>>,
3062 allow_nearest_project: bool,
3064 task_registry: Arc<RwLock<McpTaskRegistry>>,
3066 background_resources: McpBackgroundResourceEnvelope,
3068 next_task_sequence: Arc<AtomicU64>,
3070 source_observations: Arc<SourceObservationRegistry>,
3072 tool_router: ToolRouter<Self>,
3074}
3075
3076struct McpRequestCancellationBridge {
3078 stop: Arc<std::sync::atomic::AtomicBool>,
3080 monitor: Option<thread::JoinHandle<()>>,
3082 context: Option<RequestContext<RoleServer>>,
3084}
3085
3086impl McpRequestCancellationBridge {
3087 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 #[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 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 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 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 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 #[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 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 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 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 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 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 fn open_mut_store(state: &McpProjectState) -> Result<AtlasStore, CliError> {
3322 open_atlas_store_for_project(&state.db_path, &state.root)
3323 }
3324
3325 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 fn telemetry_enabled() -> bool {
3335 !telemetry_disabled()
3336 }
3337
3338 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 if usage_instance.seal(store).is_err() {
3373 return;
3374 }
3375 *usage_instance = next_instance;
3376 drop(record(next_instance));
3377 }
3378
3379 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn path_scope(&self) -> McpPathScope {
3695 if self.allow_nearest_project {
3696 McpPathScope::NearestIndexedProject
3697 } else {
3698 McpPathScope::SelectedProject
3699 }
3700 }
3701
3702 fn policy_state(enabled: bool) -> McpPolicyState {
3704 if enabled {
3705 McpPolicyState::Enabled
3706 } else {
3707 McpPolicyState::Disabled
3708 }
3709 }
3710
3711 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn nearest_project_enabled(&self, override_value: Option<bool>) -> bool {
4579 override_value.unwrap_or(self.allow_nearest_project)
4580 }
4581
4582 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(¤t)
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 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 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 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 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 fn projectatlas_db_path(root: &Path) -> PathBuf {
5128 root.join(PROJECTATLAS_DIR_NAME)
5129 .join(PROJECTATLAS_DB_FILE_NAME)
5130 }
5131
5132 fn projectatlas_nested_config_path(root: &Path) -> PathBuf {
5134 root.join(PROJECTATLAS_DIR_NAME)
5135 .join(PROJECTATLAS_CONFIG_FILE_NAME)
5136 }
5137
5138 fn projectatlas_flat_config_path(root: &Path) -> PathBuf {
5140 root.join(PROJECTATLAS_FLAT_CONFIG_FILE_NAME)
5141 }
5142
5143 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 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 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 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 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 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 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 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 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 fn selected_project_path_error(message: impl std::fmt::Display) -> CliError {
5293 CliError::InvalidInput(format!("{message}; {OUTSIDE_SELECTED_PROJECT_GUIDANCE}"))
5294 }
5295
5296 fn query_or_empty(query: Option<String>) -> String {
5298 query.unwrap_or_default()
5299 }
5300
5301 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 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 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 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 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
5492fn 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
5516fn 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
5525fn 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
5567fn 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
5580fn 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
5588fn 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
5599fn 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 #[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(¶ms.project_path))?;
5622 self.set_active_project_state(state.clone())?;
5623 Self::render_project_state(&state)
5624 })())
5625 }
5626
5627 #[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 #[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 #[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 #[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(¶ms.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 #[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 #[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 #[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 #[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 ¶ms.value,
5770 )?;
5771 Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
5772 })())
5773 }
5774
5775 #[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 ¶ms.value,
5792 )?;
5793 Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
5794 })())
5795 }
5796
5797 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 ¶ms.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 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 ¶ms.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 #[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(¶ms, Some(context))
6131 }
6132
6133 #[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: ¶ms.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 #[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 ¶ms.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 #[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 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 #[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(¶ms, Some(context))
6388 }
6389
6390 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 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 #[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(¶ms, Some(context))
6783 }
6784
6785 #[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(¶ms)?;
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(¶ms) {
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(¶ms, 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 #[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 #[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, ¶ms)?;
6887 Self::encode_named_payload(MCP_PAYLOAD_LINT, &report)
6888 })())
6889 }
6890
6891 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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(¶ms.health, purpose_queue_scope(¶ms.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 #[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, ¶ms.path)?;
7253 store.set_purpose(&node_key, ¶ms.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 #[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
7316fn mcp_unix_time_ms() -> u128 {
7318 SystemTime::now()
7319 .duration_since(UNIX_EPOCH)
7320 .map_or(0, |duration| duration.as_millis())
7321}
7322
7323fn 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
7337fn 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 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 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 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 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}