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:
Steven Enamakel's Droid
2026-05-30 23:49:07 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 4e221e978b
commit 327de34f65
41 changed files with 1707 additions and 174 deletions
@@ -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();
+7
View File
@@ -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': 'متابعة مع السحابة',
+8
View File
@@ -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': 'ক্লাউডের সাথে চালিয়ে যান',
+8
View File
@@ -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.',
+7
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': 'बादल के साथ जारी रखें',
+8
View File
@@ -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',
+8
View File
@@ -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',
+7
View File
@@ -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': '클라우드 계속하기',
+8
View File
@@ -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ą',
+7
View File
@@ -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',
+8
View File
@@ -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': 'Продолжить с Облако',
+6
View File
@@ -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': '继续使用云',
+7 -30
View File
@@ -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({
+66
View File
@@ -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);
+88
View File
@@ -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');
});
});
+129
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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)"
);
});
}
+20 -12
View File
@@ -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
+30
View File
@@ -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 {
+3 -1
View File
@@ -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.
+178 -17
View File
@@ -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
+176
View File
@@ -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>(&params, "server_id")?;
let env = read_required::<std::collections::HashMap<String, String>>(&params, "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>(&params, "smithery_api_key")?;
let mcp_official_base = read_optional::<String>(&params, "mcp_official_base")?;
let mcp_official_token = read_optional::<String>(&params, "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
+10 -4
View File
@@ -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]
+76 -21
View File
@@ -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}");
}
}
+17
View File
@@ -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))
}
+313
View File
@@ -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