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
29 changes: 29 additions & 0 deletions cmd/afs/afs_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,18 @@ func (s *afsMCPServer) Tools(_ context.Context) []mcpproto.Tool {
"required": []string{"path"},
},
},
{
Name: "file_delete",
Description: "Delete one file, symlink, or empty directory from a workspace. Refuses root and non-empty directories.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"workspace": map[string]string{"type": "string", "description": "Workspace name (defaults to current workspace)"},
"path": map[string]string{"type": "string", "description": "Absolute workspace path, for example /src/main.go"},
},
"required": []string{"path"},
},
},
{
Name: "file_lines",
Description: "Read a specific line range from a text file. Use this instead of file_read when the file is large or you only need a slice. This is for text files only. Do not use it for directory listing or cross-file search. Paths must be absolute inside the workspace, for example /src/main.go.",
Expand Down Expand Up @@ -637,6 +649,8 @@ func (s *afsMCPServer) CallTool(ctx context.Context, name string, args map[strin
value, err = s.toolFileRestoreVersion(ctx, args)
case "file_undelete":
value, err = s.toolFileUndelete(ctx, args)
case "file_delete":
value, err = s.toolFileDelete(ctx, args)
case "file_lines":
value, err = s.toolFileLines(ctx, args)
case "file_list":
Expand Down Expand Up @@ -1509,6 +1523,21 @@ func (s *afsMCPServer) toolFileDeleteLines(ctx context.Context, args map[string]
})
}

func (s *afsMCPServer) toolFileDelete(ctx context.Context, args map[string]any) (any, error) {
return s.mutateWorkspaceFile(ctx, args, func(ctx context.Context, fsClient client.Client, normalizedPath string, stat *client.StatResult) (map[string]any, error) {
if stat == nil {
return nil, os.ErrNotExist
}
if err := fsClient.Rm(ctx, normalizedPath); err != nil {
return nil, err
}
return map[string]any{
"operation": "delete",
"kind": stat.Type,
}, nil
})
}

func (s *afsMCPServer) toolFilePatch(ctx context.Context, args map[string]any) (any, error) {
var input mcpFilePatchInput
if err := decodeMCPArgs(args, &input); err != nil {
Expand Down
119 changes: 118 additions & 1 deletion cmd/afs/afs_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/alicebob/miniredis/v2"
"github.com/redis/agent-filesystem/internal/controlplane"
"github.com/redis/agent-filesystem/internal/mcptools"
mountclient "github.com/redis/agent-filesystem/mount/client"
)

func TestAFSMCPServerInitializeAndToolsList(t *testing.T) {
Expand Down Expand Up @@ -71,15 +72,23 @@ func TestAFSMCPServerInitializeAndToolsList(t *testing.T) {
if len(tools) == 0 {
t.Fatal("tools/list returned no tools")
}
foundFileDelete := false
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if !ok {
continue
}
if name, _ := tool["name"].(string); name == "file_delete_version" {
name, _ := tool["name"].(string)
if name == "file_delete" {
foundFileDelete = true
}
if name == "file_delete_version" {
t.Fatalf("tools/list unexpectedly exposes %q", name)
}
}
if !foundFileDelete {
t.Fatal("tools/list did not expose file_delete")
}
}

func TestAFSMCPFileWriteLeavesWorkspaceDirtyAndReadReturnsContent(t *testing.T) {
Expand Down Expand Up @@ -626,6 +635,114 @@ func TestAFSMCPFilePatchAppliesStructuredEdits(t *testing.T) {
}
}

func TestAFSMCPFileDeleteRemovesFile(t *testing.T) {
t.Helper()

server, closeFn := setupAFSMCPTestServer(t)
defer closeFn()

if _, err := server.toolFileWrite(context.Background(), map[string]any{
"path": "/docs/remove-me.md",
"content": "delete me\n",
}); err != nil {
t.Fatalf("toolFileWrite(remove-me.md) returned error: %v", err)
}

result := server.callTool(context.Background(), "file_delete", map[string]any{
"path": "/docs/remove-me.md",
})
if result.IsError {
t.Fatalf("file_delete returned error result: %+v", result)
}

var payload map[string]any
if err := decodeStructuredContent(result.StructuredContent, &payload); err != nil {
t.Fatalf("decodeStructuredContent(delete) returned error: %v", err)
}
if got, _ := payload["operation"].(string); got != "delete" {
t.Fatalf("operation = %#v, want %q", payload["operation"], "delete")
}

readResult := server.callTool(context.Background(), "file_read", map[string]any{
"path": "/docs/remove-me.md",
})
if !readResult.IsError {
t.Fatalf("file_read after delete succeeded: %+v", readResult)
}
}

func TestAFSMCPFileDeleteRemovesEmptyDirectory(t *testing.T) {
t.Helper()

ctx := context.Background()
server, closeFn := setupAFSMCPTestServer(t)
defer closeFn()

fsKey, _, _, err := server.store.ensureWorkspaceRoot(ctx, "repo")
if err != nil {
t.Fatalf("ensureWorkspaceRoot() returned error: %v", err)
}
fsClient := mountclient.New(server.store.rdb, fsKey)
if err := fsClient.Mkdir(ctx, "/docs/empty"); err != nil {
t.Fatalf("Mkdir(/docs/empty) returned error: %v", err)
}

result := server.callTool(ctx, "file_delete", map[string]any{
"path": "/docs/empty",
})
if result.IsError {
t.Fatalf("file_delete returned error result: %+v", result)
}

var payload map[string]any
if err := decodeStructuredContent(result.StructuredContent, &payload); err != nil {
t.Fatalf("decodeStructuredContent(delete) returned error: %v", err)
}
if got, _ := payload["kind"].(string); got != "dir" {
t.Fatalf("kind = %#v, want %q", payload["kind"], "dir")
}
if stat, err := mountclient.New(server.store.rdb, fsKey).Stat(ctx, "/docs/empty"); err != nil {
t.Fatalf("Stat(/docs/empty) returned error after delete: %v", err)
} else if stat != nil {
t.Fatalf("Stat(/docs/empty) after delete = %#v, want nil", stat)
}
}

func TestAFSMCPFileDeleteRefusesNonEmptyDirectory(t *testing.T) {
t.Helper()

server, closeFn := setupAFSMCPTestServer(t)
defer closeFn()

if _, err := server.toolFileWrite(context.Background(), map[string]any{
"path": "/docs/keep/file.md",
"content": "still here\n",
}); err != nil {
t.Fatalf("toolFileWrite(file.md) returned error: %v", err)
}

result := server.callTool(context.Background(), "file_delete", map[string]any{
"path": "/docs/keep",
})
if !result.IsError {
t.Fatal("file_delete should refuse a non-empty directory")
}
}

func TestAFSMCPFileDeleteRefusesRoot(t *testing.T) {
t.Helper()

server, closeFn := setupAFSMCPTestServer(t)
defer closeFn()

result := server.callTool(context.Background(), "file_delete", map[string]any{
"path": "/",
})
if !result.IsError {
t.Fatal("file_delete should refuse root")
}
}

func TestAFSMCPStatusAndWorkspaceCurrentPreferActiveSyncWorkspace(t *testing.T) {
t.Helper()

Expand Down
23 changes: 17 additions & 6 deletions cmd/afs/embedded/skills/afs/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: agent-filesystem
description: Persistent Redis-backed workspaces for agents. Use via `afs mcp`, the `afs` CLI, sync mode, live mounts, and explicit checkpoints.
description: "Use when agents need persistent shared storage, when saving or restoring workspace state, or when coordinating file access across multiple agents and machines. Creates Redis-backed workspaces, checkpoints and restores agent state, mounts shared filesystems locally, searches workspace contents, and forks workspaces for parallel work."
---

# Agent Filesystem
Expand All @@ -12,10 +12,11 @@ explicit checkpoints and easy movement between MCP, sync mode, and live mounts.
## When to Use This Skill

**Use for:**
- Persistent agent workspaces
- Code or docs that should live in a normal directory
- Shared notes/config/state that benefit from checkpoints and forks
- Searchable workspaces where `afs fs grep` or MCP file tools are useful
- Persistent agent workspaces that survive across sessions and machines
- Code, docs, or shared state that should live in a normal directory backed by Redis
- Saving and restoring workspace snapshots with explicit checkpoints
- Searching workspace contents with `afs fs grep` or MCP file tools
- Forking a workspace to run parallel experiments without losing the original

**Avoid for:**
- Large build output, media, or disposable artifacts
Expand Down Expand Up @@ -46,11 +47,13 @@ workspace exposed directly as a mount rather than through the sync daemon.
```bash
./afs ws create my-project
./afs ws import my-project ./existing-dir
./afs ws list # verify the workspace exists
```

### Start working locally
```bash
./afs ws mount my-project ~/my-project
./afs status # verify the mount is active
cd ~/my-project
```

Expand All @@ -63,13 +66,15 @@ cd ~/my-project
### Save and restore stable points
```bash
./afs cp create my-project before-refactor
./afs cp list my-project
./afs cp list my-project # verify the checkpoint was saved
./afs cp restore my-project before-refactor
./afs cp list my-project # confirm the restore completed
```

### Fork work for a second line of effort
```bash
./afs ws fork my-project my-project-experiment
./afs ws list # verify the fork appears
```

## Key Points
Expand All @@ -81,3 +86,9 @@ cd ~/my-project
- File edits change the live workspace immediately.
- Create checkpoints explicitly when you want a restore point.
- `.afsignore` controls what gets imported from an existing local directory.

## Further Reading

- `docs/guides/agent-filesystem.md` — agent-facing usage guide
- `docs/reference/cli.md` — full CLI command reference
- `docs/reference/mcp.md` — MCP tool reference for agent integrations
13 changes: 12 additions & 1 deletion docs/reference/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ use a workspace-scoped hosted token.
| Status/admin | `afs_status`, `workspace_list`, `workspace_create`, `workspace_fork` |
| Checkpoints | `checkpoint_list`, `checkpoint_create`, `checkpoint_restore` |
| File reads | `file_read`, `file_lines`, `file_list`, `file_glob`, `file_grep`, `file_query` |
| File writes | `file_write`, `file_create_exclusive`, `file_replace`, `file_insert`, `file_delete_lines`, `file_patch` |
| File writes | `file_write`, `file_create_exclusive`, `file_replace`, `file_insert`, `file_delete`, `file_delete_lines`, `file_patch` |
| Hosted token administration | `mcp_token_issue`, `mcp_token_revoke` |

Hosted workspace-scoped MCP exposes the workspace file and checkpoint tools.
Expand Down Expand Up @@ -395,6 +395,17 @@ Arguments:
| `start` | Yes | Start line, 1-indexed. |
| `end` | Yes | End line, inclusive. |

### `file_delete`

Deletes one file, symlink, or empty directory. Non-empty directories are refused.

Arguments:

| Field | Required | Meaning |
| --- | --- | --- |
| `workspace` | No | Local MCP workspace override. |
| `path` | Yes | Absolute file, symlink, or empty directory path. |

### `file_patch`

Applies one or more structured text patches.
Expand Down
46 changes: 45 additions & 1 deletion internal/controlplane/mcp_hosted.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,17 @@ func (p *hostedMCPProvider) workspaceTools() []mcpproto.Tool {
"required": []string{"path"},
},
},
{
Name: "file_delete",
Description: "Delete one file, symlink, or empty directory from the current workspace. Refuses root and non-empty directories.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]string{"type": "string", "description": "Absolute path inside the workspace"},
},
"required": []string{"path"},
},
},
{
Name: "file_lines",
Description: "Read a specific line range from a text file",
Expand Down Expand Up @@ -710,6 +721,11 @@ func (origProvider *hostedMCPProvider) callWorkspaceTool(ctx context.Context, na
if err == nil {
value, err = p.toolFileUndelete(ctx, args)
}
case "file_delete":
err = p.ensureWritable()
if err == nil {
value, err = p.toolFileDelete(ctx, args)
}
case "file_lines":
value, err = p.toolFileLines(ctx, args)
case "file_list":
Expand Down Expand Up @@ -1443,6 +1459,21 @@ func (p *hostedMCPProvider) toolFileDeleteLines(ctx context.Context, args map[st
})
}

func (p *hostedMCPProvider) toolFileDelete(ctx context.Context, args map[string]any) (any, error) {
return p.mutateWorkspaceFile(ctx, args, func(ctx context.Context, fsClient afsclient.Client, normalizedPath string, stat *afsclient.StatResult) (map[string]any, error) {
if stat == nil {
return nil, os.ErrNotExist
}
if err := fsClient.Rm(ctx, normalizedPath); err != nil {
return nil, err
}
return map[string]any{
"operation": "delete",
"kind": stat.Type,
}, nil
})
}

func (p *hostedMCPProvider) toolFilePatch(ctx context.Context, args map[string]any) (any, error) {
var input mcpFilePatchInput
if err := decodeMCPArgs(args, &input); err != nil {
Expand Down Expand Up @@ -1761,7 +1792,7 @@ func (p *hostedMCPProvider) mutateWorkspaceFile(ctx context.Context, args map[st
}
entry := template
entry.Path = normalizedPath
entry.Op = ChangeOpPut
entry.Op = hostedMCPMutationChangeOp(stat, updatedStat, beforeSnapshot, afterSnapshot)
entry.PrevHash = beforeSnapshot.ContentHash
entry.DeltaBytes = -beforeSnapshot.SizeBytes
if afterSnapshot.Exists {
Expand All @@ -1780,6 +1811,19 @@ func (p *hostedMCPProvider) mutateWorkspaceFile(ctx context.Context, args map[st
return payload, nil
}

func hostedMCPMutationChangeOp(beforeStat, afterStat *afsclient.StatResult, beforeSnapshot, afterSnapshot VersionedFileSnapshot) string {
if afterStat == nil && beforeStat != nil {
return deleteOpFor(beforeStat.Type)
}
if afterStat != nil {
return modifyOpFor(afterStat.Type)
}
if !afterSnapshot.Exists && beforeSnapshot.Exists {
return deleteOpFor(beforeSnapshot.Kind)
}
return ChangeOpPut
}

func ensureHostedWorkspaceParentDirs(ctx context.Context, fsClient afsclient.Client, normalizedPath string) error {
trimmed := strings.Trim(normalizedPath, "/")
if trimmed == "" {
Expand Down
Loading