diff --git a/.changeset/delete-mcp-server.md b/.changeset/delete-mcp-server.md new file mode 100644 index 000000000..f444c4590 --- /dev/null +++ b/.changeset/delete-mcp-server.md @@ -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. diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index 8a1c1d128..3b9d3d970 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -13,6 +13,7 @@ import { authorizeMcpServerRoute, createMcpServerRoute, deleteAuthorizationMcpServerRoute, + deleteMcpServerRoute, getMcpServerRoute, listAvailableMcpServersRoute, listMcpServersRoute, @@ -307,11 +308,18 @@ export function createSettingsMcpServersRouter(deps: McpServersRou } }; + const deleteHandler: RouteHandler = 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; } diff --git a/packages/trueforge/src/db/mcpServerStore.ts b/packages/trueforge/src/db/mcpServerStore.ts index 762f736c8..736edbee8 100644 --- a/packages/trueforge/src/db/mcpServerStore.ts +++ b/packages/trueforge/src/db/mcpServerStore.ts @@ -78,6 +78,11 @@ export interface IMcpServerStore extends IOAuthClientStore * Never overwrites `id`, `oauth_server`, or `oauth_client`. */ upsertServer(input: UpsertMcpServerInput, transaction?: TTransaction): Promise; + /** + * 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; } /** diff --git a/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts b/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts index 553c73ab2..264e7e16f 100644 --- a/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts +++ b/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts @@ -123,6 +123,11 @@ export class PostgresMcpServerStore implements IMcpServerStore): Promise { + 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): Promise { const db = transaction ?? this.#db; const row = await db diff --git a/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts b/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts index 2ef987ad8..11e9c628d 100644 --- a/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts +++ b/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts @@ -127,6 +127,11 @@ export class SqliteMcpServerStore implements IMcpServerStore): Promise { + 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): Promise { const db = transaction ?? this.#db; const row = await db diff --git a/packages/trueforge/src/routes/mcpServerRoutes.ts b/packages/trueforge/src/routes/mcpServerRoutes.ts index 9bfda2f62..02c4e3fdf 100644 --- a/packages/trueforge/src/routes/mcpServerRoutes.ts +++ b/packages/trueforge/src/routes/mcpServerRoutes.ts @@ -7,6 +7,7 @@ import { createRoute, z } from '@hono/zod-openapi'; import { RequestErrorResponseSchema } from '../schemas/errors'; import { CreateMcpServerRequestSchema, + DeleteMcpServerResponseSchema, GetMcpServerResponseSchema, ListAvailableMcpServersResponseSchema, ListMcpServersResponseSchema, @@ -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. diff --git a/packages/trueforge/src/schemas/mcpServer.ts b/packages/trueforge/src/schemas/mcpServer.ts index ae4d3739c..19212d77d 100644 --- a/packages/trueforge/src/schemas/mcpServer.ts +++ b/packages/trueforge/src/schemas/mcpServer.ts @@ -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 diff --git a/packages/trueforge/tests/db/mcpServerStoreContractSuite.ts b/packages/trueforge/tests/db/mcpServerStoreContractSuite.ts index 7d0d28f9f..ff3428d1f 100644 --- a/packages/trueforge/tests/db/mcpServerStoreContractSuite.ts +++ b/packages/trueforge/tests/db/mcpServerStoreContractSuite.ts @@ -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({ diff --git a/packages/trueforge/tests/unit/apis/mcpServers.test.ts b/packages/trueforge/tests/unit/apis/mcpServers.test.ts index 725c73239..1d217b2bf 100644 --- a/packages/trueforge/tests/unit/apis/mcpServers.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpServers.test.ts @@ -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({}); + }); });