feat(recipe-studio): remove MCP tools-related dialogs and refactor tool profile management logic

This commit is contained in:
Shine1i 2026-03-09 17:04:15 +01:00
commit 1f37b76b19
40 changed files with 1537 additions and 895 deletions

View file

@ -61,3 +61,19 @@ class SeedInspectResponse(BaseModel):
preview_rows: list[dict[str, Any]] = Field(default_factory=list)
split: str | None = None
subset: str | None = None
class McpToolsListRequest(BaseModel):
mcp_providers: list[dict[str, Any]] = Field(default_factory=list)
timeout_sec: float | None = Field(default=None, gt=0)
class McpToolsProviderResult(BaseModel):
name: str
tools: list[str] = Field(default_factory=list)
error: str | None = None
class McpToolsListResponse(BaseModel):
providers: list[McpToolsProviderResult] = Field(default_factory=list)
duplicate_tools: dict[str, list[str]] = Field(default_factory=dict)

View file

@ -14,6 +14,7 @@ if str(backend_path) not in sys.path:
sys.path.insert(0, str(backend_path))
from .jobs import router as jobs_router
from .mcp import router as mcp_router
from .seed import router as seed_router
from .validate import router as validate_router
@ -21,5 +22,6 @@ router = APIRouter(dependencies=[Depends(get_current_subject)])
router.include_router(seed_router)
router.include_router(validate_router)
router.include_router(jobs_router)
router.include_router(mcp_router)
__all__ = ["router"]

View file

@ -0,0 +1,77 @@
"""MCP helper endpoints for data recipe."""
from __future__ import annotations
from collections import defaultdict
from fastapi import APIRouter
from core.data_recipe.service import build_mcp_providers
from models.data_recipe import (
McpToolsListRequest,
McpToolsListResponse,
McpToolsProviderResult,
)
router = APIRouter()
@router.post("/mcp/tools", response_model=McpToolsListResponse)
def list_mcp_tools(payload: McpToolsListRequest) -> McpToolsListResponse:
try:
from data_designer.engine.mcp import io as mcp_io
except ImportError as exc:
return McpToolsListResponse(
providers=[
McpToolsProviderResult(
name="",
error=f"MCP dependencies unavailable: {exc}",
)
]
)
providers: list[McpToolsProviderResult] = []
tool_to_providers: dict[str, list[str]] = defaultdict(list)
for provider_payload in payload.mcp_providers:
provider_name = str(provider_payload.get("name", "")).strip()
built = build_mcp_providers({"mcp_providers": [provider_payload]})
if len(built) != 1:
providers.append(
McpToolsProviderResult(
name=provider_name,
error="Unsupported MCP provider config.",
)
)
continue
provider = built[0]
try:
tools = mcp_io.list_tools(provider, timeout_sec=payload.timeout_sec)
tool_names = sorted({tool.name for tool in tools if getattr(tool, "name", "")})
for tool_name in tool_names:
tool_to_providers[tool_name].append(provider.name)
providers.append(
McpToolsProviderResult(
name=provider.name,
tools=tool_names,
)
)
except Exception as exc:
providers.append(
McpToolsProviderResult(
name=provider.name or provider_name,
error=str(exc).strip() or "Failed to load tools.",
)
)
duplicate_tools = {
tool_name: provider_names
for tool_name, provider_names in sorted(tool_to_providers.items())
if len(provider_names) > 1
}
return McpToolsListResponse(
providers=providers,
duplicate_tools=duplicate_tools,
)

View file

@ -124,6 +124,25 @@ export type ValidateResponse = {
raw_detail?: string | null;
};
export type McpToolsListRequest = {
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: Record<string, unknown>[];
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec?: number;
};
export type McpToolsProviderResult = {
name: string;
tools: string[];
error?: string | null;
};
export type McpToolsListResponse = {
providers: McpToolsProviderResult[];
// biome-ignore lint/style/useNamingConvention: api schema
duplicate_tools: Record<string, string[]>;
};
async function parseErrorResponse(response: Response): Promise<string> {
const text = (await response.text()).trim();
if (!text) {
@ -261,6 +280,12 @@ export async function inspectSeedUpload(
return postJson<SeedInspectResponse>("/seed/inspect-upload", payload);
}
export async function listMcpTools(
payload: McpToolsListRequest,
): Promise<McpToolsListResponse> {
return postJson<McpToolsListResponse>("/mcp/tools", payload);
}
export async function streamRecipeJobEvents(options: {
jobId: string;
signal: AbortSignal;
@ -322,4 +347,4 @@ export async function streamRecipeJobEvents(options: {
}
}
// NOTE: tools + seed inspect/preview endpoints removed from harness.
// NOTE: preview endpoints removed from harness.

View file

@ -9,6 +9,7 @@ import {
EqualSignIcon,
FingerPrintIcon,
FunctionIcon,
Plug01Icon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
@ -29,6 +30,7 @@ import {
makeMarkdownNoteConfig,
makeModelConfig,
makeModelProviderConfig,
makeToolProfileConfig,
makeSamplerConfig,
makeSeedConfig,
makeValidatorConfig,
@ -54,7 +56,8 @@ export type BlockType =
| "seed_local"
| "seed_unstructured"
| "model_provider"
| "model_config";
| "model_config"
| "tool_config";
export type SeedBlockType = "seed_hf" | "seed_local" | "seed_unstructured";
@ -83,6 +86,7 @@ export type BlockDialogKey =
| "validator"
| "model_provider"
| "model_config"
| "tool_config"
| "expression";
export type BlockDefinition = {
@ -111,7 +115,7 @@ export const BLOCK_GROUPS: BlockGroup[] = [
{
kind: "llm",
title: "LLM + Models",
description: "Generation, providers, and model aliases.",
description: "Generation, model aliases, and shared tool profiles.",
icon: PencilEdit02Icon,
},
{
@ -297,6 +301,15 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
dialogKey: "model_config",
createConfig: (id, existing) => makeModelConfig(id, existing),
},
{
kind: "llm",
type: "tool_config",
title: "Tool Profile",
description: "Reusable MCP servers + allowed tools for one or more LLMs.",
icon: Plug01Icon,
dialogKey: "tool_config",
createConfig: (id, existing) => makeToolProfileConfig(id, existing),
},
{
kind: "validator",
type: "validator_python",
@ -399,6 +412,9 @@ export function getBlockDefinitionForConfig(
if (config.kind === "model_config") {
return getBlockDefinition("llm", "model_config");
}
if (config.kind === "tool_config") {
return getBlockDefinition("llm", "tool_config");
}
if (config.kind === "markdown_note") {
return getBlockDefinition("note", "markdown_note");
}

View file

@ -16,6 +16,7 @@ import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog";
import { UniformDialog } from "../dialogs/samplers/uniform-dialog";
import { UuidDialog } from "../dialogs/samplers/uuid-dialog";
import { MarkdownNoteDialog } from "../dialogs/markdown-note/markdown-note-dialog";
import { ToolProfileDialog } from "../dialogs/tool-profile/tool-profile-dialog";
import { ValidatorDialog } from "../dialogs/validators/validator-dialog";
export function renderBlockDialog(
@ -24,6 +25,7 @@ export function renderBlockDialog(
categoryOptions: SamplerConfig[],
modelConfigAliases: string[],
modelProviderOptions: string[],
toolProfileAliases: string[],
datetimeOptions: string[],
onUpdate: (id: string, patch: Partial<NodeConfig>) => void,
): ReactElement | null {
@ -91,6 +93,7 @@ export function renderBlockDialog(
config={config}
modelConfigAliases={modelConfigAliases}
modelProviderOptions={modelProviderOptions}
toolProfileAliases={toolProfileAliases}
onUpdate={update}
/>
) : null;
@ -106,6 +109,10 @@ export function renderBlockDialog(
onUpdate={update}
/>
) : null;
case "tool_config":
return config.kind === "tool_config" ? (
<ToolProfileDialog config={config} onUpdate={update} />
) : null;
case "expression":
return config.kind === "expression" ? (
<ExpressionDialog config={config} onUpdate={update} />

View file

@ -71,6 +71,7 @@ type BlockSheetProps = {
onAddLlm: (type: LlmType) => void;
onAddModelProvider: () => void;
onAddModelConfig: () => void;
onAddToolProfile: () => void;
onAddExpression: () => void;
onAddValidator: (
type: "validator_python" | "validator_sql" | "validator_oxc",
@ -225,6 +226,7 @@ export function BlockSheet({
onAddLlm,
onAddModelProvider,
onAddModelConfig,
onAddToolProfile,
onAddExpression,
onAddValidator,
onAddMarkdownNote,
@ -333,6 +335,10 @@ export function BlockSheet({
onAddModelConfig();
return;
}
if (type === "tool_config") {
onAddToolProfile();
return;
}
onAddLlm(type as LlmType);
return;
}

View file

@ -52,9 +52,17 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
.map((c) => c.name),
[configs],
);
const toolProfileAliases = useMemo(
() =>
Object.values(configs)
.filter((c) => c.kind === "tool_config")
.map((c) => c.name),
[configs],
);
const aliasInputRef = useRef(config.model_alias);
const lastAliasRef = useRef(config.model_alias);
const anchorRef = useRef<HTMLDivElement>(null);
const toolAnchorRef = useRef<HTMLDivElement>(null);
if (lastAliasRef.current !== config.model_alias) {
lastAliasRef.current = config.model_alias;
aliasInputRef.current = config.model_alias;
@ -107,6 +115,48 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
</Combobox>
</div>
</InlineField>
<InlineField label="Tool profile">
<div ref={toolAnchorRef}>
<Combobox
items={toolProfileAliases}
filteredItems={toolProfileAliases}
filter={null}
value={config.tool_alias || null}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: value ?? "",
})
}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
className="nodrag h-8 w-full text-xs"
placeholder="Tool profile"
onBlur={(event) => {
const next = event.target.value;
if (next !== (config.tool_alias ?? "")) {
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: next,
});
}
}}
/>
<ComboboxContent anchor={toolAnchorRef}>
<ComboboxEmpty>No tool profiles found</ComboboxEmpty>
<ComboboxList>
{(alias: string) => (
<ComboboxItem key={alias} value={alias}>
{alias}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</InlineField>
{isCode && (
<InlineField label="Code language">
<Select

View file

@ -21,6 +21,9 @@ export function getConfigUiMode(
if (config.kind === "model_provider" || config.kind === "model_config") {
return "inline";
}
if (config.kind === "tool_config") {
return "dialog";
}
if (config.kind === "llm") {
if (config.llm_type === "text" || config.llm_type === "code") {
return "inline";

View file

@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MarkdownPreview } from "@/components/markdown/markdown-preview";
import { cn } from "@/lib/utils";
@ -10,6 +11,7 @@ import {
EqualSignIcon,
FingerPrintIcon,
FunctionIcon,
Plug01Icon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
@ -99,6 +101,9 @@ const NODE_META = {
model_config: {
tone: "bg-orange-50 text-orange-600 border-orange-100",
},
tool_config: {
tone: "bg-cyan-50 text-cyan-700 border-cyan-100",
},
} as const;
const USER_NODE_TONE =
"bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-900/60";
@ -148,6 +153,9 @@ function resolveNodeIcon(
if (kind === "model_config") {
return Plant01Icon;
}
if (kind === "tool_config") {
return Plug01Icon;
}
if (kind === "seed") {
return Plant01Icon;
}
@ -203,9 +211,23 @@ function getConfigSummary(config: NodeConfig | undefined): string {
const scoreCount = config.scores?.length ?? 0;
return `${scoreCount} scorers`;
}
if (config.tool_alias?.trim()) {
return `Tool profile: ${config.tool_alias.trim()}`;
}
return "Prompt/system via linked input nodes";
}
if (config.kind === "tool_config") {
const providerCount = config.mcp_providers.length;
const allowCount = config.allow_tools?.filter((value) => value.trim()).length ?? 0;
const providerLabel =
providerCount === 1 ? "1 MCP server" : `${providerCount} MCP servers`;
if (allowCount === 0) {
return `${providerLabel} · all tools allowed`;
}
return `${providerLabel} · ${allowCount} allowed tools`;
}
if (config.kind === "validator") {
const target = config.target_columns[0]?.trim();
if (target) {
@ -283,6 +305,30 @@ function renderNodeBody(
return <InlineCategoryBadges values={config.values ?? []} />;
}
if (config?.kind === "tool_config") {
const providerNames = config.mcp_providers
.map((provider) => provider.name.trim())
.filter(Boolean);
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{summary}</p>
{providerNames.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{providerNames.map((providerName) => (
<Badge
key={providerName}
variant="secondary"
className="corner-squircle font-mono text-[11px]"
>
{providerName}
</Badge>
))}
</div>
)}
</div>
);
}
return <p className="text-xs text-muted-foreground">{summary}</p>;
}
@ -355,6 +401,7 @@ function RecipeGraphNodeBase({
const showSemanticOut =
data.kind === "model_config" ||
data.kind === "model_provider" ||
data.kind === "tool_config" ||
data.kind === "validator";
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);

View file

@ -15,6 +15,7 @@ type ConfigDialogProps = {
categoryOptions: SamplerConfig[];
modelConfigAliases: string[];
modelProviderOptions: string[];
toolProfileAliases: string[];
datetimeOptions: string[];
onUpdate: (id: string, patch: Partial<NodeConfig>) => void;
container?: HTMLDivElement | null;
@ -28,6 +29,7 @@ export function ConfigDialog({
categoryOptions,
modelConfigAliases,
modelProviderOptions,
toolProfileAliases,
datetimeOptions,
onUpdate,
container,
@ -96,6 +98,7 @@ export function ConfigDialog({
categoryOptions,
modelConfigAliases,
modelProviderOptions,
toolProfileAliases,
datetimeOptions,
onUpdate,
)}

View file

@ -20,7 +20,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, type RefObject, useMemo } from "react";
import { type ReactElement, type RefObject, useMemo, useRef } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { LlmConfig } from "../../types";
import { isLikelyImageValue } from "../../utils/image-preview";
@ -62,6 +62,7 @@ type LlmGeneralTabProps = {
config: LlmConfig;
modelConfigAliases: string[];
modelProviderOptions: string[];
toolProfileAliases: string[];
modelAliasAnchorRef: RefObject<HTMLDivElement | null>;
onUpdate: (patch: Partial<LlmConfig>) => void;
};
@ -70,17 +71,20 @@ export function LlmGeneralTab({
config,
modelConfigAliases,
modelProviderOptions,
toolProfileAliases,
modelAliasAnchorRef,
onUpdate,
}: LlmGeneralTabProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const modelAliasId = `${config.id}-model-alias`;
const toolAliasId = `${config.id}-tool-alias`;
const codeLangId = `${config.id}-code-lang`;
const promptId = `${config.id}-prompt`;
const outputFormatId = `${config.id}-output-format`;
const systemPromptId = `${config.id}-system-prompt`;
const hasModelConfigs = modelConfigAliases.length > 0;
const hasModelProviders = modelProviderOptions.length > 0;
const hasToolProfiles = toolProfileAliases.length > 0;
const validReferences = useMemo(
() => getAvailableVariables(configs, config.id),
[configs, config.id],
@ -152,6 +156,7 @@ export function LlmGeneralTab({
const traceModeId = `${config.id}-trace-mode`;
const reasoningToggleId = `${config.id}-reasoning-content`;
const advancedOpen = config.advancedOpen === true;
const toolAliasAnchorRef = useRef<HTMLDivElement>(null);
return (
<div className="space-y-4">
@ -210,6 +215,53 @@ export function LlmGeneralTab({
</Combobox>
</div>
</div>
<div className="grid gap-2">
<FieldLabel
label="Tool profile (optional)"
htmlFor={toolAliasId}
hint="Pick a shared Tool Profile block. Leave empty for no tools."
/>
<div ref={toolAliasAnchorRef}>
<Combobox
items={toolProfileAliases}
filteredItems={toolProfileAliases}
filter={null}
value={config.tool_alias || null}
onValueChange={(value) => onUpdate({ tool_alias: value ?? "" })}
itemToStringValue={(value) => value}
autoHighlight={true}
>
<ComboboxInput
id={toolAliasId}
className="nodrag w-full"
placeholder={
hasToolProfiles ? "Pick tool profile or type" : "No tool profiles yet"
}
onBlur={(event) => {
const inputValue = event.target.value;
if (inputValue !== (config.tool_alias ?? "")) {
onUpdate({ tool_alias: inputValue });
}
}}
/>
<ComboboxContent anchor={toolAliasAnchorRef}>
<ComboboxEmpty>No tool profiles found</ComboboxEmpty>
<ComboboxList>
{(alias: string) => (
<ComboboxItem key={alias} value={alias}>
{alias}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{!hasToolProfiles && (
<p className="text-xs text-muted-foreground">
Add a Tool Profile block to configure MCP servers and allowed tools.
</p>
)}
</div>
{config.llm_type === "code" && (
<div className="grid gap-2">
<FieldLabel

View file

@ -1,19 +1,19 @@
import { type ReactElement, useRef } from "react";
import type { LlmConfig } from "../../types";
import { LlmGeneralTab } from "./general-tab";
import { LlmScoresTab } from "./scores-tab";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { type ReactElement, useRef } from "react";
import type { LlmConfig } from "../../types";
import { LlmGeneralTab } from "./general-tab";
import { LlmMcpToolsTab } from "./mcp-tools-tab";
import { LlmScoresTab } from "./scores-tab";
type LlmDialogProps = {
config: LlmConfig;
modelConfigAliases: string[];
modelProviderOptions: string[];
toolProfileAliases: string[];
onUpdate: (patch: Partial<LlmConfig>) => void;
};
@ -21,22 +21,36 @@ export function LlmDialog({
config,
modelConfigAliases,
modelProviderOptions,
toolProfileAliases,
onUpdate,
}: LlmDialogProps): ReactElement {
const modelAliasAnchorRef = useRef<HTMLDivElement>(null);
if (config.llm_type !== "judge") {
return (
<LlmGeneralTab
config={config}
modelConfigAliases={modelConfigAliases}
modelProviderOptions={modelProviderOptions}
toolProfileAliases={toolProfileAliases}
modelAliasAnchorRef={modelAliasAnchorRef}
onUpdate={onUpdate}
/>
);
}
return (
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="general">General</TabsTrigger>
{config.llm_type === "judge" && <TabsTrigger value="scores">Scores</TabsTrigger>}
<TabsTrigger value="tools">MCPs / Tools</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-3">
<LlmGeneralTab
config={config}
modelConfigAliases={modelConfigAliases}
modelProviderOptions={modelProviderOptions}
toolProfileAliases={toolProfileAliases}
modelAliasAnchorRef={modelAliasAnchorRef}
onUpdate={onUpdate}
/>
@ -46,9 +60,6 @@ export function LlmDialog({
<LlmScoresTab config={config} onUpdate={onUpdate} />
</TabsContent>
)}
<TabsContent value="tools" className="pt-3">
<LlmMcpToolsTab config={config} onUpdate={onUpdate} />
</TabsContent>
</Tabs>
);
}

View file

@ -1,297 +0,0 @@
import { type ReactElement, useMemo, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
LlmConfig,
LlmMcpProviderConfig,
LlmToolConfig,
McpEnvVar,
} from "../../types";
import { toastError } from "@/shared/toast";
import { McpProvidersSection } from "./mcp-tools/mcp-providers-section";
import {
createMcpProviderId,
createToolConfigId,
isProviderReadyForToolFetch,
resolveLlmToolAlias,
toApiProvider,
} from "./mcp-tools/helpers";
import { ToolConfigsSection } from "./mcp-tools/tool-configs-section";
import { FieldLabel } from "../shared/field-label";
type LlmMcpToolsTabProps = {
config: LlmConfig;
onUpdate: (patch: Partial<LlmConfig>) => void;
};
const EMPTY_MCP_PROVIDERS: LlmMcpProviderConfig[] = [];
const EMPTY_TOOL_CONFIGS: LlmToolConfig[] = [];
function uniqueTrimmed(values: string[]): string[] {
return Array.from(
new Set(values.map((value) => value.trim()).filter(Boolean)),
);
}
export function LlmMcpToolsTab({
config,
onUpdate,
}: LlmMcpToolsTabProps): ReactElement {
const providers = config.mcp_providers ?? EMPTY_MCP_PROVIDERS;
const toolConfigs = config.tool_configs ?? EMPTY_TOOL_CONFIGS;
const [loadingTools, setLoadingTools] = useState(false);
const [toolsByProvider, setToolsByProvider] = useState<Record<string, string[]>>(
{},
);
function updateProviders(nextProviders: LlmMcpProviderConfig[]): void {
onUpdate({ mcp_providers: nextProviders });
}
function updateToolConfigs(nextToolConfigs: LlmToolConfig[]): void {
onUpdate({
tool_configs: nextToolConfigs,
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: resolveLlmToolAlias(nextToolConfigs, config.tool_alias),
});
}
function updateProviderAt(
index: number,
patch: Partial<LlmMcpProviderConfig>,
): void {
updateProviders(
providers.map((provider, currentIndex) =>
currentIndex === index ? { ...provider, ...patch } : provider,
),
);
}
function mutateProviderAt(
index: number,
mapProvider: (provider: LlmMcpProviderConfig) => Partial<LlmMcpProviderConfig>,
): void {
const provider = providers[index];
if (!provider) {
return;
}
updateProviderAt(index, mapProvider(provider));
}
function removeProvider(index: number): void {
updateProviders(
providers.filter((_, currentIndex) => currentIndex !== index),
);
}
function addProvider(): void {
updateProviders([
...providers,
{
id: createMcpProviderId(config.id, providers.length),
name: "",
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: "stdio",
command: "",
args: [""],
env: [{ key: "", value: "" }],
endpoint: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: "",
},
]);
}
function addProviderArg(providerIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
args: [...(provider.args ?? []), ""],
}));
}
function updateProviderArg(
providerIndex: number,
argIndex: number,
value: string,
): void {
mutateProviderAt(providerIndex, (provider) => {
const nextArgs = [...(provider.args ?? [])];
nextArgs[argIndex] = value;
return { args: nextArgs };
});
}
function removeProviderArg(providerIndex: number, argIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
args: (provider.args ?? []).filter(
(_, currentIndex) => currentIndex !== argIndex,
),
}));
}
function addProviderEnv(providerIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
env: [...(provider.env ?? []), { key: "", value: "" }],
}));
}
function updateProviderEnv(
providerIndex: number,
envIndex: number,
patch: Partial<McpEnvVar>,
): void {
mutateProviderAt(providerIndex, (provider) => ({
env: (provider.env ?? []).map((item, currentIndex) =>
currentIndex === envIndex ? { ...item, ...patch } : item,
),
}));
}
function removeProviderEnv(providerIndex: number, envIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
env: (provider.env ?? []).filter(
(_, currentIndex) => currentIndex !== envIndex,
),
}));
}
function updateToolConfigAt(
index: number,
patch: Partial<LlmToolConfig>,
): void {
updateToolConfigs(
toolConfigs.map((toolConfig, currentIndex) =>
currentIndex === index ? { ...toolConfig, ...patch } : toolConfig,
),
);
}
function addToolConfig(): void {
updateToolConfigs([
...toolConfigs,
{
id: createToolConfigId(config.id, toolConfigs.length),
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: "",
providers: [],
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: [],
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: "5",
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: "",
},
]);
}
function removeToolConfig(index: number): void {
updateToolConfigs(
toolConfigs.filter((_, currentIndex) => currentIndex !== index),
);
}
async function loadToolNames(): Promise<void> {
// tools endpoint removed from harness; keep UI but disable fetch.
const apiProviders = providers
.filter(isProviderReadyForToolFetch)
.map(toApiProvider)
.filter((provider) => Boolean(provider.name));
if (apiProviders.length === 0) {
toastError(
"No MCP servers configured",
"Add server name and command/endpoint first.",
);
return;
}
setLoadingTools(true);
try {
toastError("Tool fetch disabled", "Backend /tools endpoint removed.");
setToolsByProvider({});
} finally {
setLoadingTools(false);
}
}
const providerNameSuggestions = useMemo(
() => uniqueTrimmed(providers.map((provider) => provider.name)),
[providers],
);
const toolAliasOptions = useMemo(
() => uniqueTrimmed(toolConfigs.map((item) => item.tool_alias)),
[toolConfigs],
);
const activeToolAlias = useMemo(() => {
const currentAlias = config.tool_alias?.trim() ?? "";
if (toolAliasOptions.includes(currentAlias)) {
return currentAlias;
}
return toolAliasOptions[0] ?? "";
}, [config.tool_alias, toolAliasOptions]);
return (
<div className="space-y-5">
<div className="grid gap-2">
<FieldLabel
label="Active tool alias"
hint="Tool alias selected here is used by this LLM column."
/>
{toolAliasOptions.length > 0 ? (
<Select
value={activeToolAlias}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: value,
})
}
>
<SelectTrigger className="nodrag w-full">
<SelectValue placeholder="Select active tool alias" />
</SelectTrigger>
<SelectContent>
{toolAliasOptions.map((alias) => (
<SelectItem key={alias} value={alias}>
{alias}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-muted-foreground">
Add tool config alias first.
</p>
)}
</div>
<ToolConfigsSection
toolConfigs={toolConfigs}
providerNameSuggestions={providerNameSuggestions}
toolsByProvider={toolsByProvider}
loadingTools={loadingTools}
onFetchTools={() => {
void loadToolNames();
}}
onAddToolConfig={addToolConfig}
onUpdateToolConfig={updateToolConfigAt}
onRemoveToolConfig={removeToolConfig}
/>
<McpProvidersSection
providers={providers}
onAddProvider={addProvider}
onUpdateProviderAt={updateProviderAt}
onRemoveProvider={removeProvider}
onAddProviderArg={addProviderArg}
onUpdateProviderArg={updateProviderArg}
onRemoveProviderArg={removeProviderArg}
onAddProviderEnv={addProviderEnv}
onUpdateProviderEnv={updateProviderEnv}
onRemoveProviderEnv={removeProviderEnv}
/>
</div>
);
}

View file

@ -1,272 +0,0 @@
import { Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { LlmMcpProviderConfig, McpEnvVar } from "../../../types";
import { FieldLabel } from "../../shared/field-label";
type McpProvidersSectionProps = {
providers: LlmMcpProviderConfig[];
onAddProvider: () => void;
onUpdateProviderAt: (
index: number,
patch: Partial<LlmMcpProviderConfig>,
) => void;
onRemoveProvider: (index: number) => void;
onAddProviderArg: (providerIndex: number) => void;
onUpdateProviderArg: (
providerIndex: number,
argIndex: number,
value: string,
) => void;
onRemoveProviderArg: (providerIndex: number, argIndex: number) => void;
onAddProviderEnv: (providerIndex: number) => void;
onUpdateProviderEnv: (
providerIndex: number,
envIndex: number,
patch: Partial<McpEnvVar>,
) => void;
onRemoveProviderEnv: (providerIndex: number, envIndex: number) => void;
};
export function McpProvidersSection({
providers,
onAddProvider,
onUpdateProviderAt,
onRemoveProvider,
onAddProviderArg,
onUpdateProviderArg,
onRemoveProviderArg,
onAddProviderEnv,
onUpdateProviderEnv,
onRemoveProviderEnv,
}: McpProvidersSectionProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<FieldLabel
label="MCP servers"
hint="Server definitions used by tool configs for tool calls."
/>
<Button type="button" size="xs" variant="outline" onClick={onAddProvider}>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
Add MCP server
</Button>
</div>
{providers.length === 0 && (
<p className="text-xs text-muted-foreground">
Add MCP servers to be referenced by tool config providers.
</p>
)}
{providers.map((provider, providerIndex) => {
const args = provider.args && provider.args.length > 0 ? provider.args : [""];
const envVars =
provider.env && provider.env.length > 0
? provider.env
: [{ key: "", value: "" }];
return (
<div
key={provider.id}
className="space-y-3 border-b border-border/40 pb-4 last:border-b-0"
>
<div className="grid gap-2">
<FieldLabel
label="Name"
hint="Unique provider name referenced by tool configs."
/>
<Input
value={provider.name}
placeholder="MCP server name"
onChange={(event) =>
onUpdateProviderAt(providerIndex, { name: event.target.value })
}
/>
</div>
<Tabs
value={provider.provider_type}
onValueChange={(value) =>
onUpdateProviderAt(providerIndex, {
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: value === "stdio" ? "stdio" : "streamable_http",
})
}
>
<TabsList className="w-full">
<TabsTrigger value="stdio">STDIO</TabsTrigger>
<TabsTrigger value="streamable_http">Streamable HTTP</TabsTrigger>
</TabsList>
</Tabs>
{provider.provider_type === "stdio" ? (
<div className="space-y-3">
<div className="grid gap-2">
<FieldLabel
label="Command to launch"
hint="Executable used to start stdio MCP server."
/>
<Input
value={provider.command ?? ""}
placeholder="npx"
onChange={(event) =>
onUpdateProviderAt(providerIndex, {
command: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<FieldLabel
label="Arguments"
hint="CLI args passed to MCP command."
/>
{args.map((arg, argIndex) => (
<div key={`${provider.id}-arg-${argIndex}`} className="flex gap-2">
<Input
value={arg}
placeholder={argIndex === 0 ? "-y" : "argument"}
onChange={(event) =>
onUpdateProviderArg(providerIndex, argIndex, event.target.value)
}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => onRemoveProviderArg(providerIndex, argIndex)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => onAddProviderArg(providerIndex)}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
</Button>
</div>
<div className="space-y-2">
<FieldLabel
label="Environment variables"
hint="Key/value env vars for MCP process."
/>
{envVars.map((item, envIndex) => (
<div
key={`${provider.id}-env-${envIndex}`}
className="grid grid-cols-[1fr_1fr_auto] gap-2"
>
<Input
value={item.key}
placeholder="Key"
onChange={(event) =>
onUpdateProviderEnv(providerIndex, envIndex, {
key: event.target.value,
})
}
/>
<Input
value={item.value}
placeholder="Value"
onChange={(event) =>
onUpdateProviderEnv(providerIndex, envIndex, {
value: event.target.value,
})
}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => onRemoveProviderEnv(providerIndex, envIndex)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => onAddProviderEnv(providerIndex)}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
</Button>
</div>
</div>
) : (
<div className="space-y-2">
<div className="grid gap-2">
<FieldLabel
label="Endpoint"
hint="Streamable HTTP MCP server URL."
/>
<Input
value={provider.endpoint ?? ""}
placeholder="https://example.com/mcp"
onChange={(event) =>
onUpdateProviderAt(providerIndex, {
endpoint: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="API key env (optional)"
hint="Env var name for endpoint auth token."
/>
<Input
value={provider.api_key_env ?? ""}
placeholder="MCP_API_KEY"
onChange={(event) =>
onUpdateProviderAt(providerIndex, {
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="API key (optional)"
hint="Inline token for endpoint auth."
/>
<Input
value={provider.api_key ?? ""}
placeholder="api key"
onChange={(event) =>
onUpdateProviderAt(providerIndex, {
// biome-ignore lint/style/useNamingConvention: api schema
api_key: event.target.value,
})
}
/>
</div>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => onRemoveProvider(providerIndex)}
>
Remove
</Button>
</div>
</div>
);
})}
</div>
);
}

View file

@ -1,182 +0,0 @@
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ChipInput } from "../../../components/chip-input";
import type { LlmToolConfig } from "../../../types";
import { addUnique, collectToolSuggestions } from "./helpers";
import { FieldLabel } from "../../shared/field-label";
type ToolConfigsSectionProps = {
toolConfigs: LlmToolConfig[];
providerNameSuggestions: string[];
toolsByProvider: Record<string, string[]>;
loadingTools: boolean;
onFetchTools: () => void;
onAddToolConfig: () => void;
onUpdateToolConfig: (index: number, patch: Partial<LlmToolConfig>) => void;
onRemoveToolConfig: (index: number) => void;
};
export function ToolConfigsSection({
toolConfigs,
providerNameSuggestions,
toolsByProvider,
loadingTools,
onFetchTools,
onAddToolConfig,
onUpdateToolConfig,
onRemoveToolConfig,
}: ToolConfigsSectionProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<FieldLabel
label="Tool configs"
hint="Map a tool alias to MCP providers and allowed tools."
/>
<div className="flex items-center gap-2">
<Button
type="button"
size="xs"
variant="outline"
disabled={loadingTools}
onClick={onFetchTools}
>
{loadingTools ? "Loading..." : "Fetch MCP tools"}
</Button>
<Button type="button" size="xs" variant="outline" onClick={onAddToolConfig}>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
Add tool config
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Define aliases/providers here. Active alias is selected above.
</p>
{toolConfigs.length === 0 && (
<p className="text-xs text-muted-foreground">
Add at least one tool config to map alias to providers.
</p>
)}
{toolConfigs.map((toolConfig, index) => (
<div
key={toolConfig.id}
className="space-y-3 border-b border-border/40 pb-4 last:border-b-0"
>
<div className="grid gap-2">
<FieldLabel
label="Tool alias"
hint="Alias referenced by LLM column tool_alias."
/>
<Input
value={toolConfig.tool_alias}
placeholder="context7_tools"
onChange={(event) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Providers"
hint="MCP provider names this alias can call."
/>
<ChipInput
values={toolConfig.providers}
suggestions={providerNameSuggestions}
onAdd={(value) =>
onUpdateToolConfig(index, {
providers: addUnique(toolConfig.providers, value),
})
}
onRemove={(providerIndex) =>
onUpdateToolConfig(index, {
providers: toolConfig.providers.filter(
(_, currentIndex) => currentIndex !== providerIndex,
),
})
}
placeholder="Type provider name and press Enter"
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Allow tools (optional)"
hint="Optional allowlist of tool names."
/>
<ChipInput
// biome-ignore lint/style/useNamingConvention: api schema
values={toolConfig.allow_tools ?? []}
suggestions={collectToolSuggestions(toolConfig.providers, toolsByProvider)}
onAdd={(value) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: addUnique(toolConfig.allow_tools ?? [], value),
})
}
onRemove={(toolIndex) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: (toolConfig.allow_tools ?? []).filter(
(_, currentIndex) => currentIndex !== toolIndex,
),
})
}
placeholder="Type tool name and press Enter"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-2">
<FieldLabel
label="Max turns"
hint="Max tool-calling turns before forcing completion."
/>
<Input
value={toolConfig.max_tool_call_turns ?? ""}
onChange={(event) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Timeout sec"
hint="Timeout per tool call."
/>
<Input
value={toolConfig.timeout_sec ?? ""}
onChange={(event) =>
onUpdateToolConfig(index, {
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: event.target.value,
})
}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => onRemoveToolConfig(index)}
>
Remove
</Button>
</div>
</div>
))}
</div>
);
}

View file

@ -1,13 +1,9 @@
import type { LlmMcpProviderConfig, LlmToolConfig } from "../../../types";
import type { LlmMcpProviderConfig } from "../../types";
export function createMcpProviderId(prefix: string, index: number): string {
return `${prefix}-mcp-${Date.now()}-${index + 1}`;
}
export function createToolConfigId(prefix: string, index: number): string {
return `${prefix}-tool-${Date.now()}-${index + 1}`;
}
export function addUnique(items: string[], value: string): string[] {
const trimmed = value.trim();
if (!trimmed || items.includes(trimmed)) {
@ -16,6 +12,32 @@ export function addUnique(items: string[], value: string): string[] {
return [...items, trimmed];
}
export function collectToolSuggestions(
providerNames: string[],
toolsByProvider: Record<string, string[]>,
): string[] {
return Array.from(
new Set(
providerNames.flatMap(
(providerName) => toolsByProvider[providerName.trim()] ?? [],
),
),
);
}
export function isProviderReadyForToolFetch(
provider: LlmMcpProviderConfig,
): boolean {
const hasName = provider.name.trim().length > 0;
if (!hasName) {
return false;
}
if (provider.provider_type === "stdio") {
return (provider.command?.trim().length ?? 0) > 0;
}
return (provider.endpoint?.trim().length ?? 0) > 0;
}
export function toApiProvider(
provider: LlmMcpProviderConfig,
): Record<string, unknown> {
@ -45,43 +67,3 @@ export function toApiProvider(
api_key_env: provider.api_key_env?.trim() || undefined,
};
}
export function collectToolSuggestions(
providerNames: string[],
toolsByProvider: Record<string, string[]>,
): string[] {
return Array.from(
new Set(
providerNames.flatMap((providerName) => {
return toolsByProvider[providerName.trim()] ?? [];
}),
),
);
}
export function isProviderReadyForToolFetch(
provider: LlmMcpProviderConfig,
): boolean {
const hasName = provider.name.trim().length > 0;
if (!hasName) {
return false;
}
if (provider.provider_type === "stdio") {
return (provider.command?.trim().length ?? 0) > 0;
}
return (provider.endpoint?.trim().length ?? 0) > 0;
}
export function resolveLlmToolAlias(
toolConfigs: LlmToolConfig[],
previousAlias: string | undefined,
): string {
const toolAliases = toolConfigs
.map((item) => item.tool_alias.trim())
.filter(Boolean);
const currentAlias = previousAlias?.trim() ?? "";
if (currentAlias && toolAliases.includes(currentAlias)) {
return currentAlias;
}
return toolAliases[0] ?? "";
}

View file

@ -0,0 +1,776 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toastError } from "@/shared/toast";
import {
ArrowRight01Icon,
Delete02Icon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useEffect, useMemo, useState } from "react";
import { listMcpTools } from "../../api";
import { ChipInput } from "../../components/chip-input";
import type { LlmMcpProviderConfig, McpEnvVar, ToolProfileConfig } from "../../types";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
import {
addUnique,
collectToolSuggestions,
createMcpProviderId,
isProviderReadyForToolFetch,
toApiProvider,
} from "./helpers";
type ToolProfileDialogProps = {
config: ToolProfileConfig;
onUpdate: (patch: Partial<ToolProfileConfig>) => void;
};
function EmptyState({
title,
description,
}: {
title: string;
description: string;
}): ReactElement {
return (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/15 px-4 py-5 text-sm">
<p className="font-semibold text-foreground">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
);
}
function isProviderConfigured(provider: LlmMcpProviderConfig): boolean {
const hasName = provider.name.trim().length > 0;
if (!hasName) {
return false;
}
if (provider.provider_type === "stdio") {
return (provider.command?.trim().length ?? 0) > 0;
}
return (provider.endpoint?.trim().length ?? 0) > 0;
}
function McpServerCard({
provider,
index,
toolsCount,
error,
open,
onOpenChange,
onUpdateProviderAt,
onRemoveProvider,
onAddProviderArg,
onUpdateProviderArg,
onRemoveProviderArg,
onAddProviderEnv,
onUpdateProviderEnv,
onRemoveProviderEnv,
}: {
provider: LlmMcpProviderConfig;
index: number;
toolsCount?: number;
error?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onUpdateProviderAt: (
index: number,
patch: Partial<LlmMcpProviderConfig>,
) => void;
onRemoveProvider: (index: number) => void;
onAddProviderArg: (index: number) => void;
onUpdateProviderArg: (index: number, argIndex: number, value: string) => void;
onRemoveProviderArg: (index: number, argIndex: number) => void;
onAddProviderEnv: (index: number) => void;
onUpdateProviderEnv: (
index: number,
envIndex: number,
patch: Partial<McpEnvVar>,
) => void;
onRemoveProviderEnv: (index: number, envIndex: number) => void;
}): ReactElement {
const args = provider.args && provider.args.length > 0 ? provider.args : [""];
const envVars =
provider.env && provider.env.length > 0
? provider.env
: [{ key: "", value: "" }];
const summaryTitle = provider.name.trim() || `MCP server ${index + 1}`;
const transportLabel =
provider.provider_type === "stdio" ? "STDIO" : "Streamable HTTP";
const toolsLabel = typeof toolsCount === "number" ? `${toolsCount} tools` : null;
const description =
provider.provider_type === "stdio"
? "Launches a local MCP process over stdio."
: "Calls a remote MCP endpoint from the backend.";
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<div className="rounded-2xl border border-border/60 bg-background/80">
<div className="flex items-start gap-2 px-4 py-4">
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-3 text-left"
>
<HugeiconsIcon
icon={ArrowRight01Icon}
className={`mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform ${
open ? "rotate-90" : ""
}`}
/>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-foreground">
{summaryTitle}
</p>
<Badge variant="outline" className="rounded-full text-[10px] uppercase">
{transportLabel}
</Badge>
{toolsLabel ? (
<Badge variant="secondary" className="rounded-full text-[10px]">
{toolsLabel}
</Badge>
) : null}
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
</button>
</CollapsibleTrigger>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => onRemoveProvider(index)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
<CollapsibleContent className="space-y-4 border-t border-border/50 px-4 pt-4 pb-4">
{error && (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
<div className="grid gap-2">
<FieldLabel label="Server name" hint="Unique name inside this tool profile." />
<Input
className="nodrag"
value={provider.name}
placeholder="context7"
onChange={(event) =>
onUpdateProviderAt(index, { name: event.target.value })
}
/>
</div>
<Tabs
value={provider.provider_type}
onValueChange={(value) =>
onUpdateProviderAt(index, {
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: value === "stdio" ? "stdio" : "streamable_http",
})
}
>
<TabsList className="w-full">
<TabsTrigger value="stdio">STDIO</TabsTrigger>
<TabsTrigger value="streamable_http">Streamable HTTP</TabsTrigger>
</TabsList>
</Tabs>
{provider.provider_type === "stdio" ? (
<div className="space-y-4">
<div className="grid gap-2">
<FieldLabel label="Command" hint="Executable used to start the MCP server." />
<Input
className="nodrag"
value={provider.command ?? ""}
placeholder="npx"
onChange={(event) =>
onUpdateProviderAt(index, { command: event.target.value })
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<FieldLabel label="Args" hint="Optional CLI args." />
<Button
type="button"
size="xs"
variant="outline"
onClick={() => onAddProviderArg(index)}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
Add arg
</Button>
</div>
{args.map((arg, argIndex) => (
<div key={`${provider.id}-arg-${argIndex}`} className="flex gap-2">
<Input
className="nodrag"
value={arg}
placeholder={argIndex === 0 ? "-y" : "argument"}
onChange={(event) =>
onUpdateProviderArg(index, argIndex, event.target.value)
}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => onRemoveProviderArg(index, argIndex)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<FieldLabel label="Env vars" hint="Optional process env." />
<Button
type="button"
size="xs"
variant="outline"
onClick={() => onAddProviderEnv(index)}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
Add env
</Button>
</div>
{envVars.map((item, envIndex) => (
<div
key={`${provider.id}-env-${envIndex}`}
className="grid grid-cols-[1fr_1fr_auto] gap-2"
>
<Input
className="nodrag"
value={item.key}
placeholder="KEY"
onChange={(event) =>
onUpdateProviderEnv(index, envIndex, {
key: event.target.value,
})
}
/>
<Input
className="nodrag"
value={item.value}
placeholder="value"
onChange={(event) =>
onUpdateProviderEnv(index, envIndex, {
value: event.target.value,
})
}
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => onRemoveProviderEnv(index, envIndex)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid gap-2">
<FieldLabel label="Endpoint" hint="Backend calls this MCP URL." />
<Input
className="nodrag"
value={provider.endpoint ?? ""}
placeholder="https://example.com/mcp"
onChange={(event) =>
onUpdateProviderAt(index, { endpoint: event.target.value })
}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<FieldLabel
label="API key env"
hint="Optional env var used on the backend."
/>
<Input
className="nodrag"
value={provider.api_key_env ?? ""}
placeholder="MCP_API_KEY"
onChange={(event) =>
onUpdateProviderAt(index, {
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="API key"
hint="Optional inline token."
/>
<Input
className="nodrag"
value={provider.api_key ?? ""}
placeholder="token"
onChange={(event) =>
onUpdateProviderAt(index, {
// biome-ignore lint/style/useNamingConvention: api schema
api_key: event.target.value,
})
}
/>
</div>
</div>
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
export function ToolProfileDialog({
config,
onUpdate,
}: ToolProfileDialogProps): ReactElement {
const providers = config.mcp_providers;
const [loadingTools, setLoadingTools] = useState(false);
const [toolsByProvider, setToolsByProvider] = useState<Record<string, string[]>>(
{},
);
const [providerErrors, setProviderErrors] = useState<Record<string, string>>({});
const [duplicateTools, setDuplicateTools] = useState<Record<string, string[]>>({});
const [openProviders, setOpenProviders] = useState<Record<string, boolean>>({});
const providerSignature = useMemo(
() =>
JSON.stringify(
providers.map((provider) => ({
name: provider.name,
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: provider.provider_type,
command: provider.command,
args: provider.args,
env: provider.env,
endpoint: provider.endpoint,
// biome-ignore lint/style/useNamingConvention: api schema
api_key: provider.api_key,
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: provider.api_key_env,
})),
),
[providers],
);
useEffect(() => {
setToolsByProvider({});
setProviderErrors({});
setDuplicateTools({});
}, [providerSignature]);
useEffect(() => {
setOpenProviders((current) => {
const next: Record<string, boolean> = {};
for (const provider of providers) {
next[provider.id] =
current[provider.id] ?? !isProviderConfigured(provider);
}
return next;
});
}, [providers]);
function updateProviders(nextProviders: LlmMcpProviderConfig[]): void {
onUpdate({
// biome-ignore lint/style/useNamingConvention: ui schema
mcp_providers: nextProviders,
});
}
function updateProviderAt(
index: number,
patch: Partial<LlmMcpProviderConfig>,
): void {
updateProviders(
providers.map((provider, currentIndex) =>
currentIndex === index ? { ...provider, ...patch } : provider,
),
);
}
function mutateProviderAt(
index: number,
mapProvider: (provider: LlmMcpProviderConfig) => Partial<LlmMcpProviderConfig>,
): void {
const provider = providers[index];
if (!provider) {
return;
}
updateProviderAt(index, mapProvider(provider));
}
function removeProvider(index: number): void {
updateProviders(providers.filter((_, currentIndex) => currentIndex !== index));
}
function addProvider(): void {
updateProviders([
...providers,
{
id: createMcpProviderId(config.id, providers.length),
name: "",
// biome-ignore lint/style/useNamingConvention: ui schema
provider_type: "stdio",
command: "",
args: [],
env: [],
endpoint: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: "",
},
]);
}
function addProviderArg(providerIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
args: [...(provider.args ?? []), ""],
}));
}
function updateProviderArg(
providerIndex: number,
argIndex: number,
value: string,
): void {
mutateProviderAt(providerIndex, (provider) => {
const nextArgs =
provider.args && provider.args.length > 0 ? [...provider.args] : [""];
nextArgs[argIndex] = value;
return { args: nextArgs };
});
}
function removeProviderArg(providerIndex: number, argIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
args: (provider.args ?? []).filter((_, currentIndex) => currentIndex !== argIndex),
}));
}
function addProviderEnv(providerIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
env: [...(provider.env ?? []), { key: "", value: "" }],
}));
}
function updateProviderEnv(
providerIndex: number,
envIndex: number,
patch: Partial<McpEnvVar>,
): void {
mutateProviderAt(providerIndex, (provider) => ({
env: (
provider.env && provider.env.length > 0
? provider.env
: [{ key: "", value: "" }]
).map((item, currentIndex) =>
currentIndex === envIndex ? { ...item, ...patch } : item,
),
}));
}
function removeProviderEnv(providerIndex: number, envIndex: number): void {
mutateProviderAt(providerIndex, (provider) => ({
env: (provider.env ?? []).filter((_, currentIndex) => currentIndex !== envIndex),
}));
}
async function loadTools(): Promise<void> {
const readyProviders = providers.filter(isProviderReadyForToolFetch);
if (readyProviders.length === 0) {
toastError(
"No MCP servers ready",
"Add a server name plus command or endpoint first.",
);
return;
}
setLoadingTools(true);
try {
const timeoutRaw = config.timeout_sec?.trim();
const timeoutSec =
timeoutRaw && Number.isFinite(Number(timeoutRaw))
? Number(timeoutRaw)
: 15;
const response = await listMcpTools({
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: readyProviders.map(toApiProvider),
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: timeoutSec,
});
setToolsByProvider(
Object.fromEntries(
response.providers
.filter((provider) => provider.name.trim())
.map((provider) => [provider.name.trim(), provider.tools]),
),
);
setProviderErrors(
Object.fromEntries(
response.providers
.filter((provider) => provider.name.trim() && provider.error)
.map((provider) => [provider.name.trim(), provider.error ?? "Failed to load tools."]),
),
);
setDuplicateTools(response.duplicate_tools ?? {});
} catch (error) {
toastError(
"Failed to load tools",
error instanceof Error ? error.message : "Could not load MCP tools.",
);
} finally {
setLoadingTools(false);
}
}
const providerNames = useMemo(
() =>
Array.from(
new Set(providers.map((provider) => provider.name.trim()).filter(Boolean)),
),
[providers],
);
const availableTools = useMemo(
() => collectToolSuggestions(providerNames, toolsByProvider),
[providerNames, toolsByProvider],
);
const hasProviders = providers.length > 0;
return (
<Tabs defaultValue="profile" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="servers">MCP servers</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4 pt-3">
<NameField
label="Tool profile name"
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
{!hasProviders ? (
<EmptyState
title="Add MCP server to configure tools"
description="This profile becomes useful after at least one MCP server is configured in the MCP servers tab."
/>
) : (
<>
<div className="space-y-2">
<FieldLabel
label="Configured servers"
hint="All servers in this profile are available to any LLM using this tool profile."
/>
<div className="flex flex-wrap gap-2">
{providerNames.map((providerName) => (
<Badge key={providerName} variant="secondary" className="rounded-full">
{providerName}
</Badge>
))}
</div>
</div>
<div className="space-y-3 rounded-2xl border border-border/60 bg-muted/10 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">
Available tool refs
</p>
<p className="text-xs text-muted-foreground">
Load tools from backend so users pick tool names instead of guessing.
</p>
</div>
<Button
type="button"
size="xs"
variant="outline"
disabled={loadingTools}
onClick={() => {
void loadTools();
}}
>
{loadingTools ? "Loading..." : "Load tools"}
</Button>
</div>
{Object.keys(toolsByProvider).length === 0 &&
Object.keys(providerErrors).length === 0 && (
<p className="text-xs text-muted-foreground">
No tools loaded yet.
</p>
)}
{Object.entries(toolsByProvider).map(([providerName, toolNames]) => (
<div key={providerName} className="space-y-2">
<div className="flex items-center gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{providerName}
</p>
<Badge variant="outline" className="rounded-full text-[10px]">
{toolNames.length}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
{toolNames.map((toolName) => (
<Badge key={`${providerName}-${toolName}`} variant="secondary">
{toolName}
</Badge>
))}
</div>
</div>
))}
{Object.entries(duplicateTools).length > 0 && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
Duplicate tool names across servers:
{" "}
{Object.entries(duplicateTools)
.map(([toolName, providerList]) => `${toolName} (${providerList.join(", ")})`)
.join("; ")}
</div>
)}
</div>
<div className="grid gap-2">
<FieldLabel
label="Allow tools (optional)"
hint="Leave empty to allow all tools from configured MCP servers."
/>
<ChipInput
values={config.allow_tools ?? []}
suggestions={availableTools}
onAdd={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: addUnique(config.allow_tools ?? [], value),
})
}
onRemove={(toolIndex) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: (config.allow_tools ?? []).filter(
(_, currentIndex) => currentIndex !== toolIndex,
),
})
}
placeholder="Type tool name and press Enter"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<FieldLabel
label="Max tool call turns"
hint="Required. Data Designer defaults to 5."
/>
<Input
className="nodrag"
value={config.max_tool_call_turns ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Timeout sec"
hint="Optional. Applies to MCP tool loading and calls."
/>
<Input
className="nodrag"
value={config.timeout_sec ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: event.target.value,
})
}
/>
</div>
</div>
</>
)}
</TabsContent>
<TabsContent value="servers" className="space-y-4 pt-3">
<div className="flex items-center justify-between gap-3">
<FieldLabel
label="MCP servers"
hint="These server defs are owned by this tool profile and reused by linked LLMs."
/>
<Button type="button" size="xs" variant="outline" onClick={addProvider}>
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
Add MCP server
</Button>
</div>
{!hasProviders ? (
<EmptyState
title="No MCP servers yet"
description="Add one or more servers here. Then go back to Profile to load and pick tools."
/>
) : (
<div className="space-y-3">
{providers.map((provider, index) => (
<McpServerCard
key={provider.id}
provider={provider}
index={index}
toolsCount={
provider.name.trim()
? (toolsByProvider[provider.name.trim()] ?? []).length
: undefined
}
error={provider.name.trim() ? providerErrors[provider.name.trim()] : undefined}
open={openProviders[provider.id] ?? !isProviderConfigured(provider)}
onOpenChange={(open) =>
setOpenProviders((current) => ({
...current,
[provider.id]: open,
}))
}
onUpdateProviderAt={updateProviderAt}
onRemoveProvider={removeProvider}
onAddProviderArg={addProviderArg}
onUpdateProviderArg={updateProviderArg}
onRemoveProviderArg={removeProviderArg}
onAddProviderEnv={addProviderEnv}
onUpdateProviderEnv={updateProviderEnv}
onRemoveProviderEnv={removeProviderEnv}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
);
}

View file

@ -67,6 +67,7 @@ type UseRecipeEditorGraphArgs = {
addLlmNode: (type: LlmType, position?: XYPosition, openDialog?: boolean) => void;
addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
addToolProfileNode: (position?: XYPosition, openDialog?: boolean) => void;
addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void;
addValidatorNode: (
type: "validator_python" | "validator_sql" | "validator_oxc",
@ -90,6 +91,7 @@ type UseRecipeEditorGraphResult = {
handleAddLlmFromSheet: (type: LlmType) => void;
handleAddModelProviderFromSheet: () => void;
handleAddModelConfigFromSheet: () => void;
handleAddToolProfileFromSheet: () => void;
handleAddExpressionFromSheet: () => void;
handleAddValidatorFromSheet: (
type: "validator_python" | "validator_sql" | "validator_oxc",
@ -113,6 +115,7 @@ export function useRecipeEditorGraph({
addLlmNode,
addModelProviderNode,
addModelConfigNode,
addToolProfileNode,
addExpressionNode,
addValidatorNode,
addMarkdownNoteNode,
@ -231,6 +234,10 @@ export function useRecipeEditorGraph({
addModelConfigNode(position, false);
return;
}
if (payload.type === "tool_config") {
addToolProfileNode(position, false);
return;
}
addLlmNode(payload.type as LlmType, position, false);
},
[
@ -239,6 +246,7 @@ export function useRecipeEditorGraph({
addMarkdownNoteNode,
addModelConfigNode,
addModelProviderNode,
addToolProfileNode,
addSamplerNode,
addSeedNode,
addValidatorNode,
@ -290,6 +298,10 @@ export function useRecipeEditorGraph({
addExpressionNode(getViewportCenterPosition());
}, [addExpressionNode, getViewportCenterPosition]);
const handleAddToolProfileFromSheet = useCallback(() => {
addToolProfileNode(getViewportCenterPosition());
}, [addToolProfileNode, getViewportCenterPosition]);
const handleAddValidatorFromSheet = useCallback(
(type: "validator_python" | "validator_sql" | "validator_oxc") => {
addValidatorNode(type, getViewportCenterPosition());
@ -313,6 +325,7 @@ export function useRecipeEditorGraph({
handleAddLlmFromSheet,
handleAddModelProviderFromSheet,
handleAddModelConfigFromSheet,
handleAddToolProfileFromSheet,
handleAddExpressionFromSheet,
handleAddValidatorFromSheet,
handleAddMarkdownNoteFromSheet,

View file

@ -7,6 +7,7 @@ import {
EqualSignIcon,
FingerPrintIcon,
FunctionIcon,
Plug01Icon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
@ -78,6 +79,9 @@ function resolveExecutionColumnIcon(config: NodeConfig | null): IconType {
if (config.kind === "model_config") {
return Plant01Icon;
}
if (config.kind === "tool_config") {
return Plug01Icon;
}
return PencilEdit02Icon;
}

View file

@ -104,6 +104,7 @@ export function RecipeStudioPage({
addLlmNode,
addModelProviderNode,
addModelConfigNode,
addToolProfileNode,
addExpressionNode,
addValidatorNode,
addMarkdownNoteNode,
@ -141,6 +142,7 @@ export function RecipeStudioPage({
addLlmNode: state.addLlmNode,
addModelProviderNode: state.addModelProviderNode,
addModelConfigNode: state.addModelConfigNode,
addToolProfileNode: state.addToolProfileNode,
addExpressionNode: state.addExpressionNode,
addValidatorNode: state.addValidatorNode,
addMarkdownNoteNode: state.addMarkdownNoteNode,
@ -191,6 +193,7 @@ export function RecipeStudioPage({
handleAddLlmFromSheet,
handleAddModelProviderFromSheet,
handleAddModelConfigFromSheet,
handleAddToolProfileFromSheet,
handleAddExpressionFromSheet,
handleAddValidatorFromSheet,
handleAddMarkdownNoteFromSheet,
@ -210,6 +213,7 @@ export function RecipeStudioPage({
addLlmNode,
addModelProviderNode,
addModelConfigNode,
addToolProfileNode,
addExpressionNode,
addValidatorNode,
addMarkdownNoteNode,
@ -584,10 +588,11 @@ export function RecipeStudioPage({
onOpenChange={setBlockSheetOpen}
onAddSampler={handleAddSamplerFromSheet}
onAddSeed={handleAddSeedFromSheet}
onAddLlm={handleAddLlmFromSheet}
onAddModelProvider={handleAddModelProviderFromSheet}
onAddModelConfig={handleAddModelConfigFromSheet}
onAddExpression={handleAddExpressionFromSheet}
onAddLlm={handleAddLlmFromSheet}
onAddModelProvider={handleAddModelProviderFromSheet}
onAddModelConfig={handleAddModelConfigFromSheet}
onAddToolProfile={handleAddToolProfileFromSheet}
onAddExpression={handleAddExpressionFromSheet}
onAddValidator={handleAddValidatorFromSheet}
onAddMarkdownNote={handleAddMarkdownNoteFromSheet}
onOpenProcessors={openProcessorsFromSheet}
@ -651,6 +656,7 @@ export function RecipeStudioPage({
categoryOptions={dialogOptions.categoryOptions}
modelConfigAliases={dialogOptions.modelConfigAliases}
modelProviderOptions={dialogOptions.modelProviderOptions}
toolProfileAliases={dialogOptions.toolProfileAliases}
datetimeOptions={dialogOptions.datetimeOptions}
onUpdate={updateConfig}
container={sheetContainer}

View file

@ -195,6 +195,41 @@ export function syncEdgesForConfigPatch(
}
}
const hasToolAliasPatch = Object.prototype.hasOwnProperty.call(
patch,
"tool_alias",
);
if (current.kind === "llm" && hasToolAliasPatch) {
const nextAlias =
(patch as Partial<NodeConfig> & { tool_alias?: string }).tool_alias ?? "";
if (nextAlias.trim() === (current.tool_alias ?? "").trim()) {
return nextEdges;
}
nextEdges = removeTargetEdgesBySource(
nextEdges,
configs,
current.id,
(source) => Boolean(source && source.kind === "tool_config"),
);
if (nextAlias) {
const toolConfigId = findNodeIdByName(configs, nextAlias);
if (toolConfigId) {
const result = applyRecipeConnection(
{
source: toolConfigId,
sourceHandle: HANDLE_IDS.semanticOut,
target: current.id,
targetHandle: HANDLE_IDS.semanticIn,
},
configs,
nextEdges,
layoutDirection,
);
nextEdges = result.edges;
}
}
}
const hasValidatorTargetsPatch = Object.prototype.hasOwnProperty.call(
patch,
"target_columns",

View file

@ -86,6 +86,15 @@ function isConfigToLlmEdge(
return source?.kind === "model_config" && target?.kind === "llm";
}
function isToolConfigToLlmEdge(
edge: Edge,
configs: Record<string, NodeConfig>,
): boolean {
const source = configs[edge.source];
const target = configs[edge.target];
return source?.kind === "tool_config" && target?.kind === "llm";
}
function usageKey(nodeId: string, handleId: string): string {
return `${nodeId}::${handleId}`;
}
@ -263,9 +272,11 @@ export function optimizeModelInfraEdgeHandles(
const sourceHandleBefore = normalizeRecipeHandleId(edge.sourceHandle);
const targetHandleBefore = normalizeRecipeHandleId(edge.targetHandle);
const isModelSemantic =
isProviderToConfigEdge(edge, configs) || isConfigToLlmEdge(edge, configs);
if (!isModelSemantic) {
const isSemanticInfra =
isProviderToConfigEdge(edge, configs) ||
isConfigToLlmEdge(edge, configs) ||
isToolConfigToLlmEdge(edge, configs);
if (!isSemanticInfra) {
nextEdges.push(edge);
continue;
}
@ -340,6 +351,7 @@ export function centerModelInfraNodes(
): RecipeNode[] {
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
const configToLlmIds = new Map<string, string[]>();
const toolConfigToLlmIds = new Map<string, string[]>();
const providerToConfigIds = new Map<string, string[]>();
for (const edge of edges) {
@ -357,6 +369,14 @@ export function centerModelInfraNodes(
entries.push(edge.target);
}
configToLlmIds.set(edge.source, entries);
continue;
}
if (isToolConfigToLlmEdge(edge, configs)) {
const entries = toolConfigToLlmIds.get(edge.source) ?? [];
if (!entries.includes(edge.target)) {
entries.push(edge.target);
}
toolConfigToLlmIds.set(edge.source, entries);
}
}
@ -370,6 +390,9 @@ export function centerModelInfraNodes(
(config) => config.kind === "model_provider" && nodesById.has(config.id),
)
.map((config) => config.id);
const toolConfigIds = Object.values(configs)
.filter((config) => config.kind === "tool_config" && nodesById.has(config.id))
.map((config) => config.id);
const occupiedById = new Map(
nodes.map((node) => [node.id, toRect(node)] as const),
@ -444,5 +467,27 @@ export function centerModelInfraNodes(
placeNode(modelProviderId, preferred);
}
for (const toolConfigId of toolConfigIds) {
const llmIds = toolConfigToLlmIds.get(toolConfigId) ?? [];
const targetBounds = collectBounds(llmIds, nodesById);
const toolConfigNode = nodesById.get(toolConfigId);
if (!(targetBounds && toolConfigNode)) {
continue;
}
const width = readNodeWidth(toolConfigNode) ?? DEFAULT_NODE_WIDTH;
const height = readNodeHeight(toolConfigNode) ?? DEFAULT_NODE_HEIGHT;
const preferred =
direction === "LR"
? {
x: (targetBounds.minX + targetBounds.maxX) / 2 - width / 2,
y: targetBounds.minY - height - clusterGap,
}
: {
x: targetBounds.minX - width - clusterGap,
y: (targetBounds.minY + targetBounds.maxY) / 2 - height / 2,
};
placeNode(toolConfigId, preferred);
}
return nodes.map((node) => nodesById.get(node.id) ?? node);
}

View file

@ -83,6 +83,10 @@ export function applyRenameToConfig(
const base = next as LlmConfig;
next = { ...base, model_alias: to };
}
if (config.kind === "llm" && config.tool_alias === from) {
const base = next as LlmConfig;
next = { ...base, tool_alias: to };
}
if (config.kind === "validator") {
const targets = config.target_columns ?? [];
if (targets.includes(from)) {
@ -136,6 +140,10 @@ export function applyRemovalToConfig(
const base = next as LlmConfig;
next = { ...base, model_alias: "" };
}
if (config.kind === "llm" && config.tool_alias === ref) {
const base = next as LlmConfig;
next = { ...base, tool_alias: "" };
}
if (config.kind === "validator") {
const targets = (config.target_columns ?? []).filter((target) => target !== ref);
if (targets.length !== (config.target_columns ?? []).length) {

View file

@ -95,6 +95,7 @@ type RecipeStudioState = {
addLlmNode: (type: LlmType, position?: XYPosition, openDialog?: boolean) => void;
addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
addToolProfileNode: (position?: XYPosition, openDialog?: boolean) => void;
addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void;
addValidatorNode: (
type: "validator_python" | "validator_sql" | "validator_oxc",
@ -249,7 +250,8 @@ function isModelSemanticEdge(edge: Edge, configs: Record<string, NodeConfig>): b
source &&
target &&
((source.kind === "model_provider" && target.kind === "model_config") ||
(source.kind === "model_config" && target.kind === "llm")),
(source.kind === "model_config" && target.kind === "llm") ||
(source.kind === "tool_config" && target.kind === "llm")),
);
}
@ -534,6 +536,49 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
}
return { ...added, nodes, edges, configs };
}),
addToolProfileNode: (position, openDialog = true) =>
set((state) => {
if (state.executionLocked) {
return state;
}
const added = buildAddedNodeState(
state,
"llm",
"tool_config",
position,
openDialog,
);
const context = getAddedNodeContext(added);
if (!context) {
return added;
}
let { nodes, configs } = context;
let edges = state.edges;
const unboundLlms = Object.values(configs).filter(
(config) => config.kind === "llm" && !(config.tool_alias?.trim()),
);
if (!position && unboundLlms.length > 0) {
nodes = placeNodeNear(
nodes,
context.newNodeId,
unboundLlms[0].id,
state.layoutDirection,
"before",
);
}
if (unboundLlms.length === 1) {
const next = connectSemantic(
edges,
configs,
context.newNodeId,
unboundLlms[0].id,
state.layoutDirection,
);
edges = next.edges;
configs = next.configs;
}
return { ...added, nodes, edges, configs };
}),
addExpressionNode: (position, openDialog = true) =>
set((state) => {
if (state.executionLocked) {

View file

@ -48,7 +48,8 @@ export type RecipeNodeData = {
| "seed"
| "note"
| "model_provider"
| "model_config";
| "model_config"
| "tool_config";
subtype: string;
blockType:
| SamplerType
@ -60,7 +61,8 @@ export type RecipeNodeData = {
| "seed"
| "markdown_note"
| "model_provider"
| "model_config";
| "model_config"
| "tool_config";
layoutDirection?: LayoutDirection;
runtimeState?: "idle" | "running" | "done";
executionLocked?: boolean;
@ -173,6 +175,20 @@ export type LlmToolConfig = {
timeout_sec?: string;
};
export type ToolProfileConfig = {
id: string;
kind: "tool_config";
name: string;
// biome-ignore lint/style/useNamingConvention: ui schema
mcp_providers: LlmMcpProviderConfig[];
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools?: string[];
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns?: string;
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec?: string;
};
export type LlmImageContextConfig = {
enabled: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
@ -201,10 +217,6 @@ export type LlmConfig = {
output_format?: string;
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias?: string;
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs?: LlmToolConfig[];
// biome-ignore lint/style/useNamingConvention: ui schema
mcp_providers?: LlmMcpProviderConfig[];
scores?: Score[];
// ui-only, serialized into multi_modal_context for DataDesigner
// biome-ignore lint/style/useNamingConvention: ui schema
@ -349,4 +361,5 @@ export type NodeConfig =
| MarkdownNoteConfig
| SeedConfig
| ModelProviderConfig
| ModelConfig;
| ModelConfig
| ToolProfileConfig;

View file

@ -10,6 +10,7 @@ import type {
SeedSourceType,
SamplerConfig,
SamplerType,
ToolProfileConfig,
ValidatorCodeLang,
ValidatorType,
ValidatorConfig,
@ -203,10 +204,6 @@ export function makeLlmConfig(
llmType === "structured" ? '{\n "field": "string"\n}' : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: "",
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs: [],
// biome-ignore lint/style/useNamingConvention: ui schema
mcp_providers: [],
// biome-ignore lint/style/useNamingConvention: ui schema
image_context: {
enabled: false,
@ -268,6 +265,25 @@ export function makeModelConfig(
};
}
export function makeToolProfileConfig(
id: string,
existing: NodeConfig[],
): ToolProfileConfig {
return {
id,
kind: "tool_config",
name: nextName(existing, "tools"),
// biome-ignore lint/style/useNamingConvention: ui schema
mcp_providers: [],
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: [],
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: "5",
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: "",
};
}
export function makeExpressionConfig(
id: string,
existing: NodeConfig[],

View file

@ -58,7 +58,11 @@ function syncSubcategoryMapping(
}
function isModelInfraNode(config: NodeConfig): boolean {
return config.kind === "model_provider" || config.kind === "model_config";
return (
config.kind === "model_provider" ||
config.kind === "model_config" ||
config.kind === "tool_config"
);
}
function isSemanticLane(connection: Connection): boolean {
@ -80,6 +84,7 @@ function isDataLane(connection: Connection): boolean {
type SingleRefRelation =
| "provider"
| "model_alias"
| "tool_alias"
| "reference_column_name"
| "subcategory_parent"
| "validator_target_columns";
@ -94,6 +99,9 @@ function getSingleRefRelation(
if (source.kind === "model_config" && target.kind === "llm") {
return "model_alias";
}
if (source.kind === "tool_config" && target.kind === "llm") {
return "tool_alias";
}
if (
source.kind === "sampler" &&
source.sampler_type === "datetime" &&
@ -134,6 +142,9 @@ function isCompetingIncomingEdge(
if (relation === "model_alias") {
return source.kind === "model_config";
}
if (relation === "tool_alias") {
return source.kind === "tool_config";
}
if (relation === "subcategory_parent") {
return isCategoryConfig(source);
}
@ -146,7 +157,8 @@ function isCompetingIncomingEdge(
function isModelSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
return (
(source.kind === "model_provider" && target.kind === "model_config") ||
(source.kind === "model_config" && target.kind === "llm")
(source.kind === "model_config" && target.kind === "llm") ||
(source.kind === "tool_config" && target.kind === "llm")
);
}
@ -378,6 +390,10 @@ export function applyRecipeConnection(
const next = { ...target, model_alias: source.name };
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (source.kind === "tool_config" && target.kind === "llm") {
const next = { ...target, tool_alias: source.name };
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (
source.kind === "sampler" &&
source.sampler_type === "datetime" &&

View file

@ -10,6 +10,9 @@ export function isSemanticRelation(
if (source.kind === "model_config" && target.kind === "llm") {
return true;
}
if (source.kind === "tool_config" && target.kind === "llm") {
return true;
}
if (
source.kind === "llm" &&
source.llm_type === "code" &&

View file

@ -22,6 +22,7 @@ const DONE_UPSTREAM_KINDS: ReadonlySet<NodeConfig["kind"]> = new Set([
"llm",
"model_config",
"model_provider",
"tool_config",
]);
export type GraphRuntimeVisualState = {

View file

@ -20,6 +20,9 @@ function isSemanticConnection(source: NodeConfig, target: NodeConfig): boolean {
if (source.kind === "model_config" && target.kind === "llm") {
return true;
}
if (source.kind === "tool_config" && target.kind === "llm") {
return true;
}
if (
source.kind === "llm" &&
source.llm_type === "code" &&
@ -163,6 +166,9 @@ export function buildEdges(
if (config.kind === "llm" && config.model_alias) {
addEdgeByName(config.model_alias, config.name);
}
if (config.kind === "llm" && config.tool_alias) {
addEdgeByName(config.tool_alias, config.name);
}
if (config.kind === "validator") {
for (const targetColumn of config.target_columns ?? []) {
if (targetColumn.trim()) {

View file

@ -8,6 +8,7 @@ import type {
SeedConfig,
SamplerConfig,
SeedSourceType,
ToolProfileConfig,
ValidatorConfig,
} from "../../types";
import { buildEdges } from "./edges";
@ -216,15 +217,6 @@ function parseToolConfigs(input: unknown): Map<string, LlmToolConfig> {
return toolConfigs;
}
function cloneToolConfig(config: LlmToolConfig): LlmToolConfig {
return {
...config,
providers: [...config.providers],
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: [...(config.allow_tools ?? [])],
};
}
function cloneMcpProvider(config: LlmMcpProviderConfig): LlmMcpProviderConfig {
return {
...config,
@ -296,28 +288,28 @@ function applyAdvancedOpen(
config.advancedOpen = advancedOpenByNode[config.name] === true;
}
function attachLlmTooling(
config: LlmConfig,
function buildToolProfileConfig(
toolConfig: LlmToolConfig,
toolConfigsByAlias: Map<string, LlmToolConfig>,
mcpProvidersByName: Map<string, LlmMcpProviderConfig>,
): void {
const toolAlias = config.tool_alias?.trim();
if (!toolAlias) {
config.tool_alias = "";
config.tool_configs = [];
config.mcp_providers = [];
return;
}
const toolConfig = toolConfigsByAlias.get(toolAlias);
if (!toolConfig) {
config.tool_configs = [];
config.mcp_providers = [];
return;
}
config.tool_configs = [cloneToolConfig(toolConfig)];
config.mcp_providers = toolConfig.providers
.map((providerName) => mcpProvidersByName.get(providerName))
.flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : []));
id: string,
): ToolProfileConfig {
const canonical = toolConfigsByAlias.get(toolConfig.tool_alias) ?? toolConfig;
return {
id,
kind: "tool_config",
name: canonical.tool_alias,
// biome-ignore lint/style/useNamingConvention: ui schema
mcp_providers: canonical.providers
.map((providerName) => mcpProvidersByName.get(providerName))
.flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : [])),
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: [...(canonical.allow_tools ?? [])],
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: canonical.max_tool_call_turns ?? "5",
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: canonical.timeout_sec ?? "",
};
}
export function importRecipePayload(input: string): ImportResult {
@ -471,6 +463,23 @@ export function importRecipePayload(input: string): ImportResult {
});
}
for (const toolConfig of toolConfigsByAlias.values()) {
const id = `n${nextId}`;
nextId += 1;
const config = buildToolProfileConfig(
toolConfig,
toolConfigsByAlias,
mcpProvidersByName,
id,
);
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
continue;
}
nameToId.set(config.name, config.id);
configs.push(config);
}
recipe.columns.forEach((column, index) => {
if (!isRecord(column)) {
errors.push(`Column ${index + 1}: invalid object.`);
@ -482,9 +491,6 @@ export function importRecipePayload(input: string): ImportResult {
if (!config) {
return;
}
if (config.kind === "llm") {
attachLlmTooling(config, toolConfigsByAlias, mcpProvidersByName);
}
applyAdvancedOpen(config, uiAdvancedOpenByNode);
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);

View file

@ -6,6 +6,7 @@ export {
makeModelProviderConfig,
makeSamplerConfig,
makeSeedConfig,
makeToolProfileConfig,
makeValidatorConfig,
} from "./config-factories";
export {

View file

@ -97,6 +97,17 @@ export function nodeDataFromConfig(
layoutDirection,
};
}
if (config.kind === "tool_config") {
const providerCount = config.mcp_providers.length;
return {
title: "Tool Profile",
kind: "tool_config",
subtype: providerCount === 1 ? "1 MCP server" : `${providerCount} MCP servers`,
blockType: "tool_config",
name: config.name,
layoutDirection,
};
}
return {
title: "LLM",
kind: "llm",

View file

@ -24,14 +24,13 @@ import { readNodeWidth } from "../rf-node-dimensions";
import {
buildExpressionColumn,
buildLlmColumn,
buildLlmMcpProvider,
buildLlmToolConfig,
buildModelConfig,
buildModelProvider,
buildProcessors,
buildSamplerColumn,
buildSeedConfig,
buildSeedDropProcessor,
buildToolProfilePayload,
buildValidatorColumn,
pickFirstSeedConfig,
} from "./builders";
@ -169,34 +168,6 @@ export function buildRecipePayload(
}
}
columns.push(buildLlmColumn(config, errors));
for (const provider of config.mcp_providers ?? []) {
const builtProvider = buildLlmMcpProvider(provider, errors);
if (!builtProvider) {
continue;
}
pushUniqueJson(
"MCP provider",
String(builtProvider.name),
builtProvider,
mcpProviderJsonByName,
mcpProviders,
errors,
);
}
for (const toolConfig of config.tool_configs ?? []) {
const builtToolConfig = buildLlmToolConfig(toolConfig, errors);
if (!builtToolConfig) {
continue;
}
pushUniqueJson(
"Tool config",
String(builtToolConfig.tool_alias),
builtToolConfig,
toolConfigJsonByAlias,
toolConfigs,
errors,
);
}
if (config.model_alias) {
modelAliases.add(config.model_alias);
}
@ -230,6 +201,30 @@ export function buildRecipePayload(
modelProviderConfigs.push(config);
continue;
}
if (config.kind === "tool_config") {
const built = buildToolProfilePayload(config, errors);
for (const provider of built.mcp_providers) {
pushUniqueJson(
"MCP provider",
String(provider.name),
provider,
mcpProviderJsonByName,
mcpProviders,
errors,
);
}
if (built.tool_config) {
pushUniqueJson(
"Tool config",
String(built.tool_config.tool_alias),
built.tool_config,
toolConfigJsonByAlias,
toolConfigs,
errors,
);
}
continue;
}
modelConfigs.push(buildModelConfig(config, errors));
modelConfigConfigs.push(config);
}

View file

@ -1,4 +1,9 @@
import type { LlmConfig, LlmMcpProviderConfig, LlmToolConfig } from "../../types";
import type {
LlmConfig,
LlmMcpProviderConfig,
LlmToolConfig,
ToolProfileConfig,
} from "../../types";
function buildImageContext(
config: LlmConfig,
@ -200,3 +205,40 @@ export function buildLlmToolConfig(
timeout_sec: timeoutSec,
};
}
export function buildToolProfilePayload(
config: ToolProfileConfig,
errors: string[],
): {
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: Record<string, unknown>[];
// biome-ignore lint/style/useNamingConvention: api schema
tool_config: Record<string, unknown> | null;
} {
const mcpProviders = config.mcp_providers
.map((provider) => buildLlmMcpProvider(provider, errors))
.flatMap((provider) => (provider ? [provider] : []));
const toolConfig = buildLlmToolConfig(
{
id: config.id,
// biome-ignore lint/style/useNamingConvention: api schema
tool_alias: config.name,
providers: mcpProviders
.map((provider) => String(provider.name ?? "").trim())
.filter(Boolean),
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: config.allow_tools,
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns: config.max_tool_call_turns,
// biome-ignore lint/style/useNamingConvention: api schema
timeout_sec: config.timeout_sec,
},
errors,
);
return {
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: mcpProviders,
// biome-ignore lint/style/useNamingConvention: api schema
tool_config: toolConfig,
};
}

View file

@ -1,4 +1,9 @@
export { buildLlmColumn, buildLlmMcpProvider, buildLlmToolConfig } from "./builders-llm";
export {
buildLlmColumn,
buildLlmMcpProvider,
buildLlmToolConfig,
buildToolProfilePayload,
} from "./builders-llm";
export { buildModelConfig, buildModelProvider } from "./builders-model";
export { buildExpressionColumn, buildProcessors } from "./builders-processors";
export { buildSamplerColumn } from "./builders-sampler";

View file

@ -4,6 +4,7 @@ export type DialogOptions = {
categoryOptions: SamplerConfig[];
modelConfigAliases: string[];
modelProviderOptions: string[];
toolProfileAliases: string[];
datetimeOptions: string[];
};
@ -11,6 +12,7 @@ export function buildDialogOptions(configList: NodeConfig[]): DialogOptions {
const categoryOptions: SamplerConfig[] = [];
const modelConfigAliases: string[] = [];
const modelProviderOptions: string[] = [];
const toolProfileAliases: string[] = [];
const datetimeOptions: string[] = [];
for (const config of configList) {
@ -29,6 +31,10 @@ export function buildDialogOptions(configList: NodeConfig[]): DialogOptions {
}
if (config.kind === "model_provider") {
modelProviderOptions.push(config.name);
continue;
}
if (config.kind === "tool_config") {
toolProfileAliases.push(config.name);
}
}
@ -36,6 +42,7 @@ export function buildDialogOptions(configList: NodeConfig[]): DialogOptions {
categoryOptions,
modelConfigAliases,
modelProviderOptions,
toolProfileAliases,
datetimeOptions,
};
}

View file

@ -195,6 +195,44 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
errors.push("Expression is required.");
}
}
if (config.kind === "tool_config") {
if (config.mcp_providers.length === 0) {
errors.push("Add at least one MCP server.");
}
const serverNames = new Set<string>();
for (const provider of config.mcp_providers) {
const name = provider.name.trim();
if (!name) {
errors.push("Each MCP server needs a name.");
continue;
}
if (serverNames.has(name)) {
errors.push(`Duplicate MCP server name: ${name}.`);
}
serverNames.add(name);
if (provider.provider_type === "stdio") {
if (!provider.command?.trim()) {
errors.push(`MCP server ${name}: command is required.`);
}
} else if (!provider.endpoint?.trim()) {
errors.push(`MCP server ${name}: endpoint is required.`);
}
}
const maxTurnsRaw = config.max_tool_call_turns?.trim();
if (
maxTurnsRaw &&
(!Number.isFinite(Number(maxTurnsRaw)) || Number(maxTurnsRaw) < 1)
) {
errors.push("Max tool call turns must be >= 1.");
}
const timeoutRaw = config.timeout_sec?.trim();
if (
timeoutRaw &&
(!Number.isFinite(Number(timeoutRaw)) || Number(timeoutRaw) <= 0)
) {
errors.push("Timeout must be > 0.");
}
}
if (config.kind === "validator") {
const targets = (config.target_columns ?? [])
.map((value) => value.trim())

View file

@ -29,7 +29,11 @@ export function getAvailableVariableEntries(
if (config.id === currentId) {
continue;
}
if (config.kind === "model_provider" || config.kind === "model_config") {
if (
config.kind === "model_provider" ||
config.kind === "model_config" ||
config.kind === "tool_config"
) {
continue;
}