Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
329ca37
feat: audit MCP server config changes
ibetitsmike Aug 7, 2026
f7d4945
chore: polish CODAGT-717 comments per cleanup gate
ibetitsmike Aug 7, 2026
9682815
test(coderd): audit denied MCP server config deletions
ibetitsmike Aug 8, 2026
a72d5cf
fix(enterprise/audit): redact MCP server endpoint URLs in audit diffs
ibetitsmike Aug 8, 2026
0a32b24
fix(coderd): mark deleted MCP server configs in audit logs
ibetitsmike Aug 8, 2026
08a7219
fix(coderd): make MCP config audit records match persisted rows
ibetitsmike Aug 8, 2026
09fb6fb
fix(coderd): audit MCP config deletions against the locked row
ibetitsmike Aug 10, 2026
86d42a7
chore(coderd/database/migrations): renumber migration 000566 to 000569
ibetitsmike Aug 11, 2026
197a759
chore(coderd/database/migrations): renumber migration 000569 to 000570
ibetitsmike Aug 11, 2026
a04fd6f
fix(coderd): address MCP audit review feedback
ibetitsmike Aug 11, 2026
6316be4
fix(enterprise/audit): track MCP server endpoint URLs in audit diffs
ibetitsmike Aug 13, 2026
a848d84
fix(coderd/database): renumber MCP audit migration to 000571
ibetitsmike Aug 13, 2026
db41667
chore: shorten the audit layer reread comment per cleanup gate
ibetitsmike Aug 13, 2026
a0a84be
fix(coderd): drop whitespace-only MCP display name rejection
ibetitsmike Aug 17, 2026
7dfb53e
test(coderd): pass the organization to MCP config SDK calls
ibetitsmike Aug 17, 2026
9eed1a3
fix(coderd/database): renumber MCP audit migration to 000572
ibetitsmike Aug 17, 2026
a3af59d
fix(coderd/audit): fall back to the slug for empty MCP display names
ibetitsmike Aug 17, 2026
bb776b8
test(coderd): audit MCP config creation only when a row persists
ibetitsmike Aug 17, 2026
e39972f
fix(coderd/audit): retain identifiable MCP audit targets
ibetitsmike Aug 18, 2026
d3d9854
fix(enterprise/audit): canonicalize empty MCP fields
ibetitsmike Aug 18, 2026
f48ed8f
test(enterprise/audit): cover MCP header transitions
ibetitsmike Aug 18, 2026
146d8c8
fix(coderd): gate MCP audit links on management access
ibetitsmike Aug 18, 2026
a03f5ff
fix(coderd): match MCP audit links to settings page access
ibetitsmike Aug 18, 2026
b0ffee0
fix(coderd/database): renumber MCP audit migration to 000575
ibetitsmike Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions coderd/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/codersdk"
)
Expand Down Expand Up @@ -501,6 +503,18 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get
api.Logger.Error(ctx, "unable to fetch chat", slog.Error(err))
}
return false
case database.ResourceTypeMCPServerConfig:
Comment thread
ibetitsmike marked this conversation as resolved.
// MCP server configs are hard-deleted, so a 404 means deleted.
_, err := api.Database.GetMCPServerConfigByID(ctx, alog.AuditLog.ResourceID)
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
// Config reads are org-scoped, so an auditor can lack read on
// the config's organization. That is not worth logging.
if err != nil && !dbauthz.IsNotAuthorizedError(err) {
api.Logger.Error(ctx, "unable to fetch mcp server config", slog.Error(err))
}
Comment thread
ibetitsmike marked this conversation as resolved.
return false
case database.ResourceTypeUserSecret:
_, err := api.Database.GetUserSecretByID(ctx, alog.AuditLog.ResourceID)
if xerrors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -604,6 +618,17 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
// Chats are surfaced at /agents/{id}. They are owner-scoped but
// not username-scoped in the URL like workspaces or tasks.
Comment thread
ibetitsmike marked this conversation as resolved.
return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID)
case database.ResourceTypeMCPServerConfig:
actor, ok := dbauthz.ActorFromContext(ctx)
if !ok {
return ""
}
// The MCP settings page admits only deployment-config managers,
// so emit the link only for callers the page will accept.
if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return ""
}
return fmt.Sprintf("/ai/settings/mcp-servers/%s", alog.AuditLog.ResourceID)
case database.ResourceTypeUserSecret:
// TODO(PLAT-102): point at the user secrets management page once
// it ships. Until then, the audit row links nowhere.
Expand Down
1 change: 1 addition & 0 deletions coderd/audit/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type Auditable interface {
database.AIProviderKey |
database.AIGatewayKey |
database.Chat |
database.MCPServerConfig |
database.AuditableGroupAIBudget |
database.AuditableUserAIBudgetOverride |
database.UserSecret |
Expand Down
12 changes: 12 additions & 0 deletions coderd/audit/request.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package audit

import (
"cmp"
"context"
"database/sql"
"encoding/json"
Expand Down Expand Up @@ -154,6 +155,10 @@ func ResourceTarget[T Auditable](tgt T) string {
// for display; collisions affect the display label and search
// filter but not the primary resource identifier.
return typed.ID.String()[:8]
case database.MCPServerConfig:
// Updates can persist an empty display name; fall back to the slug, or
// the ID if both are empty, so the audit entry stays identifiable.
return cmp.Or(typed.DisplayName, typed.Slug, typed.ID.String())
case database.UserSecret:
return typed.Name
case database.UserSkill:
Expand Down Expand Up @@ -257,6 +262,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.UserID
case database.Chat:
return typed.ID
case database.MCPServerConfig:
return typed.ID
case database.UserSecret:
return typed.ID
case database.UserSkill:
Expand Down Expand Up @@ -335,6 +342,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeUserAIBudgetOverride
case database.Chat:
return database.ResourceTypeChat
case database.MCPServerConfig:
return database.ResourceTypeMCPServerConfig
Comment thread
ibetitsmike marked this conversation as resolved.
case database.UserSecret:
return database.ResourceTypeUserSecret
case database.UserSkill:
Expand Down Expand Up @@ -425,6 +434,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
// Chats always have a non-null organization_id (since
// migration 000467).
return true
case database.MCPServerConfig:
// MCP server configs always carry a non-null organization_id.
return true
case database.UserSecret:
// User secrets are global to the user across organizations.
return false
Expand Down
17 changes: 17 additions & 0 deletions coderd/audit/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,20 @@ func TestResourceTarget_ChatTitleNotLeaked(t *testing.T) {
require.NotContains(t, target, chat.Title,
"ResourceTarget for Chat must not contain the title; it should use a UUID prefix")
}

func TestResourceTarget_MCPServerConfigSlugFallback(t *testing.T) {
t.Parallel()

config := database.MCPServerConfig{
ID: uuid.UUID{1},
DisplayName: "GitHub MCP",
Slug: "github",
}
require.Equal(t, "GitHub MCP", audit.ResourceTarget(config))

config.DisplayName = ""
require.Equal(t, "github", audit.ResourceTarget(config))

config.Slug = ""
require.Equal(t, config.ID.String(), audit.ResourceTarget(config))
}
52 changes: 52 additions & 0 deletions coderd/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
Expand Down Expand Up @@ -143,6 +144,57 @@ func TestAuditLogs(t *testing.T) {
workspace.OwnerName, workspace.Name, buildNumberString))
})

t.Run("MCPServerConfigAuditLink", func(t *testing.T) {
t.Parallel()

ctx := context.Background()
db, ps := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps})
user := coderdtest.CreateFirstUser(t, client)
auditor, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleAuditor())
orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID))

config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: user.OrganizationID,
})
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
Action: codersdk.AuditActionCreate,
ResourceType: codersdk.ResourceTypeMCPServerConfig,
ResourceID: config.ID,
OrganizationID: user.OrganizationID,
})
require.NoError(t, err)

auditorLogs, err := auditor.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Len(t, auditorLogs.AuditLogs, 1)
require.Empty(t, auditorLogs.AuditLogs[0].ResourceLink)

// Organization admins hold MCP permissions but not the
// deployment-config access the settings page requires.
orgAdminLogs, err := orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Len(t, orgAdminLogs.AuditLogs, 1)
require.Empty(t, orgAdminLogs.AuditLogs[0].ResourceLink)

ownerLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Len(t, ownerLogs.AuditLogs, 1)
require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID), ownerLogs.AuditLogs[0].ResourceLink)
})

t.Run("Organization", func(t *testing.T) {
t.Parallel()

Expand Down
3 changes: 2 additions & 1 deletion coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- No-op, enum values can't be dropped.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TYPE resource_type
ADD VALUE IF NOT EXISTS 'mcp_server_config';
5 changes: 4 additions & 1 deletion coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/database/sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ sql:
resource_type_ai_gateway_key: ResourceTypeAIGatewayKey
mcp_server_config: MCPServerConfig
mcp_server_configs: MCPServerConfigs
resource_type_mcp_server_config: ResourceTypeMCPServerConfig
mcp_server_user_token: MCPServerUserToken
mcp_server_user_tokens: MCPServerUserTokens
mcp_server_tool_snapshot: MCPServerToolSnapshot
Expand Down
68 changes: 64 additions & 4 deletions coderd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"golang.org/x/xerrors"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
Expand Down Expand Up @@ -248,6 +249,15 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
OrganizationID: organization.ID,
})
defer commitAudit()
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) {
httpapi.Forbidden(rw)
return
Expand Down Expand Up @@ -441,6 +451,8 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
}
}

aReq.New = inserted

httpapi.Write(ctx, rw, http.StatusCreated, convertMCPServerConfig(inserted))
}

Expand Down Expand Up @@ -544,6 +556,21 @@ func (api *API) getMCPServerConfigForMutation(rw http.ResponseWriter, r *http.Re
func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit()

// Set Old before the write-authz check so a write-denied 403 is
// audited. Read-denied callers were already concealed with 404 by
// the param middleware and never reach this handler.
Comment thread
ibetitsmike marked this conversation as resolved.
aReq.Old = httpmw.MCPServerConfigParam(r)
Comment thread
ibetitsmike marked this conversation as resolved.
aReq.UpdateOrganizationID(aReq.Old.OrganizationID)

existing, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionUpdate)
if !ok {
return
Expand Down Expand Up @@ -593,14 +620,15 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {

var updated database.MCPServerConfig
err := api.Database.InTx(func(tx database.Store) error {
// Lock and re-fetch the row so omitted fields come from the latest
// version and grant invalidation serializes with in-flight OAuth
// callbacks verifying the same config.
// Lock and re-fetch the row so omitted fields and the audit baseline
// match the row this update replaces, and so grant invalidation
// serializes with in-flight OAuth callbacks verifying the config.
current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, existing.ID)
Comment thread
ibetitsmike marked this conversation as resolved.
if err != nil {
return err
}
existing = current
aReq.Old = current

touchesUserOIDC := existing.AuthType == "user_oidc" ||
(req.AuthType != nil && *req.AuthType == "user_oidc")
Expand Down Expand Up @@ -873,6 +901,8 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
}
}

aReq.New = updated

httpapi.Write(ctx, rw, http.StatusOK, convertMCPServerConfig(updated))
}

Expand All @@ -888,12 +918,42 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
})
defer commitAudit()

// Set Old before the write-authz check so a write-denied 403 is
// audited. Read-denied callers were already concealed with 404 by
// the param middleware and never reach this handler.
Comment thread
ibetitsmike marked this conversation as resolved.
aReq.Old = httpmw.MCPServerConfigParam(r)
Comment thread
ibetitsmike marked this conversation as resolved.
aReq.UpdateOrganizationID(aReq.Old.OrganizationID)

config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionDelete)
if !ok {
return
}

if err := api.Database.DeleteMCPServerConfigByID(ctx, config.ID); err != nil {
err := api.Database.InTx(func(tx database.Store) error {
// Re-fetch under a row lock so the audit record describes the
// row this request actually removes, not a middleware snapshot
// that a concurrent update may have made stale.
current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, config.ID)
if err != nil {
return err
}
aReq.Old = current
return tx.DeleteMCPServerConfigByID(ctx, current.ID)
}, nil)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete MCP server config.",
Detail: err.Error(),
Expand Down
Loading
Loading