mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>
546 lines
20 KiB
Go
546 lines
20 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// WorkspaceMcpServerResponse is the intentionally non-secret shape of one
|
|
// workspace MCP server. Never add `url`, `command`, `args`, `headers`, or
|
|
// `env`: upstream #6062 asks for shared secrets to be write-only, and every
|
|
// one of those fields routinely carries a token (a Composio-style session URL
|
|
// is a bearer credential on its own). Mirrors the daemon's own
|
|
// runtimeLocalMcpServerSummary, which draws the same line for the same reason.
|
|
//
|
|
// `Enabled` is only meaningful on the agent-scoped list, where it reflects the
|
|
// binding's toggle; it is omitted on the workspace library listing.
|
|
type WorkspaceMcpServerResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Transport string `json:"transport"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
// AgentCount is how many live agents the entry is assigned to. Only the
|
|
// workspace library listing reports it; per-agent responses omit it.
|
|
AgentCount *int64 `json:"agent_count,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// mcpTransportOf classifies a server entry for display, and — because the
|
|
// client uses it to decide whether the guided form may edit an entry — it must
|
|
// not be lossy about an explicit `type`.
|
|
//
|
|
// An unrecognised explicit type is returned VERBATIM rather than inferred from
|
|
// the presence of a `url`. Reporting `{"type":"websocket","url":"wss://…"}` as
|
|
// "http" would tell the settings UI the form can express it, and saving from
|
|
// that form rewrites the entry to `type: "http"` — silently changing the
|
|
// protocol. Only an entry with NO explicit type is inferred from command / url,
|
|
// which is lossless because the form writes that same shape back.
|
|
func mcpTransportOf(entry json.RawMessage) string {
|
|
var e struct {
|
|
Type string `json:"type"`
|
|
Command json.RawMessage `json:"command"`
|
|
URL json.RawMessage `json:"url"`
|
|
}
|
|
if err := json.Unmarshal(entry, &e); err != nil {
|
|
return "unknown"
|
|
}
|
|
if declared := strings.ToLower(strings.TrimSpace(e.Type)); declared != "" {
|
|
switch declared {
|
|
case "local", "stdio":
|
|
return "stdio"
|
|
case "remote", "http", "streamable-http":
|
|
return "http"
|
|
}
|
|
// `sse` and anything a newer client invents land here unchanged. The
|
|
// response schema keeps transport a free string for exactly this.
|
|
return declared
|
|
}
|
|
if len(e.Command) > 0 {
|
|
return "stdio"
|
|
}
|
|
if len(e.URL) > 0 {
|
|
return "http"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func workspaceMcpServerToResponse(server db.WorkspaceMcpServer) WorkspaceMcpServerResponse {
|
|
return WorkspaceMcpServerResponse{
|
|
ID: uuidToString(server.ID),
|
|
WorkspaceID: uuidToString(server.WorkspaceID),
|
|
Name: server.Name,
|
|
Transport: mcpTransportOf(server.Config),
|
|
CreatedAt: timestampToString(server.CreatedAt),
|
|
UpdatedAt: timestampToString(server.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
// ListWorkspaceMcpServers returns the workspace's MCP library. Member-visible:
|
|
// the payload carries no credential material, and an agent owner needs to see
|
|
// what is available to add.
|
|
func (h *Handler) ListWorkspaceMcpServers(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
idUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
servers, err := h.Queries.ListWorkspaceMcpServers(r.Context(), idUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list workspace MCP servers")
|
|
return
|
|
}
|
|
counts, err := h.Queries.CountWorkspaceMcpServerAgents(r.Context(), idUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list workspace MCP servers")
|
|
return
|
|
}
|
|
agentCounts := make(map[string]int64, len(counts))
|
|
for _, row := range counts {
|
|
agentCounts[uuidToString(row.ServerID)] = row.AgentCount
|
|
}
|
|
resp := make([]WorkspaceMcpServerResponse, 0, len(servers))
|
|
for _, server := range servers {
|
|
item := workspaceMcpServerToResponse(server)
|
|
count := agentCounts[item.ID]
|
|
item.AgentCount = &count
|
|
resp = append(resp, item)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// requireWorkspaceMcpWriter enforces the write gate. The router already
|
|
// restricts these routes to owner/admin, but an agent actor running under an
|
|
// owner's PAT would satisfy that gate: adding a shared MCP server has to stay a
|
|
// human admin action.
|
|
func (h *Handler) requireWorkspaceMcpWriter(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if actorType, _ := h.resolveActor(r, requestUserID(r), workspaceID); actorType == "agent" {
|
|
writeError(w, http.StatusForbidden, "agents cannot modify the workspace MCP servers")
|
|
return false
|
|
}
|
|
if !roleAllowed(member.Role, "owner", "admin") {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// WorkspaceMcpServerRequest carries one server entry. `config` is the object
|
|
// that sits under a name in `mcpServers`; it is write-only and never returned.
|
|
type WorkspaceMcpServerRequest struct {
|
|
Name string `json:"name"`
|
|
Config json.RawMessage `json:"config"`
|
|
}
|
|
|
|
// CreateWorkspaceMcpServer adds a server to the workspace library. It is bound
|
|
// to NO agent — the library entry has to be added to an agent before it does
|
|
// anything, which is the whole shape of this feature.
|
|
func (h *Handler) CreateWorkspaceMcpServer(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
idUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.requireWorkspaceMcpWriter(w, r, workspaceID) {
|
|
return
|
|
}
|
|
|
|
var req WorkspaceMcpServerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if err := validateWorkspaceMcpServerName(name); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := validateWorkspaceMcpServerEntry(req.Config); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create the MCP server")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
// Join the workspace teardown fence (#5219): this table has no FK, so
|
|
// without the shared lock a create committing after DeleteWorkspace swept
|
|
// would leave a row pointing at a workspace that no longer exists.
|
|
if _, err := qtx.LockWorkspaceForChatSessionCreate(r.Context(), idUUID); err != nil {
|
|
writeError(w, http.StatusNotFound, "workspace not found")
|
|
return
|
|
}
|
|
|
|
server, err := qtx.CreateWorkspaceMcpServer(r.Context(), db.CreateWorkspaceMcpServerParams{
|
|
WorkspaceID: idUUID,
|
|
Name: name,
|
|
Config: append([]byte(nil), req.Config...),
|
|
CreatedBy: parseUUID(requestUserID(r)),
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "an MCP server with this name already exists in the workspace")
|
|
return
|
|
}
|
|
slog.Warn("create workspace mcp server failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create the MCP server")
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create the MCP server")
|
|
return
|
|
}
|
|
// Name only — never the entry — so the audit trail cannot become a second
|
|
// copy of the workspace's credentials.
|
|
slog.Info("workspace mcp server created", append(logger.RequestAttrs(r),
|
|
"workspace_id", workspaceID, "server_id", uuidToString(server.ID), "name", server.Name)...)
|
|
writeJSON(w, http.StatusCreated, workspaceMcpServerToResponse(server))
|
|
}
|
|
|
|
// UpdateWorkspaceMcpServer replaces one library entry. Renaming is safe here:
|
|
// bindings key off the id, so an agent that uses this server keeps using it.
|
|
func (h *Handler) UpdateWorkspaceMcpServer(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
idUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.requireWorkspaceMcpWriter(w, r, workspaceID) {
|
|
return
|
|
}
|
|
serverUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "serverId"), "server id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req WorkspaceMcpServerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
params := db.UpdateWorkspaceMcpServerParams{ID: serverUUID, WorkspaceID: idUUID}
|
|
if name := strings.TrimSpace(req.Name); name != "" {
|
|
if err := validateWorkspaceMcpServerName(name); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
params.Name = pgtype.Text{String: name, Valid: true}
|
|
}
|
|
if len(req.Config) > 0 {
|
|
if err := validateWorkspaceMcpServerEntry(req.Config); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
params.Config = append([]byte(nil), req.Config...)
|
|
}
|
|
|
|
server, err := h.Queries.UpdateWorkspaceMcpServer(r.Context(), params)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "an MCP server with this name already exists in the workspace")
|
|
return
|
|
}
|
|
writeError(w, http.StatusNotFound, "MCP server not found")
|
|
return
|
|
}
|
|
slog.Info("workspace mcp server updated", append(logger.RequestAttrs(r),
|
|
"workspace_id", workspaceID, "server_id", uuidToString(server.ID), "name", server.Name)...)
|
|
writeJSON(w, http.StatusOK, workspaceMcpServerToResponse(server))
|
|
}
|
|
|
|
// DeleteWorkspaceMcpServer removes a library entry and every binding to it, in
|
|
// one transaction — the binding table carries no FK, so an orphaned row would
|
|
// otherwise keep pointing at a server that no longer exists.
|
|
func (h *Handler) DeleteWorkspaceMcpServer(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
idUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.requireWorkspaceMcpWriter(w, r, workspaceID) {
|
|
return
|
|
}
|
|
serverUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "serverId"), "server id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete the MCP server")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
// Exclusive row lock first: an assignment writer holds this row FOR SHARE
|
|
// while it inserts, so taking it here means a concurrent add either lands
|
|
// before this transaction (and is swept below) or waits and then finds the
|
|
// server gone. Without it the add could insert after the sweep, leaving a
|
|
// binding to a server that no longer exists.
|
|
if _, err := qtx.LockWorkspaceMcpServerForUpdate(r.Context(), db.LockWorkspaceMcpServerForUpdateParams{
|
|
ID: serverUUID,
|
|
WorkspaceID: idUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusNotFound, "MCP server not found")
|
|
return
|
|
}
|
|
|
|
rows, err := qtx.DeleteWorkspaceMcpServer(r.Context(), db.DeleteWorkspaceMcpServerParams{
|
|
ID: serverUUID,
|
|
WorkspaceID: idUUID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("delete workspace mcp server failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete the MCP server")
|
|
return
|
|
}
|
|
if rows == 0 {
|
|
writeError(w, http.StatusNotFound, "MCP server not found")
|
|
return
|
|
}
|
|
if err := qtx.DeleteAgentMcpServersByServer(r.Context(), serverUUID); err != nil {
|
|
slog.Warn("sweep agent mcp bindings failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete the MCP server")
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete the MCP server")
|
|
return
|
|
}
|
|
slog.Info("workspace mcp server deleted", append(logger.RequestAttrs(r),
|
|
"workspace_id", workspaceID, "server_id", uuidToString(serverUUID))...)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ListAgentMcpServers returns the workspace servers bound to this agent, with
|
|
// each binding's toggle state.
|
|
func (h *Handler) ListAgentMcpServers(w http.ResponseWriter, r *http.Request) {
|
|
agent, ok := h.loadAgentForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := h.Queries.ListAgentMcpServers(r.Context(), agent.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list the agent's MCP servers")
|
|
return
|
|
}
|
|
resp := make([]WorkspaceMcpServerResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
enabled := row.Enabled
|
|
resp = append(resp, WorkspaceMcpServerResponse{
|
|
ID: uuidToString(row.ID),
|
|
WorkspaceID: uuidToString(row.WorkspaceID),
|
|
Name: row.Name,
|
|
Transport: mcpTransportOf(row.Config),
|
|
Enabled: &enabled,
|
|
CreatedAt: timestampToString(row.CreatedAt),
|
|
UpdatedAt: timestampToString(row.UpdatedAt),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// requireAgentMcpWriter resolves the agent and enforces who may change its
|
|
// bindings. Same rule as the agent's own mcp_config: the agent owner or a
|
|
// workspace owner/admin, never an agent actor.
|
|
func (h *Handler) requireAgentMcpWriter(w http.ResponseWriter, r *http.Request) (db.Agent, bool) {
|
|
agent, ok := h.loadAgentForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return db.Agent{}, false
|
|
}
|
|
workspaceID := uuidToString(agent.WorkspaceID)
|
|
if actorType, _ := h.resolveActor(r, requestUserID(r), workspaceID); actorType == "agent" {
|
|
writeError(w, http.StatusForbidden, "agents cannot modify MCP server assignments")
|
|
return db.Agent{}, false
|
|
}
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return db.Agent{}, false
|
|
}
|
|
if !canViewAgentSecrets(agent, requestUserID(r), member.Role) {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return db.Agent{}, false
|
|
}
|
|
return agent, true
|
|
}
|
|
|
|
// AddAgentMcpServerRequest names the library entry to attach.
|
|
type AddAgentMcpServerRequest struct {
|
|
ServerID string `json:"server_id"`
|
|
}
|
|
|
|
// AddAgentMcpServer gives one workspace MCP server to this agent. This is the
|
|
// only way a library entry reaches an agent.
|
|
func (h *Handler) AddAgentMcpServer(w http.ResponseWriter, r *http.Request) {
|
|
agent, ok := h.requireAgentMcpWriter(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req AddAgentMcpServerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
serverUUID, ok := parseUUIDOrBadRequest(w, strings.TrimSpace(req.ServerID), "server_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to add the MCP server")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
// Same fence as every other workspace-scoped insert (#5219): the binding
|
|
// has no FK, so a write committing after DeleteWorkspace swept would
|
|
// outlive the agent and the server it names.
|
|
if _, err := qtx.LockWorkspaceForChatSessionCreate(r.Context(), agent.WorkspaceID); err != nil {
|
|
writeError(w, http.StatusNotFound, "workspace not found")
|
|
return
|
|
}
|
|
// Scope check AND the delete protocol in one statement: the row must
|
|
// belong to this agent's workspace — an id from another workspace would
|
|
// bind across the tenant boundary — and holding it FOR SHARE serializes
|
|
// this insert against DeleteWorkspaceMcpServer's sweep.
|
|
if _, err := qtx.LockWorkspaceMcpServerForShare(r.Context(), db.LockWorkspaceMcpServerForShareParams{
|
|
ID: serverUUID,
|
|
WorkspaceID: agent.WorkspaceID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusNotFound, "MCP server not found in this workspace")
|
|
return
|
|
}
|
|
if err := qtx.AddAgentMcpServer(r.Context(), db.AddAgentMcpServerParams{
|
|
AgentID: agent.ID,
|
|
ServerID: serverUUID,
|
|
}); err != nil {
|
|
slog.Warn("add agent mcp server failed", append(logger.RequestAttrs(r), "error", err, "agent_id", uuidToString(agent.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to add the MCP server")
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to add the MCP server")
|
|
return
|
|
}
|
|
slog.Info("agent mcp server added", append(logger.RequestAttrs(r),
|
|
"agent_id", uuidToString(agent.ID), "server_id", uuidToString(serverUUID))...)
|
|
h.writeAgentMcpServers(w, r, agent.ID)
|
|
}
|
|
|
|
// SetAgentMcpServerEnabledRequest carries the per-agent toggle.
|
|
type SetAgentMcpServerEnabledRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
// SetAgentMcpServerEnabled flips one binding's toggle without dropping it, so
|
|
// turning a server back on does not mean finding it again.
|
|
func (h *Handler) SetAgentMcpServerEnabled(w http.ResponseWriter, r *http.Request) {
|
|
agent, ok := h.requireAgentMcpWriter(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
serverUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "serverId"), "server id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req SetAgentMcpServerEnabledRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Enabled == nil {
|
|
writeError(w, http.StatusBadRequest, "enabled is required")
|
|
return
|
|
}
|
|
rows, err := h.Queries.SetAgentMcpServerEnabled(r.Context(), db.SetAgentMcpServerEnabledParams{
|
|
AgentID: agent.ID,
|
|
ServerID: serverUUID,
|
|
Enabled: *req.Enabled,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update the MCP server")
|
|
return
|
|
}
|
|
if rows == 0 {
|
|
writeError(w, http.StatusNotFound, "this MCP server is not assigned to the agent")
|
|
return
|
|
}
|
|
slog.Info("agent mcp server toggled", append(logger.RequestAttrs(r),
|
|
"agent_id", uuidToString(agent.ID), "server_id", uuidToString(serverUUID), "enabled", *req.Enabled)...)
|
|
h.writeAgentMcpServers(w, r, agent.ID)
|
|
}
|
|
|
|
// RemoveAgentMcpServer takes a workspace server away from this agent. The
|
|
// library entry itself is untouched.
|
|
func (h *Handler) RemoveAgentMcpServer(w http.ResponseWriter, r *http.Request) {
|
|
agent, ok := h.requireAgentMcpWriter(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
serverUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "serverId"), "server id")
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := h.Queries.RemoveAgentMcpServer(r.Context(), db.RemoveAgentMcpServerParams{
|
|
AgentID: agent.ID,
|
|
ServerID: serverUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to remove the MCP server")
|
|
return
|
|
}
|
|
if rows == 0 {
|
|
writeError(w, http.StatusNotFound, "this MCP server is not assigned to the agent")
|
|
return
|
|
}
|
|
slog.Info("agent mcp server removed", append(logger.RequestAttrs(r),
|
|
"agent_id", uuidToString(agent.ID), "server_id", uuidToString(serverUUID))...)
|
|
h.writeAgentMcpServers(w, r, agent.ID)
|
|
}
|
|
|
|
// writeAgentMcpServers returns the agent's binding list after a mutation, so
|
|
// the client never has to guess the resulting state.
|
|
func (h *Handler) writeAgentMcpServers(w http.ResponseWriter, r *http.Request, agentID pgtype.UUID) {
|
|
rows, err := h.Queries.ListAgentMcpServers(r.Context(), agentID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list the agent's MCP servers")
|
|
return
|
|
}
|
|
resp := make([]WorkspaceMcpServerResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
enabled := row.Enabled
|
|
resp = append(resp, WorkspaceMcpServerResponse{
|
|
ID: uuidToString(row.ID),
|
|
WorkspaceID: uuidToString(row.WorkspaceID),
|
|
Name: row.Name,
|
|
Transport: mcpTransportOf(row.Config),
|
|
Enabled: &enabled,
|
|
CreatedAt: timestampToString(row.CreatedAt),
|
|
UpdatedAt: timestampToString(row.UpdatedAt),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|