Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit d2ecfb8

Browse files
authored
fix(mcp-gateway): derive the member delete affordance from the session user
getGatewayServerRemovalAction looked the caller up in server.connections, but that roster is admin-only (empty for members), so a member who registered a custom server never saw "delete for you" — and any member got it on a creator-less custom server via undefined === undefined. Pass the session user's id in (from useCurrentUser) and require a non-null creator; cover the real member-facing shape (connections: []) in the core tests plus a UI regression test for the member delete affordance. Generated-By: PostHog Code Task-Id: 2880070b-9691-450f-b566-b76d60089914
1 parent afd9e17 commit d2ecfb8

4 files changed

Lines changed: 107 additions & 41 deletions

File tree

packages/core/src/mcp-gateway/gatewayServers.test.ts

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -328,41 +328,47 @@ describe("getGatewayServerRemovalAction", () => {
328328
hedgehog_config: null,
329329
});
330330

331+
// Members never receive `connections` (it is admin-only), so every
332+
// non-admin case keeps the default empty roster — the real API shape.
331333
it.each([
332334
[
333335
"deletes a personally added custom server",
334336
server({
335337
created_by: gatewayUser(1),
336338
your_connection: connection(),
337-
connections: [
338-
{
339-
installation_id: "inst-1",
340-
user: gatewayUser(1),
341-
last_used_at: null,
342-
pending_oauth: false,
343-
needs_reauth: false,
344-
},
345-
],
346339
}),
347340
false,
341+
1,
348342
"delete_for_you",
349343
],
350344
[
351345
"disconnects from a custom server added by someone else",
352346
server({
353347
created_by: gatewayUser(2),
354348
your_connection: connection(),
355-
connections: [
356-
{
357-
installation_id: "inst-1",
358-
user: gatewayUser(1),
359-
last_used_at: null,
360-
pending_oauth: false,
361-
needs_reauth: false,
362-
},
363-
],
364349
}),
365350
false,
351+
1,
352+
"disconnect",
353+
],
354+
[
355+
"disconnects from a custom server with no recorded creator",
356+
server({
357+
created_by: null,
358+
your_connection: connection(),
359+
}),
360+
false,
361+
1,
362+
"disconnect",
363+
],
364+
[
365+
"disconnects when the current user is unknown",
366+
server({
367+
created_by: gatewayUser(1),
368+
your_connection: connection(),
369+
}),
370+
false,
371+
null,
366372
"disconnect",
367373
],
368374
[
@@ -371,39 +377,36 @@ describe("getGatewayServerRemovalAction", () => {
371377
template_id: "template-1",
372378
created_by: gatewayUser(1),
373379
your_connection: connection(),
374-
connections: [
375-
{
376-
installation_id: "inst-1",
377-
user: gatewayUser(1),
378-
last_used_at: null,
379-
pending_oauth: false,
380-
needs_reauth: false,
381-
},
382-
],
383380
}),
384381
false,
382+
1,
385383
"disconnect",
386384
],
387385
[
388386
"deletes a custom server for everyone when requested by an admin",
389387
server({}),
390388
true,
389+
1,
391390
"delete_for_everyone",
392391
],
393392
[
394393
"does not delete a catalog server for an admin without a connection",
395394
server({ template_id: "template-1" }),
396395
true,
396+
1,
397397
null,
398398
],
399399
[
400400
"returns no action without a personal connection",
401401
server({}),
402402
false,
403+
1,
403404
null,
404405
],
405-
] as const)("%s", (_label, srv, isAdmin, expected) => {
406-
expect(getGatewayServerRemovalAction(srv, isAdmin)).toBe(expected);
406+
] as const)("%s", (_label, srv, isAdmin, currentUserId, expected) => {
407+
expect(getGatewayServerRemovalAction(srv, isAdmin, currentUserId)).toBe(
408+
expected,
409+
);
407410
});
408411
});
409412

packages/core/src/mcp-gateway/gatewayServers.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,27 +165,27 @@ export type GatewayServerRemovalAction =
165165

166166
/**
167167
* Admins remove custom servers from the team gateway. For members, a custom
168-
* server registered by the owner of the current personal installation is
169-
* theirs to delete; catalog servers and custom servers registered by somebody
170-
* else remain team entries, so removing the caller's installation is
171-
* presented as disconnecting instead.
168+
* server they registered themselves is theirs to delete; catalog servers and
169+
* custom servers registered by somebody else remain team entries, so removing
170+
* the caller's installation is presented as disconnecting instead.
171+
*
172+
* The caller's identity must come in from the session user —
173+
* `server.connections` is admin-only (empty for members), so it cannot
174+
* identify a member caller.
172175
*/
173176
export function getGatewayServerRemovalAction(
174177
server: McpGatewayServer,
175178
isAdmin: boolean,
179+
currentUserId: number | null,
176180
): GatewayServerRemovalAction | null {
177181
if (isAdmin && server.template_id === null) return "delete_for_everyone";
178182

179-
const yourConnection = server.your_connection;
180-
if (!yourConnection) return null;
183+
if (!server.your_connection) return null;
181184

182-
const yourConnectionSummary = server.connections.find(
183-
(connection) =>
184-
connection.installation_id === yourConnection.installation_id,
185-
);
186185
const personallyAddedCustomServer =
187186
server.template_id === null &&
188-
server.created_by?.id === yourConnectionSummary?.user.id;
187+
server.created_by !== null &&
188+
server.created_by.id === currentUserId;
189189

190190
return personallyAddedCustomServer ? "delete_for_you" : "disconnect";
191191
}

packages/ui/src/features/mcp-gateway/components/parts/GatewayServerDetail.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
77
const mocks = vi.hoisted(() => ({
88
gateway: {} as Record<string, unknown>,
99
setMemberAccess: vi.fn(),
10+
currentUser: null as { id: number } | null,
11+
}));
12+
13+
vi.mock("@posthog/ui/features/auth/authClient", () => ({
14+
useOptionalAuthenticatedClient: () => null,
15+
}));
16+
17+
vi.mock("@posthog/ui/features/auth/useCurrentUser", () => ({
18+
useCurrentUser: () => ({ data: mocks.currentUser }),
1019
}));
1120

1221
vi.mock(
@@ -90,6 +99,7 @@ const server = {
9099
describe("GatewayServerDetail", () => {
91100
beforeEach(() => {
92101
vi.clearAllMocks();
102+
mocks.currentUser = null;
93103
mocks.gateway = {
94104
servers: [server],
95105
serversLoading: false,
@@ -134,4 +144,47 @@ describe("GatewayServerDetail", () => {
134144
successMessage: "Ada can now use Linear",
135145
});
136146
});
147+
148+
// Members receive `connections: []` (the roster is admin-only), so the
149+
// delete affordance must derive from the session user, not the roster.
150+
it("offers a member delete on their own custom server", () => {
151+
mocks.currentUser = { id: 7 };
152+
const yourServer = {
153+
...server,
154+
connections: [],
155+
revoked_user_ids: [],
156+
created_by: {
157+
id: 7,
158+
uuid: "user-7",
159+
email: "ada@example.com",
160+
hedgehog_config: null,
161+
},
162+
your_connection: {
163+
installation_id: "installation-1",
164+
is_enabled: true,
165+
pending_oauth: false,
166+
needs_reauth: false,
167+
last_used_at: null,
168+
},
169+
} as McpGatewayServer;
170+
mocks.gateway = { ...mocks.gateway, servers: [yourServer] };
171+
172+
render(
173+
<Theme>
174+
<GatewayServerDetail
175+
serverId={yourServer.id}
176+
isAdmin={false}
177+
canManageAgentAccess={false}
178+
onNavigate={vi.fn()}
179+
/>
180+
</Theme>,
181+
);
182+
183+
expect(
184+
screen.getByRole("button", { name: "Delete server" }),
185+
).toBeInTheDocument();
186+
expect(
187+
screen.queryByRole("button", { name: /Disconnect/ }),
188+
).not.toBeInTheDocument();
189+
});
137190
});

packages/ui/src/features/mcp-gateway/components/parts/GatewayServerDetail.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
getGatewayServerRemovalAction,
2525
} from "@posthog/core/mcp-gateway/gatewayServers";
2626
import { usableInstallationId } from "@posthog/core/mcp-gateway/gatewayToolDiscovery";
27+
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
28+
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
2729
import {
2830
gatewayUserName,
2931
RobotAvatar,
@@ -117,6 +119,10 @@ export function GatewayServerDetail({
117119
});
118120
const members = useGatewayMembers({ enabled: isAdmin });
119121
const serviceAccounts = useServiceAccounts();
122+
// `server.connections` is admin-only (empty for members), so identifying
123+
// the caller as a custom server's creator needs the session user instead.
124+
const apiClient = useOptionalAuthenticatedClient();
125+
const { data: currentUser } = useCurrentUser({ client: apiClient });
120126

121127
const scopes = useMemo<GatewayPolicyScope[]>(() => {
122128
if (!server) return [];
@@ -167,7 +173,11 @@ export function GatewayServerDetail({
167173
const template = server.template_id
168174
? gateway.templatesById.get(server.template_id)
169175
: undefined;
170-
const serverRemovalAction = getGatewayServerRemovalAction(server, isAdmin);
176+
const serverRemovalAction = getGatewayServerRemovalAction(
177+
server,
178+
isAdmin,
179+
currentUser?.id ?? null,
180+
);
171181
const deletesForEveryone = serverRemovalAction === "delete_for_everyone";
172182
const deleteInstallationId =
173183
serverRemovalAction === "delete_for_you"

0 commit comments

Comments
 (0)