diff --git a/src/app/api/v2/config/mcp/oauth-clients/[clientId]/route.test.ts b/src/app/api/v2/config/mcp/oauth-clients/[clientId]/route.test.ts new file mode 100644 index 0000000..ce70986 --- /dev/null +++ b/src/app/api/v2/config/mcp/oauth-clients/[clientId]/route.test.ts @@ -0,0 +1,78 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +const mockGetUser = jest.fn(); +const mockDelete = jest.fn(); + +jest.mock('server/lib/get-user', () => ({ + __esModule: true, + getUser: (...args: unknown[]) => mockGetUser(...args), + getRequestUserIdentity: (req: unknown) => { + const payload = mockGetUser(req) as { sub?: string; realm_access?: { roles?: string[] } } | null; + return payload ? { userId: payload.sub, roles: payload.realm_access?.roles ?? [] } : null; + }, + requireRequestUserIdentity: (req: unknown) => { + const payload = mockGetUser(req) as { sub?: string; realm_access?: { roles?: string[] } } | null; + if (!payload?.sub) throw new Error('unauthorized'); + return { userId: payload.sub, roles: payload.realm_access?.roles ?? [] }; + }, +})); + +jest.mock('server/services/keycloak/mcpOauthClients', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({ + delete: (...args: unknown[]) => mockDelete(...args), + })), + }, +})); + +import { DELETE } from './route'; + +function request(): NextRequest { + return { + method: 'DELETE', + headers: new Headers({ 'x-request-id': 'request-2' }), + nextUrl: new URL('http://localhost/api/v2/config/mcp/oauth-clients/lifecycle-mcp-client-1'), + text: jest.fn().mockResolvedValue(''), + } as unknown as NextRequest; +} + +beforeEach(() => { + jest.clearAllMocks(); + process.env.ENABLE_AUTH = 'true'; + mockGetUser.mockReturnValue({ sub: 'admin-user', realm_access: { roles: ['admin'] } }); + mockDelete.mockResolvedValue(undefined); +}); + +it('deletes a Lifecycle-managed MCP OAuth client', async () => { + const response = await DELETE(request(), { + params: Promise.resolve({ clientId: 'lifecycle-mcp-client-1' }), + }); + expect(response.status).toBe(204); + expect(mockDelete).toHaveBeenCalledWith('lifecycle-mcp-client-1', 'admin-user', 'request-2'); +}); + +it('keeps deletion admin-only', async () => { + mockGetUser.mockReturnValue({ sub: 'ordinary-user', realm_access: { roles: ['user'] } }); + const response = await DELETE(request(), { + params: Promise.resolve({ clientId: 'lifecycle-mcp-client-1' }), + }); + expect(response.status).toBe(403); + expect(mockDelete).not.toHaveBeenCalled(); +}); diff --git a/src/app/api/v2/config/mcp/oauth-clients/[clientId]/route.ts b/src/app/api/v2/config/mcp/oauth-clients/[clientId]/route.ts new file mode 100644 index 0000000..6e42e0c --- /dev/null +++ b/src/app/api/v2/config/mcp/oauth-clients/[clientId]/route.ts @@ -0,0 +1,73 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { requireRequestUserIdentity } from 'server/lib/get-user'; +import McpOauthClientService from 'server/services/keycloak/mcpOauthClients'; + +/** + * @openapi + * /api/v2/config/mcp/oauth-clients/{clientId}: + * delete: + * summary: Delete a pre-registered Lifecycle MCP OAuth client + * description: Deletes only a public OAuth client that is marked as managed by Lifecycle MCP. + * tags: + * - Config + * operationId: deleteLifecycleMcpOauthClient + * parameters: + * - in: path + * name: clientId + * required: true + * schema: + * type: string + * description: Lifecycle-generated OAuth client ID. + * responses: + * '204': + * description: MCP OAuth client deleted. + * '401': + * description: Unauthorized. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '403': + * description: Forbidden. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Lifecycle-managed MCP OAuth client not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '503': + * description: Keycloak client management is unavailable. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const deleteHandler = async (req: NextRequest, { params }: { params: Promise<{ clientId: string }> }) => { + const { clientId } = await params; + const identity = requireRequestUserIdentity(req); + await McpOauthClientService.getInstance().delete(clientId, identity.userId, req.headers.get('x-request-id')); + return new NextResponse(null, { status: 204 }); +}; + +export const DELETE = createApiHandler(deleteHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/config/mcp/oauth-clients/route.test.ts b/src/app/api/v2/config/mcp/oauth-clients/route.test.ts new file mode 100644 index 0000000..67d63bb --- /dev/null +++ b/src/app/api/v2/config/mcp/oauth-clients/route.test.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +const mockGetUser = jest.fn(); +const mockList = jest.fn(); +const mockCreate = jest.fn(); + +jest.mock('server/lib/get-user', () => ({ + __esModule: true, + getUser: (...args: unknown[]) => mockGetUser(...args), + getRequestUserIdentity: (req: unknown) => { + const payload = mockGetUser(req) as { sub?: string; realm_access?: { roles?: string[] } } | null; + return payload ? { userId: payload.sub, roles: payload.realm_access?.roles ?? [] } : null; + }, + requireRequestUserIdentity: (req: unknown) => { + const payload = mockGetUser(req) as { sub?: string; realm_access?: { roles?: string[] } } | null; + if (!payload?.sub) throw new Error('unauthorized'); + return { userId: payload.sub, roles: payload.realm_access?.roles ?? [] }; + }, +})); + +jest.mock('server/services/keycloak/mcpOauthClients', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({ + list: (...args: unknown[]) => mockList(...args), + create: (...args: unknown[]) => mockCreate(...args), + })), + }, +})); + +import { GET, POST } from './route'; + +const CLIENT = { + clientId: 'lifecycle-mcp-client-1', + name: 'Desktop tool', + redirectUris: ['http://127.0.0.1:8123/callback'], + createdAt: '2026-08-01T20:00:00.000Z', +}; + +function request(method: 'GET' | 'POST', body?: unknown): NextRequest { + return { + method, + headers: new Headers({ 'x-request-id': 'request-1' }), + nextUrl: new URL('http://localhost/api/v2/config/mcp/oauth-clients'), + json: jest.fn().mockResolvedValue(body), + text: jest.fn().mockResolvedValue(body === undefined ? '' : JSON.stringify(body)), + } as unknown as NextRequest; +} + +beforeEach(() => { + jest.clearAllMocks(); + process.env.ENABLE_AUTH = 'true'; + mockGetUser.mockReturnValue({ sub: 'admin-user', realm_access: { roles: ['admin'] } }); + mockList.mockResolvedValue([CLIENT]); + mockCreate.mockResolvedValue(CLIENT); +}); + +it('lists Lifecycle-managed MCP OAuth clients', async () => { + const response = await GET(request('GET')); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ request_id: 'request-1', data: [CLIENT], error: null }); +}); + +it('creates a client as the authenticated administrator', async () => { + const body = { name: 'Desktop tool', redirectUris: ['http://127.0.0.1:8123/callback'] }; + const response = await POST(request('POST', body)); + expect(response.status).toBe(201); + expect(mockCreate).toHaveBeenCalledWith(body, 'admin-user', 'request-1'); +}); + +it('keeps list and create admin-only', async () => { + mockGetUser.mockReturnValue({ sub: 'ordinary-user', realm_access: { roles: ['user'] } }); + expect((await GET(request('GET'))).status).toBe(403); + expect((await POST(request('POST', { name: 'Client', redirectUris: ['https://example.com/callback'] }))).status).toBe( + 403 + ); +}); diff --git a/src/app/api/v2/config/mcp/oauth-clients/route.ts b/src/app/api/v2/config/mcp/oauth-clients/route.ts new file mode 100644 index 0000000..0948b36 --- /dev/null +++ b/src/app/api/v2/config/mcp/oauth-clients/route.ts @@ -0,0 +1,129 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { requireRequestUserIdentity } from 'server/lib/get-user'; +import { errorResponse, successResponse } from 'server/lib/response'; +import McpOauthClientService from 'server/services/keycloak/mcpOauthClients'; + +/** + * @openapi + * /api/v2/config/mcp/oauth-clients: + * get: + * summary: List pre-registered Lifecycle MCP OAuth clients + * description: Returns public OAuth clients created and managed by Lifecycle for MCP sign-in. + * tags: + * - Config + * operationId: listLifecycleMcpOauthClients + * responses: + * '200': + * description: Lifecycle-managed MCP OAuth clients. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ListLifecycleMcpOauthClientsSuccessResponse' + * '401': + * description: Unauthorized. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '403': + * description: Forbidden. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '503': + * description: Keycloak client management is unavailable. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * post: + * summary: Pre-register a Lifecycle MCP OAuth client + * description: Creates a public Authorization Code client with PKCE, consent, and fixed Lifecycle MCP scopes. + * tags: + * - Config + * operationId: createLifecycleMcpOauthClient + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateLifecycleMcpOauthClient' + * responses: + * '201': + * description: MCP OAuth client created. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LifecycleMcpOauthClientSuccessResponse' + * '400': + * description: Invalid client name or redirect URI. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '401': + * description: Unauthorized. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '403': + * description: Forbidden. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '409': + * description: The client conflicts with existing state or the client limit was reached. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '503': + * description: Keycloak client management is unavailable. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest) => { + const clients = await McpOauthClientService.getInstance().list(); + return successResponse(clients, { status: 200 }, req); +}; + +const postHandler = async (req: NextRequest) => { + let body: unknown; + try { + body = await req.json(); + } catch { + return errorResponse(new Error('Invalid JSON in request body'), { status: 400 }, req); + } + const identity = requireRequestUserIdentity(req); + const client = await McpOauthClientService.getInstance().create( + body, + identity.userId, + req.headers.get('x-request-id') + ); + return successResponse(client, { status: 201 }, req); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); +export const POST = createApiHandler(postHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index 467241e..854920e 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -216,6 +216,14 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ { method: 'PATCH', route: '/api/v2/config/metadata/{id}', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/config/mcp', policy: 'session', roles: ['admin'] }, { method: 'PUT', route: '/api/v2/config/mcp', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/config/mcp/oauth-clients', policy: 'session', roles: ['admin'] }, + { method: 'POST', route: '/api/v2/config/mcp/oauth-clients', policy: 'session', roles: ['admin'] }, + { + method: 'DELETE', + route: '/api/v2/config/mcp/oauth-clients/{clientId}', + policy: 'session', + roles: ['admin'], + }, { method: 'GET', route: '/api/v2/config/sites', policy: 'session', roles: ['admin'] }, { method: 'PUT', route: '/api/v2/config/sites', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/environments', policy: 'principal', scope: 'env:read' }, diff --git a/src/server/mcp/__tests__/registry.test.ts b/src/server/mcp/__tests__/registry.test.ts index a1957de..05a129c 100644 --- a/src/server/mcp/__tests__/registry.test.ts +++ b/src/server/mcp/__tests__/registry.test.ts @@ -209,6 +209,24 @@ it('rejects duplicate registered names without a fixed production count', () => ).toThrow('Duplicate MCP tool definition'); }); +it('rejects schemas that the MCP SDK JSON Schema dialect cannot interpret', () => { + const tool = definition('incompatible_output', 'understand-environments', 'read'); + tool.outputSchema = successObjectSchema( + { + values: { + type: 'array', + prefixItems: [{ type: 'string' }], + items: false, + }, + }, + ['values'] + ); + + expect(() => new McpToolRegistry([tool])).toThrow( + 'incompatible_output.outputSchema is incompatible with the MCP SDK JSON Schema validator' + ); +}); + it('rejects input and output schemas that exceed their byte budgets', () => { const oversizedInput = definition('oversized_input', 'understand-environments', 'read'); oversizedInput.inputSchema = { diff --git a/src/server/mcp/__tests__/toolHandlers.operations.test.ts b/src/server/mcp/__tests__/toolHandlers.operations.test.ts index 82efee2..a97feba 100644 --- a/src/server/mcp/__tests__/toolHandlers.operations.test.ts +++ b/src/server/mcp/__tests__/toolHandlers.operations.test.ts @@ -15,6 +15,7 @@ */ import type { Transaction } from 'objection'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; import { AppError } from 'server/lib/appError'; import type Build from 'server/models/Build'; import { BuildKind, BuildStatus, DeployStatus, DeployTypes } from 'shared/constants'; @@ -539,7 +540,7 @@ describe('destroy_environment', () => { } it('previews with a confirmation token sealed to the environment and user', async () => { - const { call } = harness({ + const { call, registry } = harness({ service: operationService({}), loadNamedEnvironment: async () => loadedEnvironment(environmentBuild()), lockDestroyPreview: async () => snapshot(), @@ -567,6 +568,10 @@ describe('destroy_environment', () => { expiresInSeconds: 300, }); expect(result.confirmToken).toMatch(/^lfcmcp_destroy_v1\./); + const outputSchema = registry.definitions().find(({ name }) => name === 'destroy_environment')!.outputSchema; + expect(new AjvJsonSchemaValidator().getValidator(outputSchema)(output)).toEqual( + expect.objectContaining({ valid: true }) + ); const claims = verifyDestroyConfirmation( result.confirmToken as string, { environmentId: ENVIRONMENT_ID, userId: 'user-1' }, diff --git a/src/server/mcp/schemaValidator.ts b/src/server/mcp/schemaValidator.ts index 79bf2cf..10a6ac0 100644 --- a/src/server/mcp/schemaValidator.ts +++ b/src/server/mcp/schemaValidator.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import Ajv2020, { type ErrorObject, type ValidateFunction } from 'ajv/dist/2020'; +import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; +import Ajv2020 from 'ajv/dist/2020'; import addFormats from 'ajv-formats'; import type { McpJsonObject, McpObjectSchema, McpToolDefinition } from './contracts'; @@ -44,18 +45,31 @@ export function successObjectSchema( return closedObjectSchema({ ...properties, requestId: MCP_REQUEST_ID_SCHEMA }, [...required, 'requestId']); } -const ajv = new Ajv2020({ +const validatorOptions = { allErrors: true, allowUnionTypes: true, coerceTypes: false, removeAdditional: false, strict: true, validateFormats: true, -}); -addFormats(ajv); +} as const; + +const ajv2020 = new Ajv2020(validatorOptions); +const mcpSdkDialectAjv = new Ajv(validatorOptions); +addFormats(ajv2020); +addFormats(mcpSdkDialectAjv); export function compileMcpJsonValidator(schema: Record): ValidateFunction { - return ajv.compile(schema); + return ajv2020.compile(schema); +} + +function assertMcpSdkDialectCompatible(schema: Record, label: string): void { + try { + mcpSdkDialectAjv.compile(schema); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${label} is incompatible with the MCP SDK JSON Schema validator: ${message}`); + } } export interface CompiledMcpToolDefinition { @@ -91,6 +105,8 @@ export function compileMcpToolDefinition(definition: McpToolDefinition): Compile assertCanonicalObjectSchema(definition.inputSchema, `${definition.name}.inputSchema`); assertCanonicalObjectSchema(definition.outputSchema, `${definition.name}.outputSchema`); assertSuccessRequestId(definition.outputSchema, definition.name); + assertMcpSdkDialectCompatible(definition.inputSchema, `${definition.name}.inputSchema`); + assertMcpSdkDialectCompatible(definition.outputSchema, `${definition.name}.outputSchema`); return { definition, diff --git a/src/server/mcp/tools/operations/schemas.ts b/src/server/mcp/tools/operations/schemas.ts index 9e9cd71..42f2ce8 100644 --- a/src/server/mcp/tools/operations/schemas.ts +++ b/src/server/mcp/tools/operations/schemas.ts @@ -300,17 +300,7 @@ const destroyPreviewResultSchema = closedObjectSchema( type: 'array', minItems: 3, maxItems: 3, - prefixItems: [ - { type: 'string', const: DESTROY_CONSEQUENCES_PREFIX }, - { - type: 'string', - minLength: 1, - maxLength: 200, - pattern: '^The name .+ becomes available for reuse\\.$', - }, - { type: 'string', const: DESTROY_IRREVERSIBLE_CONSEQUENCE }, - ], - items: false, + items: { type: 'string', minLength: 1, maxLength: 200 }, }, confirmToken: { type: 'string', diff --git a/src/server/services/keycloak/mcpOauthClients.test.ts b/src/server/services/keycloak/mcpOauthClients.test.ts new file mode 100644 index 0000000..ee03a5e --- /dev/null +++ b/src/server/services/keycloak/mcpOauthClients.test.ts @@ -0,0 +1,215 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppError } from 'server/lib/appError'; +import McpOauthClientService, { type McpOauthClientServiceDependencies } from './mcpOauthClients'; + +type Client = Record; + +function fakeService(initial: Client[] = []) { + let clients = structuredClone(initial); + const recordAudit = jest.fn(async () => undefined); + const client = { + get: jest.fn(async (path: string) => { + const url = new URL(path, 'https://keycloak.invalid'); + const requestedId = url.searchParams.get('clientId') ?? ''; + const search = url.searchParams.get('search') === 'true'; + return structuredClone( + clients.filter((candidate) => + search ? candidate.clientId?.includes(requestedId) : candidate.clientId === requestedId + ) + ); + }), + post: jest.fn(async (_path: string, body: Client) => { + clients.push({ id: `internal-${clients.length + 1}`, ...structuredClone(body) }); + }), + delete: jest.fn(async (path: string) => { + const id = decodeURIComponent(path.split('/').at(-1) ?? ''); + clients = clients.filter((candidate) => candidate.id !== id); + }), + }; + const dependencies: McpOauthClientServiceDependencies = { + client, + createClientId: () => 'lifecycle-mcp-11111111-1111-4111-8111-111111111111', + now: () => new Date('2026-08-01T20:00:00.000Z'), + recordAudit, + }; + return { service: new McpOauthClientService(dependencies), client, recordAudit, clients: () => clients }; +} + +function managedClient(overrides: Client = {}): Client { + return { + id: 'internal-1', + clientId: 'lifecycle-mcp-existing', + name: 'Desktop tool', + description: 'Lifecycle MCP OAuth client. Managed by Lifecycle.', + enabled: true, + protocol: 'openid-connect', + publicClient: true, + standardFlowEnabled: true, + implicitFlowEnabled: false, + directAccessGrantsEnabled: false, + serviceAccountsEnabled: false, + fullScopeAllowed: false, + consentRequired: true, + redirectUris: ['http://127.0.0.1:8123/callback'], + defaultClientScopes: ['basic'], + optionalClientScopes: ['mcp', 'offline_access'], + attributes: { + 'lifecycle.managed': 'true', + 'lifecycle.feature': 'mcp', + 'lifecycle.created-at': '2026-07-31T20:00:00.000Z', + 'lifecycle.created-by': 'admin-user', + 'pkce.code.challenge.method': 'S256', + }, + ...overrides, + }; +} + +it('creates a fixed public PKCE client and reads it back exactly', async () => { + const fake = fakeService(); + const result = await fake.service.create( + { + name: ' Desktop tool ', + redirectUris: ['http://127.0.0.1:8123/callback', 'com.example.desktop:/oauth/callback'], + }, + 'admin-user', + 'request-1' + ); + + expect(result).toEqual({ + clientId: 'lifecycle-mcp-11111111-1111-4111-8111-111111111111', + name: 'Desktop tool', + redirectUris: ['http://127.0.0.1:8123/callback', 'com.example.desktop:/oauth/callback'], + createdAt: '2026-08-01T20:00:00.000Z', + }); + expect(fake.client.post).toHaveBeenCalledWith( + '/clients', + expect.objectContaining({ + publicClient: true, + standardFlowEnabled: true, + implicitFlowEnabled: false, + directAccessGrantsEnabled: false, + serviceAccountsEnabled: false, + fullScopeAllowed: false, + consentRequired: true, + defaultClientScopes: ['basic'], + optionalClientScopes: ['mcp', 'offline_access'], + attributes: expect.objectContaining({ + 'lifecycle.managed': 'true', + 'lifecycle.feature': 'mcp', + 'pkce.code.challenge.method': 'S256', + }), + }) + ); + expect(fake.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'mcp.oauth_client_created', + principalId: result.clientId, + actorId: 'admin-user', + requestId: 'request-1', + }) + ); +}); + +it('lists only Lifecycle-managed MCP clients', async () => { + const fake = fakeService([ + managedClient(), + managedClient({ + id: 'unmanaged', + clientId: 'lifecycle-mcp-unmanaged', + attributes: { 'lifecycle.managed': 'false', 'lifecycle.feature': 'mcp' }, + }), + managedClient({ id: 'other', clientId: 'other-client' }), + ]); + + await expect(fake.service.list()).resolves.toEqual([ + { + clientId: 'lifecycle-mcp-existing', + name: 'Desktop tool', + redirectUris: ['http://127.0.0.1:8123/callback'], + createdAt: '2026-07-31T20:00:00.000Z', + }, + ]); +}); + +it.each([ + [{ name: 'Client', redirectUris: ['http://example.com/callback'] }, 'invalid_mcp_oauth_client_redirect'], + [{ name: 'Client', redirectUris: ['https://example.com/callback#fragment'] }, 'invalid_mcp_oauth_client_redirect'], + [{ name: 'Client', redirectUris: ['https://example.com/*'] }, 'invalid_mcp_oauth_client_redirect'], + [{ name: 'Client', redirectUris: ['https://example.com/callback'], scopes: ['admin'] }, 'invalid_mcp_oauth_client'], +])('rejects unsafe or expandable input %#', async (input, code) => { + const fake = fakeService(); + await expect(fake.service.create(input, 'admin-user', null)).rejects.toMatchObject({ + httpStatus: 400, + code, + }); + expect(fake.client.post).not.toHaveBeenCalled(); +}); + +it('deletes only a marked Lifecycle MCP client and audits the action', async () => { + const fake = fakeService([managedClient()]); + await fake.service.delete('lifecycle-mcp-existing', 'admin-user', 'request-2'); + expect(fake.clients()).toEqual([]); + expect(fake.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'mcp.oauth_client_deleted', + principalId: 'lifecycle-mcp-existing', + actorId: 'admin-user', + }) + ); +}); + +it('refuses to delete an unmarked Keycloak client', async () => { + const fake = fakeService([ + managedClient({ attributes: { 'lifecycle.managed': 'false', 'lifecycle.feature': 'mcp' } }), + ]); + await expect(fake.service.delete('lifecycle-mcp-existing', 'admin-user', null)).rejects.toMatchObject({ + httpStatus: 404, + code: 'mcp_oauth_client_not_found', + }); + expect(fake.client.delete).not.toHaveBeenCalled(); +}); + +it('removes a newly created client when Keycloak readback is weaker than requested', async () => { + const fake = fakeService(); + fake.client.post.mockImplementationOnce(async (_path: string, body: Client) => { + fake.clients().push({ id: 'weak-client', ...structuredClone(body), consentRequired: false }); + }); + await expect( + fake.service.create({ name: 'Desktop tool', redirectUris: ['http://localhost:8123/callback'] }, 'admin-user', null) + ).rejects.toMatchObject({ + httpStatus: 503, + code: 'mcp_keycloak_invalid_state', + }); + expect(fake.client.delete).toHaveBeenCalledWith('/clients/weak-client'); + expect(fake.clients()).toEqual([]); +}); + +it('removes a newly created client when the basic subject scope is missing', async () => { + const fake = fakeService(); + fake.client.post.mockImplementationOnce(async (_path: string, body: Client) => { + fake.clients().push({ id: 'missing-subject-client', ...structuredClone(body), defaultClientScopes: [] }); + }); + await expect( + fake.service.create({ name: 'Desktop tool', redirectUris: ['http://localhost:8123/callback'] }, 'admin-user', null) + ).rejects.toMatchObject({ + httpStatus: 503, + code: 'mcp_keycloak_invalid_state', + }); + expect(fake.client.delete).toHaveBeenCalledWith('/clients/missing-subject-client'); + expect(fake.clients()).toEqual([]); +}); diff --git a/src/server/services/keycloak/mcpOauthClients.ts b/src/server/services/keycloak/mcpOauthClients.ts new file mode 100644 index 0000000..8bf4c64 --- /dev/null +++ b/src/server/services/keycloak/mcpOauthClients.ts @@ -0,0 +1,417 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { AppError, BadRequestError, ConflictError, NotFoundError } from 'server/lib/appError'; +import { recordAuthAuditEvent } from '../authAudit'; +import { KeycloakAdminClient, KeycloakAdminError } from './adminClient'; +import { mcpManagementClientOptions } from './mcpProvisioning'; + +const CLIENT_ID_PREFIX = 'lifecycle-mcp-'; +const CLIENT_DESCRIPTION = 'Lifecycle MCP OAuth client. Managed by Lifecycle.'; +const MAX_CLIENTS = 100; +const MAX_NAME_LENGTH = 80; +const MAX_REDIRECT_URIS = 10; +const MAX_REDIRECT_URI_LENGTH = 2048; +const MANAGED_ATTRIBUTE = 'lifecycle.managed'; +const FEATURE_ATTRIBUTE = 'lifecycle.feature'; +const CREATED_AT_ATTRIBUTE = 'lifecycle.created-at'; +const CREATED_BY_ATTRIBUTE = 'lifecycle.created-by'; +const PKCE_ATTRIBUTE = 'pkce.code.challenge.method'; + +interface KeycloakClientRepresentation { + id?: string; + clientId?: string; + name?: string; + description?: string; + enabled?: boolean; + protocol?: string; + publicClient?: boolean; + standardFlowEnabled?: boolean; + implicitFlowEnabled?: boolean; + directAccessGrantsEnabled?: boolean; + serviceAccountsEnabled?: boolean; + fullScopeAllowed?: boolean; + consentRequired?: boolean; + redirectUris?: string[]; + webOrigins?: string[]; + attributes?: Record; + defaultClientScopes?: string[]; + optionalClientScopes?: string[]; +} + +interface KeycloakClientPort { + get(path: string): Promise; + post(path: string, body: unknown): Promise; + delete(path: string, body?: unknown): Promise; +} + +export interface LifecycleMcpOauthClient { + clientId: string; + name: string; + redirectUris: string[]; + createdAt: string | null; +} + +export interface CreateLifecycleMcpOauthClient { + name: string; + redirectUris: string[]; +} + +export interface McpOauthClientServiceDependencies { + client: KeycloakClientPort; + createClientId: () => string; + now: () => Date; + recordAudit: typeof recordAuthAuditEvent; +} + +function defaultDependencies(): McpOauthClientServiceDependencies { + const options = mcpManagementClientOptions(process.env); + if (!options) { + throw new AppError({ + httpStatus: 503, + code: 'mcp_keycloak_not_configured', + message: 'Lifecycle MCP sign-in setup is incomplete.', + }); + } + return { + client: new KeycloakAdminClient(options), + createClientId: () => `${CLIENT_ID_PREFIX}${randomUUID()}`, + now: () => new Date(), + recordAudit: recordAuthAuditEvent, + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function exactCreateInput(value: unknown): CreateLifecycleMcpOauthClient { + if (!isRecord(value) || Object.keys(value).some((key) => !['name', 'redirectUris'].includes(key))) { + throw new BadRequestError( + 'MCP OAuth client configuration must contain only name and redirectUris.', + 'invalid_mcp_oauth_client' + ); + } + const name = typeof value.name === 'string' ? value.name.trim() : ''; + if (!name || name.length > MAX_NAME_LENGTH) { + throw new BadRequestError( + `Client name must be between 1 and ${MAX_NAME_LENGTH} characters.`, + 'invalid_mcp_oauth_client_name' + ); + } + if ( + !Array.isArray(value.redirectUris) || + value.redirectUris.length < 1 || + value.redirectUris.length > MAX_REDIRECT_URIS + ) { + throw new BadRequestError( + `Provide between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`, + 'invalid_mcp_oauth_client_redirects' + ); + } + const redirectUris = value.redirectUris.map((candidate) => validateRedirectUri(candidate)); + if (new Set(redirectUris).size !== redirectUris.length) { + throw new BadRequestError('Redirect URIs must be unique.', 'invalid_mcp_oauth_client_redirects'); + } + return { name, redirectUris }; +} + +function validateRedirectUri(value: unknown): string { + if ( + typeof value !== 'string' || + value.length < 1 || + value.length > MAX_REDIRECT_URI_LENGTH || + value !== value.trim() + ) { + throw new BadRequestError('Each redirect URI must be a valid absolute URI.', 'invalid_mcp_oauth_client_redirect'); + } + let uri: URL; + try { + uri = new URL(value); + } catch { + throw new BadRequestError('Each redirect URI must be a valid absolute URI.', 'invalid_mcp_oauth_client_redirect'); + } + if (uri.username || uri.password || uri.hash || value.includes('*')) { + throw new BadRequestError( + 'Redirect URIs cannot contain credentials, fragments, or wildcards.', + 'invalid_mcp_oauth_client_redirect' + ); + } + if (uri.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(uri.hostname.toLowerCase())) { + throw new BadRequestError( + 'HTTP redirect URIs are allowed only for localhost or loopback addresses.', + 'invalid_mcp_oauth_client_redirect' + ); + } + if (['data:', 'file:', 'javascript:'].includes(uri.protocol)) { + throw new BadRequestError('This redirect URI scheme is not allowed.', 'invalid_mcp_oauth_client_redirect'); + } + if (!['http:', 'https:'].includes(uri.protocol) && !uri.hostname && (!uri.pathname || uri.pathname === '/')) { + throw new BadRequestError( + 'Private-scheme redirect URIs must include an application host and callback path.', + 'invalid_mcp_oauth_client_redirect' + ); + } + return value; +} + +function isManagedClient(client: KeycloakClientRepresentation): boolean { + return ( + client.clientId?.startsWith(CLIENT_ID_PREFIX) === true && + client.attributes?.[MANAGED_ATTRIBUTE] === 'true' && + client.attributes?.[FEATURE_ATTRIBUTE] === 'mcp' + ); +} + +function publicClientRepresentation( + input: CreateLifecycleMcpOauthClient, + clientId: string, + actorId: string, + createdAt: string +): KeycloakClientRepresentation { + return { + clientId, + name: input.name, + description: CLIENT_DESCRIPTION, + enabled: true, + protocol: 'openid-connect', + publicClient: true, + standardFlowEnabled: true, + implicitFlowEnabled: false, + directAccessGrantsEnabled: false, + serviceAccountsEnabled: false, + fullScopeAllowed: false, + consentRequired: true, + redirectUris: input.redirectUris, + webOrigins: [], + attributes: { + [MANAGED_ATTRIBUTE]: 'true', + [FEATURE_ATTRIBUTE]: 'mcp', + [CREATED_AT_ATTRIBUTE]: createdAt, + [CREATED_BY_ATTRIBUTE]: actorId, + [PKCE_ATTRIBUTE]: 'S256', + }, + defaultClientScopes: ['basic'], + optionalClientScopes: ['mcp', 'offline_access'], + }; +} + +function exposedClient(client: KeycloakClientRepresentation): LifecycleMcpOauthClient | null { + if (!isManagedClient(client) || !client.clientId || !client.name || !Array.isArray(client.redirectUris)) return null; + return { + clientId: client.clientId, + name: client.name, + redirectUris: client.redirectUris.filter((uri): uri is string => typeof uri === 'string'), + createdAt: client.attributes?.[CREATED_AT_ATTRIBUTE] ?? null, + }; +} + +function hasExpectedSecurityState( + client: KeycloakClientRepresentation, + desired: KeycloakClientRepresentation +): boolean { + return ( + isManagedClient(client) && + client.clientId === desired.clientId && + client.name === desired.name && + client.description === desired.description && + client.enabled === true && + client.protocol === 'openid-connect' && + client.publicClient === true && + client.standardFlowEnabled === true && + client.implicitFlowEnabled === false && + client.directAccessGrantsEnabled === false && + client.serviceAccountsEnabled === false && + client.fullScopeAllowed === false && + client.consentRequired === true && + client.attributes?.[PKCE_ATTRIBUTE] === 'S256' && + Array.isArray(client.redirectUris) && + client.redirectUris.length === desired.redirectUris?.length && + desired.redirectUris?.every((uri) => client.redirectUris?.includes(uri)) === true && + client.defaultClientScopes?.includes('basic') === true && + client.optionalClientScopes?.includes('mcp') === true && + client.optionalClientScopes?.includes('offline_access') === true + ); +} + +function mappedKeycloakError(error: KeycloakAdminError): AppError { + if (error.kind === 'bad_request') { + return new BadRequestError( + 'This client could not be saved. Check the name and redirect URIs, then try again.', + 'invalid_mcp_oauth_client', + { providerStatus: error.status } + ); + } + if (error.kind === 'conflict') { + return new ConflictError( + 'The MCP OAuth client conflicts with existing sign-in configuration.', + 'mcp_oauth_client_conflict' + ); + } + return new AppError({ + httpStatus: 503, + code: 'mcp_keycloak_unavailable', + message: 'Lifecycle could not update MCP sign-in clients.', + retryable: error.kind === 'rate_limited' || error.kind === 'unavailable', + cause: error, + }); +} + +export default class McpOauthClientService { + private static instance: McpOauthClientService; + + static getInstance(): McpOauthClientService { + if (!this.instance) this.instance = new McpOauthClientService(); + return this.instance; + } + + constructor(private readonly dependencies: McpOauthClientServiceDependencies = defaultDependencies()) {} + + async list(): Promise { + try { + const clients = await this.dependencies.client.get( + `/clients?clientId=${encodeURIComponent( + CLIENT_ID_PREFIX + )}&search=true&briefRepresentation=false&first=0&max=${MAX_CLIENTS}` + ); + if (!Array.isArray(clients)) { + throw new AppError({ + httpStatus: 503, + code: 'mcp_keycloak_invalid_state', + message: 'Lifecycle received invalid MCP sign-in client data.', + }); + } + return clients + .map(exposedClient) + .filter((client): client is LifecycleMcpOauthClient => client !== null) + .sort( + (left, right) => + String(right.createdAt).localeCompare(String(left.createdAt)) || left.name.localeCompare(right.name) + ); + } catch (error) { + if (error instanceof AppError) throw error; + if (error instanceof KeycloakAdminError) throw mappedKeycloakError(error); + throw error; + } + } + + async create(value: unknown, actorId: string, requestId: string | null): Promise { + const input = exactCreateInput(value); + try { + if ((await this.list()).length >= MAX_CLIENTS) { + throw new ConflictError( + `Lifecycle supports up to ${MAX_CLIENTS} pre-registered MCP clients.`, + 'mcp_oauth_client_limit' + ); + } + const clientId = this.dependencies.createClientId(); + const createdAt = this.dependencies.now().toISOString(); + const desired = publicClientRepresentation(input, clientId, actorId, createdAt); + await this.dependencies.client.post('/clients', desired); + const created = await this.findExact(clientId); + if (!created?.id || !hasExpectedSecurityState(created, desired)) { + if (created?.id) { + await this.dependencies.client.delete(`/clients/${encodeURIComponent(created.id)}`).catch(() => undefined); + } + throw new AppError({ + httpStatus: 503, + code: 'mcp_keycloak_invalid_state', + message: 'Lifecycle could not verify the new MCP sign-in client.', + }); + } + const result = exposedClient(created); + if (!result) { + throw new AppError({ + httpStatus: 503, + code: 'mcp_keycloak_invalid_state', + message: 'Lifecycle could not verify the new MCP sign-in client.', + }); + } + await this.dependencies.recordAudit({ + event: 'mcp.oauth_client_created', + principalKind: 'oauth_client', + principalId: clientId, + actorId, + requestId, + route: 'POST /api/v2/config/mcp/oauth-clients', + outcome: 'created', + meta: { name: result.name, redirectUris: result.redirectUris }, + }); + return result; + } catch (error) { + if (error instanceof AppError) throw error; + if (error instanceof KeycloakAdminError) throw mappedKeycloakError(error); + throw error; + } + } + + async delete(clientId: string, actorId: string, requestId: string | null): Promise { + if (!clientId.startsWith(CLIENT_ID_PREFIX) || clientId.length > 128) { + throw new NotFoundError('MCP OAuth client not found.', 'mcp_oauth_client_not_found'); + } + try { + const client = await this.findExact(clientId); + const exposed = client ? exposedClient(client) : null; + if (!client?.id || !exposed) { + throw new NotFoundError('MCP OAuth client not found.', 'mcp_oauth_client_not_found'); + } + await this.dependencies.client.delete(`/clients/${encodeURIComponent(client.id)}`); + await this.dependencies.recordAudit({ + event: 'mcp.oauth_client_deleted', + principalKind: 'oauth_client', + principalId: clientId, + actorId, + requestId, + route: 'DELETE /api/v2/config/mcp/oauth-clients/{clientId}', + outcome: 'deleted', + meta: { name: exposed.name, redirectUris: exposed.redirectUris }, + }); + } catch (error) { + if (error instanceof AppError) throw error; + if (error instanceof KeycloakAdminError) throw mappedKeycloakError(error); + throw error; + } + } + + private async findExact(clientId: string): Promise { + const clients = await this.dependencies.client.get( + `/clients?clientId=${encodeURIComponent(clientId)}&search=false&briefRepresentation=false&first=0&max=2` + ); + if (!Array.isArray(clients)) { + throw new AppError({ + httpStatus: 503, + code: 'mcp_keycloak_invalid_state', + message: 'Lifecycle received invalid MCP sign-in client data.', + }); + } + const exact = clients.filter((candidate) => candidate.clientId === clientId); + if (exact.length > 1) { + throw new ConflictError( + 'More than one sign-in client uses this client ID. Remove the duplicate, then try again.', + 'mcp_oauth_client_conflict' + ); + } + return exact[0] ?? null; + } +} + +export const mcpOauthClientLimits = { + maxClients: MAX_CLIENTS, + maxNameLength: MAX_NAME_LENGTH, + maxRedirectUris: MAX_REDIRECT_URIS, + maxRedirectUriLength: MAX_REDIRECT_URI_LENGTH, +} as const; diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index 99419a0..34bad7a 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -234,6 +234,10 @@ describe('OpenAPI Lifecycle MCP admin contract', () => { 'LifecycleMcpCapability', 'LifecycleMcpSettings', 'LifecycleMcpSettingsSuccessResponse', + 'CreateLifecycleMcpOauthClient', + 'LifecycleMcpOauthClient', + 'ListLifecycleMcpOauthClientsSuccessResponse', + 'LifecycleMcpOauthClientSuccessResponse', ]; it('defines the small, strict MCP settings and status family', () => { @@ -273,6 +277,31 @@ describe('OpenAPI Lifecycle MCP admin contract', () => { expect.objectContaining({ '400': expect.anything(), '409': expect.anything(), '503': expect.anything() }) ); }); + + it('defines bounded admin-only OAuth client management', () => { + expect(schemas.CreateLifecycleMcpOauthClient).toEqual( + expect.objectContaining({ + required: ['name', 'redirectUris'], + additionalProperties: false, + }) + ); + expect(schemas.CreateLifecycleMcpOauthClient.properties.redirectUris).toEqual( + expect.objectContaining({ minItems: 1, maxItems: 10, uniqueItems: true }) + ); + expect( + getOperation('/api/v2/config/mcp/oauth-clients', 'get').responses['200'].content['application/json'].schema + ).toEqual({ + $ref: '#/components/schemas/ListLifecycleMcpOauthClientsSuccessResponse', + }); + expect( + getOperation('/api/v2/config/mcp/oauth-clients', 'post').responses['201'].content['application/json'].schema + ).toEqual({ + $ref: '#/components/schemas/LifecycleMcpOauthClientSuccessResponse', + }); + expect(getOperation('/api/v2/config/mcp/oauth-clients/{clientId}', 'delete').responses['204']).toEqual( + expect.objectContaining({ description: expect.any(String) }) + ); + }); }); describe('OpenAPI v2 agent session contract', () => { diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index d122b83..4060cd4 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -524,6 +524,68 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + CreateLifecycleMcpOauthClient: { + type: 'object', + description: 'A public OAuth client to pre-register for Lifecycle MCP.', + properties: { + name: { type: 'string', minLength: 1, maxLength: 80 }, + redirectUris: { + type: 'array', + minItems: 1, + maxItems: 10, + uniqueItems: true, + items: { type: 'string', minLength: 1, maxLength: 2048 }, + }, + }, + required: ['name', 'redirectUris'], + additionalProperties: false, + }, + + LifecycleMcpOauthClient: { + type: 'object', + description: 'A public OAuth client created and managed by Lifecycle for MCP sign-in.', + properties: { + clientId: { type: 'string', pattern: '^lifecycle-mcp-[a-zA-Z0-9-]+$' }, + name: { type: 'string' }, + redirectUris: { + type: 'array', + items: { type: 'string' }, + }, + createdAt: { type: 'string', format: 'date-time', nullable: true }, + }, + required: ['clientId', 'name', 'redirectUris', 'createdAt'], + additionalProperties: false, + }, + + ListLifecycleMcpOauthClientsSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/LifecycleMcpOauthClient' }, + }, + }, + required: ['data'], + }, + ], + }, + + LifecycleMcpOauthClientSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { $ref: '#/components/schemas/LifecycleMcpOauthClient' }, + }, + required: ['data'], + }, + ], + }, + EnvironmentTrigger: { type: 'string', enum: ['api', 'github_pr'],