Skip to content

Commit f6635fb

Browse files
authored
Merge pull request #2055 from redpanda-data/av/mcp-sa-ui
Use MCP v1 api and allow specifying service account
2 parents 63b2314 + ab11992 commit f6635fb

File tree

13 files changed

+528
-121
lines changed

13 files changed

+528
-121
lines changed

frontend/src/components/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export const FEATURE_FLAGS = {
1515
enableAiAgentsInConsole: false,
1616
enableAiAgentsInspectorInConsole: false,
1717
enableAiAgentsInConsoleServerless: false,
18+
enableMcpServiceAccount: false,
1819
};

frontend/src/components/pages/agents/details/ai-agent-configuration-tab.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,9 @@ import {
4343
AIAgentUpdateSchema,
4444
UpdateAIAgentRequestSchema,
4545
} from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent_pb';
46-
import type { MCPServer } from 'protogen/redpanda/api/dataplane/v1alpha3/mcp_pb';
4746
import { useCallback, useMemo, useState } from 'react';
4847
import { useGetAIAgentQuery, useUpdateAIAgentMutation } from 'react-query/api/ai-agent';
49-
import { useListMCPServersQuery } from 'react-query/api/remote-mcp';
48+
import { type MCPServer, useListMCPServersQuery } from 'react-query/api/remote-mcp';
5049
import { useListSecretsQuery } from 'react-query/api/secret';
5150
import { useParams } from 'react-router-dom';
5251
import { toast } from 'sonner';

frontend/src/components/pages/mcp-servers/create/metadata-step.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Textarea } from 'components/redpanda-ui/components/textarea';
2222
import { Heading, Text } from 'components/redpanda-ui/components/typography';
2323
import { ResourceTierSelect } from 'components/ui/connect/resource-tier-select';
2424
import { TagsFieldList } from 'components/ui/tag/tags-field-list';
25+
import { isFeatureFlagEnabled } from 'config';
2526
import type { UseFieldArrayReturn, UseFormReturn } from 'react-hook-form';
2627

2728
import type { FormValues } from './schemas';
@@ -94,6 +95,27 @@ export const MetadataStep: React.FC<MetadataStepProps> = ({ form, tagFields, app
9495
</FormItem>
9596
)}
9697
/>
98+
99+
{isFeatureFlagEnabled('enableMcpServiceAccount') && (
100+
<div className="space-y-2">
101+
<FormLabel required>Service Account Name</FormLabel>
102+
<FormField
103+
control={form.control}
104+
name="serviceAccountName"
105+
render={({ field }) => (
106+
<FormItem>
107+
<FormControl>
108+
<Input placeholder="e.g., cluster-abc123-mcp-my-server-sa" {...field} />
109+
</FormControl>
110+
<Text className="text-sm" variant="muted">
111+
This service account will be created automatically when you create the MCP server.
112+
</Text>
113+
<FormMessage />
114+
</FormItem>
115+
)}
116+
/>
117+
</div>
118+
)}
97119
</div>
98120
</FormContainer>
99121
</div>

frontend/src/components/pages/mcp-servers/create/remote-mcp-create-page.tsx

Lines changed: 142 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,19 @@ import { defineStepper } from 'components/redpanda-ui/components/stepper';
2020
import { Heading, Text } from 'components/redpanda-ui/components/typography';
2121
import { useLintHints } from 'components/ui/lint-hint/use-lint-hints';
2222
import { useSecretDetection } from 'components/ui/secret/use-secret-detection';
23+
import {
24+
ServiceAccountSelector,
25+
type ServiceAccountSelectorRef,
26+
} from 'components/ui/service-account/service-account-selector';
2327
import { ExpandedYamlDialog } from 'components/ui/yaml/expanded-yaml-dialog';
2428
import { useYamlLabelSync } from 'components/ui/yaml/use-yaml-label-sync';
29+
import { config, isFeatureFlagEnabled } from 'config';
2530
import { ArrowLeft, FileText, Hammer, Loader2 } from 'lucide-react';
26-
import {
27-
CreateMCPServerRequestSchema,
28-
LintMCPConfigRequestSchema,
29-
} from 'protogen/redpanda/api/dataplane/v1alpha3/mcp_pb';
30-
import React, { useMemo, useState } from 'react';
31+
import { MCPServer_ServiceAccountSchema } from 'protogen/redpanda/api/dataplane/v1/mcp_pb';
32+
import React, { useEffect, useMemo, useRef, useState } from 'react';
3133
import { useFieldArray, useForm } from 'react-hook-form';
3234
import { useCreateMCPServerMutation, useLintMCPConfigMutation } from 'react-query/api/remote-mcp';
33-
import { useListSecretsQuery } from 'react-query/api/secret';
35+
import { useCreateSecretMutation, useListSecretsQuery } from 'react-query/api/secret';
3436
import { useNavigate } from 'react-router-dom';
3537
import { toast } from 'sonner';
3638
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
@@ -64,20 +66,59 @@ export const RemoteMCPCreatePage: React.FC = () => {
6466
const navigate = useNavigate();
6567
const { mutateAsync: createServer, isPending: isCreateMCPServerPending } = useCreateMCPServerMutation();
6668
const { mutateAsync: lintConfig, isPending: isLintConfigPending } = useLintMCPConfigMutation();
69+
const { mutateAsync: createSecret, isPending: isCreateSecretPending } = useCreateSecretMutation({
70+
skipInvalidation: true,
71+
});
6772

6873
// State for expanded YAML dialog
6974
const [expandedTool, setExpandedTool] = useState<{ index: number; isOpen: boolean } | null>(null);
7075

7176
// Query existing secrets
7277
const { data: secretsData } = useListSecretsQuery();
7378

79+
// Ref to ServiceAccountSelector to call createServiceAccount
80+
const serviceAccountSelectorRef = useRef<ServiceAccountSelectorRef>(null);
81+
82+
// Track the created service account info and pending state
83+
const [serviceAccountInfo, setServiceAccountInfo] = useState<{
84+
secretName: string;
85+
serviceAccountId: string;
86+
} | null>(null);
87+
const [isCreateServiceAccountPending, setIsCreateServiceAccountPending] = useState(false);
88+
7489
// Form setup
7590
const form = useForm<FormValues>({
7691
resolver: zodResolver(FormSchema),
7792
defaultValues: initialValues,
7893
mode: 'onChange',
7994
});
8095

96+
// Track the display name to auto-generate service account name
97+
const displayName = form.watch('displayName');
98+
const serviceAccountName = form.watch('serviceAccountName');
99+
100+
// Auto-generate service account name when MCP server name changes
101+
useEffect(() => {
102+
if (displayName) {
103+
const clusterType = config.isServerless ? 'serverless' : 'cluster';
104+
const sanitizedServerName = displayName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
105+
const generatedName = `${clusterType}-${config.clusterId}-mcp-${sanitizedServerName}-sa`;
106+
107+
// Only update if the field is empty or matches the previous auto-generated pattern
108+
const currentValue = form.getValues('serviceAccountName');
109+
if (!currentValue || currentValue.startsWith(`${clusterType}-${config.clusterId}-mcp-`)) {
110+
form.setValue('serviceAccountName', generatedName, { shouldValidate: false });
111+
}
112+
}
113+
}, [displayName, form]);
114+
115+
// Clear cached service account when service account name changes
116+
useEffect(() => {
117+
if (serviceAccountInfo && serviceAccountName) {
118+
setServiceAccountInfo(null);
119+
}
120+
}, [serviceAccountName, serviceAccountInfo]);
121+
81122
const {
82123
fields: tagFields,
83124
append: appendTag,
@@ -117,7 +158,11 @@ export const RemoteMCPCreatePage: React.FC = () => {
117158

118159
const handleNext = async (isOnMetadataStep: boolean, goNext: () => void) => {
119160
if (isOnMetadataStep) {
120-
const valid = await form.trigger(['displayName', 'description', 'resourcesTier', 'tags']);
161+
const fieldsToValidate: Array<keyof FormValues> = ['displayName', 'description', 'resourcesTier', 'tags'];
162+
if (isFeatureFlagEnabled('enableMcpServiceAccount')) {
163+
fieldsToValidate.push('serviceAccountName');
164+
}
165+
const valid = await form.trigger(fieldsToValidate);
121166
if (!valid) {
122167
return;
123168
}
@@ -139,11 +184,9 @@ export const RemoteMCPCreatePage: React.FC = () => {
139184
},
140185
};
141186

142-
const response = await lintConfig(
143-
create(LintMCPConfigRequestSchema, {
144-
tools: toolsMap,
145-
})
146-
);
187+
const response = await lintConfig({
188+
tools: toolsMap,
189+
});
147190

148191
// Update lint hints for this tool
149192
setLintHints((prev) => ({
@@ -152,6 +195,30 @@ export const RemoteMCPCreatePage: React.FC = () => {
152195
}));
153196
};
154197

198+
const createServiceAccountIfNeeded = async (
199+
serverName: string
200+
): Promise<{ secretName: string; serviceAccountId: string } | null> => {
201+
// If we already created one in this session, use it
202+
if (serviceAccountInfo) {
203+
return serviceAccountInfo;
204+
}
205+
206+
// Call the ServiceAccountSelector to create the service account
207+
if (!serviceAccountSelectorRef.current) {
208+
toast.error('Service account selector not initialized');
209+
return null;
210+
}
211+
212+
// The pending state is automatically tracked via onPendingChange callback
213+
const result = await serviceAccountSelectorRef.current.createServiceAccount(serverName);
214+
215+
if (result) {
216+
setServiceAccountInfo(result);
217+
}
218+
219+
return result;
220+
};
221+
155222
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complexity 56, refactor later
156223
const handleValidationError = (error: ConnectError) => {
157224
if (error.code === ConnectCode.InvalidArgument && error.details) {
@@ -235,19 +302,47 @@ export const RemoteMCPCreatePage: React.FC = () => {
235302
};
236303
}
237304

305+
const useMcpServiceAccount = isFeatureFlagEnabled('enableMcpServiceAccount');
306+
let serviceAccountConfig: ReturnType<typeof create<typeof MCPServer_ServiceAccountSchema>> | undefined;
307+
308+
// Create service account if feature flag is enabled
309+
// NOTE: Service account and secret are created before MCP server creation.
310+
// If server creation fails, these resources will not be automatically cleaned up.
311+
// This matches the AI agents implementation pattern.
312+
if (useMcpServiceAccount) {
313+
const serviceAccountResult = await createServiceAccountIfNeeded(values.displayName);
314+
if (!serviceAccountResult) {
315+
return; // Error already shown by createServiceAccountIfNeeded
316+
}
317+
318+
const { secretName, serviceAccountId } = serviceAccountResult;
319+
320+
// Add service_account_id and secret_id to tags for easy deletion
321+
tagsMap.service_account_id = serviceAccountId;
322+
tagsMap.secret_id = secretName;
323+
324+
serviceAccountConfig = create(MCPServer_ServiceAccountSchema, {
325+
clientId: `\${secrets.${secretName}.client_id}`,
326+
clientSecret: `\${secrets.${secretName}.client_secret}`,
327+
});
328+
}
329+
330+
const mcpServerPayload = {
331+
displayName: values.displayName.trim(),
332+
description: values.description?.trim() ?? '',
333+
tools: toolsMap,
334+
tags: tagsMap,
335+
resources: {
336+
cpuShares: tier?.cpu ?? '200m',
337+
memoryShares: tier?.memory ?? '800M',
338+
},
339+
...(useMcpServiceAccount && { serviceAccount: serviceAccountConfig }),
340+
};
341+
238342
await createServer(
239-
create(CreateMCPServerRequestSchema, {
240-
mcpServer: {
241-
displayName: values.displayName.trim(),
242-
description: values.description?.trim() ?? '',
243-
tools: toolsMap,
244-
tags: tagsMap,
245-
resources: {
246-
cpuShares: tier?.cpu ?? '200m',
247-
memoryShares: tier?.memory ?? '800M',
248-
},
249-
},
250-
}),
343+
{
344+
mcpServer: mcpServerPayload,
345+
},
251346
{
252347
onError: handleValidationError,
253348
onSuccess: (data) => {
@@ -329,17 +424,28 @@ export const RemoteMCPCreatePage: React.FC = () => {
329424

330425
<Stepper.Controls className={methods.isFirst ? 'flex justify-end' : 'flex justify-between'}>
331426
{!methods.isFirst && (
332-
<Button disabled={isCreateMCPServerPending} onClick={methods.prev} variant="outline">
427+
<Button
428+
disabled={isCreateMCPServerPending || isCreateServiceAccountPending}
429+
onClick={methods.prev}
430+
variant="outline"
431+
>
333432
<ArrowLeft className="h-4 w-4" />
334433
Previous
335434
</Button>
336435
)}
337436
{methods.isLast ? (
338437
<Button
339-
disabled={isCreateMCPServerPending || hasFormErrors || hasLintingIssues || hasSecretWarnings}
438+
disabled={
439+
isCreateMCPServerPending ||
440+
isCreateServiceAccountPending ||
441+
isCreateSecretPending ||
442+
hasFormErrors ||
443+
hasLintingIssues ||
444+
hasSecretWarnings
445+
}
340446
onClick={form.handleSubmit(onSubmit)}
341447
>
342-
{isCreateMCPServerPending ? (
448+
{isCreateMCPServerPending || isCreateServiceAccountPending || isCreateSecretPending ? (
343449
<div className="flex items-center gap-2">
344450
<Loader2 className="h-4 w-4 animate-spin" />
345451
<Text as="span">Creating...</Text>
@@ -358,6 +464,15 @@ export const RemoteMCPCreatePage: React.FC = () => {
358464
)}
359465
</Stepper.Controls>
360466
</Form>
467+
{isFeatureFlagEnabled('enableMcpServiceAccount') && (
468+
<ServiceAccountSelector
469+
createSecret={createSecret}
470+
onPendingChange={setIsCreateServiceAccountPending}
471+
ref={serviceAccountSelectorRef}
472+
resourceType="MCP server"
473+
serviceAccountName={serviceAccountName}
474+
/>
475+
)}
361476

362477
{/* Expanded YAML Editor Dialog */}
363478
{expandedTool && (

frontend/src/components/pages/mcp-servers/create/schemas.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { RESOURCE_TIERS } from 'components/ui/connect/resource-tier-select';
12-
import { MCPServer_Tool_ComponentType } from 'protogen/redpanda/api/dataplane/v1alpha3/mcp_pb';
12+
import { MCPServer_Tool_ComponentType } from 'react-query/api/remote-mcp';
1313
import { parse } from 'yaml';
1414
import { z } from 'zod';
1515

@@ -85,6 +85,13 @@ export const FormSchema = z
8585
},
8686
{ message: 'Tool names must be unique' }
8787
),
88+
serviceAccountName: z
89+
.string()
90+
.min(3, 'Service account name must be at least 3 characters')
91+
.max(128, 'Service account name must be at most 128 characters')
92+
.regex(/^[^<>]+$/, 'Service account name cannot contain < or > characters')
93+
.optional()
94+
.default(''),
8895
})
8996
.strict();
9097

@@ -103,4 +110,5 @@ export const initialValues: FormValues = {
103110
config: '',
104111
},
105112
],
113+
serviceAccountName: '',
106114
};

frontend/src/components/pages/mcp-servers/create/use-metadata-validation.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
* by the Apache License, Version 2.0
99
*/
1010

11+
import { isFeatureFlagEnabled } from 'config';
1112
import { useMemo } from 'react';
1213
import type { UseFormReturn } from 'react-hook-form';
1314

1415
import type { FormValues } from './schemas';
1516

1617
export function useMetadataValidation(form: UseFormReturn<FormValues>) {
1718
const formValues = form.watch();
19+
const isServiceAccountEnabled = isFeatureFlagEnabled('enableMcpServiceAccount');
1820

1921
const isMetadataComplete = useMemo(() => {
2022
// Check displayName (required)
@@ -32,17 +34,20 @@ export function useMetadataValidation(form: UseFormReturn<FormValues>) {
3234
form.formState.errors.displayName ||
3335
form.formState.errors.description ||
3436
form.formState.errors.resourcesTier ||
35-
form.formState.errors.tags
37+
form.formState.errors.tags ||
38+
(isServiceAccountEnabled && form.formState.errors.serviceAccountName)
3639
);
3740

3841
return !hasMetadataErrors;
3942
}, [
4043
formValues.displayName,
4144
formValues.resourcesTier,
45+
isServiceAccountEnabled,
4246
form.formState.errors.displayName,
4347
form.formState.errors.description,
4448
form.formState.errors.resourcesTier,
4549
form.formState.errors.tags,
50+
form.formState.errors.serviceAccountName,
4651
]);
4752

4853
return { isMetadataComplete, isMetadataInvalid: !isMetadataComplete };

0 commit comments

Comments
 (0)