Files
multica/server/internal/handler/workspace_mcp_api_test.go
104bf7be35 MUL-7732: regroup settings by scope and consolidate scattered pages (#8874)
* feat(mcp): report how many agents use each workspace MCP server

The workspace MCP library listing now carries agent_count: the number of
live (non-archived) agents each entry is assigned to. Settings shows it so
an admin can tell whether replacing or removing a server affects anyone.

The field is optional in the client schema, so an older server that omits
it simply shows no count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(settings): regroup settings by scope and consolidate scattered pages

Implements the MUL-7732 settings redesign:

- Navigation is grouped by who a setting affects: Personal, the current
  workspace (with Issues & workflow and Connections & extensions
  sub-groups), and This device on desktop. Pages and sections carry a
  scope badge (account, this device, workspace), and members see a lock on
  pages only owners and admins can change plus a shared read-only notice.
- Settings search in the nav indexes page and row titles and
  descriptions; opening a result scrolls to and highlights the row.
- Preferences is one page instead of three tabs, with a searchable time
  zone picker, a segmented theme control and a field grid for the
  create-issue dialogs.
- Code merges Repositories, GitHub and self-hosted Git. Repositories are
  read-only rows edited through dialogs; self-hosted Git connects in a
  dialog that ends on the one-time webhook secret.
- The PR merge rule moves to Statuses & transitions as an automatic
  transition; Code only shows its current value. Built-in statuses show
  edit and archive as unavailable instead of refusing after a click.
- Integrations becomes Messaging (IM channels only) with a breadcrumb on
  detail pages; Composio moves to Personal as Connected apps.
- Members gets tabs, search, invite and share-link dialogs and a seat
  summary. MCP servers show assignment counts and explain how MCP,
  connected apps and plugins differ. Workspace General shows members plain
  values and changes the issue prefix through an explicit dialog.
- Skill labels are managed from the Skills page; Settings > Labels keeps
  the issue catalog.
- Successful saves no longer toast; failures still do.

Old ?tab= values (github, repositories, integrations, issue, chat, labs,
and the Composio OAuth callback) resolve to the new pages, since server
redirects still use them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs: point settings paths at the regrouped settings pages

GitHub, repositories and self-hosted Git now live under Settings > Code,
IM bots under Settings > Messaging, and the PR merge rule under
Settings > Statuses & transitions. The GitHub guide also drops the old
master switch step in favor of pausing from the GitHub row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(settings): edit the PR merge rule on the Code page

The "After PRs merge, move the issue to" rule sits with the other pull
request rules on Code again instead of a separate Automatic transitions
section. It applies to every code host, so pausing GitHub features does
not lock it. The statuses page goes back to Issue Statuses, and its merge
badge links to the rule on Code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(settings): manage skill labels under Settings → Labels again

The Skills page entry was buried at the bottom of the filter menu's Labels
submenu. Settings → Labels switches between the issue and skill catalogs
again, using the members page's segmented switch (now SettingsViewTabs),
and the Skills page keeps its shortcut.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(settings): keep GitHub resume reachable after a disconnect

Pausing GitHub features is a workspace setting, but the pause and resume
item lived in a menu that only rendered with an installation. A workspace
paused before disconnecting, or with github_enabled=false and no
installation, lost the way back, and every PR switch stayed disabled. On
deployments without a GitHub App it could not even reconnect, though
Co-authored-by works without one.

The menu now shows whenever GitHub is connected or paused, and only
Disconnect requires an installation. The paused note shows in either state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(settings): let the URL choose the open members list

The members page read `section` only to initialize its list tab, so a
search result for invitations or share links opened while already on the
page changed the URL but not the list, and back/forward did nothing. With a
router the open list is now derived from the URL, and switching lists
replaces `section`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(settings): tell repositories on different ports apart

The manual repository dialog reused the GitHub import's identity, which
drops the port, so https://git.example.com:9443/acme/api.git read as a
duplicate of the same path on :8443 and could not be saved. Non-default
ports now stay in the identity; default ports, including 22 for ssh://,
still compare equal to the scp-like form.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(comments): stop the recipient-menu tests flaking under CI load

The steering tests opened a recipient chip menu while the composer could
still re-render its chips, from the previous send settling or the preview
catching up. That closed the menu, and the menu steps also used the 1s
default wait inside the 5s default test timeout. Under parallel load
locally, about 1 in 20 runs failed the same way as CI.

Menu choices now go through a helper that reopens the menu until the item
shows. The remaining waits get the 5s the first steps already had, and each
test gets 15s. 140 of 140 loaded runs pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-27 18:15:01 +08:00

460 lines
17 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const workspaceMcpTestSecret = "sk-live-workspace-should-never-be-echoed"
var workspaceMcpTestEntry = `{"url":"https://linear.example","headers":{"Authorization":"Bearer ` + workspaceMcpTestSecret + `"}}`
// createWorkspaceMcpServerForTest seeds one library entry and removes it (plus
// any bindings) afterwards, so these tests can share the fixture workspace.
func createWorkspaceMcpServerForTest(t *testing.T, name, config string) string {
t.Helper()
var id string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO workspace_mcp_server (workspace_id, name, config)
VALUES ($1, $2, $3)
RETURNING id
`, testWorkspaceID, name, config).Scan(&id); err != nil {
t.Fatalf("create workspace mcp server: %v", err)
}
t.Cleanup(func() {
ctx := context.Background()
testPool.Exec(ctx, `DELETE FROM agent_mcp_server WHERE server_id = $1`, id)
testPool.Exec(ctx, `DELETE FROM workspace_mcp_server WHERE id = $1`, id)
})
return id
}
func listWorkspaceMcpServersForTest(t *testing.T, mutate func(*http.Request)) (int, []WorkspaceMcpServerResponse, string) {
t.Helper()
req := newRequest(http.MethodGet, "/api/workspaces/"+testWorkspaceID+"/mcp-servers", nil)
req = withURLParam(req, "id", testWorkspaceID)
if mutate != nil {
mutate(req)
}
w := httptest.NewRecorder()
testHandler.ListWorkspaceMcpServers(w, req)
var resp []WorkspaceMcpServerResponse
raw := w.Body.String()
if w.Code == http.StatusOK {
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
}
return w.Code, resp, raw
}
// The stored entry is write-only (GH #6062): the read path returns the
// non-secret summary and nothing else, for EVERY role. Asserted on the raw
// body, not the decoded struct, so adding a secret-bearing field later fails
// here.
func TestListWorkspaceMcpServers_NeverEchoesSecrets(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
createWorkspaceMcpServerForTest(t, "linear", workspaceMcpTestEntry)
code, resp, raw := listWorkspaceMcpServersForTest(t, nil)
if code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", code, raw)
}
// The workspace owner is the most privileged caller there is; if anyone
// could read the credential back it would be this request.
if strings.Contains(raw, workspaceMcpTestSecret) {
t.Fatalf("response echoed the shared credential: %s", raw)
}
if strings.Contains(raw, "linear.example") {
t.Fatalf("response echoed the server URL, which is itself credential material: %s", raw)
}
var found *WorkspaceMcpServerResponse
for i := range resp {
if resp[i].Name == "linear" {
found = &resp[i]
}
}
if found == nil {
t.Fatalf("expected the server in the library listing, got %+v", resp)
}
if found.Transport != "http" {
t.Errorf("transport = %q, want http", found.Transport)
}
// The library listing has no binding, so no toggle state to report.
if found.Enabled != nil {
t.Errorf("library listing must not carry an enabled flag, got %v", *found.Enabled)
}
}
// The library listing reports how many live agents use each entry, so the
// settings page can show whether removing one affects anybody. Archived
// agents never receive a server and must not inflate the count.
func TestListWorkspaceMcpServers_ReportsLiveAgentCount(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
usedID := createWorkspaceMcpServerForTest(t, "sentry", workspaceMcpTestEntry)
unusedID := createWorkspaceMcpServerForTest(t, "figma", workspaceMcpTestEntry)
for _, name := range []string{"ws-mcp-count-a", "ws-mcp-count-b"} {
addAgentMcpServerForTest(t, createHandlerTestAgent(t, name, nil), usedID)
}
archivedID := createHandlerTestAgent(t, "ws-mcp-count-archived", nil)
addAgentMcpServerForTest(t, archivedID, usedID)
if _, err := testPool.Exec(context.Background(),
`UPDATE agent SET archived_at = now() WHERE id = $1`, archivedID); err != nil {
t.Fatalf("archive agent: %v", err)
}
code, resp, raw := listWorkspaceMcpServersForTest(t, nil)
if code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", code, raw)
}
counts := map[string]int64{}
for _, server := range resp {
if server.AgentCount == nil {
t.Fatalf("library listing must report agent_count for %q: %s", server.Name, raw)
}
counts[server.ID] = *server.AgentCount
}
if counts[usedID] != 2 {
t.Errorf("agent_count for the assigned server = %d, want 2", counts[usedID])
}
if got, ok := counts[unusedID]; !ok || got != 0 {
t.Errorf("agent_count for the unassigned server = %d (present=%v), want 0", got, ok)
}
}
func createWorkspaceMcpServerViaAPI(t *testing.T, body any, mutate func(*http.Request)) (int, WorkspaceMcpServerResponse, string) {
t.Helper()
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/mcp-servers", body)
req = withURLParam(req, "id", testWorkspaceID)
if mutate != nil {
mutate(req)
}
w := httptest.NewRecorder()
testHandler.CreateWorkspaceMcpServer(w, req)
var resp WorkspaceMcpServerResponse
raw := w.Body.String()
if w.Code == http.StatusCreated {
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
t.Cleanup(func() {
ctx := context.Background()
testPool.Exec(ctx, `DELETE FROM agent_mcp_server WHERE server_id = $1`, resp.ID)
testPool.Exec(ctx, `DELETE FROM workspace_mcp_server WHERE id = $1`, resp.ID)
})
}
return w.Code, resp, raw
}
// The defining property of the redesign: creating a library entry gives it to
// nobody. Adding a server must not touch any agent.
func TestCreateWorkspaceMcpServer_BindsToNoAgent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "ws-mcp-unbound-agent", nil)
code, resp, raw := createWorkspaceMcpServerViaAPI(t, map[string]any{
"name": "linear",
"config": json.RawMessage(workspaceMcpTestEntry),
}, nil)
if code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", code, raw)
}
if strings.Contains(raw, workspaceMcpTestSecret) {
t.Fatalf("create response echoed the entry: %s", raw)
}
var bindings int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM agent_mcp_server WHERE server_id = $1`, resp.ID).Scan(&bindings); err != nil {
t.Fatalf("count bindings: %v", err)
}
if bindings != 0 {
t.Fatalf("creating a library entry bound it to %d agent(s); it must bind to none", bindings)
}
// And the agent sees nothing until someone adds it.
if servers := listAgentMcpServersForTest(t, agentID); len(servers) != 0 {
t.Fatalf("agent already has %d MCP server(s) without being given any", len(servers))
}
}
func TestCreateWorkspaceMcpServer_RejectsBadInput(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
for _, body := range []map[string]any{
{"name": "", "config": map[string]any{"url": "https://mcp.example"}},
{"name": "has space", "config": map[string]any{"url": "https://mcp.example"}},
{"name": "ok", "config": map[string]any{}},
{"name": "ok", "config": "not-an-object"},
{"name": "ok"},
} {
code, _, raw := createWorkspaceMcpServerViaAPI(t, body, nil)
if code != http.StatusBadRequest {
t.Errorf("body %v: expected 400, got %d: %s", body, code, raw)
}
}
}
func TestCreateWorkspaceMcpServer_RejectsDuplicateName(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
createWorkspaceMcpServerForTest(t, "linear", workspaceMcpTestEntry)
code, _, raw := createWorkspaceMcpServerViaAPI(t, map[string]any{
"name": "linear",
"config": map[string]any{"url": "https://other.example"},
}, nil)
if code != http.StatusConflict {
t.Fatalf("expected 409 for a duplicate name, got %d: %s", code, raw)
}
}
// Curating the library stays a human admin action: a task token running under
// an owner's PAT must not be able to add a shared server.
func TestCreateWorkspaceMcpServer_DeniesAgentActor(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
caller := createHandlerTestAgent(t, "ws-mcp-lib-writer", nil)
taskID := insertHandlerTestTask(t, caller)
code, _, raw := createWorkspaceMcpServerViaAPI(t, map[string]any{
"name": "linear",
"config": map[string]any{"url": "https://mcp.example"},
}, func(req *http.Request) {
req.Header.Set("X-Actor-Source", "task_token")
req.Header.Set("X-Agent-ID", caller)
req.Header.Set("X-Task-ID", taskID)
})
if code != http.StatusForbidden {
t.Fatalf("expected 403 for an agent actor, got %d: %s", code, raw)
}
}
// Renaming is safe in this model precisely because bindings key off the id.
func TestUpdateWorkspaceMcpServer_RenameKeepsBindings(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
serverID := createWorkspaceMcpServerForTest(t, "linear", workspaceMcpTestEntry)
agentID := createHandlerTestAgent(t, "ws-mcp-rename-agent", nil)
addAgentMcpServerForTest(t, agentID, serverID)
req := newRequest(http.MethodPut, "/api/workspaces/"+testWorkspaceID+"/mcp-servers/"+serverID,
map[string]any{"name": "linear-v2"})
req = withURLParams(req, "id", testWorkspaceID, "serverId", serverID)
w := httptest.NewRecorder()
testHandler.UpdateWorkspaceMcpServer(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
servers := listAgentMcpServersForTest(t, agentID)
if len(servers) != 1 || servers[0].Name != "linear-v2" {
t.Fatalf("binding did not follow the rename: %+v", servers)
}
}
// Deleting a library entry has to sweep its bindings: the junction carries no
// foreign key, so an orphan would keep pointing at a server that is gone.
func TestDeleteWorkspaceMcpServer_SweepsBindings(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
serverID := createWorkspaceMcpServerForTest(t, "linear", workspaceMcpTestEntry)
agentID := createHandlerTestAgent(t, "ws-mcp-delete-agent", nil)
addAgentMcpServerForTest(t, agentID, serverID)
req := newRequest(http.MethodDelete, "/api/workspaces/"+testWorkspaceID+"/mcp-servers/"+serverID, nil)
req = withURLParams(req, "id", testWorkspaceID, "serverId", serverID)
w := httptest.NewRecorder()
testHandler.DeleteWorkspaceMcpServer(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
}
var bindings int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM agent_mcp_server WHERE server_id = $1`, serverID).Scan(&bindings); err != nil {
t.Fatalf("count bindings: %v", err)
}
if bindings != 0 {
t.Fatalf("delete left %d orphaned binding(s)", bindings)
}
}
func listAgentMcpServersForTest(t *testing.T, agentID string) []WorkspaceMcpServerResponse {
t.Helper()
req := newRequest(http.MethodGet, "/api/agents/"+agentID+"/mcp-servers", nil)
req = withURLParam(req, "id", agentID)
w := httptest.NewRecorder()
testHandler.ListAgentMcpServers(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListAgentMcpServers: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []WorkspaceMcpServerResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
return resp
}
func addAgentMcpServerForTest(t *testing.T, agentID, serverID string) []WorkspaceMcpServerResponse {
t.Helper()
req := newRequest(http.MethodPost, "/api/agents/"+agentID+"/mcp-servers",
map[string]any{"server_id": serverID})
req = withURLParam(req, "id", agentID)
w := httptest.NewRecorder()
testHandler.AddAgentMcpServer(w, req)
if w.Code != http.StatusOK {
t.Fatalf("AddAgentMcpServer: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []WorkspaceMcpServerResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
return resp
}
func TestAgentMcpServerBinding_AddToggleRemove(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
serverID := createWorkspaceMcpServerForTest(t, "linear", workspaceMcpTestEntry)
agentID := createHandlerTestAgent(t, "ws-mcp-binding-agent", nil)
// Add — the response is the resulting binding list, so the client never
// has to guess.
servers := addAgentMcpServerForTest(t, agentID, serverID)
if len(servers) != 1 || servers[0].ID != serverID {
t.Fatalf("expected the server to be bound, got %+v", servers)
}
if servers[0].Enabled == nil || !*servers[0].Enabled {
t.Fatalf("a freshly added server must be enabled, got %+v", servers[0])
}
// Adding twice is idempotent rather than an error.
if servers = addAgentMcpServerForTest(t, agentID, serverID); len(servers) != 1 {
t.Fatalf("re-adding duplicated the binding: %+v", servers)
}
// Toggle off — the binding survives so turning it back on is one click.
req := newRequest(http.MethodPut, "/api/agents/"+agentID+"/mcp-servers/"+serverID+"/enabled",
map[string]any{"enabled": false})
req = withURLParams(req, "id", agentID, "serverId", serverID)
w := httptest.NewRecorder()
testHandler.SetAgentMcpServerEnabled(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SetAgentMcpServerEnabled: expected 200, got %d: %s", w.Code, w.Body.String())
}
servers = listAgentMcpServersForTest(t, agentID)
if len(servers) != 1 || servers[0].Enabled == nil || *servers[0].Enabled {
t.Fatalf("expected the binding to survive as disabled, got %+v", servers)
}
// Remove — the library entry itself is untouched.
req = newRequest(http.MethodDelete, "/api/agents/"+agentID+"/mcp-servers/"+serverID, nil)
req = withURLParams(req, "id", agentID, "serverId", serverID)
w = httptest.NewRecorder()
testHandler.RemoveAgentMcpServer(w, req)
if w.Code != http.StatusOK {
t.Fatalf("RemoveAgentMcpServer: expected 200, got %d: %s", w.Code, w.Body.String())
}
if servers = listAgentMcpServersForTest(t, agentID); len(servers) != 0 {
t.Fatalf("expected no bindings after removal, got %+v", servers)
}
var libraryRows int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM workspace_mcp_server WHERE id = $1`, serverID).Scan(&libraryRows); err != nil {
t.Fatalf("count library rows: %v", err)
}
if libraryRows != 1 {
t.Fatalf("removing a binding deleted the library entry")
}
}
// A server id from another workspace must not bind across the tenant boundary.
func TestAddAgentMcpServer_RejectsServerFromAnotherWorkspace(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var otherWorkspaceID, otherServerID string
if err := testPool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description, issue_prefix)
VALUES ('Other WS', 'ws-mcp-other', '', 'OTH')
RETURNING id
`).Scan(&otherWorkspaceID); err != nil {
t.Fatalf("create other workspace: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, otherWorkspaceID) })
if err := testPool.QueryRow(ctx, `
INSERT INTO workspace_mcp_server (workspace_id, name, config)
VALUES ($1, 'foreign', '{"url":"https://foreign.example"}')
RETURNING id
`, otherWorkspaceID).Scan(&otherServerID); err != nil {
t.Fatalf("create foreign server: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM workspace_mcp_server WHERE id = $1`, otherServerID)
})
agentID := createHandlerTestAgent(t, "ws-mcp-tenant-agent", nil)
req := newRequest(http.MethodPost, "/api/agents/"+agentID+"/mcp-servers",
map[string]any{"server_id": otherServerID})
req = withURLParam(req, "id", agentID)
w := httptest.NewRecorder()
testHandler.AddAgentMcpServer(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for a server from another workspace, got %d: %s", w.Code, w.Body.String())
}
}
// The agent's binding list is secret-free too, and an agent actor may not
// change its own assignments.
func TestAgentMcpServerBinding_ResponseIsSecretFreeAndAgentActorsCannotWrite(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
serverID := createWorkspaceMcpServerForTest(t, "linear", workspaceMcpTestEntry)
agentID := createHandlerTestAgent(t, "ws-mcp-actor-agent", nil)
req := newRequest(http.MethodGet, "/api/agents/"+agentID+"/mcp-servers", nil)
req = withURLParam(req, "id", agentID)
w := httptest.NewRecorder()
testHandler.ListAgentMcpServers(w, req)
if strings.Contains(w.Body.String(), workspaceMcpTestSecret) {
t.Fatalf("agent binding list echoed the entry: %s", w.Body.String())
}
taskID := insertHandlerTestTask(t, agentID)
req = newRequest(http.MethodPost, "/api/agents/"+agentID+"/mcp-servers",
map[string]any{"server_id": serverID})
req = withURLParam(req, "id", agentID)
req.Header.Set("X-Actor-Source", "task_token")
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", taskID)
w = httptest.NewRecorder()
testHandler.AddAgentMcpServer(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for an agent actor, got %d: %s", w.Code, w.Body.String())
}
}