feat (acp): exposed available tools in acp schema (#10097)

This commit is contained in:
Lifei Zhou
2026-06-30 15:43:08 +10:00
committed by GitHub
parent d43c692127
commit 2084c43476
15 changed files with 543 additions and 40 deletions
+11
View File
@@ -17,6 +17,10 @@ function headersToRecord(headers: { name: string; value: string }[] = []) {
return Object.fromEntries(headers.map(({ name, value }) => [name, value]));
}
function availableToolsOrUndefined(availableTools?: string[] | null): string[] | undefined {
return availableTools?.length ? availableTools : undefined;
}
export function gooseExtensionToExtensionConfig(extension: GooseExtension): ExtensionConfig | null {
switch (extension.type) {
case 'builtin':
@@ -24,6 +28,7 @@ export function gooseExtensionToExtensionConfig(extension: GooseExtension): Exte
return {
...extension,
description: extension.description ?? '',
available_tools: availableToolsOrUndefined(extension.available_tools),
};
case 'mcp': {
const server = extension.server;
@@ -37,6 +42,7 @@ export function gooseExtensionToExtensionConfig(extension: GooseExtension): Exte
env_keys: extension.envKeys ?? [],
timeout: extension.timeout,
bundled: extension.bundled,
available_tools: availableToolsOrUndefined(extension.available_tools),
};
}
if ('url' in server) {
@@ -50,6 +56,7 @@ export function gooseExtensionToExtensionConfig(extension: GooseExtension): Exte
timeout: extension.timeout,
socket: extension.socket,
bundled: extension.bundled,
available_tools: availableToolsOrUndefined(extension.available_tools),
};
}
return null;
@@ -94,6 +101,7 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE
display_name: config.display_name,
timeout: config.timeout,
bundled: config.bundled,
available_tools: availableToolsOrUndefined(config.available_tools),
};
case 'platform':
return {
@@ -102,6 +110,7 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE
description: config.description,
display_name: config.display_name,
bundled: config.bundled,
available_tools: availableToolsOrUndefined(config.available_tools),
};
case 'stdio':
return {
@@ -111,6 +120,7 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE
description: config.description,
timeout: config.timeout,
bundled: config.bundled,
available_tools: availableToolsOrUndefined(config.available_tools),
};
case 'streamable_http':
return {
@@ -126,6 +136,7 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE
timeout: config.timeout,
socket: config.socket,
bundled: config.bundled,
available_tools: availableToolsOrUndefined(config.available_tools),
};
case 'sse':
case 'frontend':
@@ -259,13 +259,12 @@ export default function CreateEditRecipeModal({
: undefined;
const cleanedExtensions = extensions?.map(
(
extension: RecipeExtension & {
enabled?: boolean;
available_tools?: unknown;
(extension: RecipeExtension & { enabled?: boolean }) => {
const { enabled: _enabled, ...rest } = extension;
if (rest.available_tools == null || rest.available_tools.length === 0) {
const { available_tools: _availableTools, ...withoutAvailableTools } = rest;
return withoutAvailableTools;
}
) => {
const { enabled: _enabled, available_tools: _availableTools, ...rest } = extension;
return rest;
}
) as RecipeExtension[] | undefined;
@@ -38,6 +38,12 @@ type DisplayRecipeExtension = RecipeExtension & {
enabled?: boolean;
};
function availableToolsProps(availableTools?: string[] | null) {
return availableTools && availableTools.length > 0
? { available_tools: availableTools }
: undefined;
}
function toRecipeExtension(
extension: FixedExtensionEntry | DisplayRecipeExtension
): DisplayRecipeExtension | null {
@@ -45,21 +51,74 @@ function toRecipeExtension(
switch (extension.type) {
case 'builtin': {
const { name, description, display_name, timeout, bundled, type } = extension;
return { name, description, display_name, timeout, bundled, type, enabled };
const { name, description, display_name, timeout, bundled, available_tools, type } =
extension;
return {
name,
description,
display_name,
timeout,
bundled,
...availableToolsProps(available_tools),
type,
enabled,
};
}
case 'platform': {
const { name, description, display_name, bundled, type } = extension;
return { name, description, display_name, bundled, type, enabled };
const { name, description, display_name, bundled, available_tools, type } = extension;
return {
name,
description,
display_name,
bundled,
...availableToolsProps(available_tools),
type,
enabled,
};
}
case 'stdio': {
const { name, description, cmd, args, envs, env_keys, timeout, cwd, bundled, type } =
extension;
return { name, description, cmd, args, envs, env_keys, timeout, cwd, bundled, type, enabled };
const {
name,
description,
cmd,
args,
envs,
env_keys,
timeout,
cwd,
bundled,
available_tools,
type,
} = extension;
return {
name,
description,
cmd,
args,
envs,
env_keys,
timeout,
cwd,
bundled,
...availableToolsProps(available_tools),
type,
enabled,
};
}
case 'streamable_http': {
const { name, description, uri, envs, env_keys, headers, timeout, socket, bundled, type } =
extension;
const {
name,
description,
uri,
envs,
env_keys,
headers,
timeout,
socket,
bundled,
available_tools,
type,
} = extension;
return {
name,
description,
@@ -70,6 +129,7 @@ function toRecipeExtension(
timeout,
socket,
bundled,
...availableToolsProps(available_tools),
type,
enabled,
};
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, type RenderOptions, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RecipeExtensionSelector } from '../RecipeExtensionSelector';
import { IntlTestWrapper } from '../../../../i18n/test-utils';
import type { FixedExtensionEntry } from '../../../ConfigContext';
const configContextMock = vi.hoisted(() => ({
extensionsList: [] as FixedExtensionEntry[],
}));
vi.mock('../../../ConfigContext', () => ({
useConfig: () => ({
extensionsList: configContextMock.extensionsList,
}),
}));
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: IntlTestWrapper, ...options });
describe('RecipeExtensionSelector', () => {
beforeEach(() => {
configContextMock.extensionsList = [];
});
it('preserves non-empty available tools when selecting a configured extension', async () => {
const user = userEvent.setup();
const onExtensionsChange = vi.fn();
configContextMock.extensionsList = [
{
type: 'builtin',
name: 'developer',
description: 'Developer tools',
enabled: true,
available_tools: ['shell', 'read_file'],
},
];
renderWithIntl(
<RecipeExtensionSelector selectedExtensions={[]} onExtensionsChange={onExtensionsChange} />
);
await user.click(screen.getByText('Developer'));
expect(onExtensionsChange).toHaveBeenCalledWith([
expect.objectContaining({
type: 'builtin',
name: 'developer',
available_tools: ['shell', 'read_file'],
}),
]);
});
});
@@ -157,6 +157,34 @@ describe('Extension Utils', () => {
});
});
it('should preserve available tools metadata', () => {
const extension: FixedExtensionEntry = {
type: 'builtin',
name: 'developer',
description: 'developer',
enabled: true,
available_tools: ['shell', 'read_file'],
};
const formData = extensionToFormData(extension);
expect(formData.available_tools).toEqual(['shell', 'read_file']);
});
it('should omit empty available tools metadata', () => {
const extension: FixedExtensionEntry = {
type: 'builtin',
name: 'developer',
description: 'developer',
enabled: true,
available_tools: [],
};
const formData = extensionToFormData(extension);
expect(formData).not.toHaveProperty('available_tools');
});
it('should not escape @ in command args', () => {
const extension: FixedExtensionEntry = {
type: 'stdio',
@@ -320,6 +348,73 @@ describe('Extension Utils', () => {
timeout: 300,
});
});
it('should create sse extension config', () => {
const formData = {
name: 'test-sse',
description: 'Test SSE extension',
type: 'sse' as const,
cmd: '',
endpoint: 'http://api.example.com/sse',
enabled: true,
timeout: 300,
envVars: [],
headers: [],
};
const config = createExtensionConfig(formData);
expect(config).toEqual({
type: 'sse',
name: 'test-sse',
description: 'Test SSE extension',
uri: 'http://api.example.com/sse',
});
});
it('should preserve available tools metadata', () => {
const formData = {
name: 'developer',
description: 'developer',
type: 'builtin' as const,
cmd: '',
endpoint: '',
enabled: true,
timeout: 300,
envVars: [],
headers: [],
available_tools: ['shell', 'read_file'],
};
const config = createExtensionConfig(formData);
expect(config).toEqual({
type: 'builtin',
name: 'developer',
description: 'developer',
timeout: 300,
available_tools: ['shell', 'read_file'],
});
});
it('should omit empty available tools metadata', () => {
const formData = {
name: 'developer',
description: 'developer',
type: 'builtin' as const,
cmd: '',
endpoint: '',
enabled: true,
timeout: 300,
envVars: [],
headers: [],
available_tools: [],
};
const config = createExtensionConfig(formData);
expect(config).not.toHaveProperty('available_tools');
});
});
describe('splitCmdAndArgs', () => {
@@ -38,6 +38,7 @@ export interface ExtensionFormData {
isEdited?: boolean;
}[];
installation_notes?: string;
available_tools?: string[];
}
export function getDefaultFormData(): ExtensionFormData {
@@ -95,6 +96,11 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
);
}
const availableTools =
'available_tools' in extension
? availableToolsOrUndefined(extension.available_tools)
: undefined;
return {
name: extension.name || '',
description: extension.description || '',
@@ -116,9 +122,19 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
installation_notes: (extension as Record<string, unknown>)['installation_notes'] as
| string
| undefined,
...(availableTools ? { available_tools: availableTools } : {}),
};
}
function availableToolsOrUndefined(availableTools?: string[] | null): string[] | undefined {
return availableTools && availableTools.length > 0 ? availableTools : undefined;
}
function availableToolsConfig(availableTools?: string[] | null) {
const normalized = availableToolsOrUndefined(availableTools);
return normalized ? { available_tools: normalized } : undefined;
}
export function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig {
// Extract just the keys from env vars
const env_keys = formData.envVars.map(({ key }) => key).filter((key) => key.length > 0);
@@ -135,6 +151,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
args: args,
timeout: formData.timeout,
...(env_keys.length > 0 ? { env_keys } : {}),
...availableToolsConfig(formData.available_tools),
};
} else if (formData.type === 'streamable_http') {
// Extract headers
@@ -156,14 +173,22 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
uri: formData.endpoint || '',
...(env_keys.length > 0 ? { env_keys } : {}),
headers,
...availableToolsConfig(formData.available_tools),
};
} else {
// For other types
} else if (formData.type === 'builtin') {
return {
type: formData.type,
name: formData.name,
description: formData.description,
timeout: formData.timeout,
...availableToolsConfig(formData.available_tools),
};
} else {
return {
type: formData.type,
name: formData.name,
description: formData.description,
uri: formData.endpoint || '',
};
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ describe('Recipe Validation', () => {
expect(schemaJson).not.toContain('sse');
expect(schemaJson).not.toContain('frontend');
expect(schemaJson).not.toContain('inline_python');
expect(schemaJson).not.toContain('available_tools');
expect(schemaJson).toContain('available_tools');
});
});
});
+28
View File
@@ -15,12 +15,20 @@ export type GooseExtension = {
display_name?: string | null;
timeout?: number | null;
bundled?: boolean | null;
/**
* Tool allowlist for this extension. Omit this field to allow all tools.
*/
available_tools?: Array<string> | null;
type: 'builtin';
} | {
name: string;
description?: string | null;
display_name?: string | null;
bundled?: boolean | null;
/**
* Tool allowlist for this extension. Omit this field to allow all tools.
*/
available_tools?: Array<string> | null;
type: 'platform';
} | {
server: McpServer;
@@ -29,6 +37,10 @@ export type GooseExtension = {
timeout?: number | null;
socket?: string | null;
bundled?: boolean | null;
/**
* Tool allowlist for this extension. Omit this field to allow all tools.
*/
available_tools?: Array<string> | null;
type: 'mcp';
};
@@ -1527,12 +1539,20 @@ export type RecipeExtensionDto = {
display_name?: string | null;
timeout?: number | null;
bundled?: boolean | null;
/**
* Tool allowlist for this extension. Omit this field to allow all tools.
*/
available_tools?: Array<string> | null;
type: 'builtin';
} | {
name: string;
description?: string | null;
display_name?: string | null;
bundled?: boolean | null;
/**
* Tool allowlist for this extension. Omit this field to allow all tools.
*/
available_tools?: Array<string> | null;
type: 'platform';
} | {
name: string;
@@ -1546,6 +1566,10 @@ export type RecipeExtensionDto = {
timeout?: number | null;
cwd?: string | null;
bundled?: boolean | null;
/**
* Tool allowlist for this extension. Omit this field to allow all tools.
*/
available_tools?: Array<string> | null;
type: 'stdio';
} | {
name: string;
@@ -1561,6 +1585,10 @@ export type RecipeExtensionDto = {
timeout?: number | null;
socket?: string | null;
bundled?: boolean | null;
/**
* Tool allowlist for this extension. Omit this field to allow all tools.
*/
available_tools?: Array<string> | null;
type: 'streamable_http';
};
+28
View File
@@ -134,6 +134,10 @@ export const zGooseExtension = z.union([
z.boolean(),
z.null()
]).optional(),
available_tools: z.union([
z.array(z.string()),
z.null()
]).optional(),
type: z.literal('builtin')
}),
z.object({
@@ -150,6 +154,10 @@ export const zGooseExtension = z.union([
z.boolean(),
z.null()
]).optional(),
available_tools: z.union([
z.array(z.string()),
z.null()
]).optional(),
type: z.literal('platform')
}),
z.object({
@@ -171,6 +179,10 @@ export const zGooseExtension = z.union([
z.boolean(),
z.null()
]).optional(),
available_tools: z.union([
z.array(z.string()),
z.null()
]).optional(),
type: z.literal('mcp')
})
]);
@@ -1464,6 +1476,10 @@ export const zRecipeExtensionDto = z.union([
z.boolean(),
z.null()
]).optional(),
available_tools: z.union([
z.array(z.string()),
z.null()
]).optional(),
type: z.literal('builtin')
}),
z.object({
@@ -1480,6 +1496,10 @@ export const zRecipeExtensionDto = z.union([
z.boolean(),
z.null()
]).optional(),
available_tools: z.union([
z.array(z.string()),
z.null()
]).optional(),
type: z.literal('platform')
}),
z.object({
@@ -1504,6 +1524,10 @@ export const zRecipeExtensionDto = z.union([
z.boolean(),
z.null()
]).optional(),
available_tools: z.union([
z.array(z.string()),
z.null()
]).optional(),
type: z.literal('stdio')
}),
z.object({
@@ -1528,6 +1552,10 @@ export const zRecipeExtensionDto = z.union([
z.boolean(),
z.null()
]).optional(),
available_tools: z.union([
z.array(z.string()),
z.null()
]).optional(),
type: z.literal('streamable_http')
})
]);