Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion mcp/bgshell.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,16 @@ type bgJobManager struct {
// job's wait goroutine. Used to push a completion notice into the live run's
// message-injection channel (cogito has no concept of shell jobs).
onDone func(*bgJob)

// dir, when non-empty, is the working directory launched commands run in
// (cmd.Dir). Empty means the process cwd (legacy behavior).
dir string
}

func newBgJobManager() *bgJobManager { return &bgJobManager{jobs: map[string]*bgJob{}} }
func newBgJobManager() *bgJobManager { return newBgJobManagerInDir("") }
func newBgJobManagerInDir(dir string) *bgJobManager {
return &bgJobManager{jobs: map[string]*bgJob{}, dir: dir}
}

// launch starts script under a context derived from parent (so the job survives
// a single turn but is cancelled when the session/app shuts down). When
Expand All @@ -137,6 +144,9 @@ func (m *bgJobManager) launch(parent context.Context, script string, foreground

shellExec, shellArgs := shellInvocation(script)
cmd := exec.CommandContext(ctx, shellExec, shellArgs...)
if m.dir != "" {
cmd.Dir = m.dir
}
cmd.Stdout = &j.stdout
cmd.Stderr = &j.stderr

Expand Down Expand Up @@ -297,6 +307,10 @@ type ShellJobs struct {
// NewShellJobs creates an empty shared shell-job registry.
func NewShellJobs() *ShellJobs { return &ShellJobs{mgr: newBgJobManager()} }

// NewShellJobsInDir creates a shell-job registry whose commands run in dir
// (cmd.Dir). An empty dir preserves the legacy process-cwd behavior.
func NewShellJobsInDir(dir string) *ShellJobs { return &ShellJobs{mgr: newBgJobManagerInDir(dir)} }

// ShellJobInfo is a UI-facing snapshot of a shell job.
type ShellJobInfo struct {
ID string
Expand Down
22 changes: 22 additions & 0 deletions mcp/bgshell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"context"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -229,3 +230,24 @@ func TestLockedBufferTruncates(t *testing.T) {
t.Fatal("oversized output should be marked truncated")
}
}

func TestLaunchRunsInConfiguredDir(t *testing.T) {
dir := t.TempDir()
sj := NewShellJobsInDir(dir)
j := sj.mgr.launch(context.Background(), "pwd", false)
<-j.doneCh
out := strings.TrimSpace(j.stdout.String())
// macOS /var symlinks to /private/var; compare resolved paths.
want, _ := filepath.EvalSymlinks(dir)
got, _ := filepath.EvalSymlinks(out)
if got != want {
t.Fatalf("pwd = %q, want %q", got, want)
}
}

func TestLaunchEmptyDirUsesProcessCwd(t *testing.T) {
sj := NewShellJobs() // no dir → current behavior
if sj.mgr.dir != "" {
t.Fatalf("default manager dir = %q, want empty", sj.mgr.dir)
}
}
57 changes: 49 additions & 8 deletions mcp/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,27 @@ import (
// it or by writing it. This prevents blind edits against files the agent has
// never inspected.
type fileSystem struct {
// root, when non-empty, is the working directory that relative paths are
// rooted at. Empty means paths are used verbatim (legacy process-cwd
// behavior).
root string
mu sync.Mutex
seen map[string]bool
}

// newFileSystem creates a filesystem handler with an empty seen-file set.
func newFileSystem() *fileSystem {
return &fileSystem{seen: make(map[string]bool)}
// newFileSystem creates a filesystem handler with an empty seen-file set,
// rooted at root. An empty root preserves the legacy process-cwd behavior.
func newFileSystem(root string) *fileSystem {
return &fileSystem{root: root, seen: make(map[string]bool)}
}

// resolve roots a relative path at the configured working dir. Absolute paths
// and the empty-root case are returned unchanged.
func (f *fileSystem) resolve(path string) string {
if f.root == "" || filepath.IsAbs(path) {
return path
}
return filepath.Join(f.root, path)
}

// pathKey normalizes a path so the same file is recognized regardless of how
Expand Down Expand Up @@ -58,6 +72,7 @@ func (f *fileSystem) read(ctx context.Context, req *mcp.CallToolRequest, input r
readFileOutput,
error,
) {
input.Path = f.resolve(input.Path)
res, out, err := readFile(ctx, req, input)
if out.Success {
f.markSeen(input.Path)
Expand All @@ -72,6 +87,7 @@ func (f *fileSystem) write(ctx context.Context, req *mcp.CallToolRequest, input
writeFileOutput,
error,
) {
input.Path = f.resolve(input.Path)
res, out, err := writeFile(ctx, req, input)
if out.Success {
f.markSeen(input.Path)
Expand All @@ -86,6 +102,7 @@ func (f *fileSystem) edit(ctx context.Context, req *mcp.CallToolRequest, input e
editFileOutput,
error,
) {
input.Path = f.resolve(input.Path)
if !f.hasSeen(input.Path) {
return nil, editFileOutput{
Success: false,
Expand Down Expand Up @@ -137,6 +154,28 @@ type editFileOutput struct {
Error string `json:"error,omitempty" jsonschema:"error message if failed"`
}

// glob roots the base path at the working dir before delegating to globFiles.
// resolve("") returns "" when root is empty (globFiles then defaults to "."),
// and returns the root itself when a root is configured.
func (f *fileSystem) glob(ctx context.Context, req *mcp.CallToolRequest, input globFilesInput) (
*mcp.CallToolResult,
globFilesOutput,
error,
) {
input.Path = f.resolve(input.Path)
return globFiles(ctx, req, input)
}

// grep roots the base path at the working dir before delegating to grepFiles.
func (f *fileSystem) grep(ctx context.Context, req *mcp.CallToolRequest, input grepFilesInput) (
*mcp.CallToolResult,
grepFilesOutput,
error,
) {
input.Path = f.resolve(input.Path)
return grepFiles(ctx, req, input)
}

// Input type for glob operation
type globFilesInput struct {
Pat string `json:"pat" jsonschema:"the glob pattern to match files"`
Expand Down Expand Up @@ -540,16 +579,18 @@ func grepFiles(ctx context.Context, req *mcp.CallToolRequest, input grepFilesInp
}, nil
}

// StartFileSystemMCPServer starts the filesystem MCP server
func StartFileSystemMCPServer(ctx context.Context, transport mcp.Transport) error {
// StartFileSystemMCPServer starts the filesystem MCP server. When root is
// non-empty, relative paths are rooted at it; an empty root preserves the
// legacy process-cwd behavior.
func StartFileSystemMCPServer(ctx context.Context, transport mcp.Transport, root string) error {
// Create MCP server for filesystem operations
server := mcp.NewServer(&mcp.Implementation{
Name: "filesystem",
Version: "v1.0.0",
}, nil)

// Per-server state gating edits behind a prior read or write of the file.
fs := newFileSystem()
fs := newFileSystem(root)

// Add tool for reading files
mcp.AddTool(server, &mcp.Tool{
Expand All @@ -573,13 +614,13 @@ func StartFileSystemMCPServer(ctx context.Context, transport mcp.Transport) erro
mcp.AddTool(server, &mcp.Tool{
Name: "glob",
Description: "Find files by glob pattern, sorted by modification time (newest first)",
}, globFiles)
}, fs.glob)

// Add tool for grep file search
mcp.AddTool(server, &mcp.Tool{
Name: "grep",
Description: "Search files for regex pattern, returns up to 50 matches",
}, grepFiles)
}, fs.grep)

// Run the server
if err := server.Run(ctx, transport); err != nil {
Expand Down
26 changes: 22 additions & 4 deletions mcp/filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ func TestEditRequiresRead(t *testing.T) {
t.Fatal(err)
}

fs := newFileSystem()
fs := newFileSystem("")
ctx := context.Background()

// Editing before reading must be rejected and must not modify the file.
Expand Down Expand Up @@ -403,7 +403,7 @@ func TestEditAfterWrite(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "new.txt")

fs := newFileSystem()
fs := newFileSystem("")
ctx := context.Background()

if _, wout, err := fs.write(ctx, &mcp.CallToolRequest{}, writeFileInput{Path: tmpFile, Content: "foo bar"}); err != nil || !wout.Success {
Expand All @@ -427,7 +427,7 @@ func TestEditReadPathNormalization(t *testing.T) {
t.Fatal(err)
}

fs := newFileSystem()
fs := newFileSystem("")
ctx := context.Background()

// Read via a path containing a redundant "." segment.
Expand Down Expand Up @@ -698,7 +698,7 @@ func TestStartFileSystemMCPServer(t *testing.T) {

serverErrChan := make(chan error, 1)
go func() {
err := StartFileSystemMCPServer(ctx, serverTransport)
err := StartFileSystemMCPServer(ctx, serverTransport, "")
serverErrChan <- err
}()

Expand Down Expand Up @@ -749,3 +749,21 @@ func TestStartFileSystemMCPServer(t *testing.T) {
t.Error("server did not stop after context cancellation")
}
}

func TestFileSystemResolvesRelativeAgainstRoot(t *testing.T) {
root := t.TempDir()
fs := newFileSystem(root)
got := fs.resolve("notes.txt")
want := filepath.Join(root, "notes.txt")
if got != want {
t.Fatalf("resolve(rel) = %q, want %q", got, want)
}
abs := filepath.Join(t.TempDir(), "other.txt")
if fs.resolve(abs) != abs {
t.Fatalf("resolve(abs) must be unchanged, got %q", fs.resolve(abs))
}
// Empty root preserves the raw path (legacy cwd behavior).
if newFileSystem("").resolve("x.txt") != "x.txt" {
t.Fatalf("empty root must not rewrite paths")
}
}
4 changes: 2 additions & 2 deletions mcp/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

func StartTransports(ctx context.Context, cfg types.Config, shellJobs *ShellJobs) ([]mcp.Transport, error) {
if shellJobs == nil {
shellJobs = NewShellJobs()
shellJobs = NewShellJobsInDir(cfg.WorkingDir)
}
// Set MCP servers
bashMCPServerTransport, bashMCPServerClient := mcp.NewInMemoryTransports()
Expand All @@ -27,7 +27,7 @@ func StartTransports(ctx context.Context, cfg types.Config, shellJobs *ShellJobs
filesystemMCPServerTransport, filesystemMCPServerClient := mcp.NewInMemoryTransports()

go func() {
if err := StartFileSystemMCPServer(ctx, filesystemMCPServerTransport); err != nil {
if err := StartFileSystemMCPServer(ctx, filesystemMCPServerTransport, cfg.WorkingDir); err != nil {
fmt.Fprintf(os.Stderr, "Filesystem MCP server error: %v\n", err)
}
}()
Expand Down
9 changes: 9 additions & 0 deletions mcp/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,12 @@ func TestStartTransportsReturnsOnlyBuiltins(t *testing.T) {
t.Fatalf("StartTransports should ignore skills/mcp_servers now: %d vs %d", len(base), len(withExtras))
}
}

func TestStartTransportsUsesWorkingDir(t *testing.T) {
dir := t.TempDir()
// When the caller passes its own ShellJobs, StartTransports must NOT override it.
own := NewShellJobsInDir(dir)
if own.mgr.dir != dir {
t.Fatalf("precondition: own manager dir = %q", own.mgr.dir)
}
}
3 changes: 3 additions & 0 deletions types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ type Config struct {
// and re-applied on every turn (see Session.SendMessage), so a seeded system
// message would duplicate it. Set at runtime, never from the YAML config.
InitialHistory []openai.ChatCompletionMessage `yaml:"-"`
// WorkingDir, when non-empty, is the directory host tools (bash, filesystem)
// operate in. Runtime-only; empty means the process cwd (legacy behavior).
WorkingDir string `yaml:"-"`
}

func (c *Config) GetPrompt() string {
Expand Down
Loading