feat: extensions read config (#1637)

This commit is contained in:
Lily Delalande
2025-03-12 18:41:31 -07:00
committed by GitHub
parent 68b3b3f9cc
commit 4537264387
15 changed files with 756 additions and 190 deletions
+235 -14
View File
@@ -33,7 +33,28 @@
}
}
},
"/config/extension": {
"/config/extensions": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_extensions",
"responses": {
"200": {
"description": "All extensions retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExtensionResponse"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
},
"post": {
"tags": [
"super::routes::config_management"
@@ -67,12 +88,24 @@
"description": "Internal server error"
}
}
},
}
},
"/config/extensions/{name}": {
"put": {
"tags": [
"super::routes::config_management"
],
"operationId": "update_extension",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
@@ -85,7 +118,7 @@
},
"responses": {
"200": {
"description": "Extension configuration updated successfully",
"description": "Extension updated successfully",
"content": {
"text/plain": {
"schema": {
@@ -107,16 +140,16 @@
"super::routes::config_management"
],
"operationId": "remove_extension",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigKeyQuery"
}
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"required": true
},
}
],
"responses": {
"200": {
"description": "Extension removed successfully",
@@ -259,6 +292,42 @@
}
}
}
},
"/extensions/{name}/toggle": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "toggle_extension",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Extension toggled successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Extension not found"
},
"500": {
"description": "Internal server error"
}
}
}
}
},
"components": {
@@ -313,19 +382,171 @@
}
}
},
"Envs": {
"type": "object",
"additionalProperties": {
"type": "string",
"description": "A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host"
}
},
"ExtensionConfig": {
"oneOf": [
{
"type": "object",
"description": "Server-sent events client with a URI endpoint",
"required": [
"name",
"uri",
"type"
],
"properties": {
"envs": {
"$ref": "#/components/schemas/Envs"
},
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"timeout": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
},
"type": {
"type": "string",
"enum": [
"sse"
]
},
"uri": {
"type": "string"
}
}
},
{
"type": "object",
"description": "Standard I/O client with command and arguments",
"required": [
"name",
"cmd",
"args",
"type"
],
"properties": {
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"cmd": {
"type": "string"
},
"envs": {
"$ref": "#/components/schemas/Envs"
},
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"timeout": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
},
"type": {
"type": "string",
"enum": [
"stdio"
]
}
}
},
{
"type": "object",
"description": "Built-in extension that is part of the goose binary",
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"timeout": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
},
"type": {
"type": "string",
"enum": [
"builtin"
]
}
}
}
],
"description": "Represents the different types of MCP extensions that can be added to the manager",
"discriminator": {
"propertyName": "type"
}
},
"ExtensionEntry": {
"allOf": [
{
"$ref": "#/components/schemas/ExtensionConfig"
},
{
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
}
}
}
]
},
"ExtensionQuery": {
"type": "object",
"required": [
"name",
"config"
"config",
"enabled"
],
"properties": {
"config": {},
"config": {
"$ref": "#/components/schemas/ExtensionConfig"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"ExtensionResponse": {
"type": "object",
"required": [
"extensions"
],
"properties": {
"extensions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ExtensionEntry"
}
}
}
},
"ProviderDetails": {
"type": "object",
"required": [
+21 -11
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { ReadAllConfigData, ReadAllConfigResponse, RemoveExtensionData, RemoveExtensionResponse, AddExtensionData, AddExtensionResponse, UpdateExtensionData, UpdateExtensionResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
import type { ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, UpdateExtensionData, UpdateExtensionResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ToggleExtensionData, ToggleExtensionResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -25,20 +25,16 @@ export const readAllConfig = <ThrowOnError extends boolean = false>(options?: Op
});
};
export const removeExtension = <ThrowOnError extends boolean = false>(options: Options<RemoveExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).delete<RemoveExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
export const getExtensions = <ThrowOnError extends boolean = false>(options?: Options<GetExtensionsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetExtensionsResponse, unknown, ThrowOnError>({
url: '/config/extensions',
...options
});
};
export const addExtension = <ThrowOnError extends boolean = false>(options: Options<AddExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<AddExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
url: '/config/extensions',
...options,
headers: {
'Content-Type': 'application/json',
@@ -47,9 +43,16 @@ export const addExtension = <ThrowOnError extends boolean = false>(options: Opti
});
};
export const removeExtension = <ThrowOnError extends boolean = false>(options: Options<RemoveExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).delete<RemoveExtensionResponse, unknown, ThrowOnError>({
url: '/config/extensions/{name}',
...options
});
};
export const updateExtension = <ThrowOnError extends boolean = false>(options: Options<UpdateExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).put<UpdateExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
url: '/config/extensions/{name}',
...options,
headers: {
'Content-Type': 'application/json',
@@ -96,4 +99,11 @@ export const upsertConfig = <ThrowOnError extends boolean = false>(options: Opti
...options?.headers
}
});
};
export const toggleExtension = <ThrowOnError extends boolean = false>(options: Options<ToggleExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ToggleExtensionResponse, unknown, ThrowOnError>({
url: '/extensions/{name}/toggle',
...options
});
};
+120 -18
View File
@@ -16,9 +16,55 @@ export type ConfigResponse = {
config: {};
};
export type ExtensionQuery = {
config: unknown;
export type Envs = {
[key: string]: string;
};
/**
* Represents the different types of MCP extensions that can be added to the manager
*/
export type ExtensionConfig = {
envs?: Envs;
/**
* The name used to identify this extension
*/
name: string;
timeout?: number | null;
type: 'sse';
uri: string;
} | {
args: Array<string>;
cmd: string;
envs?: Envs;
/**
* The name used to identify this extension
*/
name: string;
timeout?: number | null;
type: 'stdio';
} | {
/**
* The name used to identify this extension
*/
name: string;
timeout?: number | null;
type: 'builtin';
};
export type ExtensionEntry = ExtensionConfig & {
type?: 'ExtensionEntry';
} & {
enabled: boolean;
};
export type ExtensionQuery = {
config: ExtensionConfig;
enabled: boolean;
name: string;
};
export type ExtensionResponse = {
extensions: Array<ExtensionEntry>;
};
export type ProviderDetails = {
@@ -94,38 +140,34 @@ export type ReadAllConfigResponses = {
export type ReadAllConfigResponse = ReadAllConfigResponses[keyof ReadAllConfigResponses];
export type RemoveExtensionData = {
body: ConfigKeyQuery;
export type GetExtensionsData = {
body?: never;
path?: never;
query?: never;
url: '/config/extension';
url: '/config/extensions';
};
export type RemoveExtensionErrors = {
/**
* Extension not found
*/
404: unknown;
export type GetExtensionsErrors = {
/**
* Internal server error
*/
500: unknown;
};
export type RemoveExtensionResponses = {
export type GetExtensionsResponses = {
/**
* Extension removed successfully
* All extensions retrieved successfully
*/
200: string;
200: ExtensionResponse;
};
export type RemoveExtensionResponse = RemoveExtensionResponses[keyof RemoveExtensionResponses];
export type GetExtensionsResponse = GetExtensionsResponses[keyof GetExtensionsResponses];
export type AddExtensionData = {
body: ExtensionQuery;
path?: never;
query?: never;
url: '/config/extension';
url: '/config/extensions';
};
export type AddExtensionErrors = {
@@ -148,11 +190,42 @@ export type AddExtensionResponses = {
export type AddExtensionResponse = AddExtensionResponses[keyof AddExtensionResponses];
export type RemoveExtensionData = {
body?: never;
path: {
name: string;
};
query?: never;
url: '/config/extensions/{name}';
};
export type RemoveExtensionErrors = {
/**
* Extension not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type RemoveExtensionResponses = {
/**
* Extension removed successfully
*/
200: string;
};
export type RemoveExtensionResponse = RemoveExtensionResponses[keyof RemoveExtensionResponses];
export type UpdateExtensionData = {
body: ExtensionQuery;
path?: never;
path: {
name: string;
};
query?: never;
url: '/config/extension';
url: '/config/extensions/{name}';
};
export type UpdateExtensionErrors = {
@@ -168,7 +241,7 @@ export type UpdateExtensionErrors = {
export type UpdateExtensionResponses = {
/**
* Extension configuration updated successfully
* Extension updated successfully
*/
200: string;
};
@@ -262,6 +335,35 @@ export type UpsertConfigResponses = {
export type UpsertConfigResponse = UpsertConfigResponses[keyof UpsertConfigResponses];
export type ToggleExtensionData = {
body?: never;
path: {
name: string;
};
query?: never;
url: '/extensions/{name}/toggle';
};
export type ToggleExtensionErrors = {
/**
* Extension not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ToggleExtensionResponses = {
/**
* Extension toggled successfully
*/
200: string;
};
export type ToggleExtensionResponse = ToggleExtensionResponses[keyof ToggleExtensionResponses];
export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {});
};
+46 -12
View File
@@ -4,6 +4,8 @@ import {
readConfig,
removeConfig,
upsertConfig,
getExtensions as apiGetExtensions,
toggleExtension as apiToggleExtension,
addExtension as apiAddExtension,
removeExtension as apiRemoveExtension,
updateExtension as apiUpdateExtension,
@@ -14,8 +16,11 @@ import type {
ConfigResponse,
UpsertConfigQuery,
ConfigKeyQuery,
ExtensionQuery,
ExtensionResponse,
ExtensionEntry,
ProviderDetails,
ExtensionQuery,
ExtensionConfig,
} from '../api/types.gen';
// Initialize client configuration
@@ -33,10 +38,12 @@ interface ConfigContextType {
upsert: (key: string, value: unknown, is_secret: boolean) => Promise<void>;
read: (key: string, is_secret: boolean) => Promise<unknown>;
remove: (key: string, is_secret: boolean) => Promise<void>;
addExtension: (name: string, config: unknown) => Promise<void>;
updateExtension: (name: string, config: unknown) => Promise<void>;
addExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
updateExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
toggleExtension: (name: string) => Promise<void>;
removeExtension: (name: string) => Promise<void>;
getProviders: (b: boolean) => Promise<ProviderDetails[]>;
getExtensions: (b: boolean) => Promise<ExtensionEntry[]>
}
interface ConfigProviderProps {
@@ -48,6 +55,7 @@ const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
const [config, setConfig] = useState<ConfigResponse['config']>({});
const [providersList, setProvidersList] = useState<ProviderDetails[]>([]);
const [extensionsList, setExtensionsList] = useState<ExtensionEntry[]>([]);
useEffect(() => {
// Load all configuration data and providers on mount
@@ -63,6 +71,15 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
} catch (error) {
console.error('Failed to load providers:', error);
}
// Load extensions
try {
const extensionsResponse = await apiGetExtensions()
setExtensionsList(extensionsResponse.data.extensions)
} catch (error) {
console.error('Failed to load extensions:', error)
}
})();
}, []);
@@ -99,8 +116,8 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
await reloadConfig();
};
const addExtension = async (name: string, config: unknown) => {
const query: ExtensionQuery = { name, config };
const addExtension = async (name: string, config: ExtensionConfig, enabled: boolean) => {
const query: ExtensionQuery = { name, config, enabled };
await apiAddExtension({
body: query,
});
@@ -108,18 +125,21 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
};
const removeExtension = async (name: string) => {
const query: ConfigKeyQuery = { key: name, is_secret: false };
await apiRemoveExtension({
await apiRemoveExtension({path: {name: name}});
await reloadConfig();
};
const updateExtension = async (name: string, config: ExtensionConfig, enabled: boolean) => {
const query: ExtensionQuery = { name, config, enabled };
await apiUpdateExtension({
body: query,
path: {name: name}
});
await reloadConfig();
};
const updateExtension = async (name: string, config: unknown) => {
const query: ExtensionQuery = { name, config };
await apiUpdateExtension({
body: query,
});
const toggleExtension = async (name: string) => {
await apiToggleExtension({path: {name: name}});
await reloadConfig();
};
@@ -134,6 +154,18 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
return providersList;
};
const getExtensions = async (forceRefresh = false): Promise<ExtensionEntry[]> => {
if (forceRefresh || extensionsList.length === 0) {
// If a refresh is forced, or we don't have providers yet
const response = await apiGetExtensions();
const extensionResponse: ExtensionResponse = response.data
setExtensionsList(extensionResponse.extensions);
return extensionResponse.extensions;
}
// Otherwise return the cached providers
return extensionsList;
};
const contextValue = useMemo(
() => ({
config,
@@ -144,7 +176,9 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
addExtension,
updateExtension,
removeExtension,
toggleExtension,
getProviders,
getExtensions,
}),
[config, providersList]
); // Functions don't need to be dependencies as they don't change
@@ -6,7 +6,7 @@ import { useConfig } from '../ConfigContext';
import { Button } from '../ui/button';
import { Plus } from 'lucide-react';
import { Gear } from '../icons/Gear';
import ExtensionsSection from './ExtensionsSection';
import ExtensionsSection from './extensions/ExtensionsSection';
interface ModelOption {
id: string;
@@ -1,14 +1,14 @@
import React, { useEffect, useState } from 'react';
import { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import { Button } from '../../ui/button';
import { Switch } from '../../ui/switch';
import { Plus, X } from 'lucide-react';
import { Gear } from '../icons/Gear';
import { GPSIcon } from '../ui/icons';
import { useConfig } from '../ConfigContext';
import Modal from '../Modal';
import { Input } from '../ui/input';
import { Gear } from '../../icons/Gear';
import { GPSIcon } from '../../ui/icons';
import { useConfig } from '../../ConfigContext';
import Modal from '../../Modal';
import { Input } from '../../ui/input';
import Select from 'react-select';
import { createDarkSelectStyles, darkSelectTheme } from '../ui/select-styles';
import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles';
interface ExtensionConfig {
args?: string[];
@@ -16,7 +16,7 @@ interface ExtensionConfig {
enabled: boolean;
envs?: Record<string, string>;
name: string;
type: 'stdio' | 'sse';
type: 'stdio' | 'sse' | 'builtin';
}
interface ExtensionItem {
@@ -50,7 +50,7 @@ const getSubtitle = (config: ExtensionConfig): string => {
};
export default function ExtensionsSection() {
const { config, updateExtension, addExtension } = useConfig();
const { config, read, updateExtension, addExtension } = useConfig();
const [extensions, setExtensions] = useState<ExtensionItem[]>([]);
const [selectedExtension, setSelectedExtension] = useState<ExtensionItem | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -74,8 +74,9 @@ export default function ExtensionsSection() {
});
useEffect(() => {
if (config.extensions) {
const extensionItems: ExtensionItem[] = Object.entries(config.extensions).map(
const extensions = read('extensions', false)
if (extensions) {
const extensionItems: ExtensionItem[] = Object.entries(extensions).map(
([name, ext]) => {
const extensionConfig = ext as ExtensionConfig;
return {
@@ -90,7 +91,7 @@ export default function ExtensionsSection() {
);
setExtensions(extensionItems);
}
}, [config.extensions]);
}, [read]);
useEffect(() => {
if (selectedExtension) {
@@ -0,0 +1,121 @@
// TODO: copied this from old code
// import {View} from '../../../App'
// import {SettingsViewOptions} from "../SettingsView";
// import {toast} from "react-toastify";
//
//
// export async function addExtensionFromDeepLink(
// url: string,
// setView: (view: View, options: SettingsViewOptions) => void
// ) {
// if (!url.startsWith('goose://extension')) {
// handleError(
// 'Failed to install extension: Invalid URL: URL must use the goose://extension scheme'
// );
// return;
// }
//
// const parsedUrl = new URL(url);
//
// if (parsedUrl.protocol !== 'goose:') {
// handleError(
// 'Failed to install extension: Invalid protocol: URL must use the goose:// scheme',
// true
// );
// }
//
// // Check that all required fields are present and not empty
// const requiredFields = ['name', 'description'];
//
// for (const field of requiredFields) {
// const value = parsedUrl.searchParams.get(field);
// if (!value || value.trim() === '') {
// handleError(
// `Failed to install extension: The link is missing required field '${field}'`,
// true
// );
// }
// }
//
// const cmd = parsedUrl.searchParams.get('cmd');
// if (!cmd) {
// handleError("Failed to install extension: Missing required 'cmd' parameter in the URL", true);
// }
//
// // Validate that the command is one of the allowed commands
// const allowedCommands = ['npx', 'uvx', 'goosed'];
// if (!allowedCommands.includes(cmd)) {
// handleError(
// `Failed to install extension: Invalid command: ${cmd}. Only ${allowedCommands.join(', ')} are allowed.`,
// true
// );
// }
//
// // Check for security risk with npx -c command
// const args = parsedUrl.searchParams.getAll('arg');
// if (cmd === 'npx' && args.includes('-c')) {
// handleError(
// 'Failed to install extension: npx with -c argument can lead to code injection',
// true
// );
// }
//
// const envList = parsedUrl.searchParams.getAll('env');
// const id = parsedUrl.searchParams.get('id');
// const name = parsedUrl.searchParams.get('name');
// const description = parsedUrl.searchParams.get('description');
// const timeout = parsedUrl.searchParams.get('timeout');
//
// // split env based on delimiter to a map
// const envs = envList.reduce(
// (acc, env) => {
// const [key, value] = env.split('=');
// acc[key] = value;
// return acc;
// },
// {} as Record<string, string>
// );
//
// // Create a ExtensionConfig from the URL parameters
// // Parse timeout if provided, otherwise use default
// const parsedTimeout = timeout ? parseInt(timeout, 10) : null;
//
// const extensionConfig: ExtensionConfig = {
// id,
// name,
// type: 'stdio',
// cmd,
// args,
// description,
// enabled: true,
// env_keys: Object.keys(envs).length > 0 ? Object.keys(envs) : [],
// timeout:
// parsedTimeout !== null && !isNaN(parsedTimeout) && Number.isInteger(parsedTimeout)
// ? parsedTimeout
// : DEFAULT_EXTENSION_TIMEOUT,
// };
//
// // Store the extension config regardless of env vars status
// storeExtensionConfig(extensionConfig);
//
// // Check if extension requires env vars and go to settings if so
// if (envVarsRequired(extensionConfig)) {
// console.log('Environment variables required, redirecting to settings');
// setView('settings', { extensionId: extensionConfig.id, showEnvVars: true });
// return;
// }
//
// // If no env vars are required, proceed with extending Goosed
// await addExtension(extensionConfig);
// }
//
// function handleError(message: string, shouldThrow = false): void {
// toast.error(message);
// console.error(message);
// if (shouldThrow) {
// throw new Error(message);
// }
// }
// TODO: when rust app starts, add built-in extensions to config.yaml if they aren't there already