Skip to content

Commit e163efa

Browse files
authored
Support directory-scoped MCP tool calls (#104)
* Support directory-scoped MCP tool calls * Document MCP directory argument --------- Co-authored-by: uchebnick <uchebnick@users.noreply.github.com>
1 parent 5e62268 commit e163efa

8 files changed

Lines changed: 293 additions & 23 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ After restart, ask Codex a codebase question as usual. The skill tells Codex to
172172
- `search_code` to search indexed code symbols before opening many files
173173
- `index_repository` to build or refresh the index when needed
174174

175+
All MCP tools accept an optional `directory` argument. The installed skill passes the active repository path so `unch` searches the workspace Codex is currently editing, even if the MCP server was launched from somewhere else.
176+
175177
Codex starts the stdio server for you with `unch start mcp`; you normally do not need to run that command by hand.
176178

177179
## What It Supports Today

internal/cli/mcp_backend.go

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ type mcpBackendConfig struct {
2222
RootAbs string
2323
TargetPaths semsearch.Paths
2424
IndexPath string
25+
StateDirInput string
26+
StateDirExplicit bool
27+
DBInput string
28+
DBExplicit bool
2529
RequestedProvider string
2630
RequestedModel string
2731
RequestedLibPath string
@@ -48,6 +52,9 @@ type mcpBackend struct {
4852
runMu sync.Mutex
4953
mu sync.Mutex
5054

55+
childrenMu sync.Mutex
56+
children map[string]*mcpBackend
57+
5158
prepared *preparedMCPResources
5259
}
5360

@@ -60,10 +67,24 @@ func (b *mcpBackend) Version() string {
6067
}
6168

6269
func (b *mcpBackend) Close() error {
70+
b.childrenMu.Lock()
71+
children := make([]*mcpBackend, 0, len(b.children))
72+
for _, child := range b.children {
73+
children = append(children, child)
74+
}
75+
b.children = nil
76+
b.childrenMu.Unlock()
77+
78+
var firstErr error
79+
for _, child := range children {
80+
if err := child.Close(); err != nil && firstErr == nil {
81+
firstErr = err
82+
}
83+
}
84+
6385
b.mu.Lock()
6486
defer b.mu.Unlock()
6587

66-
var firstErr error
6788
if b.prepared != nil {
6889
if b.prepared.repo != nil {
6990
if err := b.prepared.repo.Close(); err != nil && firstErr == nil {
@@ -78,7 +99,15 @@ func (b *mcpBackend) Close() error {
7899
return firstErr
79100
}
80101

81-
func (b *mcpBackend) WorkspaceStatus(_ context.Context) (unchmcp.WorkspaceStatusResult, error) {
102+
func (b *mcpBackend) WorkspaceStatus(ctx context.Context, params unchmcp.WorkspaceStatusParams) (unchmcp.WorkspaceStatusResult, error) {
103+
backend, err := b.backendForDirectory(params.Directory)
104+
if err != nil {
105+
return unchmcp.WorkspaceStatusResult{}, err
106+
}
107+
return backend.workspaceStatus(ctx)
108+
}
109+
110+
func (b *mcpBackend) workspaceStatus(_ context.Context) (unchmcp.WorkspaceStatusResult, error) {
82111
result := unchmcp.WorkspaceStatusResult{
83112
Root: b.cfg.RootAbs,
84113
StateDir: b.cfg.TargetPaths.LocalDir,
@@ -137,6 +166,15 @@ func (b *mcpBackend) WorkspaceStatus(_ context.Context) (unchmcp.WorkspaceStatus
137166
}
138167

139168
func (b *mcpBackend) SearchCode(ctx context.Context, params unchmcp.SearchCodeParams) (unchmcp.SearchCodeResult, error) {
169+
backend, err := b.backendForDirectory(params.Directory)
170+
if err != nil {
171+
return unchmcp.SearchCodeResult{}, err
172+
}
173+
params.Directory = ""
174+
return backend.searchCode(ctx, params)
175+
}
176+
177+
func (b *mcpBackend) searchCode(ctx context.Context, params unchmcp.SearchCodeParams) (unchmcp.SearchCodeResult, error) {
140178
b.runMu.Lock()
141179
defer b.runMu.Unlock()
142180

@@ -215,6 +253,15 @@ func (b *mcpBackend) SearchCode(ctx context.Context, params unchmcp.SearchCodePa
215253
}
216254

217255
func (b *mcpBackend) IndexRepository(ctx context.Context, params unchmcp.IndexRepositoryParams) (unchmcp.IndexRepositoryResult, error) {
256+
backend, err := b.backendForDirectory(params.Directory)
257+
if err != nil {
258+
return unchmcp.IndexRepositoryResult{}, err
259+
}
260+
params.Directory = ""
261+
return backend.indexRepository(ctx, params)
262+
}
263+
264+
func (b *mcpBackend) indexRepository(ctx context.Context, params unchmcp.IndexRepositoryParams) (unchmcp.IndexRepositoryResult, error) {
218265
b.runMu.Lock()
219266
defer b.runMu.Unlock()
220267

@@ -313,6 +360,77 @@ func (b *mcpBackend) IndexRepository(ctx context.Context, params unchmcp.IndexRe
313360
}, nil
314361
}
315362

363+
func (b *mcpBackend) backendForDirectory(directory string) (*mcpBackend, error) {
364+
rootAbs, ok, err := normalizeMCPDirectory(directory)
365+
if err != nil {
366+
return nil, err
367+
}
368+
if !ok || rootAbs == b.cfg.RootAbs {
369+
return b, nil
370+
}
371+
372+
b.childrenMu.Lock()
373+
defer b.childrenMu.Unlock()
374+
375+
if b.children == nil {
376+
b.children = map[string]*mcpBackend{}
377+
}
378+
if child, ok := b.children[rootAbs]; ok {
379+
return child, nil
380+
}
381+
382+
targetPaths, indexPath, _, err := previewStateTarget(
383+
rootAbs,
384+
b.cfg.StateDirInput,
385+
b.cfg.StateDirExplicit,
386+
b.cfg.DBInput,
387+
b.cfg.DBExplicit,
388+
)
389+
if err != nil {
390+
return nil, err
391+
}
392+
393+
child := newMCPBackend(mcpBackendConfig{
394+
RootAbs: rootAbs,
395+
TargetPaths: targetPaths,
396+
IndexPath: indexPath,
397+
StateDirInput: b.cfg.StateDirInput,
398+
StateDirExplicit: b.cfg.StateDirExplicit,
399+
DBInput: b.cfg.DBInput,
400+
DBExplicit: b.cfg.DBExplicit,
401+
RequestedProvider: b.cfg.RequestedProvider,
402+
RequestedModel: b.cfg.RequestedModel,
403+
RequestedLibPath: b.cfg.RequestedLibPath,
404+
ContextSize: b.cfg.ContextSize,
405+
Verbose: b.cfg.Verbose,
406+
})
407+
child.scanner = b.scanner
408+
child.models = b.models
409+
child.runtimes = b.runtimes
410+
b.children[rootAbs] = child
411+
return child, nil
412+
}
413+
414+
func normalizeMCPDirectory(directory string) (string, bool, error) {
415+
clean := strings.TrimSpace(directory)
416+
if clean == "" {
417+
return "", false, nil
418+
}
419+
420+
rootAbs, err := filepath.Abs(clean)
421+
if err != nil {
422+
return "", false, fmt.Errorf("resolve directory: %w", err)
423+
}
424+
rootAbs = filepath.Clean(rootAbs)
425+
426+
if info, err := os.Stat(rootAbs); err != nil {
427+
return "", false, fmt.Errorf("stat directory: %w", err)
428+
} else if !info.IsDir() {
429+
return "", false, fmt.Errorf("directory is not a directory: %s", rootAbs)
430+
}
431+
return rootAbs, true, nil
432+
}
433+
316434
func (b *mcpBackend) ensurePrepared(ctx context.Context) (*preparedMCPResources, error) {
317435
b.mu.Lock()
318436
if b.prepared != nil {
@@ -322,6 +440,10 @@ func (b *mcpBackend) ensurePrepared(ctx context.Context) (*preparedMCPResources,
322440
}
323441
b.mu.Unlock()
324442

443+
if _, err := semsearch.PathsForLocalDir(b.cfg.TargetPaths.LocalDir); err != nil {
444+
return nil, fmt.Errorf("prepare state directory: %w", err)
445+
}
446+
325447
preparedEmbedder, err := prepareEmbedder(
326448
ctx,
327449
nil,

internal/cli/mcp_backend_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
unchmcp "github.com/uchebnick/unch/internal/mcp"
10+
)
11+
12+
func TestMCPBackendWorkspaceStatusUsesDirectoryArgument(t *testing.T) {
13+
t.Parallel()
14+
15+
root := t.TempDir()
16+
other := t.TempDir()
17+
18+
paths, indexPath, _, err := previewStateTarget(root, "", false, "", false)
19+
if err != nil {
20+
t.Fatalf("previewStateTarget() error: %v", err)
21+
}
22+
backend := newMCPBackend(mcpBackendConfig{
23+
RootAbs: root,
24+
TargetPaths: paths,
25+
IndexPath: indexPath,
26+
RequestedProvider: "llama.cpp",
27+
})
28+
29+
status, err := backend.WorkspaceStatus(context.Background(), unchmcp.WorkspaceStatusParams{
30+
Directory: other,
31+
})
32+
if err != nil {
33+
t.Fatalf("WorkspaceStatus() error: %v", err)
34+
}
35+
36+
if status.Root != other {
37+
t.Fatalf("Root = %q, want %q", status.Root, other)
38+
}
39+
if want := filepath.Join(other, ".semsearch"); status.StateDir != want {
40+
t.Fatalf("StateDir = %q, want %q", status.StateDir, want)
41+
}
42+
if _, err := os.Stat(filepath.Join(other, ".semsearch")); !os.IsNotExist(err) {
43+
t.Fatalf("workspace_status created state dir unexpectedly: %v", err)
44+
}
45+
}
46+
47+
func TestMCPBackendWorkspaceStatusRejectsFileDirectory(t *testing.T) {
48+
t.Parallel()
49+
50+
root := t.TempDir()
51+
paths, indexPath, _, err := previewStateTarget(root, "", false, "", false)
52+
if err != nil {
53+
t.Fatalf("previewStateTarget() error: %v", err)
54+
}
55+
filePath := filepath.Join(root, "main.go")
56+
if err := os.WriteFile(filePath, []byte("package main\n"), 0o644); err != nil {
57+
t.Fatalf("WriteFile(%s) error: %v", filePath, err)
58+
}
59+
60+
backend := newMCPBackend(mcpBackendConfig{
61+
RootAbs: root,
62+
TargetPaths: paths,
63+
IndexPath: indexPath,
64+
RequestedProvider: "llama.cpp",
65+
})
66+
67+
if _, err := backend.WorkspaceStatus(context.Background(), unchmcp.WorkspaceStatusParams{
68+
Directory: filePath,
69+
}); err == nil {
70+
t.Fatalf("WorkspaceStatus() error = nil, want file directory error")
71+
}
72+
}

internal/cli/start.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,27 +91,27 @@ func runStartMCP(ctx context.Context, program string, args []string) error {
9191
if err != nil {
9292
return fmt.Errorf("resolve root: %w", err)
9393
}
94+
rootAbs = filepath.Clean(rootAbs)
9495

95-
previewPaths, _, _, err := previewStateTarget(rootAbs, *stateDir, stateDirWasExplicit, *dbPath, dbWasExplicit)
96+
targetPaths, resolvedIndexPath, _, err := previewStateTarget(rootAbs, *stateDir, stateDirWasExplicit, *dbPath, dbWasExplicit)
9697
if err != nil {
9798
return err
9899
}
99100
if parsedProvider, err := appembed.ParseProvider(*provider); err == nil && parsedProvider == appembed.ProviderOpenRouter {
100-
if err := preflightProviderConfig(parsedProvider, previewPaths.LocalDir); err != nil {
101+
if err := preflightProviderConfig(parsedProvider, targetPaths.LocalDir); err != nil {
101102
printMissingProviderCredentialsWarning(parsedProvider)
102103
return err
103104
}
104105
}
105106

106-
targetPaths, resolvedIndexPath, _, err := resolveStateTarget(rootAbs, *stateDir, stateDirWasExplicit, *dbPath, dbWasExplicit)
107-
if err != nil {
108-
return err
109-
}
110-
111107
backend := newMCPBackend(mcpBackendConfig{
112108
RootAbs: rootAbs,
113109
TargetPaths: targetPaths,
114110
IndexPath: resolvedIndexPath,
111+
StateDirInput: strings.TrimSpace(*stateDir),
112+
StateDirExplicit: stateDirWasExplicit,
113+
DBInput: strings.TrimSpace(*dbPath),
114+
DBExplicit: dbWasExplicit,
115115
RequestedProvider: strings.TrimSpace(*provider),
116116
RequestedModel: strings.TrimSpace(*modelPath),
117117
RequestedLibPath: strings.TrimSpace(*libPath),

0 commit comments

Comments
 (0)