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
5 changes: 5 additions & 0 deletions .changeset/connector-remove-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge-ui": patch
---

Add a "Remove" button to configured connectors in Settings → Connectors, wiring the previously-unimplemented `deleteConnector` now that `DELETE /api/v1/settings/mcp-servers/{name}` exists (#495).
5 changes: 5 additions & 0 deletions .changeset/delete-mcp-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge": minor
---

Add `DELETE /api/v1/settings/mcp-servers/{name}` to permanently remove a configured MCP server (its OAuth tokens and pending authorizations cascade-delete). Idempotent if already gone.
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ const ConnectorSettings = () => {
}).catch(() => {});
};

const handleRemoveConnector = (connector: ConnectorBase) => {
const deleteConnector = connectorCatalog.deleteConnector;
if (!deleteConnector) return;
void runMutation(async () => {
await deleteConnector({ id: connector.id });
}).catch(() => {});
};

const handleConnectorRefreshed = (refreshedConnector: ConnectorBase) => {
setSelectedConnector(current => (current?.id === refreshedConnector.id ? refreshedConnector : current));
setConnectors(current => {
Expand Down Expand Up @@ -312,6 +320,22 @@ const ConnectorSettings = () => {
Replace Key
</Button>
) : null}
{connectorCatalog.deleteConnector ? (
<Button
variant="secondary"
size="sm"
type="button"
className="transition-colors hover:bg-failure-bg/10 hover:text-failure-bg"
disabled={busy}
aria-label={`Remove ${connector.name}`}
onClick={event => {
event.stopPropagation();
handleRemoveConnector(connector);
}}
>
Remove
</Button>
) : null}
<Icon name="chevron-right" className="size-4" />
</div>
</article>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function toHarnessManifest(req: {
};
}

/** Settings connector port for `createTrueFoundryServer`. Delete omitted; disconnect unsupported. */
/** Settings connector port for `createTrueFoundryServer`. */
export function createConnectorCatalog(
client: TrueForge,
): ConnectorCatalogServer<
Expand Down Expand Up @@ -228,5 +228,8 @@ export function createConnectorCatalog(
const body = await client.mcpServers.deleteAuthorization(req.id);
return toUiConnector(body.data);
},
deleteConnector: async req => {
await client.settings.mcpServers.delete(req.id);
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import ConnectorSettings from '@/containers/SettingsBuilder/ConnectorSettings.js';
import { ServerProvider } from '@/server/ServerContext.js';
import type { ConnectorBase } from '@/server/types.js';
import { createMockAgentUIServer, createMockCatalog } from '../../server/mockServer.js';

const connectedLinear: ConnectorBase = {
id: 'linear',
name: 'linear',
description: 'Search, read, and create Linear issues.',
url: 'https://mcp.linear.app/mcp',
auth: { type: 'dcr' },
requiresAuth: false,
authenticated: true,
};

function renderConnectorSettings({ deleteConnector }: { deleteConnector?: (req: { id: string }) => Promise<void> }) {
const server = createMockAgentUIServer({
catalog: createMockCatalog({
connectorCatalog: {
getConnectorCatalog: async () => [],
listConnectors: async () => [connectedLinear],
getConnector: async () => connectedLinear,
getToolsByConnectorId: async () => [],
createConnector: async () => connectedLinear,
updateConnector: async () => connectedLinear,
authenticateConnector: async () => ({ authorization_endpoint: '' }),
disconnectConnector: async () => connectedLinear,
...(deleteConnector ? { deleteConnector } : {}),
},
}),
});

render(
<ServerProvider server={server}>
<ConnectorSettings />
</ServerProvider>,
);
}

describe('ConnectorSettings Remove button (fixes #494)', () => {
it('hides Remove when the host has not wired deleteConnector', async () => {
renderConnectorSettings({});
await screen.findByText('linear');
expect(screen.queryByRole('button', { name: 'Remove linear' })).not.toBeInTheDocument();
});

it('shows Remove and calls deleteConnector when the host supports it', async () => {
const deleteConnector = vi.fn(async () => undefined);
renderConnectorSettings({ deleteConnector });

const row = await screen.findByText('linear');
const removeButton = within(row.closest('article') as HTMLElement).getByRole('button', { name: 'Remove linear' });
fireEvent.click(removeButton);

await waitFor(() => expect(deleteConnector).toHaveBeenCalledWith({ id: 'linear' }));
});

it('Remove does not open the connector details view (stops propagation)', async () => {
const deleteConnector = vi.fn(async () => undefined);
renderConnectorSettings({ deleteConnector });

const row = await screen.findByText('linear');
const removeButton = within(row.closest('article') as HTMLElement).getByRole('button', { name: 'Remove linear' });
fireEvent.click(removeButton);

await waitFor(() => expect(deleteConnector).toHaveBeenCalled());
expect(screen.queryByRole('button', { name: 'Connectors' })).not.toBeInTheDocument();
});
});
8 changes: 8 additions & 0 deletions packages/trueforge/src/apis/mcpServers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
authorizeMcpServerRoute,
createMcpServerRoute,
deleteAuthorizationMcpServerRoute,
deleteMcpServerRoute,
getMcpServerRoute,
listAvailableMcpServersRoute,
listMcpServersRoute,
Expand Down Expand Up @@ -307,11 +308,18 @@ export function createSettingsMcpServersRouter<TTransaction>(deps: McpServersRou
}
};

const deleteHandler: RouteHandler<typeof deleteMcpServerRoute> = async c => {
const { name } = c.req.valid('param');
await deps.mcpServerStore.deleteServer({ tenant_id: TENANT_ID, name });
return c.json({}, 200);
};

const router = new OpenAPIHono();
router.openapi(listMcpServersRoute, listHandler);
router.openapi(createMcpServerRoute, createHandler);
router.openapi(putMcpServerRoute, putHandler);
router.openapi(getMcpServerRoute, getHandler);
router.openapi(deleteMcpServerRoute, deleteHandler);
return router;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/trueforge/src/db/mcpServerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export interface IMcpServerStore<TTransaction = never> extends IOAuthClientStore
* Never overwrites `id`, `oauth_server`, or `oauth_client`.
*/
upsertServer(input: UpsertMcpServerInput, transaction?: TTransaction): Promise<McpServerRecord>;
/**
* Permanently removes the server row. OAuth tokens and pending authorizations cascade-delete
* via their `oauth_server_id` foreign key. Idempotent if already gone.
*/
deleteServer(input: GetMcpServerInput, transaction?: TTransaction): Promise<void>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ export class PostgresMcpServerStore implements IMcpServerStore<Transaction<Datab
return toRecord(row);
}

async deleteServer(input: GetMcpServerInput, transaction?: Transaction<Database>): Promise<void> {
const db = transaction ?? this.#db;
await db.deleteFrom('mcp_server').where('tenant_id', '=', input.tenant_id).where('name', '=', input.name).execute();
}

async getClient(params: { id: string }, transaction?: Transaction<Database>): Promise<OAuthClientRecord | undefined> {
const db = transaction ?? this.#db;
const row = await db
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ export class SqliteMcpServerStore implements IMcpServerStore<Transaction<Databas
.executeTakeFirstOrThrow();
}

async deleteServer(input: GetMcpServerInput, transaction?: Transaction<Database>): Promise<void> {
const db = transaction ?? this.#db;
await db.deleteFrom('mcp_server').where('tenant_id', '=', input.tenant_id).where('name', '=', input.name).execute();
}

async getClient(params: { id: string }, transaction?: Transaction<Database>): Promise<OAuthClientRecord | undefined> {
const db = transaction ?? this.#db;
const row = await db
Expand Down
30 changes: 30 additions & 0 deletions packages/trueforge/src/routes/mcpServerRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createRoute, z } from '@hono/zod-openapi';
import { RequestErrorResponseSchema } from '../schemas/errors';
import {
CreateMcpServerRequestSchema,
DeleteMcpServerResponseSchema,
GetMcpServerResponseSchema,
ListAvailableMcpServersResponseSchema,
ListMcpServersResponseSchema,
Expand Down Expand Up @@ -157,6 +158,35 @@ export const putMcpServerRoute = createRoute({
},
});

export const deleteMcpServerRoute = createRoute({
method: 'delete',
path: '/{name}',
tags: [OpenApiTag.MCP_SERVERS],
summary: 'Delete an MCP server',
description:
'Permanently removes the configured MCP server by name, including any stored OAuth tokens and ' +
'pending authorizations. Idempotent if already gone.',
'x-fern-sdk-group-name': ['settings', 'mcpServers'],
'x-fern-sdk-method-name': 'delete',
request: {
params: McpServerNameParamsSchema,
},
responses: {
200: {
content: { 'application/json': { schema: DeleteMcpServerResponseSchema } },
description: 'MCP server deleted.',
},
401: {
content: { 'application/json': { schema: RequestErrorResponseSchema } },
description: 'OIDC is configured and the request has no valid session cookie.',
},
403: {
content: { 'application/json': { schema: RequestErrorResponseSchema } },
description: 'OIDC is configured and the caller is authenticated but not an admin.',
},
},
});

const ListMcpServerToolsResponseSchema = z
.object({
// TODO: Type tools/list entries to the MCP tool shape (name, description, inputSchema, …) for OpenAPI quality.
Expand Down
1 change: 1 addition & 0 deletions packages/trueforge/src/schemas/mcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const GetMcpServerResponseSchema = z.object({ data: ConfiguredMcpServerSc
export const ListMcpServersResponseSchema = z
.object({ data: z.array(ConfiguredMcpServerSchema) })
.openapi('ListMCPServersResponse');
export const DeleteMcpServerResponseSchema = z.object({}).openapi('DeleteMCPServerResponse');

/** Public auth mechanism for chat/composer (no secrets). */
export const McpServerAuthPublicSchema = z
Expand Down
19 changes: 19 additions & 0 deletions packages/trueforge/tests/db/mcpServerStoreContractSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,25 @@ export function runMcpServerStoreContractSuite(getStore: () => IMcpServerStore):
await expect(store.listServers({ tenant_id: TENANT, names: [] })).resolves.toEqual([]);
});

it('deleteServer removes the row and cascade-clears its saved OAuth client', async () => {
const store = getStore();
const created = await store.upsertServer({ tenant_id: TENANT, name: 'linear', manifest: manifest() });
await store.saveClient({ id: created.id, record: sampleOAuthClient });

await store.deleteServer({ tenant_id: TENANT, name: 'linear' });

await expect(store.getServer({ tenant_id: TENANT, name: 'linear' })).resolves.toBeUndefined();
await expect(store.getClient({ id: created.id })).resolves.toBeUndefined();
});

it('deleteServer is idempotent for an unknown server and leaves other tenants untouched', async () => {
const store = getStore();
const otherTenant = await store.upsertServer({ tenant_id: 'other-tenant', name: 'linear', manifest: manifest() });

await expect(store.deleteServer({ tenant_id: TENANT, name: 'linear' })).resolves.toBeUndefined();
await expect(store.getServer({ tenant_id: 'other-tenant', name: 'linear' })).resolves.toEqual(otherTenant);
});

it('upsert leaves oauth columns null and does not clear a saved OAuth client', async () => {
const store = getStore();
const created = await store.upsertServer({
Expand Down
31 changes: 31 additions & 0 deletions packages/trueforge/tests/unit/apis/mcpServers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,4 +870,35 @@ describe('mcp-servers routers', () => {
const missing = await mcpServersRouter.request('/missing/authorize', { method: 'DELETE' });
expect(missing.status).toBe(404);
});

it('DELETE /{name} permanently removes the server and cascades its OAuth token', async () => {
const record = await seedDcrServerWithClient({ ...putBodyWithDcr, name: 'to-delete' });
await tokenStore.saveToken({
id: record.id,
userRef: LOCAL_USER_CONTEXT.userRef,
token: {
accessToken: 'access-1',
refreshToken: null,
expiresAt: '2099-01-01T00:00:00.000Z',
scope: null,
},
});

const response = await settingsRouter.request('/to-delete', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({});

expect(await mcpServerStore.getServer({ tenant_id: TENANT_ID, name: 'to-delete' })).toBeUndefined();
expect(await tokenStore.getToken({ id: record.id, userRef: LOCAL_USER_CONTEXT.userRef })).toBeUndefined();

const listed = await settingsRouter.request('/');
const names = ((await listed.json()) as { data: { name: string }[] }).data.map(server => server.name);
expect(names).not.toContain('to-delete');
});

it('DELETE /{name} is idempotent for an unknown server', async () => {
const response = await settingsRouter.request('/never-existed', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({});
});
});