Files
multica/server/internal/handler/workspace_mcp.go
2c0912b6ec MUL-5421: make workspace MCP servers an explicit per-agent assignment (#6976)
* feat: make workspace MCP servers an explicit per-agent assignment

#6961 shipped workspace MCP as a shared document every agent inherited
automatically. That is the wrong shape: a workspace MCP server should behave
like a workspace skill — a library entry that reaches an agent only when
someone gives it to that agent, with a per-agent on/off toggle. Creating one
now gives it to nobody, so nothing propagates implicitly.

Model (mirrors `skill` + `agent_skill`):

  - `workspace_mcp_server` — the library, keyed by id so renaming is safe
  - `agent_mcp_server` — the assignment, carrying its own `enabled` toggle
  - the agent's own `mcp_config` stays what it was: servers private to it

At claim time the effective set is runtime servers < (assigned AND enabled
workspace servers + the agent's own), with the agent's own entry winning a name
collision. 318 drops `workspace.mcp_config`: leaving it would keep a second,
silently inherited source alive. No data migration — converting each workspace
document into library entries would ASSIGN them to every agent, which is the
behaviour being removed, and the column shipped in the same unreleased cycle.

The write-only secret contract, the legacy-container normalization, and the
transport guards from the previous round are all preserved; the resolver is
re-pointed at the assignment list rather than a workspace document.

UI: workspace Settings → MCP is now a library (adding says so, and renaming is
allowed because assignments key off the id); the agent's MCP tab replaces the
auto "Inherited from workspace" list with the servers actually assigned to it,
a picker to add more from the library, and a per-assignment toggle.

Co-authored-by: multica-agent <[email protected]>

* fix: fence the MCP writes, stop laundering unknown transports, gate on canEdit

Three findings from review:

1. Neither new table carries a foreign key (repo rule), so nothing in the
   database catches a write landing after the row it depends on is gone. Create
   and assign now run in a transaction that joins the workspace teardown fence
   (#5219), and assign additionally holds the target server FOR SHARE while
   DeleteWorkspaceMcpServer holds it FOR UPDATE — so an assignment either lands
   before the delete and is swept, or waits and finds the server gone. Driven
   by two two-transaction race tests, verified to fail without the locks.

2. `mcpTransportOf` classified `{"type":"websocket","url":"wss://…"}` as http,
   because an unrecognised type fell through to the url check. That told the
   settings UI the guided form could edit the entry, and saving from it rewrites
   the protocol to `type: "http"` — the failure the client-side guard was added
   to prevent, reintroduced from the server. An explicit type is now returned
   verbatim; inference from command / url happens only when none was declared,
   which is lossless.

3. The agent MCP tab showed the picker, toggles, and remove button to members
   who cannot edit the agent, where every click 403s. It now takes `canEdit`
   like SkillsTab and hides the write affordances, keeping the inventory
   readable.

318 keeps dropping `workspace.mcp_config` with no data migration and no
expand-contract window, now recorded as an explicit maintainer decision (Bohan,
2026-08-14): 314 merged in this same cycle and was never deployed, so there is
no stored document to preserve and no old binary reading the column. The
comment spells out what this file would have to become if that ever stops
holding.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 19:49:37 +08:00

194 lines
7.1 KiB
Go

package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
)
// mcpServerContainers are the two top-level keys an mcp_config document may
// keep its servers under. `mcpServers` is the Claude-style container every
// runtime consumes; `mcp` is a legacy spelling a few agents still carry and
// which the agent settings UI keeps editing in place (mcp-config-model.ts).
// Both are read when deciding what an agent already declares; only
// `mcpServers` is ever written.
var mcpServerContainers = [...]string{"mcpServers", "mcp"}
// WorkspaceMcpBinding is one workspace MCP server that an agent has been
// explicitly given: the name it is mounted under plus its entry.
type WorkspaceMcpBinding struct {
Name string
Config json.RawMessage
}
// ResolveAgentMcpConfig folds the workspace MCP servers BOUND to an agent into
// that agent's own mcp_config and returns what the claim should carry. It runs
// before the per-task overlay (mergeMCPOverlay), so the full precedence chain
// on a claim is:
//
// bound workspace servers < agent's own servers < per-task overlay
//
// The contract (GH #6062, MUL-5421):
//
// - Only servers explicitly bound to this agent and left enabled are folded
// in. A workspace MCP server is a LIBRARY entry, exactly like a workspace
// skill: creating one gives it to nobody. Nothing about this layer reaches
// an agent implicitly, which is what keeps shared credentials from
// spreading to agents no one opted in.
//
// - The agent's own entry WINS on a name collision. Its config is the more
// specific statement, and it is the one an agent owner edits directly.
//
// - An agent with no bindings and no config of its own resolves to nil —
// "nothing managed" — so its runtime keeps its native MCP inheritance.
//
// Shape: the result is NORMALIZED onto the canonical `mcpServers` container,
// including an agent's legacy top-level `mcp` entries. Normalizing is
// mandatory, not cosmetic: the daemon's runtime merge only falls back to the
// legacy container when `mcpServers` is ABSENT
// (`internal/daemon/runtime_mcp.go`), so writing bound servers into
// `mcpServers` while leaving an agent's legacy entries beside them would make
// the daemon read the bound set and silently drop the agent's own servers.
//
// Failure mode: on a malformed agent config the config is returned unchanged
// along with the error. Binding a shared server must never take away servers
// the agent runs with today.
func ResolveAgentMcpConfig(bound []WorkspaceMcpBinding, agentMcpConfig json.RawMessage) (json.RawMessage, error) {
if len(bound) == 0 {
return passthroughAgentMcpConfig(agentMcpConfig), nil
}
shared := make(map[string]json.RawMessage, len(bound))
for _, server := range bound {
if server.Name == "" || !hasManagedJSON(server.Config) {
continue
}
shared[server.Name] = server.Config
}
if len(shared) == 0 {
return passthroughAgentMcpConfig(agentMcpConfig), nil
}
if !hasManagedJSON(agentMcpConfig) {
// The agent declares nothing of its own: it runs with exactly the
// servers it was given.
out, err := json.Marshal(map[string]any{"mcpServers": shared})
if err != nil {
return nil, fmt.Errorf("resolve agent mcp_config: marshal bound servers: %w", err)
}
return out, nil
}
agentDoc, agentServers, err := parseMcpDocument(agentMcpConfig)
if err != nil {
return passthroughAgentMcpConfig(agentMcpConfig), fmt.Errorf("resolve agent mcp_config: parse agent mcp_config: %w", err)
}
merged := make(map[string]json.RawMessage, len(shared)+len(agentServers))
for name, server := range shared {
// The agent may declare this name in either container; agentServers
// spans both, so this check covers each spelling.
if _, taken := agentServers[name]; taken {
continue
}
merged[name] = server
}
// Fold the agent's own entries in, legacy container first so a name present
// in both resolves to the canonical one — the precedence the agent settings
// UI already shows (mcp-config-model.ts reads `mcpServers` before `mcp`).
for _, container := range [...]string{"mcp", "mcpServers"} {
own, err := unmarshalServerMap(agentDoc[container])
if err != nil {
return passthroughAgentMcpConfig(agentMcpConfig), fmt.Errorf("resolve agent mcp_config: agent %s: %w", container, err)
}
for name, server := range own {
merged[name] = server
}
}
out := make(map[string]json.RawMessage, len(agentDoc)+1)
for k, v := range agentDoc {
// Both containers are consumed into the canonical map above; leaving
// the legacy key behind would hand the daemon a second, stale copy.
if k == "mcpServers" || k == "mcp" {
continue
}
out[k] = v
}
serversBytes, err := json.Marshal(merged)
if err != nil {
return nil, fmt.Errorf("resolve agent mcp_config: marshal merged servers: %w", err)
}
out["mcpServers"] = serversBytes
final, err := json.Marshal(out)
if err != nil {
return nil, fmt.Errorf("resolve agent mcp_config: marshal merged document: %w", err)
}
return final, nil
}
// parseMcpDocument decodes an mcp_config document into its top-level keys plus
// the set of server names it declares across BOTH containers. The name set is
// what "the agent already owns this name" keys off, so it must span the legacy
// spelling too.
func parseMcpDocument(raw json.RawMessage) (map[string]json.RawMessage, map[string]struct{}, error) {
var doc map[string]json.RawMessage
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, nil, err
}
if doc == nil {
doc = map[string]json.RawMessage{}
}
names := map[string]struct{}{}
for _, container := range mcpServerContainers {
servers, err := unmarshalServerMap(doc[container])
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", container, err)
}
for name := range servers {
names[name] = struct{}{}
}
}
return doc, names, nil
}
// validateWorkspaceMcpServerEntry checks the shape of ONE server entry before
// it is stored. Deliberately shallow — shape only, never the contents, which
// are runtime-specific and carry secrets we do not want to inspect or echo
// back in an error.
func validateWorkspaceMcpServerEntry(raw json.RawMessage) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return errors.New("config must be a JSON object")
}
var entry map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &entry); err != nil {
// Never wrap: the underlying error can echo fragments of an entry that
// routinely embeds API tokens.
return errors.New("config must be a JSON object")
}
if len(entry) == 0 {
return errors.New("config must not be empty")
}
return nil
}
// validateWorkspaceMcpServerName checks a server name. The name is what the
// runtime mounts the server under and what an agent's own config collides
// with, so it follows the same rule the agent settings dialog enforces.
func validateWorkspaceMcpServerName(name string) error {
if name == "" {
return errors.New("name is required")
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return errors.New("name may only contain letters, digits, hyphens, and underscores")
}
}
return nil
}