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
3 changes: 3 additions & 0 deletions common/api/registry-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
PkgApiV1ListServersResponse as BaseV1ListServersResponse,
PkgApiV1CreateRequest as BaseV1CreateRequest,
PkgApiV1UpdateRequest as BaseV1UpdateRequest,
GithubComStacklokToolhivePkgSecretsSecretParameter as SecretsSecretParameter,
} from './generated/types.gen'

export type PermissionsInboundNetworkPermissions = {
Expand Down Expand Up @@ -85,8 +86,10 @@ export type RegistryProvenance = {

export type RegistryOAuthConfig = {
authorize_url?: string
bearer_token?: SecretsSecretParameter | string
callback_port?: number
client_id?: string
client_secret?: SecretsSecretParameter | string
issuer?: string
oauth_params?: { [key: string]: string }
resource?: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,56 @@ describe('getFormSchemaRemoteMcp', () => {
expect(errors).toContain('Token URL is required for OAuth2')
expect(errors).toContain('Client ID is required for OAuth 2.0')
})

it('passes when client_secret references an existing secret-store key', () => {
const input = {
...oauth2Input,
oauth_config: {
...oauth2Input.oauth_config,
client_secret: {
name: 'CLIENT_SECRET',
value: {
secret: 'CLIENT_SECRET',
isFromStore: true,
},
},
},
}

const result = getFormSchemaRemoteMcp([], undefined, {
keys: [{ key: 'CLIENT_SECRET' }],
}).safeParse(input)

expect(result.success, `${result.error}`).toBe(true)
})

it('fails when client_secret references a missing secret-store key', () => {
const input = {
...oauth2Input,
oauth_config: {
...oauth2Input.oauth_config,
client_secret: {
name: 'CLIENT_SECRET',
value: {
secret: 'CLIENT_SECRET',
isFromStore: true,
},
},
},
}

const result = getFormSchemaRemoteMcp([], undefined, {
keys: [{ key: 'OTHER_SECRET' }],
}).safeParse(input)

expect(result.success).toBe(false)
expect(result.error?.issues).toContainEqual(
expect.objectContaining({
message: 'Secret "CLIENT_SECRET" was not found in the secrets store',
path: ['oauth_config', 'client_secret'],
})
)
})
})

describe('auth_type: "oidc"', () => {
Expand Down
73 changes: 66 additions & 7 deletions renderer/src/common/lib/workloads/remote/form-schema-remote-mcp.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import z from 'zod/v4'
import type { GithubComStacklokToolhivePkgCoreWorkload as CoreWorkload } from '@common/api/generated/types.gen'
import type {
GithubComStacklokToolhivePkgCoreWorkload as CoreWorkload,
PkgApiV1ListSecretsResponse as V1ListSecretsResponse,
} from '@common/api/generated/types.gen'
import {
createRemoteMcpBaseSchema,
REMOTE_MCP_AUTH_TYPES,
} from '@/common/lib/form-schema-mcp'

type SecretFormValue = {
name: string
value: { secret: string; isFromStore: boolean }
}

const OAUTH_VALIDATION_RULES = {
oauth2: [
{
Expand Down Expand Up @@ -47,20 +55,27 @@ const OAUTH_VALIDATION_RULES = {
const validateOAuthField = (value: string | undefined): boolean =>
Boolean(value && value.trim() !== '')

const validateSecretField = (
value:
| { name: string; value: { secret: string; isFromStore: boolean } }
| undefined
): boolean =>
const validateSecretField = (value: SecretFormValue | undefined): boolean =>
Boolean(value && value.value.secret && value.value.secret.trim() !== '')

const getAvailableSecretKeys = (availableSecrets?: V1ListSecretsResponse) => {
if (!availableSecrets) return undefined
return new Set(
availableSecrets.keys
?.map((secret) => secret.key)
.filter((key): key is string => Boolean(key)) ?? []
)
}

export const getFormSchemaRemoteMcp = (
workloads: CoreWorkload[],
editingServerName?: string
editingServerName?: string,
availableSecrets?: V1ListSecretsResponse
) => {
const filteredWorkloads = editingServerName
? workloads.filter((w) => w.name !== editingServerName)
: workloads
const availableSecretKeys = getAvailableSecretKeys(availableSecrets)

return createRemoteMcpBaseSchema(filteredWorkloads).superRefine(
(data, ctx) => {
Expand Down Expand Up @@ -110,6 +125,50 @@ export const getFormSchemaRemoteMcp = (
})
}
})

const validateStoreReference = (
value: SecretFormValue | undefined,
path: (string | number)[]
) => {
if (
!availableSecretKeys ||
!value?.value.isFromStore ||
!value.value.secret.trim()
) {
return
}

const secretName = value.value.secret
if (!availableSecretKeys.has(secretName)) {
ctx.addIssue({
code: 'custom',
message: `Secret "${secretName}" was not found in the secrets store`,
path,
})
}
}

validateStoreReference(oauth_config.client_secret, [
'oauth_config',
'client_secret',
])
validateStoreReference(oauth_config.bearer_token, [
'oauth_config',
'bearer_token',
])

data.secrets.forEach((secret, index) => {
validateStoreReference(secret, ['secrets', index, 'value'])
})

data.header_forward?.add_headers_from_secret?.forEach((header, index) => {
validateStoreReference(header.secret, [
'header_forward',
'add_headers_from_secret',
index,
'secret',
])
})
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ export function DialogFormRemoteMcp({

const form = useForm<FormSchemaRemoteMcp>({
resolver: zodV4Resolver(
getFormSchemaRemoteMcp(workloads, serverToEdit || undefined)
getFormSchemaRemoteMcp(
workloads,
serverToEdit || undefined,
availableSecrets
)
),
defaultValues: { ...DEFAULT_FORM_VALUES, group: groupName },
reValidateMode: 'onChange',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,61 @@ describe('DialogFormRemoteRegistryMcp', () => {
})
})

it('blocks install when registry OAuth client_secret is missing from the secret store', async () => {
const user = userEvent.setup({ delay: null })
const mockInstallServerMutation = vi.fn()

mockUseRunRemoteServer.mockReturnValue({
installServerMutation: mockInstallServerMutation,
isErrorSecrets: false,
isPendingSecrets: false,
})

const serverWithMissingSecret: RegistryRemoteServerMetadata = {
...mockServer,
oauth_config: {
authorize_url: 'https://api.example.com/authorize',
token_url: 'https://api.example.com/token',
client_id: 'client_id',
client_secret: {
name: 'CLIENT_SECRET',
target: 'CLIENT_SECRET',
},
},
}

renderWithProviders(
<Wrapper>
<DialogFormRemoteRegistryMcp
server={serverWithMissingSecret}
isOpen
closeDialog={vi.fn()}
actionsSubmitLabel="Install server"
/>
</Wrapper>
)

await waitFor(() => {
expect(screen.getAllByDisplayValue('CLIENT_SECRET')).toHaveLength(2)
})

const submitButton = screen.getByRole('button', { name: 'Install server' })
await waitFor(() => {
expect(submitButton).toBeEnabled()
})

await user.click(submitButton)

await waitFor(() => {
expect(mockInstallServerMutation).not.toHaveBeenCalled()
expect(
screen.getByText(
'Secret "CLIENT_SECRET" was not found in the secrets store'
)
).toBeInTheDocument()
})
})

it('displays OAuth2 fields when OAuth2 is selected', async () => {
const user = userEvent.setup({ delay: null })
renderWithProviders(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import log from 'electron-log/renderer'
import type { RegistryRemoteServerMetadata } from '@common/api/registry-types'
import { zodV4Resolver } from '@/common/lib/zod-v4-resolver'
import { getApiV1BetaWorkloadsOptions } from '@common/api/generated/@tanstack/react-query.gen'
import {
getApiV1BetaSecretsDefaultKeysOptions,
getApiV1BetaWorkloadsOptions,
} from '@common/api/generated/@tanstack/react-query.gen'
import { LoadingStateAlert } from '../../../common/components/secrets/loading-state-alert'
import { AlertErrorFormSubmission } from '@/common/components/workloads/alert-error-form-submission'
import { DialogWorkloadFormWrapper } from '@/common/components/workloads/dialog-workload-form-wrapper'
Expand Down Expand Up @@ -48,6 +51,39 @@ const DEFAULT_FORM_VALUES: FormSchemaRemoteMcp = {
group: 'default',
}

function getMissingSecretStoreReference(
data: FormSchemaRemoteMcp,
availableSecrets?: { keys?: Array<{ key?: string }> }
) {
if (!availableSecrets) return undefined

const availableSecretKeys = new Set(
availableSecrets.keys
?.map((secret) => secret.key)
.filter((key): key is string => Boolean(key)) ?? []
)
const authSecret =
data.auth_type === REMOTE_MCP_AUTH_TYPES.BearerToken
? data.oauth_config.bearer_token
: data.oauth_config.client_secret

if (
authSecret?.value.isFromStore &&
authSecret.value.secret &&
!availableSecretKeys.has(authSecret.value.secret)
) {
return {
field:
data.auth_type === REMOTE_MCP_AUTH_TYPES.BearerToken
? 'oauth_config.bearer_token'
: 'oauth_config.client_secret',
secret: authSecret.value.secret,
} as const
}

return undefined
}

interface FormRunFromRegistryProps {
server: RegistryRemoteServerMetadata | null
isOpen: boolean
Expand Down Expand Up @@ -81,6 +117,7 @@ export function DialogFormRemoteRegistryMcp({
secretsCount: number
} | null>(null)
const { checkServerStatus } = useCheckServerStatus()
const queryClient = useQueryClient()
const handleSecrets = (completedCount: number, secretsCount: number) => {
setLoadingSecrets((prev) => ({
...prev,
Expand All @@ -104,14 +141,20 @@ export function DialogFormRemoteRegistryMcp({
...getApiV1BetaWorkloadsOptions({ query: { all: true } }),
retry: false,
})
const { data: availableSecrets } = useQuery({
...getApiV1BetaSecretsDefaultKeysOptions(),
retry: false,
})

const workloads = data?.workloads ?? []

const { data: groupsData } = useGroups()
const groups = groupsData?.groups ?? []

const form = useForm<FormSchemaRemoteMcp>({
resolver: zodV4Resolver(getFormSchemaRemoteMcp(workloads)),
resolver: zodV4Resolver(
getFormSchemaRemoteMcp(workloads, undefined, availableSecrets)
),
defaultValues: DEFAULT_FORM_VALUES,
reValidateMode: 'onChange',
mode: 'onChange',
Expand All @@ -125,9 +168,25 @@ export function DialogFormRemoteRegistryMcp({
const onSubmitForm = async (data: FormSchemaRemoteMcp) => {
if (!server) return

setIsSubmitting(true)
if (error) setError(null)

const secretsForValidation =
availableSecrets ??
(await queryClient.fetchQuery(getApiV1BetaSecretsDefaultKeysOptions()))
const missingSecret = getMissingSecretStoreReference(
data,
secretsForValidation
)
if (missingSecret) {
form.setError(missingSecret.field, {
type: 'manual',
message: `Secret "${missingSecret.secret}" was not found in the secrets store`,
})
return
}

setIsSubmitting(true)

const submissionData = hardcodedGroup
? { ...data, group: hardcodedGroup }
: data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,28 @@ describe('convertCreateRequestToFormData', () => {
})
expect(result.name).toBe('my-server')
})

it('preserves registry OAuth client_secret as a store reference', () => {
const result = convertCreateRequestToFormData({
...baseRequest,
oauth_config: {
authorize_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
client_id: 'client-id',
client_secret: {
name: 'CLIENT_SECRET',
target: 'CLIENT_SECRET',
},
},
})

expect(result.auth_type).toBe('oauth2')
expect(result.oauth_config.client_secret).toEqual({
name: 'CLIENT_SECRET',
value: {
secret: 'CLIENT_SECRET',
isFromStore: true,
},
})
})
})
Loading