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
2 changes: 2 additions & 0 deletions cmd/afs/auth_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ func TestCmdAuthStatusShowsSignedInCloudState(t *testing.T) {
func TestCmdStatusShowsSignedInCloudState(t *testing.T) {
t.Helper()

withTempHome(t)

cfg := defaultConfig()
cfg.ProductMode = productModeCloud
cfg.URL = "https://afs.example.com"
Expand Down
3 changes: 2 additions & 1 deletion cmd/afs/file_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ func TestCmdFSHistoryReportsExistingFileWithoutRecordedHistory(t *testing.T) {
cfg := defaultConfig()
cfg.RedisAddr = mr.Addr()
cfg.MountBackend = mountBackendNone
cfg.CurrentWorkspace = "repo"
saveTempConfig(t, cfg)

loadedCfg, store, closeStore, err := openAFSStore(context.Background())
Expand All @@ -214,7 +215,7 @@ func TestCmdFSHistoryReportsExistingFileWithoutRecordedHistory(t *testing.T) {
}

output, err := captureStdout(t, func() error {
return cmdFS([]string{"fs", "-w", "repo", "history", "helloworld.txt"})
return cmdFS([]string{"fs", "history", "helloworld.txt"})
})
if err != nil {
t.Fatalf("cmdFS(history) returned error: %v", err)
Expand Down
99 changes: 99 additions & 0 deletions internal/controlplane/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,105 @@ func TestMCPTokenFlowWorksWithoutAuth(t *testing.T) {
}
}

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

manager, _ := newTestManager(t)
server := httptest.NewServer(NewHandler(manager, "*"))
defer server.Close()

createReq, err := http.NewRequest(
http.MethodPost,
server.URL+"/v1/mcp-tokens",
strings.NewReader(`{"name":"Control plane token"}`),
)
if err != nil {
t.Fatalf("NewRequest(create control-plane token) returned error: %v", err)
}
createReq.Header.Set("Content-Type", "application/json")
createResp, err := http.DefaultClient.Do(createReq)
if err != nil {
t.Fatalf("POST control-plane mcp token returned error: %v", err)
}
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(createResp.Body)
t.Fatalf("POST control-plane mcp token status = %d, want %d, body=%s", createResp.StatusCode, http.StatusCreated, body)
}

var controlPlaneToken mcpAccessTokenResponse
if err := json.NewDecoder(createResp.Body).Decode(&controlPlaneToken); err != nil {
t.Fatalf("Decode(control-plane mcp token) returned error: %v", err)
}
if controlPlaneToken.Token == "" {
t.Fatal("expected created control-plane mcp token secret")
}
if controlPlaneToken.Scope != mcpScopeControlPlane {
t.Fatalf("control-plane token scope = %q, want %q", controlPlaneToken.Scope, mcpScopeControlPlane)
}

callBody := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mcp_token_issue","arguments":{"workspace":"repo","name":"Mounted FS","profile":"workspace-rw"}}}`
callReq, err := http.NewRequest(http.MethodPost, server.URL+"/mcp", strings.NewReader(callBody))
if err != nil {
t.Fatalf("NewRequest(mcp_token_issue) returned error: %v", err)
}
callReq.Header.Set("Content-Type", "application/json")
callReq.Header.Set("Authorization", "Bearer "+controlPlaneToken.Token)
callReq.Header.Set("Accept", "application/json, text/event-stream")
callResp, err := http.DefaultClient.Do(callReq)
if err != nil {
t.Fatalf("POST /mcp mcp_token_issue returned error: %v", err)
}
defer callResp.Body.Close()
if callResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(callResp.Body)
t.Fatalf("POST /mcp mcp_token_issue status = %d, want %d, body=%s", callResp.StatusCode, http.StatusOK, body)
}

var issuePayload struct {
Result struct {
StructuredContent map[string]any `json:"structuredContent"`
} `json:"result"`
}
if err := json.NewDecoder(callResp.Body).Decode(&issuePayload); err != nil {
t.Fatalf("Decode(mcp_token_issue response) returned error: %v", err)
}
issuedToken, _ := issuePayload.Result.StructuredContent["token"].(string)
if issuedToken == "" {
t.Fatalf("mcp_token_issue token = %#v, want non-empty", issuePayload.Result.StructuredContent["token"])
}
if got := issuePayload.Result.StructuredContent["workspace"]; got != "repo" {
t.Fatalf("mcp_token_issue workspace = %#v, want %q", got, "repo")
}
if got := issuePayload.Result.StructuredContent["profile"]; got != MCPProfileWorkspaceRW {
t.Fatalf("mcp_token_issue profile = %#v, want %q", got, MCPProfileWorkspaceRW)
}
if got, _ := issuePayload.Result.StructuredContent["scope"].(string); !strings.HasPrefix(got, mcpScopeWorkspacePrefix) {
t.Fatalf("mcp_token_issue scope = %#v, want workspace scope", issuePayload.Result.StructuredContent["scope"])
}

toolsReq, err := http.NewRequest(
http.MethodPost,
server.URL+"/mcp",
strings.NewReader(`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`),
)
if err != nil {
t.Fatalf("NewRequest(workspace-token tools/list) returned error: %v", err)
}
toolsReq.Header.Set("Content-Type", "application/json")
toolsReq.Header.Set("Authorization", "Bearer "+issuedToken)
toolsReq.Header.Set("Accept", "application/json, text/event-stream")
toolsResp, err := http.DefaultClient.Do(toolsReq)
if err != nil {
t.Fatalf("POST /mcp workspace-token tools/list returned error: %v", err)
}
defer toolsResp.Body.Close()
if toolsResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(toolsResp.Body)
t.Fatalf("POST /mcp workspace-token tools/list status = %d, want %d, body=%s", toolsResp.StatusCode, http.StatusOK, body)
}
}

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

Expand Down
6 changes: 6 additions & 0 deletions internal/controlplane/mcp_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,12 @@ func (m *DatabaseManager) requireOwnedSubject(ctx context.Context) (string, stri
return "", "", nil
}
if strings.TrimSpace(identity.Subject) == "" {
// Self-managed no-auth installs can reach this path with an ownerless
// control-plane MCP token. Those tokens are intentionally allowed to mint
// workspace-scoped MCP tokens within the self-managed policy surface.
if strings.TrimSpace(identity.Provider) == "mcp-token" && isControlPlaneScope(identity.Scope) {
return "", "", nil
}
if strings.TrimSpace(identity.Provider) == "" || strings.TrimSpace(identity.Provider) == string(AuthModeNone) {
return "", "", nil
}
Expand Down
76 changes: 75 additions & 1 deletion sdk/python/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import unittest
from unittest.mock import patch

from redis_afs.client import MCPHttpClient, AFSError, MountedFS, _MountedWorkspace, _normalize_mcp_endpoint
from redis_afs.client import AFSError, FSClient, MCPHttpClient, MountedFS, _MountedWorkspace, _normalize_mcp_endpoint


class FakeMCP:
Expand Down Expand Up @@ -34,6 +35,64 @@ def call_tool(self, name, arguments=None):
raise AssertionError(f"unexpected tool {name}")


class FakeControlPlane:
def __init__(self):
self.issued = []
self.timeout = 30.0
self.endpoint = "https://afs.example/mcp"

def call_tool(self, name, arguments=None):
arguments = arguments or {}
if name != "mcp_token_issue":
raise AssertionError(f"unexpected tool {name}")
token = f"workspace-token-{arguments['workspace']}"
self.issued.append({"name": name, "arguments": dict(arguments), "token": token})
return {
"token": token,
"url": "https://afs.example/mcp",
"workspace": arguments["workspace"],
"profile": arguments["profile"],
"scope": f"workspace:{arguments['workspace']}",
}


class FakeMountedMCPHttpClient:
files_by_token = {}

def __init__(self, *, api_key, base_url=None, timeout=30.0, headers=None):
self.api_key = api_key
self.endpoint = base_url or "https://afs.example/mcp"
self.timeout = timeout
self.headers = dict(headers or {})

def call_tool(self, name, arguments=None):
arguments = arguments or {}
files = self.files_by_token.setdefault(self.api_key, {})
if name == "file_write":
files[arguments["path"]] = arguments["content"]
return {"path": arguments["path"], "operation": "write"}
if name == "file_read":
return {
"path": arguments["path"],
"kind": "file",
"content": files.get(arguments["path"], ""),
}
if name == "file_list":
path = arguments.get("path", "/")
entries = []
for file_path in sorted(files):
if path == "/" and "/" not in file_path.strip("/"):
entries.append({"path": file_path, "name": file_path.strip("/"), "kind": "file"})
elif file_path.startswith(path.rstrip("/") + "/"):
remainder = file_path[len(path.rstrip("/")) + 1 :]
if "/" not in remainder:
entries.append({"path": file_path, "name": remainder, "kind": "file"})
return {"entries": entries}
if name == "checkpoint_create":
return {"workspace": "workspace", "checkpoint": arguments.get("checkpoint") or "auto", "created": True}
raise AssertionError(f"unexpected tool {name}")


class MountedFSTest(unittest.TestCase):
def test_single_workspace_paths_are_workspace_relative(self):
fake = FakeMCP()
Expand Down Expand Up @@ -68,6 +127,21 @@ def test_maps_absolute_workspace_paths_after_materialization(self):
self.assertIn(root, mapped)
self.assertNotEqual(mapped, "cat /foobar/README.md")

def test_fs_mount_issues_workspace_token_and_reads_and_writes_files(self):
control_plane = FakeControlPlane()

with patch("redis_afs.client.MCPHttpClient", FakeMountedMCPHttpClient):
fs = FSClient(control_plane).mount(workspaces=[{"name": "repo"}], mode="rw", token_name="Mounted FS")
self.addCleanup(fs.close)

fs.write_file("/repo/README.md", "hello from mounted fs")

self.assertEqual(fs.read_file("/repo/README.md"), "hello from mounted fs")
self.assertEqual(fs.workspace_names, ["repo"])
self.assertEqual(control_plane.issued[0]["arguments"]["workspace"], "repo")
self.assertEqual(control_plane.issued[0]["arguments"]["profile"], "workspace-rw")
self.assertEqual(control_plane.issued[0]["arguments"]["name"], "Mounted FS")


class EndpointTest(unittest.TestCase):
def test_normalizes_mcp_endpoint(self):
Expand Down
Loading