Skip to content

Commit 01b1719

Browse files
authored
refactor: mcp secrets and polling logic into a dedicated hooks (#868)
* refactor: move to util secrets functions * refactor: create common useCheckServerStatus replacing the polling logic in all custom hooks * refactor: handle secrets logic in a custom hook * fix: comments after review * fix: remote mcp client_secret * fix: remote fields with oauth * leftover * refactor: form remote auth fields
1 parent b675fd4 commit 01b1719

24 files changed

+1317
-1819
lines changed

renderer/src/common/hooks/__tests__/use-mcp-secrets.test.ts

Lines changed: 731 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useCallback, useRef } from 'react'
2+
import { useQueryClient } from '@tanstack/react-query'
3+
import { pollServerStatus } from '@/common/lib/polling'
4+
import {
5+
getApiV1BetaWorkloadsByNameStatusOptions,
6+
getApiV1BetaWorkloadsQueryKey,
7+
} from '@api/@tanstack/react-query.gen'
8+
import { toast } from 'sonner'
9+
import { Button } from '../components/ui/button'
10+
import { Link } from '@tanstack/react-router'
11+
12+
/**
13+
* Custom hook for checking server status after creation/startup.
14+
* Returns a function that polls server status and invalidates queries when ready.
15+
*/
16+
export function useCheckServerStatus() {
17+
const toastIdRef = useRef(new Date(Date.now()).toISOString())
18+
19+
const queryClient = useQueryClient()
20+
21+
const checkServerStatus = useCallback(
22+
async ({
23+
serverName,
24+
isEditing = false,
25+
}: {
26+
serverName: string
27+
isEditing?: boolean
28+
}): Promise<boolean> => {
29+
toast.loading(
30+
`${isEditing ? 'Updating' : 'Starting'} "${serverName}"...`,
31+
{
32+
duration: 30_000,
33+
id: toastIdRef.current,
34+
}
35+
)
36+
37+
const isServerReady = await pollServerStatus(
38+
() =>
39+
queryClient.fetchQuery(
40+
getApiV1BetaWorkloadsByNameStatusOptions({
41+
path: { name: serverName },
42+
})
43+
),
44+
'running'
45+
)
46+
47+
if (isServerReady) {
48+
await queryClient.invalidateQueries({
49+
queryKey: getApiV1BetaWorkloadsQueryKey({ query: { all: true } }),
50+
})
51+
52+
toast.success(
53+
`"${serverName}" ${isEditing ? 'updated' : 'started'} successfully.`,
54+
{
55+
id: toastIdRef.current,
56+
duration: 5_000, // slightly longer than default
57+
action: (
58+
<Button asChild>
59+
<Link
60+
to="/group/$groupName"
61+
params={{ groupName: 'default' }}
62+
search={{ newServerName: serverName }}
63+
onClick={() => toast.dismiss(toastIdRef.current)}
64+
viewTransition={{ types: ['slide-left'] }}
65+
className="ml-auto"
66+
>
67+
View
68+
</Link>
69+
</Button>
70+
),
71+
}
72+
)
73+
} else {
74+
toast.warning(
75+
`Server "${serverName}" was ${isEditing ? 'updated' : 'created'} but may still be starting up. Check the servers list to monitor its status.`,
76+
{
77+
id: toastIdRef.current,
78+
duration: 5_000,
79+
}
80+
)
81+
}
82+
83+
return isServerReady
84+
},
85+
[queryClient]
86+
)
87+
88+
return { checkServerStatus }
89+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { useMutation } from '@tanstack/react-query'
2+
import { postApiV1BetaSecretsDefaultKeysMutation } from '@api/@tanstack/react-query.gen'
3+
import { getApiV1BetaSecretsDefaultKeys } from '@api/sdk.gen'
4+
import type {
5+
PostApiV1BetaSecretsDefaultKeysData,
6+
SecretsSecretParameter,
7+
V1CreateSecretResponse,
8+
} from '@api/types.gen'
9+
import type { Options } from '@api/client'
10+
import { prepareSecretsWithoutNamingCollision } from '@/common/lib/secrets/prepare-secrets-without-naming-collision'
11+
import type { DefinedSecret, PreparedSecret } from '@/common/types/secrets'
12+
import type { FormSchemaLocalMcp } from '@/features/mcp-servers/lib/form-schema-local-mcp'
13+
import type { FormSchemaRemoteMcp } from '@/features/mcp-servers/lib/form-schema-remote-mcp'
14+
import type { FormSchemaRegistryMcp } from '@/features/registry-servers/lib/form-schema-registry-mcp'
15+
import type { UseMutateAsyncFunction } from '@tanstack/react-query'
16+
17+
type SaveSecretFn = UseMutateAsyncFunction<
18+
V1CreateSecretResponse,
19+
string,
20+
Options<PostApiV1BetaSecretsDefaultKeysData>,
21+
unknown
22+
>
23+
24+
/**
25+
* Takes all of the secrets from the form and saves them serially to the
26+
* secret store. Accepts a `toastId`, which it uses to provide feedback on the
27+
* progress of the operation.
28+
* // NOTE: We add a short, arbitrary delay to allow the `toast` message that
29+
* displays progress to show up-to-date progress.
30+
*/
31+
export async function saveMCPSecrets(
32+
secrets: PreparedSecret[],
33+
saveSecret: SaveSecretFn,
34+
onSecretSuccess: (completedCount: number, secretsCount: number) => void,
35+
onSecretError: (
36+
error: string,
37+
variables: Options<PostApiV1BetaSecretsDefaultKeysData>
38+
) => void
39+
): Promise<SecretsSecretParameter[]> {
40+
const secretsCount: number = secrets.length
41+
let completedCount: number = 0
42+
const createdSecrets: SecretsSecretParameter[] = []
43+
44+
for (const { secretStoreKey, target, value } of secrets) {
45+
const { key: createdKey } = await saveSecret(
46+
{
47+
body: { key: secretStoreKey, value },
48+
},
49+
{
50+
onError: (error, variables) => {
51+
onSecretError(error, variables)
52+
},
53+
onSuccess: () => {
54+
completedCount++
55+
onSecretSuccess(completedCount, secretsCount)
56+
},
57+
}
58+
)
59+
60+
if (!createdKey) {
61+
throw new Error(`Failed to create secret for key "${secretStoreKey}"`)
62+
}
63+
64+
// The arbitrary delay a UX/UI affordance to allow the user to see the progress
65+
// of the operation. This is not strictly necessary, but it helps to avoid
66+
// confusion when many secrets are being created in quick succession.
67+
// The delay is between 100 and 500ms
68+
await new Promise((resolve) =>
69+
setTimeout(
70+
resolve,
71+
process.env.NODE_ENV === 'test'
72+
? 0
73+
: Math.floor(Math.random() * 401) + 100
74+
)
75+
)
76+
createdSecrets.push({
77+
/** The name of the secret in the secret store */
78+
name: createdKey,
79+
/** The property in the MCP server's config that we are mapping the secret to */
80+
target: target,
81+
})
82+
}
83+
84+
return createdSecrets
85+
}
86+
87+
/**
88+
* A utility function to filter out secrets that are not defined.
89+
*/
90+
export function getMCPDefinedSecrets(
91+
secrets:
92+
| FormSchemaRemoteMcp['secrets']
93+
| FormSchemaLocalMcp['secrets']
94+
| FormSchemaRegistryMcp['secrets']
95+
): DefinedSecret[] {
96+
return secrets.reduce<DefinedSecret[]>((acc, { name, value }) => {
97+
if (name && value.secret) {
98+
acc.push({
99+
name,
100+
value: {
101+
secret: value.secret,
102+
isFromStore: value.isFromStore ?? false,
103+
},
104+
})
105+
}
106+
return acc
107+
}, [])
108+
}
109+
110+
/**
111+
* Groups secrets into two categories: new secrets (not from the registry) and
112+
* existing secrets (from the registry). We need this separation to know which
113+
* secrets need to be encrypted and stored before creating the server workload.
114+
*/
115+
export function groupMCPDefinedSecrets(secrets: DefinedSecret[]): {
116+
newSecrets: DefinedSecret[]
117+
existingSecrets: DefinedSecret[]
118+
} {
119+
return secrets.reduce<{
120+
newSecrets: DefinedSecret[]
121+
existingSecrets: DefinedSecret[]
122+
}>(
123+
(acc, secret) => {
124+
if (secret.value.isFromStore) {
125+
acc.existingSecrets.push(secret)
126+
} else {
127+
acc.newSecrets.push(secret)
128+
}
129+
return acc
130+
},
131+
{ newSecrets: [], existingSecrets: [] }
132+
)
133+
}
134+
135+
interface UseMCPSecretsParams {
136+
onSecretSuccess: (completedCount: number, secretsCount: number) => void
137+
onSecretError: (
138+
error: string,
139+
variables: Options<PostApiV1BetaSecretsDefaultKeysData>
140+
) => void
141+
}
142+
143+
interface MCPSecretsResult {
144+
newlyCreatedSecrets: SecretsSecretParameter[]
145+
existingSecrets: DefinedSecret[]
146+
}
147+
148+
interface UseMCPSecretsReturn {
149+
handleSecrets: (secrets: DefinedSecret[]) => Promise<MCPSecretsResult>
150+
isPendingSecrets: boolean
151+
isErrorSecrets: boolean
152+
}
153+
154+
/**
155+
* Custom hook for handling MCP secrets processing.
156+
*
157+
* This hook encapsulates the complete workflow for processing MCP secrets:
158+
* 1. Groups secrets into new and existing categories
159+
* 2. Fetches current secrets to handle naming collisions
160+
* 3. Prepares new secrets with unique names
161+
* 4. Encrypts and saves new secrets to the secret store
162+
*
163+
* @param params - Configuration object with success and error callbacks
164+
* @returns Object with handleSecrets function and loading/error states
165+
*/
166+
export function useMCPSecrets({
167+
onSecretSuccess = () => {},
168+
onSecretError = () => {},
169+
}: UseMCPSecretsParams): UseMCPSecretsReturn {
170+
const { mutateAsync: saveSecret } = useMutation({
171+
...postApiV1BetaSecretsDefaultKeysMutation(),
172+
})
173+
174+
const {
175+
mutateAsync: handleSecrets,
176+
isPending: isPendingSecrets,
177+
isError: isErrorSecrets,
178+
} = useMutation({
179+
mutationFn: async (
180+
secrets:
181+
| FormSchemaRemoteMcp['secrets']
182+
| FormSchemaLocalMcp['secrets']
183+
| FormSchemaRegistryMcp['secrets']
184+
): Promise<MCPSecretsResult> => {
185+
let newlyCreatedSecrets: SecretsSecretParameter[] = []
186+
187+
// Step 1: Group secrets into new and existing
188+
// We need to know which secrets are new (not from the registry) and which are
189+
// existing (already stored). This helps us handle the encryption and storage
190+
// of secrets correctly.
191+
const { existingSecrets, newSecrets } = groupMCPDefinedSecrets(
192+
getMCPDefinedSecrets(secrets)
193+
)
194+
195+
// Step 2: Fetch existing secrets & handle naming collisions
196+
// We need an up-to-date list of secrets so we can handle any existing keys
197+
// safely & correctly. This is done with a manual fetch call to avoid freshness issues /
198+
// side-effects from the `useQuery` hook.
199+
// In the event of a naming collision, we will append an incrementing number
200+
// to the secret name, e.g. `MY_API_TOKEN` -> `MY_API_TOKEN_2`
201+
const { data: fetchedSecrets } = await getApiV1BetaSecretsDefaultKeys({
202+
throwOnError: true,
203+
})
204+
const preparedNewSecrets = prepareSecretsWithoutNamingCollision(
205+
newSecrets,
206+
fetchedSecrets
207+
)
208+
209+
// Step 3: Encrypt secrets
210+
// If there are secrets with values, create them in the secret store first.
211+
// We need the data returned by the API to pass along with the "run workload" request.
212+
if (preparedNewSecrets.length > 0) {
213+
newlyCreatedSecrets = await saveMCPSecrets(
214+
preparedNewSecrets,
215+
saveSecret,
216+
onSecretSuccess,
217+
onSecretError
218+
)
219+
}
220+
221+
return {
222+
newlyCreatedSecrets,
223+
existingSecrets,
224+
}
225+
},
226+
})
227+
228+
return {
229+
handleSecrets,
230+
isPendingSecrets,
231+
isErrorSecrets,
232+
}
233+
}

renderer/src/common/lib/form-schema-mcp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ const createRegistrySecretsSchema = (
157157
.object({
158158
name: secretNameSchema,
159159
value: z.object({
160-
secret: z.string().optional(),
160+
secret: z.string(),
161161
isFromStore: z.boolean(),
162162
}),
163163
})
@@ -291,7 +291,7 @@ const remoteMcpOauthConfigSchema = z.object({
291291
client_secret: z.string().optional(),
292292
issuer: z.string().optional(),
293293
oauth_params: z.record(z.string(), z.string()).optional(),
294-
scopes: z.array(z.string()).optional(),
294+
scopes: z.string().optional(),
295295
skip_browser: z.boolean(),
296296
token_url: z.string().optional(),
297297
use_pkce: z.boolean(),

0 commit comments

Comments
 (0)