feat(recipe-studio): add support for managing tools by provider in tool profiles

This commit is contained in:
Shine1i 2026-03-09 19:19:14 +01:00
commit d31420acc3
7 changed files with 119 additions and 10 deletions

View file

@ -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<Record<string, string[]>>(
{},
config.fetched_tools_by_provider ?? {},
);
const [providerErrors, setProviderErrors] = useState<Record<string, string>>({});
const [duplicateTools, setDuplicateTools] = useState<Record<string, string[]>>({});
const [openProviders, setOpenProviders] = useState<Record<string, boolean>>({});
const previousProviderSignatureRef = useRef<string | null>(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

View file

@ -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<string, unknown>).map((envKey) => [envKey, ""]),
);
}
return output;
}

View file

@ -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<string, string[]>;
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools?: string[];
// biome-ignore lint/style/useNamingConvention: api schema

View file

@ -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

View file

@ -252,6 +252,46 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
return noteNodes;
}
function parseUiToolProfileNodes(input: unknown): Map<string, Record<string, string[]>> {
const toolProfiles = new Map<string, Record<string, string[]>>();
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<string, boolean> {
if (!isRecord(input)) {
return {};
@ -292,6 +332,7 @@ function buildToolProfileConfig(
toolConfig: LlmToolConfig,
toolConfigsByAlias: Map<string, LlmToolConfig>,
mcpProvidersByName: Map<string, LlmMcpProviderConfig>,
fetchedToolsByProfileName: Map<string, Record<string, string[]>>,
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)) {

View file

@ -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,

View file

@ -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<string, string[]>;
}>;
edges: {
from: string;