feat: Adding streamable-http transport support for backend, desktop and cli (#2942)

This commit is contained in:
btdeviant
2025-07-01 16:35:11 -07:00
committed by GitHub
parent 2cadc1db7e
commit 2948d4a375
21 changed files with 1085 additions and 21 deletions
+15
View File
@@ -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 {
+12
View File
@@ -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),
}),