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 index d625c7828d..76a8e5b449 100644 --- 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 @@ -14,7 +14,7 @@ import { PlusSignIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { type ReactElement, useEffect, useMemo, useState } from "react"; +import { type ReactElement, useEffect, useMemo, useRef, useState } from "react"; import { listMcpTools } from "../../api"; import { ChipInput } from "../../components/chip-input"; import type { LlmMcpProviderConfig, McpEnvVar, ToolProfileConfig } from "../../types"; @@ -351,11 +351,12 @@ export function ToolProfileDialog({ const providers = config.mcp_providers; const [loadingTools, setLoadingTools] = useState(false); const [toolsByProvider, setToolsByProvider] = useState>( - {}, + config.fetched_tools_by_provider ?? {}, ); const [providerErrors, setProviderErrors] = useState>({}); const [duplicateTools, setDuplicateTools] = useState>({}); const [openProviders, setOpenProviders] = useState>({}); + const previousProviderSignatureRef = useRef(null); const providerSignature = useMemo( () => @@ -378,10 +379,30 @@ export function ToolProfileDialog({ ); useEffect(() => { + const previousSignature = previousProviderSignatureRef.current; + previousProviderSignatureRef.current = providerSignature; + if (previousSignature === null) { + setToolsByProvider(config.fetched_tools_by_provider ?? {}); + return; + } + if (previousSignature === providerSignature) { + return; + } setToolsByProvider({}); setProviderErrors({}); setDuplicateTools({}); - }, [providerSignature]); + if (Object.keys(config.fetched_tools_by_provider ?? {}).length > 0) { + onUpdate({ + // biome-ignore lint/style/useNamingConvention: ui schema + fetched_tools_by_provider: {}, + }); + } + }, [config.fetched_tools_by_provider, onUpdate, providerSignature]); + + useEffect(() => { + const tools = config.fetched_tools_by_provider ?? {}; + setToolsByProvider(tools); + }, [config.fetched_tools_by_provider]); useEffect(() => { setOpenProviders((current) => { @@ -523,13 +544,16 @@ export function ToolProfileDialog({ // 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]), - ), + const nextToolsByProvider = Object.fromEntries( + response.providers + .filter((provider) => provider.name.trim()) + .map((provider) => [provider.name.trim(), provider.tools]), ); + setToolsByProvider(nextToolsByProvider); + onUpdate({ + // biome-ignore lint/style/useNamingConvention: ui schema + fetched_tools_by_provider: nextToolsByProvider, + }); setProviderErrors( Object.fromEntries( response.providers diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts index 099dfb2f6b..7ea6f8726e 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts @@ -61,6 +61,16 @@ function stripApiKeys(value: unknown): unknown { } output[key] = stripApiKeys(entry); } + if ( + output.provider_type === "stdio" && + output.env && + typeof output.env === "object" && + !Array.isArray(output.env) + ) { + output.env = Object.fromEntries( + Object.keys(output.env as Record).map((envKey) => [envKey, ""]), + ); + } return output; } diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts index c62da8fd9f..4e0470db71 100644 --- a/studio/frontend/src/features/recipe-studio/types/index.ts +++ b/studio/frontend/src/features/recipe-studio/types/index.ts @@ -181,6 +181,8 @@ export type ToolProfileConfig = { name: string; // biome-ignore lint/style/useNamingConvention: ui schema mcp_providers: LlmMcpProviderConfig[]; + // biome-ignore lint/style/useNamingConvention: ui schema + fetched_tools_by_provider?: Record; // biome-ignore lint/style/useNamingConvention: api schema allow_tools?: string[]; // biome-ignore lint/style/useNamingConvention: api schema 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 d605e1a775..697a819ecf 100644 --- a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts +++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts @@ -275,6 +275,8 @@ export function makeToolProfileConfig( name: nextName(existing, "tools"), // biome-ignore lint/style/useNamingConvention: ui schema mcp_providers: [], + // biome-ignore lint/style/useNamingConvention: ui schema + fetched_tools_by_provider: {}, // biome-ignore lint/style/useNamingConvention: api schema allow_tools: [], // biome-ignore lint/style/useNamingConvention: api schema 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 b8823882f7..41460fb7bf 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts @@ -252,6 +252,46 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] { return noteNodes; } +function parseUiToolProfileNodes(input: unknown): Map> { + const toolProfiles = new Map>(); + if (!Array.isArray(input)) { + return toolProfiles; + } + for (const node of input) { + if (!isRecord(node)) { + continue; + } + const nodeType = readString(node.node_type) ?? readString(node.type); + if (nodeType !== "tool_config") { + continue; + } + const name = readString(node.name) ?? readString(node.id); + if (!name?.trim()) { + continue; + } + const rawToolsByProvider = isRecord(node.tools_by_provider) + ? node.tools_by_provider + : null; + if (!rawToolsByProvider) { + continue; + } + const toolsByProvider = Object.fromEntries( + Object.entries(rawToolsByProvider).flatMap(([providerName, tools]) => { + const trimmedName = providerName.trim(); + if (!trimmedName || !Array.isArray(tools)) { + return []; + } + const values = Array.from( + new Set(tools.map((value) => String(value).trim()).filter(Boolean)), + ); + return values.length > 0 ? [[trimmedName, values]] : []; + }), + ); + toolProfiles.set(name.trim(), toolsByProvider); + } + return toolProfiles; +} + function parseAdvancedOpenByNode(input: unknown): Record { if (!isRecord(input)) { return {}; @@ -292,6 +332,7 @@ function buildToolProfileConfig( toolConfig: LlmToolConfig, toolConfigsByAlias: Map, mcpProvidersByName: Map, + fetchedToolsByProfileName: Map>, id: string, ): ToolProfileConfig { const canonical = toolConfigsByAlias.get(toolConfig.tool_alias) ?? toolConfig; @@ -303,6 +344,8 @@ function buildToolProfileConfig( mcp_providers: canonical.providers .map((providerName) => mcpProvidersByName.get(providerName)) .flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : [])), + // biome-ignore lint/style/useNamingConvention: ui schema + fetched_tools_by_provider: fetchedToolsByProfileName.get(canonical.tool_alias) ?? {}, // biome-ignore lint/style/useNamingConvention: api schema allow_tools: [...(canonical.allow_tools ?? [])], // biome-ignore lint/style/useNamingConvention: api schema @@ -370,6 +413,7 @@ export function importRecipePayload(input: string): ImportResult { ); const uiAdvancedOpenByNode = parseAdvancedOpenByNode(ui?.advanced_open_by_node); const uiMarkdownNotes = parseUiMarkdownNoteNodes(ui?.nodes); + const uiToolProfilesByName = parseUiToolProfileNodes(ui?.nodes); for (const note of uiMarkdownNotes) { const id = `n${nextId}`; @@ -470,6 +514,7 @@ export function importRecipePayload(input: string): ImportResult { toolConfig, toolConfigsByAlias, mcpProvidersByName, + uiToolProfilesByName, id, ); if (nameToId.has(config.name)) { 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 615d48b739..758fcd4986 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 @@ -267,6 +267,31 @@ export function buildRecipePayload( }, ]; } + if (config.kind === "tool_config") { + const toolsByProvider = Object.fromEntries( + Object.entries(config.fetched_tools_by_provider ?? {}).flatMap( + ([providerName, tools]) => { + const name = providerName.trim(); + const values = Array.from( + new Set(tools.map((tool) => tool.trim()).filter(Boolean)), + ); + return name && values.length > 0 ? [[name, values]] : []; + }, + ), + ); + return [ + { + id: config.name, + x: node.position.x, + y: node.position.y, + ...(width !== null ? { width } : {}), + node_type: "tool_config" as const, + ...(Object.keys(toolsByProvider).length > 0 && { + tools_by_provider: toolsByProvider, + }), + }, + ]; + } return [ { id: config.name, diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts index e8e207a3e7..69db100d96 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts @@ -37,11 +37,12 @@ export type RecipePayload = { x: number; y: number; width?: number; - node_type?: "markdown_note"; + node_type?: "markdown_note" | "tool_config"; name?: string; markdown?: string; note_color?: string; note_opacity?: string; + tools_by_provider?: Record; }>; edges: { from: string;