Skip to content

Commit ac7d122

Browse files
committed
Harden MCP setup, shared access, and HTTP discovery ownership
1 parent 50a4dff commit ac7d122

11 files changed

Lines changed: 788 additions & 161 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ ctxt version # → 0.0.2 (ctxt --version also works)
106106
| Parallel requests | `10` |
107107
| Symbol flow | Symbols extracted BEFORE synonyms (sequential), then fed to LLM during synonym generation |
108108
| Default bench engines | `ctxt,find,grep` (`fd`, `rg`, `hybrid`, `combined` are opt-in via `--engines`) |
109-
| Scoring | Exact basename match +15 (highest); confidence gap heuristic truncates results at score cliffs |
109+
| Scoring | Exact symbol match +24 (highest); exact basename +15, exact token +12, basename contains +7, path contains +4, synonym exact +8, symbol token +4; confidence gap heuristic truncates results at score cliffs |
110110
| `--agent` flag | Blocks state-modifying commands (`init`/`watch`/`sync`/`clean`) |
111111

112112
### Flag Defaults

‎command_mcp.go‎

Lines changed: 181 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,155 @@ type searchToolArgs struct {
2626
// MCP status tool has no input.
2727
type statusToolArgs struct{}
2828

29+
// defaultMCPLimit and maxMCPResults bound the search tool's result count so a client
30+
// cannot dump the whole index (or an unbounded response) into an agent's context.
31+
const (
32+
defaultMCPLimit = 10
33+
maxMCPResults = 50
34+
)
35+
36+
// searchToolSchema is the JSON Schema for the search tool's input. It is hand-written
37+
// rather than inferred because the go-sdk's inferred schemas only carry field
38+
// descriptions; enum and maximum constraints require an explicit schema.
39+
var searchToolSchema = map[string]any{
40+
"type": "object",
41+
"properties": map[string]any{
42+
"query": map[string]any{
43+
"type": "string",
44+
"description": "Search query - keywords, filenames, or concepts",
45+
},
46+
"limit": map[string]any{
47+
"type": "integer",
48+
"description": "Max results (default 10, maximum 50)",
49+
"default": defaultMCPLimit,
50+
"minimum": 1,
51+
"maximum": maxMCPResults,
52+
},
53+
"type": map[string]any{
54+
"type": "string",
55+
"description": "Filter: all, files, or dirs",
56+
"enum": []any{"all", "files", "dirs"},
57+
},
58+
"explain": map[string]any{
59+
"type": "boolean",
60+
"description": "Include score breakdown",
61+
},
62+
"hybrid": map[string]any{
63+
"type": "boolean",
64+
"description": "Enable content fallback via ripgrep when index results are sparse",
65+
},
66+
},
67+
"required": []any{"query"},
68+
}
69+
70+
// registerMCPSearchTool exposes concept-based ranked file search over MCP. Search is
71+
// strictly read-only (the manager keeps the index in memory), so it is annotated
72+
// read-onlyHint to let clients auto-approve it.
73+
func registerMCPSearchTool(server *mcp.Server, manager *IndexManager) {
74+
mcp.AddTool(server, &mcp.Tool{
75+
Name: "search",
76+
Description: "Search a codebase for files using concept-based ranked search. Faster and more relevant than grep or find for locating WHERE code lives. " +
77+
"Query with plain keywords, concepts, partial filenames, or symbol names (e.g. \"auth login\", \"jwt token refresh\", \"payment handler\", \"createUser\"). " +
78+
"Results are ranked by relevance score (exact basename +15 and exact symbol +24 rank highest; partial path and basename matches add +4..+7; synonym matches add +5..+8). " +
79+
"The live index updates automatically as files change, so no separate watch or init step is required while this server is running. " +
80+
"Set hybrid=true to enable content fallback via ripgrep when index results are sparse. " +
81+
"Use this instead of grep when you need to find which file handles a concept — grep finds what's INSIDE files, this finds WHICH files matter. " +
82+
"Do not use for searching file contents (use grep) or file metadata like size/date/permissions (use find).",
83+
InputSchema: searchToolSchema,
84+
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
85+
}, searchToolHandler(manager))
86+
}
87+
88+
func searchToolHandler(manager *IndexManager) func(ctx context.Context, req *mcp.CallToolRequest, args searchToolArgs) (*mcp.CallToolResult, any, error) {
89+
return func(ctx context.Context, req *mcp.CallToolRequest, args searchToolArgs) (*mcp.CallToolResult, any, error) {
90+
if args.Query == "" {
91+
return mcpErrorResult("missing required argument: query"), nil, nil
92+
}
93+
limit := args.Limit
94+
if limit <= 0 {
95+
limit = defaultMCPLimit
96+
}
97+
if limit > maxMCPResults {
98+
limit = maxMCPResults
99+
}
100+
typeFilter := args.Type
101+
if typeFilter == "" {
102+
typeFilter = "all"
103+
}
104+
switch typeFilter {
105+
case "all", "files", "dirs":
106+
default:
107+
return mcpErrorResult(fmt.Sprintf("invalid type %q; must be one of all, files, or dirs", typeFilter)), nil, nil
108+
}
109+
results := manager.Search(args.Query, SearchOptions{
110+
Limit: limit,
111+
MinScore: 1,
112+
TypeFilter: typeFilter,
113+
IncludeDebug: args.Explain,
114+
ContentFallback: args.Hybrid,
115+
})
116+
return &mcp.CallToolResult{
117+
Content: []mcp.Content{mcpTextResult(formatMCPSearchResults(results, args.Explain))},
118+
}, nil, nil
119+
}
120+
}
121+
122+
func registerMCPStatusTool(server *mcp.Server, manager *IndexManager) {
123+
mcp.AddTool(server, &mcp.Tool{
124+
Name: "status",
125+
Description: "Report the health and coverage of the ctxt codebase index. " +
126+
"Returns total indexed files, directories, index generation time, and root path. " +
127+
"The server keeps an in-memory index current as files change; use this to check what the live index covers before searching. " +
128+
"This tool is read-only and never modifies project state.",
129+
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
130+
}, func(ctx context.Context, req *mcp.CallToolRequest, _ statusToolArgs) (*mcp.CallToolResult, any, error) {
131+
stats := manager.SnapshotStats()
132+
root := manager.RootPath()
133+
generatedAt := manager.IndexGeneratedAt()
134+
var sb strings.Builder
135+
fmt.Fprintf(&sb, "Root: %s\n", root)
136+
fmt.Fprintf(&sb, "Total nodes: %d\n", stats.TotalNodes)
137+
fmt.Fprintf(&sb, "Files: %d\n", stats.TotalFiles)
138+
fmt.Fprintf(&sb, "Directories: %d\n", stats.TotalDirs)
139+
fmt.Fprintf(&sb, "Generated: %s\n", generatedAt.Format(time.RFC3339))
140+
return &mcp.CallToolResult{
141+
Content: []mcp.Content{mcpTextResult(sb.String())},
142+
}, nil, nil
143+
})
144+
}
145+
146+
func mcpErrorResult(text string) *mcp.CallToolResult {
147+
return &mcp.CallToolResult{
148+
IsError: true,
149+
Content: []mcp.Content{mcpTextResult(text)},
150+
}
151+
}
152+
153+
func mcpTextResult(text string) *mcp.TextContent {
154+
return &mcp.TextContent{Text: text}
155+
}
156+
157+
func formatMCPSearchResults(results []SearchResult, explain bool) string {
158+
var sb strings.Builder
159+
if len(results) == 0 {
160+
sb.WriteString("No results found.")
161+
return sb.String()
162+
}
163+
for i, r := range results {
164+
fmt.Fprintf(&sb, "%d. %s (score: %d, type: %s)", i+1, r.Path, r.Score, r.Type)
165+
if len(r.Matches) > 0 {
166+
fmt.Fprintf(&sb, " matches: %s", strings.Join(r.Matches, ", "))
167+
}
168+
sb.WriteString("\n")
169+
if explain && len(r.Breakdown) > 0 {
170+
for _, b := range r.Breakdown {
171+
fmt.Fprintf(&sb, " - %s\n", b)
172+
}
173+
}
174+
}
175+
return sb.String()
176+
}
177+
29178
func newMCPCommand() *cobra.Command {
30179
flags := CommonFlags{}
31180
var debounce time.Duration
@@ -48,19 +197,26 @@ Exposes two tools:
48197
49198
The server watches for file changes and keeps the index current. All communication is via stdin/stdout JSON-RPC — no network, fully local.
50199
200+
Pass the project's absolute path so the server targets the right project no matter what working directory the AI client launches it from. If several ctxt servers run on one project, the first becomes the writer and later ones serve read-only (live in-memory search, no snapshot writes).
201+
51202
Setup: Add to your AI client's MCP config:
52-
{"command": "ctxt", "args": ["mcp"]}`,
203+
{"command": "ctxt", "args": ["mcp", "/abs/path/to/project"]}`,
53204
Args: cobra.MaximumNArgs(1),
54205
RunE: func(cmd *cobra.Command, args []string) error {
55206
// MCP uses stdout for JSON-RPC; all logging must go to stderr.
56207
logToStderr = true
57208

58-
var absConfigPath string
59-
if configPath != "" {
60-
var err error
61-
absConfigPath, err = filepath.Abs(configPath)
62-
if err != nil {
63-
return fmt.Errorf("resolve config path: %w", err)
209+
rootArg := ""
210+
if len(args) == 1 {
211+
rootArg = args[0]
212+
}
213+
absConfigPath, err := resolveStartupConfigPath(configPath, rootArg, cmd.Flags().Changed("config"))
214+
if err != nil {
215+
return err
216+
}
217+
if absConfigPath != "" && !cmd.Flags().Changed("config") {
218+
if _, statErr := os.Stat(absConfigPath); os.IsNotExist(statErr) {
219+
LogWarnf("Config file not found at %s; using defaults.", absConfigPath)
64220
}
65221
}
66222
cfg, err := LoadContextingConfig(absConfigPath)
@@ -106,8 +262,8 @@ Setup: Add to your AI client's MCP config:
106262
}
107263

108264
rootPath := "."
109-
if len(args) == 1 {
110-
rootPath = args[0]
265+
if rootArg != "" {
266+
rootPath = rootArg
111267
} else if cfg.Watch.RootPath != "" {
112268
rootPath = cfg.Watch.RootPath
113269
}
@@ -116,16 +272,13 @@ Setup: Add to your AI client's MCP config:
116272
if err != nil {
117273
return fmt.Errorf("resolve mcp path: %w", err)
118274
}
119-
if _, statErr := os.Stat(absConfigPath); os.IsNotExist(statErr) {
120-
if !cmd.Flags().Changed("config") {
121-
return fmt.Errorf("config file not found at %s; run ctxt from the project root directory", absConfigPath)
122-
}
123-
}
124-
releaseWriter, err := acquireWriterGuard(absRoot)
275+
releaseWriter, writeMode, err := acquireWriterGuardAllowShared(absRoot)
125276
if err != nil {
126277
return err
127278
}
128-
defer releaseWriter()
279+
if writeMode {
280+
defer releaseWriter()
281+
}
129282
outputPath := resolveProjectPath(absRoot, flags.OutputPath)
130283
if _, statErr := os.Stat(outputPath); os.IsNotExist(statErr) {
131284
return fmt.Errorf("no index found at %s; run 'ctxt init' from the project root first", outputPath)
@@ -366,79 +519,8 @@ Setup: Add to your AI client's MCP config:
366519
Version: currentVersion(),
367520
}, nil)
368521

369-
mcp.AddTool(server, &mcp.Tool{
370-
Name: "search",
371-
Description: "Before searching, ensure an index exists. Run `ctxt init .` in your project directory to build the index. For live updates while coding, run `ctxt watch .`. Use `ctxt status` to check index health. " +
372-
"Then use this search tool to find files by concept. " +
373-
"Search a codebase for files using concept-based ranked search. Faster and more relevant than grep or find for locating WHERE code lives. " +
374-
"Query with plain keywords, concepts, partial filenames, or symbol names (e.g. \"auth login\", \"jwt token refresh\", \"payment handler\", \"createUser\"). " +
375-
"Results are ranked by relevance score (symbol matches +4, synonyms +3, basename +7, exact match +15). " +
376-
"Set hybrid=true to enable content fallback via ripgrep when index results are sparse. " +
377-
"Use this instead of grep when you need to find which file handles a concept — grep finds what's INSIDE files, this finds WHICH files matter. " +
378-
"Do not use for searching file contents (use grep) or file metadata like size/date/permissions (use find).",
379-
}, func(ctx context.Context, req *mcp.CallToolRequest, args searchToolArgs) (*mcp.CallToolResult, any, error) {
380-
if args.Query == "" {
381-
return &mcp.CallToolResult{
382-
IsError: true,
383-
Content: []mcp.Content{&mcp.TextContent{Text: "missing required argument: query"}},
384-
}, nil, nil
385-
}
386-
limit := args.Limit
387-
if limit <= 0 {
388-
limit = 10
389-
}
390-
typeFilter := args.Type
391-
if typeFilter == "" {
392-
typeFilter = "all"
393-
}
394-
results := manager.Search(args.Query, SearchOptions{
395-
Limit: limit,
396-
MinScore: 1,
397-
TypeFilter: typeFilter,
398-
IncludeDebug: args.Explain,
399-
ContentFallback: args.Hybrid,
400-
})
401-
var sb strings.Builder
402-
if len(results) == 0 {
403-
sb.WriteString("No results found.")
404-
} else {
405-
for i, r := range results {
406-
fmt.Fprintf(&sb, "%d. %s (score: %d, type: %s)", i+1, r.Path, r.Score, r.Type)
407-
if len(r.Matches) > 0 {
408-
fmt.Fprintf(&sb, " matches: %s", strings.Join(r.Matches, ", "))
409-
}
410-
sb.WriteString("\n")
411-
if args.Explain && len(r.Breakdown) > 0 {
412-
for _, b := range r.Breakdown {
413-
fmt.Fprintf(&sb, " - %s\n", b)
414-
}
415-
}
416-
}
417-
}
418-
return &mcp.CallToolResult{
419-
Content: []mcp.Content{&mcp.TextContent{Text: sb.String()}},
420-
}, nil, nil
421-
})
422-
423-
mcp.AddTool(server, &mcp.Tool{
424-
Name: "status",
425-
Description: "Check the health and coverage of the ctxt codebase index. " +
426-
"Returns total indexed files, directories, index generation time, and root path. " +
427-
"Use this FIRST before searching to verify the index exists and is current. If no index exists, tell the user to run `ctxt init .` in their project directory.",
428-
}, func(ctx context.Context, req *mcp.CallToolRequest, _ statusToolArgs) (*mcp.CallToolResult, any, error) {
429-
stats := manager.SnapshotStats()
430-
root := manager.RootPath()
431-
generatedAt := manager.IndexGeneratedAt()
432-
var sb strings.Builder
433-
fmt.Fprintf(&sb, "Root: %s\n", root)
434-
fmt.Fprintf(&sb, "Total nodes: %d\n", stats.TotalNodes)
435-
fmt.Fprintf(&sb, "Files: %d\n", stats.TotalFiles)
436-
fmt.Fprintf(&sb, "Directories: %d\n", stats.TotalDirs)
437-
fmt.Fprintf(&sb, "Generated: %s\n", generatedAt.Format(time.RFC3339))
438-
return &mcp.CallToolResult{
439-
Content: []mcp.Content{&mcp.TextContent{Text: sb.String()}},
440-
}, nil, nil
441-
})
522+
registerMCPSearchTool(server, manager)
523+
registerMCPStatusTool(server, manager)
442524

443525
LogInfof("MCP server ready on stdio.")
444526
serverErr := server.Run(ctx, &mcp.StdioTransport{})
@@ -453,12 +535,16 @@ Setup: Add to your AI client's MCP config:
453535
emitSynonymWarning(result.SynonymError)
454536
}
455537
}
456-
flushed, flushErr := manager.FlushIfDirty()
457-
if flushErr != nil {
458-
LogErrorf("Failed to flush snapshot on shutdown: %v", flushErr)
459-
}
460-
if flushed {
461-
LogInfof("Flushed snapshot to %s and %s", outputPath, cachePath)
538+
if writeMode {
539+
flushed, flushErr := manager.FlushIfDirty()
540+
if flushErr != nil {
541+
LogErrorf("Failed to flush snapshot on shutdown: %v", flushErr)
542+
}
543+
if flushed {
544+
LogInfof("Flushed snapshot to %s and %s", outputPath, cachePath)
545+
}
546+
} else {
547+
LogInfof("Read-only MCP server: skipping snapshot flush.")
462548
}
463549
LogInfof("Stopping MCP server.")
464550
if serverErr != nil {

0 commit comments

Comments
 (0)