Skip to main content

code_system_graph/
mcp.rs

1//! MCP stdio delivery adapter.
2
3use std::ffi::OsString;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use code_system_graph_core::{
8    ChangeAnalysisOptions, ChangeImpactReport, ContractReport, ImpactReport, ImpactRequest, LocalContextResult, PullRequestInspection, PullRequestProviderKind, SearchReport
9};
10use code_system_graph_model::{
11    FreshnessSummary, OverallFreshness, ToolEnvelope, ToolStatus, TraceReport
12};
13use code_system_graph_store_sqlite::{SqliteStore, StoreLock};
14use rmcp::handler::server::router::tool::ToolRouter;
15use rmcp::handler::server::wrapper::Parameters;
16use rmcp::model::{
17    Implementation, ListResourcesResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerInfo
18};
19use rmcp::service::{RequestContext, RoleServer};
20use rmcp::{ErrorData as McpError, Json, ServerHandler, tool, tool_handler, tool_router};
21
22use crate::{
23    CODEGRAPH_DISABLED_CODE, CODEGRAPH_DISABLED_MESSAGE, ChangesInput, CommunityReport, ExploreInput, PullRequestInput, ScanOverrides, SearchInput, TraceInput, add_manual_link_to_manifest, add_repository_to_manifest, analyze_workspace_changes, communities_workspace, explore_repository, impact_workspace, impact_workspace_with_codegraph, inspect_pull_request, remove_repository_from_manifest, scan_workspace_with_overrides, search_workspace, trace_workspace
24};
25
26#[path = "mcp_support/mod.rs"]
27mod mcp_support;
28
29use mcp_support::{
30    ADMIN_TOOL_NAMES, AdminAudit, CacheCleanInput, CacheCleanReport, CommunitiesInput, ContractsInput, GraphStatusReport, JSON_MIME_TYPE, ManifestAdminReport, ManualLinkWriteInput, ResourceErrorKind, SourceContextInput, SourceContextReport, WorkspaceInput, WorkspaceUpdateInput, admin_audit_envelope, admin_mutation_envelope, configured_manifest_path, contracts_envelope, read_resource, resource_uris, source_context_envelope, status_envelope
31};
32
33const EXPLORE_TOOL_NAME: &str = "explore";
34
35/// Trusted process-level policy for bounded local intelligence.
36#[derive(Debug, Clone, Default)]
37struct CodeGraphPolicy {
38    enabled: bool,
39    binary: Option<OsString>,
40}
41
42/// Workspace-scoped `Code System Graph` MCP server with an optional explicit admin profile.
43#[derive(Debug, Clone)]
44pub struct CodeSystemGraphServer {
45    database_path: PathBuf,
46    workspace: String,
47    admin_enabled: bool,
48    codegraph: CodeGraphPolicy,
49    github_pull_requests_enabled: bool,
50    bitbucket_pull_requests_enabled: bool,
51    tool_router: ToolRouter<Self>,
52}
53
54#[tool_router(router = tool_router)]
55impl CodeSystemGraphServer {
56    /// Creates a read-only server for one configured workspace.
57    #[must_use]
58    pub fn new(database_path: PathBuf, workspace: String) -> Self {
59        let mut tool_router = Self::tool_router();
60        for name in ADMIN_TOOL_NAMES {
61            tool_router.disable_route(name);
62        }
63        tool_router.disable_route(EXPLORE_TOOL_NAME);
64        Self {
65            database_path,
66            workspace,
67            admin_enabled: false,
68            codegraph: CodeGraphPolicy::default(),
69            github_pull_requests_enabled: false,
70            bitbucket_pull_requests_enabled: false,
71            tool_router,
72        }
73    }
74
75    /// Enables or disables the explicit administrative MCP profile.
76    ///
77    /// Disabled routes are absent from discovery and cannot be called. Applications should map
78    /// their trusted `CODE_SYSTEM_GRAPH_MCP_ADMIN=1`-equivalent configuration to this constructor option;
79    /// request arguments can never enable the profile.
80    #[must_use]
81    pub fn with_admin_profile(mut self, enabled: bool) -> Self {
82        self.admin_enabled = enabled;
83        for name in ADMIN_TOOL_NAMES {
84            if enabled {
85                self.tool_router.enable_route(name);
86            } else {
87                self.tool_router.disable_route(name);
88            }
89        }
90        self
91    }
92
93    /// Applies trusted process-level `CodeGraph` policy to exploration, impact, and scans.
94    #[must_use]
95    pub fn with_codegraph(mut self, enabled: bool, binary: Option<OsString>) -> Self {
96        if enabled {
97            self.tool_router.enable_route(EXPLORE_TOOL_NAME);
98        } else {
99            self.tool_router.disable_route(EXPLORE_TOOL_NAME);
100        }
101        self.codegraph = CodeGraphPolicy { enabled, binary };
102        self
103    }
104
105    /// Enables selected public pull-request providers while retaining per-request consent.
106    #[must_use]
107    pub fn with_pull_request_providers(mut self, github: bool, bitbucket: bool) -> Self {
108        self.github_pull_requests_enabled = github;
109        self.bitbucket_pull_requests_enabled = bitbucket;
110        self
111    }
112
113    /// Traces a bounded path through the current federated snapshot.
114    #[tool(
115        name = "trace",
116        description = "Finds a bounded, explainable path between two persisted entities across repository boundaries. Use when both endpoint identifiers are known; use query first to discover identifiers. Returns a versioned ToolEnvelope containing trace segments and freshness metadata.",
117        annotations(
118            title = "Cross-repository path trace",
119            read_only_hint = true,
120            destructive_hint = false,
121            idempotent_hint = true,
122            open_world_hint = false
123        )
124    )]
125    pub async fn trace(
126        &self,
127        Parameters(input): Parameters<TraceInput>,
128    ) -> Json<ToolEnvelope<TraceReport>> {
129        match trace_workspace(&self.database_path, &self.workspace, &input) {
130            Ok(envelope) => Json(envelope),
131            Err(error) => Json(ToolEnvelope {
132                schema_version: 1,
133                status: ToolStatus::Error,
134                data: None,
135                freshness: FreshnessSummary {
136                    overall: OverallFreshness::Unknown,
137                    stale_repositories: Vec::new(),
138                    reasons: vec!["Trace inputs could not be validated.".to_owned()],
139                },
140                warnings: vec![error.to_string()],
141            }),
142        }
143    }
144
145    /// Searches ranked federated entities without returning source bodies.
146    #[tool(
147        name = "query",
148        description = "Searches persisted architecture entities, contracts, and communities across the workspace without returning source bodies. Use to discover entity identifiers before trace, source_context, or impact. Returns a versioned ToolEnvelope containing ranked matches and freshness metadata.",
149        annotations(
150            title = "Federated entity search",
151            read_only_hint = true,
152            destructive_hint = false,
153            idempotent_hint = true,
154            open_world_hint = false
155        )
156    )]
157    pub async fn query(
158        &self,
159        Parameters(input): Parameters<SearchInput>,
160    ) -> Json<ToolEnvelope<SearchReport>> {
161        match search_workspace(&self.database_path, &self.workspace, &input) {
162            Ok(envelope) => Json(envelope),
163            Err(error) => Json(error_envelope(
164                "Query inputs could not be validated.",
165                error,
166            )),
167        }
168    }
169
170    /// Explores bounded repository-local source and flow context without persisting source.
171    #[tool(
172        name = "explore",
173        description = "Retrieves bounded, ephemeral repository-local source and call-flow context through CodeGraph. Use for symbols, callers, callees, tests, and implementation details; use query for persisted cross-repository entities. Returns a versioned ToolEnvelope containing source-bearing local context that is never persisted.",
174        annotations(
175            title = "Repository source exploration",
176            read_only_hint = true,
177            destructive_hint = false,
178            idempotent_hint = true,
179            open_world_hint = false
180        )
181    )]
182    pub async fn explore(
183        &self,
184        Parameters(input): Parameters<ExploreInput>,
185    ) -> Result<Json<ToolEnvelope<LocalContextResult>>, McpError> {
186        if !self.codegraph.enabled {
187            return Err(codegraph_disabled_error());
188        }
189        Ok(Json(
190            explore_repository(
191                &self.database_path,
192                &self.workspace,
193                &input,
194                self.codegraph.binary.clone(),
195            )
196            .await,
197        ))
198    }
199
200    /// Lists, inspects, or compares persisted deterministic communities.
201    #[tool(
202        name = "communities",
203        description = "Lists, shows, or compares deterministic persisted communities using one explicit action. Use for inferred service boundaries, memberships, metrics, and snapshot comparisons; use query for general entity search. Returns a versioned ToolEnvelope containing a CommunityReport and freshness metadata.",
204        annotations(
205            title = "Community inspection",
206            read_only_hint = true,
207            destructive_hint = false,
208            idempotent_hint = true,
209            open_world_hint = false
210        )
211    )]
212    pub async fn communities(
213        &self,
214        Parameters(input): Parameters<CommunitiesInput>,
215    ) -> Json<ToolEnvelope<CommunityReport>> {
216        if input.workspace() != self.workspace {
217            return Json(error_envelope(
218                "Workspace policy rejected the request.",
219                format!(
220                    "workspace `{}` is outside this server's configured workspace `{}`",
221                    input.workspace(),
222                    self.workspace
223                ),
224            ));
225        }
226        match communities_workspace(
227            &self.database_path,
228            &self.workspace,
229            &input.application_input(),
230        ) {
231            Ok(envelope) => Json(envelope),
232            Err(error) => Json(error_envelope(
233                "Community inputs could not be validated.",
234                error,
235            )),
236        }
237    }
238
239    /// Computes conservative impact without executing tests or repository commands.
240    #[tool(
241        name = "impact",
242        description = "Analyzes bounded upstream or downstream effects and conservative risk for one persisted graph target across repositories. Use for a known entity; use analyze_changes for staged, worktree, or committed Git changes. Returns a versioned ToolEnvelope containing an ImpactReport, freshness metadata, and optional ephemeral CodeGraph enrichment.",
243        annotations(
244            title = "Cross-repository impact analysis",
245            read_only_hint = true,
246            destructive_hint = false,
247            idempotent_hint = true,
248            open_world_hint = false
249        )
250    )]
251    pub async fn impact(
252        &self,
253        Parameters(input): Parameters<ImpactRequest>,
254    ) -> Json<ToolEnvelope<ImpactReport>> {
255        let result = if self.codegraph.enabled {
256            impact_workspace_with_codegraph(
257                &self.database_path,
258                &self.workspace,
259                &input,
260                self.codegraph.binary.clone(),
261            )
262            .await
263        } else {
264            impact_workspace(&self.database_path, &self.workspace, &input)
265        };
266        match result {
267            Ok(envelope) => Json(envelope),
268            Err(error) => Json(error_envelope(
269                "Impact inputs could not be validated.",
270                error,
271            )),
272        }
273    }
274
275    /// Inspects bounded local Git changes without modifying repository state.
276    #[tool(
277        name = "analyze_changes",
278        description = "Analyzes fingerprinted staged, worktree, or committed local Git changes and maps them to conservative graph impact. Use for repository diffs; use impact for one known persisted target. Returns a versioned ToolEnvelope containing a ChangeImpactReport and freshness metadata without modifying Git state.",
279        annotations(
280            title = "Local change impact analysis",
281            read_only_hint = true,
282            destructive_hint = false,
283            idempotent_hint = true,
284            open_world_hint = false
285        )
286    )]
287    pub async fn analyze_changes(
288        &self,
289        Parameters(input): Parameters<ChangesInput>,
290    ) -> Json<ToolEnvelope<ChangeImpactReport>> {
291        match analyze_workspace_changes(
292            &self.database_path,
293            &self.workspace,
294            &input,
295            &ChangeAnalysisOptions::default(),
296            None,
297        )
298        .await
299        {
300            Ok(envelope) => Json(envelope),
301            Err(error) => Json(error_envelope(
302                "Change inputs could not be validated.",
303                error,
304            )),
305        }
306    }
307
308    /// Inspects one explicitly enabled public pull-request provider.
309    #[tool(
310        name = "analyze_pull_request",
311        description = "Fetches and analyzes one consented GitHub or Bitbucket Cloud pull request from a provider enabled at server startup. Use for remote pull-request metadata and changed-file context; use analyze_changes for local Git state. Returns a versioned ToolEnvelope containing a PullRequestInspection and provider freshness metadata.",
312        annotations(
313            title = "Pull request analysis",
314            read_only_hint = true,
315            destructive_hint = false,
316            idempotent_hint = true,
317            open_world_hint = true
318        )
319    )]
320    pub async fn analyze_pull_request(
321        &self,
322        Parameters(input): Parameters<PullRequestInput>,
323    ) -> Json<ToolEnvelope<PullRequestInspection>> {
324        let (enabled, token, basic_auth_username) = match input.provider {
325            PullRequestProviderKind::GitHub => (
326                self.github_pull_requests_enabled,
327                std::env::var("GITHUB_TOKEN").ok(),
328                None,
329            ),
330            PullRequestProviderKind::BitbucketCloud => (
331                self.bitbucket_pull_requests_enabled,
332                std::env::var("BITBUCKET_TOKEN").ok(),
333                std::env::var("BITBUCKET_USER").ok(),
334            ),
335            PullRequestProviderKind::BitbucketDataCenter => (false, None, None),
336        };
337        match inspect_pull_request(&input, enabled, token, basic_auth_username).await {
338            Ok(envelope) => Json(envelope),
339            Err(error) => Json(error_envelope(
340                "Pull-request inputs or provider response could not be validated.",
341                error,
342            )),
343        }
344    }
345
346    /// Reports persisted snapshot health without reading repository source files.
347    #[tool(
348        name = "status",
349        description = "Reports persisted graph health, freshness, and current snapshot metadata for the configured workspace. Use to verify workspace readiness before other analysis; not for entity search. Returns a versioned ToolEnvelope containing a source-free GraphStatusReport.",
350        annotations(
351            title = "Workspace graph status",
352            read_only_hint = true,
353            destructive_hint = false,
354            idempotent_hint = true,
355            open_world_hint = false
356        )
357    )]
358    pub async fn status(
359        &self,
360        Parameters(input): Parameters<WorkspaceInput>,
361    ) -> Json<ToolEnvelope<GraphStatusReport>> {
362        Json(status_envelope(
363            &self.database_path,
364            &self.workspace,
365            &input.workspace,
366        ))
367    }
368
369    /// Lists a bounded page of contract entities from the immutable graph snapshot.
370    #[tool(
371        name = "contracts",
372        description = "Lists, shows, validates, diffs, or explains persisted contracts using one explicit action. Use for API, event, database, package, and infrastructure contract analysis; use query for non-contract entities. Returns a versioned ToolEnvelope containing a ContractReport and freshness metadata.",
373        annotations(
374            title = "Contract inspection and validation",
375            read_only_hint = true,
376            destructive_hint = false,
377            idempotent_hint = true,
378            open_world_hint = false
379        )
380    )]
381    pub async fn contracts(
382        &self,
383        Parameters(input): Parameters<ContractsInput>,
384    ) -> Json<ToolEnvelope<ContractReport>> {
385        Json(contracts_envelope(
386            &self.database_path,
387            &self.workspace,
388            &input,
389        ))
390    }
391
392    /// Returns bounded persisted graph and evidence metadata for one entity.
393    #[tool(
394        name = "source_context",
395        description = "Returns bounded, source-free persisted graph context and evidence metadata for one exact entity. Use after query to explain relationships and provenance without source bodies. It does not return implementation source. Returns a versioned ToolEnvelope containing a SourceContextReport and freshness metadata.",
396        annotations(
397            title = "Persisted entity context",
398            read_only_hint = true,
399            destructive_hint = false,
400            idempotent_hint = true,
401            open_world_hint = false
402        )
403    )]
404    pub async fn source_context(
405        &self,
406        Parameters(input): Parameters<SourceContextInput>,
407    ) -> Json<ToolEnvelope<SourceContextReport>> {
408        Json(source_context_envelope(
409            &self.database_path,
410            &self.workspace,
411            &input,
412        ))
413    }
414
415    /// Scans and atomically publishes the configured workspace.
416    #[tool(
417        name = "scan",
418        description = "Scans the configured workspace and atomically publishes a new persisted graph snapshot. Use only in the enabled admin profile after repository or configuration changes; use status for a read-only health check. Returns an audited ToolEnvelope containing a ScanSummary, snapshot identity, freshness, and visible CodeGraph degradations.",
419        annotations(
420            title = "Publish workspace snapshot",
421            read_only_hint = false,
422            destructive_hint = true,
423            idempotent_hint = false,
424            open_world_hint = false
425        )
426    )]
427    pub async fn scan(
428        &self,
429        Parameters(input): Parameters<WorkspaceInput>,
430    ) -> Json<ToolEnvelope<AdminAudit<crate::ScanSummary>>> {
431        Json(self.run_admin_scan(&input.workspace, "scan"))
432    }
433
434    /// Adds or removes one repository entry through the constrained manifest editor.
435    #[tool(
436        name = "update_workspace",
437        description = "Adds or removes one repository registration in the configured workspace manifest, then validates and rescans it. Use only in the enabled admin profile for constrained workspace membership changes; use scan when membership is unchanged. Returns an audited ToolEnvelope containing the manifest mutation, backup metadata, and ScanSummary.",
438        annotations(
439            title = "Update workspace repositories",
440            read_only_hint = false,
441            destructive_hint = true,
442            idempotent_hint = false,
443            open_world_hint = false
444        )
445    )]
446    pub async fn update_workspace(
447        &self,
448        Parameters(input): Parameters<WorkspaceUpdateInput>,
449    ) -> Json<ToolEnvelope<AdminAudit<ManifestAdminReport>>> {
450        Json(self.run_workspace_update(&input))
451    }
452
453    /// Appends one exact manual relationship declaration and republishes the workspace.
454    #[tool(
455        name = "write_manual_link",
456        description = "Adds or suppresses one exact manual graph relationship in the configured manifest, then validates and rescans it. Use only in the enabled admin profile when an operator must record a reasoned relationship decision. Returns an audited ToolEnvelope containing the manifest mutation, backup metadata, and ScanSummary.",
457        annotations(
458            title = "Write manual relationship",
459            read_only_hint = false,
460            destructive_hint = true,
461            idempotent_hint = false,
462            open_world_hint = false
463        )
464    )]
465    pub async fn write_manual_link(
466        &self,
467        Parameters(input): Parameters<ManualLinkWriteInput>,
468    ) -> Json<ToolEnvelope<AdminAudit<ManifestAdminReport>>> {
469        Json(self.run_manual_link_write(&input))
470    }
471
472    /// Removes bounded reusable query results for the configured workspace.
473    #[tool(
474        name = "clean_cache",
475        description = "Removes reusable query-summary cache entries for the configured workspace without changing graph snapshots. Use only in the enabled admin profile to invalidate cached query results; not to rescan source. Returns an audited ToolEnvelope containing the number of removed entries and current snapshot identity.",
476        annotations(
477            title = "Clear query cache",
478            read_only_hint = false,
479            destructive_hint = true,
480            idempotent_hint = true,
481            open_world_hint = false
482        )
483    )]
484    pub async fn clean_cache(
485        &self,
486        Parameters(input): Parameters<CacheCleanInput>,
487    ) -> Json<ToolEnvelope<AdminAudit<CacheCleanReport>>> {
488        Json(self.run_cache_clean(&input))
489    }
490
491    /// Recomputes communities by publishing a freshly analyzed workspace snapshot.
492    #[tool(
493        name = "recompute_communities",
494        description = "Rescans the configured workspace and deterministically recomputes communities in a newly published snapshot. Use only in the enabled admin profile when community results must be refreshed; use communities for read-only inspection. Returns an audited ToolEnvelope containing the ScanSummary, snapshot identity, and freshness metadata.",
495        annotations(
496            title = "Recompute workspace communities",
497            read_only_hint = false,
498            destructive_hint = true,
499            idempotent_hint = false,
500            open_world_hint = false
501        )
502    )]
503    pub async fn recompute_communities(
504        &self,
505        Parameters(input): Parameters<WorkspaceInput>,
506    ) -> Json<ToolEnvelope<AdminAudit<crate::ScanSummary>>> {
507        Json(self.run_admin_scan(&input.workspace, "community_recompute"))
508    }
509
510    fn run_admin_scan(
511        &self,
512        workspace: &str,
513        operation: &str,
514    ) -> ToolEnvelope<AdminAudit<crate::ScanSummary>> {
515        if let Err(error) = self.validate_admin_workspace(workspace) {
516            return error;
517        }
518        let manifest = match configured_manifest_path(&self.database_path, &self.workspace) {
519            Ok(path) => path,
520            Err(error) => {
521                return error_envelope("The configured manifest path could not be loaded.", error);
522            }
523        };
524        match scan_workspace_with_overrides(
525            &manifest,
526            &self.database_path,
527            &ScanOverrides {
528                workspace: Some(workspace.to_owned()),
529                codegraph: self.codegraph.enabled,
530                codegraph_binary: self.codegraph.binary.as_ref().map(PathBuf::from),
531                ..ScanOverrides::default()
532            },
533        ) {
534            Ok(summary) => {
535                admin_audit_envelope(&self.database_path, &self.workspace, operation, summary)
536            }
537            Err(error) => error_envelope("Administrative scan failed.", error),
538        }
539    }
540
541    fn run_workspace_update(
542        &self,
543        input: &WorkspaceUpdateInput,
544    ) -> ToolEnvelope<AdminAudit<ManifestAdminReport>> {
545        if let Err(error) = self.validate_admin_workspace(input.workspace()) {
546            return error;
547        }
548        let manifest = match configured_manifest_path(&self.database_path, &self.workspace) {
549            Ok(path) => path,
550            Err(error) => {
551                return error_envelope("The configured manifest path could not be loaded.", error);
552            }
553        };
554        let mutation_lock = match StoreLock::acquire(&self.database_path, Duration::from_mins(5)) {
555            Ok(lock) => lock,
556            Err(error) => {
557                return error_envelope("Workspace update lock acquisition failed.", error);
558            }
559        };
560        let mutation = match input.repository_path() {
561            Some(path) => add_repository_to_manifest(&manifest, input.alias(), path, false),
562            None => remove_repository_from_manifest(&manifest, input.alias(), false),
563        };
564        let mutation = match mutation {
565            Ok(summary) => summary,
566            Err(error) => return error_envelope("Workspace manifest update failed.", error),
567        };
568        drop(mutation_lock);
569        let scan = match scan_workspace_with_overrides(
570            &manifest,
571            &self.database_path,
572            &ScanOverrides {
573                workspace: Some(self.workspace.clone()),
574                codegraph: self.codegraph.enabled,
575                codegraph_binary: self.codegraph.binary.as_ref().map(PathBuf::from),
576                ..ScanOverrides::default()
577            },
578        ) {
579            Ok(summary) => summary,
580            Err(error) => {
581                return error_envelope(
582                    "Workspace manifest changed but the follow-up scan failed; use the recorded backup to restore if needed.",
583                    error,
584                );
585            }
586        };
587        let snapshot_id = scan.snapshot_id.clone();
588        admin_mutation_envelope(
589            &self.database_path,
590            &self.workspace,
591            "workspace_update",
592            &snapshot_id,
593            ManifestAdminReport { mutation, scan },
594        )
595    }
596
597    fn run_manual_link_write(
598        &self,
599        input: &ManualLinkWriteInput,
600    ) -> ToolEnvelope<AdminAudit<ManifestAdminReport>> {
601        if let Err(error) = self.validate_admin_workspace(input.workspace()) {
602            return error;
603        }
604        let manifest = match configured_manifest_path(&self.database_path, &self.workspace) {
605            Ok(path) => path,
606            Err(error) => {
607                return error_envelope("The configured manifest path could not be loaded.", error);
608            }
609        };
610        let mutation_lock = match StoreLock::acquire(&self.database_path, Duration::from_mins(5)) {
611            Ok(lock) => lock,
612            Err(error) => {
613                return error_envelope("Manual-link write lock acquisition failed.", error);
614            }
615        };
616        let declaration = input.declaration();
617        let mutation = match add_manual_link_to_manifest(&manifest, &declaration, false) {
618            Ok(summary) => summary,
619            Err(error) => return error_envelope("Manual-link manifest update failed.", error),
620        };
621        drop(mutation_lock);
622        let scan = match scan_workspace_with_overrides(
623            &manifest,
624            &self.database_path,
625            &ScanOverrides {
626                workspace: Some(self.workspace.clone()),
627                codegraph: self.codegraph.enabled,
628                codegraph_binary: self.codegraph.binary.as_ref().map(PathBuf::from),
629                ..ScanOverrides::default()
630            },
631        ) {
632            Ok(summary) => summary,
633            Err(error) => {
634                return error_envelope(
635                    "Manual-link manifest changed but the follow-up scan failed; use the recorded backup to restore if needed.",
636                    error,
637                );
638            }
639        };
640        let snapshot_id = scan.snapshot_id.clone();
641        admin_mutation_envelope(
642            &self.database_path,
643            &self.workspace,
644            "manual_link_write",
645            &snapshot_id,
646            ManifestAdminReport { mutation, scan },
647        )
648    }
649
650    fn run_cache_clean(
651        &self,
652        input: &CacheCleanInput,
653    ) -> ToolEnvelope<AdminAudit<CacheCleanReport>> {
654        if let Err(error) = self.validate_admin_workspace(&input.workspace) {
655            return error;
656        }
657        let _lock = match StoreLock::acquire(&self.database_path, Duration::from_mins(5)) {
658            Ok(lock) => lock,
659            Err(error) => return error_envelope("Query cache lock acquisition failed.", error),
660        };
661        let mut store = match SqliteStore::open(&self.database_path) {
662            Ok(store) => store,
663            Err(error) => return error_envelope("Query cache store could not be opened.", error),
664        };
665        let snapshot = match store.current_snapshot_summary(&self.workspace) {
666            Ok(snapshot) => snapshot,
667            Err(error) => {
668                return error_envelope("Current snapshot could not be resolved.", error);
669            }
670        };
671        let removed_entries = match store.clear_query_cache(&self.workspace) {
672            Ok(count) => count,
673            Err(error) => return error_envelope("Query cache cleanup failed.", error),
674        };
675        admin_mutation_envelope(
676            &self.database_path,
677            &self.workspace,
678            "cache_clean",
679            &snapshot.snapshot_id,
680            CacheCleanReport { removed_entries },
681        )
682    }
683
684    fn validate_admin_workspace<T>(&self, workspace: &str) -> Result<(), ToolEnvelope<T>> {
685        if !self.admin_enabled {
686            return Err(error_envelope(
687                "Administrative profile is disabled.",
688                "set the trusted constructor admin option before server startup",
689            ));
690        }
691        if workspace != self.workspace {
692            return Err(error_envelope(
693                "Workspace policy rejected the request.",
694                format!(
695                    "workspace `{workspace}` is outside this server's configured `{}` policy",
696                    self.workspace
697                ),
698            ));
699        }
700        Ok(())
701    }
702}
703
704fn error_envelope<T>(reason: &str, error: impl std::fmt::Display) -> ToolEnvelope<T> {
705    ToolEnvelope {
706        schema_version: 1,
707        status: ToolStatus::Error,
708        data: None,
709        freshness: FreshnessSummary {
710            overall: OverallFreshness::Unknown,
711            stale_repositories: Vec::new(),
712            reasons: vec![reason.to_owned()],
713        },
714        warnings: vec![error.to_string()],
715    }
716}
717
718fn codegraph_disabled_error() -> McpError {
719    McpError::invalid_request(
720        CODEGRAPH_DISABLED_MESSAGE,
721        Some(serde_json::json!({ "code": CODEGRAPH_DISABLED_CODE })),
722    )
723}
724
725#[allow(unknown_lints)]
726#[allow(
727    clippy::unused_async_trait_impl,
728    reason = "rmcp requires async trait methods even when resource reads complete synchronously"
729)]
730#[tool_handler(router = self.tool_router)]
731impl ServerHandler for CodeSystemGraphServer {
732    fn get_info(&self) -> ServerInfo {
733        let instructions = if self.codegraph.enabled {
734            "Code System Graph exposes bounded cross-repository intelligence. Tool and resource results \
735             are versioned JSON; use query for persisted entity discovery, explore for ephemeral \
736             repository source, source_context for source-free evidence, impact for known targets, \
737             and analyze_changes for Git diffs. Administrative tools mutate state only when enabled."
738        } else {
739            "Code System Graph exposes bounded cross-repository intelligence. Tool and resource results \
740             are versioned JSON; use query for persisted entity discovery, source_context for source-free \
741             evidence, impact for known targets, and analyze_changes for Git diffs. Repository-local \
742             source access is unavailable because CodeGraph is disabled. Administrative tools mutate \
743             state only when enabled."
744        };
745        ServerInfo::new(
746            ServerCapabilities::builder()
747                .enable_tools()
748                .enable_resources()
749                .build(),
750        )
751        .with_server_info(Implementation::new(
752            "code_system_graph",
753            env!("CARGO_PKG_VERSION"),
754        ))
755        .with_instructions(instructions)
756    }
757
758    async fn list_resources(
759        &self,
760        _request: Option<PaginatedRequestParams>,
761        _context: RequestContext<RoleServer>,
762    ) -> Result<ListResourcesResult, McpError> {
763        let resources = resource_uris(&self.workspace)
764            .into_iter()
765            .map(|(uri, name, description)| {
766                Resource::new(uri, name)
767                    .with_description(description)
768                    .with_mime_type(JSON_MIME_TYPE)
769            })
770            .collect();
771        Ok(ListResourcesResult::with_all_items(resources).with_ttl_ms(1_000))
772    }
773
774    async fn read_resource(
775        &self,
776        request: ReadResourceRequestParams,
777        _context: RequestContext<RoleServer>,
778    ) -> Result<ReadResourceResponse, McpError> {
779        let uri = request.uri;
780        match read_resource(&self.database_path, &self.workspace, &uri) {
781            Ok(text) => Ok(ReadResourceResult::new(vec![
782                ResourceContents::text(text, uri).with_mime_type(JSON_MIME_TYPE),
783            ])
784            .into()),
785            Err(error) => match error.kind {
786                ResourceErrorKind::Invalid => Err(McpError::invalid_params(error.message, None)),
787                ResourceErrorKind::Missing => {
788                    Err(McpError::resource_not_found(error.message, None))
789                }
790                ResourceErrorKind::Internal => Err(McpError::internal_error(error.message, None)),
791            },
792        }
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    #[cfg(unix)]
799    use std::os::unix::fs::PermissionsExt;
800    use std::path::PathBuf;
801
802    use code_system_graph_store_sqlite::SqliteStore;
803    use rmcp::ServerHandler;
804    #[cfg(unix)]
805    use rmcp::handler::server::wrapper::Parameters;
806
807    use super::{CodeSystemGraphServer, mcp_support};
808    #[cfg(unix)]
809    use crate::{CODEGRAPH_DISABLED_CODE, ExploreInput, scan_workspace};
810
811    #[test]
812    fn server_should_publish_read_only_tools() {
813        let server = CodeSystemGraphServer::new(PathBuf::from("graph.db"), "commerce".to_owned())
814            .with_codegraph(true, None);
815        let tools = server.tool_router.list_all();
816        let names = tools
817            .iter()
818            .map(|tool| tool.name.as_ref())
819            .collect::<Vec<_>>();
820
821        assert_eq!(
822            names,
823            vec![
824                "analyze_changes",
825                "analyze_pull_request",
826                "communities",
827                "contracts",
828                "explore",
829                "impact",
830                "query",
831                "source_context",
832                "status",
833                "trace"
834            ]
835        );
836        assert!(tools.iter().all(|tool| {
837            tool.annotations.as_ref().is_some_and(|annotations| {
838                annotations.read_only_hint == Some(true)
839                    && annotations.destructive_hint == Some(false)
840            })
841        }));
842    }
843
844    #[test]
845    fn explore_should_be_hidden_until_codegraph_is_enabled() {
846        let disabled = CodeSystemGraphServer::new(PathBuf::from("graph.db"), "commerce".to_owned());
847        let enabled = disabled.clone().with_codegraph(true, None);
848
849        assert!(
850            disabled
851                .tool_router
852                .list_all()
853                .iter()
854                .all(|tool| tool.name != "explore")
855        );
856        assert!(
857            enabled
858                .tool_router
859                .list_all()
860                .iter()
861                .any(|tool| tool.name == "explore")
862        );
863    }
864
865    #[cfg(unix)]
866    #[tokio::test]
867    async fn explore_handler_should_reject_disabled_codegraph_before_execution()
868    -> Result<(), Box<dyn std::error::Error>> {
869        let temporary = tempfile::tempdir()?;
870        let database = temporary.path().join("graph.db");
871        let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
872            .join("../../fixtures/platform-demo/code-system-graph.yaml");
873        scan_workspace(&manifest, &database)?;
874        let binary = temporary.path().join("codegraph-marker");
875        let marker = temporary.path().join("codegraph-invoked");
876        std::fs::write(
877            &binary,
878            "#!/bin/sh\n: > \"$(dirname \"$0\")/codegraph-invoked\"\nexit 1\n",
879        )?;
880        let mut permissions = std::fs::metadata(&binary)?.permissions();
881        permissions.set_mode(0o755);
882        std::fs::set_permissions(&binary, permissions)?;
883        let server = CodeSystemGraphServer::new(database, "commerce-platform".to_owned())
884            .with_codegraph(false, Some(binary.into_os_string()));
885
886        let result = server
887            .explore(Parameters(ExploreInput {
888                workspace: "commerce-platform".to_owned(),
889                repository: Some("orders".to_owned()),
890                query: "create_order callers".to_owned(),
891                max_files: 4,
892            }))
893            .await;
894        let Err(error) = result else {
895            panic!("disabled CodeGraph policy must reject direct handler calls");
896        };
897
898        assert_eq!(
899            (
900                error.data.as_ref().and_then(|data| data["code"].as_str()),
901                marker.exists()
902            ),
903            (Some(CODEGRAPH_DISABLED_CODE), false)
904        );
905        Ok(())
906    }
907
908    #[test]
909    fn initialize_contract_should_align_instructions_with_advertised_tools() {
910        let disabled = CodeSystemGraphServer::new(PathBuf::from("graph.db"), "commerce".to_owned());
911        let enabled = disabled.clone().with_codegraph(true, None);
912        let disabled_info = disabled.get_info();
913
914        assert!(disabled_info.capabilities.tools.is_some());
915        assert!(disabled_info.capabilities.resources.is_some());
916        assert_eq!(disabled_info.server_info.name, "code_system_graph");
917
918        for (server, codegraph_enabled) in [(&disabled, false), (&enabled, true)] {
919            let explore_advertised = server
920                .tool_router
921                .list_all()
922                .iter()
923                .any(|tool| tool.name == "explore");
924            let instructions = server
925                .get_info()
926                .instructions
927                .expect("server instructions should be present");
928
929            assert_eq!(explore_advertised, codegraph_enabled);
930            assert_eq!(instructions.contains("explore"), codegraph_enabled);
931        }
932
933        let source_context = disabled
934            .tool_router
935            .list_all()
936            .into_iter()
937            .find(|tool| tool.name == "source_context")
938            .expect("source_context should be advertised");
939        let description = source_context
940            .description
941            .as_deref()
942            .expect("source_context description should be present");
943        assert!(!description.contains("explore"));
944    }
945
946    #[test]
947    fn admin_tools_should_be_hidden_until_constructor_profile_is_enabled() {
948        let hidden = CodeSystemGraphServer::new(PathBuf::from("graph.db"), "commerce".to_owned());
949        assert!(
950            hidden
951                .tool_router
952                .list_all()
953                .iter()
954                .all(|tool| !mcp_support::ADMIN_TOOL_NAMES.contains(&tool.name.as_ref()))
955        );
956
957        let enabled = hidden.with_admin_profile(true);
958        for name in mcp_support::ADMIN_TOOL_NAMES {
959            let tool = enabled
960                .tool_router
961                .list_all()
962                .into_iter()
963                .find(|tool| tool.name == name);
964            assert!(tool.is_some());
965            assert!(tool.is_some_and(|tool| {
966                tool.annotations.as_ref().is_some_and(|annotations| {
967                    annotations.read_only_hint == Some(false)
968                        && annotations.destructive_hint == Some(true)
969                })
970            }));
971        }
972    }
973
974    #[test]
975    fn resource_list_should_publish_stable_workspace_policy_uris() {
976        let uris = mcp_support::resource_uris("commerce")
977            .into_iter()
978            .map(|(uri, _, _)| uri)
979            .collect::<Vec<_>>();
980
981        assert_eq!(uris.len(), 10);
982        assert!(uris.contains(&"code-system-graph://workspaces".to_owned()));
983        assert!(uris.contains(&"code-system-graph://workspace/commerce/schema".to_owned()));
984        assert!(uris.contains(&"code-system-graph://evidence/{id}".to_owned()));
985    }
986
987    #[test]
988    fn resource_read_should_reject_wrong_workspace_before_store_access() {
989        let error = mcp_support::read_resource(
990            &PathBuf::from("missing.db"),
991            "commerce",
992            "code-system-graph://workspace/payments/status",
993        );
994
995        assert!(error.is_err());
996        assert!(error.is_err_and(|error| {
997            error.kind == mcp_support::ResourceErrorKind::Invalid
998                && error.message.contains("outside this server's configured")
999        }));
1000    }
1001
1002    #[test]
1003    fn resource_read_should_return_versioned_source_free_json()
1004    -> Result<(), Box<dyn std::error::Error>> {
1005        let text = mcp_support::read_resource(
1006            &PathBuf::from("missing.db"),
1007            "commerce",
1008            "code-system-graph://workspaces",
1009        )
1010        .map_err(|error| std::io::Error::other(error.message))?;
1011        let value: serde_json::Value = serde_json::from_str(&text)?;
1012
1013        assert_eq!(value["schema_version"], 1);
1014        assert_eq!(value["workspaces"][0]["name"], "commerce");
1015        assert!(!text.contains("source_body"));
1016        Ok(())
1017    }
1018
1019    #[test]
1020    fn evidence_template_read_should_require_concrete_identifier() {
1021        let error = mcp_support::read_resource(
1022            &PathBuf::from("missing.db"),
1023            "commerce",
1024            "code-system-graph://evidence/{id}",
1025        );
1026
1027        assert!(error.is_err_and(|error| {
1028            error.kind == mcp_support::ResourceErrorKind::Invalid
1029                && error.message.contains("concrete evidence identifier")
1030        }));
1031    }
1032
1033    #[test]
1034    fn evidence_read_should_report_missing_metadata() -> Result<(), Box<dyn std::error::Error>> {
1035        let temporary = tempfile::tempdir()?;
1036        let database = temporary.path().join("graph.db");
1037        drop(SqliteStore::open(&database)?);
1038
1039        let error = mcp_support::read_resource(
1040            &database,
1041            "commerce",
1042            "code-system-graph://evidence/evidence:missing",
1043        );
1044
1045        assert!(error.is_err_and(|error| {
1046            error.kind == mcp_support::ResourceErrorKind::Missing
1047                && error.message.contains("no evidence snapshot")
1048        }));
1049        Ok(())
1050    }
1051
1052    #[test]
1053    fn schema_resource_should_catalog_every_tool_input_and_result()
1054    -> Result<(), Box<dyn std::error::Error>> {
1055        let catalog = mcp_support::schema_catalog();
1056        let schemas = catalog
1057            .get("schemas")
1058            .and_then(serde_json::Value::as_object);
1059
1060        assert!(schemas.is_some());
1061        let schemas = schemas.map_or(0, serde_json::Map::len);
1062        assert_eq!(schemas, 30);
1063        for name in [
1064            "explore.input",
1065            "explore.result",
1066            "update_workspace.input",
1067            "update_workspace.result",
1068            "write_manual_link.input",
1069            "write_manual_link.result",
1070            "clean_cache.input",
1071            "clean_cache.result",
1072        ] {
1073            assert!(catalog["schemas"].get(name).is_some(), "missing {name}");
1074        }
1075        assert_eq!(catalog["schema_version"], 1);
1076        assert!(catalog["application_interfaces"]["schemas"].is_array());
1077        assert!(serde_json::to_vec(&catalog)?.len() <= 2 * 1024 * 1024);
1078        Ok(())
1079    }
1080
1081    #[test]
1082    fn tool_schemas_should_keep_codegraph_policy_out_of_llm_inputs() {
1083        let catalog = mcp_support::schema_catalog();
1084        let impact = catalog["schemas"]["impact.input"].to_string();
1085        let scan = catalog["schemas"]["scan.input"].to_string();
1086
1087        assert!(!impact.contains("local_enrichment"));
1088        assert!(!scan.contains("codegraph"));
1089        assert!(!scan.contains("codegraph_binary"));
1090    }
1091
1092    #[test]
1093    fn conditional_tool_inputs_should_require_action_specific_fields() {
1094        let contract: Result<mcp_support::ContractsInput, _> =
1095            serde_json::from_value(serde_json::json!({
1096                "action": "diff",
1097                "workspace": "commerce",
1098                "contract": "contract:a"
1099            }));
1100        let workspace_update: Result<mcp_support::WorkspaceUpdateInput, _> =
1101            serde_json::from_value(serde_json::json!({
1102                "action": "remove_repository",
1103                "workspace": "commerce",
1104                "alias": "api",
1105                "repository_path": "api"
1106            }));
1107        let manual_link: Result<mcp_support::ManualLinkWriteInput, _> =
1108            serde_json::from_value(serde_json::json!({
1109                "workspace": "commerce",
1110                "from": "a",
1111                "to": "b",
1112                "relation": "documents",
1113                "reason": "Operator decision"
1114            }));
1115        let communities: Result<mcp_support::CommunitiesInput, _> =
1116            serde_json::from_value(serde_json::json!({
1117                "action": "show",
1118                "workspace": "commerce",
1119                "community_id": "community:a",
1120                "snapshot_id": "snapshot:old"
1121            }));
1122
1123        assert!(contract.is_err());
1124        assert!(workspace_update.is_err());
1125        assert!(manual_link.is_err());
1126        assert!(communities.is_err());
1127    }
1128}