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
51 commits
Select commit Hold shift + click to select a range
b3c6421
feat(site): add organization picker to MCP server settings pages
ibetitsmike Aug 13, 2026
37481ea
fix(site): preserve the organization in the MCP form back link
ibetitsmike Aug 13, 2026
2a2d63e
fix(site): surface MCP server detail load errors instead of redirecting
ibetitsmike Aug 13, 2026
ed719e4
fix(site): set a page title on the MCP server detail error state
ibetitsmike Aug 13, 2026
427da40
fix(site/src/pages/AISettingsPage): preserve the organization after a…
ibetitsmike Aug 14, 2026
83e0faa
fix(site/src/pages/AISettingsPage): carry the organization after crea…
ibetitsmike Aug 14, 2026
1257b32
fix(site/src/pages/AISettingsPage): redirect cached MCP detail querie…
ibetitsmike Aug 14, 2026
1a6862d
fix(site/src/pages/AISettingsPage): keep the cached MCP form on trans…
ibetitsmike Aug 14, 2026
e695639
fix(site): disable the MCP organization picker while creating a server
ibetitsmike Aug 14, 2026
da0504a
fix(site/src): apply MCP org picker review fixes
ibetitsmike Aug 17, 2026
22b57fc
chore(site/src/pages/AISettingsPage): use the selected organization d…
ibetitsmike Aug 17, 2026
08ea27a
docs(docs/ai-coder/agents/platform-controls): describe multi-organiza…
ibetitsmike Aug 17, 2026
b4cbabd
fix(site/src/pages/AISettingsPage): include the selected organization…
ibetitsmike Aug 17, 2026
d8813ff
fix(coderd): include the organization in MCP audit resource links
ibetitsmike Aug 17, 2026
a7c3acd
fix(site/src): disambiguate colliding organization names
ibetitsmike Aug 17, 2026
ca7a5b6
test(site/src): cover colliding organization display names in autocom…
ibetitsmike Aug 17, 2026
d9b5e4c
test(site/src): cover colliding org display names in MCP servers pick…
ibetitsmike Aug 17, 2026
0075864
fix(site/src): detect org label collisions on effective display labels
ibetitsmike Aug 17, 2026
c7512dd
test(site/src): cover fallback name collisions in autocomplete stories
ibetitsmike Aug 17, 2026
005e472
fix(site/src): show disambiguated org label in autocomplete trigger
ibetitsmike Aug 17, 2026
b62e492
fix(site): let org admins with MCP permissions access MCP server sett…
ibetitsmike Aug 17, 2026
56df9e4
test(site/src): cover org admin access to MCP server settings
ibetitsmike Aug 17, 2026
779c5a1
fix(site): gate MCP settings actions on per-organization permissions
ibetitsmike Aug 17, 2026
58093f4
fix(site): filter MCP organizations by view permission
ibetitsmike Aug 17, 2026
484c287
fix(site): gate selected MCP organization actions
ibetitsmike Aug 17, 2026
49fa7f6
fix(site): stabilize MCP permission gating stories
ibetitsmike Aug 17, 2026
315ac64
fix(site): keep MCP org picker and delete action reachable under part…
ibetitsmike Aug 18, 2026
309b14f
fix(site): keep partial MCP actions accessible
ibetitsmike Aug 18, 2026
63e0a30
test(site): query MCP server row by accessible role
ibetitsmike Aug 18, 2026
5bad6cb
refactor(site): simplify MCP permission gating after review fixes
ibetitsmike Aug 18, 2026
daf2836
fix(site): keep MCP pages mounted on permissions refetch errors
ibetitsmike Aug 18, 2026
ac2df22
fix(site): redirect MCP-only admins to the MCP servers page
ibetitsmike Aug 18, 2026
5c41e49
fix(site): let create-only roles add MCP servers
ibetitsmike Aug 18, 2026
506fc18
fix(site): expose MCP add page to create-only administrators
ibetitsmike Aug 18, 2026
abca7fb
fix(site): gate user_oidc MCP server updates on deployment permission
ibetitsmike Aug 18, 2026
2c9b320
fix(site): enforce MCP form permissions
ibetitsmike Aug 18, 2026
a18db66
fix(site): admit mutation-only orgs and reset create form on success
ibetitsmike Aug 18, 2026
d248f33
test(site): assert MCP create error toast presence
ibetitsmike Aug 18, 2026
8bc8acb
fix(site): reach add flow for create-only organizations
ibetitsmike Aug 18, 2026
026afc4
fix(site): keep denied MCP organizations selected
ibetitsmike Aug 18, 2026
310abb8
test(site): cover denied MCP permission refetch
ibetitsmike Aug 18, 2026
f0dcd12
fix(site): expose disabled MCP switch reason accessibly
ibetitsmike Aug 18, 2026
c7362f8
test(site): assert accessible disabled state for enabled switch
ibetitsmike Aug 18, 2026
d6d7622
fix(site): always show MCP target organization
ibetitsmike Aug 18, 2026
fad55eb
test(site): keep MCP add navigation story isolated
ibetitsmike Aug 18, 2026
9c8c851
fix(coderd): admit MCP managers to audit resource links
ibetitsmike Aug 18, 2026
71d1def
fix(site): pin implicit MCP create target in the URL
ibetitsmike Aug 18, 2026
d50c2d9
fix(coderd): keep MCP audit links for mutation-only admins
ibetitsmike Aug 18, 2026
7cada68
fix(site): disambiguate create-only MCP add targets
ibetitsmike Aug 18, 2026
6c964b9
fix(site): disambiguate MCP add target from the selected organization
ibetitsmike Aug 18, 2026
adbe7c9
fix(site/src): include the selected list-only org when disambiguating…
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
23 changes: 18 additions & 5 deletions coderd/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"

Expand All @@ -22,7 +23,6 @@ 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 @@ -619,16 +619,29 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
// not username-scoped in the URL like workspaces or tasks.
return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID)
case database.ResourceTypeMCPServerConfig:
// Mutation-only administrators may lack read access, so fetch
// with system access; the explicit checks below authorize.
//nolint:gocritic // The requester is authorized against the fetched config right below.
config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), alog.AuditLog.ResourceID)
if err != nil {
return ""
}
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 {
// The MCP settings page admits anyone who can manage the
// config, so emit the link only for callers it will accept.
if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, config.RBACObject()); err != nil {
if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionDelete, config.RBACObject()); err != nil {
return ""
}
}
organization, err := api.Database.GetOrganizationByID(ctx, alog.AuditLog.OrganizationID)
if err != nil {
return ""
}
return fmt.Sprintf("/ai/settings/mcp-servers/%s", alog.AuditLog.ResourceID)
return fmt.Sprintf("/ai/settings/mcp-servers/%s?org=%s", alog.AuditLog.ResourceID, url.QueryEscape(organization.Name))
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
18 changes: 11 additions & 7 deletions coderd/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,20 @@ func TestAuditLogs(t *testing.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))
organization := dbgen.Organization(t, db, database.Organization{
Name: "mcp-audit-link-org",
})
require.NotEqual(t, user.OrganizationID, organization.ID)
orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, organization.ID, rbac.ScopedRoleOrgAdmin(organization.ID))

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

Expand All @@ -174,16 +178,16 @@ func TestAuditLogs(t *testing.T) {
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.
// Organization admins hold MCP management permissions, and the
// settings page now admits them.
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)
require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s?org=mcp-audit-link-org", config.ID), orgAdminLogs.AuditLogs[0].ResourceLink)

ownerLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Expand All @@ -192,7 +196,7 @@ func TestAuditLogs(t *testing.T) {
})
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)
require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s?org=mcp-audit-link-org", config.ID), ownerLogs.AuditLogs[0].ResourceLink)
})

t.Run("Organization", func(t *testing.T) {
Expand Down
7 changes: 5 additions & 2 deletions docs/ai-coder/agents/platform-controls/mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ servers are injected into or offered to users during chat depending on the
availability policy.

This is an admin-only feature accessible at **AI Settings** > **Coder Agents** > **MCP servers**
(`/ai/settings/mcp-servers`). The settings page currently manages the default
organization's servers; other organizations are managed through the API.
(`/ai/settings/mcp-servers`). In multi-organization deployments, use the
organization picker to choose which organization's servers to manage. The
server list shows the picker when you can access more than one organization's
servers. The add and update views always show the target organization, as a
read-only field when only one organization is available.

## Add an MCP server

Expand Down
60 changes: 60 additions & 0 deletions enterprise/coderd/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ package coderd_test

import (
"context"
"fmt"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)

func TestEnterpriseAuditLogs(t *testing.T) {
Expand Down Expand Up @@ -102,4 +106,60 @@ func TestEnterpriseAuditLogs(t *testing.T) {
// OrganizationID is deprecated, but make sure it is empty.
require.Equal(t, uuid.Nil, alogs.AuditLogs[0].OrganizationID)
})

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

ctx := testutil.Context(t, testutil.WaitLong)
owner, firstUser := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})

config := createMCPServerConfigForOrganization(t, owner, firstUser.OrganizationID, "audit-link-mcp")
// Remove the default everyone-in-org ACL grant so the custom
// role's update permission is the requester's only access.
//nolint:gocritic // Owner access removes the default ACL grant.
err := owner.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
GroupRoles: map[string]codersdk.MCPServerConfigRole{
firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted,
},
})
require.NoError(t, err)

//nolint:gocritic // Owner access isolates custom-role setup from the behavior under test.
role, err := owner.CreateOrganizationRole(ctx, codersdk.Role{
Name: "mcp-update-only-audit",
OrganizationID: firstUser.OrganizationID.String(),
OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceMCPServerConfig: {codersdk.ActionUpdate},
codersdk.ResourceAuditLog: {codersdk.ActionRead},
}),
})
require.NoError(t, err)
updateOnly, _ := coderdtest.CreateAnotherUser(t, owner, firstUser.OrganizationID,
rbac.RoleIdentifier{Name: role.Name, OrganizationID: firstUser.OrganizationID})

err = owner.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
Action: codersdk.AuditActionCreate,
ResourceType: codersdk.ResourceTypeMCPServerConfig,
ResourceID: config.ID,
OrganizationID: firstUser.OrganizationID,
})
require.NoError(t, err)

// Update-only administrators lack config read access, yet the
// settings page admits them, so the link must still appear.
logs, err := updateOnly.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Len(t, logs.AuditLogs, 1)
require.Contains(t, logs.AuditLogs[0].ResourceLink, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID))
})
}
16 changes: 16 additions & 0 deletions site/permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,22 @@
"object": { "resource_type": "idpsync_settings", "any_org": true },
"action": "read"
},
"viewAnyMCPServerConfigs": {
"object": { "resource_type": "mcp_server_config", "any_org": true },
"action": "read"
},
"createAnyMCPServerConfig": {
"object": { "resource_type": "mcp_server_config", "any_org": true },
"action": "create"
},
"updateAnyMCPServerConfig": {
"object": { "resource_type": "mcp_server_config", "any_org": true },
"action": "update"
},
"deleteAnyMCPServerConfig": {
"object": { "resource_type": "mcp_server_config", "any_org": true },
"action": "delete"
},
"editAnySettings": {
"object": { "resource_type": "organization", "any_org": true },
"action": "update"
Expand Down
10 changes: 10 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3921,6 +3921,16 @@ class ExperimentalApiMethods {
return response.data;
};

getMCPServerConfig = async (
organization: string,
id: string,
): Promise<TypesGen.MCPServerConfig> => {
const response = await this.axios.get<TypesGen.MCPServerConfig>(
mcpServerConfigPath(organization, id),
);
return response.data;
};

createMCPServerConfig = async (
organization: string,
req: TypesGen.CreateMCPServerConfigRequest,
Expand Down
9 changes: 9 additions & 0 deletions site/src/api/queries/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2336,6 +2336,15 @@ export const mcpServerConfigs = (organization: string) => ({
API.experimental.getMCPServerConfigs(organization),
});

export const mcpServerConfigKey = (organization: string, id: string) =>
[...mcpServerConfigsKey(organization), "detail", id] as const;

export const mcpServerConfig = (organization: string, id: string) => ({
queryKey: mcpServerConfigKey(organization, id),
queryFn: (): Promise<TypesGen.MCPServerConfig> =>
API.experimental.getMCPServerConfig(organization, id),
});

const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => {
await queryClient.invalidateQueries({ queryKey: mcpServersKey });
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,92 @@ export const ManyOrgs: Story = {
},
};

const collidingDisplayNameOrganizations = [
{ ...MockOrganization, name: "org-a", display_name: "Dev" },
{ ...MockOrganization2, name: "org-b", display_name: "Dev" },
{ ...MockOrganization, id: "org-c", name: "org-c", display_name: "Ops" },
];

export const CollidingDisplayNames: Story = {
args: {
value: collidingDisplayNameOrganizations[0],
options: collidingDisplayNameOrganizations,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: /Dev \(org-a\)/ }),
).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button"));
await waitFor(() => {
expect(
screen.getByRole("option", { name: "Dev (org-a)" }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Dev (org-b)" }),
).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Ops" })).toBeInTheDocument();
});
},
};

// Collides with an option but is not itself selectable, mirroring a
// deep link to a list-only organization.
const listOnlyOrganization = {
...MockOrganization,
id: "org-t",
name: "org-t",
display_name: "Dev",
};

export const NonOptionValueDisambiguatesOptions: Story = {
args: {
value: listOnlyOrganization,
options: collidingDisplayNameOrganizations.slice(0, 1),
labelOrganizations: [
...collidingDisplayNameOrganizations.slice(0, 1),
listOnlyOrganization,
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: /Dev \(org-t\)/ }),
).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button"));
await waitFor(() => {
expect(
screen.getByRole("option", { name: "Dev (org-a)" }),
).toBeInTheDocument();
expect(
screen.queryByRole("option", { name: /org-t/ }),
).not.toBeInTheDocument();
});
},
};

export const FallbackNameCollision: Story = {
args: {
value: null,
options: [
{ ...MockOrganization, name: "dev", display_name: "" },
{ ...MockOrganization2, name: "org-b", display_name: "dev" },
{ ...MockOrganization, id: "org-c", name: "org-c", display_name: "Ops" },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await waitFor(() => {
expect(screen.getByRole("option", { name: /^dev$/ })).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "dev (org-b)" }),
).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Ops" })).toBeInTheDocument();
});
},
};

export const WithValue: Story = {
args: {
value: MockOrganization2,
Expand Down
Loading
Loading