Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0c3b11a
feat(coderd): add MCP ACL candidates endpoint
ibetitsmike Aug 25, 2026
fc5da1a
feat(site): add MCP server ACL sharing plumbing
ibetitsmike Aug 25, 2026
661b361
feat(site): add MCP server sharing dialog
ibetitsmike Aug 25, 2026
ab5d87e
feat(site): wire MCP server sharing controls
ibetitsmike Aug 25, 2026
8233626
test(site): stabilize MCP share access story
ibetitsmike Aug 25, 2026
52a7ecb
refactor(site): narrow MCP permission query keys
ibetitsmike Aug 25, 2026
5f4a1ed
refactor(site): reuse typed query keys in MCP ACL queries
ibetitsmike Aug 25, 2026
82b68dc
fix(site): surface AI settings navigation for MCP share-only users
ibetitsmike Aug 25, 2026
115f978
test(site): cover share-only MCP navigation with interaction stories
ibetitsmike Aug 25, 2026
ff0a96b
fix(coderd): keep share-only MCP detail route contract
ibetitsmike Aug 25, 2026
220826f
fix(site): surface MCP permission lookup failure in AI settings redirect
ibetitsmike Aug 25, 2026
15e5b07
refactor(site): share ACL dialog implementation
ibetitsmike Aug 25, 2026
395e0ab
Merge remote-tracking branch 'origin/main' into mike/acl-sharing-a8yw
ibetitsmike Aug 26, 2026
3a04800
chore: tighten ACL sharing comments
ibetitsmike Aug 26, 2026
33c6eab
fix: align MCP sharing permissions
ibetitsmike Aug 26, 2026
ac1ba20
chore: merge main into MCP ACL sharing
ibetitsmike Aug 26, 2026
5795281
Merge remote-tracking branch 'origin/main' into mike/acl-sharing-a8yw
ibetitsmike Aug 26, 2026
58855aa
fix(coderd): limit ACL candidates route to v2
ibetitsmike Aug 26, 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
Prev Previous commit
Next Next commit
test(site): cover share-only MCP navigation with interaction stories
  • Loading branch information
ibetitsmike committed Aug 25, 2026
commit 115f9780dc4753c50c4bca442252e3262f943464
2 changes: 1 addition & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1393,7 +1393,7 @@ func New(options *Options) *API {
r.Post("/", api.createMCPServerConfig)
r.Route("/{mcpserverconfig}", func(r chi.Router) {
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
policy.ActionRead, policy.ActionUpdate, policy.ActionDelete)).Get("/", api.getMCPServerConfig)
policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare)).Get("/", api.getMCPServerConfig)
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
policy.ActionUpdate)).Patch("/", api.updateMCPServerConfig)
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
Expand Down
7 changes: 2 additions & 5 deletions coderd/mcp_acl_available_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,6 @@ func TestMCPServerConfigDisabledShareOnlyFetch(t *testing.T) {
DisplayName: "MCP Server Config Sharer",
OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true},
OrgPermissions: database.CustomRolePermissions{
{
ResourceType: rbac.ResourceMCPServerConfig.Type,
Action: policy.ActionRead,
},
{
ResourceType: rbac.ResourceMCPServerConfig.Type,
Action: policy.ActionShare,
Expand All @@ -147,7 +143,8 @@ func TestMCPServerConfigDisabledShareOnlyFetch(t *testing.T) {
require.NoError(t, err)

// Share-authorized callers keep access to disabled configs so they can
// still open them and manage sharing, matching the list behavior.
// still open them and manage sharing, matching the list behavior. The
// role grants share without read to prove the whole path admits it.
fetched, err := sharerClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Equal(t, config.ID, fetched.ID)
Expand Down
44 changes: 44 additions & 0 deletions site/src/modules/dashboard/DashboardLayout.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ const pageContent = (
</DashboardFullPage>
);

const mcpServersRouter = reactRouterParameters({
location: { path: "/" },
routing: [
{ path: "/", useStoryElement: true },
{ path: "/ai/settings", element: <h1>AI settings for sharers</h1> },
],
});

const modelSettingsRouter = reactRouterParameters({
location: { path: "/" },
routing: [
Expand Down Expand Up @@ -167,6 +175,42 @@ export const ACLReadableMemberCanOpenModels: Story = {
play: openModels,
};

export const CustomOrganizationRoleCanOpenMCPServers: Story = {
parameters: {
pixel: { matrix: pixelWithDesktop },
user: MockUserMember,
permissions: MockNoPermissions,
reactRouter: mcpServersRouter,
queries: [
{ key: buildInfoKey, data: MockBuildInfo },
{ key: updateCheckQueryKey, data: MockUpdateCheck },
{ key: deploymentStatsQueryKey, data: MockDeploymentStats },
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
[MockDefaultOrganization.id]: {
...MockNoOrganizationPermissions,
shareMCPServerConfig: true,
},
},
},
],
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
await user.click(
await canvas.findByRole("button", { name: "Admin settings" }),
);
await user.click(await screen.findByRole("menuitem", { name: "AI" }));
await expect(
await canvas.findByRole("heading", {
name: "AI settings for sharers",
}),
).toBeInTheDocument();
},
};

export const UpdateAvailable: Story = {
parameters: {
queries: [
Expand Down
89 changes: 89 additions & 0 deletions site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { chatModels } from "#/api/queries/chats";
import { organizationsPermissions } from "#/api/queries/organizations";
import {
MockDefaultOrganization,
MockNoOrganizationPermissions,
MockNoPermissions,
MockUserMember,
} from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
} from "#/testHelpers/storybook";
import { AISettingsIndexRedirect } from "./AISettingsIndexRedirect";

const meta: Meta<typeof AISettingsIndexRedirect> = {
title: "pages/AISettingsPage/AISettingsIndexRedirect",
component: AISettingsIndexRedirect,
decorators: [withAuthProvider, withDashboardProvider],
parameters: {
user: MockUserMember,
permissions: MockNoPermissions,
reactRouter: reactRouterParameters({
location: { path: "/ai/settings" },
routing: [
{ path: "/ai/settings", useStoryElement: true },
{
path: "/ai/settings/mcp-servers",
element: <h1>Organization MCP servers</h1>,
},
{ path: "/ai/settings/providers", element: <h1>AI providers</h1> },
],
}),
},
};

export default meta;
type Story = StoryObj<typeof AISettingsIndexRedirect>;

export const OrganizationMCPSharerRedirectsToMCPServers: Story = {
parameters: {
queries: [
{
key: chatModels(MockDefaultOrganization.id).queryKey,
data: { models: [], providers: [] },
},
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
[MockDefaultOrganization.id]: {
...MockNoOrganizationPermissions,
shareMCPServerConfig: true,
},
},
},
],
},
play: async () => {
await expect(
await screen.findByRole("heading", {
name: "Organization MCP servers",
}),
).toBeInTheDocument();
},
};

export const MemberWithoutMCPSharingFallsBack: Story = {
parameters: {
queries: [
{
key: chatModels(MockDefaultOrganization.id).queryKey,
data: { models: [], providers: [] },
},
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
[MockDefaultOrganization.id]: MockNoOrganizationPermissions,
},
},
],
},
play: async () => {
await expect(
await screen.findByRole("heading", { name: "AI providers" }),
).toBeInTheDocument();
},
};
32 changes: 0 additions & 32 deletions site/src/pages/AISettingsPage/AISettingsIndexRedirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,35 +57,3 @@ it("redirects a deployment administrator to Coder Agents", async () => {
await screen.findByText("Coder Agents");
expect(router.state.location.pathname).toBe("/ai/settings/coder-agents");
});

it("redirects an organization MCP sharer to MCP servers", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
vi.spyOn(API.experimental, "getChatModels").mockRejectedValue({
isAxiosError: true,
response: { status: 403 },
});
vi.spyOn(API, "checkAuthorization").mockResolvedValue({
[`${MockDefaultOrganization.id}.shareMCPServerConfig`]: true,
});
const router = createMemoryRouter(
[
{ path: "/ai/settings", element: <AISettingsIndexRedirect /> },
{
path: "/ai/settings/mcp-servers",
element: <div>MCP Servers</div>,
},
],
{ initialEntries: ["/ai/settings"] },
);

render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);

await screen.findByText("MCP Servers");
expect(router.state.location.pathname).toBe("/ai/settings/mcp-servers");
});
Loading