Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3900,6 +3900,15 @@ class ExperimentalApiMethods {
return response.data;
};

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

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

export const mcpServerConfig = (id: string) => ({
queryKey: [...mcpServersKey, "detail", id] as const,
queryFn: (): Promise<TypesGen.MCPServerConfig> =>
API.experimental.getMCPServerConfig(id),
});

const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => {
await queryClient.invalidateQueries({ queryKey: mcpServersKey });
};
Expand Down
4 changes: 0 additions & 4 deletions site/src/modules/dashboard/useDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ export const useDashboard = (): DashboardValue => {
return context;
};

export const getDefaultOrganizationId = (
organizations: DashboardValue["organizations"],
): string => organizations.find((org) => org.is_default)?.id ?? "";

export const getDefaultOrganizationName = (
organizations: DashboardValue["organizations"],
): string => organizations.find((org) => org.is_default)?.name ?? "";
Original file line number Diff line number Diff line change
@@ -1,32 +1,46 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import { createMCPServerConfig } from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import {
getDefaultOrganizationId,
useDashboard,
} from "#/modules/dashboard/useDashboard";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import {
mcpServersPath,
orgSearchParam,
selectOrganization,
} from "../organizationParam";
import AddMCPServerPageView from "./AddMCPServerPageView";

const AddMCPServerPage: FC = () => {
const { permissions } = useAuthenticated();
const { organizations } = useDashboard();
const organization = getDefaultOrganizationId(organizations);
const [searchParams, setSearchParams] = useSearchParams();
const organization = selectOrganization(
organizations,
searchParams.get(orgSearchParam),
);
const queryClient = useQueryClient();
const navigate = useNavigate();
const createMutation = useMutation(
createMCPServerConfig(queryClient, organization),
createMCPServerConfig(queryClient, organization?.id ?? ""),
);

return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AddMCPServerPageView
isSaving={createMutation.isPending}
onCancel={() => void navigate("/ai/settings/mcp-servers")}
organizations={organizations}
organization={organization}
onSelectOrganization={(org) => {
setSearchParams((params) => {
params.set(orgSearchParam, org.name);
return params;
});
}}
onCancel={() => void navigate(mcpServersPath(organization))}
Comment thread
ibetitsmike marked this conversation as resolved.
onCreateServer={async (req) => {
try {
const server = await createMutation.mutateAsync(req);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { MockMCPServerConfig } from "#/testHelpers/chatEntities";
import { MockDefaultOrganization } from "#/testHelpers/entities";
import AddMCPServerPageView from "./AddMCPServerPageView";

const meta: Meta<typeof AddMCPServerPageView> = {
title: "pages/AISettingsPage/MCPServersPage/AddMCPServerPageView",
component: AddMCPServerPageView,
args: {
isSaving: false,
organizations: [MockDefaultOrganization],
organization: MockDefaultOrganization,
onSelectOrganization: fn(),
onCreateServer: fn(
async (req: TypesGen.CreateMCPServerConfigRequest) =>
({ ...MockMCPServerConfig, ...req }) as TypesGen.MCPServerConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { pageTitle } from "#/utils/page";
import { MCPServerForm } from "../components/MCPServerForm";
import { OrganizationPicker } from "../components/OrganizationPicker";
import { mcpServersPath } from "../organizationParam";

interface AddMCPServerPageViewProps {
isSaving: boolean;
organizations: readonly TypesGen.Organization[];
organization: TypesGen.Organization | undefined;
onSelectOrganization: (organization: TypesGen.Organization) => void;
onCreateServer: (
req: TypesGen.CreateMCPServerConfigRequest,
) => Promise<unknown>;
Expand All @@ -13,13 +18,24 @@ interface AddMCPServerPageViewProps {

const AddMCPServerPageView: FC<AddMCPServerPageViewProps> = ({
isSaving,
organizations,
organization,
onSelectOrganization,
onCreateServer,
onCancel,
}) => {
return (
<>
<title>{pageTitle("Add server", "AI Settings")}</title>
<OrganizationPicker
id="mcp-add-organization"
className="mb-6"
organizations={organizations}
organization={organization}
onChange={onSelectOrganization}
/>
<MCPServerForm
listPath={mcpServersPath(organization)}
isSaving={isSaving}
onCreateServer={onCreateServer}
onCancel={onCancel}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import { MockDefaultOrganization, MockUserOwner } from "#/testHelpers/entities";
import type * as TypesGen from "#/api/typesGenerated";
import {
MockDefaultOrganization,
MockOrganization2,
MockUserOwner,
} from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
Expand All @@ -12,6 +17,14 @@ import MCPServersPage from "./MCPServersPage";
import { MockCoderMCPServer } from "./testFixtures";
import UpdateMCPServerPage from "./UpdateMCPServerPage/UpdateMCPServerPage";

const MockOrganization2MCPServer: TypesGen.MCPServerConfig = {
...MockCoderMCPServer,
id: "mcp-org2",
display_name: "Org2 Search",
slug: "org2-search",
organization_id: MockOrganization2.id,
};

const meta = {
title: "pages/AISettingsPage/MCPServersPage/MCPServersPage",
component: MCPServersPage,
Expand Down Expand Up @@ -47,6 +60,43 @@ export const ListUsesDefaultOrganization: Story = {
);
});
await expect(canvas.getByText("Coder")).toBeVisible();
expect(
canvas.queryByRole("button", { name: "Organization" }),
).not.toBeInTheDocument();
},
};

export const ListSwitchesOrganization: Story = {
parameters: {
organizations: [MockDefaultOrganization, MockOrganization2],
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/mcp-servers" },
routing: { path: "/ai/settings/mcp-servers" },
}),
},
beforeEach: () => {
spyOn(API.experimental, "getMCPServerConfigs").mockImplementation(
async (organization) =>
organization === MockOrganization2.id
? [MockOrganization2MCPServer]
: [MockCoderMCPServer],
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText("Coder")).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Organization" }));
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("option", {
name: MockOrganization2.display_name,
}),
);
await expect(await canvas.findByText("Org2 Search")).toBeVisible();
expect(canvas.queryByText("Coder")).not.toBeInTheDocument();
expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith(
MockOrganization2.id,
);
},
};

Expand Down Expand Up @@ -84,7 +134,89 @@ export const AddUsesDefaultOrganization: Story = {
},
};

export const UpdateLoadsDefaultOrganization: Story = {
export const AddToSelectedOrganization: Story = {
render: () => <AddMCPServerPage />,
parameters: {
organizations: [MockDefaultOrganization, MockOrganization2],
reactRouter: reactRouterParameters({
location: {
path: "/ai/settings/mcp-servers/add",
searchParams: { org: MockOrganization2.name },
},
routing: { path: "/ai/settings/mcp-servers/add" },
}),
},
beforeEach: () => {
spyOn(API.experimental, "createMCPServerConfig").mockResolvedValue(
MockOrganization2MCPServer,
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: "Organization" }),
).toHaveTextContent(MockOrganization2.display_name);
await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub");
await userEvent.type(
canvas.getByLabelText(/server url/i),
"https://api.githubcopilot.com/mcp/",
);
await userEvent.click(canvas.getByRole("button", { name: "Add server" }));
await waitFor(() => {
expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith(
MockOrganization2.id,
expect.objectContaining({ display_name: "GitHub" }),
);
});
},
};

export const AddSwitchesOrganization: Story = {
render: () => <AddMCPServerPage />,
parameters: {
organizations: [MockDefaultOrganization, MockOrganization2],
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/mcp-servers/add" },
routing: { path: "/ai/settings/mcp-servers/add" },
}),
},
beforeEach: () => {
spyOn(API.experimental, "createMCPServerConfig").mockResolvedValue(
MockOrganization2MCPServer,
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub");
await userEvent.type(
canvas.getByLabelText(/server url/i),
"https://api.githubcopilot.com/mcp/",
);
await userEvent.click(canvas.getByRole("button", { name: "Organization" }));
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("option", {
name: MockOrganization2.display_name,
}),
);
await expect(canvas.getByLabelText(/display name/i)).toHaveValue("GitHub");
await expect(
canvas.getByRole("link", { name: /back to mcp servers/i }),
).toHaveAttribute(
"href",
`/ai/settings/mcp-servers?org=${MockOrganization2.name}`,
);
await userEvent.click(canvas.getByRole("button", { name: "Add server" }));
await waitFor(() => {
expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith(
MockOrganization2.id,
expect.objectContaining({ display_name: "GitHub" }),
);
});
},
};

export const UpdateShowsDetailLoadError: Story = {
render: () => <UpdateMCPServerPage />,
parameters: {
reactRouter: reactRouterParameters({
Expand All @@ -93,15 +225,37 @@ export const UpdateLoadsDefaultOrganization: Story = {
}),
},
beforeEach: () => {
spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([
spyOn(API.experimental, "getMCPServerConfig").mockRejectedValue(
new Error("Failed to load MCP server."),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByText("Failed to load MCP server."),
).toBeVisible();
expect(canvas.queryByLabelText(/display name/i)).not.toBeInTheDocument();
},
};

export const UpdateLoadsServerById: Story = {
render: () => <UpdateMCPServerPage />,
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/mcp-servers/mcp-coder" },
routing: { path: "/ai/settings/mcp-servers/:serverId" },
}),
},
beforeEach: () => {
spyOn(API.experimental, "getMCPServerConfig").mockResolvedValue(
MockCoderMCPServer,
]);
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith(
MockDefaultOrganization.id,
expect(API.experimental.getMCPServerConfig).toHaveBeenCalledWith(
MockCoderMCPServer.id,
);
});
await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder");
Expand Down
Loading
Loading