From 163b7a684de0ec10eb9b7a3501e76b120f29f156 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 4 May 2026 09:36:48 -0700 Subject: [PATCH] Fix ownerless MCP token minting --- cmd/afs/auth_commands_test.go | 2 + cmd/afs/file_commands_test.go | 3 +- internal/controlplane/http_test.go | 99 +++++++++++++++++++++++++++++ internal/controlplane/mcp_tokens.go | 6 ++ sdk/python/tests/test_client.py | 76 +++++++++++++++++++++- 5 files changed, 184 insertions(+), 2 deletions(-) diff --git a/cmd/afs/auth_commands_test.go b/cmd/afs/auth_commands_test.go index 89e77c0..1aa8c69 100644 --- a/cmd/afs/auth_commands_test.go +++ b/cmd/afs/auth_commands_test.go @@ -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" diff --git a/cmd/afs/file_commands_test.go b/cmd/afs/file_commands_test.go index 68551d5..de67ded 100644 --- a/cmd/afs/file_commands_test.go +++ b/cmd/afs/file_commands_test.go @@ -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()) @@ -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) diff --git a/internal/controlplane/http_test.go b/internal/controlplane/http_test.go index ae96f8a..9c2b104 100644 --- a/internal/controlplane/http_test.go +++ b/internal/controlplane/http_test.go @@ -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() diff --git a/internal/controlplane/mcp_tokens.go b/internal/controlplane/mcp_tokens.go index ba2451e..717da25 100644 --- a/internal/controlplane/mcp_tokens.go +++ b/internal/controlplane/mcp_tokens.go @@ -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 } diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 7391214..e7c1fef 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -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: @@ -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() @@ -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):