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/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.
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({});
});
});