From 1f37b76b19dc714af5cbda8b53d214ed232c859d Mon Sep 17 00:00:00 2001 From: Shine1i Date: Mon, 9 Mar 2026 17:04:15 +0100 Subject: [PATCH] feat(recipe-studio): remove MCP tools-related dialogs and refactor tool profile management logic --- studio/backend/models/data_recipe.py | 16 + studio/backend/routes/data_recipe/__init__.py | 2 + studio/backend/routes/data_recipe/mcp.py | 77 ++ .../src/features/recipe-studio/api/index.ts | 27 +- .../recipe-studio/blocks/definitions.ts | 20 +- .../recipe-studio/blocks/render-dialog.tsx | 7 + .../recipe-studio/components/block-sheet.tsx | 6 + .../components/inline/inline-llm.tsx | 50 ++ .../components/inline/inline-policy.ts | 3 + .../components/recipe-graph-node.tsx | 47 ++ .../recipe-studio/dialogs/config-dialog.tsx | 3 + .../recipe-studio/dialogs/llm/general-tab.tsx | 54 +- .../recipe-studio/dialogs/llm/llm-dialog.tsx | 29 +- .../dialogs/llm/mcp-tools-tab.tsx | 297 ------- .../llm/mcp-tools/mcp-providers-section.tsx | 272 ------ .../llm/mcp-tools/tool-configs-section.tsx | 182 ---- .../mcp-tools => tool-profile}/helpers.ts | 72 +- .../tool-profile/tool-profile-dialog.tsx | 776 ++++++++++++++++++ .../hooks/use-recipe-editor-graph.ts | 13 + .../hooks/use-recipe-runtime-visuals.ts | 4 + .../recipe-studio/recipe-studio-page.tsx | 14 +- .../recipe-studio/stores/helpers/edge-sync.ts | 35 + .../stores/helpers/model-infra-layout.ts | 51 +- .../stores/helpers/reference-sync.ts | 8 + .../recipe-studio/stores/recipe-studio.ts | 47 +- .../src/features/recipe-studio/types/index.ts | 27 +- .../recipe-studio/utils/config-factories.ts | 24 +- .../utils/graph/recipe-graph-connection.ts | 20 +- .../recipe-studio/utils/graph/relations.ts | 3 + .../utils/graph/runtime-visual-state.ts | 1 + .../recipe-studio/utils/import/edges.ts | 6 + .../recipe-studio/utils/import/importer.ts | 70 +- .../src/features/recipe-studio/utils/index.ts | 1 + .../features/recipe-studio/utils/node-data.ts | 11 + .../utils/payload/build-payload.ts | 55 +- .../utils/payload/builders-llm.ts | 44 +- .../recipe-studio/utils/payload/builders.ts | 7 +- .../recipe-studio/utils/recipe-studio-view.ts | 7 + .../recipe-studio/utils/validation.ts | 38 + .../features/recipe-studio/utils/variables.ts | 6 +- 40 files changed, 1537 insertions(+), 895 deletions(-) create mode 100644 studio/backend/routes/data_recipe/mcp.py delete mode 100644 studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools-tab.tsx delete mode 100644 studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/mcp-providers-section.tsx delete mode 100644 studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/tool-configs-section.tsx rename studio/frontend/src/features/recipe-studio/dialogs/{llm/mcp-tools => tool-profile}/helpers.ts (72%) create mode 100644 studio/frontend/src/features/recipe-studio/dialogs/tool-profile/tool-profile-dialog.tsx diff --git a/studio/backend/models/data_recipe.py b/studio/backend/models/data_recipe.py index 01e7c8cd1f..418ef97afc 100644 --- a/studio/backend/models/data_recipe.py +++ b/studio/backend/models/data_recipe.py @@ -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) diff --git a/studio/backend/routes/data_recipe/__init__.py b/studio/backend/routes/data_recipe/__init__.py index 0d3f4febf9..65e7db0279 100644 --- a/studio/backend/routes/data_recipe/__init__.py +++ b/studio/backend/routes/data_recipe/__init__.py @@ -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"] diff --git a/studio/backend/routes/data_recipe/mcp.py b/studio/backend/routes/data_recipe/mcp.py new file mode 100644 index 0000000000..5aa5c3247c --- /dev/null +++ b/studio/backend/routes/data_recipe/mcp.py @@ -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, + ) diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts index c1d4174feb..ef13eb92da 100644 --- a/studio/frontend/src/features/recipe-studio/api/index.ts +++ b/studio/frontend/src/features/recipe-studio/api/index.ts @@ -124,6 +124,25 @@ export type ValidateResponse = { raw_detail?: string | null; }; +export type McpToolsListRequest = { + // biome-ignore lint/style/useNamingConvention: api schema + mcp_providers: Record[]; + // 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; +}; + async function parseErrorResponse(response: Response): Promise { const text = (await response.text()).trim(); if (!text) { @@ -261,6 +280,12 @@ export async function inspectSeedUpload( return postJson("/seed/inspect-upload", payload); } +export async function listMcpTools( + payload: McpToolsListRequest, +): Promise { + return postJson("/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. diff --git a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts index 2a16d7d825..c2d68b26cf 100644 --- a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts +++ b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts @@ -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"); } diff --git a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx index d07aa6b627..d8d8222842 100644 --- a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx @@ -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) => 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" ? ( + + ) : null; case "expression": return config.kind === "expression" ? ( diff --git a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx index ffe11076ad..079a0ee004 100644 --- a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx +++ b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx @@ -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; } diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-llm.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-llm.tsx index 6a269ca684..1e7bfef6c5 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-llm.tsx +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-llm.tsx @@ -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(null); + const toolAnchorRef = useRef(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 { + +
+ + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + tool_alias: value ?? "", + }) + } + itemToStringValue={(value) => value} + autoHighlight={true} + > + { + const next = event.target.value; + if (next !== (config.tool_alias ?? "")) { + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + tool_alias: next, + }); + } + }} + /> + + No tool profiles found + + {(alias: string) => ( + + {alias} + + )} + + + +
+
{isCode && ( - onUpdate({ - // biome-ignore lint/style/useNamingConvention: api schema - tool_alias: value, - }) - } - > - - - - - {toolAliasOptions.map((alias) => ( - - {alias} - - ))} - - - ) : ( -

- Add tool config alias first. -

- )} - - { - void loadToolNames(); - }} - onAddToolConfig={addToolConfig} - onUpdateToolConfig={updateToolConfigAt} - onRemoveToolConfig={removeToolConfig} - /> - - - ); -} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/mcp-providers-section.tsx b/studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/mcp-providers-section.tsx deleted file mode 100644 index c2b892564b..0000000000 --- a/studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/mcp-providers-section.tsx +++ /dev/null @@ -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, - ) => 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, - ) => void; - onRemoveProviderEnv: (providerIndex: number, envIndex: number) => void; -}; - -export function McpProvidersSection({ - providers, - onAddProvider, - onUpdateProviderAt, - onRemoveProvider, - onAddProviderArg, - onUpdateProviderArg, - onRemoveProviderArg, - onAddProviderEnv, - onUpdateProviderEnv, - onRemoveProviderEnv, -}: McpProvidersSectionProps) { - return ( -
-
- - -
- - {providers.length === 0 && ( -

- Add MCP servers to be referenced by tool config providers. -

- )} - - {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 ( -
-
- - - onUpdateProviderAt(providerIndex, { name: event.target.value }) - } - /> -
- - - onUpdateProviderAt(providerIndex, { - // biome-ignore lint/style/useNamingConvention: ui schema - provider_type: value === "stdio" ? "stdio" : "streamable_http", - }) - } - > - - STDIO - Streamable HTTP - - - - {provider.provider_type === "stdio" ? ( -
-
- - - onUpdateProviderAt(providerIndex, { - command: event.target.value, - }) - } - /> -
- -
- - {args.map((arg, argIndex) => ( -
- - onUpdateProviderArg(providerIndex, argIndex, event.target.value) - } - /> - -
- ))} - -
- -
- - {envVars.map((item, envIndex) => ( -
- - onUpdateProviderEnv(providerIndex, envIndex, { - key: event.target.value, - }) - } - /> - - onUpdateProviderEnv(providerIndex, envIndex, { - value: event.target.value, - }) - } - /> - -
- ))} - -
-
- ) : ( -
-
- - - onUpdateProviderAt(providerIndex, { - endpoint: event.target.value, - }) - } - /> -
-
- - - onUpdateProviderAt(providerIndex, { - // biome-ignore lint/style/useNamingConvention: api schema - api_key_env: event.target.value, - }) - } - /> -
-
- - - onUpdateProviderAt(providerIndex, { - // biome-ignore lint/style/useNamingConvention: api schema - api_key: event.target.value, - }) - } - /> -
-
- )} - -
- -
-
- ); - })} -
- ); -} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/tool-configs-section.tsx b/studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/tool-configs-section.tsx deleted file mode 100644 index 7dcf601554..0000000000 --- a/studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/tool-configs-section.tsx +++ /dev/null @@ -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; - loadingTools: boolean; - onFetchTools: () => void; - onAddToolConfig: () => void; - onUpdateToolConfig: (index: number, patch: Partial) => void; - onRemoveToolConfig: (index: number) => void; -}; - -export function ToolConfigsSection({ - toolConfigs, - providerNameSuggestions, - toolsByProvider, - loadingTools, - onFetchTools, - onAddToolConfig, - onUpdateToolConfig, - onRemoveToolConfig, -}: ToolConfigsSectionProps) { - return ( -
-
- -
- - -
-
-

- Define aliases/providers here. Active alias is selected above. -

- {toolConfigs.length === 0 && ( -

- Add at least one tool config to map alias to providers. -

- )} - {toolConfigs.map((toolConfig, index) => ( -
-
- - - onUpdateToolConfig(index, { - // biome-ignore lint/style/useNamingConvention: api schema - tool_alias: event.target.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" - /> -
- -
- - - 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" - /> -
- -
-
- - - onUpdateToolConfig(index, { - // biome-ignore lint/style/useNamingConvention: api schema - max_tool_call_turns: event.target.value, - }) - } - /> -
-
- - - onUpdateToolConfig(index, { - // biome-ignore lint/style/useNamingConvention: api schema - timeout_sec: event.target.value, - }) - } - /> -
-
- -
- -
-
- ))} -
- ); -} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/helpers.ts b/studio/frontend/src/features/recipe-studio/dialogs/tool-profile/helpers.ts similarity index 72% rename from studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/helpers.ts rename to studio/frontend/src/features/recipe-studio/dialogs/tool-profile/helpers.ts index 7c8788beec..332c92fbfa 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/llm/mcp-tools/helpers.ts +++ b/studio/frontend/src/features/recipe-studio/dialogs/tool-profile/helpers.ts @@ -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[] { + 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 { @@ -45,43 +67,3 @@ export function toApiProvider( api_key_env: provider.api_key_env?.trim() || undefined, }; } - -export function collectToolSuggestions( - providerNames: string[], - toolsByProvider: Record, -): 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] ?? ""; -} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/tool-profile/tool-profile-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/tool-profile/tool-profile-dialog.tsx new file mode 100644 index 0000000000..d625c7828d --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/dialogs/tool-profile/tool-profile-dialog.tsx @@ -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) => void; +}; + +function EmptyState({ + title, + description, +}: { + title: string; + description: string; +}): ReactElement { + return ( +
+

{title}

+

{description}

+
+ ); +} + +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, + ) => 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, + ) => 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 ( + +
+
+ + + + +
+ + + {error && ( +
+ {error} +
+ )} + +
+ + + onUpdateProviderAt(index, { name: event.target.value }) + } + /> +
+ + + onUpdateProviderAt(index, { + // biome-ignore lint/style/useNamingConvention: ui schema + provider_type: value === "stdio" ? "stdio" : "streamable_http", + }) + } + > + + STDIO + Streamable HTTP + + + + {provider.provider_type === "stdio" ? ( +
+
+ + + onUpdateProviderAt(index, { command: event.target.value }) + } + /> +
+ +
+
+ + +
+ {args.map((arg, argIndex) => ( +
+ + onUpdateProviderArg(index, argIndex, event.target.value) + } + /> + +
+ ))} +
+ +
+
+ + +
+ {envVars.map((item, envIndex) => ( +
+ + onUpdateProviderEnv(index, envIndex, { + key: event.target.value, + }) + } + /> + + onUpdateProviderEnv(index, envIndex, { + value: event.target.value, + }) + } + /> + +
+ ))} +
+
+ ) : ( +
+
+ + + onUpdateProviderAt(index, { endpoint: event.target.value }) + } + /> +
+
+
+ + + onUpdateProviderAt(index, { + // biome-ignore lint/style/useNamingConvention: api schema + api_key_env: event.target.value, + }) + } + /> +
+
+ + + onUpdateProviderAt(index, { + // biome-ignore lint/style/useNamingConvention: api schema + api_key: event.target.value, + }) + } + /> +
+
+
+ )} +
+
+
+ ); +} + +export function ToolProfileDialog({ + config, + onUpdate, +}: ToolProfileDialogProps): ReactElement { + const providers = config.mcp_providers; + const [loadingTools, setLoadingTools] = useState(false); + const [toolsByProvider, setToolsByProvider] = useState>( + {}, + ); + const [providerErrors, setProviderErrors] = useState>({}); + const [duplicateTools, setDuplicateTools] = useState>({}); + const [openProviders, setOpenProviders] = useState>({}); + + 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 = {}; + 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, + ): void { + updateProviders( + providers.map((provider, currentIndex) => + currentIndex === index ? { ...provider, ...patch } : provider, + ), + ); + } + + function mutateProviderAt( + index: number, + mapProvider: (provider: LlmMcpProviderConfig) => Partial, + ): 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, + ): 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 { + 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 ( + + + Profile + MCP servers + + + + onUpdate({ name: value })} + /> + + {!hasProviders ? ( + + ) : ( + <> +
+ +
+ {providerNames.map((providerName) => ( + + {providerName} + + ))} +
+
+ +
+
+
+

+ Available tool refs +

+

+ Load tools from backend so users pick tool names instead of guessing. +

+
+ +
+ + {Object.keys(toolsByProvider).length === 0 && + Object.keys(providerErrors).length === 0 && ( +

+ No tools loaded yet. +

+ )} + + {Object.entries(toolsByProvider).map(([providerName, toolNames]) => ( +
+
+

+ {providerName} +

+ + {toolNames.length} + +
+
+ {toolNames.map((toolName) => ( + + {toolName} + + ))} +
+
+ ))} + + {Object.entries(duplicateTools).length > 0 && ( +
+ Duplicate tool names across servers: + {" "} + {Object.entries(duplicateTools) + .map(([toolName, providerList]) => `${toolName} (${providerList.join(", ")})`) + .join("; ")} +
+ )} +
+ +
+ + + 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" + /> +
+ +
+
+ + + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + max_tool_call_turns: event.target.value, + }) + } + /> +
+
+ + + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + timeout_sec: event.target.value, + }) + } + /> +
+
+ + )} +
+ + +
+ + +
+ + {!hasProviders ? ( + + ) : ( +
+ {providers.map((provider, index) => ( + + setOpenProviders((current) => ({ + ...current, + [provider.id]: open, + })) + } + onUpdateProviderAt={updateProviderAt} + onRemoveProvider={removeProvider} + onAddProviderArg={addProviderArg} + onUpdateProviderArg={updateProviderArg} + onRemoveProviderArg={removeProviderArg} + onAddProviderEnv={addProviderEnv} + onUpdateProviderEnv={updateProviderEnv} + onRemoveProviderEnv={removeProviderEnv} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts index 8e8582b855..11e60c06d7 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts @@ -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, diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts index 7d0b259181..5389909b74 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts @@ -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; } diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index 3bc2384ac8..d2617cb21f 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -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} diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts index cf4812959f..379a285654 100644 --- a/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts @@ -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 & { 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", diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts index ce09c46513..cd065103af 100644 --- a/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts @@ -86,6 +86,15 @@ function isConfigToLlmEdge( return source?.kind === "model_config" && target?.kind === "llm"; } +function isToolConfigToLlmEdge( + edge: Edge, + configs: Record, +): 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(); + const toolConfigToLlmIds = new Map(); const providerToConfigIds = new Map(); 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); } diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts index 736745ac4f..c0d7fe892a 100644 --- a/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts @@ -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) { diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts index 38f4dd56d4..7c71400c15 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -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): 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((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) { diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts index 9c2b8feac7..c62da8fd9f 100644 --- a/studio/frontend/src/features/recipe-studio/types/index.ts +++ b/studio/frontend/src/features/recipe-studio/types/index.ts @@ -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; diff --git a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts index 404cf018de..d605e1a775 100644 --- a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts +++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts @@ -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[], diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts index 6699ab1007..bcafb09ebc 100644 --- a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts +++ b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts @@ -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" && diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts b/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts index 8513002d9b..a032cc65d8 100644 --- a/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts +++ b/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts @@ -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" && diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/runtime-visual-state.ts b/studio/frontend/src/features/recipe-studio/utils/graph/runtime-visual-state.ts index d36014d4c9..4806892d27 100644 --- a/studio/frontend/src/features/recipe-studio/utils/graph/runtime-visual-state.ts +++ b/studio/frontend/src/features/recipe-studio/utils/graph/runtime-visual-state.ts @@ -22,6 +22,7 @@ const DONE_UPSTREAM_KINDS: ReadonlySet = new Set([ "llm", "model_config", "model_provider", + "tool_config", ]); export type GraphRuntimeVisualState = { diff --git a/studio/frontend/src/features/recipe-studio/utils/import/edges.ts b/studio/frontend/src/features/recipe-studio/utils/import/edges.ts index 4e02877b6c..127b2015bb 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/edges.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/edges.ts @@ -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()) { diff --git a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts index def08974bc..b8823882f7 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts @@ -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 { 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, mcpProvidersByName: Map, -): 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}.`); diff --git a/studio/frontend/src/features/recipe-studio/utils/index.ts b/studio/frontend/src/features/recipe-studio/utils/index.ts index fa03a50e40..a30db80f98 100644 --- a/studio/frontend/src/features/recipe-studio/utils/index.ts +++ b/studio/frontend/src/features/recipe-studio/utils/index.ts @@ -6,6 +6,7 @@ export { makeModelProviderConfig, makeSamplerConfig, makeSeedConfig, + makeToolProfileConfig, makeValidatorConfig, } from "./config-factories"; export { diff --git a/studio/frontend/src/features/recipe-studio/utils/node-data.ts b/studio/frontend/src/features/recipe-studio/utils/node-data.ts index c887a979c3..195a5f03bb 100644 --- a/studio/frontend/src/features/recipe-studio/utils/node-data.ts +++ b/studio/frontend/src/features/recipe-studio/utils/node-data.ts @@ -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", diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts index f037586af7..615d48b739 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts @@ -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); } diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-llm.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-llm.ts index f142a63701..f805a046f7 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/builders-llm.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-llm.ts @@ -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[]; + // biome-ignore lint/style/useNamingConvention: api schema + tool_config: Record | 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, + }; +} diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders.ts index fb756cd159..b963d6bc0b 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/builders.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders.ts @@ -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"; diff --git a/studio/frontend/src/features/recipe-studio/utils/recipe-studio-view.ts b/studio/frontend/src/features/recipe-studio/utils/recipe-studio-view.ts index b75dc454ba..d16175933e 100644 --- a/studio/frontend/src/features/recipe-studio/utils/recipe-studio-view.ts +++ b/studio/frontend/src/features/recipe-studio/utils/recipe-studio-view.ts @@ -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, }; } diff --git a/studio/frontend/src/features/recipe-studio/utils/validation.ts b/studio/frontend/src/features/recipe-studio/utils/validation.ts index b7805ce0c3..7aa755971a 100644 --- a/studio/frontend/src/features/recipe-studio/utils/validation.ts +++ b/studio/frontend/src/features/recipe-studio/utils/validation.ts @@ -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(); + 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()) diff --git a/studio/frontend/src/features/recipe-studio/utils/variables.ts b/studio/frontend/src/features/recipe-studio/utils/variables.ts index f98f0662bc..7a34ad7006 100644 --- a/studio/frontend/src/features/recipe-studio/utils/variables.ts +++ b/studio/frontend/src/features/recipe-studio/utils/variables.ts @@ -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; }