feat(mcp): support mcp resources (#2215)

* feat(mcp): support mcp resources

* feat(web): split MCP resources into tab

* docs: add MCP resources PR review

* feat(mcp): productionize resource support

* feat(mcp): scope local agent tools and resources

* fix(web): gate space embedding models behind login

* fix(web): prevent clipped space model CTA

* test: update preproc resource tool expectations

* fix(web): expose skill authoring tools in selector

---------

Co-authored-by: yang.xiang <yang.xiang@advancegroup.com>
Co-authored-by: Hyu <chenhyu@proton.me>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
advancer-young
2026-06-30 19:16:30 +08:00
committed by GitHub
parent 2618e06492
commit 096ec1a8ce
39 changed files with 4103 additions and 201 deletions
@@ -1,4 +1,5 @@
import {
DynamicFormItemType,
IDynamicFormItemSchema,
SYSTEM_FIELD_PREFIX,
} from '@/app/infra/entities/form/dynamic';
@@ -57,6 +58,95 @@ function resolveShowIfValue(
return externalDependentValues?.[field];
}
type DynamicFormValueSpec = Pick<
IDynamicFormItemSchema,
'default' | 'name' | 'required' | 'type'
>;
function getValueSpecs(item: IDynamicFormItemSchema): DynamicFormValueSpec[] {
if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) {
return [
item,
{
name: 'enable-all-tools',
type: DynamicFormItemType.BOOLEAN,
required: false,
default: true,
},
];
}
if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) {
return [
item,
{
name: 'mcp-resources',
type: DynamicFormItemType.UNKNOWN,
required: false,
default: [],
},
{
name: 'mcp-resource-agent-read-enabled',
type: DynamicFormItemType.BOOLEAN,
required: false,
default: true,
},
];
}
return [item];
}
function getValueSchema(spec: DynamicFormValueSpec) {
if (spec.name === 'mcp-resources') {
return z.array(z.any());
}
switch (spec.type) {
case DynamicFormItemType.INT:
return z.number();
case DynamicFormItemType.FLOAT:
return z.number();
case DynamicFormItemType.BOOLEAN:
return z.boolean();
case DynamicFormItemType.STRING:
return z.string();
case DynamicFormItemType.STRING_ARRAY:
return z.array(z.string());
case DynamicFormItemType.SELECT:
return z.string();
case DynamicFormItemType.LLM_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
return z.string();
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
case DynamicFormItemType.RESOURCES_SELECTOR:
case DynamicFormItemType.RICH_TOOLS_SELECTOR:
case DynamicFormItemType.TOOLS_SELECTOR:
return z.array(z.string());
case DynamicFormItemType.BOT_SELECTOR:
return z.string();
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR:
return z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
});
case DynamicFormItemType.PROMPT_EDITOR:
return z.array(
z.object({
content: z.string(),
role: z.string(),
}),
);
default:
return z.string();
}
}
/**
* Display-only component for embed code fields with copy animation.
*/
@@ -322,9 +412,16 @@ export default function DynamicFormComponent({
// model-fallback-selector) is coerced to the expected shape
// so that downstream components never crash.
const normalizeFieldValue = (
item: IDynamicFormItemSchema,
item: DynamicFormValueSpec,
value: unknown,
): unknown => {
if (
item.name === 'mcp-resources' ||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
) {
return Array.isArray(value) ? value : [];
}
if (item.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
@@ -368,68 +465,16 @@ export default function DynamicFormComponent({
[itemConfigList],
);
const editableValueSpecs = useMemo(
() => editableItems.flatMap(getValueSpecs),
[editableItems],
);
// 根据 itemConfigList 动态生成 zod schema
const formSchema = z.object(
editableItems.reduce(
editableValueSpecs.reduce(
(acc, item) => {
let fieldSchema;
switch (item.type) {
case 'integer':
fieldSchema = z.number();
break;
case 'float':
fieldSchema = z.number();
break;
case 'boolean':
fieldSchema = z.boolean();
break;
case 'string':
fieldSchema = z.string();
break;
case 'array[string]':
fieldSchema = z.array(z.string());
break;
case 'select':
fieldSchema = z.string();
break;
case 'llm-model-selector':
fieldSchema = z.string();
break;
case 'embedding-model-selector':
fieldSchema = z.string();
break;
case 'rerank-model-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-multi-selector':
fieldSchema = z.array(z.string());
break;
case 'bot-selector':
fieldSchema = z.string();
break;
case 'tools-selector':
fieldSchema = z.array(z.string());
break;
case 'model-fallback-selector':
fieldSchema = z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
});
break;
case 'prompt-editor':
fieldSchema = z.array(
z.object({
content: z.string(),
role: z.string(),
}),
);
break;
default:
fieldSchema = z.string();
}
let fieldSchema = getValueSchema(item);
if (
item.required &&
@@ -454,7 +499,7 @@ export default function DynamicFormComponent({
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: editableItems.reduce((acc, item) => {
defaultValues: editableValueSpecs.reduce((acc, item) => {
// 优先使用 initialValues,如果没有则使用默认值
const rawValue = initialValues?.[item.name] ?? item.default;
return {
@@ -493,7 +538,7 @@ export default function DynamicFormComponent({
if (initialValues && hasRealChange) {
// 合并默认值和初始值
const mergedValues = editableItems.reduce(
const mergedValues = editableValueSpecs.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
@@ -508,10 +553,16 @@ export default function DynamicFormComponent({
previousInitialValues.current = initialValues;
}
}, [initialValues, form, editableItems]);
}, [initialValues, form, editableValueSpecs]);
// Get reactive form values for conditional rendering
const watchedValues = form.watch();
const setFormValue = (name: string, value: unknown) => {
form.setValue(name as keyof FormValues, value as never, {
shouldDirty: true,
shouldValidate: true,
});
};
// Stable ref for onSubmit to avoid re-triggering the effect when the
// parent passes a new closure on every render.
@@ -524,7 +575,7 @@ export default function DynamicFormComponent({
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = editableItems.reduce(
const initialFinalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -544,7 +595,7 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = editableItems.reduce(
const finalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -555,7 +606,7 @@ export default function DynamicFormComponent({
previousInitialValues.current = finalValues as Record<string, object>;
});
return () => subscription.unsubscribe();
}, [form, editableItems]);
}, [form, editableValueSpecs]);
// State for QR code login dialog
const [qrDialogOpen, setQrDialogOpen] = useState(false);
@@ -786,6 +837,41 @@ export default function DynamicFormComponent({
);
}
if (
config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
config.type === DynamicFormItemType.RESOURCES_SELECTOR
) {
return (
<FormField
key={config.id}
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem className="min-w-0">
<FormControl>
<div
className={cn(
'min-w-0 max-w-full overflow-x-hidden',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (
@@ -816,7 +902,10 @@ export default function DynamicFormComponent({
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</FormControl>
</div>
@@ -853,7 +942,10 @@ export default function DynamicFormComponent({
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</div>
</FormControl>
@@ -64,15 +64,23 @@ import {
import SettingsDialog, {
SettingsSection,
} from '@/app/home/components/settings-dialog/SettingsDialog';
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
export default function DynamicFormItemComponent({
config,
field,
formValues,
onFileUploaded,
setFormValue,
systemContext,
}: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps<any, any>;
formValues?: Record<string, unknown>;
onFileUploaded?: (fileKey: string) => void;
setFormValue?: (name: string, value: unknown) => void;
systemContext?: Record<string, unknown>;
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
@@ -103,10 +111,34 @@ export default function DynamicFormItemComponent({
});
};
const fetchEmbeddingModels = () => {
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models);
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
});
};
const fetchRerankModels = () => {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
};
const handleModelsDialogChange = (open: boolean) => {
setModelsDialogOpen(open);
if (!open) {
fetchLlmModels();
fetchEmbeddingModels();
fetchRerankModels();
}
};
@@ -174,27 +206,13 @@ export default function DynamicFormItemComponent({
useEffect(() => {
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models);
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
});
fetchEmbeddingModels();
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
fetchRerankModels();
}
}, [config.type]);
@@ -248,6 +266,16 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
const handleCompositePatch = (patch: Record<string, unknown>) => {
for (const [name, value] of Object.entries(patch)) {
if (setFormValue) {
setFormValue(name, value);
} else if (name === field.name) {
field.onChange(value);
}
}
};
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
@@ -377,10 +405,10 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Separate space models from regular models
const spaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const regularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
// Group regular models by provider
@@ -481,7 +509,7 @@ export default function DynamicFormItemComponent({
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
@@ -558,7 +586,10 @@ export default function DynamicFormItemComponent({
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
onClick={() => {
setSettingsSection('models');
setModelsDialogOpen(true);
}}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
@@ -574,9 +605,15 @@ export default function DynamicFormItemComponent({
</div>
);
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
// Group embedding models by provider
const groupedEmbeddingModels = embeddingModels.reduce(
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
const spaceEmbeddingModels = embeddingModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const regularEmbeddingModels = embeddingModels.filter(
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const groupedEmbeddingModels = regularEmbeddingModels.reduce(
(acc, model) => {
const providerName = model.provider?.name || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
@@ -586,29 +623,169 @@ export default function DynamicFormItemComponent({
{} as Record<string, EmbeddingModel[]>,
);
const groupedSpaceEmbeddingModels = spaceEmbeddingModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, EmbeddingModel[]>,
);
const previewEmbeddingModelNames = [
'text-embedding-3-large',
'text-embedding-3-small',
'bge-m3',
'jina-embeddings-v3',
'qwen3-embedding-8b',
];
return (
<div className="w-full max-w-md min-w-0">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('knowledge.selectEmbeddingModel')}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{(spaceEmbeddingModels.length > 0
? spaceEmbeddingModels.map((m) => m.name)
: previewEmbeddingModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(spaceEmbeddingModels.length > 0
? spaceEmbeddingModels.map((m) => m.name)
: previewEmbeddingModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
),
)}
</SelectContent>
</Select>
) : !systemInfo.disable_models_service ? (
Object.entries(groupedSpaceEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent>
</Select>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
setSettingsSection('models');
setModelsDialogOpen(true);
}}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<SettingsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/>
</div>
);
}
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
const groupedRerankModels = rerankModels.reduce(
@@ -652,10 +829,10 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const fbRegularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
// Group regular models by provider
@@ -786,7 +963,7 @@ export default function DynamicFormItemComponent({
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
@@ -1383,6 +1560,32 @@ export default function DynamicFormItemComponent({
</>
);
case DynamicFormItemType.RICH_TOOLS_SELECTOR:
return (
<ToolResourceSelectors
mode="tools"
pipelineId={systemContext?.pipeline_id as string | undefined}
value={{
...(formValues || {}),
[field.name]: field.value,
}}
onChange={handleCompositePatch}
/>
);
case DynamicFormItemType.RESOURCES_SELECTOR:
return (
<ToolResourceSelectors
mode="resources"
pipelineId={systemContext?.pipeline_id as string | undefined}
value={{
...(formValues || {}),
[field.name]: field.value,
}}
onChange={handleCompositePatch}
/>
);
case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default
@@ -56,6 +56,13 @@ export function getDefaultValues(
return acc;
}
acc[item.name] = item.default;
if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) {
acc['enable-all-tools'] = true;
}
if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) {
acc['mcp-resources'] = [];
acc['mcp-resource-agent-read-enabled'] = true;
}
return acc;
},
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,8 @@ import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
import {
MCPServerRuntimeInfo,
MCPTool,
MCPResource,
MCPResourceContent,
MCPServer,
MCPSessionStatus,
MCPServerExtraArgsRemote,
@@ -244,13 +246,121 @@ function ToolsList({ tools, t }: { tools: MCPTool[]; t: TFunction }) {
);
}
function ResourcesList({
resources,
serverName,
t,
}: {
resources: MCPResource[];
serverName: string;
t: (key: string) => string;
}) {
const [expandedUri, setExpandedUri] = React.useState<string | null>(null);
const [resourceContent, setResourceContent] =
React.useState<MCPResourceContent | null>(null);
const [loadingContent, setLoadingContent] = React.useState(false);
const handleToggleResource = async (uri: string) => {
if (expandedUri === uri) {
setExpandedUri(null);
setResourceContent(null);
return;
}
setExpandedUri(uri);
setResourceContent(null);
setLoadingContent(true);
try {
const resp = await httpClient.readMCPServerResource(
serverName,
uri,
65536,
);
if (resp.contents && resp.contents.length > 0) {
setResourceContent(resp.contents[0]);
}
} catch {
setResourceContent(null);
} finally {
setLoadingContent(false);
}
};
return (
<div className="space-y-2 pb-6">
{resources.map((resource, index) => (
<Card key={index} className="py-3 shadow-none">
<CardHeader
className="cursor-pointer"
onClick={() => handleToggleResource(resource.uri)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-sm">{resource.name}</CardTitle>
{resource.mime_type && (
<span className="text-xs text-muted-foreground font-mono">
{resource.mime_type}
</span>
)}
</div>
{resource.description && (
<CardDescription className="text-xs">
{resource.description}
</CardDescription>
)}
<div className="text-xs text-muted-foreground font-mono break-all">
{resource.uri}
</div>
</CardHeader>
{expandedUri === resource.uri && (
<CardContent>
{loadingContent ? (
<div className="text-xs text-muted-foreground">
{t('mcp.loading')}
</div>
) : resourceContent?.type === 'text' && resourceContent.text ? (
<div className="space-y-2">
<pre className="text-xs bg-muted p-3 rounded-md overflow-auto max-h-[200px] whitespace-pre-wrap break-all">
{resourceContent.text}
</pre>
{resourceContent.truncated && (
<div className="text-xs text-muted-foreground">
{t('mcp.resourceTruncated')}
</div>
)}
</div>
) : resourceContent?.type === 'blob' ? (
<div className="text-xs text-muted-foreground">
{resourceContent.binary_omitted
? t('mcp.resourceBinaryOmitted')
: t('mcp.resourceBinaryContent')}
</div>
) : (
<div className="text-xs text-muted-foreground">
{t('mcp.resourceReadFailed')}
</div>
)}
</CardContent>
)}
</Card>
))}
</div>
);
}
type RuntimePanelContent = 'all' | 'tools' | 'resources';
function RuntimePanel({
mcpTesting,
runtimeInfo,
serverName,
content = 'all',
t,
}: {
mcpTesting: boolean;
runtimeInfo: MCPServerRuntimeInfo | null;
serverName: string;
content?: RuntimePanelContent;
t: TFunction;
}) {
// Show tools whenever we have runtime info — either an edit-mode server or a
@@ -259,7 +369,9 @@ function RuntimePanel({
if (!runtimeInfo) {
return (
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
{t('mcp.noToolsFound')}
{content === 'resources'
? t('mcp.noResourcesFound')
: t('mcp.noToolsFound')}
</div>
);
}
@@ -267,6 +379,15 @@ function RuntimePanel({
const isConnected =
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
const tools = runtimeInfo.tools || [];
const resources = runtimeInfo.resources || [];
const showTools = content === 'all' || content === 'tools';
const showResources = content === 'all' || content === 'resources';
const empty =
content === 'tools'
? tools.length === 0
: content === 'resources'
? resources.length === 0
: tools.length === 0 && resources.length === 0;
return (
<section className="space-y-4">
@@ -276,11 +397,26 @@ function RuntimePanel({
</div>
)}
{isConnected && tools.length > 0 && <ToolsList tools={tools} t={t} />}
{isConnected && showTools && tools.length > 0 && (
<ToolsList tools={tools} t={t} />
)}
{isConnected && tools.length === 0 && (
{isConnected && showResources && resources.length > 0 && (
<div className="space-y-2">
{content === 'all' && (
<div className="text-sm font-medium">
{t('mcp.resourceCount', { count: resources.length })}
</div>
)}
<ResourcesList resources={resources} serverName={serverName} t={t} />
</div>
)}
{isConnected && empty && (
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
{t('mcp.noToolsFound')}
{content === 'resources'
? t('mcp.noResourcesFound')
: t('mcp.noToolsFound')}
</div>
)}
</section>
@@ -732,6 +868,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
error_message: errorMsg,
tool_count: 0,
tools: [],
resource_count: 0,
resources: [],
});
} else {
if (isEditMode) {
@@ -1026,20 +1164,29 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
);
const runtimePanel = (
<RuntimePanel mcpTesting={mcpTesting} runtimeInfo={runtimeInfo} t={t} />
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
t={t}
/>
);
// In edit mode the right side shows a tablist switching between the live
// Tools list and the Docs (README captured from LangBot Space at install).
// Tools/resources lists and the Docs (README captured from LangBot Space at install).
// Create mode has neither, so it falls back to the bare runtime placeholder.
// The tool count lives in the tab label (only when connected); the panel
// Counts live in the tab labels (only when connected); the panel
// body itself no longer repeats a title/subtitle.
const toolsConnected =
const runtimeConnected =
!mcpTesting && runtimeInfo?.status === MCPSessionStatus.CONNECTED;
const toolsCount = runtimeInfo?.tools?.length ?? 0;
const toolsTabLabel = toolsConnected
const resourcesCount = runtimeInfo?.resources?.length ?? 0;
const toolsTabLabel = runtimeConnected
? `${t('mcp.tabTools')} ${toolsCount}`
: t('mcp.tabTools');
const resourcesTabLabel = runtimeConnected
? `${t('mcp.tabResources')} ${resourcesCount}`
: t('mcp.tabResources');
const detailPanel = isEditMode ? (
<Tabs defaultValue="tools" className="flex h-full min-h-0 flex-col">
@@ -1050,6 +1197,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<TabsTrigger value="tools" className="flex-none px-4">
{toolsTabLabel}
</TabsTrigger>
<TabsTrigger value="resources" className="flex-none px-4">
{resourcesTabLabel}
</TabsTrigger>
</TabsList>
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
<MCPReadme readme={readme} />
@@ -1058,7 +1208,25 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
value="tools"
className="mt-4 min-h-0 flex-1 overflow-y-auto"
>
{runtimePanel}
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
content="tools"
t={t}
/>
</TabsContent>
<TabsContent
value="resources"
className="mt-4 min-h-0 flex-1 overflow-y-auto"
>
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
content="resources"
t={t}
/>
</TabsContent>
</Tabs>
) : (
@@ -12,16 +12,40 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
import { CircleHelp, Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Plugin } from '@/app/infra/entities/plugin';
import { MCPServer, Skill } from '@/app/infra/entities/api';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
function InfoTooltip({ label }: { label: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label={label}
>
<CircleHelp className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[280px]">
{label}
</TooltipContent>
</Tooltip>
);
}
export default function PipelineExtension({
pipelineId,
}: {
@@ -418,9 +442,14 @@ export default function PipelineExtension({
{/* MCP Servers Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.mcpServersTitle')}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.mcpServersTitle')}
</h3>
<InfoTooltip
label={t('pipelines.extensions.mcpServersScopeTooltip')}
/>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-mcp-servers"
@@ -428,6 +457,9 @@ export default function PipelineExtension({
>
{t('pipelines.extensions.enableAllMCPServers')}
</Label>
<InfoTooltip
label={t('pipelines.extensions.enableAllMCPServersTooltip')}
/>
<Switch
id="enable-all-mcp-servers"
checked={enableAllMCPServers}
@@ -427,13 +427,14 @@ export default function PipelineFormComponent({
const forcedBoxTemplate =
systemInfo.limitation?.force_box_session_id_template || '';
const boxScopeForced = !!forcedBoxTemplate;
const stageSystemContext =
stage.name === 'local-agent'
? {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
}
: undefined;
const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent';
const stageSystemContext = isLocalAgentStage
? {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
pipeline_id: pipelineId,
}
: undefined;
// When the deployment pins every pipeline to a fixed sandbox scope (SaaS
// ``force_box_session_id_template``), the Sandbox Scope selector is locked.
@@ -446,12 +447,26 @@ export default function PipelineFormComponent({
const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
const effectiveInitialValues =
stage.name === 'local-agent' && boxScopeForced
isLocalAgentStage && boxScopeForced
? {
...stageInitialValues,
'box-session-id-template': forcedBoxTemplate,
}
: stageInitialValues;
const emitStageValues = (values: object) => {
if (!isLocalAgentStage) {
handleDynamicFormEmit(formName, stage.name, values);
return;
}
const latestStageValues =
((form.getValues(formName) as Record<string, any>) || {})[stage.name] ||
{};
handleDynamicFormEmit(formName, stage.name, {
...latestStageValues,
...values,
});
};
return (
<Card key={stage.name}>
@@ -467,9 +482,7 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={effectiveInitialValues}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
onSubmit={emitStageValues}
systemContext={stageSystemContext}
/>
</CardContent>
+61
View File
@@ -563,6 +563,11 @@ export interface MCPServerRuntimeInfo {
* server runs inside Box. Absent when Box is unavailable. */
box_session_id?: string;
box_enabled?: boolean;
resource_count: number;
resources: MCPResource[];
resource_template_count?: number;
resource_templates?: MCPResourceTemplate[];
resource_capabilities?: Record<string, unknown>;
}
export type MCPServer =
@@ -617,11 +622,67 @@ export interface MCPTool {
parameters?: object;
}
export interface MCPResource {
uri: string;
name: string;
title?: string;
description: string;
mime_type: string;
size?: number;
icons?: object[];
annotations?: Record<string, unknown>;
_meta?: Record<string, unknown>;
}
export interface MCPResourceTemplate {
uri_template: string;
name: string;
title?: string;
description: string;
mime_type: string;
icons?: object[];
annotations?: Record<string, unknown>;
_meta?: Record<string, unknown>;
}
export interface MCPResourceContent {
uri: string;
mime_type: string;
type: 'text' | 'blob';
text?: string;
blob?: string | null;
bytes?: number;
truncated?: boolean;
binary_omitted?: boolean;
_meta?: Record<string, unknown>;
}
export interface ApiRespMCPResources {
resources: MCPResource[];
resource_templates?: MCPResourceTemplate[];
resource_capabilities?: Record<string, unknown>;
}
export interface ApiRespMCPResourceContents {
contents: MCPResourceContent[];
server_name?: string;
server_uuid?: string;
uri?: string;
source?: string;
bytes?: number;
truncated?: boolean;
cache_hit?: boolean;
warnings?: string[];
}
export interface PluginTool {
name: string;
description: string;
human_desc: string;
parameters: object;
source?: 'builtin' | 'plugin' | 'mcp' | 'skill';
source_name?: string;
source_id?: string;
}
export interface ApiRespTools {
@@ -67,6 +67,8 @@ export enum DynamicFormItemType {
PLUGIN_SELECTOR = 'plugin-selector',
BOT_SELECTOR = 'bot-selector',
TOOLS_SELECTOR = 'tools-selector',
RICH_TOOLS_SELECTOR = 'rich-tools-selector',
RESOURCES_SELECTOR = 'resources-selector',
WEBHOOK_URL = 'webhook-url',
EMBED_CODE = 'embed-code',
QR_CODE_LOGIN = 'qr-code-login',
+58 -4
View File
@@ -40,6 +40,8 @@ import {
BoxSessionInfo,
ApiRespMCPServers,
ApiRespMCPServer,
ApiRespMCPResources,
ApiRespMCPResourceContents,
MCPServer,
ApiRespModelProviders,
ApiRespModelProvider,
@@ -269,10 +271,20 @@ export class BackendClient extends BaseHttpClient {
enable_all_plugins: boolean;
enable_all_mcp_servers: boolean;
enable_all_skills: boolean;
mcp_resource_agent_read_enabled: boolean;
bound_plugins: Array<{ author: string; name: string }>;
available_plugins: Plugin[];
bound_mcp_servers: string[];
available_mcp_servers: MCPServer[];
bound_mcp_resources: Array<{
server_uuid?: string;
server_name?: string;
uri: string;
mode?: string;
enabled?: boolean;
max_bytes?: number;
max_tokens?: number;
}>;
bound_skills: string[];
available_skills: Skill[];
}> {
@@ -287,15 +299,32 @@ export class BackendClient extends BaseHttpClient {
enable_all_mcp_servers: boolean = true,
bound_skills: string[] = [],
enable_all_skills: boolean = true,
bound_mcp_resources?: Array<{
server_uuid?: string;
server_name?: string;
uri: string;
mode?: string;
enabled?: boolean;
max_bytes?: number;
max_tokens?: number;
}>,
mcp_resource_agent_read_enabled?: boolean,
): Promise<object> {
return this.put(`/api/v1/pipelines/${uuid}/extensions`, {
const payload: Record<string, unknown> = {
bound_plugins,
bound_mcp_servers,
enable_all_plugins,
enable_all_mcp_servers,
bound_skills,
enable_all_skills,
});
};
if (bound_mcp_resources !== undefined) {
payload.bound_mcp_resources = bound_mcp_resources;
}
if (mcp_resource_agent_read_enabled !== undefined) {
payload.mcp_resource_agent_read_enabled = mcp_resource_agent_read_enabled;
}
return this.put(`/api/v1/pipelines/${uuid}/extensions`, payload);
}
// ============ WebSocket Chat API ============
@@ -865,8 +894,11 @@ export class BackendClient extends BaseHttpClient {
// ========== Tools ==========
public getTools(): Promise<ApiRespTools> {
return this.get('/api/v1/tools');
public getTools(pipelineId?: string): Promise<ApiRespTools> {
return this.get(
'/api/v1/tools',
pipelineId ? { pipeline_uuid: pipelineId } : undefined,
);
}
public getToolDetail(toolName: string): Promise<ApiRespToolDetail> {
@@ -926,6 +958,28 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/mcp/servers', { source });
}
public getMCPServerResources(
serverName: string,
): Promise<ApiRespMCPResources> {
return this.get(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/resources`,
);
}
public readMCPServerResource(
serverName: string,
uri: string,
maxBytes?: number,
): Promise<ApiRespMCPResourceContents> {
return this.post(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/resources/read`,
{
uri,
max_bytes: maxBytes,
},
);
}
// ============ System API ============
public getSystemInfo(): Promise<ApiRespSystemInfo> {
return this.get('/api/v1/system/info');