feat: Adding streamable-http transport support for backend, desktop and cli (#2942)
This commit is contained in:
@@ -124,6 +124,21 @@ export type ExtensionConfig = {
|
||||
name: string;
|
||||
timeout?: number | null;
|
||||
type: 'builtin';
|
||||
} | {
|
||||
/**
|
||||
* Whether this extension is bundled with Goose
|
||||
*/
|
||||
bundled?: boolean | null;
|
||||
description?: string | null;
|
||||
env_keys?: Array<string>;
|
||||
envs?: Envs;
|
||||
/**
|
||||
* The name used to identify this extension
|
||||
*/
|
||||
name: string;
|
||||
timeout?: number | null;
|
||||
type: 'streamable_http';
|
||||
uri: string;
|
||||
} | {
|
||||
/**
|
||||
* Whether this extension is bundled with Goose
|
||||
|
||||
@@ -36,7 +36,7 @@ interface CreateScheduleModalProps {
|
||||
// Interface for clean extension in YAML
|
||||
interface CleanExtension {
|
||||
name: string;
|
||||
type: 'stdio' | 'sse' | 'builtin' | 'frontend';
|
||||
type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http';
|
||||
cmd?: string;
|
||||
args?: string[];
|
||||
uri?: string;
|
||||
@@ -160,6 +160,8 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
|
||||
|
||||
if (ext.type === 'sse' && extAny.uri) {
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (ext.type === 'streamable_http' && extAny.uri) {
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (ext.type === 'stdio') {
|
||||
if (extAny.cmd) {
|
||||
cleanExt.cmd = extAny.cmd as string;
|
||||
@@ -195,7 +197,8 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
|
||||
cleanExt.type = 'stdio';
|
||||
cleanExt.cmd = extAny.command as string;
|
||||
} else if (extAny.uri) {
|
||||
cleanExt.type = 'sse';
|
||||
// Default to streamable_http for URI-based extensions for forward compatibility
|
||||
cleanExt.type = 'streamable_http';
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (extAny.tools) {
|
||||
cleanExt.type = 'frontend';
|
||||
|
||||
@@ -72,6 +72,26 @@ function getSseConfig(remoteUrl: string, name: string, description: string, time
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an extension config for Streamable HTTP from the deeplink URL
|
||||
*/
|
||||
function getStreamableHttpConfig(
|
||||
remoteUrl: string,
|
||||
name: string,
|
||||
description: string,
|
||||
timeout: number
|
||||
) {
|
||||
const config: ExtensionConfig = {
|
||||
name,
|
||||
type: 'streamable_http',
|
||||
uri: remoteUrl,
|
||||
description,
|
||||
timeout: timeout,
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles adding an extension from a deeplink URL
|
||||
*/
|
||||
@@ -120,9 +140,12 @@ export async function addExtensionFromDeepLink(
|
||||
|
||||
const cmd = parsedUrl.searchParams.get('cmd');
|
||||
const remoteUrl = parsedUrl.searchParams.get('url');
|
||||
const transportType = parsedUrl.searchParams.get('transport') || 'sse'; // Default to SSE for backward compatibility
|
||||
|
||||
const config = remoteUrl
|
||||
? getSseConfig(remoteUrl, name, description || '', timeout)
|
||||
? transportType === 'streamable_http'
|
||||
? getStreamableHttpConfig(remoteUrl, name, description || '', timeout)
|
||||
: getSseConfig(remoteUrl, name, description || '', timeout)
|
||||
: getStdioConfig(cmd!, parsedUrl, name, description || '', timeout);
|
||||
|
||||
// Check if extension requires env vars and go to settings if so
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Input } from '../../../ui/input';
|
||||
|
||||
interface ExtensionConfigFieldsProps {
|
||||
type: 'stdio' | 'sse' | 'builtin';
|
||||
type: 'stdio' | 'sse' | 'streamable_http' | 'builtin';
|
||||
full_cmd: string;
|
||||
endpoint: string;
|
||||
onChange: (key: string, value: string) => void;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Select } from '../../../ui/Select';
|
||||
|
||||
interface ExtensionInfoFieldsProps {
|
||||
name: string;
|
||||
type: 'stdio' | 'sse' | 'builtin';
|
||||
type: 'stdio' | 'sse' | 'streamable_http' | 'builtin';
|
||||
description: string;
|
||||
onChange: (key: string, value: string) => void;
|
||||
submitAttempted: boolean;
|
||||
@@ -43,7 +43,17 @@ export default function ExtensionInfoFields({
|
||||
<div className="w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block text-textStandard">Type</label>
|
||||
<Select
|
||||
value={{ value: type, label: type.toUpperCase() }}
|
||||
value={{
|
||||
value: type,
|
||||
label:
|
||||
type === 'stdio'
|
||||
? 'STDIO'
|
||||
: type === 'sse'
|
||||
? 'SSE'
|
||||
: type === 'streamable_http'
|
||||
? 'HTTP'
|
||||
: type.toUpperCase(),
|
||||
}}
|
||||
onChange={(newValue: unknown) => {
|
||||
const option = newValue as { value: string; label: string } | null;
|
||||
if (option) {
|
||||
@@ -53,6 +63,7 @@ export default function ExtensionInfoFields({
|
||||
options={[
|
||||
{ value: 'stdio', label: 'Standard IO (STDIO)' },
|
||||
{ value: 'sse', label: 'Server-Sent Events (SSE)' },
|
||||
{ value: 'streamable_http', label: 'Streamable HTTP' },
|
||||
]}
|
||||
isSearchable={false}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '../../../ui/button';
|
||||
import Modal from '../../../Modal';
|
||||
import { ExtensionFormData } from '../utils';
|
||||
import EnvVarsSection from './EnvVarsSection';
|
||||
import HeadersSection from './HeadersSection';
|
||||
import ExtensionConfigFields from './ExtensionConfigFields';
|
||||
import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import ExtensionInfoFields from './ExtensionInfoFields';
|
||||
@@ -34,13 +35,18 @@ export default function ExtensionModal({
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [showCloseConfirmation, setShowCloseConfirmation] = useState(false);
|
||||
const [hasPendingEnvVars, setHasPendingEnvVars] = useState(false);
|
||||
const [hasPendingHeaders, setHasPendingHeaders] = useState(false);
|
||||
|
||||
// Function to check if form has been modified
|
||||
const hasFormChanges = (): boolean => {
|
||||
// Check if command/endpoint has changed
|
||||
const commandChanged =
|
||||
(formData.type === 'stdio' && formData.cmd !== initialData.cmd) ||
|
||||
(formData.type === 'sse' && formData.endpoint !== initialData.endpoint);
|
||||
(formData.type === 'sse' && formData.endpoint !== initialData.endpoint) ||
|
||||
(formData.type === 'streamable_http' && formData.endpoint !== initialData.endpoint);
|
||||
|
||||
// Check if headers have changed
|
||||
const headersChanged = formData.headers.some((header) => header.isEdited === true);
|
||||
|
||||
// Check if any environment variables have been modified
|
||||
const envVarsChanged = formData.envVars.some((envVar) => envVar.isEdited === true);
|
||||
@@ -60,10 +66,11 @@ export default function ExtensionModal({
|
||||
);
|
||||
|
||||
// Check if there are pending environment variables being typed
|
||||
const hasPendingInput = hasPendingEnvVars;
|
||||
const hasPendingInput = hasPendingEnvVars || hasPendingHeaders;
|
||||
|
||||
return (
|
||||
commandChanged ||
|
||||
headersChanged ||
|
||||
envVarsChanged ||
|
||||
envVarsAdded ||
|
||||
envVarsRemoved ||
|
||||
@@ -123,6 +130,37 @@ export default function ExtensionModal({
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddHeader = (key: string, value: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
headers: [...formData.headers, { key, value, isEdited: true }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (index: number) => {
|
||||
const newHeaders = [...formData.headers];
|
||||
newHeaders.splice(index, 1);
|
||||
setFormData({
|
||||
...formData,
|
||||
headers: newHeaders,
|
||||
});
|
||||
};
|
||||
|
||||
const handleHeaderChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newHeaders = [...formData.headers];
|
||||
newHeaders[index][field] = value;
|
||||
|
||||
// Mark as edited if it's a value change
|
||||
if (field === 'value') {
|
||||
newHeaders[index].isEdited = true;
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
headers: newHeaders,
|
||||
});
|
||||
};
|
||||
|
||||
// Function to store a secret value
|
||||
const storeSecret = async (key: string, value: string) => {
|
||||
try {
|
||||
@@ -159,7 +197,10 @@ export default function ExtensionModal({
|
||||
const isConfigValid = () => {
|
||||
return (
|
||||
(formData.type === 'stdio' && !!formData.cmd && formData.cmd.trim() !== '') ||
|
||||
(formData.type === 'sse' && !!formData.endpoint && formData.endpoint.trim() !== '')
|
||||
(formData.type === 'sse' && !!formData.endpoint && formData.endpoint.trim() !== '') ||
|
||||
(formData.type === 'streamable_http' &&
|
||||
!!formData.endpoint &&
|
||||
formData.endpoint.trim() !== '')
|
||||
);
|
||||
};
|
||||
|
||||
@@ -169,6 +210,12 @@ export default function ExtensionModal({
|
||||
);
|
||||
};
|
||||
|
||||
const isHeadersValid = () => {
|
||||
return formData.headers.every(
|
||||
({ key, value }) => (key === '' && value === '') || (key !== '' && value !== '')
|
||||
);
|
||||
};
|
||||
|
||||
const isTimeoutValid = () => {
|
||||
// Check if timeout is not undefined, null, or empty string
|
||||
if (formData.timeout === undefined || formData.timeout === null) {
|
||||
@@ -185,7 +232,9 @@ export default function ExtensionModal({
|
||||
|
||||
// Form validation
|
||||
const isFormValid = () => {
|
||||
return isNameValid() && isConfigValid() && isEnvVarsValid() && isTimeoutValid();
|
||||
return (
|
||||
isNameValid() && isConfigValid() && isEnvVarsValid() && isHeadersValid() && isTimeoutValid()
|
||||
);
|
||||
};
|
||||
|
||||
// Handle submit with validation and secret storage
|
||||
@@ -344,6 +393,25 @@ export default function ExtensionModal({
|
||||
onPendingInputChange={setHasPendingEnvVars}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Request Headers - Only for streamable_http */}
|
||||
{formData.type === 'streamable_http' && (
|
||||
<>
|
||||
{/* Divider */}
|
||||
<hr className="border-t border-borderSubtle mb-4" />
|
||||
|
||||
<div className="mb-6">
|
||||
<HeadersSection
|
||||
headers={formData.headers}
|
||||
onAdd={handleAddHeader}
|
||||
onRemove={handleRemoveHeader}
|
||||
onChange={handleHeaderChange}
|
||||
submitAttempted={submitAttempted}
|
||||
onPendingInputChange={setHasPendingHeaders}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Input } from '../../../ui/input';
|
||||
import { cn } from '../../../../utils';
|
||||
|
||||
interface HeadersSectionProps {
|
||||
headers: { key: string; value: string; isEdited?: boolean }[];
|
||||
onAdd: (key: string, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
||||
submitAttempted: boolean;
|
||||
onPendingInputChange?: (hasPending: boolean) => void;
|
||||
}
|
||||
|
||||
export default function HeadersSection({
|
||||
headers,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onChange,
|
||||
submitAttempted,
|
||||
onPendingInputChange,
|
||||
}: HeadersSectionProps) {
|
||||
const [newKey, setNewKey] = React.useState('');
|
||||
const [newValue, setNewValue] = React.useState('');
|
||||
const [validationError, setValidationError] = React.useState<string | null>(null);
|
||||
const [invalidFields, setInvalidFields] = React.useState<{ key: boolean; value: boolean }>({
|
||||
key: false,
|
||||
value: false,
|
||||
});
|
||||
|
||||
// Track pending input changes
|
||||
React.useEffect(() => {
|
||||
const hasPendingInput = newKey.trim() !== '' || newValue.trim() !== '';
|
||||
onPendingInputChange?.(hasPendingInput);
|
||||
}, [newKey, newValue, onPendingInputChange]);
|
||||
|
||||
const handleAdd = () => {
|
||||
const keyEmpty = !newKey.trim();
|
||||
const valueEmpty = !newValue.trim();
|
||||
const keyHasSpaces = newKey.includes(' ');
|
||||
|
||||
if (keyEmpty || valueEmpty) {
|
||||
setInvalidFields({
|
||||
key: keyEmpty,
|
||||
value: valueEmpty,
|
||||
});
|
||||
setValidationError('Both header name and value must be entered');
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyHasSpaces) {
|
||||
setInvalidFields({
|
||||
key: true,
|
||||
value: false,
|
||||
});
|
||||
setValidationError('Header name cannot contain spaces');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
setInvalidFields({ key: false, value: false });
|
||||
onAdd(newKey, newValue);
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
};
|
||||
|
||||
const clearValidation = () => {
|
||||
setValidationError(null);
|
||||
setInvalidFields({ key: false, value: false });
|
||||
};
|
||||
|
||||
const isFieldInvalid = (index: number, field: 'key' | 'value') => {
|
||||
if (!submitAttempted) return false;
|
||||
const value = headers[index][field].trim();
|
||||
return value === '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mb-2">
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">Request Headers</label>
|
||||
<p className="text-xs text-textSubtle mb-4">
|
||||
Add custom HTTP headers to include in requests to the MCP server. Click the "+" button to
|
||||
add after filling both fields.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
||||
{/* Existing headers */}
|
||||
{headers.map((header, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => onChange(index, 'key', e.target.value)}
|
||||
placeholder="Header name"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
isFieldInvalid(index, 'key') && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => onChange(index, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
isFieldInvalid(index, 'value') && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
className="group p-2 h-auto text-iconSubtle hover:bg-transparent"
|
||||
>
|
||||
<X className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Empty row with Add button */}
|
||||
<Input
|
||||
value={newKey}
|
||||
onChange={(e) => {
|
||||
setNewKey(e.target.value);
|
||||
clearValidation();
|
||||
}}
|
||||
placeholder="Header name"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
invalidFields.key && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={newValue}
|
||||
onChange={(e) => {
|
||||
setNewValue(e.target.value);
|
||||
clearValidation();
|
||||
}}
|
||||
placeholder="Value"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
invalidFields.value && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
variant="ghost"
|
||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-textStandard bg-bgApp border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||
>
|
||||
<Plus /> Add
|
||||
</Button>
|
||||
</div>
|
||||
{validationError && <div className="mt-2 text-red-500 text-sm">{validationError}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +93,14 @@ export function getSubtitle(config: ExtensionConfig): SubtitleParts {
|
||||
return { description, command };
|
||||
}
|
||||
|
||||
if (config.type === 'streamable_http') {
|
||||
const description = config.description
|
||||
? `Streamable HTTP extension: ${config.description}`
|
||||
: 'Streamable HTTP extension';
|
||||
const command = config.uri || null;
|
||||
return { description, command };
|
||||
}
|
||||
|
||||
return {
|
||||
description: 'Unknown type of extension',
|
||||
command: null,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { ExtensionConfig } from '../../../api/types.gen';
|
||||
export interface ExtensionFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'stdio' | 'sse' | 'builtin';
|
||||
type: 'stdio' | 'sse' | 'streamable_http' | 'builtin';
|
||||
cmd?: string;
|
||||
endpoint?: string;
|
||||
enabled: boolean;
|
||||
@@ -31,6 +31,11 @@ export interface ExtensionFormData {
|
||||
value: string;
|
||||
isEdited?: boolean;
|
||||
}[];
|
||||
headers: {
|
||||
key: string;
|
||||
value: string;
|
||||
isEdited?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function getDefaultFormData(): ExtensionFormData {
|
||||
@@ -43,12 +48,14 @@ export function getDefaultFormData(): ExtensionFormData {
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFormData {
|
||||
// Type guard: Check if 'envs' property exists for this variant
|
||||
const hasEnvs = extension.type === 'sse' || extension.type === 'stdio';
|
||||
const hasEnvs =
|
||||
extension.type === 'sse' || extension.type === 'streamable_http' || extension.type === 'stdio';
|
||||
|
||||
// Handle both envs (legacy) and env_keys (new secrets)
|
||||
let envVars = [];
|
||||
@@ -75,16 +82,32 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
|
||||
);
|
||||
}
|
||||
|
||||
// Handle headers for streamable_http
|
||||
let headers = [];
|
||||
if (extension.type === 'streamable_http' && 'headers' in extension && extension.headers) {
|
||||
headers.push(
|
||||
...Object.entries(extension.headers).map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
isEdited: false, // Mark as not edited initially
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: extension.name || '',
|
||||
description:
|
||||
extension.type === 'stdio' || extension.type === 'sse' ? extension.description || '' : '',
|
||||
extension.type === 'stdio' || extension.type === 'sse' || extension.type === 'streamable_http'
|
||||
? extension.description || ''
|
||||
: '',
|
||||
type: extension.type === 'frontend' ? 'stdio' : extension.type,
|
||||
cmd: extension.type === 'stdio' ? combineCmdAndArgs(extension.cmd, extension.args) : undefined,
|
||||
endpoint: extension.type === 'sse' ? extension.uri : undefined,
|
||||
endpoint:
|
||||
extension.type === 'sse' || extension.type === 'streamable_http' ? extension.uri : undefined,
|
||||
enabled: extension.enabled,
|
||||
timeout: 'timeout' in extension ? (extension.timeout ?? undefined) : undefined,
|
||||
envVars,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +137,27 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
|
||||
uri: formData.endpoint || '',
|
||||
...(env_keys.length > 0 ? { env_keys } : {}),
|
||||
};
|
||||
} else if (formData.type === 'streamable_http') {
|
||||
// Extract headers
|
||||
const headers = formData.headers
|
||||
.filter(({ key, value }) => key.length > 0 && value.length > 0)
|
||||
.reduce(
|
||||
(acc, header) => {
|
||||
acc[header.key] = header.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'streamable_http',
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
timeout: formData.timeout,
|
||||
uri: formData.endpoint || '',
|
||||
...(env_keys.length > 0 ? { env_keys } : {}),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
};
|
||||
} else {
|
||||
// For other types
|
||||
return {
|
||||
|
||||
@@ -17,6 +17,14 @@ export type ExtensionConfig =
|
||||
env_keys?: string[];
|
||||
timeout?: number;
|
||||
}
|
||||
| {
|
||||
type: 'streamable_http';
|
||||
name: string;
|
||||
uri: string;
|
||||
env_keys?: string[];
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
| {
|
||||
type: 'stdio';
|
||||
name: string;
|
||||
@@ -73,6 +81,10 @@ export async function addExtension(
|
||||
name: sanitizeName(extension.name),
|
||||
uri: extension.uri,
|
||||
}),
|
||||
...(extension.type === 'streamable_http' && {
|
||||
name: sanitizeName(extension.name),
|
||||
uri: extension.uri,
|
||||
}),
|
||||
...(extension.type === 'builtin' && {
|
||||
name: sanitizeName(extension.name),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user