mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mcp): ship MCP registry — unhide UI, transport-aware install, reconfigure, registry creds (#3039) (#3058)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
4e221e978b
commit
327de34f65
@@ -5,11 +5,13 @@ import InstallDialog from './InstallDialog';
|
||||
|
||||
const mockRegistryGet = vi.fn();
|
||||
const mockInstall = vi.fn();
|
||||
const mockConnect = vi.fn();
|
||||
|
||||
vi.mock('../../../services/api/mcpClientsApi', () => ({
|
||||
mcpClientsApi: {
|
||||
registryGet: (...args: unknown[]) => mockRegistryGet(...args),
|
||||
install: (...args: unknown[]) => mockInstall(...args),
|
||||
connect: (...args: unknown[]) => mockConnect(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -25,6 +27,8 @@ describe('InstallDialog', () => {
|
||||
beforeEach(() => {
|
||||
mockRegistryGet.mockReset();
|
||||
mockInstall.mockReset();
|
||||
mockConnect.mockReset();
|
||||
mockConnect.mockResolvedValue({ server_id: 'srv-1', status: 'connected', tools: [] });
|
||||
});
|
||||
|
||||
it('shows loading state while fetching detail', () => {
|
||||
@@ -107,6 +111,40 @@ describe('InstallDialog', () => {
|
||||
env: { API_KEY: 'my-api-key', SECRET_TOKEN: 'my-secret' },
|
||||
config: undefined,
|
||||
});
|
||||
// Auto-connect on success (issue #3039 gap B3).
|
||||
expect(mockConnect).toHaveBeenCalledWith('srv-1');
|
||||
expect(onSuccess).toHaveBeenCalledWith(installedServer);
|
||||
});
|
||||
|
||||
it('still reports success when auto-connect fails (best-effort)', async () => {
|
||||
const installedServer = {
|
||||
server_id: 'srv-1',
|
||||
...DETAIL,
|
||||
command_kind: 'node' as const,
|
||||
command: 'node',
|
||||
args: [],
|
||||
env_keys: ['API_KEY', 'SECRET_TOKEN'],
|
||||
installed_at: 1000,
|
||||
};
|
||||
mockRegistryGet.mockResolvedValue(DETAIL);
|
||||
mockInstall.mockResolvedValue(installedServer);
|
||||
mockConnect.mockRejectedValue(new Error('spawn failed'));
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
render(
|
||||
<InstallDialog qualifiedName="acme/test-server" onSuccess={onSuccess} onCancel={() => {}} />
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByLabelText('API_KEY'));
|
||||
fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'k' } });
|
||||
fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 's' } });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
|
||||
});
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledWith('srv-1');
|
||||
// A connect failure must NOT block the install success callback.
|
||||
expect(onSuccess).toHaveBeenCalledWith(installedServer);
|
||||
});
|
||||
|
||||
|
||||
@@ -115,6 +115,20 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
|
||||
config: parsedConfig,
|
||||
});
|
||||
log('install success server_id=%s', server.server_id);
|
||||
// Best-effort auto-connect — fire-and-forget so the dialog closes
|
||||
// immediately on install success. A slow or failing handshake must
|
||||
// never block onSuccess (the detail view surfaces errors and offers
|
||||
// a Connect retry).
|
||||
void mcpClientsApi
|
||||
.connect(server.server_id)
|
||||
.then(() => log('auto-connect success server_id=%s', server.server_id))
|
||||
.catch((connectErr: unknown) =>
|
||||
log(
|
||||
'auto-connect failed server_id=%s: %s',
|
||||
server.server_id,
|
||||
connectErr instanceof Error ? connectErr.message : String(connectErr)
|
||||
)
|
||||
);
|
||||
onSuccess(server);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('mcp.install.failedInstall');
|
||||
|
||||
@@ -6,12 +6,14 @@ import InstalledServerDetail from './InstalledServerDetail';
|
||||
const mockConnect = vi.fn();
|
||||
const mockDisconnect = vi.fn();
|
||||
const mockUninstall = vi.fn();
|
||||
const mockUpdateEnv = vi.fn();
|
||||
|
||||
vi.mock('../../../services/api/mcpClientsApi', () => ({
|
||||
mcpClientsApi: {
|
||||
connect: (...args: unknown[]) => mockConnect(...args),
|
||||
disconnect: (...args: unknown[]) => mockDisconnect(...args),
|
||||
uninstall: (...args: unknown[]) => mockUninstall(...args),
|
||||
updateEnv: (...args: unknown[]) => mockUpdateEnv(...args),
|
||||
configAssist: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -33,6 +35,7 @@ describe('InstalledServerDetail', () => {
|
||||
mockConnect.mockReset();
|
||||
mockDisconnect.mockReset();
|
||||
mockUninstall.mockReset();
|
||||
mockUpdateEnv.mockReset();
|
||||
});
|
||||
|
||||
it('renders server name and description', () => {
|
||||
@@ -201,6 +204,78 @@ describe('InstalledServerDetail', () => {
|
||||
expect(screen.getByText('Timed out')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Env reconfiguration (issue #3039)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
it('opens the reconfigure form with one input per env key', () => {
|
||||
render(
|
||||
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reconfigure' }));
|
||||
expect(screen.getByLabelText('API_KEY')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('DB_URL')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save & reconnect' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates that every env key is filled before saving', async () => {
|
||||
render(
|
||||
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reconfigure' }));
|
||||
// Fill only one of the two keys.
|
||||
fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'k' } });
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save & reconnect' }));
|
||||
});
|
||||
expect(screen.getByText('"DB_URL" is required')).toBeInTheDocument();
|
||||
expect(mockUpdateEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls updateEnv with all values and shows success on reconnect', async () => {
|
||||
mockUpdateEnv.mockResolvedValue({
|
||||
server_id: 'srv-1',
|
||||
status: 'connected',
|
||||
env_keys: ['API_KEY', 'DB_URL'],
|
||||
tools: [],
|
||||
});
|
||||
render(
|
||||
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reconfigure' }));
|
||||
fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'new-key' } });
|
||||
fireEvent.change(screen.getByLabelText('DB_URL'), { target: { value: 'new-url' } });
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save & reconnect' }));
|
||||
});
|
||||
expect(mockUpdateEnv).toHaveBeenCalledWith({
|
||||
server_id: 'srv-1',
|
||||
env: { API_KEY: 'new-key', DB_URL: 'new-url' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Environment updated and reconnected.')).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces an error when reconnect after update fails', async () => {
|
||||
mockUpdateEnv.mockResolvedValue({
|
||||
server_id: 'srv-1',
|
||||
status: 'disconnected',
|
||||
env_keys: ['API_KEY', 'DB_URL'],
|
||||
error: 'bad token',
|
||||
});
|
||||
render(
|
||||
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reconfigure' }));
|
||||
fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'k' } });
|
||||
fireEvent.change(screen.getByLabelText('DB_URL'), { target: { value: 'u' } });
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save & reconnect' }));
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('bad token')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Tool Execution Playground gating (PR review fix)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -33,7 +33,15 @@ const InstalledServerDetail = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmUninstall, setConfirmUninstall] = useState(false);
|
||||
const [showAssistant, setShowAssistant] = useState(false);
|
||||
const [suggestedEnv, setSuggestedEnv] = useState<Record<string, string> | null>(null);
|
||||
// Reconfigure form: when open, renders one input per env key so the user can
|
||||
// supply replacement values and reconnect without uninstall/reinstall
|
||||
// (issue #3039 env-reconfiguration). Values are never pre-filled from the
|
||||
// server (we only ever hold key names) — except when the config assistant
|
||||
// suggests values, which seed `reconfigValues` for the user to confirm.
|
||||
const [reconfigOpen, setReconfigOpen] = useState(false);
|
||||
const [reconfigValues, setReconfigValues] = useState<Record<string, string>>({});
|
||||
const [showReconfig, setShowReconfig] = useState<Record<string, boolean>>({});
|
||||
const [reconfigDone, setReconfigDone] = useState(false);
|
||||
// When non-null, the Tool Execution Playground modal is rendered for
|
||||
// this tool. Cleared on close. Only meaningful while the server is
|
||||
// connected (the gate is enforced at the McpToolList rendering site).
|
||||
@@ -107,10 +115,58 @@ const InstalledServerDetail = ({
|
||||
});
|
||||
}, [server.server_id, runBusy, onUninstalled]);
|
||||
|
||||
const handleApplySuggestedEnv = useCallback((env: Record<string, string>) => {
|
||||
setSuggestedEnv(env);
|
||||
log('suggested_env applied, keys=%o', Object.keys(env));
|
||||
}, []);
|
||||
const openReconfigure = useCallback(
|
||||
(prefill?: Record<string, string>) => {
|
||||
const initial: Record<string, string> = {};
|
||||
const initialVisibility: Record<string, boolean> = {};
|
||||
for (const key of server.env_keys) {
|
||||
initial[key] = prefill?.[key] ?? '';
|
||||
initialVisibility[key] = false;
|
||||
}
|
||||
setReconfigValues(initial);
|
||||
setShowReconfig(initialVisibility);
|
||||
setReconfigDone(false);
|
||||
setReconfigOpen(true);
|
||||
},
|
||||
[server.env_keys]
|
||||
);
|
||||
|
||||
// The config assistant suggests values — seed the reconfigure form with them
|
||||
// so the user can confirm/complete before we persist + reconnect. Suggested
|
||||
// sets may be partial; the form requires every key so a reconnect never drops
|
||||
// a required var (issue #3039 gap B6 — suggested values were never persisted).
|
||||
const handleApplySuggestedEnv = useCallback(
|
||||
(env: Record<string, string>) => {
|
||||
log('suggested_env received, opening reconfigure form keys=%o', Object.keys(env));
|
||||
setShowAssistant(false);
|
||||
openReconfigure(env);
|
||||
},
|
||||
[openReconfigure]
|
||||
);
|
||||
|
||||
const handleSaveReconfigure = useCallback(() => {
|
||||
void runBusy(async () => {
|
||||
// Replace-all semantics (update_env DELETEs then INSERTs): every key must
|
||||
// have a value or the server loses required env on reconnect. Mirror the
|
||||
// install dialog's validation.
|
||||
for (const key of server.env_keys) {
|
||||
if (!reconfigValues[key]?.trim()) {
|
||||
throw new Error(t('mcp.install.missingRequired').replace('{key}', key));
|
||||
}
|
||||
}
|
||||
log('reconfigure save server_id=%s', server.server_id);
|
||||
const result = await mcpClientsApi.updateEnv({
|
||||
server_id: server.server_id,
|
||||
env: reconfigValues,
|
||||
});
|
||||
setTools(result.tools ?? []);
|
||||
if (result.status !== 'connected') {
|
||||
throw new Error(result.error ?? t('mcp.detail.reconfigureReconnectFailed'));
|
||||
}
|
||||
setReconfigDone(true);
|
||||
setReconfigOpen(false);
|
||||
});
|
||||
}, [server.env_keys, server.server_id, reconfigValues, runBusy, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -152,16 +208,10 @@ const InstalledServerDetail = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested env notice */}
|
||||
{suggestedEnv && (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p className="font-medium mb-1">{t('mcp.detail.suggestedEnvReady')}</p>
|
||||
<p className="text-xs">
|
||||
{t('mcp.detail.suggestedEnvBody').replace(
|
||||
'{keys}',
|
||||
Object.keys(suggestedEnv).join(', ')
|
||||
)}
|
||||
</p>
|
||||
{/* Reconfigure success notice */}
|
||||
{reconfigDone && (
|
||||
<div className="rounded-lg border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 px-4 py-3 text-sm text-sage-700 dark:text-sage-300">
|
||||
{t('mcp.detail.reconfigureSuccess')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -224,21 +274,75 @@ const InstalledServerDetail = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Env keys (names only) */}
|
||||
{/* Env keys (names only) + reconfigure affordance */}
|
||||
{server.env_keys.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-stone-600 dark:text-neutral-400">
|
||||
{t('mcp.detail.envVars')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{server.env_keys.map(key => (
|
||||
<span
|
||||
key={key}
|
||||
className="px-2 py-0.5 text-[11px] font-mono rounded bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 border border-stone-200 dark:border-neutral-700">
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-stone-600 dark:text-neutral-400">
|
||||
{t('mcp.detail.envVars')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => (reconfigOpen ? setReconfigOpen(false) : openReconfigure())}
|
||||
className="text-[11px] font-medium text-primary-600 dark:text-primary-400 hover:underline disabled:opacity-50">
|
||||
{reconfigOpen ? t('common.cancel') : t('mcp.detail.reconfigure')}
|
||||
</button>
|
||||
</div>
|
||||
{!reconfigOpen && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{server.env_keys.map(key => (
|
||||
<span
|
||||
key={key}
|
||||
className="px-2 py-0.5 text-[11px] font-mono rounded bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 border border-stone-200 dark:border-neutral-700">
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{reconfigOpen && (
|
||||
<div className="space-y-2 rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
|
||||
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('mcp.detail.reconfigureHint')}
|
||||
</p>
|
||||
{server.env_keys.map(key => (
|
||||
<div key={key} className="space-y-1">
|
||||
<label
|
||||
htmlFor={`reconfig-${key}`}
|
||||
className="block text-[11px] font-medium text-stone-600 dark:text-neutral-400">
|
||||
{key}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id={`reconfig-${key}`}
|
||||
type={showReconfig[key] ? 'text' : 'password'}
|
||||
value={reconfigValues[key] ?? ''}
|
||||
onChange={e =>
|
||||
setReconfigValues(prev => ({ ...prev, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={t('mcp.install.enterValue').replace('{key}', key)}
|
||||
disabled={busy}
|
||||
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReconfig(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||
disabled={busy}
|
||||
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1 text-[11px] text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
|
||||
{showReconfig[key] ? t('mcp.install.hide') : t('mcp.install.show')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={handleSaveReconfigure}
|
||||
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
|
||||
{busy ? t('mcp.detail.reconfigureSaving') : t('mcp.detail.saveReconnect')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ describe('McpServersTab', () => {
|
||||
mockStatus.mockReset();
|
||||
mockInstall.mockReset();
|
||||
mockConnect.mockReset();
|
||||
mockConnect.mockResolvedValue({ server_id: '', status: 'connected', tools: [] });
|
||||
mockDisconnect.mockReset();
|
||||
mockUninstall.mockReset();
|
||||
mockRegistryGet.mockReset();
|
||||
|
||||
@@ -1122,6 +1122,13 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'نعم، قم بإلغاء التثبيت',
|
||||
'mcp.detail.uninstall': 'قم بإلغاء التثبيت',
|
||||
'mcp.detail.envVars': 'متغيرات البيئة',
|
||||
'mcp.detail.reconfigure': 'إعادة التهيئة',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'أدخل قيمًا جديدة لكل متغير، ثم أعد الاتصال. سيؤدي ذلك إلى استبدال القيم المخزَّنة.',
|
||||
'mcp.detail.saveReconnect': 'حفظ وإعادة الاتصال',
|
||||
'mcp.detail.reconfigureSaving': 'جارٍ الحفظ…',
|
||||
'mcp.detail.reconfigureSuccess': 'تم تحديث البيئة وإعادة الاتصال.',
|
||||
'mcp.detail.reconfigureReconnectFailed': 'تم الحفظ، لكن فشلت إعادة الاتصال بالقيم الجديدة.',
|
||||
'mcp.detail.tools': 'الأدوات',
|
||||
'onboarding.skipForNow': 'التخطي الآن',
|
||||
'onboarding.localAI.continueWithCloud': 'متابعة مع السحابة',
|
||||
|
||||
@@ -1138,6 +1138,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'হ্যাঁ, আনইনস্টল করুন',
|
||||
'mcp.detail.uninstall': 'আনইনস্টল',
|
||||
'mcp.detail.envVars': 'এনভায়রনমেন্ট ভেরিয়েবল',
|
||||
'mcp.detail.reconfigure': 'পুনঃকনফিগার করুন',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'প্রতিটি ভেরিয়েবলের জন্য নতুন মান লিখুন, তারপর পুনরায় সংযোগ করুন। এটি সংরক্ষিত মান প্রতিস্থাপন করে।',
|
||||
'mcp.detail.saveReconnect': 'সংরক্ষণ ও পুনঃসংযোগ',
|
||||
'mcp.detail.reconfigureSaving': 'সংরক্ষণ করা হচ্ছে…',
|
||||
'mcp.detail.reconfigureSuccess': 'এনভায়রনমেন্ট আপডেট ও পুনঃসংযোগ করা হয়েছে।',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'সংরক্ষিত হয়েছে, তবে নতুন মান দিয়ে পুনঃসংযোগ ব্যর্থ হয়েছে।',
|
||||
'mcp.detail.tools': 'টুলস',
|
||||
'onboarding.skipForNow': 'এখনই এড়িয়ে যান',
|
||||
'onboarding.localAI.continueWithCloud': 'ক্লাউডের সাথে চালিয়ে যান',
|
||||
|
||||
@@ -1173,6 +1173,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Ja, deinstallieren',
|
||||
'mcp.detail.uninstall': 'Deinstallieren',
|
||||
'mcp.detail.envVars': 'Umgebungsvariablen',
|
||||
'mcp.detail.reconfigure': 'Neu konfigurieren',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Geben Sie neue Werte für jede Variable ein und stellen Sie die Verbindung wieder her. Dadurch werden die gespeicherten Werte ersetzt.',
|
||||
'mcp.detail.saveReconnect': 'Speichern und neu verbinden',
|
||||
'mcp.detail.reconfigureSaving': 'Wird gespeichert…',
|
||||
'mcp.detail.reconfigureSuccess': 'Umgebung aktualisiert und neu verbunden.',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'Gespeichert, aber das Neuverbinden mit den neuen Werten ist fehlgeschlagen.',
|
||||
'mcp.detail.tools': 'Werkzeuge',
|
||||
'onboarding.skipForNow': 'Vorerst überspringen',
|
||||
'onboarding.localAI.continueWithCloud': 'Fahren Sie mit der Cloud fort.',
|
||||
|
||||
@@ -1267,6 +1267,13 @@ const en: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Yes, uninstall',
|
||||
'mcp.detail.uninstall': 'Uninstall',
|
||||
'mcp.detail.envVars': 'Environment variables',
|
||||
'mcp.detail.reconfigure': 'Reconfigure',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Enter new values for every variable, then reconnect. This replaces the stored values.',
|
||||
'mcp.detail.saveReconnect': 'Save & reconnect',
|
||||
'mcp.detail.reconfigureSaving': 'Saving…',
|
||||
'mcp.detail.reconfigureSuccess': 'Environment updated and reconnected.',
|
||||
'mcp.detail.reconfigureReconnectFailed': 'Saved, but reconnecting with the new values failed.',
|
||||
'mcp.detail.tools': 'Tools',
|
||||
'onboarding.skipForNow': 'Skip for Now',
|
||||
'onboarding.localAI.continueWithCloud': 'Continue with Cloud',
|
||||
|
||||
@@ -1170,6 +1170,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Sí, desinstalar',
|
||||
'mcp.detail.uninstall': 'Desinstalar',
|
||||
'mcp.detail.envVars': 'Variables ambientales',
|
||||
'mcp.detail.reconfigure': 'Reconfigurar',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Introduce nuevos valores para cada variable y vuelve a conectar. Esto reemplaza los valores guardados.',
|
||||
'mcp.detail.saveReconnect': 'Guardar y reconectar',
|
||||
'mcp.detail.reconfigureSaving': 'Guardando…',
|
||||
'mcp.detail.reconfigureSuccess': 'Entorno actualizado y reconectado.',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'Guardado, pero no se pudo reconectar con los nuevos valores.',
|
||||
'mcp.detail.tools': 'Herramientas',
|
||||
'onboarding.skipForNow': 'Saltar por ahora',
|
||||
'onboarding.localAI.continueWithCloud': 'Continuar con la nube',
|
||||
|
||||
@@ -1172,6 +1172,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Oui, désinstaller',
|
||||
'mcp.detail.uninstall': 'Désinstaller',
|
||||
'mcp.detail.envVars': "Variables d'environnement",
|
||||
'mcp.detail.reconfigure': 'Reconfigurer',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Saisissez de nouvelles valeurs pour chaque variable, puis reconnectez-vous. Cela remplace les valeurs enregistrées.',
|
||||
'mcp.detail.saveReconnect': 'Enregistrer et reconnecter',
|
||||
'mcp.detail.reconfigureSaving': 'Enregistrement…',
|
||||
'mcp.detail.reconfigureSuccess': 'Environnement mis à jour et reconnecté.',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'Enregistré, mais la reconnexion avec les nouvelles valeurs a échoué.',
|
||||
'mcp.detail.tools': 'Outils',
|
||||
'onboarding.skipForNow': "Passer pour l'instant",
|
||||
'onboarding.localAI.continueWithCloud': 'Continuer avec Cloud',
|
||||
|
||||
@@ -1139,6 +1139,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'हाँ, अनइंस्टॉल करें',
|
||||
'mcp.detail.uninstall': 'अनइंस्टॉल करें',
|
||||
'mcp.detail.envVars': 'पर्यावरण चर',
|
||||
'mcp.detail.reconfigure': 'पुन: कॉन्फ़िगर करें',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'हर वेरिएबल के लिए नए मान दर्ज करें, फिर पुन: कनेक्ट करें। यह संग्रहीत मानों को बदल देता है।',
|
||||
'mcp.detail.saveReconnect': 'सहेजें और पुन: कनेक्ट करें',
|
||||
'mcp.detail.reconfigureSaving': 'सहेजा जा रहा है…',
|
||||
'mcp.detail.reconfigureSuccess': 'एनवायरनमेंट अपडेट हो गया और पुन: कनेक्ट हो गया।',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'सहेजा गया, लेकिन नए मानों के साथ पुन: कनेक्ट करना विफल रहा।',
|
||||
'mcp.detail.tools': 'उपकरण',
|
||||
'onboarding.skipForNow': 'अभी के लिए छोड़ें',
|
||||
'onboarding.localAI.continueWithCloud': 'बादल के साथ जारी रखें',
|
||||
|
||||
@@ -1146,6 +1146,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Ya, hapus instalan',
|
||||
'mcp.detail.uninstall': 'Hapus instalan',
|
||||
'mcp.detail.envVars': 'Variabel lingkungan',
|
||||
'mcp.detail.reconfigure': 'Konfigurasi ulang',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Masukkan nilai baru untuk setiap variabel, lalu hubungkan kembali. Ini menggantikan nilai yang tersimpan.',
|
||||
'mcp.detail.saveReconnect': 'Simpan dan hubungkan kembali',
|
||||
'mcp.detail.reconfigureSaving': 'Menyimpan…',
|
||||
'mcp.detail.reconfigureSuccess': 'Lingkungan diperbarui dan terhubung kembali.',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'Tersimpan, tetapi gagal menghubungkan kembali dengan nilai baru.',
|
||||
'mcp.detail.tools': 'Alat',
|
||||
'onboarding.skipForNow': 'Lewati Sekarang',
|
||||
'onboarding.localAI.continueWithCloud': 'Lanjutkan dengan Cloud',
|
||||
|
||||
@@ -1166,6 +1166,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Sì, disinstalla',
|
||||
'mcp.detail.uninstall': 'Disinstalla',
|
||||
'mcp.detail.envVars': 'Variabili di ambiente',
|
||||
'mcp.detail.reconfigure': 'Riconfigura',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Inserisci nuovi valori per ogni variabile, quindi riconnetti. Questo sostituisce i valori memorizzati.',
|
||||
'mcp.detail.saveReconnect': 'Salva e riconnetti',
|
||||
'mcp.detail.reconfigureSaving': 'Salvataggio…',
|
||||
'mcp.detail.reconfigureSuccess': 'Ambiente aggiornato e riconnesso.',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'Salvato, ma la riconnessione con i nuovi valori non è riuscita.',
|
||||
'mcp.detail.tools': 'Strumenti',
|
||||
'onboarding.skipForNow': 'Salta per ora',
|
||||
'onboarding.localAI.continueWithCloud': 'Continua con Cloud',
|
||||
|
||||
@@ -1136,6 +1136,13 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': '예, 제거합니다.',
|
||||
'mcp.detail.uninstall': '제거',
|
||||
'mcp.detail.envVars': '환경 변수',
|
||||
'mcp.detail.reconfigure': '재구성',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'모든 변수에 새 값을 입력한 다음 다시 연결하세요. 저장된 값이 대체됩니다.',
|
||||
'mcp.detail.saveReconnect': '저장 후 다시 연결',
|
||||
'mcp.detail.reconfigureSaving': '저장 중…',
|
||||
'mcp.detail.reconfigureSuccess': '환경이 업데이트되고 다시 연결되었습니다.',
|
||||
'mcp.detail.reconfigureReconnectFailed': '저장했지만 새 값으로 다시 연결하지 못했습니다.',
|
||||
'mcp.detail.tools': '도구',
|
||||
'onboarding.skipForNow': '지금 건너뛰기',
|
||||
'onboarding.localAI.continueWithCloud': '클라우드 계속하기',
|
||||
|
||||
@@ -1159,6 +1159,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Tak, odinstaluj',
|
||||
'mcp.detail.uninstall': 'Odinstaluj',
|
||||
'mcp.detail.envVars': 'Zmienne środowiskowe',
|
||||
'mcp.detail.reconfigure': 'Skonfiguruj ponownie',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Wprowadź nowe wartości dla każdej zmiennej, a następnie połącz ponownie. Spowoduje to zastąpienie zapisanych wartości.',
|
||||
'mcp.detail.saveReconnect': 'Zapisz i połącz ponownie',
|
||||
'mcp.detail.reconfigureSaving': 'Zapisywanie…',
|
||||
'mcp.detail.reconfigureSuccess': 'Środowisko zaktualizowane i połączone ponownie.',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'Zapisano, ale ponowne połączenie z nowymi wartościami nie powiodło się.',
|
||||
'mcp.detail.tools': 'Narzędzia',
|
||||
'onboarding.skipForNow': 'Pomiń na razie',
|
||||
'onboarding.localAI.continueWithCloud': 'Kontynuuj z chmurą',
|
||||
|
||||
@@ -1170,6 +1170,13 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Sim, desinstalar',
|
||||
'mcp.detail.uninstall': 'Desinstalar',
|
||||
'mcp.detail.envVars': 'Variáveis de ambiente',
|
||||
'mcp.detail.reconfigure': 'Reconfigurar',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Insira novos valores para cada variável e reconecte. Isso substitui os valores armazenados.',
|
||||
'mcp.detail.saveReconnect': 'Salvar e reconectar',
|
||||
'mcp.detail.reconfigureSaving': 'Salvando…',
|
||||
'mcp.detail.reconfigureSuccess': 'Ambiente atualizado e reconectado.',
|
||||
'mcp.detail.reconfigureReconnectFailed': 'Salvo, mas a reconexão com os novos valores falhou.',
|
||||
'mcp.detail.tools': 'Ferramentas',
|
||||
'onboarding.skipForNow': 'Ignorar por agora',
|
||||
'onboarding.localAI.continueWithCloud': 'Continuar com a nuvem',
|
||||
|
||||
@@ -1153,6 +1153,14 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': 'Да, удалить',
|
||||
'mcp.detail.uninstall': 'Удалить',
|
||||
'mcp.detail.envVars': 'Переменные среды',
|
||||
'mcp.detail.reconfigure': 'Перенастроить',
|
||||
'mcp.detail.reconfigureHint':
|
||||
'Введите новые значения для каждой переменной, затем переподключитесь. Это заменит сохранённые значения.',
|
||||
'mcp.detail.saveReconnect': 'Сохранить и переподключиться',
|
||||
'mcp.detail.reconfigureSaving': 'Сохранение…',
|
||||
'mcp.detail.reconfigureSuccess': 'Окружение обновлено, выполнено переподключение.',
|
||||
'mcp.detail.reconfigureReconnectFailed':
|
||||
'Сохранено, но переподключиться с новыми значениями не удалось.',
|
||||
'mcp.detail.tools': 'Инструменты',
|
||||
'onboarding.skipForNow': 'Пропустить сейчас',
|
||||
'onboarding.localAI.continueWithCloud': 'Продолжить с Облако',
|
||||
|
||||
@@ -1086,6 +1086,12 @@ const messages: TranslationMap = {
|
||||
'mcp.detail.confirmUninstallAction': '是的,卸载',
|
||||
'mcp.detail.uninstall': '卸载',
|
||||
'mcp.detail.envVars': '环境变量',
|
||||
'mcp.detail.reconfigure': '重新配置',
|
||||
'mcp.detail.reconfigureHint': '为每个变量输入新值,然后重新连接。这将替换已存储的值。',
|
||||
'mcp.detail.saveReconnect': '保存并重新连接',
|
||||
'mcp.detail.reconfigureSaving': '正在保存…',
|
||||
'mcp.detail.reconfigureSuccess': '环境已更新并重新连接。',
|
||||
'mcp.detail.reconfigureReconnectFailed': '已保存,但使用新值重新连接失败。',
|
||||
'mcp.detail.tools': '工具',
|
||||
'onboarding.skipForNow': '暂时跳过',
|
||||
'onboarding.localAI.continueWithCloud': '继续使用云',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import ChannelSetupModal from '../components/channels/ChannelSetupModal';
|
||||
import McpServersTab from '../components/channels/mcp/McpServersTab';
|
||||
import ComposioConnectModal from '../components/composio/ComposioConnectModal';
|
||||
import {
|
||||
composioToolkitMeta,
|
||||
@@ -276,35 +277,6 @@ function ChannelTile({ def, status, icon, testId, onOpen }: ChannelTileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function McpComingSoonPanel() {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<EmptyStateCard
|
||||
icon={
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 7.5h10.5m-10.5 4.5h10.5m-10.5 4.5h6m-9.75 3h13.5A2.25 2.25 0 0 0 19.5 17.25V6.75A2.25 2.25 0 0 0 17.25 4.5H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5A2.25 2.25 0 0 0 6.75 19.5Z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
title={t('skills.mcpComingSoon.title')}
|
||||
description={t('skills.mcpComingSoon.description')}
|
||||
footer={
|
||||
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
{t('common.comingSoon')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposioApiKeyEmptyState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
@@ -1130,7 +1102,12 @@ export default function Skills() {
|
||||
{t('channels.mcp.description')}
|
||||
</p>
|
||||
</div>
|
||||
<McpComingSoonPanel />
|
||||
{/* The real browse/install/manage surface (issue #3039).
|
||||
McpServersTab manages its own height via h-full, so it
|
||||
needs a sized container to fill. */}
|
||||
<div className="h-[72vh] min-h-[480px]">
|
||||
<McpServersTab />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import '../../test/mockDefaultSkillStatusHooks';
|
||||
@@ -34,14 +34,31 @@ vi.mock('../../lib/composio/hooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/mcpClientsApi', () => ({
|
||||
mcpClientsApi: {
|
||||
installedList: vi.fn().mockResolvedValue([]),
|
||||
status: vi.fn().mockResolvedValue([]),
|
||||
registrySearch: vi.fn().mockResolvedValue({ servers: [], page: 1, total_pages: 1 }),
|
||||
registryGet: vi.fn().mockResolvedValue(null),
|
||||
install: vi.fn().mockResolvedValue({}),
|
||||
connect: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
disconnect: vi.fn().mockResolvedValue({}),
|
||||
uninstall: vi.fn().mockResolvedValue({}),
|
||||
configAssist: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Skills page — MCP tab', () => {
|
||||
it('shows a coming soon placeholder for MCP server management', () => {
|
||||
it('renders the live MCP servers tab (not a coming-soon placeholder)', async () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
|
||||
|
||||
expect(screen.getAllByRole('heading', { name: 'MCP Servers' })).toHaveLength(2);
|
||||
expect(screen.getByText(/MCP server management is coming soon/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Coming Soon')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('No MCP servers installed yet.') ||
|
||||
screen.getByText('Loading MCP servers...')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,6 +274,65 @@ describe('mcpClientsApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEnv', () => {
|
||||
it('calls update_env and returns reconnect status', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
server_id: 'srv-1',
|
||||
status: 'connected',
|
||||
env_keys: ['API_KEY'],
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const { mcpClientsApi } = await import('./mcpClientsApi');
|
||||
const result = await mcpClientsApi.updateEnv({
|
||||
server_id: 'srv-1',
|
||||
env: { API_KEY: 'rotated' },
|
||||
});
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_clients_update_env',
|
||||
params: { server_id: 'srv-1', env: { API_KEY: 'rotated' } },
|
||||
});
|
||||
expect(result.status).toBe('connected');
|
||||
expect(result.env_keys).toEqual(['API_KEY']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry settings', () => {
|
||||
it('registrySettingsGet returns the is-set booleans', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
smithery_api_key_set: true,
|
||||
mcp_official_token_set: false,
|
||||
mcp_official_base: null,
|
||||
});
|
||||
|
||||
const { mcpClientsApi } = await import('./mcpClientsApi');
|
||||
const result = await mcpClientsApi.registrySettingsGet();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_clients_registry_settings_get',
|
||||
params: {},
|
||||
});
|
||||
expect(result.smithery_api_key_set).toBe(true);
|
||||
expect(result.mcp_official_token_set).toBe(false);
|
||||
});
|
||||
|
||||
it('registrySettingsSet forwards only the provided fields', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
smithery_api_key_set: true,
|
||||
mcp_official_token_set: false,
|
||||
});
|
||||
|
||||
const { mcpClientsApi } = await import('./mcpClientsApi');
|
||||
await mcpClientsApi.registrySettingsSet({ smithery_api_key: 'sk-x' });
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_clients_registry_settings_set',
|
||||
params: { smithery_api_key: 'sk-x' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('configAssist', () => {
|
||||
it('calls config_assist and returns reply', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
|
||||
@@ -70,6 +70,21 @@ interface ConfigAssistResult {
|
||||
suggested_env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface UpdateEnvResult {
|
||||
server_id: string;
|
||||
status: 'connected' | 'disconnected';
|
||||
env_keys: string[];
|
||||
tools?: McpTool[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Non-secret registry-credentials snapshot. Secret *values* are never returned. */
|
||||
export interface RegistrySettings {
|
||||
smithery_api_key_set: boolean;
|
||||
mcp_official_token_set: boolean;
|
||||
mcp_official_base?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -135,6 +150,57 @@ export const mcpClientsApi = {
|
||||
return result.server;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the stored env values for an installed server and reconnect so the
|
||||
* new credentials take effect (reconfigure / rotate keys without
|
||||
* uninstall+reinstall). `status` is `connected` when the reconnect succeeded.
|
||||
*/
|
||||
updateEnv: async (params: {
|
||||
server_id: string;
|
||||
env: Record<string, string>;
|
||||
}): Promise<UpdateEnvResult> => {
|
||||
log('update_env server_id=%s env_keys=%o', params.server_id, Object.keys(params.env));
|
||||
const result = await callCoreRpc<UpdateEnvResult>({
|
||||
method: 'openhuman.mcp_clients_update_env',
|
||||
params,
|
||||
});
|
||||
log('update_env status=%s', result.status);
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Read which registry credentials are configured (booleans only, no values). */
|
||||
registrySettingsGet: async (): Promise<RegistrySettings> => {
|
||||
log('registry_settings_get');
|
||||
const result = await callCoreRpc<RegistrySettings>({
|
||||
method: 'openhuman.mcp_clients_registry_settings_get',
|
||||
params: {},
|
||||
});
|
||||
log(
|
||||
'registry_settings_get smithery=%s official=%s',
|
||||
result.smithery_api_key_set,
|
||||
result.mcp_official_token_set
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist registry credentials. Omit a field to leave it unchanged, pass an
|
||||
* empty string to clear it. Secrets are write-only — the response is the same
|
||||
* non-secret snapshot as registrySettingsGet.
|
||||
*/
|
||||
registrySettingsSet: async (params: {
|
||||
smithery_api_key?: string;
|
||||
mcp_official_base?: string;
|
||||
mcp_official_token?: string;
|
||||
}): Promise<RegistrySettings> => {
|
||||
log('registry_settings_set fields=%o', Object.keys(params));
|
||||
const result = await callCoreRpc<RegistrySettings>({
|
||||
method: 'openhuman.mcp_clients_registry_settings_set',
|
||||
params,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Uninstall a server by ID. */
|
||||
uninstall: async (server_id: string): Promise<UninstallResult> => {
|
||||
log('uninstall server_id=%s', server_id);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
|
||||
vi.mock('../coreRpcClient', () => ({
|
||||
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
|
||||
}));
|
||||
|
||||
describe('mcpSetupApi', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
it('search calls mcp_setup_search', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ servers: [], page: 1, total_pages: 1 });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
await mcpSetupApi.search({ query: 'notion' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_search',
|
||||
params: { query: 'notion' },
|
||||
});
|
||||
});
|
||||
|
||||
it('get unwraps the server detail', async () => {
|
||||
const server = { qualified_name: 'q', display_name: 'd', connections: [] };
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ server });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.get('q');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_get',
|
||||
params: { qualified_name: 'q' },
|
||||
});
|
||||
expect(result).toEqual(server);
|
||||
});
|
||||
|
||||
it('requestSecret returns an opaque ref', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ ref: 'secret://abc', key_name: 'NOTION_API_KEY' });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.requestSecret({
|
||||
key_name: 'NOTION_API_KEY',
|
||||
prompt: 'Paste your token',
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_request_secret',
|
||||
params: { key_name: 'NOTION_API_KEY', prompt: 'Paste your token' },
|
||||
});
|
||||
expect(result.ref).toBe('secret://abc');
|
||||
});
|
||||
|
||||
it('submitSecret forwards the ref + value', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ ref: 'secret://abc', fulfilled: true });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.submitSecret({ ref_id: 'secret://abc', value: 'tok' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_submit_secret',
|
||||
params: { ref_id: 'secret://abc', value: 'tok' },
|
||||
});
|
||||
expect(result.fulfilled).toBe(true);
|
||||
});
|
||||
|
||||
it('testConnection returns ok + tools', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ ok: true, tools: [{ name: 'search' }] });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.testConnection({
|
||||
qualified_name: 'q',
|
||||
env_refs: { NOTION_API_KEY: 'secret://abc' },
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_test_connection',
|
||||
params: { qualified_name: 'q', env_refs: { NOTION_API_KEY: 'secret://abc' } },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('installAndConnect commits the install', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ server_id: 'srv-1', status: 'connected', tools: [] });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.installAndConnect({
|
||||
qualified_name: 'q',
|
||||
env_refs: { NOTION_API_KEY: 'secret://abc' },
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_install_and_connect',
|
||||
params: { qualified_name: 'q', env_refs: { NOTION_API_KEY: 'secret://abc' } },
|
||||
});
|
||||
expect(result.status).toBe('connected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Typed RPC wrapper for the MCP **setup-agent** domain (`mcp_setup_*`).
|
||||
*
|
||||
* These RPCs power the agent-native "install MCP server with assistant" flow:
|
||||
* search → get → request_secret (out-of-band native prompt) → test_connection →
|
||||
* install_and_connect. Secret *values* never travel through here as plaintext
|
||||
* the agent sees — `request_secret` returns an opaque `secret://<hex>` ref that
|
||||
* the UI fulfils via `submitSecret`, and only refs are passed to
|
||||
* `testConnection` / `installAndConnect`.
|
||||
*
|
||||
* Centralises the `openhuman.mcp_setup_<function>` method-name strings so
|
||||
* components never spell them out directly (issue #3039 gap B4).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import type {
|
||||
McpTool,
|
||||
SmitheryServer,
|
||||
SmitheryServerDetail,
|
||||
} from '../../components/channels/mcp/types';
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('mcp-setup:api');
|
||||
|
||||
interface SetupSearchResult {
|
||||
servers: SmitheryServer[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface SetupGetResult {
|
||||
server: SmitheryServerDetail;
|
||||
}
|
||||
|
||||
interface RequestSecretResult {
|
||||
ref: string;
|
||||
key_name: string;
|
||||
}
|
||||
|
||||
interface SubmitSecretResult {
|
||||
ref: string;
|
||||
fulfilled: boolean;
|
||||
}
|
||||
|
||||
interface TestConnectionResult {
|
||||
ok: boolean;
|
||||
tools?: McpTool[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface InstallAndConnectResult {
|
||||
server_id: string;
|
||||
status: 'connected' | 'installed_disconnected';
|
||||
tools?: McpTool[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const mcpSetupApi = {
|
||||
/** Search all enabled registries (Smithery + official). */
|
||||
search: async (params: {
|
||||
query?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<SetupSearchResult> => {
|
||||
log('search params=%o', params);
|
||||
return callCoreRpc<SetupSearchResult>({ method: 'openhuman.mcp_setup_search', params });
|
||||
},
|
||||
|
||||
/** Fetch one server's detail with `required_env_keys` injected. */
|
||||
get: async (qualified_name: string): Promise<SmitheryServerDetail> => {
|
||||
log('get qualified_name=%s', qualified_name);
|
||||
const result = await callCoreRpc<SetupGetResult>({
|
||||
method: 'openhuman.mcp_setup_get',
|
||||
params: { qualified_name },
|
||||
});
|
||||
return result.server;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the core to request a secret from the user out-of-band. Returns an
|
||||
* opaque ref; the value is collected by the native SecretPromptDialog and
|
||||
* submitted via {@link submitSecret}.
|
||||
*/
|
||||
requestSecret: async (params: {
|
||||
key_name: string;
|
||||
prompt: string;
|
||||
}): Promise<RequestSecretResult> => {
|
||||
log('request_secret key_name=%s', params.key_name);
|
||||
return callCoreRpc<RequestSecretResult>({
|
||||
method: 'openhuman.mcp_setup_request_secret',
|
||||
params,
|
||||
});
|
||||
},
|
||||
|
||||
/** UI-side: fulfil a pending `request_secret` with the user-entered value. */
|
||||
submitSecret: async (params: { ref_id: string; value: string }): Promise<SubmitSecretResult> => {
|
||||
// Intentionally NOT logging the value.
|
||||
log('submit_secret ref_id=%s', params.ref_id);
|
||||
return callCoreRpc<SubmitSecretResult>({ method: 'openhuman.mcp_setup_submit_secret', params });
|
||||
},
|
||||
|
||||
/** Dry-run a candidate install (spawn, list tools, tear down — nothing persisted). */
|
||||
testConnection: async (params: {
|
||||
qualified_name: string;
|
||||
env_refs: Record<string, string>;
|
||||
}): Promise<TestConnectionResult> => {
|
||||
log('test_connection qualified_name=%s', params.qualified_name);
|
||||
const result = await callCoreRpc<TestConnectionResult>({
|
||||
method: 'openhuman.mcp_setup_test_connection',
|
||||
params,
|
||||
});
|
||||
log('test_connection ok=%s', result.ok);
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Commit the install + secrets, then connect and return the tool list. */
|
||||
installAndConnect: async (params: {
|
||||
qualified_name: string;
|
||||
env_refs: Record<string, string>;
|
||||
}): Promise<InstallAndConnectResult> => {
|
||||
log('install_and_connect qualified_name=%s', params.qualified_name);
|
||||
const result = await callCoreRpc<InstallAndConnectResult>({
|
||||
method: 'openhuman.mcp_setup_install_and_connect',
|
||||
params,
|
||||
});
|
||||
log('install_and_connect status=%s', result.status);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
+28
-21
@@ -1,12 +1,16 @@
|
||||
# MCP Setup Agent — design sketch
|
||||
# MCP Setup Agent
|
||||
|
||||
A sub-agent that walks the user through installing, configuring, and
|
||||
connecting an MCP server from one of the upstream registries
|
||||
(`mcp_registry::registries`: Smithery, modelcontextprotocol/registry).
|
||||
|
||||
This document is a **design sketch** for follow-up implementation. Nothing
|
||||
here is wired up yet beyond the underlying primitives in
|
||||
`src/openhuman/mcp_registry/`.
|
||||
**Status: implemented** (issue #3039). The agent archetype, its five
|
||||
`mcp_setup_*` tools, the opaque-secret request/submit flow, and the
|
||||
`install_and_connect` commit path are all live. The orchestrator delegates to
|
||||
it via the `setup_mcp_server` delegate (the `mcp_setup` entry in
|
||||
`src/openhuman/agent_registry/agents/orchestrator/agent.toml`), so a chat turn
|
||||
like *"set up the Notion MCP server"* routes here. The sections below describe
|
||||
how the flow works; paths reflect the shipped layout.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,7 +35,7 @@ subprocess, and the persistence.
|
||||
|
||||
## Tool surface
|
||||
|
||||
Four tools registered behind a `mcp_setup_*` namespace. All tool inputs
|
||||
Five tools registered behind a `mcp_setup_*` namespace. All tool inputs
|
||||
and outputs are JSON; secret values **never** appear in either direction.
|
||||
|
||||
| Tool | Input | Output | Notes |
|
||||
@@ -40,7 +44,7 @@ and outputs are JSON; secret values **never** appear in either direction.
|
||||
| `mcp_setup_get` | `{ qualified_name }` | `{ detail, required_env_keys }` | Wraps `registry_get`; pre-computes `required_env_keys` from the `config_schema` (same logic as `ops::collect_required_env_keys`). |
|
||||
| `mcp_setup_request_secret` | `{ key_name, prompt }` | `{ ref: "secret://<opaque>" }` | Triggers an out-of-band UI prompt. Returns an opaque ref; raw value is held in a process-local in-memory map keyed by ref. |
|
||||
| `mcp_setup_test_connection` | `{ qualified_name, env_refs: { KEY: "secret://…" } }` | `{ ok, tools?: [McpTool], error?: string }` | Spawns the candidate subprocess in a **scratch** workspace, resolves refs to values just-in-time, runs `initialize` + `tools/list`, tears it down. No persistence. |
|
||||
| `mcp_setup_install_and_connect` | `{ qualified_name, env_refs }` | `{ server_id, status, tools: [McpTool] }` | Resolves refs, persists the install + `mcp_client_env` rows, calls `connections::connect`. Refs are consumed (removed from the in-memory map) regardless of outcome. |
|
||||
| `mcp_setup_install_and_connect` | `{ qualified_name, env_refs }` | `{ server_id, status, tools: [McpTool] }` | Resolves refs, persists the install + `mcp_client_env` rows, calls `connections::connect`. Refs are always consumed (removed from the in-memory map) regardless of outcome — on failure the agent must re-prompt via `mcp_setup_request_secret`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -68,10 +72,11 @@ Lifecycle of `SETUP_SECRETS`:
|
||||
- Process-local `OnceLock<RwLock<HashMap<RefId, SecretEntry>>>`.
|
||||
- Entries TTL out after, say, 15 min (defends against stranded secrets if
|
||||
the conversation is abandoned mid-flow).
|
||||
- `mcp_setup_install_and_connect` consumes refs on success: pulls each
|
||||
value, writes it to the `mcp_client_env` table (existing persistence,
|
||||
already keyed by `server_id`), removes the ref. On failure refs are
|
||||
left intact so the agent can retry without re-prompting the user.
|
||||
- `mcp_setup_install_and_connect` consumes refs regardless of outcome:
|
||||
pulls each value, writes it to the `mcp_client_env` table (existing
|
||||
persistence, already keyed by `server_id`), and removes the ref from
|
||||
the in-memory map. On failure the agent should re-prompt via
|
||||
`mcp_setup_request_secret` to collect fresh refs for a retry.
|
||||
- On core shutdown the map is dropped — refs do not survive restart.
|
||||
|
||||
`RefId` is a short random hex string. **No structure or hint of the
|
||||
@@ -96,17 +101,18 @@ values.
|
||||
|
||||
## Where the agent lives
|
||||
|
||||
Follow the existing sub-agent pattern (`src/openhuman/agent/harness/`):
|
||||
Follows the existing sub-agent pattern (`src/openhuman/agent_registry/`):
|
||||
|
||||
- New archetype TOML at `app/src/lib/ai/agents/mcp_setup.toml` (loaded by
|
||||
`AgentDefinitionRegistry::init_global`).
|
||||
- Prompt + tool allowlist scoped tight: only the four `mcp_setup_*` tools
|
||||
plus the standard `chat` / `ask_user` primitives. **No** general
|
||||
filesystem, network, or shell tools — the agent shouldn't be able to
|
||||
exfiltrate a leaked ref even if one shows up.
|
||||
- Triggered by the main agent via `spawn_subagent("mcp_setup", { goal })`
|
||||
or by an explicit UI affordance ("Add MCP server…" button that opens a
|
||||
thread pinned to this archetype).
|
||||
- Archetype TOML at `src/openhuman/agent_registry/agents/mcp_setup/agent.toml`
|
||||
(loaded by the agent registry loader).
|
||||
- Prompt + tool allowlist scoped tight: only the five `mcp_setup_*` tools
|
||||
plus `ask_user_clarification`. **No** general filesystem, network, or shell
|
||||
tools — the agent shouldn't be able to exfiltrate a leaked ref even if one
|
||||
shows up. (`submit_secret` is intentionally NOT in the agent allowlist — the
|
||||
UI calls it out-of-band via the socket bridge.)
|
||||
- Triggered by the orchestrator's `setup_mcp_server` delegate (the `mcp_setup`
|
||||
entry in `agent_registry/agents/orchestrator/agent.toml`), or directly from
|
||||
chat when the user asks to add/install/set up an MCP server.
|
||||
|
||||
---
|
||||
|
||||
@@ -127,7 +133,8 @@ Following the project's `Specify → Rust → JSON-RPC → UI → tests` flow:
|
||||
modal listening on a new socket event `mcp_setup_request_secret`),
|
||||
submit POSTs the value to a Tauri command that calls into core to
|
||||
register the ref.
|
||||
4. **Archetype** + system prompt at `app/src/lib/ai/agents/mcp_setup.toml`.
|
||||
4. **Archetype** + system prompt at
|
||||
`src/openhuman/agent_registry/agents/mcp_setup/agent.toml`.
|
||||
5. **Tests**:
|
||||
- Unit: ref lifecycle (mint → resolve → consume → TTL expiry).
|
||||
- Integration (`tests/mcp_registry_e2e.rs` style): full flow against
|
||||
|
||||
@@ -418,6 +418,9 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
||||
| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution |
|
||||
| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test |
|
||||
| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params |
|
||||
| 11.1.10 | MCP registry install→connect→tool_call | RI | `tests/json_rpc_e2e.rs` (`mcp_clients_install_connect_tool_call_happy_path`), `tests/mcp_registry_e2e.rs`, `src/openhuman/mcp_registry/setup_ops.rs` (this PR, #3039) | ✅ | HTTP-RPC happy path install→connect→tool_call→update_env against `test-mcp-stub`; transport-aware install (stdio + http_remote) via `build_install_transport` |
|
||||
| 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (this PR, #3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation |
|
||||
| 11.1.12 | MCP UI surface + setup-agent client | VU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts` (this PR, #3039) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper |
|
||||
|
||||
<!-- 11.1.9 Vault Markdown Writes — removed: Knowledge Vaults dropped (vault domain + VaultPanel deleted). -->
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
---
|
||||
description: >-
|
||||
The dynamic, user-facing side of MCP-client support — discover servers on
|
||||
Smithery.ai, persist installs to SQLite, supervise local-spawn subprocess
|
||||
lifecycle, surface their tools to agents via the unified tool registry.
|
||||
Smithery and the official MCP registry, persist installs to SQLite, supervise
|
||||
local-spawn subprocess lifecycle, surface their tools to agents via the
|
||||
unified tool registry.
|
||||
icon: plug
|
||||
---
|
||||
|
||||
# MCP Registry (`src/openhuman/mcp_registry/`)
|
||||
|
||||
`src/openhuman/mcp_registry/` is the **dynamic, user-facing** half of OpenHuman's Model Context Protocol client support. It lets a user browse the Smithery.ai MCP registry, install a chosen server, persist that choice to SQLite, and (for servers launched as local subprocesses) supervise the subprocess lifecycle. Installed servers' tools are surfaced to agents via the unified tool registry (`crate::openhuman::tool_registry`).
|
||||
`src/openhuman/mcp_registry/` is the **dynamic, user-facing** half of OpenHuman's Model Context Protocol client support. It lets a user browse the supported upstream registries (Smithery and the official modelcontextprotocol registry), install a chosen server, persist that choice to SQLite, and (for servers launched as local subprocesses or HTTP-remote endpoints) supervise the connection lifecycle. Installed servers' tools are surfaced to agents via the unified tool registry (`crate::openhuman::tool_registry`).
|
||||
|
||||
> **Naming note**: the Rust module path is `mcp_registry`, but the RPC namespace and on-disk SQLite filename are still `mcp_clients` for backward compatibility with existing frontend code and stored user state. Grep both names when chasing call sites.
|
||||
|
||||
@@ -16,7 +17,7 @@ This module is paired with `src/openhuman/mcp_client/` — the **transport libra
|
||||
|
||||
```text
|
||||
┌───────────────────────────────────────────────┐
|
||||
Smithery.ai ──► registries/ + registry.rs (10-min SQLite cache)│
|
||||
Registries ───► registries/ + registry.rs (10-min SQLite cache)│
|
||||
└────────────────────┬──────────────────────────┘
|
||||
│ browse / install
|
||||
▼
|
||||
@@ -47,13 +48,16 @@ This module is paired with `src/openhuman/mcp_client/` — the **transport libra
|
||||
|
||||
## Server transport model
|
||||
|
||||
Today every `InstalledServer` is a **local subprocess** launched by `npx`, `uvx`, or a direct binary (see `types::CommandKind`). The connection is **stdio JSON-RPC**, owned by `connections.rs`.
|
||||
An `InstalledServer` carries a `transport: Transport` discriminator (`types.rs`) with two variants:
|
||||
|
||||
HTTP-remote MCP servers (the majority of what Smithery actually lists) are **not yet modelled** as an `InstalledServer` variant. Adding a remote transport variant is planned follow-up work; after that, the registry will hold both kinds and `connections.rs` will dispatch by transport.
|
||||
- **`Stdio`** — a local subprocess launched by `npx`, `uvx`, or a direct binary (see `types::CommandKind`), speaking **stdio JSON-RPC**.
|
||||
- **`HttpRemote { url }`** — a hosted server (the majority of what Smithery lists), dialled over streamable HTTP by `mcp_client::McpHttpClient`.
|
||||
|
||||
`connections.rs` dispatches on the transport. Both the manual install dialog (`mcp_clients_install`) and the setup-agent path (`mcp_setup_install_and_connect`) pick the best connection via `setup_ops::pick_connection` (published stdio → any stdio → published http_remote → any http_remote) and build the transport with `setup_ops::build_install_transport`, so the two paths behave identically.
|
||||
|
||||
## Boot-time spawn
|
||||
|
||||
`boot::spawn_installed_servers` is called from `bootstrap_core_runtime` so every local-spawn server is connected as soon as the core comes up. Errors are logged per-server and **never block boot** — a broken MCP install should not gate the desktop app starting.
|
||||
`boot::spawn_installed_servers` is called from `bootstrap_core_runtime` so every installed server is connected as soon as the core comes up. Errors are logged per-server and **never block boot** — a broken MCP install should not gate the desktop app starting. The lifecycle log subscriber (`bus::init`) is registered alongside the other domain subscribers in `register_domain_subscribers` so those connect events are observed.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -62,7 +66,7 @@ HTTP-remote MCP servers (the majority of what Smithery actually lists) are **not
|
||||
| `types.rs` | Data structures: `InstalledServer`, `McpTool`, `ConnStatus`, Smithery DTOs, etc. |
|
||||
| `store.rs` | SQLite persistence — `mcp_clients.db`, CRUD over `InstalledServer` rows. |
|
||||
| `registry.rs` | Smithery HTTP client with a 10-minute SQLite cache so re-browsing doesn't hammer the upstream registry. |
|
||||
| `registries/` | Adapters for the upstream registries this code can browse (currently Smithery). |
|
||||
| `registries/` | Adapters for the upstream registries this code can browse: Smithery (`smithery.rs`) + the official modelcontextprotocol registry (`mcp_official.rs`). Each reads optional auth config-first with an env-var fallback (`mcp_client.registry_auth`). |
|
||||
| `connections.rs` | Global in-process connection registry. Wraps `crate::openhuman::mcp_client::McpStdioClient` — there is no separate stdio client implementation here. |
|
||||
| `boot.rs` | Boot-time spawn (`spawn_installed_servers`) called from `bootstrap_core_runtime`. |
|
||||
| `setup.rs` / `setup_ops.rs` | "Setup agent" support — the small agent that walks a user through configuring a freshly installed server (env vars, secrets, first connect). |
|
||||
@@ -96,7 +100,7 @@ Everything else — `boot`, `bus`, `connections`, `store`, `setup`, `setup_ops`
|
||||
## Called by
|
||||
|
||||
- `bootstrap_core_runtime` (via `boot::spawn_installed_servers`).
|
||||
- Frontend Skills UI — the (currently stubbed) MCP servers panel will dispatch through `ops.rs` over the `openhuman.mcp_clients_*` RPC namespace.
|
||||
- Frontend Skills UI — the **MCP** tab at `/skills?tab=mcp` (`McpServersTab`) dispatches through `ops.rs` over the `openhuman.mcp_clients_*` RPC namespace: browse, install (auto-connects), connect/disconnect, status, tool_call, `update_env` (reconfigure + reconnect), and `registry_settings_get` / `registry_settings_set` (Smithery / official-registry credentials; secret values are write-only). The agent-native flow uses `openhuman.mcp_setup_*` via the `mcp_setup` sub-agent (orchestrator delegate `setup_mcp_server`).
|
||||
- The setup agent in `setup_ops.rs` — for first-connect onboarding.
|
||||
|
||||
## Tests
|
||||
|
||||
+8
-1
@@ -1926,8 +1926,15 @@ fn register_domain_subscribers(
|
||||
// calls instead of importing `run_tool_call_loop` directly.
|
||||
crate::openhuman::agent::bus::register_agent_handlers();
|
||||
|
||||
// MCP clients lifecycle subscriber: logs McpServer{Installed,Connected,
|
||||
// Disconnected} + McpClientToolExecuted for observability. The boot-time
|
||||
// spawn of installed servers (boot::spawn_installed_servers) runs later
|
||||
// in bootstrap_core_runtime; this subscriber must be live before then so
|
||||
// those connect events are observed (issue #3039 gap A1).
|
||||
crate::openhuman::mcp_registry::bus::init();
|
||||
|
||||
log::info!(
|
||||
"[event_bus] domain subscribers registered (webhook, channel, health, conversation, composio, restart, proactive, agent, session_expired)"
|
||||
"[event_bus] domain subscribers registered (webhook, channel, health, conversation, composio, restart, proactive, agent, session_expired, mcp_client)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1043,13 +1043,13 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
name: "Browse MCP Server Registry",
|
||||
domain: "channels",
|
||||
category: CapabilityCategory::Channels,
|
||||
description: "Search and discover MCP servers from the Smithery.ai public registry.",
|
||||
how_to: "Channels > MCP Servers > Browse Registry",
|
||||
description: "Search and discover MCP servers from the Smithery.ai and official modelcontextprotocol registries.",
|
||||
how_to: "Skills > MCP > Browse catalog",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: Some(CapabilityPrivacy {
|
||||
leaves_device: true,
|
||||
data_kind: PrivacyDataKind::Metadata,
|
||||
destinations: &["Smithery.ai registry API"],
|
||||
destinations: &["Smithery.ai registry API", "modelcontextprotocol registry API"],
|
||||
}),
|
||||
},
|
||||
Capability {
|
||||
@@ -1057,30 +1057,38 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
name: "Install MCP Servers",
|
||||
domain: "channels",
|
||||
category: CapabilityCategory::Channels,
|
||||
description: "Install MCP servers locally. Required env vars are stored encrypted and never included in logs or responses.",
|
||||
how_to: "Channels > MCP Servers > Install",
|
||||
description: "Install MCP servers locally — both local stdio subprocesses and hosted HTTP-remote servers. Required env vars are stored encrypted and never included in logs or responses. Can also be done conversationally via the MCP setup assistant.",
|
||||
how_to: "Skills > MCP > Browse catalog > Install, or ask the assistant to \"set up the <name> MCP server\"",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_CREDENTIALS,
|
||||
},
|
||||
Capability {
|
||||
id: "channels.mcp_server_connect",
|
||||
name: "Connect / Disconnect MCP Servers",
|
||||
name: "Connect / Reconfigure MCP Servers",
|
||||
domain: "channels",
|
||||
category: CapabilityCategory::Channels,
|
||||
description: "Spawn and manage MCP server subprocesses via the stdio JSON-RPC protocol.",
|
||||
how_to: "Channels > MCP Servers > Connect",
|
||||
description: "Spawn and manage MCP server connections (stdio subprocess or HTTP-remote). Reconfigure stored env vars and reconnect without uninstalling.",
|
||||
how_to: "Skills > MCP > select a server > Connect / Reconfigure",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: None,
|
||||
privacy: Some(CapabilityPrivacy {
|
||||
leaves_device: true,
|
||||
data_kind: PrivacyDataKind::Derived,
|
||||
destinations: &["Configured MCP endpoint(s)"],
|
||||
}),
|
||||
},
|
||||
Capability {
|
||||
id: "channels.mcp_tool_call",
|
||||
name: "Invoke MCP Server Tools",
|
||||
domain: "channels",
|
||||
category: CapabilityCategory::Channels,
|
||||
description: "Call tools exposed by connected MCP servers. Results are surfaced to the agent.",
|
||||
how_to: "Human > ask the assistant to use a tool from a connected MCP server",
|
||||
description: "Call tools exposed by connected MCP servers. Tools are surfaced to the agent and runnable from the tool playground.",
|
||||
how_to: "Skills > MCP > select a connected server > Tools > Try, or ask the assistant in Chat",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: None,
|
||||
privacy: Some(CapabilityPrivacy {
|
||||
leaves_device: true,
|
||||
data_kind: PrivacyDataKind::Derived,
|
||||
destinations: &["Configured MCP endpoint(s)"],
|
||||
}),
|
||||
},
|
||||
Capability {
|
||||
id: "settings.configure_ai",
|
||||
|
||||
@@ -66,6 +66,14 @@ subagents = [
|
||||
# so there is exactly one canonical route — through this delegate —
|
||||
# which keeps the venue-specific approval-gate prompt in scope.
|
||||
"markets_agent",
|
||||
# MCP setup specialist (#3039). Synthesised into a `delegate_setup_mcp_server`
|
||||
# tool at agent-build time. Route any "install / add / set up / connect an MCP
|
||||
# server" request here — the agent owns the full flow: search registries →
|
||||
# request required secrets via a native dialog (raw values never enter the
|
||||
# agent context) → test the connection → commit the install + connect. Without
|
||||
# this entry the agent-native install path (issue #3039 gap B5) is unreachable
|
||||
# from chat.
|
||||
"mcp_setup",
|
||||
# NOTE: `summarizer` used to be listed here for the runtime-only
|
||||
# oversized-tool-result hook. That path is currently disabled
|
||||
# (`context.summarizer_payload_threshold_tokens = 0`) after recursive
|
||||
|
||||
@@ -388,6 +388,11 @@ pub struct McpClientConfig {
|
||||
/// Identity block sent during initialize.
|
||||
#[serde(default)]
|
||||
pub client_identity: McpClientIdentityConfig,
|
||||
/// Optional auth/overrides for the MCP *registry* browse APIs (Smithery +
|
||||
/// the official modelcontextprotocol/registry). Each value falls back to
|
||||
/// the corresponding env var when unset (issue #3039 gap A6).
|
||||
#[serde(default)]
|
||||
pub registry_auth: McpRegistryAuthConfig,
|
||||
}
|
||||
|
||||
impl Default for McpClientConfig {
|
||||
@@ -396,10 +401,35 @@ impl Default for McpClientConfig {
|
||||
enabled: defaults::default_true(),
|
||||
servers: Vec::new(),
|
||||
client_identity: McpClientIdentityConfig::default(),
|
||||
registry_auth: McpRegistryAuthConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry-browse auth + endpoint overrides. Lets a user who hits Smithery
|
||||
/// rate limits (or needs an authenticated official-registry endpoint) supply
|
||||
/// credentials from the desktop app instead of editing env vars. Each field is
|
||||
/// config-first with an env-var fallback so existing CI/Docker deployments that
|
||||
/// only set env vars keep working unchanged.
|
||||
///
|
||||
/// Secrets are write-only over RPC: the getter reports whether each secret is
|
||||
/// *set* (a boolean) and never echoes the value back.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct McpRegistryAuthConfig {
|
||||
/// Smithery API key. Falls back to `SMITHERY_API_KEY`.
|
||||
#[serde(default)]
|
||||
pub smithery_api_key: Option<String>,
|
||||
/// Base URL override for the official registry. Falls back to
|
||||
/// `MCP_OFFICIAL_REGISTRY_BASE` (non-secret).
|
||||
#[serde(default)]
|
||||
pub mcp_official_base: Option<String>,
|
||||
/// Bearer token for the official registry. Falls back to
|
||||
/// `MCP_OFFICIAL_REGISTRY_TOKEN`.
|
||||
#[serde(default)]
|
||||
pub mcp_official_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct SeltzConfig {
|
||||
|
||||
@@ -114,7 +114,9 @@ The in-process connection registry (`connections.rs`) is a `OnceLock<RwLock<Hash
|
||||
## Notes / gotchas
|
||||
|
||||
- **`mcp_clients` vs `mcp_registry`**: the RPC namespace and db filename keep the old `mcp_clients` name for backwards-compat; only the Rust module path is `mcp_registry`.
|
||||
- **Two install paths**: legacy `mcp_clients_install` is **stdio-only** (filters for `stdio` connections). HTTP-remote installs go exclusively through the newer `mcp_setup_install_and_connect`, which routes via `pick_connection` (preference: published stdio → any stdio → published http_remote → any http_remote).
|
||||
- **Unified install transport**: both `mcp_clients_install` (manual install dialog) and `mcp_setup_install_and_connect` (setup agent) pick the best connection via `setup_ops::pick_connection` and build the `Transport` via `setup_ops::build_install_transport` (preference: published stdio → any stdio → published http_remote → any http_remote). So HTTP-remote listings install from the UI too, not just via the setup agent.
|
||||
- **Reconfigure**: `mcp_clients_update_env` replaces the stored env values (and the server row's `env_keys`), disconnects, and reconnects — API-key rotation without uninstall/reinstall.
|
||||
- **Registry credentials**: `mcp_clients_registry_settings_get` / `_set` expose Smithery / official-registry auth (config-first, env-fallback via `mcp_client.registry_auth`). The getter reports `*_set` booleans only; secret values are write-only and never returned.
|
||||
- **Smithery DTO naming**: the canonical result shapes are named `Smithery*` for wire compat; non-Smithery registries adapt into the same shapes and tag the `source` field.
|
||||
- **HTTP-remote env**: env vars for HTTP-remote installs (typically OAuth tokens) are picked up by `McpHttpClient`'s own auth config, not injected at dial time by this module.
|
||||
- **Secret safety**: raw secret values flow only through `submit_secret` and the just-in-time resolve in `test_connection`/`install_and_connect`; never echoed in responses or logged. `consume_refs` only removes refs after values are persisted.
|
||||
|
||||
@@ -128,16 +128,20 @@ pub async fn mcp_clients_install(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch registry detail: {e}"))?;
|
||||
|
||||
// Resolve stdio connection details (prefer published=true, fall back to first)
|
||||
let stdio_conn = detail
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|c| c.r#type == "stdio")
|
||||
.find(|c| c.published)
|
||||
.or_else(|| detail.connections.iter().find(|c| c.r#type == "stdio"));
|
||||
|
||||
// Derive command and args from qualified_name (npm/npx convention)
|
||||
let (command_kind, command, args) = resolve_command(qualified_name.trim(), stdio_conn);
|
||||
// Pick the best dialable connection — published stdio > any stdio >
|
||||
// published http_remote > any http_remote — using the same picker the
|
||||
// setup-agent path uses. Previously this path was stdio-only, so most
|
||||
// Smithery listings (HTTP-remote) could not be installed from the manual
|
||||
// install dialog at all; now both transports work identically
|
||||
// (issue #3039 gap A2).
|
||||
let picked = super::setup_ops::pick_connection(&detail.connections).ok_or_else(|| {
|
||||
format!(
|
||||
"server `{}` exposes neither stdio nor http_remote connections; nothing to install",
|
||||
qualified_name.trim()
|
||||
)
|
||||
})?;
|
||||
let (transport, command_kind, command, args) =
|
||||
super::setup_ops::build_install_transport(qualified_name.trim(), picked)?;
|
||||
|
||||
// Derive required env keys from provided map + schema
|
||||
let env_keys: Vec<String> = env.keys().cloned().collect();
|
||||
@@ -148,12 +152,6 @@ pub async fn mcp_clients_install(
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// The legacy install path only ever picked stdio connections (see the
|
||||
// `c.r#type == "stdio"` filter above), so legacy installs continue to
|
||||
// be stdio-only. HTTP-remote installs go through the newer
|
||||
// `setup_ops::mcp_setup_install_and_connect` setup-agent path, which
|
||||
// picks the right transport based on what the registry actually
|
||||
// exposes.
|
||||
let server = InstalledServer {
|
||||
server_id: server_id.clone(),
|
||||
qualified_name: qualified_name.trim().to_string(),
|
||||
@@ -167,7 +165,7 @@ pub async fn mcp_clients_install(
|
||||
config: config_value,
|
||||
installed_at: now_ms,
|
||||
last_connected_at: None,
|
||||
transport: super::types::Transport::Stdio,
|
||||
transport,
|
||||
};
|
||||
|
||||
store::insert_server(config, &server).map_err(|e| e.to_string())?;
|
||||
@@ -317,6 +315,169 @@ pub async fn mcp_clients_disconnect(server_id: String) -> Result<RpcOutcome<Valu
|
||||
))
|
||||
}
|
||||
|
||||
// ── update_env ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Replace the stored env values for an already-installed server and reconnect
|
||||
/// so the new credentials take effect immediately (issue #3039 gap A3 — API-key
|
||||
/// rotation / reconfigure without uninstall+reinstall).
|
||||
///
|
||||
/// Flow: persist env → disconnect (drop the stale session) → reload the install
|
||||
/// record → reconnect with the fresh env. The reconnect reuses the transport
|
||||
/// dispatch in [`connections::connect`], so this works for both stdio and
|
||||
/// HTTP-remote installs. Values are never logged — only the key names.
|
||||
///
|
||||
/// A failed reconnect does **not** roll back the persisted env (matching
|
||||
/// `mcp_setup_install_and_connect`): the user fixed a value, we keep it, and the
|
||||
/// error is surfaced so they can retry `mcp_clients_connect`.
|
||||
pub async fn mcp_clients_update_env(
|
||||
config: &Config,
|
||||
server_id: String,
|
||||
env: HashMap<String, String>,
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
if server_id.trim().is_empty() {
|
||||
return Err("server_id must not be empty".to_string());
|
||||
}
|
||||
let server_id = server_id.trim();
|
||||
|
||||
tracing::debug!(
|
||||
"[mcp-client] update_env server_id={} env_keys={:?}",
|
||||
server_id,
|
||||
env.keys().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Persist first so the new values survive even if the reconnect fails.
|
||||
store::set_env_values(config, server_id, &env).map_err(|e| e.to_string())?;
|
||||
|
||||
// Drop any live session so the reconnect picks up the new env.
|
||||
connections::disconnect(server_id).await;
|
||||
let _ = publish_global(DomainEvent::McpServerDisconnected {
|
||||
server_id: server_id.to_string(),
|
||||
reason: Some("env reconfigured".to_string()),
|
||||
});
|
||||
|
||||
let mut server = store::get_server(config, server_id).map_err(|e| e.to_string())?;
|
||||
|
||||
// Keep the install record's `env_keys` list in sync with the values we just
|
||||
// wrote — `set_env_values` replaces the value table wholesale, so the
|
||||
// key-name list shown in the UI (and returned below) must track it too.
|
||||
let mut new_keys: Vec<String> = env.keys().cloned().collect();
|
||||
new_keys.sort();
|
||||
if server.env_keys != new_keys {
|
||||
server.env_keys = new_keys;
|
||||
store::update_server_env_keys(config, server_id, &server.env_keys)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
match connections::connect(config, &server).await {
|
||||
Ok(tools) => {
|
||||
let tool_count = tools.len() as u32;
|
||||
let _ = publish_global(DomainEvent::McpServerConnected {
|
||||
server_id: server_id.to_string(),
|
||||
tool_count,
|
||||
});
|
||||
Ok(RpcOutcome::new(
|
||||
json!({
|
||||
"server_id": server_id,
|
||||
"status": "connected",
|
||||
"env_keys": server.env_keys,
|
||||
"tools": tools,
|
||||
}),
|
||||
vec![format!(
|
||||
"update_env reconnected server_id={server_id} tools={tool_count}"
|
||||
)],
|
||||
))
|
||||
}
|
||||
Err(err) => Ok(RpcOutcome::new(
|
||||
json!({
|
||||
"server_id": server_id,
|
||||
"status": "disconnected",
|
||||
"env_keys": server.env_keys,
|
||||
"error": err.to_string(),
|
||||
}),
|
||||
vec![format!(
|
||||
"update_env persisted env for server_id={server_id} but reconnect failed: {err}"
|
||||
)],
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── registry settings ──────────────────────────────────────────────────────
|
||||
|
||||
/// Build the non-secret registry-settings snapshot: booleans reporting whether
|
||||
/// each credential is set (from config OR env) plus the user-configured
|
||||
/// official-registry base URL. Secret *values* are never included.
|
||||
fn registry_settings_snapshot(config: &Config) -> Value {
|
||||
json!({
|
||||
"smithery_api_key_set":
|
||||
super::registries::smithery::smithery_api_key(config).is_some(),
|
||||
"mcp_official_token_set":
|
||||
super::registries::mcp_official::auth_token(config).is_some(),
|
||||
"mcp_official_base":
|
||||
config
|
||||
.mcp_client
|
||||
.registry_auth
|
||||
.mcp_official_base
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Report which registry credentials are configured. NEVER returns secret
|
||||
/// values — only `*_set` booleans + the (non-secret) base URL override
|
||||
/// (issue #3039 gap A6).
|
||||
pub async fn mcp_clients_registry_settings_get(
|
||||
config: &Config,
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
tracing::debug!("[mcp-client] registry_settings_get");
|
||||
Ok(RpcOutcome::new(
|
||||
registry_settings_snapshot(config),
|
||||
vec!["registry_settings_get".to_string()],
|
||||
))
|
||||
}
|
||||
|
||||
/// Persist registry credentials to config (issue #3039 gap A6).
|
||||
///
|
||||
/// Per-field semantics: `None` leaves the stored value unchanged; `Some(s)`
|
||||
/// sets it, where an empty/whitespace string clears the value (falling back to
|
||||
/// the env var, if any). Secrets are write-only — the response is the same
|
||||
/// non-secret snapshot as the getter, never the values just written.
|
||||
pub async fn mcp_clients_registry_settings_set(
|
||||
config: &mut Config,
|
||||
smithery_api_key: Option<String>,
|
||||
mcp_official_base: Option<String>,
|
||||
mcp_official_token: Option<String>,
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
fn apply(field: &mut Option<String>, update: Option<String>) {
|
||||
if let Some(value) = update {
|
||||
let trimmed = value.trim();
|
||||
*field = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"[mcp-client] registry_settings_set smithery_key_present={} official_token_present={} official_base_present={}",
|
||||
smithery_api_key.is_some(),
|
||||
mcp_official_token.is_some(),
|
||||
mcp_official_base.is_some()
|
||||
);
|
||||
|
||||
let auth = &mut config.mcp_client.registry_auth;
|
||||
apply(&mut auth.smithery_api_key, smithery_api_key);
|
||||
apply(&mut auth.mcp_official_base, mcp_official_base);
|
||||
apply(&mut auth.mcp_official_token, mcp_official_token);
|
||||
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(RpcOutcome::new(
|
||||
registry_settings_snapshot(config),
|
||||
vec!["registry_settings_set saved".to_string()],
|
||||
))
|
||||
}
|
||||
|
||||
// ── status ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn mcp_clients_status(config: &Config) -> Result<RpcOutcome<Value>, String> {
|
||||
|
||||
@@ -157,7 +157,7 @@ impl Registry for McpOfficialRegistry {
|
||||
}
|
||||
};
|
||||
|
||||
let body = fetch_page(q, limit, cursor_for_request.as_deref()).await?;
|
||||
let body = fetch_page(config, q, limit, cursor_for_request.as_deref()).await?;
|
||||
let parsed: OfficialListResponse = serde_json::from_str(&body)
|
||||
.with_context(|| format!("Failed to parse MCP official response: {body}"))?;
|
||||
let next_cursor = parsed.next_cursor().map(str::to_string);
|
||||
@@ -187,11 +187,14 @@ impl Registry for McpOfficialRegistry {
|
||||
let client = http_client()?;
|
||||
let url = format!(
|
||||
"{}/v0/servers/{}",
|
||||
base_url(),
|
||||
base_url(config),
|
||||
urlencoding_encode(qualified_name)
|
||||
);
|
||||
tracing::debug!("[mcp-official] get fetching {url}");
|
||||
let req = apply_auth(client.get(&url).header("Accept", "application/json"));
|
||||
let req = apply_auth(
|
||||
config,
|
||||
client.get(&url).header("Accept", "application/json"),
|
||||
);
|
||||
|
||||
let resp = req.send().await.context("MCP official get failed")?;
|
||||
let status = resp.status();
|
||||
@@ -214,7 +217,7 @@ impl Registry for McpOfficialRegistry {
|
||||
/// Fetch one page from the registry, optionally with a cursor. Returns the
|
||||
/// raw response body so callers can both parse it and write it to the SQLite
|
||||
/// response cache.
|
||||
async fn fetch_page(q: &str, limit: u32, cursor: Option<&str>) -> Result<String> {
|
||||
async fn fetch_page(config: &Config, q: &str, limit: u32, cursor: Option<&str>) -> Result<String> {
|
||||
// `q` is user-typed search input — log presence + length only so the
|
||||
// diagnostic doesn't leak query text into log aggregators.
|
||||
tracing::debug!(
|
||||
@@ -225,7 +228,7 @@ async fn fetch_page(q: &str, limit: u32, cursor: Option<&str>) -> Result<String>
|
||||
);
|
||||
|
||||
let client = http_client()?;
|
||||
let url = format!("{}/v0/servers", base_url());
|
||||
let url = format!("{}/v0/servers", base_url(config));
|
||||
let mut req = client.get(&url).header("Accept", "application/json");
|
||||
if !q.is_empty() {
|
||||
req = req.query(&[("search", q)]);
|
||||
@@ -234,7 +237,7 @@ async fn fetch_page(q: &str, limit: u32, cursor: Option<&str>) -> Result<String>
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
}
|
||||
req = apply_auth(req);
|
||||
req = apply_auth(config, req);
|
||||
|
||||
let resp = req.send().await.context("MCP official search failed")?;
|
||||
let status = resp.status();
|
||||
@@ -302,7 +305,7 @@ async fn walk_cursor_for_page(
|
||||
body
|
||||
}
|
||||
_ => {
|
||||
let body = fetch_page(q, limit, cursor.as_deref()).await?;
|
||||
let body = fetch_page(config, q, limit, cursor.as_deref()).await?;
|
||||
let _ = store::set_cached(config, &cache_key, &body);
|
||||
net_fetches += 1;
|
||||
body
|
||||
@@ -359,21 +362,43 @@ fn http_client() -> Result<Client> {
|
||||
.context("Failed to build MCP official HTTP client")
|
||||
}
|
||||
|
||||
fn base_url() -> String {
|
||||
std::env::var("MCP_OFFICIAL_REGISTRY_BASE")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
/// Effective official-registry base URL: config-first
|
||||
/// (`mcp_client.registry_auth.mcp_official_base`), then the
|
||||
/// `MCP_OFFICIAL_REGISTRY_BASE` env var, then the hard-coded default
|
||||
/// (issue #3039 gap A6).
|
||||
fn base_url(config: &Config) -> String {
|
||||
config
|
||||
.mcp_client
|
||||
.registry_auth
|
||||
.mcp_official_base
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("MCP_OFFICIAL_REGISTRY_BASE")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_BASE.to_string())
|
||||
}
|
||||
|
||||
fn auth_token() -> Option<String> {
|
||||
std::env::var("MCP_OFFICIAL_REGISTRY_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
/// Effective official-registry bearer token: config-first, then the
|
||||
/// `MCP_OFFICIAL_REGISTRY_TOKEN` env var (issue #3039 gap A6).
|
||||
pub(crate) fn auth_token(config: &Config) -> Option<String> {
|
||||
config
|
||||
.mcp_client
|
||||
.registry_auth
|
||||
.mcp_official_token
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("MCP_OFFICIAL_REGISTRY_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_auth(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(token) = auth_token() {
|
||||
fn apply_auth(config: &Config, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(token) = auth_token(config) {
|
||||
builder.bearer_auth(token)
|
||||
} else {
|
||||
builder
|
||||
@@ -716,4 +741,27 @@ mod tests {
|
||||
"renamed `server` field must surface as parse error"
|
||||
);
|
||||
}
|
||||
|
||||
/// A config-set base URL overrides both the env var and the default
|
||||
/// (issue #3039 gap A6: config-first, env-fallback).
|
||||
#[test]
|
||||
fn base_url_prefers_config_override() {
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.mcp_client.registry_auth.mcp_official_base =
|
||||
Some("https://registry.example.test".to_string());
|
||||
assert_eq!(base_url(&config), "https://registry.example.test");
|
||||
|
||||
// A blank config value is ignored (falls back to env / default) —
|
||||
// asserted env-independently so an ambient env override can't flake it.
|
||||
config.mcp_client.registry_auth.mcp_official_base = Some(" ".to_string());
|
||||
assert_ne!(base_url(&config), " ");
|
||||
}
|
||||
|
||||
/// A config-set token is returned without touching the env var.
|
||||
#[test]
|
||||
fn auth_token_prefers_config_override() {
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.mcp_client.registry_auth.mcp_official_token = Some("tok-config".to_string());
|
||||
assert_eq!(auth_token(&config).as_deref(), Some("tok-config"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ impl Registry for SmitheryRegistry {
|
||||
("pageSize", &page_size.to_string()),
|
||||
])
|
||||
.header("Accept", "application/json");
|
||||
req = apply_auth(req);
|
||||
req = apply_auth(config, req);
|
||||
|
||||
let resp = req.send().await.context("Smithery search request failed")?;
|
||||
let status = resp.status();
|
||||
@@ -116,7 +116,10 @@ impl Registry for SmitheryRegistry {
|
||||
"{SMITHERY_BASE}/servers/{}",
|
||||
urlencoding_encode(qualified_name)
|
||||
);
|
||||
let req = apply_auth(client.get(&url).header("Accept", "application/json"));
|
||||
let req = apply_auth(
|
||||
config,
|
||||
client.get(&url).header("Accept", "application/json"),
|
||||
);
|
||||
|
||||
let resp = req.send().await.context("Smithery get request failed")?;
|
||||
let status = resp.status();
|
||||
@@ -150,14 +153,25 @@ fn http_client() -> Result<Client> {
|
||||
.context("Failed to build Smithery HTTP client")
|
||||
}
|
||||
|
||||
fn smithery_api_key() -> Option<String> {
|
||||
std::env::var("SMITHERY_API_KEY")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
/// Effective Smithery API key: config-first (`mcp_client.registry_auth`), then
|
||||
/// the `SMITHERY_API_KEY` env var (issue #3039 gap A6). Empty strings are
|
||||
/// treated as unset so a blank config/env value doesn't send `Bearer `.
|
||||
pub(crate) fn smithery_api_key(config: &Config) -> Option<String> {
|
||||
config
|
||||
.mcp_client
|
||||
.registry_auth
|
||||
.smithery_api_key
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("SMITHERY_API_KEY")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_auth(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(key) = smithery_api_key() {
|
||||
fn apply_auth(config: &Config, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(key) = smithery_api_key(config) {
|
||||
builder.bearer_auth(key)
|
||||
} else {
|
||||
builder
|
||||
|
||||
@@ -20,12 +20,15 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("registry_get"),
|
||||
schemas("installed_list"),
|
||||
schemas("install"),
|
||||
schemas("update_env"),
|
||||
schemas("uninstall"),
|
||||
schemas("connect"),
|
||||
schemas("disconnect"),
|
||||
schemas("status"),
|
||||
schemas("tool_call"),
|
||||
schemas("config_assist"),
|
||||
schemas("registry_settings_get"),
|
||||
schemas("registry_settings_set"),
|
||||
// Setup-agent surface (mcp_setup namespace, lives in setup_ops.rs).
|
||||
setup_schemas("search"),
|
||||
setup_schemas("get"),
|
||||
@@ -54,6 +57,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("install"),
|
||||
handler: handle_install,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("update_env"),
|
||||
handler: handle_update_env,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("uninstall"),
|
||||
handler: handle_uninstall,
|
||||
@@ -78,6 +85,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("config_assist"),
|
||||
handler: handle_config_assist,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("registry_settings_get"),
|
||||
handler: handle_registry_settings_get,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("registry_settings_set"),
|
||||
handler: handle_registry_settings_set,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: setup_schemas("search"),
|
||||
handler: handle_setup_search,
|
||||
@@ -216,6 +231,54 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
}],
|
||||
},
|
||||
|
||||
"update_env" => ControllerSchema {
|
||||
namespace: "mcp_clients",
|
||||
function: "update_env",
|
||||
description: "Replace the stored env values for an installed server and reconnect so the new credentials take effect (reconfigure / rotate keys without reinstalling).",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "server_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "UUID of the installed server to reconfigure.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "env",
|
||||
ty: TypeSchema::Map(Box::new(TypeSchema::String)),
|
||||
comment: "Replacement environment variable values. Stored encrypted and never returned.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "server_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "The reconfigured server id.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "status",
|
||||
ty: TypeSchema::Enum {
|
||||
variants: vec!["connected", "disconnected"],
|
||||
},
|
||||
comment: "`connected` if the reconnect succeeded, `disconnected` if env was saved but reconnect failed.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "env_keys",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
|
||||
comment: "Env key names after the update (values omitted).",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "tools",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("McpTool"))),
|
||||
comment: "Tools exposed after reconnect (present only when status=connected).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"uninstall" => ControllerSchema {
|
||||
namespace: "mcp_clients",
|
||||
function: "uninstall",
|
||||
@@ -399,6 +462,79 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
],
|
||||
},
|
||||
|
||||
"registry_settings_get" => ControllerSchema {
|
||||
namespace: "mcp_clients",
|
||||
function: "registry_settings_get",
|
||||
description: "Report which registry credentials are configured (Smithery key, official-registry base/token). Never returns secret values — only `*_set` booleans plus the non-secret base URL override.",
|
||||
inputs: vec![],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "smithery_api_key_set",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when a Smithery API key is set (config or env).",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "mcp_official_token_set",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when an official-registry bearer token is set (config or env).",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "mcp_official_base",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "User-configured official-registry base URL override (non-secret).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"registry_settings_set" => ControllerSchema {
|
||||
namespace: "mcp_clients",
|
||||
function: "registry_settings_set",
|
||||
description: "Persist registry credentials. Per field: omit to leave unchanged, empty string to clear, value to set. Secrets are write-only; the response is the same non-secret snapshot as registry_settings_get.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "smithery_api_key",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "New Smithery API key (empty string clears).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "mcp_official_base",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "New official-registry base URL override (empty string clears).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "mcp_official_token",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "New official-registry bearer token (empty string clears).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "smithery_api_key_set",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when a Smithery API key is set after the update.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "mcp_official_token_set",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when an official-registry token is set after the update.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "mcp_official_base",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Official-registry base URL override after the update.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Handled by setup_schemas() — surface a clearer error rather than
|
||||
// falling through to the generic unknown sink.
|
||||
"setup_search"
|
||||
@@ -482,6 +618,18 @@ fn handle_install(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_update_env(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let server_id = read_required::<String>(¶ms, "server_id")?;
|
||||
let env = read_required::<std::collections::HashMap<String, String>>(¶ms, "env")?;
|
||||
to_json(
|
||||
crate::openhuman::mcp_registry::ops::mcp_clients_update_env(&config, server_id, env)
|
||||
.await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_uninstall(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
@@ -552,6 +700,34 @@ fn handle_config_assist(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_registry_settings_get(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let _ = params;
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(
|
||||
crate::openhuman::mcp_registry::ops::mcp_clients_registry_settings_get(&config).await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_registry_settings_set(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let smithery_api_key = read_optional::<String>(¶ms, "smithery_api_key")?;
|
||||
let mcp_official_base = read_optional::<String>(¶ms, "mcp_official_base")?;
|
||||
let mcp_official_token = read_optional::<String>(¶ms, "mcp_official_token")?;
|
||||
let mut config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(
|
||||
crate::openhuman::mcp_registry::ops::mcp_clients_registry_settings_set(
|
||||
&mut config,
|
||||
smithery_api_key,
|
||||
mcp_official_base,
|
||||
mcp_official_token,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── mcp_setup_* schemas + handlers ────────────────────────────────────────────
|
||||
|
||||
/// All setup-agent schemas under the `mcp_setup` RPC namespace. Kept in a
|
||||
|
||||
@@ -67,8 +67,9 @@ fn schemas_unknown_function_returns_placeholder() {
|
||||
#[test]
|
||||
fn all_controller_schemas_covers_expected_methods() {
|
||||
let schemas = all_controller_schemas();
|
||||
// 10 mcp_clients + 6 mcp_setup
|
||||
assert_eq!(schemas.len(), 16);
|
||||
// 13 mcp_clients (incl. update_env + registry_settings_get/set, #3039)
|
||||
// + 6 mcp_setup.
|
||||
assert_eq!(schemas.len(), 19);
|
||||
let mcp_clients_count = schemas
|
||||
.iter()
|
||||
.filter(|s| s.namespace == "mcp_clients")
|
||||
@@ -77,14 +78,19 @@ fn all_controller_schemas_covers_expected_methods() {
|
||||
.iter()
|
||||
.filter(|s| s.namespace == "mcp_setup")
|
||||
.count();
|
||||
assert_eq!(mcp_clients_count, 10);
|
||||
assert_eq!(mcp_clients_count, 13);
|
||||
assert_eq!(mcp_setup_count, 6);
|
||||
// The #3039 additions are present.
|
||||
let functions: Vec<_> = schemas.iter().map(|s| s.function).collect();
|
||||
assert!(functions.contains(&"update_env"));
|
||||
assert!(functions.contains(&"registry_settings_get"));
|
||||
assert!(functions.contains(&"registry_settings_set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_registered_controllers_has_handler_per_schema() {
|
||||
let controllers = all_registered_controllers();
|
||||
assert_eq!(controllers.len(), 16);
|
||||
assert_eq!(controllers.len(), 19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -271,26 +271,7 @@ pub async fn mcp_setup_install_and_connect(
|
||||
// Branch on the picked transport. Stdio installs still populate
|
||||
// command/args (current behavior). HTTP-remote installs leave them
|
||||
// empty and stash the deployment URL in `transport`.
|
||||
let (transport, command_kind, command, args) = match picked.transport_kind() {
|
||||
"http_remote" => {
|
||||
let url = picked.deployment_url.clone().unwrap_or_default();
|
||||
if url.is_empty() {
|
||||
return Err(format!(
|
||||
"server `{q}` http_remote connection has empty deployment_url"
|
||||
));
|
||||
}
|
||||
(
|
||||
Transport::HttpRemote { url },
|
||||
CommandKind::Node, // unused for HTTP, but a sensible default
|
||||
String::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let (kind, command, args) = resolve_command(q, Some(picked));
|
||||
(Transport::Stdio, kind, command, args)
|
||||
}
|
||||
};
|
||||
let (transport, command_kind, command, args) = build_install_transport(q, picked)?;
|
||||
|
||||
// Consume refs only after `registry_get` succeeds — that way a
|
||||
// misconfigured server name doesn't burn the user's collected
|
||||
@@ -441,10 +422,43 @@ pub(super) fn pick_connection(connections: &[SmitheryConnection]) -> Option<&Smi
|
||||
.find(|c| c.transport_kind() == "http_remote")
|
||||
}
|
||||
|
||||
/// Resolve the persisted [`Transport`] + launch command for an install from the
|
||||
/// connection the picker selected. Stdio installs populate `command`/`args`;
|
||||
/// HTTP-remote installs leave them empty and stash the deployment URL in the
|
||||
/// `Transport`. Shared by the setup-agent path
|
||||
/// ([`mcp_setup_install_and_connect`]) and the manual install dialog path
|
||||
/// ([`super::ops::mcp_clients_install`]) so both transports behave identically
|
||||
/// (issue #3039 gap A2).
|
||||
pub(super) fn build_install_transport(
|
||||
qualified_name: &str,
|
||||
picked: &SmitheryConnection,
|
||||
) -> Result<(Transport, CommandKind, String, Vec<String>), String> {
|
||||
match picked.transport_kind() {
|
||||
"http_remote" => {
|
||||
let url = picked.deployment_url.clone().unwrap_or_default();
|
||||
if url.is_empty() {
|
||||
return Err(format!(
|
||||
"server `{qualified_name}` http_remote connection has empty deployment_url"
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
Transport::HttpRemote { url },
|
||||
CommandKind::Node, // unused for HTTP, but a sensible default
|
||||
String::new(),
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
let (kind, command, args) = resolve_command(qualified_name, Some(picked));
|
||||
Ok((Transport::Stdio, kind, command, args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise a [`SmitheryConnection::r#type`] string into the same vocabulary
|
||||
/// the persisted [`Transport`] enum uses. The registry side uses `"http"`
|
||||
/// in its DTOs; we route those into the `"http_remote"` install path.
|
||||
trait ConnectionKind {
|
||||
pub(super) trait ConnectionKind {
|
||||
fn transport_kind(&self) -> &str;
|
||||
}
|
||||
|
||||
@@ -527,4 +541,45 @@ mod tests {
|
||||
let conns = vec![conn("websocket-future", true, None)];
|
||||
assert!(pick_connection(&conns).is_none());
|
||||
}
|
||||
|
||||
/// `build_install_transport` turns a picked stdio connection into a
|
||||
/// `Transport::Stdio` with a resolved launch command (npx default when the
|
||||
/// example_config has no command). Shared by both install paths (gap A2).
|
||||
#[test]
|
||||
fn build_install_transport_stdio_resolves_command() {
|
||||
let picked = conn("stdio", true, None);
|
||||
let (transport, _kind, command, args) =
|
||||
build_install_transport("@scope/echo", &picked).expect("stdio install resolves");
|
||||
assert_eq!(transport, Transport::Stdio);
|
||||
assert_eq!(command, "npx");
|
||||
assert_eq!(args, vec!["-y".to_string(), "@scope/echo".to_string()]);
|
||||
}
|
||||
|
||||
/// A picked http_remote connection becomes `Transport::HttpRemote { url }`
|
||||
/// with empty command/args — the manual install dialog can now install
|
||||
/// Smithery's HTTP-only listings (gap A2).
|
||||
#[test]
|
||||
fn build_install_transport_http_remote_uses_deployment_url() {
|
||||
let picked = conn("http", true, Some("https://x.io/mcp"));
|
||||
let (transport, _kind, command, args) =
|
||||
build_install_transport("io.x/remote", &picked).expect("http install resolves");
|
||||
assert_eq!(
|
||||
transport,
|
||||
Transport::HttpRemote {
|
||||
url: "https://x.io/mcp".to_string()
|
||||
}
|
||||
);
|
||||
assert!(command.is_empty());
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
/// An http_remote connection with no deployment URL is a hard error rather
|
||||
/// than installing an undialable server.
|
||||
#[test]
|
||||
fn build_install_transport_http_remote_requires_url() {
|
||||
let picked = conn("http", true, None);
|
||||
let err = build_install_transport("io.x/remote", &picked)
|
||||
.expect_err("missing deployment_url must error");
|
||||
assert!(err.contains("deployment_url"), "got: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,23 @@ pub fn insert_server_conn(conn: &Connection, server: &InstalledServer) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update only the `env_keys` list for an installed server. Used by
|
||||
/// `mcp_clients_update_env` to keep the persisted key-name list in sync with a
|
||||
/// reconfigure — the env *values* live in the separate `mcp_client_env` table,
|
||||
/// while the key-name list shown in list/status responses lives on the server
|
||||
/// row. A plain `insert_server` would conflict on the primary key.
|
||||
pub fn update_server_env_keys(config: &Config, server_id: &str, env_keys: &[String]) -> Result<()> {
|
||||
let env_keys_json = serde_json::to_string(env_keys)?;
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE mcp_servers SET env_keys_json = ?2 WHERE server_id = ?1",
|
||||
params![server_id, env_keys_json],
|
||||
)
|
||||
.context("Failed to update mcp_server env_keys")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_servers(config: &Config) -> Result<Vec<InstalledServer>> {
|
||||
with_connection(config, |conn| list_servers_conn(conn))
|
||||
}
|
||||
|
||||
@@ -8317,6 +8317,319 @@ async fn mcp_clients_lifecycle() {
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
/// MCP clients **happy path** over real JSON-RPC: install → connect → tool_call
|
||||
/// → update_env (reconnect) → disconnect against a real stdio MCP subprocess
|
||||
/// (the `test-mcp-stub` binary), with the registry lookup served hermetically
|
||||
/// from the SQLite detail cache (issue #3039 acceptance: "JSON-RPC E2E —
|
||||
/// happy-path install/connect/tool_call against stub server over HTTP RPC").
|
||||
///
|
||||
/// No npx, no network: we pre-seed `smithery:detail:<name>` with a detail whose
|
||||
/// stdio `exampleConfig.command` points at the stub binary, so
|
||||
/// `mcp_clients_install` resolves the launch command to the stub.
|
||||
#[tokio::test]
|
||||
async fn mcp_clients_install_connect_tool_call_happy_path() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
|
||||
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
||||
|
||||
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
let mock_origin = format!("http://{}", mock_addr);
|
||||
write_min_config(&openhuman_home, &mock_origin);
|
||||
let user_scoped_dir = openhuman_home.join("users").join("local");
|
||||
write_min_config(&user_scoped_dir, &mock_origin);
|
||||
|
||||
// Seed the registry detail cache so `registry_get` resolves offline to a
|
||||
// stdio connection whose command is the hermetic stub binary. The config we
|
||||
// load here resolves the same workspace dir the RPC handlers use, so the
|
||||
// cache row lands in the DB the install path reads.
|
||||
let stub_path = env!("CARGO_BIN_EXE_test-mcp-stub");
|
||||
let qualified_name = "@openhuman-test/echo";
|
||||
let detail = serde_json::json!({
|
||||
"qualifiedName": qualified_name,
|
||||
"displayName": "Test Echo",
|
||||
"description": "Stub MCP server for the json_rpc_e2e happy path.",
|
||||
"connections": [{
|
||||
"type": "stdio",
|
||||
"published": true,
|
||||
"exampleConfig": { "command": stub_path, "args": [] }
|
||||
}]
|
||||
});
|
||||
let seed_config = openhuman_core::openhuman::config::load_config_with_timeout()
|
||||
.await
|
||||
.expect("load config for cache seed");
|
||||
openhuman_core::openhuman::mcp_registry::store::set_cached(
|
||||
&seed_config,
|
||||
&format!("smithery:detail:{qualified_name}"),
|
||||
&detail.to_string(),
|
||||
)
|
||||
.expect("seed smithery detail cache");
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{}", rpc_addr);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// ── 1. install resolves the stub command from the seeded detail ──────────
|
||||
let install = post_json_rpc(
|
||||
&rpc_base,
|
||||
9920,
|
||||
"openhuman.mcp_clients_install",
|
||||
json!({ "qualified_name": qualified_name, "env": {} }),
|
||||
)
|
||||
.await;
|
||||
let install_result = assert_no_jsonrpc_error(&install, "mcp_clients_install (happy path)");
|
||||
let install_body = install_result.get("result").unwrap_or(install_result);
|
||||
let server_id = install_body
|
||||
.get("server")
|
||||
.and_then(|s| s.get("server_id"))
|
||||
.and_then(Value::as_str)
|
||||
.expect("install returns a server.server_id")
|
||||
.to_string();
|
||||
|
||||
// ── 2. connect spawns the stub and lists its one `echo` tool ─────────────
|
||||
let connect = post_json_rpc(
|
||||
&rpc_base,
|
||||
9921,
|
||||
"openhuman.mcp_clients_connect",
|
||||
json!({ "server_id": server_id }),
|
||||
)
|
||||
.await;
|
||||
let connect_result = assert_no_jsonrpc_error(&connect, "mcp_clients_connect (happy path)");
|
||||
let connect_body = connect_result.get("result").unwrap_or(connect_result);
|
||||
assert_eq!(
|
||||
connect_body.get("status").and_then(Value::as_str),
|
||||
Some("connected"),
|
||||
"connect should report connected: {connect_body}"
|
||||
);
|
||||
let tools = connect_body
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.expect("connect returns a tools array");
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|t| t.get("name").and_then(Value::as_str) == Some("echo")),
|
||||
"stub should advertise the echo tool: {tools:?}"
|
||||
);
|
||||
|
||||
// ── 3. tool_call echoes the input back, is_error=false ───────────────────
|
||||
let tool_call = post_json_rpc(
|
||||
&rpc_base,
|
||||
9922,
|
||||
"openhuman.mcp_clients_tool_call",
|
||||
json!({
|
||||
"server_id": server_id,
|
||||
"tool_name": "echo",
|
||||
"arguments": { "message": "hello over rpc" }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let tc_result = assert_no_jsonrpc_error(&tool_call, "mcp_clients_tool_call (happy path)");
|
||||
let tc_body = tc_result.get("result").unwrap_or(tc_result);
|
||||
assert_eq!(
|
||||
tc_body.get("is_error"),
|
||||
Some(&json!(false)),
|
||||
"echo tool_call should not be an error: {tc_body}"
|
||||
);
|
||||
assert!(
|
||||
tc_body.to_string().contains("hello over rpc"),
|
||||
"echo tool_call should round-trip the input payload: {tc_body}"
|
||||
);
|
||||
|
||||
// ── 4. update_env reconfigures + reconnects (no uninstall/reinstall) ─────
|
||||
let update_env = post_json_rpc(
|
||||
&rpc_base,
|
||||
9923,
|
||||
"openhuman.mcp_clients_update_env",
|
||||
json!({ "server_id": server_id, "env": { "EXAMPLE_TOKEN": "rotated" } }),
|
||||
)
|
||||
.await;
|
||||
let ue_result = assert_no_jsonrpc_error(&update_env, "mcp_clients_update_env (happy path)");
|
||||
let ue_body = ue_result.get("result").unwrap_or(ue_result);
|
||||
assert_eq!(
|
||||
ue_body.get("status").and_then(Value::as_str),
|
||||
Some("connected"),
|
||||
"update_env should reconnect: {ue_body}"
|
||||
);
|
||||
let env_keys = ue_body
|
||||
.get("env_keys")
|
||||
.and_then(Value::as_array)
|
||||
.expect("update_env returns env_keys");
|
||||
assert!(
|
||||
env_keys.iter().any(|k| k.as_str() == Some("EXAMPLE_TOKEN")),
|
||||
"update_env should persist the new env key: {env_keys:?}"
|
||||
);
|
||||
|
||||
// Verify the reconnected session is still functional: call echo again.
|
||||
let tool_call2 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9925,
|
||||
"openhuman.mcp_clients_tool_call",
|
||||
json!({
|
||||
"server_id": server_id,
|
||||
"tool_name": "echo",
|
||||
"arguments": { "message": "hello after reconfigure" }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let tc2_result =
|
||||
assert_no_jsonrpc_error(&tool_call2, "mcp_clients_tool_call (after update_env)");
|
||||
let tc2_body = tc2_result.get("result").unwrap_or(tc2_result);
|
||||
assert_eq!(
|
||||
tc2_body.get("is_error"),
|
||||
Some(&json!(false)),
|
||||
"echo tool_call after reconfigure should not be an error: {tc2_body}"
|
||||
);
|
||||
assert!(
|
||||
tc2_body.to_string().contains("hello after reconfigure"),
|
||||
"echo tool_call after reconfigure should round-trip the input payload: {tc2_body}"
|
||||
);
|
||||
|
||||
// ── 5. disconnect cleans up the subprocess ───────────────────────────────
|
||||
let disconnect = post_json_rpc(
|
||||
&rpc_base,
|
||||
9924,
|
||||
"openhuman.mcp_clients_disconnect",
|
||||
json!({ "server_id": server_id }),
|
||||
)
|
||||
.await;
|
||||
let disc_result = assert_no_jsonrpc_error(&disconnect, "mcp_clients_disconnect (happy path)");
|
||||
let disc_body = disc_result.get("result").unwrap_or(disc_result);
|
||||
assert_eq!(
|
||||
disc_body.get("status").and_then(Value::as_str),
|
||||
Some("disconnected"),
|
||||
"disconnect should report disconnected: {disc_body}"
|
||||
);
|
||||
|
||||
mock_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
/// Registry settings RPC: the getter reports `*_set` booleans without ever
|
||||
/// echoing secret values; the setter persists and clears them (issue #3039
|
||||
/// gap A6).
|
||||
#[tokio::test]
|
||||
async fn mcp_clients_registry_settings_roundtrip() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _smithery_guard = EnvVarGuard::unset("SMITHERY_API_KEY");
|
||||
let _official_token_guard = EnvVarGuard::unset("MCP_OFFICIAL_REGISTRY_TOKEN");
|
||||
|
||||
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
let mock_origin = format!("http://{}", mock_addr);
|
||||
write_min_config(&openhuman_home, &mock_origin);
|
||||
write_min_config(&openhuman_home.join("users").join("local"), &mock_origin);
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{}", rpc_addr);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Initially nothing is set.
|
||||
let get1 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9930,
|
||||
"openhuman.mcp_clients_registry_settings_get",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let get1_result = assert_no_jsonrpc_error(&get1, "registry_settings_get (initial)");
|
||||
let get1_body = get1_result.get("result").unwrap_or(get1_result);
|
||||
assert_eq!(get1_body.get("smithery_api_key_set"), Some(&json!(false)));
|
||||
assert_eq!(get1_body.get("mcp_official_token_set"), Some(&json!(false)));
|
||||
|
||||
// Set a key + base override.
|
||||
let set1 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9931,
|
||||
"openhuman.mcp_clients_registry_settings_set",
|
||||
json!({
|
||||
"smithery_api_key": "sk-secret-value",
|
||||
"mcp_official_base": "https://registry.example.test"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let set1_result = assert_no_jsonrpc_error(&set1, "registry_settings_set (set)");
|
||||
let set1_body = set1_result.get("result").unwrap_or(set1_result);
|
||||
assert_eq!(set1_body.get("smithery_api_key_set"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
set1_body.get("mcp_official_base").and_then(Value::as_str),
|
||||
Some("https://registry.example.test"),
|
||||
);
|
||||
// The secret value is NEVER echoed back anywhere in the response.
|
||||
assert!(
|
||||
!set1.to_string().contains("sk-secret-value"),
|
||||
"registry_settings_set must not echo the secret value"
|
||||
);
|
||||
|
||||
// Read-after-write: verify getter reflects the persisted state.
|
||||
let get2 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9933,
|
||||
"openhuman.mcp_clients_registry_settings_get",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let get2_result = assert_no_jsonrpc_error(&get2, "registry_settings_get (after set)");
|
||||
let get2_body = get2_result.get("result").unwrap_or(get2_result);
|
||||
assert_eq!(get2_body.get("smithery_api_key_set"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
get2_body.get("mcp_official_base").and_then(Value::as_str),
|
||||
Some("https://registry.example.test"),
|
||||
);
|
||||
// Getter must never return the raw secret.
|
||||
assert!(
|
||||
!get2.to_string().contains("sk-secret-value"),
|
||||
"registry_settings_get must not return the secret value"
|
||||
);
|
||||
|
||||
// Clearing with an empty string flips the boolean back to false.
|
||||
let set2 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9932,
|
||||
"openhuman.mcp_clients_registry_settings_set",
|
||||
json!({ "smithery_api_key": "" }),
|
||||
)
|
||||
.await;
|
||||
let set2_result = assert_no_jsonrpc_error(&set2, "registry_settings_set (clear)");
|
||||
let set2_body = set2_result.get("result").unwrap_or(set2_result);
|
||||
assert_eq!(set2_body.get("smithery_api_key_set"), Some(&json!(false)));
|
||||
// The base override persists across the clear of an unrelated field.
|
||||
assert_eq!(
|
||||
set2_body.get("mcp_official_base").and_then(Value::as_str),
|
||||
Some("https://registry.example.test"),
|
||||
);
|
||||
|
||||
// Read-after-clear: verify getter reflects the cleared state.
|
||||
let get3 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9934,
|
||||
"openhuman.mcp_clients_registry_settings_get",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let get3_result = assert_no_jsonrpc_error(&get3, "registry_settings_get (after clear)");
|
||||
let get3_body = get3_result.get("result").unwrap_or(get3_result);
|
||||
assert_eq!(get3_body.get("smithery_api_key_set"), Some(&json!(false)));
|
||||
// Base override should persist even after clearing the API key.
|
||||
assert_eq!(
|
||||
get3_body.get("mcp_official_base").and_then(Value::as_str),
|
||||
Some("https://registry.example.test"),
|
||||
);
|
||||
|
||||
mock_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
/// Proxy config corruption recovery (PR #1563 guard).
|
||||
///
|
||||
/// Verifies that when the config.toml on disk is corrupted *after* the core
|
||||
|
||||
Reference in New Issue
Block a user