1use 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#[derive(Debug, Clone, Default)]
37struct CodeGraphPolicy {
38 enabled: bool,
39 binary: Option<OsString>,
40}
41
42#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}