mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>
357 lines
10 KiB
Go
357 lines
10 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: workspace_mcp.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const addAgentMcpServer = `-- name: AddAgentMcpServer :exec
|
|
INSERT INTO agent_mcp_server (agent_id, server_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT DO NOTHING
|
|
`
|
|
|
|
type AddAgentMcpServerParams struct {
|
|
AgentID pgtype.UUID `json:"agent_id"`
|
|
ServerID pgtype.UUID `json:"server_id"`
|
|
}
|
|
|
|
func (q *Queries) AddAgentMcpServer(ctx context.Context, arg AddAgentMcpServerParams) error {
|
|
_, err := q.db.Exec(ctx, addAgentMcpServer, arg.AgentID, arg.ServerID)
|
|
return err
|
|
}
|
|
|
|
const createWorkspaceMcpServer = `-- name: CreateWorkspaceMcpServer :one
|
|
INSERT INTO workspace_mcp_server (workspace_id, name, config, created_by)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, workspace_id, name, config, created_by, created_at, updated_at
|
|
`
|
|
|
|
type CreateWorkspaceMcpServerParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Config []byte `json:"config"`
|
|
CreatedBy pgtype.UUID `json:"created_by"`
|
|
}
|
|
|
|
func (q *Queries) CreateWorkspaceMcpServer(ctx context.Context, arg CreateWorkspaceMcpServerParams) (WorkspaceMcpServer, error) {
|
|
row := q.db.QueryRow(ctx, createWorkspaceMcpServer,
|
|
arg.WorkspaceID,
|
|
arg.Name,
|
|
arg.Config,
|
|
arg.CreatedBy,
|
|
)
|
|
var i WorkspaceMcpServer
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Config,
|
|
&i.CreatedBy,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteAgentMcpServersByServer = `-- name: DeleteAgentMcpServersByServer :exec
|
|
DELETE FROM agent_mcp_server WHERE server_id = $1
|
|
`
|
|
|
|
// Bindings carry no FK, so removing a library entry must sweep them explicitly
|
|
// in the same transaction as the delete.
|
|
func (q *Queries) DeleteAgentMcpServersByServer(ctx context.Context, serverID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteAgentMcpServersByServer, serverID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceMcpServer = `-- name: DeleteWorkspaceMcpServer :execrows
|
|
DELETE FROM workspace_mcp_server
|
|
WHERE id = $1 AND workspace_id = $2
|
|
`
|
|
|
|
type DeleteWorkspaceMcpServerParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) DeleteWorkspaceMcpServer(ctx context.Context, arg DeleteWorkspaceMcpServerParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, deleteWorkspaceMcpServer, arg.ID, arg.WorkspaceID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const getWorkspaceMcpServer = `-- name: GetWorkspaceMcpServer :one
|
|
SELECT id, workspace_id, name, config, created_by, created_at, updated_at FROM workspace_mcp_server
|
|
WHERE id = $1 AND workspace_id = $2
|
|
`
|
|
|
|
type GetWorkspaceMcpServerParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) GetWorkspaceMcpServer(ctx context.Context, arg GetWorkspaceMcpServerParams) (WorkspaceMcpServer, error) {
|
|
row := q.db.QueryRow(ctx, getWorkspaceMcpServer, arg.ID, arg.WorkspaceID)
|
|
var i WorkspaceMcpServer
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Config,
|
|
&i.CreatedBy,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listAgentMcpServers = `-- name: ListAgentMcpServers :many
|
|
SELECT s.id, s.workspace_id, s.name, s.config, s.created_at, s.updated_at, ams.enabled
|
|
FROM workspace_mcp_server s
|
|
JOIN agent_mcp_server ams ON ams.server_id = s.id
|
|
WHERE ams.agent_id = $1
|
|
ORDER BY s.name ASC
|
|
`
|
|
|
|
type ListAgentMcpServersRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Config []byte `json:"config"`
|
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// Every workspace server bound to this agent, enabled or not, for the agent
|
|
// settings list. `enabled` comes from the binding.
|
|
func (q *Queries) ListAgentMcpServers(ctx context.Context, agentID pgtype.UUID) ([]ListAgentMcpServersRow, error) {
|
|
rows, err := q.db.Query(ctx, listAgentMcpServers, agentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListAgentMcpServersRow{}
|
|
for rows.Next() {
|
|
var i ListAgentMcpServersRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Config,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Enabled,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listEnabledAgentMcpServers = `-- name: ListEnabledAgentMcpServers :many
|
|
SELECT s.name, s.config
|
|
FROM workspace_mcp_server s
|
|
JOIN agent_mcp_server ams ON ams.server_id = s.id
|
|
WHERE ams.agent_id = $1 AND ams.enabled = TRUE
|
|
ORDER BY s.name ASC
|
|
`
|
|
|
|
type ListEnabledAgentMcpServersRow struct {
|
|
Name string `json:"name"`
|
|
Config []byte `json:"config"`
|
|
}
|
|
|
|
// Claim path: only bound AND enabled servers reach the runtime. An unbound
|
|
// workspace server is invisible to this agent — creating one gives it to
|
|
// nobody until someone adds it here.
|
|
func (q *Queries) ListEnabledAgentMcpServers(ctx context.Context, agentID pgtype.UUID) ([]ListEnabledAgentMcpServersRow, error) {
|
|
rows, err := q.db.Query(ctx, listEnabledAgentMcpServers, agentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListEnabledAgentMcpServersRow{}
|
|
for rows.Next() {
|
|
var i ListEnabledAgentMcpServersRow
|
|
if err := rows.Scan(&i.Name, &i.Config); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listWorkspaceMcpServers = `-- name: ListWorkspaceMcpServers :many
|
|
SELECT id, workspace_id, name, config, created_by, created_at, updated_at FROM workspace_mcp_server
|
|
WHERE workspace_id = $1
|
|
ORDER BY name ASC
|
|
`
|
|
|
|
// The workspace's MCP library. Returns `config` because the callers that need
|
|
// it (claim assembly, the write path's read-modify-write) run server-side; the
|
|
// HTTP layer projects it down to the non-secret inventory.
|
|
func (q *Queries) ListWorkspaceMcpServers(ctx context.Context, workspaceID pgtype.UUID) ([]WorkspaceMcpServer, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspaceMcpServers, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []WorkspaceMcpServer{}
|
|
for rows.Next() {
|
|
var i WorkspaceMcpServer
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Config,
|
|
&i.CreatedBy,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const lockWorkspaceMcpServerForShare = `-- name: LockWorkspaceMcpServerForShare :one
|
|
SELECT id FROM workspace_mcp_server
|
|
WHERE id = $1 AND workspace_id = $2
|
|
FOR SHARE
|
|
`
|
|
|
|
type LockWorkspaceMcpServerForShareParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
// Taken by the assignment writer before it inserts. A shared row lock
|
|
// conflicts with the exclusive lock DeleteWorkspaceMcpServer takes, so an
|
|
// assignment can never land in the window between that delete sweeping the
|
|
// bindings and committing — the junction has no FK to catch the orphan.
|
|
func (q *Queries) LockWorkspaceMcpServerForShare(ctx context.Context, arg LockWorkspaceMcpServerForShareParams) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, lockWorkspaceMcpServerForShare, arg.ID, arg.WorkspaceID)
|
|
var id pgtype.UUID
|
|
err := row.Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
const lockWorkspaceMcpServerForUpdate = `-- name: LockWorkspaceMcpServerForUpdate :one
|
|
SELECT id FROM workspace_mcp_server
|
|
WHERE id = $1 AND workspace_id = $2
|
|
FOR UPDATE
|
|
`
|
|
|
|
type LockWorkspaceMcpServerForUpdateParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
// The delete half of the same protocol: take the row exclusively first, so a
|
|
// concurrent assignment either lands before this transaction (and is swept) or
|
|
// waits and then finds the server gone.
|
|
func (q *Queries) LockWorkspaceMcpServerForUpdate(ctx context.Context, arg LockWorkspaceMcpServerForUpdateParams) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, lockWorkspaceMcpServerForUpdate, arg.ID, arg.WorkspaceID)
|
|
var id pgtype.UUID
|
|
err := row.Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
const removeAgentMcpServer = `-- name: RemoveAgentMcpServer :execrows
|
|
DELETE FROM agent_mcp_server
|
|
WHERE agent_id = $1 AND server_id = $2
|
|
`
|
|
|
|
type RemoveAgentMcpServerParams struct {
|
|
AgentID pgtype.UUID `json:"agent_id"`
|
|
ServerID pgtype.UUID `json:"server_id"`
|
|
}
|
|
|
|
func (q *Queries) RemoveAgentMcpServer(ctx context.Context, arg RemoveAgentMcpServerParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, removeAgentMcpServer, arg.AgentID, arg.ServerID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const setAgentMcpServerEnabled = `-- name: SetAgentMcpServerEnabled :execrows
|
|
UPDATE agent_mcp_server
|
|
SET enabled = $3
|
|
WHERE agent_id = $1 AND server_id = $2
|
|
`
|
|
|
|
type SetAgentMcpServerEnabledParams struct {
|
|
AgentID pgtype.UUID `json:"agent_id"`
|
|
ServerID pgtype.UUID `json:"server_id"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
func (q *Queries) SetAgentMcpServerEnabled(ctx context.Context, arg SetAgentMcpServerEnabledParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, setAgentMcpServerEnabled, arg.AgentID, arg.ServerID, arg.Enabled)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const updateWorkspaceMcpServer = `-- name: UpdateWorkspaceMcpServer :one
|
|
UPDATE workspace_mcp_server SET
|
|
name = COALESCE($3, name),
|
|
config = COALESCE($4, config),
|
|
updated_at = now()
|
|
WHERE id = $1 AND workspace_id = $2
|
|
RETURNING id, workspace_id, name, config, created_by, created_at, updated_at
|
|
`
|
|
|
|
type UpdateWorkspaceMcpServerParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Name pgtype.Text `json:"name"`
|
|
Config []byte `json:"config"`
|
|
}
|
|
|
|
// Full replace of one entry. The name can change here (unlike the previous
|
|
// document model) because bindings key off the id, so a rename cannot orphan
|
|
// the agents that use it.
|
|
func (q *Queries) UpdateWorkspaceMcpServer(ctx context.Context, arg UpdateWorkspaceMcpServerParams) (WorkspaceMcpServer, error) {
|
|
row := q.db.QueryRow(ctx, updateWorkspaceMcpServer,
|
|
arg.ID,
|
|
arg.WorkspaceID,
|
|
arg.Name,
|
|
arg.Config,
|
|
)
|
|
var i WorkspaceMcpServer
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Config,
|
|
&i.CreatedBy,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|