acp migration (ui) : ui connect to acp directly instead of goosed (#10081)
This commit is contained in:
@@ -34,6 +34,7 @@ import type { CallToolResult, JSONRPCRequest, Tool } from '@modelcontextprotocol
|
||||
import { GripHorizontal, Maximize2, PictureInPicture2, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { callMcpAppTool, readMcpAppResource } from '../../acp/mcp-apps';
|
||||
import { httpBaseFromAcpWebSocketUrl, isLoopbackAcpWebSocketUrl } from '../../acp/url';
|
||||
import { getCachedTools } from './toolsCache';
|
||||
import { AppEvents } from '../../constants/events';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
@@ -181,31 +182,37 @@ function getContainerDimensions(
|
||||
|
||||
async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise<string | null> {
|
||||
try {
|
||||
const baseUrl = await window.electron.getGoosedHostPort();
|
||||
const acpUrl = await window.electron.getAcpUrl();
|
||||
const secretKey = await window.electron.getSecretKey();
|
||||
|
||||
if (!baseUrl || !secretKey) {
|
||||
console.error('[McpAppRenderer] Failed to get goosed host/port or secret key');
|
||||
if (!acpUrl || !secretKey) {
|
||||
console.error('[McpAppRenderer] Failed to get ACP URL or secret key');
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('secret', secretKey);
|
||||
if (!isLoopbackAcpWebSocketUrl(acpUrl)) {
|
||||
console.error('[McpAppRenderer] MCP app proxy is only supported for loopback ACP backends');
|
||||
return null;
|
||||
}
|
||||
|
||||
const httpBase = httpBaseFromAcpWebSocketUrl(acpUrl).replace(/\/+$/, '');
|
||||
const proxyUrl = new URL(`${httpBase}/mcp-app-proxy`);
|
||||
proxyUrl.searchParams.set('secret', secretKey);
|
||||
|
||||
if (csp?.connectDomains?.length) {
|
||||
params.set('connect_domains', csp.connectDomains.join(','));
|
||||
proxyUrl.searchParams.set('connect_domains', csp.connectDomains.join(','));
|
||||
}
|
||||
if (csp?.resourceDomains?.length) {
|
||||
params.set('resource_domains', csp.resourceDomains.join(','));
|
||||
proxyUrl.searchParams.set('resource_domains', csp.resourceDomains.join(','));
|
||||
}
|
||||
if (csp?.frameDomains?.length) {
|
||||
params.set('frame_domains', csp.frameDomains.join(','));
|
||||
proxyUrl.searchParams.set('frame_domains', csp.frameDomains.join(','));
|
||||
}
|
||||
if (csp?.baseUriDomains?.length) {
|
||||
params.set('base_uri_domains', csp.baseUriDomains.join(','));
|
||||
proxyUrl.searchParams.set('base_uri_domains', csp.baseUriDomains.join(','));
|
||||
}
|
||||
|
||||
return `${baseUrl}/mcp-app-proxy?${params.toString()}`;
|
||||
return proxyUrl.toString();
|
||||
} catch (error) {
|
||||
console.error('[McpAppRenderer] Error fetching MCP App Proxy URL:', error);
|
||||
return null;
|
||||
@@ -253,7 +260,9 @@ interface GooseAppFrameProps {
|
||||
onMessage: (params: {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
onOpenLink: (params: { url: string }) => Promise<{ status: 'success' | 'error'; message?: string }>;
|
||||
onOpenLink: (params: {
|
||||
url: string;
|
||||
}) => Promise<{ status: 'success' | 'error'; message?: string }>;
|
||||
onCallTool: (params: {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
@@ -334,12 +343,9 @@ function GooseAppFrame({
|
||||
logging: {},
|
||||
message: { text: {} },
|
||||
};
|
||||
const bridge = new AppBridge(
|
||||
null,
|
||||
{ name: 'MCP-UI Host', version: '1.0.0' },
|
||||
capabilities,
|
||||
{ hostContext: hostContextRef.current }
|
||||
);
|
||||
const bridge = new AppBridge(null, { name: 'MCP-UI Host', version: '1.0.0' }, capabilities, {
|
||||
hostContext: hostContextRef.current,
|
||||
});
|
||||
bridge.onmessage = (params) => onMessageRef.current(params);
|
||||
bridge.onopenlink = (params) => onOpenLinkRef.current(params);
|
||||
bridge.onloggingmessage = (params) => onLoggingMessageRef.current(params);
|
||||
|
||||
@@ -4,30 +4,35 @@ import { Input } from '../../ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { ExternalGoosedConfig, defaultSettings } from '../../../utils/settings';
|
||||
import { WEB_PROTOCOLS } from '../../../utils/urlSecurity';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
import { normalizeAcpHttpBaseUrl } from '../../../acp/url';
|
||||
|
||||
const i18n = defineMessages({
|
||||
title: {
|
||||
id: 'externalBackendSection.title',
|
||||
defaultMessage: 'Goose Server',
|
||||
defaultMessage: 'External Backend (ACP)',
|
||||
},
|
||||
description: {
|
||||
id: 'externalBackendSection.description',
|
||||
defaultMessage:
|
||||
'By default goose launches a server for you, use this to connect to an external goose server',
|
||||
'By default Goose starts a local backend. Use this to connect to an external ACP-compatible backend.',
|
||||
},
|
||||
useExternalServer: {
|
||||
id: 'externalBackendSection.useExternalServer',
|
||||
defaultMessage: 'Use external server',
|
||||
defaultMessage: 'Use external backend',
|
||||
},
|
||||
useExternalServerDescription: {
|
||||
id: 'externalBackendSection.useExternalServerDescription',
|
||||
defaultMessage: 'Connect to a goose server running elsewhere (requires app restart)',
|
||||
defaultMessage: 'Connect to an ACP-compatible backend running elsewhere.',
|
||||
},
|
||||
serverUrl: {
|
||||
id: 'externalBackendSection.serverUrl',
|
||||
defaultMessage: 'Server URL',
|
||||
defaultMessage: 'Backend Base URL',
|
||||
},
|
||||
serverUrlHelp: {
|
||||
id: 'externalBackendSection.serverUrlHelp',
|
||||
defaultMessage:
|
||||
'Enter the HTTP(S) base URL. Goose checks /status and connects to /acp under this base.',
|
||||
},
|
||||
secretKey: {
|
||||
id: 'externalBackendSection.secretKey',
|
||||
@@ -39,7 +44,7 @@ const i18n = defineMessages({
|
||||
},
|
||||
secretKeyHelp: {
|
||||
id: 'externalBackendSection.secretKeyHelp',
|
||||
defaultMessage: 'The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY)',
|
||||
defaultMessage: 'The secret key configured on the external backend (GOOSE_SERVER__SECRET_KEY).',
|
||||
},
|
||||
certFingerprint: {
|
||||
id: 'externalBackendSection.certFingerprint',
|
||||
@@ -56,7 +61,7 @@ const i18n = defineMessages({
|
||||
restartNote: {
|
||||
id: 'externalBackendSection.restartNote',
|
||||
defaultMessage:
|
||||
'Changes require restarting Goose to take effect. New chat windows will connect to the external server.',
|
||||
'Changes apply to new chat windows. Restart Goose to update existing windows.',
|
||||
},
|
||||
urlProtocolError: {
|
||||
id: 'externalBackendSection.urlProtocolError',
|
||||
@@ -70,6 +75,10 @@ const i18n = defineMessages({
|
||||
id: 'externalBackendSection.urlFormatError',
|
||||
defaultMessage: 'Invalid URL format',
|
||||
},
|
||||
urlBaseError: {
|
||||
id: 'externalBackendSection.urlBaseError',
|
||||
defaultMessage: 'URL must be the backend base URL before /acp, without query parameters or fragments',
|
||||
},
|
||||
});
|
||||
|
||||
export default function ExternalBackendSection() {
|
||||
@@ -95,19 +104,26 @@ export default function ExternalBackendSection() {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!WEB_PROTOCOLS.includes(parsed.protocol)) {
|
||||
setUrlError(intl.formatMessage(i18n.urlProtocolError));
|
||||
return false;
|
||||
}
|
||||
const normalizedUrl = normalizeAcpHttpBaseUrl(value);
|
||||
const parsed = new URL(normalizedUrl);
|
||||
if (certFingerprint?.trim() && parsed.protocol !== 'https:') {
|
||||
setUrlError(intl.formatMessage(i18n.fingerprintRequiresHttps));
|
||||
return false;
|
||||
}
|
||||
setUrlError(null);
|
||||
return true;
|
||||
} catch {
|
||||
setUrlError(intl.formatMessage(i18n.urlFormatError));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('http: or https:')) {
|
||||
setUrlError(intl.formatMessage(i18n.urlProtocolError));
|
||||
} else if (
|
||||
message.includes('base URL before /acp') ||
|
||||
message.includes('query parameters or fragments')
|
||||
) {
|
||||
setUrlError(intl.formatMessage(i18n.urlBaseError));
|
||||
} else {
|
||||
setUrlError(intl.formatMessage(i18n.urlFormatError));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -203,6 +219,9 @@ export default function ExternalBackendSection() {
|
||||
{urlError}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-text-secondary">
|
||||
{intl.formatMessage(i18n.serverUrlHelp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { addExtensionFromDeepLink } from './deeplink';
|
||||
import { toastService } from '../../../toasts';
|
||||
|
||||
vi.mock('../../../toasts', () => ({
|
||||
toastService: {
|
||||
@@ -85,4 +86,43 @@ describe('addExtensionFromDeepLink', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stdio command validation', () => {
|
||||
it('should allow goose for bundled MCP deeplinks', async () => {
|
||||
const url =
|
||||
'goose://extension?cmd=goose&arg=mcp&arg=memory&name=Memory&description=Memory';
|
||||
|
||||
await addExtensionFromDeepLink(url, mockAddExtension, mockSetView);
|
||||
|
||||
expect(mockAddExtension).toHaveBeenCalledWith(
|
||||
'Memory',
|
||||
expect.objectContaining({
|
||||
type: 'stdio',
|
||||
cmd: 'goose',
|
||||
args: ['mcp', 'memory'],
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject legacy goosed deeplinks', async () => {
|
||||
vi.mocked(toastService.handleError).mockImplementationOnce(() => {
|
||||
throw new Error('Invalid command');
|
||||
});
|
||||
|
||||
const url =
|
||||
'goose://extension?cmd=goosed&arg=mcp&arg=memory&name=Memory&description=Memory';
|
||||
|
||||
await expect(addExtensionFromDeepLink(url, mockAddExtension, mockSetView)).rejects.toThrow(
|
||||
'Invalid command'
|
||||
);
|
||||
|
||||
expect(toastService.handleError).toHaveBeenCalledWith(
|
||||
'Invalid Command',
|
||||
expect.stringContaining('Invalid command: goosed'),
|
||||
{ shouldThrow: true }
|
||||
);
|
||||
expect(mockAddExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ function getStdioConfig(
|
||||
'jbang',
|
||||
'npx',
|
||||
'uvx',
|
||||
'goosed',
|
||||
'goose',
|
||||
'npx.cmd',
|
||||
'i-ching-mcp-server',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user