Skip to content

Commit 61e0f8b

Browse files
docs(mcp): annotate context and analysis handlers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 621b0b4 commit 61e0f8b

11 files changed

Lines changed: 96 additions & 26 deletions

internal/mcp/cache.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ func (c *Cache) Close() {
8686
})
8787
}
8888

89+
// @intent drop one cache entry to keep total size at or below the configured maximum.
8990
func (c *Cache) evictOneLocked() {
9091
if len(c.entries) <= maxCacheEntries {
9192
return

internal/mcp/deps.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @index Dependency contracts and injected services for MCP handlers.
12
package mcp
23

34
import (
@@ -36,6 +37,8 @@ type ImpactAnalyzer interface {
3637
ImpactRadius(ctx context.Context, nodeID uint, depth int) ([]model.Node, error)
3738
}
3839

40+
// BoundedImpactAnalyzer extends ImpactAnalyzer with node and depth caps to prevent runaway traversal on large graphs.
41+
// @intent expose bounded blast-radius analysis for handlers that must protect shared MCP requests from unbounded graph walks.
3942
type BoundedImpactAnalyzer interface {
4043
ImpactRadiusBounded(ctx context.Context, nodeID uint, depth int, opts impactpkg.RadiusOptions) (*impactpkg.RadiusResult, error)
4144
}
@@ -47,6 +50,8 @@ type FlowTracer interface {
4750
TraceFlow(ctx context.Context, startNodeID uint) (*model.Flow, error)
4851
}
4952

53+
// BoundedFlowTracer extends FlowTracer with a node cap to prevent runaway traversal on deeply nested call chains.
54+
// @intent let MCP handlers trace deep call chains without letting one request expand into an unbounded traversal.
5055
type BoundedFlowTracer interface {
5156
TraceFlowBounded(ctx context.Context, startNodeID uint, opts flowspkg.TraceOptions) (*flowspkg.TraceResult, error)
5257
}
@@ -117,6 +122,8 @@ type IncrementalSyncer interface {
117122
SyncWithExisting(ctx context.Context, files map[string]incremental.FileInfo, existingFiles []string) (*incremental.SyncStats, error)
118123
}
119124

125+
// PostprocessPolicy gates and records automatic postprocess runs so repeated failures can be detected and suppressed.
126+
// @intent centralize automatic postprocess decisions so repeated failures can degrade execution before handlers retry expensive work.
120127
type PostprocessPolicy interface {
121128
Resolve(ctx context.Context, input postprocesspolicy.DecisionInput) (string, string, error)
122129
RecordRun(ctx context.Context, record postprocesspolicy.RunRecord) error

internal/mcp/handler_analysis.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,10 +395,15 @@ func (h *handlers) findDeadCode(ctx context.Context, request mcp.CallToolRequest
395395
}))
396396
}
397397

398+
// @intent validate repo_root inputs against configured analysis roots before git-based analysis reads the filesystem.
398399
func (h *handlers) validateRepoRoot(repoRoot string) (string, error) {
399400
return validateRepoRootWithin(repoRoot, h.deps.RepoRoot, h.workspaceRoot())
400401
}
401402

403+
// validateRepoRootWithin checks that repoRoot resolves to a canonical path within one of the allowed analysis roots.
404+
// @intent prevent git-based analysis from reading paths outside the configured project boundaries.
405+
// @requires configuredRepoRoot or workspaceRoot must be non-empty; repoRoot must be a valid filesystem path.
406+
// @ensures returned path is absolute, symlink-resolved, and contained within an allowed root.
402407
func validateRepoRootWithin(repoRoot, configuredRepoRoot, workspaceRoot string) (string, error) {
403408
if repoRoot == "" {
404409
return "", fmt.Errorf("repo_root is required")
@@ -421,6 +426,8 @@ func validateRepoRootWithin(repoRoot, configuredRepoRoot, workspaceRoot string)
421426
return repo, nil
422427
}
423428

429+
// configuredAnalysisRoots returns the deduplicated list of allowed root paths derived from server config and workspace.
430+
// @intent build the allowlist used by path validation so each source of truth contributes exactly once.
424431
func configuredAnalysisRoots(repoRoot, workspaceRoot string) []string {
425432
roots := make([]string, 0, 2)
426433
for _, root := range []string{repoRoot, workspaceRoot} {
@@ -434,6 +441,7 @@ func configuredAnalysisRoots(repoRoot, workspaceRoot string) []string {
434441
return roots
435442
}
436443

444+
// @intent linear membership check for small string slices used by allowlist evaluation.
437445
func sliceContainsString(values []string, target string) bool {
438446
for _, value := range values {
439447
if value == target {
@@ -443,6 +451,9 @@ func sliceContainsString(values []string, target string) bool {
443451
return false
444452
}
445453

454+
// validatePathWithinAllowedRoots reports whether target falls inside any of the canonical allowed roots.
455+
// @intent enforce that user-supplied paths cannot escape the configured analysis boundary.
456+
// @requires allowedRoots must be non-empty and each entry must be a valid filesystem path.
446457
func validatePathWithinAllowedRoots(target string, allowedRoots []string) (bool, error) {
447458
for _, root := range allowedRoots {
448459
base, err := canonicalPath(root)
@@ -460,6 +471,8 @@ func validatePathWithinAllowedRoots(target string, allowedRoots []string) (bool,
460471
return false, nil
461472
}
462473

474+
// isWithinRoot reports whether target is the same as root or a descendant of it.
475+
// @intent detect path traversal by checking the relative path does not escape upward.
463476
func isWithinRoot(root, target string) (bool, error) {
464477
rel, err := filepath.Rel(root, target)
465478
if err != nil {
@@ -480,6 +493,8 @@ func isWithinRoot(root, target string) (bool, error) {
480493
return true, nil
481494
}
482495

496+
// canonicalPath resolves path to an absolute, symlink-free, cleaned filesystem path.
497+
// @intent normalize user-supplied paths before comparison to prevent symlink-based boundary escapes.
483498
func canonicalPath(path string) (string, error) {
484499
abs, err := filepath.Abs(path)
485500
if err != nil {

internal/mcp/handler_context.go

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @index MCP context handlers that summarize graph state for downstream tool selection.
12
package mcp
23

34
import (
@@ -14,6 +15,42 @@ import (
1415
"github.com/tae2089/code-context-graph/internal/model"
1516
)
1617

18+
// fileCount carries a single COUNT(DISTINCT file_path) scan result.
19+
// @intent capture the distinct-file count returned by the minimal-context summary query.
20+
type fileCount struct {
21+
Count int64
22+
}
23+
24+
// commCount holds aggregated membership counts per community.
25+
// @intent transport GROUP BY community_id results into MCP response shaping.
26+
type commCount struct {
27+
CommunityID uint
28+
Count int
29+
}
30+
31+
// commInfo is the summarized community payload shared by MCP responses.
32+
// @intent serialize minimal-context community summaries without introducing extra response fields.
33+
type minimalContextCommInfo struct {
34+
Label string `json:"label"`
35+
NodeCount int `json:"node_count"`
36+
}
37+
38+
// flowCount holds aggregated membership counts per flow.
39+
// @intent transport GROUP BY flow_id results into MCP response shaping.
40+
type flowCount struct {
41+
FlowID uint
42+
Count int
43+
}
44+
45+
// flowInfo is the summarized flow payload shared by MCP responses.
46+
// @intent serialize minimal-context flow summaries without introducing extra response fields.
47+
type minimalContextFlowInfo struct {
48+
Name string `json:"name"`
49+
NodeCount int `json:"node_count"`
50+
}
51+
52+
// getMinimalContext returns a compact project snapshot with risk hints and suggested tools.
53+
// @intent give agents a cheap first read of namespace state before they spend tokens on deeper graph queries.
1754
func (h *handlers) getMinimalContext(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1855
ctx = h.applyWorkspace(ctx, request)
1956
log := h.logger()
@@ -49,7 +86,6 @@ func (h *handlers) getMinimalContext(ctx context.Context, request mcp.CallToolRe
4986
return "", trace.Wrap(err, "count edges")
5087
}
5188

52-
type fileCount struct{ Count int64 }
5389
var fc fileCount
5490
fileQ := h.deps.DB.WithContext(ctx).Model(&model.Node{}).Select("COUNT(DISTINCT file_path) as count").Where("namespace = ?", ns)
5591
if err := fileQ.Scan(&fc).Error; err != nil {
@@ -110,11 +146,6 @@ func (h *handlers) getMinimalContext(ctx context.Context, request mcp.CallToolRe
110146
if keyEntities == nil {
111147
keyEntities = []string{}
112148
}
113-
114-
type commCount struct {
115-
CommunityID uint
116-
Count int
117-
}
118149
var ccRows []commCount
119150
commCountQ := h.deps.DB.WithContext(ctx).
120151
Model(&model.CommunityMembership{}).
@@ -136,26 +167,16 @@ func (h *handlers) getMinimalContext(ctx context.Context, request mcp.CallToolRe
136167
if err := communityQ.Find(&communities).Error; err != nil {
137168
return "", trace.Wrap(err, "find communities")
138169
}
139-
140-
type commInfo struct {
141-
Label string `json:"label"`
142-
NodeCount int `json:"node_count"`
143-
}
144-
commInfos := make([]commInfo, len(communities))
170+
commInfos := make([]minimalContextCommInfo, len(communities))
145171
for i, c := range communities {
146-
commInfos[i] = commInfo{Label: c.Label, NodeCount: ccMap[c.ID]}
172+
commInfos[i] = minimalContextCommInfo{Label: c.Label, NodeCount: ccMap[c.ID]}
147173
}
148174
sort.Slice(commInfos, func(i, j int) bool {
149175
return commInfos[i].NodeCount > commInfos[j].NodeCount
150176
})
151177
if len(commInfos) > 3 {
152178
commInfos = commInfos[:3]
153179
}
154-
155-
type flowCount struct {
156-
FlowID uint
157-
Count int
158-
}
159180
var fcRows []flowCount
160181
flowCountQ := h.deps.DB.WithContext(ctx).
161182
Model(&model.FlowMembership{}).
@@ -176,14 +197,9 @@ func (h *handlers) getMinimalContext(ctx context.Context, request mcp.CallToolRe
176197
if err := flowQ.Find(&flowList).Error; err != nil {
177198
return "", trace.Wrap(err, "find flows")
178199
}
179-
180-
type flowInfo struct {
181-
Name string `json:"name"`
182-
NodeCount int `json:"node_count"`
183-
}
184-
flowInfos := make([]flowInfo, len(flowList))
200+
flowInfos := make([]minimalContextFlowInfo, len(flowList))
185201
for i, f := range flowList {
186-
flowInfos[i] = flowInfo{Name: f.Name, NodeCount: fcMap[f.ID]}
202+
flowInfos[i] = minimalContextFlowInfo{Name: f.Name, NodeCount: fcMap[f.ID]}
187203
}
188204
sort.Slice(flowInfos, func(i, j int) bool {
189205
return flowInfos[i].NodeCount > flowInfos[j].NodeCount
@@ -214,6 +230,8 @@ func (h *handlers) getMinimalContext(ctx context.Context, request mcp.CallToolRe
214230
}))
215231
}
216232

233+
// suggestTools maps common task wording to the MCP tools most likely to help.
234+
// @intent steer callers toward high-signal graph operations without requiring them to know the full tool catalog.
217235
func suggestTools(task string) []string {
218236
lower := strings.ToLower(task)
219237

internal/mcp/handler_docs.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/tae2089/code-context-graph/internal/ragindex"
1414
)
1515

16+
// @intent resolve the base directory that stores generated doc-index artifacts for MCP documentation tools.
1617
func (h *handlers) ragIndexRoot() string {
1718
dir := h.deps.RagIndexDir
1819
if dir == "" {
@@ -21,6 +22,7 @@ func (h *handlers) ragIndexRoot() string {
2122
return dir
2223
}
2324

25+
// @intent normalize a docs/index root to an absolute, symlink-evaluated path before path checks.
2426
func resolveSafeRoot(root string, create bool) (string, error) {
2527
absRoot, err := filepath.Abs(root)
2628
if err != nil {
@@ -43,6 +45,7 @@ func resolveSafeRoot(root string, create bool) (string, error) {
4345
return filepath.Clean(absRoot), nil
4446
}
4547

48+
// @intent reject relative paths that would resolve outside the resolved docs root.
4649
func safePathUnderRoot(root, relPath, field string, createRoot bool, allowMissingLeaf bool) (string, error) {
4750
clean := filepath.Clean(relPath)
4851
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
@@ -63,6 +66,7 @@ func safePathUnderRoot(root, relPath, field string, createRoot bool, allowMissin
6366
return target, nil
6467
}
6568

69+
// @intent derive the safe doc-index.json path for either the shared docs root or one workspace-specific subtree.
6670
func (h *handlers) resolvedRagIndexPath(workspace string) (string, error) {
6771
if workspace != "" {
6872
if err := validateWorkspacePath(workspace, ""); err != nil {

internal/mcp/handler_parse.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020

2121
var refreshSearchDocuments = service.RefreshSearchDocuments
2222

23+
// @intent apply per-request parse limits without mutating the shared handler dependency configuration.
2324
func (h *handlers) withParseLimitsFromRequest(request mcp.CallToolRequest) *handlers {
2425
maxFileBytes := int64(request.GetInt("max_file_bytes", int(h.deps.MaxFileBytes)))
2526
maxTotalParsedBytes := int64(request.GetInt("max_total_parsed_bytes", int(h.deps.MaxTotalParsedBytes)))
@@ -34,6 +35,7 @@ func (h *handlers) withParseLimitsFromRequest(request mcp.CallToolRequest) *hand
3435
return &hCopy
3536
}
3637

38+
// @intent assemble a short-lived GraphService view from injected MCP dependencies for one parse or update request.
3739
func (h *handlers) graphService() *service.GraphService {
3840
walkers := make(map[string]service.Parser, len(h.deps.Walkers))
3941
for ext, parser := range h.deps.Walkers {
@@ -474,6 +476,7 @@ func (h *handlers) runPostprocess(ctx context.Context, request mcp.CallToolReque
474476
return mcp.NewToolResultText(jsonStr), nil
475477
}
476478

479+
// @intent append values to a slice while preserving uniqueness for skipped-step reporting.
477480
func appendUniqueStrings(dst []string, values ...string) []string {
478481
for _, value := range values {
479482
if !slices.Contains(dst, value) {
@@ -483,6 +486,7 @@ func appendUniqueStrings(dst []string, values ...string) []string {
483486
return dst
484487
}
485488

489+
// @intent restrict parse and build requests to configured analysis roots before filesystem traversal begins.
486490
func (h *handlers) validateAnalysisPath(path string) (string, error) {
487491
if path == "" {
488492
return "", fmt.Errorf("path is required")
@@ -505,6 +509,7 @@ func (h *handlers) validateAnalysisPath(path string) (string, error) {
505509
return target, nil
506510
}
507511

512+
// @intent resolve a path to its absolute, symlink-evaluated form for analysis-root containment checks.
508513
func canonicalExistingPath(path string) (string, error) {
509514
abs, err := filepath.Abs(path)
510515
if err != nil {

internal/mcp/handler_postprocess.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @index MCP handlers for inspecting and resetting automatic postprocess policy state.
12
package mcp
23

34
import (
@@ -8,6 +9,8 @@ import (
89
postprocesspolicy "github.com/tae2089/code-context-graph/internal/postprocess/policy"
910
)
1011

12+
// getPostprocessPolicy returns the recorded postprocess policy summary for a namespace and tool.
13+
// @intent expose automatic fail-open versus fail-closed decisions so operators can diagnose degraded postprocess behavior.
1114
func (h *handlers) getPostprocessPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1215
ctx = h.applyWorkspace(ctx, request)
1316
if h.deps.PostprocessPolicy == nil {
@@ -33,6 +36,8 @@ func (h *handlers) getPostprocessPolicy(ctx context.Context, request mcp.CallToo
3336
return finalizeToolResult(result, err)
3437
}
3538

39+
// resetPostprocessPolicy clears the stored failure streak for a postprocess tool.
40+
// @intent let operators recover from fail-closed state after they have fixed the underlying issue.
3641
func (h *handlers) resetPostprocessPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
3742
ctx = h.applyWorkspace(ctx, request)
3843
if h.deps.PostprocessPolicy == nil {

internal/mcp/handlers.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @index Shared handler helpers and response utilities for MCP tools.
12
package mcp
23

34
import (
@@ -56,17 +57,20 @@ func (h *handlers) logger() *slog.Logger {
5657
return slog.Default()
5758
}
5859

60+
// @intent attach the requested namespace to handler context before downstream stores and analyzers run.
5961
func (h *handlers) applyWorkspace(ctx context.Context, request mcp.CallToolRequest) context.Context {
6062
return ctxns.WithNamespace(ctx, resolveNamespace(ctx, requestNamespace(request)))
6163
}
6264

65+
// @intent prefer explicit request namespace while falling back to the namespace already carried on context.
6366
func resolveNamespace(ctx context.Context, workspace string) string {
6467
if workspace != "" {
6568
return ctxns.Normalize(workspace)
6669
}
6770
return ctxns.FromContext(ctx)
6871
}
6972

73+
// @intent read namespace isolation arguments while supporting the deprecated workspace alias.
7074
func requestNamespace(request mcp.CallToolRequest) string {
7175
if namespace := request.GetString("namespace", ""); namespace != "" {
7276
return namespace
@@ -113,6 +117,7 @@ func nodeNotFoundErr(qn string) error {
113117
return newToolResultErr(fmt.Sprintf("node %q not found", qn))
114118
}
115119

120+
// @intent reject zero and negative list limits before handlers hit database queries.
116121
func validatePositiveLimit(limit int) error {
117122
if limit <= 0 {
118123
return newToolResultErr(fmt.Sprintf("limit must be > 0, got %d", limit))

internal/mcp/http.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
// @index HTTP safety helpers for MCP server endpoints.
12
package mcp
23

34
import (
45
"net/http"
56
)
67

7-
const maxMCPRequestBodyBytes = 10 << 20 // 10 MB
8+
// maxMCPRequestBodyBytes caps MCP HTTP request bodies at 10 MB.
9+
const maxMCPRequestBodyBytes = 10 << 20
810

11+
// @intent cap request memory usage before MCP handlers allocate or parse large request bodies.
12+
// @ensures requests larger than the configured limit are rejected with 413.
13+
// @sideEffect wraps the request body with a MaxBytesReader.
914
func LimitHTTPBody(next http.Handler) http.Handler {
1015
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1116
if r.ContentLength > maxMCPRequestBodyBytes {

internal/mcp/prompts.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ func promptResult(text string) *mcp.GetPromptResult {
4242
}
4343
}
4444

45+
// @intent pick the namespace for a prompt invocation, preferring an explicit argument over the workspace fallback.
4546
func resolvePromptNamespace(ctx context.Context, args map[string]string) string {
4647
if namespace := args["namespace"]; namespace != "" {
4748
return ctxns.Normalize(namespace)
4849
}
4950
return resolveNamespace(ctx, args["workspace"])
5051
}
5152

53+
// @intent resolve the on-disk root used to validate prompt repo paths, falling back through namespace and workspace roots.
5254
func promptNamespaceRoot(deps *Deps) string {
5355
if deps.NamespaceRoot != "" {
5456
return deps.NamespaceRoot

0 commit comments

Comments
 (0)